学会这些API 让你的页面更加丝滑 “再也不当切图仔了”

6 阅读12分钟

1. requestAnimationFrame / cancelAnimationFrame

常规写法

setIntervalsetTimeout 驱动动画时,间隔与屏幕刷新率无关;后台标签页仍会按固定频率执行,浪费 CPU;多段动画容易不同步。

// 常规:固定 16ms 假设 60fps,实际可能与 VSync 错位;切后台仍跑
let x = 0;
const timer = setInterval(() => {
  x += 2;
  box.style.transform = `translateX(${x}px)`;
  if (x > 300) clearInterval(timer);
}, 16);

优化写法

与浏览器下一次重绘对齐;页面不可见时浏览器通常会降频或暂停回调,更省电。

let rafId;
let x = 0;

function tick() {
  x += 2;
  box.style.transform = `translateX(${x}px)`;
  if (x < 300) {
    rafId = requestAnimationFrame(tick);
  }
}

rafId = requestAnimationFrame(tick);

// 组件卸载或停止动画时务必取消,否则内存泄漏 / 幽灵更新
function stop() {
  cancelAnimationFrame(rafId);
}

对照小结

对比项常规 setIntervalrequestAnimationFrame
与刷新率脱钩,可能掉帧或多余帧与显示刷新对齐
后台标签常仍执行通常节流或暂停
取消方式clearIntervalcancelAnimationFrame

2. requestIdleCallback(含降级)

常规写法

setTimeout(0)setInterval 做「低优先级」任务,可能在主线程已经很忙时仍插队执行,加剧掉帧。

// 常规:一有机会就跑,不管当前帧是否还有空档
setTimeout(() => {
  heavyNonCriticalWork();
}, 0);

优化写法

在浏览器空闲切片里干活;用 deadline.timeRemaining() 控制本帧最多占用多久。无 API 时降级为 setTimeout

function requestIdle(fn, timeout) {
  if (typeof requestIdleCallback === 'function') {
    return requestIdleCallback(fn, timeout != null ? { timeout } : undefined);
  }
  return setTimeout(() => fn({ timeRemaining: () => 5, didTimeout: true }), 1);
}

const queue = [];
function enqueue(task) {
  queue.push(task);
  schedule();
}

function schedule() {
  requestIdle((deadline) => {
    while (queue.length > 0 && deadline.timeRemaining() > 1) {
      const task = queue.shift();
      task();
    }
    if (queue.length) schedule();
  });
}

// 使用:把大数组拆分处理
const big = new Array(10000).fill(0).map((_, i) => i);
big.forEach((item) => {
  enqueue(() => processItem(item));
});

对照小结

对比项setTimeout(0)requestIdleCallback
调度依据宏任务队列顺序帧内剩余空闲时间
与交互/动画可能抢主线程优先让路给绘制与输入
兼容性Safari 等需 polyfill

3. IntersectionObserver

常规写法

scroll / resize 里对每个元素 getBoundingClientRect(),滚动时每秒触发极多次,强制布局成本高。

// 常规:滚动时全量几何计算
function onScroll() {
  document.querySelectorAll('[data-lazy]').forEach((img) => {
    const rect = img.getBoundingClientRect();
    if (rect.top < window.innerHeight && rect.bottom > 0) {
      img.src = img.dataset.src;
    }
  });
}
window.addEventListener('scroll', onScroll, { passive: true });

优化写法

由浏览器在交集变化时异步通知,避免高频手动算位置。

const io = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;
      const img = entry.target;
      img.src = img.dataset.src;
      io.unobserve(img);
    });
  },
  {
    root: null,
    rootMargin: '200px 0px',
    threshold: 0.01,
  }
);

document.querySelectorAll('[data-lazy]').forEach((el) => io.observe(el));

对照小结

对比项滚动里算 getBoundingClientRectIntersectionObserver
触发频率与滚动事件绑定,极高仅在可见性变化时
布局读取易形成强制同步布局由浏览器优化
代码复杂度需手写 root、margin 逻辑配置 rootMargin 即可

4. ResizeObserver

常规写法

监听 windowresize 只能感知视口,感知不到父容器被侧栏拖动、flex 重排等引起的尺寸变化;有人用 setInterval 轮询 offsetWidth,持续浪费。

