← 代码课堂

第 19 课:零依赖构建与交付验收

难度:★★★(综合课:构建、测试、交付)
教材:course-publisher/ + portfolio-site/
预计时间:讲解 90 分钟 + 练习 50 分钟
学完你能:写一个"零依赖但很正经"的构建器,并用契约测试 + 真浏览器验收来保证交付质量


课前须知:从"能打开"到"能交付"

一个作品集网站,难点不在页面好不好看,而在交付质量:

这两个项目(课程发布器 + 作品集站)给了很硬核的答案,而且零第三方依赖。


第一步:跑起来

REM A. 课程发布器:Markdown → 静态站
cd course-publisher
py -3 -m venv .venv
.venv\Scripts\pip install pyyaml
.venv\Scripts\python build.py          REM 产出 dist/(页面 + zip + index.json)
start.bat                              REM 构建 + 起服务 + 开浏览器

REM B. 作品集站:构建 + 契约测试 + 浏览器验收
cd ..\portfolio-site
node build.mjs                         REM 构建到 outputs/portfolio-site
node --test contracts.test.mjs         REM 契约测试
node verify.mjs                        REM 真浏览器验收(需要 Edge,会输出截图)

第二步:模块地图

course-publisher/
├── md2html.py     260 行  手写 Markdown → HTML 渲染器(零依赖)
├── build.py       596 行  课程校验 + 页面生成 + zip + index.json
├── start.bat / stop.bat   启动/停止(先 build 再 serve;纯 ASCII 防代码页坑)
└── tests/         3 个文件、60 个测试

portfolio-site/
├── core.js            145 行  纯数据边界(浏览器/Node 共用,校验+安全函数)
├── build.mjs          132 行  构建器(校验→staging→原子发布→备份)
├── contracts.test.mjs 178 行  契约测试(node:test)
├── verify.mjs         234 行  真浏览器验收(原生 CDP 控制 headless Edge)
├── zip.mjs             61 行  零依赖 ZIP 写入器
├── package.mjs         26 行  按清单 + sha256 校验打包
├── ink.js             131 行  汉字笔顺开场动画
└── portfolio.json     131 行  唯一内容源

共同点:零第三方依赖,却做出了"正经工具链"的功能。


第三步:逐块精讲

块 1:手写 Markdown 渲染器 —— 一个"状态机"(md2html.py 第 112-229 行)

不用 markdown 库,自己写渲染器。难点不在加粗斜体,而在列表里嵌答案:

def render(md: str) -> str:
    """把 Markdown 正文渲染成 HTML 片段(不含 <html>/<body>)。

    列表用一个"层栈"来管:每层记 {indent, kind, li_open}。
    `<li>` 故意**不立刻闭合**,因为「课堂提问」的折叠答案要落在它里面;
    遇到下一个块级元素或列表层级变化时才补上 `</li>`。
    """
    stack: list[dict] = []   # [{indent, kind, li_open}]
    can_attach = False       # 刚输出过 <li>,下一条引用可以折叠进去

对应场景(你在第 01-12 课见过):

1. 第一题
   > 这题的答案是……      ← 这个引用会被折叠进上一条 li

渲染成:

<details class="answer"><summary>看答案</summary>...</details>

关键实现(第 197-213 行):

if s.startswith(">"):                       # 引用
    ...
    if can_attach and stack:
        # 「课堂提问」:答案折叠进上一个列表项,先自己想再看答案
        out.append('<details class="answer"><summary>看答案</summary>'
                   f'<div class="answer-body">{_loose(body)}</div></details>')
        close_li(stack[-1])                 # 现在才补 </li>
        can_attach = False
    else:
        out.append(f"<blockquote>{_loose(body)}</blockquote>")

三个知识点:

  1. 列表用"层栈"管理:每遇到一个更深的缩进就压栈,缩进变浅就弹栈——

这是处理嵌套结构的经典手法(和括号匹配、HTML 解析同源)

  1. 延迟闭合 <li>:为了把后面的内容塞进上一条列表项,

故意不立刻写 </li>,等确定"不会再有附属内容"了才补

  1. 区分"松散列表"和"紧凑列表"(第 143-156 行):

空行后面如果还是同层列表项,不能断开——否则会另起一个 <ol>,编号从 1 重来

这类"手写解析器"的坑非常多,所以作者配了 tests/test_md2html.py(197 行):
专门测"折叠答案必须落在 <li> 内""松散列表不能断成两个 <ol>"等等。
规则越微妙,测试越重要。

块 2:课程源严格校验 + 站内样式(build.py)

load_course()(第 148-196 行)对课程源做严格校验,每种错误都给出可操作的提示
(CourseError,第 61-62 行):缺 course.yaml、YAML 损坏、缺 title、
章节编号重复、slug 撞车……错误信息直接告诉你怎么改。

页面生成(第 423-527 行)有两个值得学的决定:

保证离线可用、双击可用

