Day 16 / 共 20 天 · 第 4 周 前端与工程收官

前端壳与路由:Rush.js monorepo 里的那一个 App

从今天起换战场——走进 frontend/。Coze Loop 前端是一个由 Rush.js 管理的、含 59 个包的 monorepo,而你平时在浏览器里看到的那个完整页面,只是其中一个叫 apps/cozeloop 的"壳"应用。今天先搞清楚这个壳怎么搭、路由怎么分层。

📍 你在整门课的位置 · 第 4 周 前端与工程收官(共 4 周 · 20 天)
D14 Trace 观测 D15 跨模块组装图 D16 前端壳与路由 D17 前端分层 D18 IDL 端到端
L01

换战场:为什么前端要拆这么多包

🤔 痛点:一个网页应用,为什么代码分散在 59 个包里? 如果你只写过"一个 React 项目一个 src/ 文件夹"的小应用,第一次看到 frontend/ 目录下密密麻麻的 packages/apps/ 可能会懵——这不是过度设计,而是大型多人协作前端工程的常见解法
💡 本质:和 Day 15 学的后端"模块化"是同一个道理,只是换成了前端的包管理工具 后端用 Go 的包(package)+ DDD 分层去隔离模块,前端用 Rush.js + pnpm workspace 把代码拆成一个个独立发布单元的 npm 包,包之间靠 package.json 里的依赖声明来管理"谁能用谁"。Day 17 会看到前端也有一张和 Day 15 后端依赖图几乎同构的"六层依赖结构图"。
生活类比:从"一个人的杂货店"到"分工明确的连锁超市" 一个人的杂货店(单文件夹小项目)什么都自己进货、自己上架,简单但没法规模化。连锁超市(Rush monorepo)有专门的"生鲜采购部""收银系统部""会员系统部",各部门独立运作但共享同一套供应链——货架上的商品(最终页面)看起来是一个整体,背后是很多个团队各自维护的模块拼出来的。
L02

Rush.js:一个 rush.json 管住 59 个包

frontend/rush.json 是整个前端 monorepo 的"户口本",每个包在这里登记一条记录:

{
  "rushVersion": "5.172.1",
  "pnpmVersion": "10.27.0",
  "allowedProjectTags": ["core", "infra", "rush-tools", "level-1", "level-2", "level-3", "level-4", "level-5", "level-6"],
  "projects": [
    { "packageName": "@coze-arch/eslint-config", "projectFolder": "frontend/config/eslint-config", "tags": ["core", "level-1"] },
    { "packageName": "@cozeloop/api-schema", "projectFolder": "frontend/packages/loop-base/api-schema", "tags": ["level-2"] },
    { "packageName": "@cozeloop/prompt-pages", "projectFolder": "frontend/packages/loop-pages/prompt-pages", "tags": ["level-5"] },
    // ... 一共 59 条
  ]
}
💡 tags 字段里的 level-1 到 level-6 是什么 提前预告 Day 17 的内容:每个包被打上 level-N 标签,代表它在依赖分层里的位置——Rush 会用这些标签配合 lint 规则强制"高层级只能依赖低层级",防止有人一时手滑让基础包反向依赖了业务页面包,从架构层面杜绝"意大利面式"的相互依赖。

常用命令(frontend/README.zh-CN.md):

npm i -g pnpm@10.27.0 @microsoft/rush@5.172.1   # 全局装 Rush 和 pnpm
rush update                                      # 安装/更新所有包的依赖(替代 npm install)
cd apps/cozeloop && rushx dev                    # 用 rushx 而不是 npm run 启动某个包的脚本
rush update-api                                  # Day 18 会细讲:从 IDL 生成前端类型
⚠️ 小白常踩的坑:忘了用 rushx 在 monorepo 的某个子包目录里,直接 npm run dev 有时能跑,但 Rush 管理下推荐永远用 rushx dev——它会确保依赖的 workspace 包(workspace:* 版本的那些)用的是仓库里最新的源码而不是可能过期的 node_modules 缓存。
L03

