1. requestAnimationFrame / cancelAnimationFrame
常规写法
用 setInterval 或 setTimeout 驱动动画时,间隔与屏幕刷新率无关;后台标签页仍会按固定频率执行,浪费 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);
}
对照小结
| 对比项 | 常规 setInterval | requestAnimationFrame |
|---|---|---|
| 与刷新率 | 脱钩,可能掉帧或多余帧 | 与显示刷新对齐 |
| 后台标签 | 常仍执行 | 通常节流或暂停 |
| 取消方式 | clearInterval | cancelAnimationFrame |
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));
对照小结
| 对比项 | 滚动里算 getBoundingClientRect | IntersectionObserver |
|---|---|---|
| 触发频率 | 与滚动事件绑定,极高 | 仅在可见性变化时 |
| 布局读取 | 易形成强制同步布局 | 由浏览器优化 |
| 代码复杂度 | 需手写 root、margin 逻辑 | 配置 rootMargin 即可 |
4. ResizeObserver
常规写法
监听 window 的 resize 只能感知视口,感知不到父容器被侧栏拖动、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 | 解决什么问题 | 滥用后果 |
|---|---|---|
memo | props 浅比较跳过子树渲染 | 大列表每项都 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-virtual、vue-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: auto | JS 虚拟列表 |
|---|---|---|---|
| DOM 节点数 | O(n) | O(n)(仍在文档里) | O(视口) |
| 内存(节点+布局) | 高 | 中高(节点仍在) | 低 |
| 首屏/滚动渲染压力 | 高 | 明显降低(跳过屏外绘制/布局) | 低 |
| React/Vue 等组件实例 | 仍 n 个 | 仍 n 个 | 仅窗口内 |
| 实现成本 | 最低 | 几行 CSS(要调占位高度) | 高(窗口、缓冲、键盘聚焦等) |
| 适用 | 短列表 | 静态/轻量 DOM 长列表、文章流 | 超大量数据、重组件、表格 |
选型建议:能改 DOM 结构且以原生内容为主时,先尝试 content-visibility + contain-intrinsic-size,改动小、收益常不错;若瓶颈是 JS 实例数、复杂子树 diff、表格合并单元格 等,仍应用虚拟列表或分页。
对照小结(本节总表)
| 对比项 | 全量列表 | CSS content-visibility | JS 虚拟列表 |
|---|---|---|---|
| 本质 | 无优化 | 屏外跳过渲染工作,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 + 降级 |
| 懒加载 | 滚动里算 rect | IntersectionObserver |
| 元素尺寸 | window.resize / 轮询 | ResizeObserver |
| 高频事件 | 每次触发全量执行 | debounce / throttle |
| 包体积 | 静态全量 import | 动态 import、路由分割 |
| React | 子组件连带渲染 | memo + useMemo / useCallback |
| 长列表 | 万级 DOM | content-visibility(轻量)或 JS 虚拟列表(重组件/大数据) |
| 重计算 | 主线程阻塞 | Web Worker |
| DOM | 读写交错 | 批量读写在 DocumentFragment |
| 滚动监听 | 默认 passive | { passive: true } |
| 图片 | 全量、单分辨率 | lazy、srcset、fetchpriority |
| 测量 | Date.now() | performance.now()、PerformanceObserver |
| 长页 CSS | 全域绘制 | content-visibility、contain |