Skip to content

前端开发小知识点

更新: 6/14/2026 字数: 0 字 时长: 0 分钟

1. 缓存数组 length,别在循环里反复读取

for (let i = 0; i < arr.length; i++) 时,arr.length 在每轮循环都会被求值一次。数组的 length 是个动态属性,引擎要去读取数组对象当前的长度状态,循环次数大时这点开销会累积。把它先存进常量再用,整个循环只读一次:

javascript
for (let i = 0, len = arr.length; i < len; i++) { /* ... */ }

为什么有用: 减少了 N 次属性访问。不过要注意适用边界——现代 V8 对简单数组的 length 访问已经优化得很好,循环体内如果会增删数组元素,缓存长度反而会出错。它真正的价值更多体现在 DOM 集合(NodeListHTMLCollection)上:这类对象的 length 每次访问可能触发重新计算,缓存收益明显大于普通数组。

缓存数组 length 提升循环性能

2. 测执行用时:日常用 console.time,要精度用 performance.now()

console.time('label')console.timeEnd('label') 成对使用,自动算出两点间的耗时并打印,写起来最省事,适合粗略看一段逻辑跑多久。

要测很短的代码片段,或者要做性能对比时,用 performance.now() 更准:

javascript
const t0 = performance.now();
doSomething();
const t1 = performance.now();
console.log(`耗时 ${t1 - t0} 毫秒`);

为什么更精准: performance.now() 返回的是亚毫秒级(小数点后多位)的高精度时间戳,基于单调时钟(monotonic clock),不受系统时间被修改的影响,两次取值的差值稳定可靠。而 Date.now() 精度只到毫秒,还可能因系统对时跳变。测微小片段时,单次差值容易被噪声淹没,建议跑多次取平均。

测量代码执行耗时

3. V8 的 JIT 优化是动态的,跑基准测试要小心被它"骗"

V8 执行 JS 不是简单逐行解释。它先用解释器(Ignition)跑,同时统计哪些函数是热点;热点代码会被即时编译器(TurboFan,即 JIT)编译成优化过的机器码,执行就快了。优化基于运行时观察到的类型假设,一旦后续遇到不符合假设的输入(比如参数类型变了),会触发去优化(deopt),退回解释执行。

对开发的实际影响: 同一段代码先跑几次"预热"后会变快,这就是为什么写基准测试时第一次和后续几次的结果差别很大——前面的执行让 V8 进入了优化状态。测性能要先 warmup、多轮取值,否则结论不可信。写代码时保持函数参数类型稳定(别一会传数字一会传字符串),能让 V8 的优化更稳定地命中。

V8 引擎 JIT 即时编译优化

4. 少用全局变量,作用域查得越远越慢

JS 查找变量沿作用域链进行:先在当前函数作用域找,找不到逐级往外,最后到全局作用域。链路越长,查找成本越高。频繁访问全局变量意味着每次都要走完整条链,热点循环里尤其明显。

两个层面的问题:

  • 性能:把高频使用的全局值(或 window.xxx)先用局部变量接住,缩短查找路径。
  • 可维护性:全局变量在任何地方都能被改,容易出现命名冲突和被意外覆盖,调试时很难追踪是谁动了它。这往往比那点性能损耗更要命。

实践上多用模块作用域、闭包、局部变量把状态封起来,既快又安全。

减少全局变量加快作用域查找

5. 按场景选数据结构,别什么都用数组

不同结构的强项不一样,选对了性能差很多:

  • 数组(Array):按索引访问是 O(1),很快;但在头部或中间插入、删除要移动后面所有元素,是 O(n)。适合顺序存储、按下标读取的场景。
  • Set:判断"某个值在不在"和去重是它的强项,has 接近 O(1),比用 array.includes(O(n))快得多。需要频繁查存在性或去重时优先用它。
  • Map:键值对存取,get / has / set 平均 O(1),键可以是任意类型(对象也行)。比用普通对象当字典更合适——它能保持插入顺序、有 size 属性、不会被原型链上的键污染。