// 常规:只关心窗口,且非窗口变化时无效
window.addEventListener('resize', () => {
  chart.resize(chartEl.offsetWidth, chartEl.offsetHeight);
});

// 更差的常规:轮询
setInterval(() => {
  const w = chartEl.offsetWidth;
  if (w !== lastW) {
    lastW = w;
    chart.resize(w, chartEl.offsetHeight);
  }
}, 200);

优化写法

元素内容框尺寸变化即回调(含 display 切换、父级变化)。

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { width, height } = entry.contentRect;
    chart.resize(Math.floor(width), Math.floor(height));
  }
});
ro.observe(chartEl);

// 销毁时
// ro.disconnect();

对照小结

对比项window.resize / 轮询ResizeObserver
感知范围视口或盲目轮询具体元素布局盒
CPU轮询持续占用事件驱动
嵌套布局难覆盖直接对应节点

5. 防抖(debounce)与节流(throttle)

5.1 防抖 — 常规与对照

场景:搜索框输入联想。常规写法是每个 input 立刻请求接口,请求风暴 + 渲染浪费。

// 常规:每次输入都打接口
input.addEventListener('input', (e) => {
  fetch(`/api/suggest?q=${encodeURIComponent(e.target.value)}`);
});

优化:连续输入时只在停顿后执行最后一次(或首次+最后一次,视产品而定)。

function debounce(fn, wait) {
  let t;
  return function (...args) {
    clearTimeout(t);
    t = setTimeout(() => fn.apply(this, args), wait);
  };
}

const onInput = debounce((value) => {
  fetch(`/api/suggest?q=${encodeURIComponent(value)}`);
}, 300);

input.addEventListener('input', (e) => onInput(e.target.value));

5.2 节流 — 常规与对照

场景:滚动更新阅读进度。常规是每次 scroll 都读 DOM、写状态,极易卡顿。

// 常规:无限制执行
window.addEventListener('scroll', () => {
  const pct = (window.scrollY / (document.body.scrollHeight - innerHeight)) * 100;
  progressBar.style.width = pct + '%';
});

优化:固定时间片内最多执行一次(或用 requestAnimationFrame 实现「每帧最多一次」)。

function throttle(fn, wait) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= wait) {
      last = now;
      fn.apply(this, args);
    }
  };
}

window.addEventListener(
  'scroll',
  throttle(() => {
    const pct =
      (window.scrollY / (document.body.scrollHeight - innerHeight)) * 100;
    progressBar.style.width = pct + '%';
  }, 100),
  { passive: true }
);

对照小结

模式行为典型场景
常规直连事件触发即执行简单逻辑、低频
防抖N 次触发 → 停后 1 次搜索、resize 结束布局
节流N 次触发 → 间隔内 1 次滚动、拖拽、mousemove

6. 动态导入与路由级代码分割

常规写法

所有页面组件静态 import,打成一个巨大 bundle,首屏要下载并解析用不到的代码。

// 常规:首包包含编辑器、图表等所有路由
import Editor from './pages/Editor.vue';
import Dashboard from './pages/Dashboard.vue';
import ChartHeavy from './components/ChartHeavy.vue';

优化写法

按路由或重型组件拆 chunk,进入对应路由再加载。

// Vue Router 懒加载
const routes = [
  {
    path: '/editor',
    component: () => import('./pages/Editor.vue'),
  },
];

// React
import { lazy, Suspense } from 'react';
const ChartHeavy = lazy(() => import('./components/ChartHeavy'));

function Page() {
  return (
    <Suspense fallback={<div>加载中…</div>}>
      <ChartHeavy />
    </Suspense>
  );
}

对照小结

对比项静态 import动态 import()
首包体积小,按需增长
首屏解析 JS
网络策略一次全拿可配合 prefetch 预取下一页

7. React:memo / useMemo / useCallback

常规写法

父组件任意 setState,子组件即使 props 没变也会重渲染;子树昂贵时白白 diff。

