只睡四小时头像

INP实战:单文件HTML全量重绘拖到226ms

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 s5×8000ms + 2×7000ms = 54s 常数累加,实测含调度误差

结论有点反直觉:零构建、零外部请求、79KB 的小页面,数据量一上来照样能把 INP 拖垮。因为 INP 考的不是首屏下载多快,而是「用户点下去到屏幕给出下一帧」之间主线程被占了多久。这个页面的每一次交互,都在主线程上做两件重活:同步写整个数组,然后把所有模块从头重建一遍。

二、技术选型:单文件 + localStorage 的账要算两面

单文件方案的爽点很明确,但爽点的背面就是性能债。先把选型摆清楚,后面讲优化才不会变成「早该用框架」的空话。

维度本项目的选择常见替代换来的好处欠下的债
交付形态单个 HTML,0 构建Vite + React SPA双击即开、无工具链无代码分割,66 个函数全在一块 script 里
传输79KiB → gzip 21.5KiB多 chunk + CDN0 次额外握手,首屏无瀑布流改一行就整包失效,缓存粒度粗
存储localStorage 同步 APIIndexedDB 异步 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 次中位数:

每类记录数 NcreateElementinnerHTML 标签合计节点JS 耗时(3次中位)
102214756965.12 ms
509651907287212.47 ms
10018953697559228.48 ms
3005615108571647265.69 ms
600111952159732792125.23 ms
1000186353591754552221.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 时数值略有出入:

NsaverenderAll合计INP 判定
1000.04 ms23.84 ms23.88 ms良好
3000.10 ms67.67 ms67.78 ms良好
6000.21 ms132.00 ms132.21 ms良好
10000.45 ms225.70 ms226.16 ms需改进
20001.04 ms520.07 ms521.11 ms差
40002.19 ms843.71 ms845.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 s8.8 s
第 2 个源才成功8.8 s0.0 s
第 3 个源才成功17.8 s0.0 s
第 4 个源才成功25.8 s0.0 s
第 5 个源才成功34.8 s0.0 s
第 6 个源才成功43.2 s0.0 s
第 7 个源才成功51.8 s0.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 个 renderDOM 节点约降到 1/7要维护「数据 key → 模块」依赖表
日历预索引一次 groupBy 建 Map,格子查表95 次 filter → 1 次 O(N)多一份 Map 常驻内存
并发竞速firstSuccess → Promise.any59.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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--