跳到主要内容

首屏优化

问题

如何优化网页首屏加载速度?关键渲染路径是什么?有哪些具体的优化手段?

面试速答版

什么是关键渲染路径? 指浏览器把 HTML/CSS/JS 变成屏幕像素的过程:

  • 流程是 DOM 构建 → CSSOM 构建 → 合并成 Render Tree → Layout → Paint → Composite
  • 关键资源指:阻塞渲染的 HTML、CSS、同步 JS。优化目标是减少关键资源数量、缩小关键资源体积、缩短关键路径长度
  • CSS 默认阻塞渲染(构建 CSSOM),同步 JS 默认阻塞解析,所以减阻塞是首屏优化的核心。

怎么优化首屏加载? 按「网络 → 解析 → 渲染」三段优化:

  • 网络:HTTP/2/3 多路复用、CDN 就近、preconnect/dns-prefetch 提前建连、关键资源 preload
  • 解析:JS 加 defer 不阻塞 HTML 解析、CSS 关键样式内联到 HTML 里、非关键 CSS 用 media 异步加载。
  • 渲染:按页面选择 SSR/SSG/Streaming,让关键内容更早到达;对唯一高概率 LCP 图片考虑 fetchpriority="high",骨架屏必须与最终布局稳定对应。
  • 代码层:路由级代码分割(React.lazy + Suspense)。例如在中端移动设备、冷缓存、普通 4G 的营销页上,可以先把首屏压缩后 JS 100KB 作为挑战性初始预算,再根据真实基线调整。

有哪些具体可落地的手段? 常用清单:

  • 资源:图片用 WebP/AVIF + srcset 响应式;字体按场景选择 optional/swap 并匹配后备字体;Tree Shaking 去除无用代码。
  • 加载策略:应用脚本优先 defer/ESM;第三方脚本按同意状态和页面需要延迟,async 只改变执行顺序,不会减少 CPU 成本。
  • 缓存:合理设 Cache-Control、Service Worker 做预缓存。
  • 指标:用 Lighthouse 定位加载问题,用 web-vitals 监控真实 LCP;良好标准是移动端和桌面端页面访问样本 P75 ≤ 2.5s。

答案

首屏优化是前端性能优化的核心,目标是让用户尽快看到页面内容。涉及关键渲染路径优化、资源加载策略、渲染策略等多个方面。


关键渲染路径

关键渲染路径(Critical Rendering Path)是浏览器将 HTML、CSS、JavaScript 转换为像素的过程。

阶段说明优化方向
DOM 构建解析 HTML减少 HTML 大小
CSSOM 构建解析 CSS内联关键 CSS
Render Tree合并 DOM 和 CSSOM减少渲染阻塞
Layout计算元素位置减少重排
Paint绘制像素减少重绘
Composite图层合成使用合成属性

核心指标

指标全称含义目标值
FCPFirst Contentful Paint首次内容绘制< 1.8s
LCPLargest Contentful Paint最大内容绘制≤ 2.5s
INPInteraction to Next Paint交互响应≤ 200ms
CLSCumulative Layout Shift累积布局偏移≤ 0.1

FCP 是辅助加载指标;LCP/INP/CLS 的达标口径应分别按移动端和桌面端页面访问样本 P75 评估。TTI 仅用于理解旧版实验室报告,当前更常用 TBT 和实际交互 trace 定位主线程问题。


LCP 四阶段诊断

只说“压图片、上 CDN”很容易优化错方向。LCP 应按四段看:

阶段典型问题优化方向
TTFBCDN 回源、服务端取数、重定向慢边缘缓存、服务端并行、Streaming、Server-Timing
Resource Load Delay图片由 JS/CSS 晚发现、优先级低初始 HTML 可发现、选择性 preload/fetchpriority
Resource Load Duration文件大、CDN/网络慢响应式图片、压缩、缓存、就近分发
Element Render Delay主线程、Hydration、字体或样式阻塞减少首屏 JS、拆长任务、缩小渲染范围
先确认 LCP 元素

同一路由在手机、桌面、登录态和 A/B 版本中可能有不同 LCP 元素。RUM 应记录 LCP 元素和资源 URL,实验室再复现对应视口与内容。


优化策略总览


关键 CSS 内联

// 构建时提取关键 CSS
// vite-plugin-critical
import critical from 'vite-plugin-critical';

export default {
plugins: [
critical({
criticalUrl: 'http://localhost:3000',
criticalPages: [
{ uri: '/', template: 'index' }
],
criticalConfig: {
inline: true,
dimensions: [
{ width: 375, height: 667 }, // 移动端
{ width: 1920, height: 1080 } // 桌面
]
}
})
]
};
<!-- 内联关键 CSS -->
<head>
<style>
/* 关键 CSS,首屏立即渲染 */
.header { ... }
.hero { ... }
</style>
<!-- 非关键 CSS 异步加载 -->
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>
</head>