// 常规:每次父组件渲染,Child 都会执行(除非默认 Pure 行为不存在)
function Parent() {
  const [n, setN] = useState(0);
  const items = [1, 2, 3].map((i) => i * n);
  return (
    <>
      <button onClick={() => setN((x) => x + 1)}>{n}</button>
      <ExpensiveChild items={items} onSelect={(id) => console.log(id)} />
    </>
  );
}

优化写法

子组件用 memo;稳定引用用 useMemo / useCallback(仅当子组件确实昂贵或已用 memo 时再包)。

const ExpensiveChild = memo(function ExpensiveChild({ items, onSelect }) {
  return items.map((v) => (
    <button key={v} onClick={() => onSelect(v)}>
      {v}
    </button>
  ));
});

function Parent() {
  const [n, setN] = useState(0);
  const items = useMemo(() => [1, 2, 3].map((i) => i * n), [n]);
  const onSelect = useCallback((id) => {
    console.log(id);
  }, []);

  return (
    <>
      <button onClick={() => setN((x) => x + 1)}>{n}</button>
      <ExpensiveChild items={items} onSelect={onSelect} />
    </>
  );
}

对照小结

API解决什么问题滥用后果
memoprops 浅比较跳过子树渲染大列表每项都 memo 可能对比成本 > 渲染
useMemo避免昂贵计算、稳定引用简单计算反而多一次依赖比较
useCallback稳定函数引用配合 memo依赖乱写导致闭包陈旧 bug

8. 列表虚拟化(windowing)

常规写法

一万条数据 map 出一万个 DOM,首屏创建节点、样式计算、内存都爆炸,滚动掉帧。

// 常规:全量 DOM
function List({ data }) {
  return (
    <ul>
      {data.map((row) => (
        <li key={row.id}>{row.text}</li>
      ))}
    </ul>
  );
}

优化写法

只挂载「视口 + 缓冲」内的行,滚动时改 transform 或复用行组件。(以下为原理级伪代码,实际项目用 react-window@tanstack/react-virtualvue-virtual-scroller 等。)

