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

相关推荐
Moonbit8 小时前
MoonBit 再次走进清华:张宏波受邀参加「思源计划」与「程序设计训练课」
前端·后端·编程语言
RestCloud8 小时前
一站式数据集成:iPaaS 如何让开发者和业务人员都满意?
前端·后端·架构
li35749 小时前
React 核心 Hook 与冷门技巧:useReducer、useEffect、useRef 及 is 属性全解析
前端·javascript·react.js
菜市口的跳脚长颌9 小时前
Web3 基础
前端
快乐是Happy9 小时前
分享一个非常实用的防止重复提交操作
前端·javascript
王蛋1119 小时前
前端工作问题或知识记录
前端·npm·node.js
可子是我的小猫9 小时前
【JS】模块(二)
javascript
云枫晖9 小时前
JS核心知识-执行上下文
前端·javascript
麦当_9 小时前
TanStack Router File-Based Router Mask 完全指南
前端·javascript·设计模式
普通码农9 小时前
解决 Element Plus 分页组件英文显示问题
前端