JS 加载优化

defer 和 async

<!-- 阻塞渲染(避免) -->
<script src="app.js"></script>

<!-- 异步加载,乱序执行 -->
<script async src="analytics.js"></script>

<!-- 异步加载,顺序执行,DOMContentLoaded 前 -->
<script defer src="app.js"></script>

代码分割

// React 路由懒加载
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));

function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
);
}
// Vite 配置分包
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor': ['react', 'react-dom'],
'ui': ['antd', '@ant-design/icons'],
'utils': ['lodash-es', 'dayjs']
}
}
}
}
};

预加载策略

<!-- DNS 预解析 -->
<link rel="dns-prefetch" href="//cdn.example.com">

<!-- 预连接(DNS + TCP + TLS) -->
<link rel="preconnect" href="https://api.example.com">

<!-- 预加载“晚发现但当前页面确定需要”的关键资源 -->
<link rel="preload" href="critical.css" as="style">
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>

<!-- 响应式 LCP 图片预加载要带 imagesrcset/imagesizes -->
<link
rel="preload"
as="image"
imagesrcset="hero-640.avif 640w, hero-1280.avif 1280w"
imagesizes="100vw"
fetchpriority="high"
>

<!-- 预获取下一页资源 -->
<link rel="prefetch" href="next-page.js">

preload 是强提示,使用过多会与 CSS、LCP 图片和主脚本争抢带宽;preconnect 也会占用连接。下一页预渲染应评估现代 Speculation Rules,并基于真实导航概率、用户流量和 Save-Data 等条件谨慎启用。

<script type="speculationrules">
{
"prerender": [{
"where": { "href_matches": "/products/*" },
"eagerness": "moderate"
}]
}
</script>
// 动态预加载
function preloadNextPage(url: string): void {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
document.head.appendChild(link);
}

// 基于用户行为预加载
document.querySelector('.nav-link')?.addEventListener('mouseenter', () => {
preloadNextPage('/next-page.js');
});

骨架屏

// 骨架屏组件
function Skeleton({ width, height, circle = false }: {
width?: string | number;
height?: string | number;
circle?: boolean;
}) {
return (
<div
className="skeleton"
style={{
width,
height,
borderRadius: circle ? '50%' : '4px'
}}
/>
);
}

// 列表骨架屏
function ListSkeleton({ count = 5 }: { count?: number }) {
return (
<div className="list-skeleton">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="item-skeleton">
<Skeleton width={48} height={48} circle />
<div className="content">
<Skeleton width="60%" height={16} />
<Skeleton width="80%" height={14} />
</div>
</div>
))}
</div>
);
}

// 使用
function UserList() {
const { data, isLoading } = useQuery('users', fetchUsers);

if (isLoading) return <ListSkeleton count={10} />;

return (
<ul>
{data.map(user => <UserItem key={user.id} user={user} />)}
</ul>
);
}
/* 骨架屏动画 */
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e8e8e8 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

SSR 服务端渲染

// Next.js SSR
export async function getServerSideProps() {
const data = await fetchData();

return {
props: { data }
};
}

function Page({ data }: { data: DataType }) {
return <div>{/* 渲染数据 */}</div>;
}
// React 18 Streaming SSR
import { renderToPipeableStream } from 'react-dom/server';
import { Suspense } from 'react';

app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(
<Suspense fallback={<Loading />}>
<App />
</Suspense>,
{
bootstrapScripts: ['/main.js'],
onShellReady() {
res.setHeader('Content-Type', 'text/html');
pipe(res);
}
}
);
});

资源优先级

<!-- 高优先级资源 -->
<img src="hero.webp" fetchpriority="high" alt="Hero">

<!-- 低优先级资源 -->
<img src="below-fold.jpg" fetchpriority="low" loading="lazy" alt="">
<script src="analytics.js" fetchpriority="low" async></script>
// Fetch API 优先级
fetch('/api/critical-data', {
priority: 'high'
});

fetch('/api/secondary-data', {
priority: 'low'
});

fetchpriority 是相对优先级提示,不保证绝对顺序。通常只给当前视口唯一或极少数高概率 LCP 资源设置 high;如果图片本来就在初始 HTML 中,先尝试只加 fetchpriority,不要为了“更快”再无条件重复 preload。


避免布局偏移(CLS)

<!-- 始终指定图片尺寸 -->
<img src="image.jpg" width="800" height="600" alt="">

<!-- 使用 aspect-ratio -->
<img
src="image.jpg"
style="aspect-ratio: 16/9; width: 100%;"
alt=""
>
/* 为动态内容预留空间 */
.ad-container {
min-height: 250px;
}

/* 字体加载策略 */
@font-face {
font-family: 'CustomFont';
src: url('font.woff2') format('woff2');
font-display: swap; /* 或 optional */
}

