有个老同事转行之前跟我说过一句话,我到现在都记在心里:“写了三年代码以后,真正拉开距离的,不是你会不会用某个新框架,而是你敢不敢把那个黑色的盒子打开看看。”这句话放在很多技术人身上都成立。大家在社区里刷到的“进阶技巧”,往往是一段看起来很精妙的代码,或者一个别人不知道的 API 用法,但你能不能把这些技巧移植到自己的业务场景里,前提是你到底知不知道它底下那几层是怎么转的。我索性把这篇分享做成一个“进阶技巧与底层原理”的专题,拿 JavaScript 生态里最常见的一些例子,从排序的稳定性、事件循环的任务队列、内存回收的引用规则这几个角度,拆开给你看它们和日常工作里的那些“奇技淫巧”是怎么连起来的。如果你正处在写得出功能、但总感觉差一口气的阶段,这篇内容值得你泡杯茶慢慢读。
1. Array.sort 为什么有时候稳定、有时候不稳定:一个底层取舍引发的实战教训
1.1 现象:明明给了相同 id,列表却自己换了位置
先说一个最近真实踩过的坑。我维护的一个后台项目里有个表格,可以让用户按某个字段排序,后端返回的数据里偶尔会出现两条记录的 id 一样——这个 id 并不是数据库主键,而是从上游带过来的一种“分组标签”。前端排序用的是:
javascript复制list.sort((a, b) => a.group - b.group);
结果就出现了让我很困惑的问题:在本地开发环境下,相同 group 的记录次序跟原始数组保持一致;但用户报告说,在某些浏览器上,同样的数据排序后,相同 group 的内部次序会翻转。当时的第一反应是“是不是 comparator 写错了”,查了半天发现逻辑没有问题,最后只能把问题锁定到排序算法本身上。V8 在数组长度较短的时候走的是插入排序,长度达到一定阈值会切换成 TimSort。这个阈值在很长一段时间里是 10,但不同版本的 V8、不同构建设备上并不是铁板一块。JDK、Python、V8 各自的排序实现都不一样,稳定性的保证自然也不一样。规范里只规定了 Array.prototype.sort 是“稳定排序”,但这个稳定指的是“相等元素之间的相对顺序不改变”,V8 只要最后结果是稳定的,就符合规范。关键在于,如果我在某个内部比较时不小心用了一个 Math.random() 作为平局决胜器,这种“稳定”就直接被打破了;而在我的场景里,那个 group 相同的指令竟然包含了一些未被规约化的随机字段被我带进了比较逻辑里,才会偶发乱序。
1.2 打开底盒之后:一段排序源码告诉你的不只是排序
后来我把 V8 里 TimSort 的公开实现看了一遍,印象最深的是它的 Galloping 模式:当两个子序列合并时,引擎会猜测某一段连续胜出的元素数量,如果超过一个阈值,就直接跳到“跳跃模式”,一次性找出一大段符合条件的元素。这个设计的收益,是为了让已经接近有序的数据拥有接近 O(n) 的复杂度,而不是从头去比较。底层原理知道了,能延伸出什么进阶技巧?首先,在写自定义 Array.sort 的 comparator 时,要尽量让比较函数“廉价且无副作用”。因为底层为了性能会疯狂调用 comparator,一次调用如果做了昂贵的正则匹配或者深层展开对象,几万条数据的排序就可能把主线程卡出明显的白屏。比较好的做法是先把需要比较的字段抽成原始类型数组,再对索引数组排序,最后用这个索引重建数据顺序。这种做法其实就是“额外多花一点内存,换来 comparator 的极简”,它和 V8 对插入排序、归并排序的切换逻辑并无直接关系,但背后的原则是一样的:把耗散操作从最热路径上挪走。其次,如果你要给一个大规模前端表格做业务排序,就别指望每次用户点表头都直接排全量数组。既然你知道底层是“近似归并”的思路,那完全可以利用这个原理先对局部切片做一次近似有序,再合入之前排好的结果,很多复杂场景里这种按已有分组预处理的“脏”方式,反而比写一个完美的比较函数更抗造。
1.3 一个通用方法:从 API 变化反推引擎取舍
我还想分享一个分析框架:当你看到一个 API 的行为前后不一致时,不要急着骂 bug,试着问三个问题。第一,这个 API 是 ECMAScript规范规定的,还是引擎自由发挥的?如果是自由发挥,那稳定性、时间复杂性都可能随版本变化。第二,它是“更吃内存”还是“更吃 CPU”?V8 会选择在短数组上用插入排序,是因为插入排序在接近有序或有少量倒置时,实际比较次数往往少于快排,而且它的空间复杂度是 O(1),这对短数组非常友好。这个“小数据要稳,大数据要快”的思路,在很多库里都能看到,比如 React 的 key diff、Lodash 的 chunk 实现。第三,如果我写代码时依赖了这个 API 的某种内部行为,那有没有更声明式、更不容易受底层实现影响的写法?排序行为就是一个经典例子:规范保证稳定,那我就应该把相等元素的次序当作无意义;如果真的需要优先级次序,写一个显式的平局决胜 comparator 才是王道。一旦你养成了用这三问去审视 API 的习惯,像 sort 这种“黑盒”就不会再莫名其妙地咬你一口。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JavaScript 运行时的任务调度:事件循环里其实有两支队伍在抢着执行
2.1 一个把很多人都骗了的输出顺序题
以前有不少面试题喜欢考“setTimeout 和 Promise.resolve 谁先执行”,大部分人都能背出答案:“Promise 先”。但如果你多问一句,为什么 Promise 先?很少有人说清楚。我之前在重构一个异步数据恢复模块时,遇到了一个类似又不完全一样的“坑”:有一个流程需要在页面刷新后恢复本地缓存,同时还要发起一次服务端请求来比对版本。我当时的代码逻辑很简单:
javascript复制async function init() {
promiseA = restoreCache();
setTimeout(scheduleServerSync, 0);
await promiseA;
}
结果恢复缓存时向 store 写入了一批数据,而那批数据在 setTimeout 里同步读取时居然读不到。我把 setTimeout 改成 queueMicrotask 就恢复正常了。这件事让我彻底去把事件循环的细节刷了一遍。事件循环里不止有一个队列,而是要区分 task(宏任务) 和 microtask(微任务)。每一轮循环,引擎会从宏任务队列里取出一个任务执行,然后会把当轮产生的所有微任务清空,之后才可能去处理渲染或其他 I/O。setTimeout 的回调是一个宏任务,而 await 后面的代码是以微任务形式执行的,所以 await 之后写入 store 的时间点,一定会早于下一个宏任务里的同步读取。看起来是我把执行顺序搞反了。
2.2 微任务的“清空”规则是理解很多异步技巧的钥匙
有一个很关键但容易被忽略的词是“清空”。不是每次处理微任务只执行一个,而是要把当前微任务队列全部拉出来执行完。假如我在一个微任务里又新增了一个微任务,那它会在当前这一轮里继续被执行,理论上可能无限循环卡死。反过来,宏任务是一轮只执行一个。这个“全清”和“只执行一个”的不对称,完美解释了为什么很多库会用 Promise.resolve().then(...) 做“立刻执行但放到主流程之后”的操作,也能解释为什么被 Promise 包裹的递归调用可能比 setTimeout 递归要快得多。这个原理还能帮你解释一个真实的进阶场景:当你在一个被高频触发的事件回调里,又想合并很多次状态更新时,可以把更新逻辑放进 queueMicrotask 或 Promise 回调里,这样同一次事件循环里多个事件处理器触发时,只会在微任务阶段执行一次真正的渲染逻辑。这是一个很传统的“批处理合并”手法,底层原理就是“宏任务结束后会有一次清空微任务的机会”。
2.3 setTimeout(fn, 0) 不是“立即执行”,而是“下一个合适时机执行”
我还见到不少初级工程师把 setTimeout(fn, 0) 当作“立即执行”的代名词,实际上它只是告知运行时“请尽快把这个任务放到宏任务队列”。光是这一点区别,就能派生出不少技巧。比如需要把一段 CPU 密集型的计算拆成若干段,避免长时间占住主线程,你可以用 setTimeout(..., 0) 作为将控制权还给浏览器的时机。更聪明的做法是自己维护一个任务分片器:把一个大数组断成每批 50 个元素,处理完一批后 await new Promise(r => setTimeout(r, 0)),这样每批任务之间浏览器就有机会处理点击、滚动和绘制。换句话说,事件循环不仅是“后进先出还是先进先出”的问题,它是浏览器在你所有代码之间插入“呼吸”的位置。在 Node.js 里同样如此,setImmediate 和 process.nextTick 更有不一样的优先级,nextTick 会在每个宏任务结束、当前操作后立刻执行,优先级比 Promise 还高。多知道一层调度顺序,你就可以有意识地把一些需要“请求渲染完成后再做”的逻辑放到 requestAnimationFrame 里,把需要“同步完成”的批次处理放到 nextTick,而不是所有异步都“拍脑袋”塞进 Promise 里。
2.4 用事件循环读懂的三个业务层代码模式
底层原理一旦清楚了,你会发现在业务层能用它解决很多问题。第一个模式是“批量上报”:像埋点 SDK 之类,往往会在一段时间内收集一批事件,然后用微任务把这些事件合并成一个请求发出去,这就是利用了“事件循环的一个 tick 之后马上清空微任务”的特性。第二个模式是“状态统一提交”:比如拖拽过程中直接改数据源里的字段,但组件的 render 不要立刻执行,而是在微任务里统一通过一个 flush 方法去刷新,避免一个拖拽事件里渲染好几次。第三个模式是“时序防抖”:之前我用一个 version 数字来规避过期的异步回调:
javascript复制let currentRequestId = 0;
async function fetchVersion() {
const thisRequestId = ++currentRequestId;
const data = await request();
if (thisRequestId !== currentRequestId) return; // 丢弃过期结果
applyData(data);
}
这种方式本质上是在提前声明“这次异步回调应该在哪个任务顺序之后才有效”。如果没有对任务队列的清晰理解,你就很难解释为什么要在外面维护一个计数器,也很难在问题出现时想到用这个手段去防御。
3. 为什么防抖、节流和重绘优化都是同一个底层原理的统一应用
3.1 把“事件”重新理解成“任务流”,一切都会顺起来
很多人把防抖(debounce)和节流(throttle)当成两个背诵公式,知道“debounce 是回酒店洗澡要等水热,throttle 是开闸放水每秒只能一次”的生活类比。但你要真想在任何场景里灵活变体,光记公式不够,还得回到事件循环里去看它们到底做了什么。每一次用户滚动、鼠标移动、键盘输入,都会向宏任务队列里丢出很多任务。如果每个任务里都去调用那种触发布局、触发网络请求的昂贵操作,浏览器不一会就会因为抢不到空闲而卡顿。防抖和节流本质上是给“任务流”制造一个阀门:防抖是“只留最后一个任务”,节流是“每个固定时间窗口只放行一个任务”。两者都是基于任务队列的简化模型去做的调度策略。理解到这一层,你就会知道为什么不少团队已经有了一个 util 却仍然会在特定场景卡住——因为 util 只控制了你注册的那个回调,而没有控制回调内部触发的“重排重绘”等浏览器层面的任务。如果你能把 scroll 回调里的 getBoundingClientRect 提前到外侧一次性读取,然后配合节流把动画更新放行,那才是真正抓到了性能问题的绳子。
3.2 requestAnimationFrame 与 setInterval 的本质差异
另一个绕不开的原理点是 requestAnimationFrame(rAF)。它的回调不是由 setTimeout 那种固定延时决定的,而是由显示器的刷新率决定,通常在 60Hz 屏幕上每帧之前触发。它能保证的,是你在它里面修改 DOM 的时候,刚好赶在浏览器即将绘制的那一帧之前,所有修改可以合并到一次布局计算里。换句话说,rAF 的调度位置处在“事件循环的渲染前阶段”,而不是任意的宏任务队列。这个底层位置决定了它的威力:如果把一段会反复修改元素位置的代码放到 setInterval(..., 16.7) 里,你的精度会受主线程负载影响,而且可能在一个渲染周期里执行两次,造成无谓的重复布局。放到 rAF 里则是由浏览器来告诉你“我马上要画这一帧了,你要不要做点事”。很多 visual effect 的祖传坑,比如滚动动画的抖动,根本原因是任务的触发时机没有和对齐绘制挂钩。这个底层区别还可以推导出“如果页面切到后台去,rAF 会直接暂停”的特性,因为那时候没有新的帧产生;而 setInterval 仍然会傻乎乎地继续执行,浪费 CPU。这也是为什么很多为了省电、为了保持动画稳定的动画逻辑,推荐的是 rAF 而不是 setInterval。
3.3 更进一步:如何从自己的卡顿报告里反推出应该用 debounce 还是 rAF
很多性能优化文章的调调是“上来就推荐你用’防抖+节流+rAF’,按照这个标准模板改”,实际上最好的判断工具是你的 Performance 面板,同时要回到原理上分析。如果卡顿现象是“频率太高导致大量函数调用”,那可以用节流降低频率。如果卡顿现象是“每一次事件都会触发一次昂贵的布局计算,但用户只关心最后一次状态”,比如输入框实时搜索,那用防抖更合适。如果卡顿出现在“视觉滚动与 DOM 更新不同步”的地方,比如自定的平滑滚动,那就更适合 rAF。关键并不是背下来三类场景的对应关系,而是把任务队列与帧周期的图在脑子里画出来。比如你输入关键词“abcd”触发四次搜索请求,网络返回顺序可能不是 a、b、c、d,这时候防抖加计数都能解决竞态,但要谈到本质,还是任务先后顺序的问题。如果你能把一个交互里的所有动作拆成“产生事件、排队任务、渲染帧”三个层级,绝大多数优化方案都能直接推理出来,甚至不需要记任何“十条铁律”。
4. 作用域与内存:最不常被提起的底层原理,却最能拉开应用稳定性差距
4.1 闭包为什么能“记住”变量:一个作用域链表的故事
讨论完异步调度,我们再看另一个底层原理层——内存。很多年轻人经常写出的一个问题:在循环里创建闭包,回调里明明用的是期望的那个 i,结果所有回调拿到的都是同一个末值。这背后不是“闭包捕获值”,而是“闭包捕获变量引用”。在实现层面,通常会生成一个环境对象,负责保存当前作用域内的变量;闭包对应的函数对象会持有一个指向这个环境对象的引用。如果循环变量是 let,JavaScript 引擎一般会为每轮迭代单独创建一个环境记录;如果是 var,所有函数共享同一个环境对象,于是等到回调真正执行时,看到的自然是循环结束后的值。这就是为什么“使用 let 或者用 IIFE 包一层”能解决问题。掌握了闭包背后的环境记录模型,你其实可以推导出更多:闭包不是“克隆”了一份变量数据,而是变量一旦被你引用,那个变量的生命周期会被延长到闭包本身被回收为止。
4.2 从内存视角看两个容易忽略的坑:循环引用和隐式全局
我又想到一个项目里实际碰到的问题。当时写了一个数据上报队列,队列里每一项都是一个携带了回调函数、参数数据和 this 上下文的结构体。某个模块在销毁后没有清掉这个上报队列,导致队列里一直挂着已经完全不可达但又被闭包引用的巨型对象,页面存活越久,内存占用就越高。后来排查到根因上,发现表面上是一个“忘记清理”的问题,底层原理讲的其实是“垃圾回收器只看从根到对象的可达性”这一条规则。一个对象表面上没人用了,但只要你把它挂在数组里,或者某个仍然注册在全局事件上的回调闭包引用着它,它就不会被回收。想要真正进阶,就要学会“画引用图”。比如写一个组件时,在 destroy 方法里除了移除 DOM,还要移除监听器、清空 timers、释放被引用的第三方实例,甚至把超大数据字段置为 null 好让 v8 的标记清除可以更快地识别出死对象。除了循环引用,另一个人人都会犯的坑是误把变量挂到 window 上,形成一个隐式全局对象。这类 bug 内存大师看一眼就懂,因为从底层来说,只要全局持有引用,那它作为 GC 的根,就永远不会被清理。平时做 code review 时,遇到长生命周期的模块,我会额外查一遍“是谁还抱着这个对象不放手”,这句话说起来有点玄,但对应的就是内存分析工具里的 Retaining Tree。
4.3 WeakMap、WeakSet 的实际价值:从“我记不住要清理”到“让引擎替我自然清理”
很多前端同学不太用 WeakMap 和 WeakSet,理由是“Set 和 Map 足够了”。可如果你站在底层引用的角度,WeakMap 的卖点恰恰是“键的引用不阻止垃圾回收”。它适合的场景是,用某个对象作为 key 去缓存和它关联的额外信息,但不希望在对象销毁时,缓存本身还变成阻碍回收的“僵尸引用”。我最近在写一个组件实例的信息缓存,就是把每个组件实例当 key,value 里存它对应的虚拟节点和监听队列,然后整个缓存放在 WeakMap 里;组件销毁后,只要没有其他代码持有实例,引擎自然会在下一轮 GC 里把这条记录带走,我完全不需要写“从 map 中 remove”的额外逻辑。这当然也有代价:你不能遍历 WeakMap,永远不知道里面有多少条。想清楚这个原理,之后你看到很多源码里“用 WeakMap 存储私有字段”的写法时,就不会觉得是炫技,而是知道它就是为了不破坏对象生命周期。反过来,如果你试图用 WeakMap 保存一些基础类型,因为基础类型不是对象,引擎会直接报错甚至悄悄失败,这也是引用语义和值语义的区别。学会在合适位置选用弱引用容器,是从“写内存优化代码”迈向“写内存友好架构”的一个分水岭。
4.4 如何通过一次内存泄漏的复现找到自己代码里的根源
如果你真想亲身体会这种底层原理,我可以提供一个可复现的实验。先创建一个大数组或者一个 class 实例,然后把它注册给一个全局事件监听器。再把这个监听器移除掉,但保留注册函数里的闭包变量可被全局环境访问。用 DevTools 的 Memory 面板做一次 heap snapshot,你会发现即使你 delete 了引用,只要事件系统内部还遗留有监听器(例如没有用 AbortController 或 removeEventListener),快照里仍然能看到那个大对象,Retaining Path 会指向一个事件监听器列表。我用这个实验培训过组里的新人,建议他们遇到“页面越来越卡”的问题时,用 Memory 面板里 Snapshot 和 Allocation instrumentation on timeline 结合,主动找三个高水位点:打开页面、完成交互、销毁逻辑执行之后。如果销毁后对象数量没有回落到和初始接近,那多半就是还有引用链路没切断。实践后发现,这个排查方法比一遍遍读代码猜“谁没清定时器”要高效不少,因为工具直接告诉你垃圾回收器视角里的存活链路,让你从“人脑推原理”变成了“工具直接展示原理”。
5. 进阶之路上的底层学习法:三个递进的复盘方式帮你建立自己的知识映射
5.1 从“它怎么用”到“它为什么这样设计”:先做一次提问实验
讲完了排序、事件循环和内存这些具体的底层原理,我觉得更有义务给你一套可长期使用的方法。第一阶段是提问实验。不管学什么新 API 或新框架,试着问自己一个让很多人头疼的问题:这个 API 的参数为什么设计成这个样子?为什么某些场景会有这种限制?比如刚开始用 Array.reduce 时,很多人不知道它如果没有设置初始值,会把数组的第一个元素当作初始值并直接从第二个元素开始遍历,对空数组调用 reduce 还会直接抛错。设计这样一个初始值参数,本质上是为了和 Haskell 里 fold 的语义统一;知道这个语义以后,你就会明白为什么几乎所有人都建议“reduce 永远给初始值”。这类问题每一个都能成为一个挖掘底层原理的入口。如果你对某个工具只能说出“它是干嘛的”,大概说明你还没有穿过那层“黑盒玻璃”。
5.2 从“我会写这个功能”到“我能预测这个功能的边界”:做一次压测与极端输入实验
第二个阶段是边界实验。记得我很早学 async/await 的时候,只会写那种串行往下走的数据流,但没想过它和 Promise.all 到底差在哪。于是我写了一个小脚本,连续 1 万次循环里 await 一个立即 resolve 的 Promise,测出性能和普通同步循环差多少,以及把循环换成 for await 去迭代一个异步生成器时又有什么变化。这类实验不会告诉你某个具体框架的魔法,却能强迫你去验证那些你其实还不太确定的底层假设。很多高级技巧的“手感”,就是靠这种反复试边界练出来的。再举个例子,你写一个事件代理时,知道事件冒泡的底边在哪吗?对同一个元素同时注册 capture 和 bubble 两个监听器,触发顺序如何?只有亲手写一个小页面验证之后,才能在遇到深层嵌套的 DOM 事件问题时第一时间定位到是哪一层 shadow root 拦住了事件。边界的实验越密集,你对底层原理的 map 就越完整。
5.3 从“读别人源码”到“把源码改成自己的玩具”:用裁剪建立内化
最后一个阶段是“裁剪一个玩具”。我现在每学一个新工具,相对高效的路径并不是把整个源码通读一遍,而是把一个很小的核心模块单独抽出来重写成最小可运行版,比如自己实现一个很简化的 Promise,或者复刻一个 const 语义不变的简易响应式系统。为什么通过裁剪让自己重新实现一遍能火速拉高底层的理解?因为你在重写的时候会遇到无数“呀,这里如果不这么做,那个特性就实现不了”的顿悟时刻。像事件循环的时序,自己写一个只有三条优先级的任务调度器,就能在日志里看到 Task 和 Microtask 的边界。自己实现一个“防抖+取消”的工具,你会发现取消其实意味着“不在队列里留下执行入口”,而不只是调一下 clearTimeout 这么简单。这套方法的“副作用”是你会越来越擅长看源码,因为在裁剪过程中你把很多符号名、内部函数的位置都变成了一种个人的索引结构。我常对新人说,源码不是用来背的,是用手想过一遍的,想完之后你的脑子是唯一长期保存这些底层原理的“环境对象”。
5.4 每日代码复盘里最值得问的一个小问题
最后想给你一个最容易被忽略但是实践下来极其有效的复盘方式:每天下班之前挑一段你白天写过的代码,然后问自己一句话——“如果这个函数在底层遇到一百万次调用,我还会这样写吗?”这个问题背后的指向,就是底层的性能模型和调用的热路径。它会让你不知不觉地养成看空间复杂度和时间复杂度的习惯,也会让你不再只是为了“运行通过”而写代码,而是为了“在合理的底层约束下优雅地运转”而写代码。比如你在点击事件里写了一个 Array.includes,长度只有 5 没什么感觉;可如果这段点击事件在表格里每行都要触发,而且那 5 个元素其实是 5000 个中的一个,你就会自然想到 Set 查找。说到底,所谓进阶,就是从“知道怎么运行”过渡到“理解为什么这样运行”,再由理解倒推出无数种变体和优化方式。底层原理不是高深不可攀的源码大山,它就藏在你每一天都会写的每一行代码里的那份调度规则、引用规则和数据结构取舍之间。
