代码优化怎么做?别急着优化,先看看我的踩坑记录

🔑 关键词:代码优化,性能分析,JavaScript优化,重构,算法选择

📖 摘要:一个维护老项目的性能优化经历:从盲目改动到用数据说话,分享了具体的工具、测量方法和踩过的坑,适合做前端性能调试的开发者参考。

上个月在维护一个内部用的表格列表页,用户天天在群里吐槽卡。我打开页面,发现每次切换筛选都要等差不多3秒,而且中间有一阵子完全没反应——你能想象那个画面,鼠标转圈,用户关闭标签页。最初我的解决办法很粗暴:在React组件里加了个 useMemo,又给列表加了分散的setTimeout,想着让界面“先画出来”,但其实完全是在赌。后来朋友提醒我去看 Chrome DevTools 的 Performance 面板,让我录制一段5秒与表格交互的过程,我才真正看到:最底层的那个行数据格式化函数,一次调用要花40ms,而它在一个循环里被调用了800次,总共占了3200ms左右。源码倒是不复杂,就是个嵌套filter+map,每次还要去正则匹配一长串配置,我看了半天才反应过来,这完全不是“渲染慢”,是纯JavaScript的循环里做了太多重复的字符串处理。

图片

把那个函数改好之后,我突然有点想写一篇博客,但不是什么新技术,而是一个最土又最好使的原则:先测量,再下手。我自己就是典型的反面教材。可能你也遇到过这种代码优化文章说“用二分法替换线性循环就能快几百倍”,但现实并不是这样简单。我给一个数组做过去重,刚开始用的是双层循环,数组里10000个对象,双循环要跑约1150ms;我当时想也没想,直接套了一个 Set 的写法,结果呢?同样的数据降到 4ms 左右。看着挺爽,这是复杂度从O(n²)降到O(n)的结果。但后面发生一件事:另一个列表只需要遍历100个元素,用了Set其实反而比原来的双循环慢了 0.2ms 左右,因为每个对象都要先比对引用、构建哈希结构,而双循环的前几次循环就命中了。所以我发现,你要是在只知道“复杂度”而不知道数据规模的情况下做优化,就会把本该很简单的地方弄得越来越绕——甚至有时,O(n²)在小数据上是赢家。写代码最终要有实际数据做参照。我建议你需要一个固定的基准环境,比如我的笔记本是ThinkPad T480, i5-8250U,内存16G,Chrome 94。在这样的环境里跑同一个函数十次,取第三到第五次的中位数,因为前几次会有JIT编译和垃圾回收的噪音。这里我踩过坑:以前我在循环里硬生生写了 setTimeout 来模拟等待效果,结果污染了整个测试结果,害得我花了一个晚上查找那几毫秒到底去了哪里。

图片

其实代码优化最好玩的部分是性能和可读性的对决。记得有一次我为了“极致性能”,把一个求模运算改成了位运算——比如 index % 2 被我用 index & 1 替换了,基准测试确实提升了大概 10% 左右,0.2ms 的差别。但当我过了半个月回去改这个模块时,我盯着那行完全看不懂了,不禁想这里原先是个普通的求模,为什么会变成按位与?后来我实在忍受不了,直接在Git记录里找到了上一个版本的代码,写了一个注释,说“这里检查的不是bit flag,是奇偶性”,并且revert了这一处。我并不是想说位运算不好,而是想提醒你:在100ms以上的瓶颈中,10%不过才10ms,可读性的损失却可能是长期的。类似的,还有一个服务端的接口Query要拼接动态SQL,我一开始把所有过滤条件都放到内存里用箭头函数过滤,把数据库索引完全晾在一边,直到DBA发来慢查询日志我才意识到,自己写的premature优化有多么可笑——明明加一个 WHERE state = 'ACTIVE' 就能让数据库从扫描160万行降到扫描1.5万行,我却想着把数据全部load到内存里,结果内存瞬间多了150MB,服务险些被OOM。那一次的教训是,在“改代码”之前,先问数据是在哪里被处理的,是数据库还是应用服务器?不同的位置,优化手段完全不一样。

图片

所以,如果要我梳理一个可复用的优化流程,我现在会按下面的顺序来:第一步,用 performance.now() 或者浏览器的 Performance 工具录一小段操作,标出用户可感知延迟超过100ms的地方,因为我记得一个说法是超过100ms就要让用户有等待的感觉了,通过看Performance上的Long Tasks,每个红色的长任务一般都会超过200ms,它就是目标。第二步,拿到Long Task之后往里跳,看具体的Call Tree和函数耗时,排序下来前面的往往是循环里的某一句大字符串操作或DOM处理。第三步才是改代码。每次改完不要立刻上生产,对比旧的版本和新的版本,至少连续跑5轮,而且要确保数据一样。比如我刚改的那个行格式化函数,我直接把旧函数和新的函数写在同一个文件里,依次调用,比较返回结果是否一致,再对比耗时。在这三步之外,我还想强调“不要优化不需要优化的代码”这个观点可能大家都听过,但实际遇到需求时还是容易忍不住。比如列表页看起来卡了,不一定就是代码耗时长——有可能是内存泄漏,也有可能是CSS绘制太大,如果你不先通过Performance面板测量,很容易把所有东西都改成缓存和memo,最后把项目拆成一堆没有必要的状态。我在上一家公司待过半年,见过最离谱的一个案例是有人为了解决一次点击卡顿,给整个全局state加了immutable实现,升级了一堆依赖,结果卡得更严重了,因为每次操作要克隆整棵状态树。

图片

说真的,现在翻看那些标榜“代码优化技巧”的文章,看得越多越容易焦虑:好像你不把每处都改成位运算、不把每个递归改成循环、不用上worker就是不合格。可实际情况是,代码优化百分之九十的工作不是写出运行最快的代码,而是找到那百分之九十的性能都浪费在了哪里。你找到一个耗时100ms的函数,优化完之后变成20ms,这个订单能比那些只会背复杂度的“优化专家”实实在在多了。而我每次进行这类性能调查的时候,都不忘把数据留在注释里。像上个月那次,我最后在那一行的上面写下了:原函数耗时3200ms,过滤正则3次,改为单次遍历并预编译正则后耗时12ms,页面筛选按钮响应时间从2000ms降至150ms。你看,这样的注释才是后来人需要的东西,而不是什么“这段循环很快”。

图片