难度:★★★(综合课:构建、测试、交付)
教材:course-publisher/+portfolio-site/
预计时间:讲解 90 分钟 + 练习 50 分钟
学完你能:写一个"零依赖但很正经"的构建器,并用契约测试 + 真浏览器验收来保证交付质量
一个作品集网站,难点不在页面好不好看,而在交付质量:
index.html(file://)能正常看吗?还是必须架服务器?这两个项目(课程发布器 + 作品集站)给了很硬核的答案,而且零第三方依赖。
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 行 唯一内容源
共同点:零第三方依赖,却做出了"正经工具链"的功能。
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>")
三个知识点:
这是处理嵌套结构的经典手法(和括号匹配、HTML 解析同源)
<li>:为了把后面的内容塞进上一条列表项,故意不立刻写 </li>,等确定"不会再有附属内容"了才补
空行后面如果还是同层列表项,不能断开——否则会另起一个 <ol>,编号从 1 重来
这类"手写解析器"的坑非常多,所以作者配了
tests/test_md2html.py(197 行):
专门测"折叠答案必须落在<li>内""松散列表不能断成两个<ol>"等等。
规则越微妙,测试越重要。
build.py)load_course()(第 148-196 行)对课程源做严格校验,每种错误都给出可操作的提示
(CourseError,第 61-62 行):缺 course.yaml、YAML 损坏、缺 title、
章节编号重复、slug 撞车……错误信息直接告诉你怎么改。
页面生成(第 423-527 行)有两个值得学的决定:
http:///https://)——保证离线可用、双击可用
_esc()(第 405-412 行)——防 XSS(复习第 05 课)zip 打包(write_zip() 第 269-286 行)也很讲究:zip 里套一层以课程 slug 命名的目录,
这样别人解压不会把文件撒得到处都是。
portfolio-site/build.mjs 第 52-124 行)这是本课最值得学的工程模式。构建流程:
① 边界校验(validateOutput)
② 读内容源 portfolio.json → 校验数据(core.validatePortfolio)
③ 生成产物到 staging 临时目录
④ 算每个文件的 sha256,写 build-manifest.json
⑤ 把旧版 rename 成 .previous-<uuid>(备份)
⑥ 把 staging rename 成正式目录(原子替换)
⑦ 出错则把备份 rename 回来
四个关键点:
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 解析符号链接——防止有人用目录联接把输出指到系统目录。
构建过程写的是临时目录,只有全部成功才替换正式目录。
中途失败?正式目录一动没动。这就是"失败不发布"。
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; } // 失败回滚
open(lockPath, "wx")(wx = 排他创建)防止两个构建同时跑互相踩。
build-manifest.json,又不像旧版文件结构,就拒绝构建——
防止误覆盖别人的目录。
这个"staging + 原子替换 + 备份回滚 + 锁"的组合,
和数据库的"事务"、第 14 课的"快照回滚"是同一种思想:要么全成功,要么保持原样。
contracts.test.mjs)node:test 是 Node 内置的测试框架(不用装 Jest)。它测的不只是函数,还有契约:
portfolio.json 必须满足的结构。用一张"非法用例表"(第 34-52 行)逐条验证"坏数据必须被拒绝":未知版本、缺 profile、错误 ID 类型、重复 ID、
危险链接、越界截图路径……
safeLink 拒绝 7 种危险链接(javascript:、带凭据、换行、反斜杠……),safeImage 拒绝 8 种越界路径(../、隐藏目录、%2e%2e……)——
这些是"防注入"的白名单,和手机遥控那课的思路一致
坏数据不能替换旧版、目录联接不能当输出……
core.adaptCourseIndex 要能读course-publisher 生成的 index.json——包括兼容无版本的旧格式、
拒绝未知版本、"没生成 HTML 就不伪造入口"。
这是两个项目之间的"接口协议",用测试钉死。
契约测试的价值:它保护的是"模块之间的约定",
一旦有人改了数据结构或接口,测试立刻报错——协作的安全网。
verify.mjs)静态构建对不代表"用户打开是对的"。所以它做端到端验收:
build-manifest.json 里每个文件的 sha256(确认产物没被改动)index.html 映射到不同 URL,并注入不同数据夹具:正常、空数据、坏数据、未知版本、超长标题、
转义字符、破图……
spawn 一个 headless Edge,用 --remote-debugging-port=0,从 stderr 抓 DevTools listening on ws://...
执行 Runtime.evaluate:检查渲染结果、点按钮、截图
它验收约 40 个点,包括:
file:// 双击可用、缓存旧页面缺文件时自愈不用 Puppeteer/Playwright,直接用 CDP——因为要零依赖。
(想深入的话:CDP 就是浏览器暴露的调试协议,Puppeteer 本质是它的封装。)
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]}`;
...
}
<path class="handwriting-stroke">然后是"描边动画"(第 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。
还有两个工程细节:
scale(scale, -scale) 翻转 Y 轴(第 104 行):汉字数据是 y 向下 0..1000,SVG 是 y 向上,所以要翻转并平移
就渲染成普通文字并在 console 警告"缺哪个文件、怎么补"——
不允许"静默失败"(否则调试起来极其痛苦)
而 build.mjs 会把笔画数据合成一个内联快照 hanzi-data.js(第 58-72 行),
原因写在注释里:file:// 下 fetch 会被 CORS 拦截,
内联进 <script> 才能让"双击预览"和"HTTP 访问"表现一致。
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 打开它,确认文件内容正确。
跨语言验证一个自研实现,是很有意思的练习。
<li>、区分松散/紧凑列表stroke-dashoffset 描边 + JS 量长度/算延迟 + CSS 执行| 术语 | 人话解释 |
|---|---|
| SSG | 静态站点生成器(内容源→静态 HTML) |
| 原子替换 | 一次操作要么完成要么不发生(rename 是原子的) |
| staging 目录 | 临时生成区,成功后才替换正式目录 |
| 构建锁 | 防止两个构建同时运行互相踩 |
| 契约测试 | 测"模块之间的约定"而非单个函数 |
| sha256 | 文件内容指纹,用来校验产物没变 |
| headless 浏览器 | 无界面的浏览器,用于自动化 |
| CDP | Chrome DevTools Protocol,浏览器调试协议 |
| SVG stroke-dashoffset | 用虚线偏移做"描边画出"的动画 |
| Catmull-Rom | 由离散点生成平滑曲线的样条算法 |
| file:// | 双击本地 HTML 打开的协议(无服务器) |
第 20 课(本次增补的最后一课):数据分析实验的工程化。
教材是 生信入门 的课程 v2:4 个真实数据实验(序列/差异表达/富集/单细胞)、
可复现的数据下载校验(URL + sha256)、以及很巧的自动判分 + TODO 清单机制。
学完你就知道"教别人做数据分析"和"自己会做"之间的差距在哪。