// 思路:总高度用占位撑开,可见区只渲染 window 内的项
function VirtualList({ data, rowHeight, height }) {
  const [scrollTop, setScrollTop] = useState(0);
  const totalHeight = data.length * rowHeight;
  const start = Math.floor(scrollTop / rowHeight);
  const end = Math.min(
    data.length,
    Math.ceil((scrollTop + height) / rowHeight) + 2
  );
  const visible = data.slice(start, end);

  return (
    <div
      style={{ height, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${start * rowHeight}px)` }}>
          {visible.map((row, i) => (
            <div key={row.id} style={{ height: rowHeight }}>
              {row.text}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

CSS 解法:content-visibility: auto —— CSS 里的「虚拟滚动」

和 JS 虚拟列表不是一回事,但目标部分重叠:列表仍然是「一万个节点都在 DOM 里」,但浏览器对当前视口外的子树可以跳过绘制与布局等渲染工作(近似「只认真渲染看得见的那一段」),首屏和长列表滚动时常明显更轻。它不减少节点数量与内存占用,不能替代真正 windowing 去扛「一万个 React 组件同时存在」这类 JS 成本;适合结构简单、行内主要是静态内容或轻量 DOM 的长页/Feed。

常规写法

长列表每个区块都是普通块,浏览器会尽量把整个文档树都参与布局与绘制,滚动前也可能做大量无用工作。

<ul class="feed">
  <li class="feed-item"></li>
  <!-- 重复数千条,无提示 -->
</ul>

优化写法

每一条列表项(或每一个独立大块)加上 content-visibility: auto。进入视口附近时浏览器再「补齐」渲染;离开视口又可跳过。

务必配合 contain-intrinsic-size(或旧写法里用预估高度),否则未渲染区域高度会被当成 0,滚动条长度乱跳、滚动位置漂移auto 后的值是「占位高度」提示,尽量接近真实平均行高。

<ul class="feed">
  <li class="feed-item"></li>
  <li class="feed-item"></li>
</ul>
.feed-item {
  content-visibility: auto;
  /* 占位高度:接近单行/单卡真实高度,减少滚动条跳动 */
  contain-intrinsic-size: auto 120px;
}

/* 若整条列表可视为独立区域,也可在外层加 contain(按文档结构取舍) */
.feed {
  contain: layout style paint;
}

说明:contain-intrinsic-size: auto 120px 表示「在尚未精细测量前,先假设这块在块轴上约占 120px」;实际进入视口渲染后会用真实高度修正(具体行为以浏览器实现为准,不同版本略有差异)。动态行高差异极大时,可取分位数或略大于中位数的高度,在「跳动」与「占位误差」之间折中。

与 JS 虚拟列表(windowing)对照

对比项全量列表content-visibility: autoJS 虚拟列表
DOM 节点数O(n)O(n)(仍在文档里)O(视口)
内存(节点+布局)中高(节点仍在)
首屏/滚动渲染压力明显降低(跳过屏外绘制/布局)
React/Vue 等组件实例仍 n 个仍 n 个仅窗口内
实现成本最低几行 CSS(要调占位高度)高(窗口、缓冲、键盘聚焦等)
适用短列表静态/轻量 DOM 长列表、文章流超大量数据、重组件、表格

选型建议:能改 DOM 结构且以原生内容为主时,先尝试 content-visibility + contain-intrinsic-size,改动小、收益常不错;若瓶颈是 JS 实例数、复杂子树 diff、表格合并单元格 等,仍应用虚拟列表或分页。

对照小结(本节总表)

对比项全量列表CSS content-visibilityJS 虚拟列表
本质无优化屏外跳过渲染工作,DOM 仍在屏外不挂载(或复用少量节点)
首屏成本中低(取决于块大小与占位)
实现成本低(注意占位高度)

9. Web Worker

常规写法

主线程解析 10MB JSON、做图像卷积等,长时间占用导致点击无响应、动画卡死

// 常规:主线程一把梭
fetch('/api/big.json')
  .then((r) => r.json())
  .then((data) => {
    const result = heavyTransform(data); // UI 冻结数秒
    render(result);
  });

优化写法

把 CPU 密集段放到 Worker;大数据用 postMessage 的第二个参数传 Transferable 避免拷贝。

// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), {
  type: 'module',
});

worker.postMessage({ url: '/api/big.json' });
worker.onmessage = (e) => {
  render(e.data.result);
};

// worker.js
async function heavyTransform(data) {
  /* ... */
}

self.onmessage = async (e) => {
  const res = await fetch(e.data.url);
  const data = await res.json();
  const result = await heavyTransform(data);
  self.postMessage({ result });
};

对照小结

对比项主线程计算Web Worker
UI 响应易被阻塞主线程可继续处理输入/动画
访问 DOM可以不可以
数据传递无序列化结构化克隆;大缓冲可 transfer

10. DOM:批量读写、减少强制同步布局

常规写法

循环里先读 offsetHeight 再写 style,每次读都可能触发 layout,形成「布局抖动」。

// 常规:读写交替 → 多次强制同步布局
const boxes = document.querySelectorAll('.box');
for (let i = 0; i < boxes.length; i++) {
  const h = boxes[i].offsetHeight; // 读布局
  boxes[i].style.height = h + 10 + 'px'; // 写样式 → 下一循环又读,反复 layout
}

优化写法

先读后写:第一轮收集所有几何信息,第二轮统一改样式;或用 classList 一次切换。

const boxes = document.querySelectorAll('.box');
const heights = Array.from(boxes).map((el) => el.offsetHeight);
boxes.forEach((el, i) => {
  el.style.height = heights[i] + 10 + 'px';
});

批量挂载:用 DocumentFragment 减少多次插入引起的重排。

// 常规:循环 append → 每次可能触发布局
// data.forEach(item => container.appendChild(createNode(item)));

const frag = document.createDocumentFragment();
data.forEach((item) => frag.appendChild(createNode(item)));
container.appendChild(frag);

对照小结

对比项读写交错批量读 + 批量写
layout 次数
适用简单单次循环内多元素

11. 事件:passive: true

常规写法

wheel / touchstart 等监听器默认可能 preventDefault,浏览器必须同步等你执行完才能滚动,易造成滚动卡顿。

// 常规:未声明 passive,浏览器保守等待
el.addEventListener('wheel', (e) => {
  trackScrollDelta(e.deltaY); // 实际并未 preventDefault
});

优化写法

确定不会调用 preventDefault 时声明 passive: true,浏览器可异步滚动优化。

el.addEventListener(
  'wheel',
  (e) => {
    trackScrollDelta(e.deltaY);
  },
  { passive: true }
);

对照小结

对比项默认(非 passive){ passive: true }
滚动性能可能阻塞主线程等待监听可优化为更流畅滚动
preventDefault可用调用无效(需注意)

12. 资源与图片:loading / srcset / fetchpriority

常规写法

所有 <img> 首屏一次性请求原图;小屏也下 4K 图;首屏关键图与装饰图同等优先级。

<!-- 常规:全部立即加载、单一大图 -->
<img src="https://cdn.example.com/hero-3840.jpg" alt="" />
<img src="https://cdn.example.com/footer-icon.png" alt="" />

优化写法

<!-- 首屏关键图:提高优先级(支持度因浏览器而异) -->
<img
  fetchpriority="high"
  src="https://cdn.example.com/hero-800.jpg"
  srcset="
    https://cdn.example.com/hero-400.jpg 400w,
    https://cdn.example.com/hero-800.jpg 800w
  "
  sizes="(max-width: 600px) 100vw, 50vw"
  alt=""
/>

<!-- 视口外:延迟加载 -->
<img loading="lazy" src="article-figure.jpg" alt="" />

对照小结

手段常规优化效果
loading全部并发抢带宽lazy 延后非首屏请求
src一刀切分辨率srcset+sizes 匹配 DPR 与布局宽度
无优先级关键图可能晚于低价值资源fetchpriority 微调(谨慎使用)

13. performance.now()PerformanceObserver

常规写法

Date.now() 测耗时受系统时钟调整影响;无法系统性地订阅 LCP、长任务 等指标。

// 常规:测一段逻辑
const t0 = Date.now();
doWork();
console.log(Date.now() - t0);

优化写法

const t0 = performance.now();
doWork();
console.log(performance.now() - t0);

// 订阅最大内容绘制 LCP(需在实际业务中配合上报)
try {
  const po = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const last = entries[entries.length - 1];
    console.log('LCP:', last.renderTime || last.loadTime);
  });
  po.observe({ type: 'largest-contentful-paint', buffered: true });
} catch (_) {
  /* 不支持 */
}

对照小结

对比项Date.now()performance.now()
精度毫秒级,可能受时钟跳变影响亚毫秒、单调递增,适合测间隔
用途时间戳性能测量

14. CSS:content-visibility / contain / will-change

常规写法

长页面所有块都参与布局与绘制,即使用户尚未滚动到该区域。

/* 常规:无提示,浏览器全力渲染整页 */
.section {
  padding: 2rem;
}

优化写法

/* 视口外大块:跳过渲染工作(需保证高度不被错误折叠,可配合 min-height 或骨架) */
.section {
  content-visibility: auto;
  contain-intrinsic-size: auto 400px;
}

/* 独立卡片:限制重排/重绘范围 */
.card {
  contain: layout paint;
}

/* 仅短期动画前使用,动画结束后移除,避免长期占 GPU */
.slide-in {
  will-change: transform;
}

对照小结

属性常规(无)使用注意
content-visibility全部渲染错误高度会导致滚动条跳动
contain影响可能扩散到整文档子元素 fixed 等行为会受 contain 影响
will-change无额外层滥用 = 显存与合成成本上升

小结表

方向常规痛点典型优化手段
动画setInterval 与刷新不同步requestAnimationFrame
低优任务setTimeout 乱插队requestIdleCallback + 降级
懒加载滚动里算 rectIntersectionObserver
元素尺寸window.resize / 轮询ResizeObserver
高频事件每次触发全量执行debounce / throttle
包体积静态全量 import动态 import、路由分割
React子组件连带渲染memo + useMemo / useCallback
长列表万级 DOMcontent-visibility(轻量)或 JS 虚拟列表(重组件/大数据)
重计算主线程阻塞Web Worker
DOM读写交错批量读写在 DocumentFragment
滚动监听默认 passive{ passive: true }
图片全量、单分辨率lazysrcsetfetchpriority
测量Date.now()performance.now()PerformanceObserver
长页 CSS全域绘制content-visibilitycontain