难度:★★★(需要读过第 05 课前端、第 11 课 React 思想)
教材:worry_debate_game/frontend/(新前端)+worry_debate_game/app.py
预计时间:讲解 60 分钟 + 练习 50 分钟
学完你能:看懂现代前端工程(Vite/TS/npm scripts),理解类型系统怎么帮你少写 bug
第 05 课的「查资料」是原生 JS:<script> 直接引文件、全局对象、没有类型。
「天使与恶魔」最近把前端整个重做成了 Vite + TypeScript:
| 旧前端(static/script.js) | 新前端(frontend/) | |
|---|---|---|
| 组织方式 | 一个 1384 行的 js | 几十个 ts 模块分文件夹 |
| 类型 | 无,写错全靠运行时报错 | TypeScript 编译期就报错 |
| 依赖管理 | 无(手写) | npm + three.js |
| 开发体验 | 改完手动刷新 | 热更新(保存即生效) |
| 构建 | 无 | vite build 打包压缩到 static/ |
工程化前端解决三个问题:代码怎么分模块、错误怎么提前发现、依赖怎么管理。
REM 方式 A:只看后端(构建产物已提交,不需要 Node)
cd worry_debate_game
start.bat REM 浏览器打开 http://127.0.0.1:8000
REM 方式 B:改前端(需要 Node)
cd frontend
npm install
npm run dev REM 打开 http://localhost:5173/static/
改前端时打开方式 B:保存代码,页面自动刷新——这就是热更新。
注意 npm run dev 的地址是 5173,而不是 8000。
frontend/
├── package.json 项目与依赖清单(npm 的"身份证")
├── vite.config.ts 构建配置(输出到哪、开发代理、分包)
├── tsconfig.json TypeScript 编译选项
└── src/
├── main.ts 入口:启动、画风选择、UI 事件
├── api/client.ts 和后端说话:SSE 流式客户端(本课重点)
├── core/ 与界面无关的通用能力
│ ├── emitter.ts 事件总线(复习第 06 课)
│ ├── math.ts
│ ├── render-budget.ts 自动画质预算(按帧率降特效)
│ └── settings.ts
├── stage/ Three.js 舞台渲染(森林绘本 / 霓虹舞台)
├── show/director.ts 演出编排:把流式台词变成"按节拍播放的剧本"
├── audio/ 音频引擎(合成器、音乐、环境音)
└── ui/ 界面层(气泡、HUD、设置面板、历史)
规律又是老朋友:core(逻辑)/ stage(渲染)/ show(编排)/ ui(界面)
——和第 06 课 Python 的 core/cli/ui、第 11 课 React 的 hooks/components
是同一个"分层"思想,只是换了语言。
package.json —— 前端项目的"身份证"(第 6-19 行)"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "node --experimental-strip-types --test tests/*.test.ts",
...
},
"dependencies": { "three": "^0.170.0" },
"devDependencies": {
"@types/three": "^0.170.0",
"puppeteer-core": "^25.12.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
三个要点:
scripts 是"命令别名":npm run build 实际执行tsc --noEmit && vite build——先做类型检查,通过了才构建。
类型错了直接停,不会构建出有问题的产物。
dependencies vs devDependencies:运行时要用的(three)放前者,只有开发用的(编译器、测试工具)放后者(复习第 12 课的依赖管理)。
^0.170.0 的含义:允许 0.170.x 范围内的升级(semver 版本规则)。vite.config.ts —— 构建配置(第 3-24 行)export default defineConfig({
base: '/static/', // 后端挂载在 /static 下
build: {
outDir: '../worry_debate_game/static', // 产物直接输出给后端
emptyOutDir: true,
rollupOptions: { output: { manualChunks: { three: ['three'] } } },
},
server: {
port: 5173,
proxy: {
'/api': { target: 'http://127.0.0.1:8000', changeOrigin: true },
'/assets': { target: 'http://127.0.0.1:8000', changeOrigin: true },
},
},
});
四行配置,四个知识:
base: '/static/':告诉 Vite "你的资源会被放在 /static 路径下",生成的 HTML 里引用路径才会正确
outDir 指向后端目录:构建产物直接吐进 worry_debate_game/static/,后端 app.py 第 38-40 行把它挂到 /static——前后端就接上了
manualChunks: { three: ['three'] }:把体积最大的 three.js 单独打一个包,好处是升级业务代码时,用户浏览器还能复用缓存的 three 包(性能优化)
proxy(开发代理):开发时前端在 5173、后端在 8000,浏览器直接请求 8000 会被CORS 拦(复习第 08 课)。代理让 5173 的 /api 请求转发到 8000——开发期不需要 CORS
api/client.ts 第 12-27 行)第 09 课的事件名是手写字符串("angel"、"demon"),写错一个字前端就收不到。
TypeScript 用联合类型把它变成"编译期可检查"的:
export type ServerEvent =
| { type: 'session'; session_id: string; max_rounds: number }
| { type: 'worry'; text: string }
| { type: 'angel'; text: string }
| { type: 'interrupt'; by: string }
| { type: 'judge'; angel: number; demon: number; ... }
| { type: 'round_done'; round: number; done: boolean; tendency: number }
| { type: 'error'; message: string; retryable?: boolean };
这叫可辨识联合(discriminated union):每个事件用 type 字段区分,
后面的字段各不相同。好处:
switch (ev.type) 时,TypeScript 会帮你收窄类型,自动提示每个分支的字段这就是"类型系统"的价值:把一大批运行时 bug 提前到写代码时。
streamSSE —— 第 09 课流式客户端的"工程化版"(第 53-108 行)async function streamSSE(url, body, apiKey, onEvent, signal): Promise<void> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (apiKey) headers['X-API-Key'] = apiKey;
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal });
if (!res.ok || !res.body) {
// 解析 FastAPI 的错误:detail 可能是字符串,也可能是 Pydantic 的数组
...
onEvent({ type: 'error', message: msg, retryable: res.status >= 500 });
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const flush = (block: string) => { /* 解析 event:/data: → onEvent */ };
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
let idx: number;
while ((idx = buffer.indexOf('\n\n')) >= 0) {
flush(buffer.slice(0, idx));
buffer = buffer.slice(idx + 2);
}
}
if (buffer.trim()) flush(buffer);
}
和第 09 课 readSSEStream 的原理完全一样(getReader + 按空行切 + 留半包),
但有四个工程化改进:
onEvent 回调:解析和业务解耦,谁用谁传处理函数AbortSignal:可以随时取消请求(用户点"重新开始"时用得上)X-API-Key 请求头:每次请求带 Key,不依赖服务端保存detail(可能是字符串或数组)统一成人类可读的消息;retryable: res.status >= 500 表示"服务端问题值得重试,参数错误就别重试"
stage/surface.ts 定义了两种舞台(森林绘本 / 霓虹 Three.js)共用的接口,main.ts 根据用户选择实例化其中一种。这与第 07 课 Qt 的
"接口 + 多实现"、第 06 课的"抽象类 + NoOp"是同一个套路:
先约定接口,再让不同实现插进去。 换舞台不动上层代码。
show/director.ts(819 行)值得单独提:它把流式台词转成
"按节拍播放的演出片段"——AI 生成的速度和播放的节奏是两回事,
中间需要一个"导演"来缓冲、排序、控制节奏(和第 09 课前端的排队机制同源)。
| 原生 JS(查资料) | Vite + TS(worry) | |
|---|---|---|
| 模块化 | <script> 顺序加载 + 全局对象 | ES Module import/export |
| 类型 | 无 | TypeScript 编译期检查 |
| 依赖 | 自己下载 echarts.min.js | npm 管理 |
| 构建 | 无 | 打包、压缩、分包、哈希命名 |
| 开发 | 手动刷新 | 热更新 |
Vite 是什么? 新一代构建工具(替代 webpack):开发时用浏览器原生 ESM 秒起服务,
构建时用 Rollup 打包。它的卖点是"快"。
三者定位不同:Vite 管"打包",Next.js 是"框架"。
这个项目把 frontend/ 的构建产物 static/assets/* 提交进了 git
(app.py 第 37 行注释写明)。好处:别人 clone 下来直接跑后端就能用,不需要装 Node。
代价:仓库变大、每次改前端都要重新提交产物。
这是"用户方便"和"仓库干净"之间的权衡——没有标准答案,取决于用户是谁。
练习 1(热身):让类型检查抓错
在 api/client.ts 的 ServerEvent 里,把 { type: 'worry'; text: string }
改成 { type: 'worry'; text: number },然后在 frontend/ 跑:
npm run typecheck
看编译器报错指出哪里用错了类型。改回来。体会:不用运行程序,错误就暴露了。
练习 2(必做):加一个事件类型
在 Python 后端 _play_round()(app.py 第 123 行)里加一条事件:
yield sse("notice", {"text": "本轮开始"})
再在 ServerEvent 联合类型里加上 | { type: 'notice'; text: string },
最后在 main.ts 的事件处理里打印它(搜索现有 ev.type === 'angel' 的写法照着加)。
跑 npm run build 确认类型检查通过。
练习 3(必做):体验构建
在 frontend/ 跑 npm run build,然后:
(1)观察 worry_debate_game/static/assets/ 里生成了哪些文件
(2)找到 three 单独打的那个包
文件名会带 three
(3)回到后端目录跑 start.bat,确认页面正常——你完成了一次"前端构建 → 后端托管"的闭环
练习 4(思考题):vite.config.ts 里的 proxy 只在 npm run dev 时生效。
那生产环境(npm run build 之后)前后端同源了,为什么就不需要代理了?
同源策略和端口。
练习 5(选做):
给 package.json 加一个脚本 "format": "echo TODO"(占位即可),
跑 npm run format 验证。再想想:为什么团队项目喜欢用 npm run xxx 而不是让每个人
记一堆长命令?
统一入口。
base(路径前缀)、outDir(产物位置)、manualChunks(分包)、proxy(开发代理解决跨域)
npm run build = 类型检查 + 构建;类型错误会拦住构建interface + 多实现(两种舞台)是跨语言的通用设计| 术语 | 人话解释 |
|---|---|
| Vite | 新一代前端构建/开发服务器 |
| TypeScript | 带类型系统的 JavaScript |
| npm scripts | package.json 里的命令别名 |
| 热更新(HMR) | 改代码立即在浏览器生效,不用手动刷新 |
| 打包(bundle) | 把多个模块合成浏览器能高效加载的文件 |
| 分包(chunk) | 把代码切成多个文件按需/并行加载 |
| 代理(proxy) | 开发时把请求转发到后端,绕过跨域 |
| 联合类型 | "A 或 B 或 C"的类型,用来描述多种事件 |
| AbortSignal | 用来取消进行中的请求 |
| semver | 版本号规则 主.次.修订,^ 表示允许小升级 |
第 14 课:流式会话进阶:锁、快照回滚、打断。
继续用这个项目,但视角换到后端:Session 对象、会话过期清理、
"一轮只允许跑一个"的锁、失败后能重试的状态快照,
以及最有趣的部分——怎么让 AI"被打断"(_stream_text 的 cut_at)。