骨架屏、Streaming 与第三方脚本

骨架屏改善感知,但不等于加载变快

  • 骨架布局应与最终内容尺寸一致,否则替换时产生 CLS。
  • 如果骨架是视口内最大元素,它可能被记录为 LCP 候选;复杂闪烁动画还会增加绘制成本。
  • 数据在 100–200ms 内完成时,骨架一闪而过反而更差,可设置短延迟和最短展示时间。
  • 启动失败时必须从骨架进入错误/重试状态,不能无限 loading。

Streaming 的价值

传统 SSR 等所有接口完成后才返回 HTML,慢模块仍会拖住整页。Streaming 配合 Suspense 可以先返回稳定壳和关键内容,再流式补充慢区域;前提是 Suspense 边界靠近慢区域,并避免客户端 Hydration 形成新的请求 waterfall。

第三方脚本也属于首屏预算

埋点、广告、客服、A/B 和同意管理脚本既占网络也占主线程。应按页面与用户授权条件加载,记录第三方域名、传输体积、CPU 时间和长任务。async/defer 不会减少脚本执行成本,关键首屏期间仍可能影响 LCP 与 INP。


完整优化清单

const firstScreenOptimizationChecklist = {
// 资源优化
resources: [
'启用 Gzip/Brotli 压缩',
'使用 WebP/AVIF 图片格式',
'压缩和内联关键 CSS',
'代码分割和懒加载',
'移除未使用的代码(Tree Shaking)'
],

// 加载优化
loading: [
'使用 CDN',
'配置强缓存',
'DNS 预解析和预连接',
'仅预加载晚发现且确定需要的关键资源',
'defer/async 脚本'
],

// 渲染优化
rendering: [
'骨架屏或加载指示器',
'SSR/SSG',
'避免布局偏移',
'设置资源优先级'
],

// 监控
monitoring: [
'监控 Core Web Vitals',
'按 LCP 四阶段定位',
'监控第三方网络与 CPU',
'设置性能预算',
'定期 Lighthouse 检测'
]
};

常见面试问题

Q1: 如何优化首屏加载时间?

答案

// 1. 减少关键资源
// - 内联关键 CSS
// - defer/async JS
// - 代码分割

// 2. 减少资源大小
// - 压缩(Gzip/Brotli)
// - Tree Shaking
// - 图片优化

// 3. 提前加载
// - dns-prefetch
// - preconnect
// - preload 关键资源

// 4. 架构优化
// - SSR/SSG
// - 骨架屏
// - 流式渲染

Q2: 什么是关键渲染路径?如何优化?

答案

关键渲染路径是浏览器将 HTML、CSS、JS 转换为屏幕像素的步骤。

优化策略

步骤优化方法
DOM减少 HTML 嵌套,精简标签
CSSOM内联关键 CSS,异步加载非关键 CSS
JavaScriptdefer/async,代码分割
Render Tree减少渲染阻塞资源
Layout/Paint避免强制同步布局

Q3: preload、prefetch、preconnect 的区别?

答案

指令优先级用途时机
preload当前页面关键资源立即
prefetch下一页资源空闲时
preconnect建立连接立即
dns-prefetchDNS 解析空闲时
<!-- preload: 当前页必需 -->
<link rel="preload" href="main.js" as="script">

<!-- prefetch: 下一页可能需要 -->
<link rel="prefetch" href="next.js">

<!-- preconnect: 第三方域名 -->
<link rel="preconnect" href="https://api.example.com">

Q4: 如何避免 CLS(布局偏移)?

答案

<!-- 1. 图片指定尺寸 -->
<img src="image.jpg" width="800" height="600">

<!-- 2. 使用 aspect-ratio -->
<div style="aspect-ratio: 16/9;">
<img src="image.jpg" style="width: 100%; height: 100%;">
</div>

<!-- 3. 预留广告位空间 -->
<div class="ad-slot" style="min-height: 250px;"></div>

<!-- 4. 字体加载策略 -->
<style>
@font-face {
font-family: 'MyFont';
src: url('font.woff2');
font-display: swap;
}
</style>

<!-- 5. 动态内容使用骨架屏 -->

Q5: SSR 和 CSR 的优缺点?

答案

对比SSRCSR
首屏速度有机会更早呈现,取决于 TTFB/Streaming取决于 HTML 壳、JS 与数据请求
SEO差(需处理)
服务器压力可通过缓存/边缘渲染降低页面渲染压力低,但接口和客户端成本仍存在
交互响应Client Components 仍需 HydrationJS 执行后可交互,启动期也可能阻塞
开发复杂度
缓存策略复杂简单

选择建议

  • 内容型网站(博客、新闻)→ SSR/SSG
  • 应用型网站(后台、工具)→ CSR
  • 混合需求 → 部分 SSR + CSR

相关链接