判断方法: 操作以"按 key 查找 / 判断存在 / 去重"为主,用 Set / Map;以"按顺序遍历 / 按下标取值"为主,用数组。频繁在中间增删的场景,数组是性能陷阱。

按场景选择数据结构

6. 高频事件用防抖和节流,别让回调被狂刷

输入框搜索、scrollresizemousemove 这类事件触发非常密集,回调里如果有请求或计算,会卡顿甚至打爆接口。两种控制手段:

  • 防抖(debounce):事件停下来一段时间后才执行。典型场景是搜索框——用户停止输入 300ms 再发请求,中途的每次输入都重新计时。
  • 节流(throttle):固定时间间隔最多执行一次,不管中间触发多少次。适合 scroll 监听、拖拽、按钮防连点这类需要"匀速响应"的场景。

怎么选: 只关心"最终状态"用防抖(搜索、表单校验);需要过程中持续反馈用节流(滚动加载、进度更新)。lodash 的 debounce / throttle 直接可用,自己写也就几行闭包加定时器。

防抖与节流

7. 用事件委托代替给每个子元素绑监听

列表有 1000 个 li,要给每个加点击事件,逐个 addEventListener 会创建 1000 个监听器,占内存还慢,动态新增的项还得手动补绑。事件委托利用事件冒泡:只在父容器上绑一个监听,点击子元素时事件冒泡上来,通过 event.target 判断点的是哪一项。

javascript
list.addEventListener('click', (e) => {
  const item = e.target.closest('li');
  if (item) handleClick(item.dataset.id);
});

为什么划算: 监听器从 N 个降到 1 个,内存和绑定开销大幅下降;动态增删的子元素自动生效,不用重新绑定。React 等框架的合成事件底层就是这套思路。

事件委托

8. 批量 DOM 操作,减少重排重绘

每次改动 DOM 几何属性(尺寸、位置、增删节点)可能触发浏览器重排(reflow),重新计算布局再重绘(repaint),代价很高。循环里逐个 appendChild 100 个节点,最坏会触发 100 次布局。把节点先放进 DocumentFragment(游离在文档外的容器),拼好后一次性插入,只触发一次重排:

javascript
const frag = document.createDocumentFragment();
items.forEach(d => frag.appendChild(createNode(d)));
container.appendChild(frag); // 一次插入

其他手段: 要改很多样式时,用 class 切换代替逐条改 style;需要大改的元素先 display: none 改完再显示;用 cloneNode 在副本上操作再替换。

批量 DOM 操作减少重排重绘

9. 读写分离,避开强制同步布局

边读边写布局属性会触发"强制同步布局"(layout thrashing)。比如循环里先读 el.offsetHeight(读取会强制浏览器立刻算布局),紧接着改 el.style.height(写让布局失效),下次再读又得重算——一读一写来回拉锯,性能急剧下降。

javascript
// ❌ 读写交错,每轮强制重排
items.forEach(el => { el.style.height = el.offsetHeight * 2 + 'px'; });

// ✅ 先集中读,再集中写
const heights = items.map(el => el.offsetHeight);
items.forEach((el, i) => { el.style.height = heights[i] * 2 + 'px'; });

关键点: 触发同步布局的属性包括 offsetTop/Width/HeightscrollTopgetComputedStylegetBoundingClientRect 等。把所有读操作放一起、所有写操作放一起,布局只算一次。需要在合适时机批量改,可配合 requestAnimationFrame

读写分离避免布局抖动

10. 拆分长任务,别阻塞主线程

JS 是单线程,一段同步代码跑超过 50ms 就会阻塞主线程,导致点击没反应、动画掉帧。遇到大量数据处理或复杂计算,把长任务切成小块,每块跑完把控制权交还给事件循环,让浏览器有机会响应交互和渲染:

javascript
function processChunked(list, i = 0) {
  const end = Math.min(i + 100, list.length);
  for (; i < end; i++) handle(list[i]);
  if (i < list.length) setTimeout(() => processChunked(list, i), 0);
}

更好的工具: requestIdleCallback 在浏览器空闲时执行低优先级任务;requestAnimationFrame 把视觉更新对齐到下一帧。React 的 Fiber、时间切片本质都是这个思路——把不可中断的大任务变成可中断的小任务。

