JavaScript 究竟怎么跑

那天深夜,我把一段看上去平平无奇的订单状态更新函数丢进预生产,结果线上 CPU 一路飙红、页面直接卡死。 事后回溯,我们才意识到:看似同步几毫秒就能跑完 的代码,在事件循环的显微镜下其实是一条彻底堵住主线程的巨蟒。

下面我就用最贴近业务的例子,带你走完这段"JavaScript 究竟怎么跑"的完整流水线,并给出我们后来能秒级止血的三板斧。


问题场景:一条把 10 万条订单一次性算完的"自杀"代码

js 复制代码
// 🔍 这段循环在测试环境只有 200 条订单,表现良好
export function bulkCalcFee(orders) {
  return orders.map(o => {             // ①
    let fee = 0;
    for (let sku of o.skus) {          // ②
      fee += sku.price * sku.qty * complexTax(sku.category);
    }
    return { id: o.id, fee };
  });
}
  • 行①:Array.map 是同步的,它会把 10 万次complexTax 一股脑压进调用栈 ,主线程寸步难行[1]。
  • 行②:complexTax() 本身又包含 4 层if-else,栈帧越叠越高,堆内存跟着膨胀。

解决方案:把重计算切成"宏任务"+"空闲切片"

我们做的第一件事,就是把每 256 条订单拆成一个 chunk,然后借助浏览器/Node 里的事件循环让出控制权。

js 复制代码
// 🔍 chunkSize 根据经验值在浏览器里 16~256 效果最佳
export async function bulkCalcFeeAsync(orders, chunkSize = 256) {
  const results = [];
  for (let i = 0; i < orders.length; i += chunkSize) {
    const slice = orders.slice(i, i + chunkSize);
    results.push(...slice.map(calcSingle));   // 同步计算一小块
    await new Promise(setTimeout);            // 把余下推到下一轮宏任务
  }
  return results;
}
  • 解释第 8 行:await new Promise(setTimeout) 不是"延迟 0 ms",而是在宏任务队列里插入一个空任务 ,让浏览器/Node 有机会去清空微任务、更新 UI,随后再拉取下一轮 chunk,从而把一次 200 ms 的卡顿拆成 50 次 4 ms 的微抖动

原理剖析:同步海啸→宏任务大坝→微任务分洪

把上面的修复逻辑画成文字版时序图,就能看清事件循环的三层闸门:

lua 复制代码
        ┌-----------┐
同步代码 │ calcSingle│
一次性    │ (4 ms)    │
跑完      └-----┬-----┘
                ▼      <-- 调用栈瞬间拉满
        ┌---------------┐
宏任务    │ setTimeout(fn,0)│
缓冲      └-----┬-----------┘
                ▼      <-- 让出主线程,界面可滑动
        ┌---------------┐
微任务    │ 更新进度条    │
插队      └-----┬-----------┘
                ▼
        浏览器重渲染
  • 微任务队列总是在当前宏任务末尾一次性清空 ,因此进度条动画不会因为多次宏任务而闪烁;这也是我们把进度回调 放到 queueMicrotask 而不直接丢 setTimeout 的原因。

应用扩展:三种可落地的"任务分片"战术

场景 战术 关键 API / 配置
浏览器端列表渲染 切片 + requestIdleCallback await new Promise(requestIdleCallback)
Node 端批量查询 使用 Node 自带 setImmediate setImmediate(() => nextChunk())setTimeout 少一次 timer 阶段
Web Worker 大计算 真·多线程 new Worker('./heavy.js')整块计算挪走,主线程 0 ms

举一反三:三个变体,照着就能抄

  1. 搜索联想 每次用户按键盘就触发 200 ms 的拼音匹配? → 用 AbortController 在上一个宏任务未完成时直接取消,减少重复计算。

  2. Excel-like 表格公式自动重算 公式链可能深度 20 层? → 把依赖拓扑转成 DAG,每个节点用微任务更新,让 UI 先刷出绿色"计算中"图标。

  3. Canvas 实时渲染热力图 10 万个坐标点? → ImageData 拆成 1 k 点一组,利用 requestAnimationFrame浏览器每次重绘前只刷一部分像素,肉眼根本看不出分段。


最后我们给那段"肇事函数"加了道保险:在编译阶段接入 types/bundlesize 把循环次数写进类型注解,一旦测试用例出现 >5000 条数据直接 Type Error,提前把性能雷埋掉------这比上线后回滚便宜太多

JavaScript 不是"跑得慢",而是"把异步写成同步就会堵"。事件循环把一切都准备好了,缺的是我们给主线程留的那口喘息。

相关推荐
Nan_Shu_61411 小时前
学习: Threejs (2)
前端·javascript·学习
G_G#11 小时前
纯前端js插件实现同一浏览器控制只允许打开一个标签,处理session变更问题
前端·javascript·浏览器标签页通信·只允许一个标签页
@大迁世界12 小时前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路12 小时前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug12 小时前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213812 小时前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中12 小时前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路12 小时前
GDAL 实现矢量合并
前端
hxjhnct12 小时前
React useContext的缺陷
前端·react.js·前端框架
冰暮流星12 小时前
javascript逻辑运算符
开发语言·javascript·ecmascript