zip 打包(write_zip() 第 269-286 行)也很讲究:zip 里套一层以课程 slug 命名的目录,
这样别人解压不会把文件撒得到处都是。

块 3:原子发布构建器(portfolio-site/build.mjs 第 52-124 行)

这是本课最值得学的工程模式。构建流程:

① 边界校验(validateOutput)
② 读内容源 portfolio.json → 校验数据(core.validatePortfolio)
③ 生成产物到 staging 临时目录
④ 算每个文件的 sha256,写 build-manifest.json
⑤ 把旧版 rename 成 .previous-<uuid>(备份)
⑥ 把 staging rename 成正式目录(原子替换)
⑦ 出错则把备份 rename 回来

四个关键点:

  1. 边界校验(第 22-28 行)防止"构建把源码或 outputs 本身覆盖了":
   assert(inside(resolvedWorkspace, resolvedPublic), "outputs 必须在当前工作区内,不能通过链接指向外部目录");
   assert(inside(resolvedPublic, resolvedOutput), "输出必须是工作区 outputs 内的独立子目录,不能覆盖 outputs 本身");
   assert(resolvedSource !== resolvedOutput && !inside(...) && !inside(...), "输出不能与源码重叠");
   assert(resolvedOutput === path.resolve(output), "输出路径不能包含符号链接或目录联接");

inside() 用 path.relative 判断包含关系;
realpath 解析符号链接——防止有人用目录联接把输出指到系统目录。

  1. 先 staging 再原子 rename(第 79、106-110 行):

构建过程写的是临时目录,只有全部成功才替换正式目录。
中途失败?正式目录一动没动。这就是"失败不发布"。

   backup = path.join(..., `.${path.basename(output)}.previous-${randomUUID()}`);
   await rename(output, backup);          // 旧版改成备份
   try { await rename(stage, output); }   // 新版本上位
   catch (error) { if (backup) await rename(backup, output); throw error; }  // 失败回滚
  1. 构建锁(第 75-78 行):用 open(lockPath, "wx")(wx = 排他创建)

防止两个构建同时跑互相踩。

  1. 不替换"未认领"的目录(第 85-91 行):如果输出目录里没有本项目的

build-manifest.json,又不像旧版文件结构,就拒绝构建——
防止误覆盖别人的目录。

这个"staging + 原子替换 + 备份回滚 + 锁"的组合,
和数据库的"事务"、第 14 课的"快照回滚"是同一种思想:要么全成功,要么保持原样。

块 4:契约测试 —— 测"约定",不只测函数(contracts.test.mjs)

node:test 是 Node 内置的测试框架(不用装 Jest)。它测的不只是函数,还有契约:

逐条验证"坏数据必须被拒绝":未知版本、缺 profile、错误 ID 类型、重复 ID、
危险链接、越界截图路径……

safeImage 拒绝 8 种越界路径(../、隐藏目录、%2e%2e……)——
这些是"防注入"的白名单,和手机遥控那课的思路一致

坏数据不能替换旧版、目录联接不能当输出……

course-publisher 生成的 index.json——包括兼容无版本的旧格式、
拒绝未知版本、"没生成 HTML 就不伪造入口"。
这是两个项目之间的"接口协议",用测试钉死。

契约测试的价值:它保护的是"模块之间的约定",
一旦有人改了数据结构或接口,测试立刻报错——协作的安全网。

块 5:真浏览器验收(verify.mjs)

静态构建对不代表"用户打开是对的"。所以它做端到端验收:

  1. 先校验 build-manifest.json 里每个文件的 sha256(确认产物没被改动)
  2. 起一个本地 HTTP 服务器,把 index.html 映射到不同 URL,

并注入不同数据夹具:正常、空数据、坏数据、未知版本、超长标题、
转义字符、破图……

  1. spawn 一个 headless Edge,用 --remote-debugging-port=0,

从 stderr 抓 DevTools listening on ws://...

  1. 用 Node 内置的 WebSocket 直连 CDP(Chrome DevTools Protocol),

执行 Runtime.evaluate:检查渲染结果、点按钮、截图

它验收约 40 个点,包括:

不用 Puppeteer/Playwright,直接用 CDP——因为要零依赖。
(想深入的话:CDP 就是浏览器暴露的调试协议,Puppeteer 本质是它的封装。)

块 6:汉字笔顺开场动画(ink.js 第 55-129 行)

这是整个项目最"炫"的部分,原理却不复杂:

