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)有专门的"生鲜采购部""收银系统部""会员系统部",各部门独立运作但共享同一套供应链——货架上的商品(最终页面)看起来是一个整体,背后是很多个团队各自维护的模块拼出来的。
L02Rush.js:一个
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() 懒加载,路径带 /* 表示内部还有自己的子路由SpaceRoute(routes/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 模式。