cozeloop 应用:把所有业务包"装配"起来的壳

frontend/apps/cozeloop/package.json 的依赖列表本身就是一份"业务地图":

{
  "name": "@cozeloop/community-base",
  "dependencies": {
    "@cozeloop/account": "workspace:*",
    "@cozeloop/auth-pages": "workspace:*",
    "@cozeloop/evaluate-pages": "workspace:*",
    "@cozeloop/observation-pages": "workspace:*",
    "@cozeloop/prompt-pages": "workspace:*",
    "@cozeloop/tag-pages": "workspace:*",
    "react": "~18.2.0",
    "react-router-dom": "^6.22.0",
    "zustand": "^4.4.7"
  }
}
💡 workspace:* 是什么意思 这不是一个真实的版本号,而是 pnpm workspace 的特殊语法:"这个依赖不要去 npm registry 下载,直接软链接到本仓库里这个包的源码"。所以你改 prompt-pages 包里的源码,cozeloop 这个壳应用立刻能感知到变化(不需要发版、不需要重新 npm install)——这是 monorepo 相比多仓库最大的开发体验优势。
"壳"这个词怎么理解 apps/cozeloop 自己几乎不写业务逻辑——它只负责:① 定义路由骨架、② 把各个 xxx-pages 包"挂"到对应路径上、③ 提供全局的登录态检查、导航栏、侧边栏这些"外壳"UI。业务逻辑的大头都在被依赖的那些包里,这和后端 Day 15 学的"application 层薄、domain 层厚"是同一种"壳/核"分离思想。
L04

路由三层:Base → Enterprise/Space → 业务模块

真实路由定义在 frontend/apps/cozeloop/src/routes/index.tsx

const Auth = lazy(() => import('@cozeloop/auth-pages'));
const Evaluation = lazy(() => import('@cozeloop/evaluate-pages'));
const Observation = lazy(() => import('@cozeloop/observation-pages'));
const Prompt = lazy(() => import('@cozeloop/prompt-pages'));
const Tag = lazy(() => import('@cozeloop/tag-pages'));

