前端卡顿优化:别让 Service Worker、Web Worker 和 React 各干错活
页面已经打开却仍然卡,问题通常不在下载体积。用 Service Worker 减少重复读取、Web Worker 隔离重计算,再把 React 重渲染收紧到真正变化的叶子组件。
客户端体积降下来以后,页面不一定就顺了。
代码可以很快下载完,但拖动图表时还是掉帧;输入框可以立即显示,但旁边一个弹窗的草稿变化却让整张图重新渲染;历史数据已经读过,切回来又做了一遍解密和整理。
这些问题都叫“卡”,原因却不一样。
在 60Hz 的屏幕上,浏览器大约每 16.7 毫秒就要准备一帧。JavaScript 计算、React 提交、布局、绘制和用户输入都要从这点时间里分。如果主线程连续忙上几十甚至几百毫秒,包再小,操作也会像隔着一层胶水。
最近整理市场详情页时,我最后保留了一条很简单的判断:先找出是谁占住了主线程,再把重复读取、重计算和无关渲染分别交给正确的边界。
先分清三种看起来一样的卡
不要一看到卡顿就加 memo,也不要把所有东西都丢进 Worker。先看时间到底花在哪里。
| 卡顿来源 | 更合适的处理位置 | 解决的问题 |
|---|---|---|
| 同一份静态资源和历史数据反复读取 | Service Worker | 减少重复下载、磁盘与网络往返 |
| 技术指标、回放等连续 CPU 计算 | Web Worker | 不让计算长时间占住页面主线程 |
| 无关状态变化带动大组件更新 | React 组件与 Store 订阅边界 | 缩小重渲染和原生图表同步范围 |
| 实时数据只变了最后一点,却重建全部内容 | 图表增量更新边界 | 避免把小变化做成全量工作 |
这里最容易混淆的是 Service Worker 和 Web Worker。名字很像,工作完全不同。
Service Worker 更像浏览器和网络之间的一间中转仓库。它适合缓存、离线恢复和后台资源处理。Web Worker 更像另一张计算桌,适合执行不需要直接操作 DOM 的重计算。
让中转仓库去算 24,000 根 K 线的指标,不会更专业,只会把职责放错地方。
Service Worker 先消灭重复读取
我们的 Service Worker 会复用带内容哈希的 JavaScript、CSS、字体和 Worker 文件。一个文件的内容没变,它的地址也不会变。用户第一次成功加载后,后续路由和交互所需的懒加载代码可以直接命中缓存。
公开图片走独立缓存。只有满足公开路径、正确图片类型、成功响应和无私有缓存标记等条件的资源才能进入。登录后的 HTML、业务 Action、私有附件和用户数据不会因为“加速”两个字就被塞进 Cache Storage。
市场历史数据又是另一条边界。已经确认收盘的 K 线会加密后存进 IndexedDB,Service Worker 通过 MessageChannel 处理读取、加密和覆盖范围判断。页面拿到的是一个可丢弃的浏览器投影,权威数据仍在服务端。
这个缓存没有资格挡住首屏:
- 最新一页始终向服务端读取;
- 首次内容不会等待 Service Worker 安装或激活;
- Worker、IndexedDB、密钥引导或两秒通信超时失败时,直接绕过缓存走正常网络;
- 页面销毁或请求过期后,迟到的缓存结果不能写回新状态。
这条设计比“缓存一切”更重要。缓存应该是加速器,不应该成为页面能否工作的前提。
Service Worker 解决的是重复 I/O。它能让回访少下载一些,让读过的历史少走一些远路,但它不能替代首屏体积预算,也不能保证第一次访问更快。浏览器清理站点数据以后,缓存照样会消失。
Web Worker 把真正的重计算移出主线程
技术指标计算更适合 Web Worker,因为它本质上是纯 CPU 工作。
我们用合成数据做过一组可复现测试。23 类指标处理 2,400 根 K 线时,中位数约 30 毫秒;扩大到 24,000 根时,中位数约 323 毫秒,最慢样本约 559 毫秒。这是纯 CPU 时间,不是线上交互延迟,但已经足以说明它不该和输入、滚动、拖拽共享主线程。
把函数包进 Worker 还不够。用户可能连续切周期、改参数或加载更早历史。如果每次变化都排队完整计算,页面虽然不被直接堵住,结果却会越来越晚。
我们的计算控制器只保留两件工作:一个正在执行,一个最新待执行。中间已经过时的请求会被新请求覆盖。每次计算都有递增的 generation,返回结果只有同时满足“仍是最新请求”和“组件仍然存活”才能进入页面。
这解决了三个常见问题:
- 旧周期的结果不会盖住新周期。
- 快速连续修改不会积出一条长队。
- 组件卸载时会终止 Worker,不留下后台计算。
主线程还会保留页面上下文,Worker 只返回指标结果,不把整组 K 线再复制回来。颜色和线型也不进入计算指纹,所以只改外观时,不会重新跑一遍公式。切换语言同样不会重建 Worker、重新提交计算或全量刷新蜡烛数据。
Web Worker 不是免费的。postMessage 仍要序列化或复制数据,Worker 也有创建和回收成本。几百微秒的工作不值得来回搬家;几十到几百毫秒、可以独立输入输出的纯计算,才是更合适的对象。
顺便说一句,WASM 也不是自动加速按钮。算法本身、数据搬运和剩余耗时没有测清楚之前,换一种执行格式只会增加新的复杂度。
组件优化先收紧订阅,再考虑 memo
很多 React 卡顿不是组件本身太慢,而是它收到了太多与自己无关的变化。
如果一个页面直接订阅整棵 Store,输入框多敲一个字,图表、侧栏和文档区都可能跟着收到更新。随后再给每个组件套 memo,是在渲染发生以后补救,状态边界仍然太宽。
我们先把订阅放到真正使用数据的叶子组件:单个字段用单字段 selector,多个字段用浅比较组合。图表、提醒弹窗、指标弹窗、Review 和文档正文分别订阅自己的切片。大组件用 memo 隔离,但前提是传入的数组、对象和回调保持稳定。
这里有几个很小、却很容易漏掉的细节:
- 默认空数组要用稳定常量,不能每次渲染都创建新的
[]; - 派生数据只在语义输入变化时重算,外观变化不应该改公式输入;
- Effect 内需要最新属性的回调用稳定事件边界,不要因为闭包变化反复退订和重订;
- 普通事件回调只有在确实影响子组件身份时才用
useCallback,不是见函数就包。
我们没有只看代码“感觉应该少渲染”,而是挂载真实 React、Zustand、图表和指标 Worker 后直接计数:
| 操作 | 应该变化的区域 | 不应该变化的区域 | 实际结果 |
|---|---|---|---|
| 连续更新 20 次弹窗草稿 | 弹窗叶子 | 页面外壳、蜡烛图 | 外壳 0 次,图表 0 次 |
| 连续移动 100 次垂直游标 | 游标收益文本 | 直方图 | 文本 100 次,直方图 0 次 |
| 连续修改 20 次文档正文 | 正文 | 文档工作区外壳 | 正文 20 次,外壳 0 次 |
| 切换语言 | 文案 | Worker 与蜡烛数据 | 0 次重建、0 次计算、0 次全量写入 |
这组数字证明的是订阅隔离,不是所有设备上的最终延迟。但它比“加了 memo,应该快了”可靠得多。
数据变一点,就只更新一点
组件不重渲染以后,还要看组件内部是不是在做全量工作。
早期技术图表会把不同指标拆成多套渲染路径,还为每根 K 线建立额外的 SVG 与坐标投影。历史越长,DOM 和同步工作越多。后来统一到 Canvas 图表和原生 pane 后,2,000 根 K 线仍然只保留约 142 个固定 DOM 节点和 27 个 Canvas,节点数量不再跟数据行数一起增长。
实时行情通常只替换最后一根蜡烛,或者追加下一根。这个变化不需要对完整历史执行 setData。增量路径只对受影响的蜡烛和指标序列调用 update。在 2,000 行的合成场景里,17 个序列更新约用了 1.2 毫秒,没有发生一次全量 setData,DOM 数量也没变化。
加载更早历史确实需要结构更新,但更新后要恢复原来的逻辑视窗,不能让用户正在看的位置突然跳走。外观编辑则尽量复用已有 series 和 pane,不因为换个颜色就重建整张图。
优化到这里,memo 只是最后一道小门。真正省下来的工作来自更早的决定:不用每个数据点对应一个 DOM,不让局部变化走全量路径,也不让无关状态抵达图表。
卡顿优化要看三本账
我们现在把性能检查分成三类,不再用一个总耗时解释所有问题。
第一本是 CPU 账。固定输入规模、预热和样本数,分别记录中位数和较慢样本。它用来判断哪段计算应该留在主线程,哪段应该进入 Worker。
第二本是 渲染账。用真实组件和 Store 记录 React render、Worker 提交、图表 setData 与增量 update 次数。它能抓出“功能没变,但昂贵边界又被碰了一次”的回归。
第三本是 交互账。在浏览器里看 Long Task、输入响应、滚动和拖拽,同时确认没有错误、视窗跳动或清理遗漏。合成 CPU 速度不能替代这一层,渲染次数少也不等于用户一定感觉顺。
这三本账要分开记录。否则一次更快的本地函数测试,很容易被写成一个根本没有测过的线上体验结论。
最后不是“用了 Worker”,而是主线程少做了什么
前端卡顿没有一个通用开关。
Service Worker 适合消灭重复读取,让已经下载或处理过的资源继续复用。Web Worker 适合隔离经过测量的重计算,并且只接纳最新结果。React 的工作是把状态变化留在真正需要更新的叶子上。图表和编辑器这类重组件,还要继续采用固定节点和增量更新。
真正值得验收的不是技术名词有没有出现在代码里,而是这些问题:
- 主线程最长的一段工作缩短了吗?
- 一个局部输入还会不会惊动整张页面?
- 过时的后台任务会不会继续排队和回写?
- 缓存失效时,页面还能不能走正确路径?
- 同一个动作重复 20 次、100 次以后,昂贵组件到底渲染了几次?
页面顺滑,不是因为 Worker 越多越好。是因为每一份工作都去了它该去的地方,而用户正在操作的那条主线程,终于有空把下一帧画出来。