前阵子在群里又被问到:“代码能跑,页面就是卡,到底怎么查?” 这句话几乎是每个写 JavaScript 的人都会遇到的魔咒。尤其是做 H5 活动页、复杂后台看板,或者塞在移动端 WebView 里的应用,用户一句“总感觉不跟手”,就够你排查一整天。这几年我大部分精力都花在前端性能和工程化上,踩过的坑攒了不少,也沉淀了一套自己习惯的 JavaScript 性能优化实战方法。今天不打算罗列什么“十大技巧”,而是把提速技巧里最容易被忽略、但又最影响体验的部分串起来讲,聊聊我平时到底先看什么、怎么量化、怎么动手改、怎么防止下次再犯。
这篇文章适合谁看? 如果你刚入门前端,学会语法但是写出来的页面偶尔卡顿;如果你已经写了两年以上 JS,想知道怎么用 Chrome DevTools 和 Performance API 把卡顿原因定位准;如果你在 Hybrid App 里做 H5,被原生桥接调用和内存上涨折磨过——这篇内容应该对你有用。我会尽量少讲空泛理论,多给可以直接抄走的代码片段和排查步骤。
1. 先搞清楚瓶颈在哪里,再谈优化手段
1.1 从资源加载到可交互,JS 在整个链路里的位置
很多人一上来就优化某个函数的写法,其实方向容易走偏。我习惯先把整个页面从“请求第一个字节”到“用户能正常操作”的流程在脑子里过一遍,再判断 JS 到底在哪一段拖了后腿。
大致链路是这样:浏览器先请求 HTML,解析 HTML 过程中遇到 <script> 会停下来取脚本并执行,因为脚本可能修改 DOM 结构。这个“停”就是主线程被占用。之后浏览器构建 CSSOM、完成样式计算,再走布局、绘制、合成,最后用户看到可交互页面。JavaScript 的核心开销集中在两块:一是加载和解析(Parse/Compile),二是执行时的主线程占用。两者都会直接拖累 FCP(首次内容绘制)和 TTI(可交互时间)。
用一个厨房类比来说明:主线程就是店里唯一的厨师。每来一个任务(比如执行一段比较耗时的 JS 逻辑),他必须放下手里的菜去处理。如果这段 JS 逻辑耗时 300ms,那么期间页面就无法响应点击、滚动和动画,用户感知到的就是卡顿、白屏、掉帧。所以优化 JavaScript 性能,本质上不是“把代码写漂亮”,而是“减少厨师被无关任务打断的总时长”。
很多新手犯的错误是想都不想就上一堆虚拟列表、Web Worker、Canvas 渲染方案,最后复杂度翻倍,收益却很小。正确顺序是先确认瓶颈在哪一段,再针对性优化。
1.2 用数据代替感觉:几个必须认识的性能指标
“感觉有点卡”没办法指导优化,必须量化。我平时最常看的指标有这么几个:FCP、LCP(最大内容绘制)、FID(首次输入延迟)/ TBT(总阻塞时间)、CLS(布局偏移)和 TTI。这几个指标覆盖了加载、交互、视觉稳定三个维度。
在本地调试时,我建议养成三个习惯:
- 打开 Chrome DevTools 的 Performance 面板,录制一段操作,看主线程上有没有超过 50ms 的“长任务”(Long Task)。
- 用 Lighthouse 跑一遍,重点不是看总分,而是看 “Opportunities” 和 “Diagnostics” 里列出的具体条目。
- 在业务代码里埋点和打点,用 Performance API 记录关键阶段耗时。
比如在应用入口和核心模块加载完成后分别打点:
javascript复制performance.mark('app-start');
// 异步加载必要模块完成后
Promise.all([loadA(), loadB()]).then(() => {
performance.mark('app-ready');
performance.measure('load-cost', 'app-start', 'app-ready');
const entries = performance.getEntriesByName('load-cost');
if (entries.length > 0) {
console.log('核心模块加载耗时:', entries[0].duration.toFixed(2), 'ms');
}
});
这里有两个实用技巧:一是 performance.mark 可以打多个点,最后直接看 performance.getEntriesByType('measure') 就能对拍多个阶段耗时,不用自己算时间戳;二是长任务监听可以后续扩展到线上监控,我建议从开发阶段就顺手埋上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高频代码模式优化:循环、字符串与函数
2.1 循环里的隐形开销:缓存 length、避免 for-in、慎用链式调用
循环是 JS 里最常见的批量数据处理场景,也是初学者最容易随手写出低效代码的地方。先说一个经典问题:for (let i = 0; i < arr.length; i++) 每次循环都会读取一遍 arr.length。在现代 V8 引擎里,编译器会对这种简单表达式做优化,但在某些旧版 WebView 或较老环境下,反复读取确实会有额外开销。我的习惯是统一缓存一下:
javascript复制const len = arr.length;
for (let i = 0; i < len; i++) {
// 处理 arr[i]
}
更有争议的是遍历方式的选择。我实测过 for、for...of、forEach 在三万到三十万条数据下的差异,结论是:量级较小的时候几乎看不出差别,到十万级以上 for 通常比 forEach 快 5%-15%,而 for...of 在现代引擎上已经优化得和 for 非常接近。比选哪种 for 更重要的是别用错方法:for...in 千万不要用来遍历数组,它会枚举原型链上的可枚举属性,既有额外开销又有隐藏 bug。
还有一个新手容易忽略的点:数组链式方法 filter().map().reduce() 在超大数组上会产生中间数组,增加 GC 压力。看这段:
javascript复制const result = hugeList
.filter(item => item.active)
.map(item => item.price)
.reduce((sum, p) => sum + p, 0);
功能上没问题,但每次调用 filter 会产生一个新数组,再 map 又产生一个新数组。数据量小无所谓,百万级数据就能明显感受到 GC 停顿。如果这段代码在渲染热路径上,完全可以合并成一个循环:
javascript复制let sum = 0;
for (let i = 0; i < hugeList.length; i++) {
const item = hugeList[i];
if (item.active) sum += item.price;
}
这不是说永远别用链式写法,语义清晰同样重要。我的建议是:数据量不大时随意,数据量大且执行频率高时选择更“抠内存”的方式,并写清注释说明为什么没有用更简洁的写法。
2.2 字符串拼接与正则表达式的性能陷阱
字符串也是日常开发里逃不掉的一块。很多人以为字符串拼接是最简单的操作,但 JS 字符串是不可变类型,每次 += 都会生成一个新的字符串对象。现代引擎对连续拼接做了一些优化(比如 V8 的 ConsString),但习惯上大量拼接时我仍然会用数组收集,最后一次性 join,或者直接用模板字符串分片连接:
javascript复制// 不推荐:大量拼接
let log = '';
for (const item of items) {
log += item.name + ':' + item.value + '\n';
}
// 推荐:分段收集
const parts = [];
for (const item of items) {
parts.push(`${item.name}:${item.value}`);
}
const log = parts.join('\n');
正则表达式是另一个很容易“让页面直接卡死”的隐藏雷区,现象叫灾难性回溯(ReDoS)。比如一个看似无害的表达式:
javascript复制// 危险示例:嵌套量词
const regex = /^(\d+)+$/;
regex.test('12345678901234567890X');
当输入串以大量数字结尾再加一个非数字时,(\d+)+ 之间会产生海量回溯路径,表现为执行时间从几毫秒暴涨到几秒甚至更久。我的经验是:写正则时尽量避免“量词套量词”;能用字符类表达就尽量用字符类;如果正则来自用户输入或者业务配置,一定要加复杂度上限,甚至直接在服务端校验。另一个细节是,如果同一个正则要反复执行,记得把正则对象存下来,而不是每次重新 new RegExp。
2.3 函数优化:防抖节流、闭包泄漏与传参习惯
函数层面的优化,我感触最深的是高频事件的防抖和节流。搜索框输入建议用防抖(等待静默后再触发),滚动或缩放用节流(固定时间间隔触发)。二者的代码都不复杂:
javascript复制function debounce(fn, wait = 300) {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, wait);
};
}
function throttle(fn, interval = 200) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
闭包带来的内存泄漏很多人没意识到。常见场景是一个对象被放进闭包回调后,回调又被事件监听器或定时器持有,就会导致这个对象长期无法被回收。比如:
javascript复制function addListener(root) {
const bigData = loadBigData();
root.addEventListener('click', () => {
console.log(bigData.length);
});
}
这里 bigData 被闭包捕获,只要监听器不解除,内存就不会被释放。排查建议很简单:如果某个页面的留存内存随操作次数持续增长,优先怀疑事件监听器、定时器和闭包引用。
再补一个传参习惯:在性能敏感的渲染循环里,尽量把需要频繁读取的属性“就近”存为局部变量,避免每轮循环都走属性查找:
javascript复制// 不推荐
function render(items) {
for (const item of items) {
draw(item.config.theme.color, item.config.size);
}
}
// 推荐
function render(items) {
const color = items[0].config.theme.color;
const size = items[0].config.size;
for (const item of items) {
draw(color, size);
}
}
这只是个简化示例,强调的思路是:越往高频路径深处走,就越要留意“每帧都出现的重复属性访问”。
3. 交互与渲染优化:事件、DOM 与 H5 图片缩放
3.1 事件委托与 passive: true 的真实效果
给一长串列表每个子元素单独绑定事件,是新手常踩的坑。事件委托只需要在父节点绑定一次监听,利用冒泡找到真正触发元素,既能减少监听器数量,也能减少内存占用,代码还更好维护:
javascript复制document.getElementById('list').addEventListener('click', (e) => {
const li = e.target.closest('li');
if (li) {
handleItemClick(li.dataset.id);
}
});
另一个很重要的参数是 passive: true。在移动端滚动或触摸场景的监听事件里,如果不加这个参数,浏览器无法确定你是否会调用 preventDefault(),就必须等待脚本执行完才能决定是否继续处理默认滚动行为。明确不阻止默认行为时尽量开启:
javascript复制window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('touchmove', onTouchMove, { passive: true });
这里有一个容易踩到的点:一旦加了 passive: true,在你回调里调用 preventDefault() 会被浏览器忽略并抛警告。所以只有当确定不需要阻止默认手势时才加。
3.2 防止强制同步布局:layout thrashing 经典场景
“强制同步布局”听起来很专业,实际触发条件很简单:你在 JS 里读取一个需要计算布局的属性,比如 offsetHeight、offsetTop、getBoundingClientRect,浏览器为了保证值准确,只好立刻把整个布局流程提前跑一遍。如果这种读取和样式修改在循环或滚动事件里反复交错,就会形成布局抖动,也就是 Layout Thrashing。
看一个反面教材:
javascript复制// 不推荐:读取与写入交替,触发多次布局
items.forEach(item => {
const h = item.offsetHeight; // 触发布局
item.style.height = (h * 2) + 'px'; // 触发布局
});
改成先统一读取、再统一写入,一次循环就能避免大量重复布局:
javascript复制const heights = items.map(item => item.offsetHeight);
items.forEach((item, i) => {
item.style.height = (heights[i] * 2) + 'px';
});
动画场景里我还会配合 requestAnimationFrame 做合并。顺带说一句:现在 CSS 里提供了独立的 rotate、scale、translate 属性,有人会写 v.style.rotate = '-90deg' 这种代码。这比直接拼 transform 字符串更语义化,但在某些老旧浏览器里仍不支持。高频动画里如果频繁改这些独立属性,同样会触发样式计算更新。我的习惯是:如果只是简单旋转和缩放,优先用 transform 配合 will-change: transform,把合成阶段的任务尽量交给 GPU,避免反复触碰布局相关属性。
3.3 移动端 H5 图片缩放与触摸交互实战
搜索热词里经常能看到“h5 图片 手机端 可以手指放大缩小”,我猜是很多人在做图片预览或证件上传时被这个问题卡过。这里我把核心思路讲透。
实现双指缩放时,最常见的错误是直接修改图片的 width 或 height,这会让浏览器反复进入布局流程,几乎必然掉帧。正确做法是用 transform: scale() 去缩放,因为 transform 操作发生在合成阶段,不会触发 layout 和 paint。配合触摸事件,核心结构大致是这样:
javascript复制let scaleValue = 1;
let startDistance = 0;
const img = document.getElementById('preview-image');
img.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
startDistance = getDistance(e.touches[0], e.touches[1]);
}
}, { passive: true });
img.addEventListener('touchmove', (e) => {
if (e.touches.length === 2) {
const dist = getDistance(e.touches[0], e.touches[1]);
scaleValue = dist / startDistance;
requestAnimationFrame(() => {
img.style.transform = `scale(${scaleValue})`;
});
}
}, { passive: true });
function getDistance(touchA, touchB) {
const dx = touchA.clientX - touchB.clientX;
const dy = touchA.clientY - touchB.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
这里有三个实战经验值得注意:
touchmove事件触发频率极高,直接在回调里改transform仍然可能让主线程来不及处理。用requestAnimationFrame包一层,可以把同帧内的缩放值最后一次合并到渲染管线里,实际体验会顺滑很多。- 在 iOS WebView 和 Android 部分浏览器里,双指手势默认会被浏览器当成“页面缩放”或“页面滚动”抢走。需要给图片容器加上
touch-action: none,同时确认 viewport 设置允许申明user-scalable=no但不建议强制禁用,因为会伤害可用性。 - 缩放过程中要注意图片是否超出容器边界,通常会配合
translate做位移限制,但这就进入了更完整的“手势系统”设计,核心仍然是不碰 layout 属性、高频回调进 rAF、合理处理默认手势。
这类优化同样适用于手游性能优化中的 H5 原型、WebAR 预览、图片编辑器等场景,原则都是:能让浏览器只做合成层的变换,就别去手动操作布局属性。
4. 内存管理、运行时报错与 WebView 桥接降频
4.1 内存泄漏的五个常见来源与排查手段
JavaScript 的性能问题不只是“卡”,还有“越来越卡”,后者通常是内存泄漏。我总结了五个最常见的来源:
- 隐式全局变量:未声明直接赋值,会把变量挂到
window上。 - 定时器和事件监听器未清理:组件销毁后
setInterval还在执行。 - 移除的 DOM 元素仍被 JS 变量引用,导致节点无法被 GC。
- 闭包捕获大对象。
EventSource、WebSocket、ResizeObserver等实例没有close()/disconnect()。
排查手段很简单:打开 Chrome DevTools 的 Memory 面板,录制一段交互前后各拍一次堆快照(Heap Snapshot),对比 Retained Size 增长。如果某个对象明显保留着一大堆数据,就沿着引用链找是谁持有它。
这里我想横向提一句 Julia 语言的内存优化思路,热搜词里也有人关注 Julia 性能优化与内存管理。Julia 社区非常强调“预先分配、尽量避免中途创建临时对象”。这个理念放在 JS 里同样适用。比如在动画循环里频繁创建新数组或新对象,会让 GC 频繁工作,造成不可预期的掉帧。适当复用对象,可以减少 GC 压力:
javascript复制// 不要每帧都 new
const tmp = { x: 0, y: 0 };
function onFrame(e) {
tmp.x = e.clientX;
tmp.y = e.clientY;
element.style.transform = `translate(${tmp.x}px, ${tmp.y}px)`;
}
这种“扣到极致”的做法不是所有场景都需要,但在游戏、动画、可视化等高频路径上,确实能明显降低 GC 触发的次数。
4.2 运行时报错的快速定位清单
JS 运行时报错里,我见过最多的三类是:
TypeError: Cannot read properties of undefined:多半是拿到空数据后直接访问了深层属性。RangeError: Maximum call stack size exceeded:递归没有出口,或者JSON.stringify遇到了循环引用。Unhandled promise rejection:异步异常没有被 catch。
快速定位的经验分三个层面。第一,开发环境一定要开 Source Map,让堆栈能映射回源码;第二,给关键入口和原生桥接调用加 try/catch,并把错误信息、路由、用户操作步骤一起上报,而不是只上报一个 message;第三,在全局监听 window.onerror 和 unhandledrejection,至少能保证别人线上遇到问题时你有日志可以看。
如果遇到“复现不了”的报错,我习惯在可疑位置打上临时日志,并利用 Performance 面板跟踪调用顺序。八成情况是异步时序问题:某个依赖没有就绪,后续代码就急急忙忙开始执行了。
4.3 WebView 桥接调用里的性能瓶颈:OC/JS 互相调用降频
做 Hybrid App 时,经常要面对“OC 和 JavaScript 互相调用”的场景。很多人第一次接原生桥接时有个错觉:调用原生方法就像调用本地函数一样快。实际上每次桥接调用都要经过数据序列化、跨线程或跨进程通信、再反序列化,开销比普通函数调用高出几个数量级。
我处理过的最典型案例是:H5 页面里每渲染一个列表项就去原生桥接读取一次本地配置,列表有上百项时首屏时间直接翻倍。优化的核心思路是降频,也就是“能合并的合并,能缓存就缓存”。
第一种做法是批量请求:在 JS 侧维护一个待发送队列,用 setTimeout(0) 做一次微任务或宏任务合并,把这一帧内所有桥接调用打包成一次原生消息:
javascript复制const pendingQueue = [];
let scheduled = false;
function callNative(method, params) {
return new Promise((resolve) => {
const id = generateId();
pendingQueue.push({ id, method, params, resolve });
if (!scheduled) {
scheduled = true;
setTimeout(flushQueue, 0);
}
});
}
function flushQueue() {
const queue = pendingQueue.splice(0, pendingQueue.length);
const payload = queue.map(item => ({
id: item.id,
method: item.method,
params: item.params,
}));
// 通过实际 bridge 发送 payload,等原生回调后逐个 resolve
nativeBridge.postMessage({ type: 'batch', queue: payload });
scheduled = false;
}
这里只是简化逻辑,但思路很重要:高频桥接调用合并后,发送频率会显著下降。
第二种做法是结果缓存。对于“读取设备型号”“获取城市定位”这类低频变化的原生能力,完全没有必要每次调用都重新走桥接,启动时机一次性读取并缓存即可。还有一种做法是把高频逻辑整体下沉到原生侧,JS 只管数据展示。手游里也常用类似思路,减少 JS 与原生/引擎之间的调用频次,把性能敏感的计算放在更底层完成。
5. 工程化层面的提速:构建产物与长期监控
5.1 代码分割、Tree Shaking 与按需加载
运行性能优化到后面,有一个绕不开的坎:脚本本身的体积。同样一段业务逻辑,300KB 的压缩脚本和 100KB 的脚本在解析和执行阶段的时间差距,在低端 Android 手机上可以非常明显。
工程化手段主要有三个:
- 路由级/组件级按需加载:用动态
import()把首屏不需要的代码拆出去。 - Tree Shaking:在构建配置里开启
sideEffects标记,去掉没用到的导出。 - 体积预算:给打包产物设置
maxAssetSize阈值,超过就提醒。
一个典型的动态导入:
javascript复制// 不再是静态 import 大型图表库
const { default: Chart } = await import('./Chart.js');
注意动态导入并非万能。如果拆得太碎,会产生大量小请求,网络握手时间反而拖慢首屏。经验值是让首屏必要的核心代码尽量合并,非首屏、懒加载、低频使用的模块再单独拆包,做到“体积和请求数之间的平衡”。
5.2 用 PerformanceObserver 监听长任务与性能预算
人工跑 Lighthouse 只能验证单次优化结果,不能保证后续不劣化。我强烈建议在项目里加一层持续监控,最简单的是用 PerformanceObserver 监听长任务:
javascript复制const observer = new PerformanceObserver(list => {
const longTasks = list.getEntries().filter(entry => entry.duration > 50);
if (longTasks.length > 0) {
// 上报到监控平台,附带 duration 和发生时间
monitor.reportLongTasks(longTasks);
}
});
observer.observe({ entryTypes: ['longtask'] });
这里 50ms 是个硬指标:超过这个时长,浏览器就无法保证 60fps 的流畅感。线上监控关注的不只是任务时长,还要关注频率:少量长任务可容忍,频繁长任务才是卡顿来源。
工程流程上,我建议在 CI 里接入 Lighthouse CI 或性能预算检查,给关键指标设“分数红线”。比如 LCP 大于 4 秒直接报警,新增依赖导致包体积超过预算时不通过构建。这样性能就不会变成“改完一次就不管了”的临时活动。
5.3 真实项目里的优化顺序建议
聊到最后,我想给一个我自己在真实项目里固定使用的优化优先级,避免一上来就陷入细节:
- 先检查网络和资源体积:图片有没有压缩,JS/CSS 有没有拆包,接口有没有串行等待。
- 再优化主线程长任务:定位长函数、高频循环、渲染热路径,把卡顿源头干掉。
- 然后处理内存和异常:解决持续性占用,清理报错。
- 最后才是“抠指令级细节”:手动调循环、缓存属性引用这类操作。
这话可能有点反直觉:很多时候影响体验最大的反而是资源体积和请求数量,而不是代码里某一行写得不够优雅。有个 H5 活动页的案例我印象很深:改动前 LCP 接近 5 秒,排查后发现首屏阻塞时间主要来自一个 800KB 的 JSON 配置被当成同步脚本加载,并且渲染前还有两个接口串行。把配置改成按需请求、把接口并行化之后,LCP 直接降到 2 秒多,全程没怎么动业务逻辑,纯属“结构性提速”。
我个人在实际项目里还有一个一直坚持的小习惯:每次性能优化动手之前,先保存一份优化前的阶段耗时数据;优化完以后,再在同样的环境下跑一遍同样的操作,对比耗时差异。不要凭感觉说“好像变快了”,要把前后数据贴到文档里。
另外一个很实用的收尾技巧:把所有关键的 performance.mark() 埋点在调试环境里汇总成一份 JSON,每次发版本后自动采集一次。后续再出现卡顿,你只需要对比历史曲线,看是哪个阶段突然陡增,定位效率会高很多。性能优化这件事,最怕的不是代码难改,而是“拍脑袋优化”和“凭感觉验收”。把量化、对比、监控养成习惯,很多玄学的卡顿问题其实都藏不住。