// 中线是离散采样点,直接用会显得折线感重;用 Catmull-Rom 转成平滑贝塞尔。
function smoothPath(points) {
  ...
  const c1x = p1[0] + (p2[0] - p0[0]) / 6;
  const c1y = p1[1] + (p2[1] - p0[1]) / 6;
  const c2x = p2[0] - (p3[0] - p1[0]) / 6;
  const c2y = p2[1] - (p3[1] - p1[1]) / 6;
  path += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2[0]} ${p2[1]}`;
  ...
}

然后是"描边动画"(第 108-126 行):

element.innerHTML = markup;
// 路径长度只有进 DOM 后才量得准,用它算出每笔的动画区间,交给 CSS 动画执行。
for (const stroke of element.querySelectorAll(".handwriting-stroke")) {
  const length = stroke.getTotalLength();
  const charOrder = Number(stroke.dataset.charOrder || 0);
  const strokeIndex = Number(stroke.dataset.stroke || 0);
  stroke.style.setProperty("--length", length.toFixed(2));
  stroke.style.setProperty("--duration", `${STROKE_DURATION}ms`);
  stroke.style.setProperty("--delay", `${charOrder * PER_CHAR_DELAY + strokeIndex * STROKE_DELAY}ms`);
}

原理:SVG 路径可以用 stroke-dasharray + stroke-dashoffset 实现"从无到有画出来":
把虚线间隔设成路径总长,偏移量从"路径长度"动画到 0,视觉上就是一笔一笔写出来。
JS 只负责量长度、算好每笔的延迟(--delay),实际动画交给 CSS。

还有两个工程细节:

SVG 是 y 向上,所以要翻转并平移

就渲染成普通文字并在 console 警告"缺哪个文件、怎么补"——
不允许"静默失败"(否则调试起来极其痛苦)

而 build.mjs 会把笔画数据合成一个内联快照 hanzi-data.js(第 58-72 行),
原因写在注释里:file:// 下 fetch 会被 CORS 拦截,
内联进 <script> 才能让"双击预览"和"HTTP 访问"表现一致。


第四步:对照开源,别人怎么做构建与验收

对照静态站点生成器(SSG)

Hugo / Jekyll / VitePress / Astro 都是"内容源 → 静态站"的生成器。
你的 course-publisher 是它们的教学版:校验、生成、打包、离线可用。

对照"发布原子性"

做法风险
直接往正式目录写中途失败 → 线上被写坏一半
staging + 原子 rename(你的)失败不发布、旧版可回滚
蓝绿部署 / 滚动发布生产级方案,思路相同

对照端到端测试工具

工具特点
Puppeteer / Playwright封装完善、用得多
Cypress交互式调试友好
原生 CDP(你的)零依赖,理解原理最透彻

对照契约测试

"契约测试"在微服务领域很常见(消费者驱动契约,如 Pact)。
教材项目用它保护"JSON 结构 + 跨项目接口",规模虽小,方法很正。


动手练习(做完才算过关)

练习 1(热身):构建一次课程站
跑 python build.py,打开 dist/index.html 看效果;
再改一门课的某个 .md(加一句话),重新构建,确认页面更新。

练习 2(必做):让契约测试抓错
在 portfolio.json 里把某个项目的 id 改成数字(本该是字符串),
跑 node --test contracts.test.mjs,看测试怎么精确指出问题。改回来。

练习 3(必做):验证"失败不发布"
在 build.mjs 里临时把内容源改成一个不存在的文件(或在生成阶段抛个错),
跑构建,确认旧的 outputs/portfolio-site 没有被破坏(还有 build-manifest.json)。
改回来。

练习 4(必做):跑一次浏览器验收
跑 node verify.mjs,找到它输出的 outputs/portfolio-test-results.json,
看里面有多少个检查点、有没有失败的。这就是"自动验收"。

练习 5(思考题):
为什么 paint() 要在元素进 DOM 之后才用 getTotalLength() 量路径长度?

看答案

不在 DOM 里的元素能计算布局吗?

练习 6(选做,进阶):
给 zip.mjs(零依赖 ZIP 写入器)写一个测试:生成一个含两个文件的 zip,
然后用 Python 的 zipfile 打开它,确认文件内容正确。
跨语言验证一个自研实现,是很有意思的练习。


本课小结

术语表

术语人话解释
SSG静态站点生成器(内容源→静态 HTML)
原子替换一次操作要么完成要么不发生(rename 是原子的)
staging 目录临时生成区,成功后才替换正式目录
构建锁防止两个构建同时运行互相踩
契约测试测"模块之间的约定"而非单个函数
sha256文件内容指纹,用来校验产物没变
headless 浏览器无界面的浏览器,用于自动化
CDPChrome DevTools Protocol,浏览器调试协议
SVG stroke-dashoffset用虚线偏移做"描边画出"的动画
Catmull-Rom由离散点生成平滑曲线的样条算法
file://双击本地 HTML 打开的协议(无服务器)

下节预告

第 20 课(本次增补的最后一课):数据分析实验的工程化。
教材是 生信入门 的课程 v2:4 个真实数据实验(序列/差异表达/富集/单细胞)、
可复现的数据下载校验(URL + sha256)、以及很巧的自动判分 + TODO 清单机制。
学完你就知道"教别人做数据分析"和"自己会做"之间的差距在哪。