静态前端应用交付流水线
把「一个跑在浏览器里的静态页面」变成「用户能直接双击打开、老师能拿去上课、客户能离线安装」的成品。
适用范围:纯前端、零后端、零网络请求的应用 —— 教学课件、数据看板、演示工具、交互原型、离线表单、本地小游戏、可视化编辑器。
> <skill-dir> 指本 skill 所在目录,不是用户项目目录。
一、四条铁律(先记住,能省掉一半返工)
| # | 规则 | 为什么 |
|---|---|---|
| 1 | 零网络依赖:不引 CDN、不请求外部字体/图片/接口 | 用户可能在无网教室、内网、飞机上打开。一个 https://fonts.googleapis.com 就能让首屏白屏 3 秒 |
| 2 | 验证先于交付:任何打包动作之前必须跑无头验证,且 errors: [] |
打包一次 Android 要 30 秒~几分钟,白屏问题到用户手里才发现,代价是重新走一遍流水线 |
| 3 | 版本号单点同步:版本号散落在 4~6 个文件,改版本必须一次改全 | 用户装完发现「关于」页还写着旧版本,这种低级错误最伤信任 |
| 4 | 产物≠源码:源码留着,构建缓存删掉 | 见过太多工作区被 node_modules + build/ 撑到 1GB 以上 |
二、工作流(按顺序走)
阶段 1 · 搭骨架
目录约定(相对路径引用,才能 file:// 双击打开):
app/
├── index.html # 单页,所有视图都在里面,用 .hidden 切换
├── assets/css/style.css
├── assets/js/*.js # 按职责拆:渲染层 / 数据层 / 引擎 / UI 装配
└── assets/* # 图片、字体等静态资源(一律本地)
要点:
- 单页多视图:#home / #app / #player ... 全在 index.html 里,JS 用 classList.toggle('hidden') 切换 —— 避免多页面带来的相对路径地狱。
- IIFE 挂载全局:每个 js 文件 (function(global){ ... global.Xxx = Xxx })(window),不用打包器也能按依赖顺序 <script> 引入。
- 脚本顺序即依赖顺序:渲染层 → 特效 → 引擎 → 数据 → UI 装配,UI 放最后(它负责初始化一切)。
阶段 2 · 写渲染层(若涉及 Canvas)
这条最容易写出 bug,务必先读 → docs/canvas-engineering.md
核心模式一句话:每个可绘制对象在绘制时把自己的几何写回 obj._geo,供特效定位、命中测试、吸附连接复用。别在别处重复算第二遍坐标。
阶段 3 · 无头验证(必做,不可跳过)
python3 <skill-dir>/scripts/verify_headless.py \
--dir /path/to/app \
--config <skill-dir>/scripts/verify.example.json \
--port 8171
脚本会:起本地静态服务 → 用 Playwright 打开 → 捕获 console.error / pageerror → 跑配置里的断言 → 对每个 <canvas> 做墨迹率探测 → 输出 JSON 报告。
墨迹率 = 画布上「非背景像素」占比。这是看不到截图时判断画面是否真的画出了东西的关键手段(详见 docs/headless-verification.md)。
报告判读:
- errors 非空 → 先修 JS 报错,别往下走
- 某 canvas 墨迹率 < 1% → 基本是空白帧(画错了 / 尺寸为 0 / 视图隐藏)
- 断言失败 → 按名字定位
阶段 4 · 三端打包
# 网页源码包(最小,最通用)
bash <skill-dir>/scripts/pack_web.sh /path/to/app /out/App-v1.0-web.zip
# Windows 免安装 EXE(nw.js)
bash <skill-dir>/scripts/pack_win.sh /path/to/app /out/App-v1.0-win.zip 我的应用v1.0.exe
# Android APK(Capacitor)
bash <skill-dir>/scripts/pack_apk.sh /path/to/app /out/App-v1.0.apk 我的应用v1.0 1 1.0
打包前先做「版本同步 + 清理」检查 → docs/packaging.md
三、版本同步清单(改版本号时逐项核对)
| 文件 | 字段 |
|---|---|
index.html |
<title>、页面里显示的版本文案 |
README / 说明文档 |
标题、文件名引用、末尾版本行 |
capacitor.config.json |
appName |
android/app/build.gradle |
versionCode(整数递增)、versionName |
| 应用内「关于」 | 若有 |
> versionCode 是整数且只能递增,Android 靠它判断新旧 —— 1.0→1.1 时别写成 11。
四、交付前最后 5 分钟
# 1. 确认三个产物完整
unzip -t 产物.zip | tail -3 # 应输出 "No errors detected"
# 2. 清掉构建缓存(Android 项目尤其大)
rm -rf capacitor-app/node_modules capacitor-app/android/{app/build,build,.gradle}
# 3. 删掉上一版本产物,避免用户下错文件
# 4. 起预览服务,给用户一个能立刻点开的链接
cd app && python3 -m http.server 8171 &
# 5. present_files 里第一个放预览 URL,其余放产物
五、踩过的坑(快速索引)
完整版见 docs/pitfalls.md,这里列最高频的 5 个:
- 全局主题被最后一个 rAF 偷走 —— 两个 Canvas 循环共用全局
THEME。修法:谁可见谁每帧设置自己的主题,隐藏的循环直接return(用canvas.offsetParent === null判断)。 - 类型别名导致静默不可见 —— 数据里写
dish,渲染层只有evaporating,结果什么都不画还不报错。修法:渲染入口做 ALIAS 映射,并对未知类型console.warn。 - 负高度矩形 / 非有限坐标 ——
ctx.rect(x,y,w,-h)与createLinearGradient(NaN)后者会直接抛异常导致页面卡死。修法:几何计算后统一做isFinite防御。 - 上下翻转后命中框失效 ——
top > bot时y >= top && y <= bot恒假。修法:比较前一律Math.min/max。 - 移动端 flex 抢高度 —— 画布被挤成 200px。修法:给画布容器显式
flex:0 0 auto+height:56.25vw; max-height:52vh。
文档索引
| 文档 | 什么时候读 |
|---|---|
docs/canvas-engineering.md |
要写 Canvas/SVG 渲染层、要做拖拽/命中/吸附/动画 |
docs/headless-verification.md |
要自动验证页面;模型看不到截图时如何判断画面正确 |
docs/packaging.md |
要产出 Windows EXE / Android APK / 网页包 |
docs/pitfalls.md |
遇到诡异 bug 时按症状反查 |
| 脚本 | 作用 |
|---|---|
scripts/verify_headless.py |
通用无头验证 + Canvas 墨迹率探测 |
scripts/verify.example.json |
验证配置示例,照着改 |
scripts/pack_web.sh |
打包网页源码 zip |
scripts/pack_win.sh |
nw.js 打包 Windows 免安装 EXE |
scripts/pack_apk.sh |
Capacitor + Gradle 打包 Android APK |