← 代码课堂

第 13 课:前端工程化 —— Vite + TypeScript

难度:★★★(需要读过第 05 课前端、第 11 课 React 思想)
教材:worry_debate_game/frontend/(新前端)+ worry_debate_game/app.py
预计时间:讲解 60 分钟 + 练习 50 分钟
学完你能:看懂现代前端工程(Vite/TS/npm scripts),理解类型系统怎么帮你少写 bug


课前须知:从"手写 JS"到"工程化前端"

第 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
是同一个"分层"思想,只是换了语言。


第三步:逐块精讲

块 1: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"
}

三个要点:

  1. scripts 是"命令别名":npm run build 实际执行

tsc --noEmit && vite build——先做类型检查,通过了才构建。
类型错了直接停,不会构建出有问题的产物。

  1. dependencies vs devDependencies:运行时要用的(three)放前者,

只有开发用的(编译器、测试工具)放后者(复习第 12 课的依赖管理)。

  1. ^0.170.0 的含义:允许 0.170.x 范围内的升级(semver 版本规则)。

块 2: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 },
    },
  },
});

四行配置,四个知识:

生成的 HTML 里引用路径才会正确

后端 app.py 第 38-40 行把它挂到 /static——前后端就接上了

好处是升级业务代码时,用户浏览器还能复用缓存的 three 包(性能优化)

CORS 拦(复习第 08 课)。代理让 5173 的 /api 请求转发到 8000——开发期不需要 CORS

块 3:TypeScript 的"事件类型"(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 字段区分,
后面的字段各不相同。好处:

这就是"类型系统"的价值:把一大批运行时 bug 提前到写代码时。

块 4: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 + 按空行切 + 留半包),
但有四个工程化改进:

  1. onEvent 回调:解析和业务解耦,谁用谁传处理函数
  2. AbortSignal:可以随时取消请求(用户点"重新开始"时用得上)
  3. X-API-Key 请求头:每次请求带 Key,不依赖服务端保存
  4. 错误信息分级:把 FastAPI 的 detail(可能是字符串或数组)统一成人类可读的消息;

retryable: res.status >= 500 表示"服务端问题值得重试,参数错误就别重试"

块 5:分层与"舞台抽象"

stage/surface.ts 定义了两种舞台(森林绘本 / 霓虹 Three.js)共用的接口,
main.ts 根据用户选择实例化其中一种。这与第 07 课 Qt 的
"接口 + 多实现"、第 06 课的"抽象类 + NoOp"是同一个套路:

先约定接口,再让不同实现插进去。 换舞台不动上层代码。

show/director.ts(819 行)值得单独提:它把流式台词转成
"按节拍播放的演出片段"——AI 生成的速度和播放的节奏是两回事,
中间需要一个"导演"来缓冲、排序、控制节奏(和第 09 课前端的排队机制同源)。


第四步:对照开源,别人怎么做前端工程

对照第 05 课(原生 JS)

原生 JS(查资料)Vite + TS(worry)
模块化<script> 顺序加载 + 全局对象ES Module import/export
类型无TypeScript 编译期检查
依赖自己下载 echarts.min.jsnpm 管理
构建无打包、压缩、分包、哈希命名
开发手动刷新热更新

Vite 是什么? 新一代构建工具(替代 webpack):开发时用浏览器原生 ESM 秒起服务,
构建时用 Rollup 打包。它的卖点是"快"。

对照 CRA / webpack / Next.js

三者定位不同: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 而不是让每个人
记一堆长命令?

看答案

统一入口。


本课小结

proxy(开发代理解决跨域)

术语表

术语人话解释
Vite新一代前端构建/开发服务器
TypeScript带类型系统的 JavaScript
npm scriptspackage.json 里的命令别名
热更新(HMR)改代码立即在浏览器生效,不用手动刷新
打包(bundle)把多个模块合成浏览器能高效加载的文件
分包(chunk)把代码切成多个文件按需/并行加载
代理(proxy)开发时把请求转发到后端,绕过跨域
联合类型"A 或 B 或 C"的类型,用来描述多种事件
AbortSignal用来取消进行中的请求
semver版本号规则 主.次.修订,^ 表示允许小升级

下节预告

第 14 课:流式会话进阶:锁、快照回滚、打断。
继续用这个项目,但视角换到后端:Session 对象、会话过期清理、
"一轮只允许跑一个"的锁、失败后能重试的状态快照,
以及最有趣的部分——怎么让 AI"被打断"(_stream_text 的 cut_at)。