MODO®
ISSUE 07 / 2026
01 工程手记 / Engineering

Web 性能优化实战

从 3.2 秒到 0.8 秒的完整路径。一份基于真实数据的优化手记,记录一个数据看板项目的三轮迭代。

作者 林知远
日期 2026.09.30
时长 12 分钟
标签 性能 · 前端

三个月前,我们接手了一个数据看板项目:首屏 LCP 高达 3.2s,滚动时偶发卡顿,用户投诉「点进去要等半天」。经过三轮迭代,最终把 LCP 压到 0.8s,INP 从 340ms 降到 96ms。

这篇文章记录完整的优化过程。核心观点只有一句话——没有度量,就没有优化。所有「感觉会变快」的改动,都必须由数据背书。

01

先测量,再优化

最常见的错误是拿到项目就开始「加缓存、上 CDN、压缩图片」。这些手段本身没错,但在不知道瓶颈在哪的情况下,你无法判断哪一项真正有效,也无法证明改动带来了收益。

三个核心指标

现代 Web 性能度量围绕 Core Web Vitals 展开。对我们这个看板项目来说,真正影响体验的是下面三项:

指标 含义 良好 优化前
LCP 最大内容绘制 < 2.5s 3.2s
INP 交互到下次绘制 < 200ms 340ms
CLS 累计布局偏移 < 0.1 0.24

CLS 偏高通常意味着资源尺寸未声明或字体加载导致回流,这一点在第二节会详细展开。先用 Lighthouse 和真实用户监控(RUM)拿到基线数据,再动手。

NOTE

本地 Lighthouse 跑分容易失真。请务必接入真实用户的 web-vitals 上报,实验室数据与现场数据的差距往往超过 40%。

建立性能预算

性能预算把「快」变成可执行的数字。我们把它写进 CI,一旦超出阈值就让构建失败:

{
  "budgets": [
    { "metric": "LCP",       "target": 2500, "unit": "ms" },
    { "metric": "INP",       "target": 200,  "unit": "ms" },
    { "metric": "CLS",       "target": 0.1,  "unit": ""   },
    { "metric": "js-bundle", "target": 170,  "unit": "KB" }
  ]
}

预算一旦确立,后续每一次提交都有了明确的对错标准,团队讨论也从「我觉得还行」变成了「它超了 12KB,得砍掉点什么」。

没有度量,就没有优化。

— 本文核心原则
02

加载阶段:把首屏需要的东西最快送达

加载阶段的目标很明确:让浏览器尽早拿到渲染首屏所必需的最小资源集合。我们做了两件事——拆分资源、优化传输。

代码分割与预加载

改造前,整个应用被打包成一个 1.4MB 的 JS 文件。改造后按路由拆分,首屏只需要加载约 156KB:

// 路由级代码分割:首屏只加载必需的 chunk
const routes = [
  { path: '/',         load: () => import('./pages/Dashboard.js') },
  { path: '/reports',  load: () => import('./pages/Reports.js') },
  { path: '/settings', load: () => import('./pages/Settings.js') },
];

// 预加载:在浏览器空闲时提前拉取「下一个可能访问」的页面
function prefetch(path) {
  const route = routes.find((r) => r.path === path);
  if (route) route.load();
}

// 侧边栏 hover 时触发预加载,用户点击时资源已在缓存中
sidebar.addEventListener('mouseover', (e) => {
  const link = e.target.closest('[data-route]');
  if (link) prefetch(link.dataset.route);
});

同时,把关键资源提前告知浏览器,消除「发现延迟」:

<link rel="preconnect" href="https://cdn.example.com">
<link rel="preload" as="font" type="font/woff2"
      href="/fonts/inter-var.woff2" crossorigin>
<link rel="modulepreload" href="/assets/dashboard.js">

图片与字体

图片通常占据页面体积的一半以上。我们的做法是:

<picture>
  <source srcset="/img/chart.avif" type="image/avif">
  <source srcset="/img/chart.webp" type="image/webp">
  <img src="/img/chart.jpg" alt="营收趋势图"
       width="960" height="540" loading="lazy" decoding="async">
</picture>
TIP

字体务必使用 font-display: swap,并配合 size-adjust 调整回退字体的度量,避免字体切换瞬间的跳动。

03

渲染阶段:让浏览器少干活

资源送到了,接下来是渲染。这一阶段的核心是减少主线程的工作量,尤其是布局(Layout)与绘制(Paint)的开销。

减少重排与重绘

最典型的反面案例就是动画直接改 top / left / width,这些属性每帧都会触发完整的布局计算:

/* ❌ 差:每帧触发 Layout + Paint + Composite */
.card:hover {
  top: -6px;
  box-shadow: 0 14px 30px rgba(0, 0, 0, .18);
}

/* ✅ 好:transform 只走 Composite,跳过 Layout 与 Paint */
.card {
  transform: translateY(0);
  transition: transform .22s cubic-bezier(.4, 0, .2, 1);
}
.card:hover {
  transform: translateY(-6px);
}

此外还要避免「布局抖动」——即读写交替导致的强制同步布局:

// ❌ 每次循环都强制浏览器重新计算布局
for (const el of items) {
  el.style.height = el.offsetHeight + 10 + 'px';
}

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

长列表虚拟化

看板中有一张两万行的数据表。全量渲染时,首屏需要创建两万个 DOM 节点,滚动帧率直接掉到 18fps。

引入虚拟滚动后,无论数据多少,DOM 中始终只保留可视区域附近的约 30 个节点:

改造后滚动稳定在 58–60fps,内存占用下降约 70%。

04

运行时:让交互始终顺滑

加载优化解决的是「第一次见面的印象」,运行时优化决定的才是「长期相处是否愉快」。影响 INP 的头号敌人是长任务——任何连续占用主线程超过 50 毫秒的任务。

我们的处理方式是把大计算拆成可中断的小块:

// 把大任务切片,每处理一批就让出主线程
async function processInChunks(items, chunkSize = 50) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    chunk.forEach(handle);
    // 让出主线程,让浏览器有机会响应用户输入
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
}

// 若浏览器支持,优先使用 scheduler.yield(优先级继承,体验更好)
async function yieldToMain() {
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

同时,把非关键逻辑挪出主线程:

WARN

给所有事件都加 passive: true 并非万能。对于确实需要 preventDefault() 的场景,要改用非 passive 监听,否则会出现「滚动正常但手势失效」的诡异问题。

05

总结与收益复盘

三个月、三轮迭代,最终数据如下(取 P75 真实用户数据):

3.2→0.8s
LCP
340→96ms
INP
0.24→0.02
CLS
指标 优化前 优化后 变化
LCP 3.2s 0.8s ↓ 75%
INP 340ms 96ms ↓ 72%
CLS 0.24 0.02 ↓ 92%
首屏 JS 1.4MB 156KB ↓ 89%

回头看,真正起作用的其实只有四件事:

性能优化没有终点,只有持续的权衡。希望这条路径能帮你少走一些弯路。如果你在实践中遇到了别的瓶颈,欢迎在评论区一起讨论。

性能优化 Web Vitals 前端工程 实践总结