前端分层走一页:跟着 Prompt 页面从上挖到底
昨天认识了壳应用和路由。今天用 docs/reference/frontend-packages.md 里的六层依赖图打底,然后真的跟着一个页面的代码——Prompt 列表页——从 loop-pages 一路往下挖到 loop-base,顺路搞懂前端版的"接口与实现分离":Adapter 模式。
先看全景:六层依赖结构
docs/reference/frontend-packages.md 把 59 个包的关系画成一张六层图(高层级只能依赖低层级,禁止反向依赖):
Level-6 apps/cozeloop/ 主 SPA 应用(React 18, Rsbuild)
↑
Level-5 packages/loop-pages/ 5 个页面模块(auth/evaluate/observation/prompt/tag-pages)
Level-4 packages/loop-modules/ 高阶业务模块(evaluate)
Level-3 packages/loop-components/ 13 个 UI 组件包 + adapter
Level-2 packages/loop-base/ 20 个基础库(account/api-schema/hooks/stores…)
Level-1 config/ (6) + infra/ (4+) 工具链配置 + 基础设施(idl 转换等)
level-N 标签 + lint 规则"防止乱依赖。核心诉求一致:越基础的东西越稳定、被依赖的次数越多,越顶层的东西越容易变、只能往下依赖。跟着 Prompt 列表页真实代码往下挖
打开 frontend/packages/loop-pages/prompt-pages/src/pages/list/index.tsx 顶部的 import(这是 Level-5 页面包),逐条对照它依赖了哪些更底层的包:
import {
promptDisplayColumns, PromptList, PromptDeleteModal, PromptCreateModal,
} from '@cozeloop/prompt-components-v2'; // Level-3:Prompt 专用组件
import { I18n } from '@cozeloop/i18n-adapter'; // Level-2:国际化(也带 adapter 后缀,见 L04)
import { useModalData, useRefresh } from '@cozeloop/hooks'; // Level-2:通用 hooks
import { TableColActions } from '@cozeloop/components'; // Level-2:基础 UI 组件
import {
useNavigateModule, useOpenWindow, useSpace, useUserInfo,
} from '@cozeloop/biz-hooks-adapter'; // Level-3:业务 hooks(走 Adapter)
import { UserSelect } from '@cozeloop/biz-components-adapter'; // Level-3:业务组件(走 Adapter)
import { type Prompt } from '@cozeloop/api-schema/prompt'; // Level-2:从 IDL 生成的类型(Day 18 细讲)
prompt-pages(L5)页面级:组装页面布局、处理页面级状态prompt-components-v2(L3)组件级:可复用的 Prompt 列表表格、弹窗等 UI 单元biz-hooks-adapter / biz-components-adapter(L3)业务通用能力,但内容因开源/商业版而异,见 L04components / hooks / i18n-adapter(L2)最基础的通用能力,几乎所有页面都会用到api-schema(L2)从后端 IDL 生成的 TypeScript 类型,Day 18 的主角Level-2 基础库特写:api-schema
@cozeloop/api-schema/prompt 里的 Prompt 类型不是手写的——它是 Day 18 要细讲的"IDL 代码生成"产物。frontend/packages/loop-base/api-schema 这个包的所有 .ts 类型文件,都是跑一条命令自动生成出来的:
cd frontend && rush update-api # 从 idl/thrift/ 生成 TS 类型,落进 packages/loop-base/api-schema
Prompt 类型在 prompt-pages 和可能的 evaluate-pages 里都会用到,评测时要引用一个 Prompt)。抽成 Level-2 的独立包,保证"同一个后端概念在前端只有一份类型定义",避免各个页面包各自维护一份、久而久之定义漂移不一致。account(用户账户)、stores(Zustand 全局状态)、route(路由工具)、tea(数据埋点)、i18n(国际化底座)——都是"跟具体业务无关,但几乎每个页面都要用"的基础设施,对应后端 Day 15 里 foundation 模块的地位。Adapter 模式:为什么名字里都带个"adapter"
UserSelect)在开源版可能只能选本地账号,商业版可能要接企业级的组织架构服务。如果在组件内部写一堆 if (isCommercial) {...} else {...},代码会越改越乱,而且开源仓库里还得留着商业版的代码分支(不利于开源发布)。adapter-interfaces/ # 接口定义(Level-3)
↑
*-adapter/ # 接口实现(evaluate-adapter, observation-adapter…)
↑
components-with-adapter/ # 消费者
页面代码永远只 import xxx-adapter 这个包名(比如 @cozeloop/biz-components-adapter),而这个包在开源版仓库里放的是开源实现,商业化仓库同步时会替换成商业版实现——包名和调用方式完全不变。
adapter-interfaces 就是"国标插座的形状规范",*-adapter 是"具体插进去的电器"——你家的插座(页面组件)只关心"插孔形状对不对",不关心插的是电风扇还是台灯。换一个电器(开源版换商业版实现)完全不用改墙上的插座。真实的 Adapter 接口定义长什么样
frontend/packages/loop-components/adapter-interfaces/package.json 用 exports 字段按业务域拆分导出入口:
{
"name": "@cozeloop/adapter-interfaces",
"exports": {
".": ["./src/index.ts"],
"./evaluate": ["./src/evaluate/index.ts"],
"./prompt": ["./src/prompt/index.ts"],
"./observation": ["./src/observation/index.ts"]
}
}
目录结构上,adapter-interfaces/src/ 下每个业务域一个文件夹(evaluate/experiments.ts、observation/trace.ts 等),每个文件定义的是纯 TypeScript 接口/类型,不含任何实现——这和后端 components/model/interface.go(Eino 教程 Day 08 讲过的"接口与实现分离")几乎是同一种代码组织方式。
👶 小白问:那 components-with-adapter 又是干嘛的?为什么不直接让页面 import *-adapter 完事?
👨🏫 老师:有些组件需要"接口实现"之外再包一层通用的 UI 逻辑(比如加载状态、错误边界)——这层通用逻辑在开源版商业版都一样,不需要重复实现。components-with-adapter 就是"消费 *-adapter 的具体实现 + 提供通用 UI 包装"的那一层,页面代码最终 import 的可能是这一层,也可能直接 import *-adapter,取决于该组件是否需要这层包装。
一个容易confuse的小细节:包名 ≠ 文件夹名
如果你直接在 frontend/packages/loop-components/ 下找"biz-components-adapter"文件夹,会找不到——它的真实文件夹叫 biz-components。答案在 rush.json 里的注册记录:
{ "packageName": "@cozeloop/biz-components-adapter", "projectFolder": "frontend/packages/loop-components/biz-components", "tags": ["level-3"] }
packageName(导入名)和 projectFolder(磁盘路径)是两个独立字段
Rush 允许一个包的 npm 包名和它在磁盘上的文件夹名不一致——biz-components 文件夹产出的包对外叫 @cozeloop/biz-components-adapter,这个命名本身就在提示使用者"这是一个走 Adapter 模式的包",即使它的源码文件夹名字看起来更朴素。packages/ 下找不到同名文件夹"时,第一反应应该是去 rush.json 里搜 packageName,而不是怀疑自己找错了仓库。今日小结 + 动手 + 预告
🧠 今天你应该能回答
- 前端六层依赖结构从上到下分别是什么?
- 怎么只靠一个页面文件的 import 语句就画出它的依赖子图?
- 为什么要把 api-schema 单独抽成一个 Level-2 包?
- Adapter 模式解决的核心问题是什么?和后端 Day 15 的哪个设计思路对应?
- 遇到 import 的包名在磁盘上找不到对应文件夹,该去哪里查?
✋ 动手 5 分钟(可选)
# 1. 完整看一遍六层依赖参考文档
cat docs/reference/frontend-packages.md
# 2. 看 Prompt 列表页的全部 import,数一数涉及几个不同层级的包
head -30 frontend/packages/loop-pages/prompt-pages/src/pages/list/index.tsx
# 3. 找出所有带 "adapter" 字样的包名
grep "adapter" rush.json | grep packageName
# 4. 看 adapter-interfaces 包按业务域拆出的几个入口
cat frontend/packages/loop-components/adapter-interfaces/package.json
rush update-api 和 kitex 脚本到底做了什么。