export const routeConfig: RouteObject[] = [
  { path: '/auth/*', element:  },
  { path: '/', element: , children: [
    { index: true, element:  },
    { path: 'console', children: [
      { index: true, element:  },
      { path: 'enterprise/:enterpriseID', element: , children: [
        { index: true, element:  },
        { path: 'space/:spaceID', children: [
          { index: true, element:  },
          { path: 'pe/*', element:  },
          { path: 'evaluation/*', element:  },
          { path: 'observation/*', element:  },
          { path: 'tag/*', element:  },
        ]},
      ]},
    ]},
  ]},
];
BaseRoute最外层:检查登录态(useCheckLogin),没登录就 Navigate 去 /auth,登录后用 GuardProvider 包一层权限守卫
EnterpriseRoute / SpaceRoute企业/空间选择层:确认"你当前在哪个企业、哪个工作空间下操作"
Prompt / Evaluation / Observation / Tag真正的业务页面模块,用 lazy() 懒加载,路径带 /* 表示内部还有自己的子路由

SpaceRouteroutes/space-route.tsx)体现了"没有可用空间时给出清晰提示"的细节:

export function SpaceRoute({ index }: Props) {
  const space = useSpaceStore(s => s.space);
  if (!space?.id) {
    return ;
  }
  const path = index ? `space/${space.id}` : space.id;
  return ;
}
为什么要"企业 → 空间 → 业务模块"三层,而不是直接进业务页面:Coze Loop 是多租户 SaaS 形态——同一个账号可能属于多个企业,每个企业下有多个工作空间(Space,用于隔离不同项目/团队的数据)。路由结构直接映射了这套组织架构模型,URL 里带 enterpriseID/spaceID 也方便直接分享一个"某企业某空间下的某个 Prompt"链接给同事。
L05

为什么业务模块要 lazy() 懒加载

🤔 痛点:五个业务模块(Auth/Evaluation/Observation/Prompt/Tag)都很大,用户打开首页只会先用到一个 如果把这五个模块的代码全打进一个 JS 文件,用户第一次打开页面就要下载全部代码——哪怕他今天只想编辑一个 Prompt,也要背着评测、观测模块的代码"陪跑"。
💡 本质:lazy() + import() 让打包工具自动拆分成多个文件,按需下载 const Prompt = lazy(() => import('@cozeloop/prompt-pages')) 这行代码会让 Rsbuild(下一节讲)在构建时把 prompt-pages 单独打成一个 chunk 文件。用户导航到 /pe/* 路径时才会触发这个文件的网络请求下载,首屏只需要加载 BaseRoute/EnterpriseRoute/SpaceRoute 这些框架代码,加载更快。
生活类比:自助餐 vs 套餐 不用 lazy() 就像"点套餐"——不管你想不想吃,五道菜一次全上齐,等菜的时间也更长。lazy() 像"自助餐现点现做"——你走到哪个窗口(路由)就现做那道菜(下载那个 chunk),其他窗口的菜暂时不用准备。
L06

Rsbuild:这个壳应用怎么被构建出来

frontend/apps/cozeloop/rsbuild.config.ts

import { createRsbuildConfig } from '@cozeloop/rsbuild-config';

const port = 8090;

export default createRsbuildConfig({
  server: { port, cors: { origin: '*' } },
  dev: { lazyCompilation: false, assetPrefix: `http://localhost:${port}`, ... },
  html: {
    title: 'Coze Loop',
    template: './src/assets/template.html',
    favicon: './src/assets/images/coze.svg',
  },
});
💡 createRsbuildConfig 是 Level-1 共享配置包的产物 注意这个配置文件本身很短——大量通用配置(Tailwind、TS、代理规则等)被抽到了 frontend/packages/loop-base/...-config 或类似的 Level-1 配置包里,cozeloop 只需要传入"我这个应用特有的部分"(端口、标题、favicon)。这正是 Day 17 要讲的"依赖分层"在构建配置层面的体现——每个业务应用不必重复写一遍构建配置的全部细节。

Rsbuild 是字节开源的新一代构建工具(基于 Rspack,Rust 实现的 webpack 替代品),比传统 webpack 配置更简洁、构建速度更快——这也是为什么 rsbuild.config.ts 能写得这么短。

L07

今日小结 + 动手 + 预告

🧠 今天你应该能回答

  • 前端为什么要拆成 59 个包管理,而不是一个大项目?
  • workspace:* 依赖版本号是什么意思?
  • 路由的三层结构分别解决什么问题?
  • 为什么五个业务模块要用 lazy() 懒加载?
  • apps/cozeloop 这个"壳"应用自己承担了哪些职责,哪些不承担?
🎵 记忆口诀Rush 管仓库,壳应用管装配,路由分三层,懒加载省流量」——今天认识的"壳/核分离"和 Day 15 后端的"模块化单体"是同一种架构直觉在前端的投影。

✋ 动手 5 分钟(可选)

# 1. 数一数 rush.json 里注册了多少个包
grep -c '"packageName"' frontend/rush.json

# 2. 看 cozeloop 壳应用依赖了哪些 workspace 包
grep "workspace:\*" frontend/apps/cozeloop/package.json

# 3. 完整看一遍路由定义
cat frontend/apps/cozeloop/src/routes/index.tsx

# 4. 跟着 README 尝试本地启动(可选,需要 Node 24+ / pnpm / rush)
cat frontend/README.zh-CN.md
明天预告 · Day 17:今天看到的 @cozeloop/prompt-pages 这些包,它们内部又依赖了哪些更底层的包?我们会读 docs/reference/frontend-packages.md 里的六层依赖图,跟着 Prompt 页面从 pages 一路往下走到 api-schema,认识区分商业版/开源版的 Adapter 模式。
← 上一天 Day 15 下一天 · 前端分层走一页 →