事件循环与长任务拆分

11. 重计算放进 Web Worker,主线程专心管 UI

主线程既要处理 JS 又要渲染界面,把图像处理、大数据解析、加解密这类 CPU 密集任务放主线程会直接卡死界面。Web Worker 跑在独立线程,通过 postMessage 和主线程通信,算完把结果传回来,全程不占用主线程:

javascript
const worker = new Worker('calc.js');
worker.postMessage(bigData);
worker.onmessage = (e) => render(e.data);

注意边界: Worker 里访问不了 DOM 和 window;通信的数据默认是拷贝(结构化克隆),传大对象有序列化开销,可以用 Transferable(如 ArrayBuffer)转移所有权来零拷贝。适合纯计算,不适合频繁小数据来回传。

Web Worker 多线程计算

12. 长列表用虚拟滚动,只渲染看得见的那几条

一次性渲染上万条数据的列表,会创建上万个 DOM 节点,首屏卡死、内存暴涨、滚动掉帧。虚拟滚动(virtual list)只渲染可视区域内的十几条,配上撑起总高度的占位元素,滚动时根据滚动位置动态替换内容、复用节点。

核心原理: 算出当前滚动位置对应哪些数据项落在可视区,只渲染这部分,用 transform 或绝对定位摆放,外层用一个等于总高度的容器维持滚动条比例。react-windowvue-virtual-scroller、TanStack Virtual 都是成熟方案。表格、消息列表、无限滚动 feed 都该上这个,不然数据一多必卡。

长列表虚拟滚动

13. 图片懒加载 + 关键资源预加载

图片往往是页面最大的流量来源。懒加载让视口外的图片先不下载,滚到附近再加载,省首屏流量、加快首屏。原生写法最简单:

html
<img src="photo.jpg" loading="lazy" alt="">

要更精细控制(比如提前一屏加载)用 IntersectionObserver 监听元素进入视口再赋值 src

反过来,对首屏必需的关键资源(字体、首屏大图、关键 JS/CSS)用 <link rel="preload"> 提前拉取,避免渲染时才发现要等它:

html
<link rel="preload" href="hero.webp" as="image">

搭配技巧: 还有 prefetch(空闲时预取下个页面资源)、preconnect(提前建连到关键域名)。懒加载和预加载方向相反,按资源是否首屏必需来分。

图片懒加载与资源预加载

14. 主动断开引用,防内存泄漏

JS 有垃圾回收,但只要还有引用指着的对象就不会被回收,引用没断干净就是内存泄漏,长时间运行的 SPA 尤其明显。四个常见泄漏源:

  • 没移除的事件监听:组件销毁了但 addEventListener 还挂在 window/document 上。
  • 没清的定时器setIntervalclearInterval 会一直跑且持有闭包引用。
  • 闭包意外持有大对象:回调闭包引用了不该长期存在的变量。
  • 游离 DOM 引用:节点从页面移除了,但 JS 变量还存着它,整棵子树都回收不了。

处置: 组件卸载时配对清理——removeEventListenerclearInterval、把不用的引用置 null。需要弱引用缓存用 WeakMap/WeakSet,它们不阻止回收。用 DevTools 的 Memory 面板做堆快照对比能定位泄漏。

避免内存泄漏

15. 代码分割,首屏只加载必要的部分

整个应用打成一个大 bundle,用户打开首页要下载全站代码,首屏自然慢。代码分割按路由或功能把 bundle 拆成多个 chunk,用到哪块加载哪块。动态 import() 是关键:

javascript
// 路由级懒加载
const Detail = lazy(() => import('./pages/Detail'));

webpack / Vite 见到动态 import() 会自动切出独立 chunk,访问对应路由时才请求。

实践组合: 路由懒加载(最大头)、大型第三方库按需引入(别整包导入 lodash,用 lodash-es + tree-shaking)、不常用功能(编辑器、图表库)延迟加载。配合 webpack-bundle-analyzer 看清谁占体积大,优先拆它。首屏体积下来了,加载和可交互时间都会明显改善。

代码分割与按需加载