Ghost Admin(React):新一代发布后台如何通过 Ember Bridge 渐进式替换 Ember 管理界面
Ghost Admin 是 Ghost 博客平台的新一代后台管理界面,它基于 React 重写,目标是在不大规模"大爆炸式重写"的前提下,逐步替换掉历史悠久的 Ember.js 管理后台(ghost-admin)。本文以其官方说明文档 apps/admin/README.md 为主线,结合仓库内路由、桥接、CSS 与测试体系的源码实现,讲解这套渐进式迁移架构、部署兼容策略,以及单元测试、验收测试与生产构建的全套工程实践。
背景:为什么需要一套"桥"而非"重写"
Ghost 的后台沉淀了大量成熟功能(成员管理、设置、编辑器、仪表盘等),全部推倒重写风险极高。因此仓库采取了渐进式替换路线:新的 React 管理界面与旧 Ember 管理界面长期共存,直到所有路由逐一迁移完成。apps/admin/ 目录就是这个新的 React 应用,官方文档称之为 "New React-based Ghost admin interface, gradually replacing the existing Ember admin"。
这一共存机制的核心是一套称为 Ember Bridge 的桥接系统,其工作方式可以概括为:
- 已移植到 React 的路由直接渲染 React 组件;
- 尚未移植的路由回退(fallback)到仍在运行的 Ember 管理后台;
- 两者共享同一片 UI 空间,用户几乎感知不到切换的边界。
从源码看,"共享 UI 空间"并非比喻:React 应用与 Ember 应用事实上挂载在同一个页面里协同工作。
架构解剖:React、admin-x-framework、Shade 与 Ember 如何共存
应用入口 src/main.tsx 非常简短:它创建一个 React root 渲染 AdminAppRoot,并把一份 framework 配置传进去,其中包括导航回调(externalNavigate)、Unsplash 配置(defaultUnsplashConfig)、Sentry DSN 以及来自 ember-bridge 的 emberMutationHandlers。
AdminAppRoot(见 src/app-root.tsx)则拼出完整的 Provider 金字塔:
StrictMode
└── FrameworkProvider (来自 admin-x-framework,注入 API 与桥接回调)
└── RouterProvider (前缀 "/",路由表来自 src/routes.tsx)
└── ThemeProvider
└── ShadeApp (design system 外壳,darkMode 主题切换)
注释中特别说明:这个 Provider 栈被生产入口 src/main.tsx 与验收测试入口 renderAdminApp 原样共享,因此"测试渲染的就是与生产一致的组件栈"在构造层面就是成立的,两边只在 framework props(导航/桥接回调、QueryClient)上存在差异。
Ember Bridge 的状态桥:StateBridge
桥接的底层契约定义在 src/ember-bridge/ember-bridge.tsx。Ember 侧会把一个全局对象挂到 window.EmberBridge 上,其中核心是 state(StateBridge),它提供:
- 数据同步:
onUpdate(dataType, response)、onInvalidate(dataType)、onDelete(dataType, id); - 事件订阅:
on(event, callback)/off(event, callback),事件类型见StateBridgeEventMap,包括emberDataChange、emberAuthChange、subscriptionChange、sidebarVisibilityChange、routeChange、openGiftLinkModal、featureFlagsChange; - 状态读取:
sidebarVisible、isFeatureEnabled(name); - 路由互操作:
getRouteUrl(routeName, queryParams)与isRouteActive(...); - 主题联动:
preloadAdminThemeStylesheet()、applyAdminThemePreference(mode)。
由于 Ember 应用可能尚未加载完成,桥接代码提供 waitForStateBridge——以 100ms 间隔轮询 window.EmberBridge,拿到后再注册订阅(源码注释表明未来可能懒加载 Ember,因此轮询是"无限期"的)。
基于这层契约,仓库暴露了一整套 React Hook(useEmberDataSync、useEmberAuthSync、useEmberFeatureFlag、useSidebarVisibility、useEmberRouting、useForceUpgrade 等),供 React 侧响应 Ember 状态:
- useEmberDataSync / useEmberAuthSync:监听 Ember Data 的增删改与登录态变化,自动使对应的 React Query 缓存失效,保证两端数据一致。这里有一张模型名映射表
EMBER_TO_REACT_TYPE_MAPPING,把 Ember Data 模型(integration、setting、post、member…)翻译成 React 的 ResponseType 字符串。代码里还有一个值得注意的细节:保存 post/page 会连带使TagsResponseType失效,因为编辑器里新建的 tag 是作为 post 内嵌关联一起写入的,Ember 只会上报post变更——若不同步失效,帖子列表的 tag 过滤就会一直命中旧缓存。 - useSidebarVisibility:以 Ember 为权威来源,通过
useSyncExternalStore订阅sidebarVisible(例如进入编辑器时 Ember 会隐藏侧栏)。 - useEmberRouting:把 Ember 的路由 URL 生成与激活判断暴露给 React。值得留意的是,源码对
isRouteActive加了条件:React 自有的导航走pushState,Ember 观察不到,因此只有在当前正渲染 Ember fallback 时才信任 Ember 的路由状态。 - useForceUpgrade:综合
config.hostSettings.forceUpgrade与订阅状态判断站点是否处于强制升级(欠费)模式。 - emberMutationHandlers:反向通道——React 侧的 mutation 成功后,通过
window.EmberBridge?.state.onUpdate/onInvalidate/onDelete通知 Ember 的 store 同步,无桥接时安全地 no-op。
路由分工:谁渲染什么,一目了然
真正的"渐进"体现在路由表 src/routes.tsx 中。文件顶部的 EMBER_ROUTES 数组集中声明了仍由 Ember 处理的路由:
const EMBER_ROUTES: string[] = [
'/site', '/setup', '/signin/*', '/signout', '/signup/*', '/reset/*',
'/pro/*', '/posts/analytics/:postId/debug', '/restore', '/migrate/*',
'/members-activity',
];
这些路径统一挂到 EmberFallback 组件。EmberFallback(见 src/ember-bridge/ember-fallback.tsx)本身渲染 null,只通过 registerFallback() 在挂载/卸载时通知 Ember 显示/隐藏自身——相当于一个"我接管了这块屏幕"的占位信号。
另一批路由则由 Gate(网关)组件根据 Labs 功能开关决定交给 React 还是 Ember:
/posts、/pages→PostsListGate/PagesListGate,依据postsListReact开关;/editor/*→EditorGate,依据editorReact开关;/tags/:tagSlug→TagDetailGate,依据tagDetailsReact开关。
辅助路由还包括负责角色鉴权的 RouteAccessGuard(handle.requiresAccess 规则:如 /tags 要求 canManageTags、/members 要求 canManageMembers)、负责欠费强制升级的 ForceUpgradeGuard、以及 /posts 下的帖子分析(/posts/analytics/:postId)、自动化的全屏编辑器(/automations/:id,带 hideAdminSidebar)、设置模块(settings,同样隐藏侧栏)等。未被 React 或 Ember 覆盖的路径最终落入 NotFound 404 兜底。
一个工程细节:因为 Ember 的 router 只通过 hashchange 感知 URL 变化,而 React Router 的 pushState 导航不会触发该事件,所以链接进 Ember 管辖路由时必须使用原生 hash 锚点——useIsEmberOwnedRoute 就是为此而生的判断工具。
CSS 策略:单一 Tailwind 入口与无分层级联
Admin 的样式只有一个入口:src/index.css。它是 "single Tailwind CSS entry point",其职责可归纳为四块:
- 用
@source声明 Tailwind 的源码扫描范围,让原子类工具能够覆盖多个工作区:
@source "../../shade/src/**/*.{ts,tsx}";
@source "../../activitypub/src/**/*.{ts,tsx}";
@source "../../admin-x-framework/src/**/*.{ts,tsx}";
@source "../node_modules/@tryghost/kg-unsplash-selector/dist/**/*.js";
这保证了 Admin、Shade、ActivityPub、admin-x-framework 以及内嵌的 Koenig Unsplash 选择器都能被 Tailwind 识别。整个仓库只有这个应用为内嵌 Admin 的 CSS 流水线加载 @tailwindcss/vite 插件(见 apps/admin/package.json 的 devDependencies)。
-
入口导入:
@import '@tryghost/shade/styles.css'引入设计系统样式,再导入 Inter Admin 字体;随后以@theme块声明主题字体族变量(从 Cardo、Manrope 到 JetBrains Mono 等二十余种供设置页字体选择器使用)。 -
定义
.admin7作用域下的排版与字体特性:CSS 代码中大量使用@custom-variant admin7与body.react-admin:has(> #root .admin7)这类选择器,把字体规则镜像到 Shade portal 与 Ember 遗留 overlay(#ember-basic-dropdown-wormhole、#ember-modal-wormhole等)。 -
排版 Ember 与 React 共存的页面网格:
body.react-admin使用height: 100svh; display: grid; overflow: hidden建立auto 1fr的网格,alerts wormhole 占第一行、#root下的.shade.shade-admin占第二行;#ember-app用isolation: isolate隔离旧 Ember 的 z-index,防止其(如 billing overlay 的 9999)盖过 React 外壳的侧栏抽屉。
文档给出了两条刚性约束:
- 内嵌的 Admin 应用不得自行
@import '@tryghost/shade/styles.css'——否则会生成重复的 utility 类,与 Ember 遗留 CSS 产生级联冲突; - Shade 的 Tailwind 导入必须保持无分层(unlayered),因为 Ember 遗留 CSS 也是无分层的,依赖源码顺序解决重叠工具类的覆盖关系。在未考虑遗留级联的前提下,不要把 Shade 导入挪进某个 CSS layer。
这种"谁的样式跟谁走"的分界,是迁移期新旧 UI 在同一页面共存而不互相污染的关键。
部署兼容:Admin 与 Core 可以不同步上线
一个极易踩坑的工程原则:Ghost Admin(前端)与 Ghost Core(后端)允许在不同时间部署。因此,依赖某个新 setting、endpoint 或 config 值的新 UI,必须主动探测后端是否支持,不支持时隐藏或安全禁用该功能。
文档特别警告:仅靠 Labs 开关不能作为兼容性判断——Labs 开关可能在支撑它的后端版本上线之前就存在了。与此同时,官方要求为"旧后端"场景补一个验收测试;settings 的社会账号设置与 membership tiers 相关测试里保留着"在支撑性设置出现前先隐藏控件"的现行范例。这是保证灰度发布安全性的最后一道防线。
本地开发:从 monorepo 根目录一键启动
开发命令:
# 从 monorepo 根目录启动开发服务器
pnpm dev
pnpm dev 会联动启动多个工作区。在 apps/admin/package.json 的 Nx 配置里可以看到 dev target 的 dependsOn:它会先拉起 ghost-monorepo 的 docker(ghost-monorepo:docker:up)、Ember admin(ghost-admin:dev)、admin-x-framework 与 Shade 的 dev server,最后才启动 Vite。
开发新功能时文档给出三条纪律:
- 在这个 React 应用里开发新后台功能;
- API 访问统一走
admin-x-framework,UI 统一用 Shade,不再新增admin-x-design-system组件; - 产品文案放在
ghosti18n namespace 下,遵循 国际化指南。该指南说明:翻译文件位于 packages/i18n/locales,ghostnamespace 覆盖 Ghost Core(含服务端、前台与会员邮件模板),英文源字符串本身就是翻译 key。
测试体系:单元 / 验收 / 浏览器 e2e 三层
Admin 的测试按成本与保真度分三层,各有归属:
单元测试(Unit)
pnpm test:unit
基于 Vitest + jsdom,测试文件与被测组件同目录共存(*.test.ts(x))。从 package.json 的脚本可见:test 默认就是 test:unit,且 Nx 配置里 unit 与 acceptance 都 dependsOn ^build(先构建依赖的工作区)。
验收测试(Acceptance)
pnpm test:acceptance # 运行一次
pnpm test:acceptance:watch # watch 模式
这套体系的机制很特别,详见 apps/admin/test-utils/acceptance/README.md:它用 Vitest Browser Mode 启动真实的 Chromium,跑的是真实 Admin 应用(与 src/main.tsx 相同 Provider 栈),但 API 层由浏览器内的 MSW 提供一份"简化但可用"的假 Ghost Admin API(与 e2e 中 fake-stripe-server 同族的 test double)。外壳的启动请求(settings/config/site/me、成员计数、当前主题、changelog feed)由默认的 boot.ts 处理,测试用例无需关心。
验收测试还建立了一套颇具设计感的约定:
- 418 循环:任何未被假 API 处理的请求都会被回 418,并在
afterEach中使测试失败、列出缺失的路由——与其猜测应用的网络图,不如让测试告诉你还差什么 fake。 - Boot 表:
browseSettings/browseConfig/browseSite/browseMe/browseMembersCount/browseActiveTheme/editUserPreferences默认已处理;renderAdminApp(route, {boot: {...}})可覆盖其中某项的响应,{labs: {someFlag: true}}则是同步覆盖 setting + config 的语法糖。Boot 响应是无状态的,唯一例外是editUserPreferences会把 PUT 的 user 字段回显——否则框架会把缓存用户整个覆盖掉。 - THE RULE:fake 永不实现 NQL 语义——声明响应、断言发出的过滤字符串,而不是去重实现一次查询。
- 断言风格统一为三种:元素状态(
expect.element(...).toBeVisible()等)、元素数量(toHaveCount(n))、以及被捕获的请求(expect(membersApi).toHaveSentFilter(...))。 - 每个测试只 render 一次(每轮渲染配一个全新 QueryClient),需要跨刷新持久化真实服务器状态的旅程归属 e2e 层;由 Ember state-bridge(
window.EmberBridge)喂数据的 UI(如 nav 激活态、upgrade banner)也无法在这一层覆盖,应留在 e2e。
浏览器 e2e
针对真实 Ghost 实例的浏览器端到端测试位于仓库顶层 e2e 工作区,由 Playwright 驱动,页面对象与验收测试共享来自 packages/testing/test-data 的 selector 常量注册表,保证 UI 一旦改动两层测试同步失效。
生产构建:产物落地与 Nx 编排
pnpm nx run @tryghost/admin:build
根据 apps/admin/package.json 的 Nx target 定义,build 的输出有两个:{projectRoot}/dist(即 apps/admin/dist/)以及 {workspaceRoot}/ghost/core/core/built/admin。也就是说——tsc -b && vite build(见 package.json 的 build script)先产出 React 应用的静态资源,Ember Admin 的 asset-delivery addon 再把产物与 Admin 资源一起拷贝进 ghost/core/core/built/admin/,最终由 Ghost Core 对外提供服务。整个构建链的依赖顺序是:先构建依赖工作区(admin-x-framework、shade、checkout 等),再构建 Ember admin(ghost-admin:build),最后打包 React Admin。
这也解释了 CSS 章节的拓扑约束:只有 apps/admin 需要为"内嵌 Admin"产出独立 CSS 流水线,其余应用按各自能力被集成进这一个入口。
小结:一条可借鉴的"渐进式重构"工程样板
从 apps/admin/README.md 出发可以看到,Ghost 的 React Admin 迁移并非简单的"新技术替换旧技术",而是一整套精密的共存工程:
- 路由级灰度:
EMBER_ROUTES集中登记未迁移路由,*Gate组件配合 Labs 开关逐页放量 React 版本; - 状态桥与数据同步:
window.EmberBridge.state契约 + 映射表 + React Query 失效,让两套框架共享同一份数据真相; - CSS 隔离共存:单一 Tailwind 入口、无分层级联策略、z-index 隔离,解决新旧 UI 同屏渲染的样式战争;
- 前后端独立部署的兼容意识:用能力探测 + 旧后端验收测试保证灰度安全;
- 三层测试与共享 selector 注册表:unit 保快速、acceptance 用真实应用+MSW 假 API 保高保真、e2e 对真实实例兜底。
对于任何面临"大型遗留系统渐进迁移"挑战的团队,这套以"桥"为核心、以测试为护栏的架构,都是一份值得反复研读的参考资料。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00