INP实战:单文件HTML全量重绘拖到226ms
2026 年最难过的前端指标是 INP,≤200ms 才算良好。我把 79KB 单文件 HTML 工作台拆开实测:一次勾选就整包写数组、全量重建 7 个模块,1000 条时 JS 侧 226ms,掉出良好区间。本文给出 DOM 节点账、写放大倍数与七源串行改并发的实测对比。
一、先看结果:一份 79KB 单文件的 INP 体检表
项目是「星露工作台」:stardew-workspace.html 一个文件装下任务、记账、习惯、日历、热点资讯、基金六个模块,数据全在浏览器 localStorage,双击就能开,不需要 npm、不需要服务器。之前那篇文章讲的是它的架构分层、连续打卡算法、中文金额解析和手写 SVG 图表;这次只盯一件事——它的交互到底要付多少主线程代价。
我把内联脚本原样抽出来,配一套假 DOM 在 Node 里跑起来(方法见第四节),得到下面这张体检表。所有数字都是脚本跑出来的,不是估的。
| 体检项 | 实测值 | 说明 |
|---|---|---|
| 文件体积 | 80846 B(78.95 KiB) | 1308 行,1 个 <script>、1 个 <style> |
| gzip 后 | 22018 B(21.50 KiB) | 压缩比 3.67x,省 72.8% |
| 外部 JS/CSS 请求 | 0 个 | <link> 0 个,带 src 的 script 0 个 |
| 内联函数 | 66 个 | 8 个 localStorage key |
一次 renderAll() 的 DOM 节点 | 54552 个(N=1000) | 18635 次 createElement + 35917 个 innerHTML 标签 |
| 一次点击的 JS 侧耗时 | 226.16 ms(N=1000) | save 0.45ms + 全量重绘 225.70ms |
| 日历视图的 filter 次数 | 95 次 / 次重绘 | 当月 30 格 ×3 = 90 次全表扫描 |
| 七源全部失败的最坏等待 | 59.7 s | 5×8000ms + 2×7000ms = 54s 常数累加,实测含调度误差 |
结论有点反直觉:零构建、零外部请求、79KB 的小页面,数据量一上来照样能把 INP 拖垮。因为 INP 考的不是首屏下载多快,而是「用户点下去到屏幕给出下一帧」之间主线程被占了多久。这个页面的每一次交互,都在主线程上做两件重活:同步写整个数组,然后把所有模块从头重建一遍。
二、技术选型:单文件 + localStorage 的账要算两面
单文件方案的爽点很明确,但爽点的背面就是性能债。先把选型摆清楚,后面讲优化才不会变成「早该用框架」的空话。
| 维度 | 本项目的选择 | 常见替代 | 换来的好处 | 欠下的债 |
|---|---|---|---|---|
| 交付形态 | 单个 HTML,0 构建 | Vite + React SPA | 双击即开、无工具链 | 无代码分割,66 个函数全在一块 script 里 |
| 传输 | 79KiB → gzip 21.5KiB | 多 chunk + CDN | 0 次额外握手,首屏无瀑布流 | 改一行就整包失效,缓存粒度粗 |
| 存储 | localStorage 同步 API | IndexedDB 异步 API | 代码只要两行 | 主线程阻塞、5MiB 配额、只能存字符串 |
| 渲染 | 原生 DOM 全量重建 | 虚拟 DOM diff / 增量 patch | 无框架、无依赖 | O(N) 重建,没有 diff,没有复用 |
| 数据源 | 7 个公开源串行降级 | 一个后端聚合接口 | 不养服务器、不配密钥 | 最坏串行等 54 秒 |
这张表里最该划重点的是「存储」和「渲染」两行:localStorage 的 setItem 是同步的,全量重建又是同步的,两者叠在同一个点击事件里,主线程没有任何喘息机会,浏览器也没法在中间插一帧。这正是 2026 年 INP 优化指南里反复点名的「长任务(>50ms)+ 事件处理器里昂贵重渲染」组合。
三、核心原理:一次点击到底发生了什么
先把链路画出来。用户勾掉一条任务,代码走的是这样一条直线:
用户点击「完成」
↓
t.done = true // 只改了 1 个对象的 1 个字段
↓
save(K.tasks, tasks) // 同步:JSON.stringify(整个数组) + setItem
↓
renderAll() // 同步:7 个模块全部 innerHTML="" 再重建
├─ renderToday() ├─ renderTasks() ├─ renderLedger()
├─ renderHabits() ├─ renderNews() ├─ renderFund()
└─ 若日历可见 → renderCalendar()(30 格 ×3 次全表 filter)
↓
浏览器才有机会样式计算 / 布局 / 绘制 → 用户看到反馈
注意最后一行:INP 计的是从点击到「下一帧绘制」,而绘制排在这一整串同步 JS 之后。JS 跑得越久,用户等得越久。
3.1 save():只改 1 条,却把整个数组重写一遍
// 项目原文:所有写操作都走这两个函数,只有「整包」一条路径
function load(k,def){try{var v=localStorage.getItem(k);return v?JSON.parse(v):def;}catch(e){return def;}}
function save(k,v){localStorage.setItem(k,JSON.stringify(v));} // 同步阻塞,没有批量/增量
save 接收的是 tasks 这个数组本身,不是被改的那一条。于是勾 1 条任务,要 JSON.stringify 全部 N 条、再 setItem 写回全部 N 条的字节。实测单条任务约 118.9 B(按与源码示例同构的紧凑数据;换成示例里更长的中文标题时约 124 B),于是写放大倍数就等于 N:1000 条时一次点击要序列化并写入 124471 B,40000 条时是 5198671 B。
N(条) stringify字节 stringify耗时(5次中位)
100 12151 0.037 ms
1000 124471 0.405 ms
10000 1274671 5.452 ms
40000 5198671 22.771 ms
单看这几行,序列化耗时其实不算大头;真正贵的是后面那一整片 DOM 重建。但它有两个隐性代价:一是 setItem 同步写盘,数据量上去后浏览器要卡住;二是 localStorage 单域名 5MiB 配额由 8 个 key 共享,按 118.9 B/条算单 key 约 44094 条就到顶,而且 QuotaExceededError 在 save() 里没有任何 try/catch,写爆了会直接把点击处理器打断。
3.2 renderAll():7 个模块,一次全丢一次全建
// 项目原文:任何数据变更,最后几乎都调用它
function renderAll(){
renderToday(); renderTasks(); renderLedger(); renderHabits();
renderNews(); renderFund();
if(!document.getElementById("view-calendar").classList.contains("hidden")) renderCalendar();
}
// 每个 render 的第一句几乎都是「清空容器」,已有 DOM 全部丢弃
function renderTasks(){
var list=document.getElementById("taskList");
list.innerHTML=""; // 中文注释:这一句抹掉全部旧节点
var sorted=tasks.slice().sort(/* 按完成态/序号/优先级/截止日排序 */);
sorted.forEach(function(t){
var div=document.createElement("div"); // 每条记录重新 createElement
...
});
}
innerHTML="" 之后再按 sorted 逐条 createElement,意味着没有节点复用、没有 diff、没有 key。任务、记账、资讯三个列表都这么干,每个列表项内部又是 4~5 个子节点加上 innerHTML 拼出来的标签。所以 DOM 节点数随记录数线性暴涨:10 条时 221 次 createElement,1000 条时 18635 次,加上 innerHTML 里的 35917 个标签,合计 54552 个节点。
这里还有个容易被忽略的连带损失:容器被清空,用户正在编辑的输入框、列表滚动位置、键盘焦点全没了。源码把编辑态放在闭包变量 editingTaskId 里,重绘时会重新进入编辑分支,但输入框里已经敲进去却没保存的文字是找不回来的。
3.3 renderCalendar():30 个格子,每格扫三遍全表
// 项目原文:日历只给「当月日期格」算圆点,每格 3 次全表 filter
for(var d=1;d<=days;d++){
var ds=calY+"-"+pad(calM+1)+"-"+pad(d);
var td=tasks.filter(function(t){return t.due===ds&&!t.done;}).length;
var hd=habits.filter(function(h){return h.rec[ds];}).length;
var ld=ledger.filter(function(l){return l.d===ds;}).length;
// 中文注释:下面再根据 td/hd/ld 拼圆点,并 appendChild 到网格
}
这是典型的「按视图驱动查询」:为了给 30 个格子各画几个圆点,把 tasks、habits、ledger 三张表各扫 30 遍。2026-11 有 30 个当月格,就是 90 次全表扫描,再加 renderCalDetail 的 3 次和 renderLedger 里 monthKeys() 的 2 次,实测一次重绘共 95 次 filter 调用。
我用 Proxy 包住数组统计被扫过的元素数:N=1000 时,日历可见的一次 renderAll() 累计扫描 63096 个元素,而日历隐藏时只有 1003 个——切到日历页,交互成本直接多了 63 倍的数组遍历。复杂度是 O(30×N),但换个写法(先 groupBy 成 Map)就能降成 O(N)。
3.4 firstSuccess():七个源排队,前一个超时才轮到下一个
// 项目原文:用 reduce 把 7 个源串成一条 catch 接力链
function firstSuccess(tasks){
return tasks.reduce(function(p,task){
return p.catch(function(){return task();});
}, Promise.reject(new Error("start")));
}
// 5 个 fetch 源超时 8000ms,2 个 JSONP 源超时 7000ms
// 中文注释:最坏情况 5×8 + 2×7 = 54 秒才给出「获取失败」提示
资讯模块为了在 file:// 下也能拿到热榜,准备了 7 个源:三个 orz 百度热搜(直连 + 两个 CORS 代理)、两个 60s 每日资讯(两个代理)、两个 JSONP 兜底。参与降级的域名形如 news.orz.ai、60s-api.viki.moe、api.allorigins.win、api.codetabs.com、api.vvhan.com、api.oioweb.cn,这里只列域名,不贴完整地址。
firstSuccess 的语义是「前一个失败了才试下一个」,好处是省流量,坏处是等待时间会累加。更麻烦的是它挂在启动路径上:init 末尾 if(navigator.onLine) 就调用,切到资讯页时 lastNewsSync!==todayStr() 又会调一次。
四、实测方法:怎么把单文件里的 IIFE 原样跑起来
这类单文件页面没有模块、没有导出,内部函数全关在 IIFE 里。想测它,最省事的办法不是手抄复刻(容易抄走样),而是把 <script> 原文喂给 new Function,再注入一套假 DOM。
// 从单文件抽出内联脚本,注入观测钩子后原样执行(脚本节选)
const html = fs.readFileSync(FILE,'utf8').replace(/\r\n/g,'\n'); // 中文注释:先归一化换行
const src = html.slice(html.indexOf('<script>')+8, html.lastIndexOf('</script>'));
const pos = src.lastIndexOf('})();');
const hook = 'globalThis.__V__={renderAll:renderAll,save:save,firstSuccess:firstSuccess,...};';
const patched = src.slice(0,pos) + hook + '\n' + src.slice(pos);
new Function('window','document','localStorage','navigator','CTRL', patched)(...);
几个关键点:源码读入先做 \r\n→\n,否则后面任何含换行的字符串匹配都会失效;假 DOM 用 Proxy 做万能兜底,getElementById 返回可复用元素,innerHTML 的 setter 里统计标签数,createElement 计数;navigator.onLine 设成 false,关掉启动时那两条自动同步链;最后在 IIFE 结束前注入 __V__ 把内部函数暴露出来。这样跑的是项目原文,不是我复刻的近似版。
数组再用 Proxy 包一层,就能数出原文里 filter / slice / sort 被调用了多少次、累计扫过多少元素。计时统一取 3~5 次中位数,避开 Node 的 GC 抖动。完整脚本与日志留在项目的 tools/_measure_valley_perf.js 和 logs/_measure_valley_perf.txt。
4.1 DOM 节点账与耗时曲线
下表是**默认视图(日历隐藏)**下一次 renderAll() 的开销,计时取 3 次中位数:
| 每类记录数 N | createElement | innerHTML 标签 | 合计节点 | JS 耗时(3次中位) |
|---|---|---|---|---|
| 10 | 221 | 475 | 696 | 5.12 ms |
| 50 | 965 | 1907 | 2872 | 12.47 ms |
| 100 | 1895 | 3697 | 5592 | 28.48 ms |
| 300 | 5615 | 10857 | 16472 | 65.69 ms |
| 600 | 11195 | 21597 | 32792 | 125.23 ms |
| 1000 | 18635 | 35917 | 54552 | 221.55 ms |
getElementById 恒为 16 次,说明模块数量固定;随 N 线性增长的只有列表项。也就是说,只要记录数翻一倍,一次点击的 DOM 工作量就翻一倍——这正是全量重建最要命的地方。
4.2 对照 INP 阈值:从「良好」掉到「差」只差 4000 条
把 save() 和 renderAll() 合起来当作一次点击的主线程占用,对照 2026 年 Core Web Vitals 的 INP 分档(≤200ms 良好,200~500ms 需改进,>500ms 差)。这一组切到日历可见,且是独立跑的 5 次中位数,与 4.1 视图不同、实验独立,所以同为 N=1000 时数值略有出入:
| N | save | renderAll | 合计 | INP 判定 |
|---|---|---|---|---|
| 100 | 0.04 ms | 23.84 ms | 23.88 ms | 良好 |
| 300 | 0.10 ms | 67.67 ms | 67.78 ms | 良好 |
| 600 | 0.21 ms | 132.00 ms | 132.21 ms | 良好 |
| 1000 | 0.45 ms | 225.70 ms | 226.16 ms | 需改进 |
| 2000 | 1.04 ms | 520.07 ms | 521.11 ms | 差 |
| 4000 | 2.19 ms | 843.71 ms | 845.91 ms | 差 |
1000 条时 226ms 刚好越过 200ms 那条线,2000 条就 521ms 直接进「差」。而这一切还只是JS 侧的合成耗时——浏览器还要叠加样式计算、布局和绘制,中低端手机的真实 INP 只会更高。换句话说,这张表是乐观下界。
4.3 七源串行 vs 并发:6.75 倍的差距
因为真实等待 54 秒太久,脚本把源码里的超时常数按 100 倍缩放(8000ms→80ms、7000ms→70ms)后真跑,比例不变,再换算回原始量级。串行版直接调用项目 firstSuccess 原文,并发版用 Promise.any 取首个成功。
| 场景(缩放前等价秒) | 串行(项目原文) | 并发(Promise.any) |
|---|---|---|
| 7 源全部失败 | 59.7 s | 8.8 s |
| 第 2 个源才成功 | 8.8 s | 0.0 s |
| 第 3 个源才成功 | 17.8 s | 0.0 s |
| 第 4 个源才成功 | 25.8 s | 0.0 s |
| 第 5 个源才成功 | 34.8 s | 0.0 s |
| 第 6 个源才成功 | 43.2 s | 0.0 s |
| 第 7 个源才成功 | 51.8 s | 0.0 s |
串行 / 并发 = 6.75x,与理论值(54s / 8s = 6.75x)完全吻合。第二行往下更说明问题:只要有一个源能成功,并发几乎立刻返回,而串行必须先把前面所有失败源的超时一个一个熬完。实测 59.7s 高于理论 54s,是 Node 定时器调度误差,已如实标注。
五、踩坑记录
- 坑 1:一次点击全量重绘 7 个模块。
renderAll()里多个列表型 render 都先innerHTML="",N=1000 时一次点击重建 54552 个节点。表现是「勾个任务,整个页面闪一下」,而且输入框、滚动位置、焦点全丢。改法:只重绘受影响模块,或加一层轻量 diff。 - 坑 2:
save()的写放大等于 N。 改 1 条要 stringify + setItem 整个数组,1000 条时写 124471 B;8 个 key 共享 5MiB 配额,且setItem没有 try/catch,写爆配额会直接抛异常打断点击处理器。改法:换 IndexedDB 做单条 put,或至少给save()加配额兜底。 - 坑 3:日历的 O(30×N) 全表扫描。 30 个当月格各做 3 次 filter,实测一次重绘 95 次 filter、N=1000 时扫 63096 个元素,而日历隐藏时只扫 1003 个,差 63 倍。改法:先按日期 groupBy 建 Map,格子查表 O(1)。
- 坑 4:七源串行降级最坏 54 秒。 5×8000ms + 2×7000ms 的超时会累加,且
syncNews()挂在启动和切页两处。网络差时用户盯着「正在获取今日实时热点…」能等近一分钟。改法:Promise.any并发竞速,最坏 8.8 秒。 - 坑 5:同一页面两套重绘粒度,排查时容易误判。 删一条资讯只调
renderNews()(局部重绘,很快),勾一条任务却调renderAll()(全量重绘,很慢)。同是「改一条数据」,体感差别巨大,我一开始还以为是资讯列表数据少,其实是调用路径不同。改法:统一收敛到一个patch(changedKeys)入口。 - 坑 6:同步链没有 in-flight 守卫。 「刷新热点」按钮直接绑
syncNews,连点几次就并行起几条串行链;每条链成功时都执行save(K.news)与renderNews(),且applyNewsFromArray用arr.slice(0,22)整体替换news,用户手动加的资讯会被冲掉。改法:加一个syncing标志,并把新数据合并而不是覆盖。
六、优化方案对比评测
把上面六个坑对应到可落地的改造,收益和代价一起列,方便按自己的数据量取舍:
| 改造 | 具体动作 | 实测/推算收益 | 代价 |
|---|---|---|---|
| 模块级重绘 | renderAll() → 只调 2 个 render | DOM 节点约降到 1/7 | 要维护「数据 key → 模块」依赖表 |
| 日历预索引 | 一次 groupBy 建 Map,格子查表 | 95 次 filter → 1 次 O(N) | 多一份 Map 常驻内存 |
| 并发竞速 | firstSuccess → Promise.any | 59.7 s → 8.8 s(6.75x) | 7 个请求同时发出,瞬时流量变大 |
| 增量写入 | 整包 stringify → IndexedDB 单条 put | 写放大 N → 1,且不再阻塞主线程 | 全链路要改异步,代码量翻倍 |
| 长列表虚拟滚动 | 只渲染视口内卡片 | DOM 节点与 N 解耦 | 需要固定行高、处理滚动锚定 |
| 长任务切分 | scheduler.yield() / requestIdleCallback | 让浏览器有机会插帧 | 只改善体感,不减少总工作量 |
对这类「个人工具、数据量大不了几千条」的页面,我的取舍是:先做前三项,改动都在几十行以内,收益最明显;IndexedDB 和虚拟滚动留到真的攒到上万条再说。至于 scheduler.yield(),它解决的是「让点击后先给个视觉反馈」,属于兜底手段,不能替代减少工作量本身。
七、小结与下一步
单文件 HTML 的优势是实打实的:79KiB、gzip 21.5KiB、0 外部请求、双击即开。但「没有框架」不等于「没有代价」——框架替你做的 diff、批量更新、异步调度,在这个项目里全部变成了开发者自己要付的同步主线程时间。
- INP 考的是交互到下一帧,不是首屏下载;零请求的小页面一样能栽在长任务上。
- 全量重建让 DOM 节点数随记录数线性增长,1000 条时 54552 个节点、226ms JS 耗时。
- 同步 localStorage 的写放大等于 N,5MiB 配额还要 8 个 key 分。
- 串行降级把 7 个超时累加成 54 秒,改并发就能压到 8 秒。
- 最省事的测法是把 IIFE 原文喂给
new Function+ 假 DOM,别手抄复刻。 - 优化优先级:减工作量 > 让出主线程 > 换存储引擎。
下一步打算把「模块级重绘 + 日历预索引」两个改动真正合进文件,再跑一遍同一套脚本,看 1000 条时能不能把 226ms 压回 200ms 以内;如果可以,再顺手把七源改成并发竞速,凑成一篇「改造前后对比」。
八、源码
完整工程已整理好(单文件即可运行,含一键启动说明与踩坑记录),需要对照着改的同学在评论区扣「源码」,我看到会一一回复;也欢迎关注我,后续把这个单文件工作台的优化系列继续更下去,可商用、可二次开发,接私活时也用得上。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/Dai_sir_man/article/details/170366145



