前端架构与性能优化那些事儿
Category(分类): Browser, Performance, Architecture Status: 已更新
原文从“雅虎军规”、压缩与合并、HTTP/2、多种缓存、CSS/JS 阻塞、渲染流水线、GPU 加速、SPA/MPA 和离线缓存等角度讨论性能。本文尽量保留这些学习材料和题目,但删除固定连接数、错误的压缩结论、绝对化的 GPU 说法,并补充 HTTP/3、现代资源加载、Core Web Vitals、SSR/SSG、Service Worker 和代码分割。
零、写在前面
性能优化不是把某个数字调到最小,也不是背一份永远不变的军规。它是围绕用户任务,在网络、CPU、内存、渲染和业务约束之间做取舍:
测量真实问题 -> 找到瓶颈 -> 做最小改动 -> 验证收益 -> 持续回归
大型 B 端应用、移动端页面、内容站、后台管理系统和 Web 游戏的瓶颈不同。一个页面可能网络很快,却被 JavaScript 长任务阻塞;也可能主线程很空闲,但首屏图片太大或服务端 TTFB 很高。
一、为什么要进行性能优化
用户对加载速度和交互响应敏感,性能还可能影响转化、留存、可访问性和基础设施成本。原文引用的“3 秒”“每慢 1 秒损失多少”来自特定时间、样本和业务环境,不能当成所有网站都成立的普遍常数。
更稳妥的做法是建立自己的基线:
- 关注 LCP、INP、CLS 等用户体验指标的 p75/p95;
- 按设备、网络、地区、页面类型和版本分组;
- 同时观察错误率、转化率、留存和服务器成本;
- 先测量再优化,不要为了追求 Lighthouse 分数牺牲可用性和可维护性。
PV(Page View)是页面浏览量,UV(Unique Visitor)是去重后的访客数。它们是流量指标,不是性能指标,但可以和性能分布关联分析。
二、历史上的“雅虎军规”
原文提到的 Yahoo Performance Best Practices 在前端性能学习史上很有代表性:

1. 内容和请求
历史建议:
- 减少不必要的 HTTP 请求;
- 减少 DNS 查找和重定向;
- 让可缓存的 AJAX 响应可缓存;
- 延迟加载非关键组件,预加载确定会使用的关键组件;
- 减少不必要的 DOM 节点;
- 避免 404 和无意义的空资源 URL;
- 谨慎使用 iframe。
现代补充:
- HTTP/2 和 HTTP/3 的多路复用降低了“合并所有文件”的必要性,但请求数量和响应字节仍有成本;
- 重点是减少关键路径上的资源和字节,而不是机械追求“请求越少越好”;
- 资源提示(
preload、preconnect、prefetch、fetchpriority)要依据真实关键路径使用,滥用会抢占真正关键资源; - 重定向会增加导航延迟,CDN、HTTPS、www 规范化和登录跳转应尽量减少链路;
- 代码分割应按路由和交互边界拆分,不能为了减少初始包把用户点击后的所有内容都变成瀑布请求。
2. CSS
历史建议:
- 样式表尽量放在文档头部;
- 避免 CSS 表达式和旧式滤镜;
- 谨慎使用
@import; - 让关键 CSS 尽早可用。
现代补充:
<link rel="stylesheet">通常会阻塞首次渲染,因为浏览器需要 CSSOM 才能安全计算样式;- 不要把整个大型 CSS 框架作为所有页面的关键资源,可按路由拆分并移除未使用 CSS;
- 关键 CSS 内联或
preload需要用真实指标验证,内联过多会增加 HTML 和缓存成本; - 使用
content-visibility、contain、虚拟列表等方式减少不可见大区域的初始工作,但要测试可访问性、滚动和查找行为; - CSS-in-JS 不是天然的性能优化,运行时注入样式也可能增加 JavaScript 和样式计算成本。
3. JavaScript
历史建议:
- 移除重复脚本;
- 减少不必要的 DOM 查询和强制布局;
- 使用事件委托;
- 把非关键脚本延后。
现代补充:
- 优先使用
<script type="module">、defer或框架的代码分割; async适合互相独立的脚本,执行顺序不保证;defer保持文档顺序,并在 HTML 解析完成后执行;- 降低初始 JavaScript 字节只是第一步,还要减少解析、编译、执行和水合成本;
- 第三方脚本要有预算、延迟加载和失败隔离;
- 长任务会阻塞输入和渲染,可以拆分任务、批量更新、使用 Worker 或延迟非关键计算。
4. 静态资源、Cookie 和服务器
历史建议:
- 压缩 JS/CSS;
- 优化图片和雪碧图;
- 不要用 HTML 把大图缩小显示;
- 减小 Cookie;
- 使用 CDN;
- 配置 Gzip、ETag、
Expires和Cache-Control。
现代补充:
- 图片应使用合适尺寸和格式,提供
srcset/sizes,为非首屏图片使用loading="lazy"; - JPEG、PNG、WebP、AVIF 的选择取决于内容和浏览器支持,不能只看文件扩展名;
- Brotli(
br)和 Gzip 主要用于 HTML、CSS、JavaScript、JSON、SVG 等文本,通常不要再对已压缩的 JPEG/PNG 进行 HTTP Brotli/Gzip; - 内容指纹资源可以长缓存,HTML 入口需要及时验证;
- CDN 不只是“多开几个连接”,还涉及距离、缓存命中、TLS、压缩、失效和回源策略;
- Cookie 会自动发送到匹配的请求,静态资源可以使用独立无 Cookie 域,但要结合安全策略、CORS 和缓存分区设计;
ETag和缓存策略要依据实际缓存链路配置,不要把旧的字段优先级表当成固定规则。
三、文件压缩、合并与 HTTP/2/HTTP/3
压缩和合并确实存在取舍:合并能减少请求,但可能让不同页面下载不需要的代码,也会降低缓存复用;拆分能实现按需加载,但拆得过细会形成请求瀑布。
现代项目应根据关键路径和构建产物做决定:
- HTML、CSS、JS、JSON、SVG 等文本使用 Brotli 或 Gzip;
- 生产构建移除调试代码、压缩语法、压缩 source map(source map 需单独保护);
- 图片在源文件阶段压缩,并输出适合显示尺寸的变体;
- 使用内容哈希文件名,让长期缓存和快速更新同时成立;
- 对首屏必需资源使用有限的
preload或fetchpriority="high",不要给所有资源加高优先级。
BR 和 Gzip 的区别
Brotli(br)和 Gzip 都是 HTTP 内容编码,服务器根据请求的 Accept-Encoding 选择并返回 Content-Encoding。Brotli 在许多文本场景下压缩率较好,但压缩级别、CPU 成本和服务端配置要综合考虑。
Accept-Encoding: br, gzip
Content-Encoding: br
Vary: Accept-Encoding
“Brotli 对图片压缩效果非常好”是不准确的。JPEG、PNG 等格式已经有内部压缩,再套一层 Brotli/Gzip 通常收益很小,可能浪费 CPU;图片优化应在编码格式、质量、尺寸和响应式变体上进行。
HTTP/2
HTTP/2 可以在一条连接上复用多个带优先级的流,并提供头部压缩。它缓解了 HTTP/1.1 下队头阻塞和连接数管理的部分问题,但浏览器、代理和服务端仍然可能受连接、带宽和 HTTP/2 TCP 队头阻塞影响。

“同一域名所有请求永远只有一路连接”和“Apache 最大连接数因此直接提升 60 倍”都不是通用结论。浏览器可能因不同网络地址、代理、连接状态和协议协商建立多个连接。
HTTP/3
HTTP/3 使用 QUIC 和 UDP,提供基于流的传输,并减少 TCP 层队头阻塞的影响。是否使用 HTTP/3 由客户端、服务端和中间网络协商,不能在前端代码中假设它一定存在。
Keep-Alive 与 HTTP/2 多路复用也不是一回事:
- HTTP/1.1 Keep-Alive 主要复用一条 TCP 连接,避免每个请求重新握手;
- HTTP/2 在连接上复用多条流;
- HTTP/3 使用 QUIC 连接和流。
四、离线缓存:不要用 localStorage 存资源正文
原文以 localStorage 保存资源映射,并提到 localForage、Service Worker 和 basket.js。这些材料体现了离线缓存的发展过程,但新项目不应把 localStorage 当作资源缓存层:它是同步 API,只存字符串,容量和淘汰受浏览器管理,保存大量脚本正文会阻塞主线程。
1. 推荐的现代分层
- HTTP 缓存:由响应头控制,适合普通网络资源;
- Service Worker + Cache API:适合离线 App Shell、静态资源和自定义网络策略;
- IndexedDB:适合结构化业务数据、离线队列和较大数据;
- localStorage:适合少量非敏感配置,不适合 Token、密集写入和大资源。
2. 版本化缓存示意
const CACHE_NAME = 'app-shell-v3'
const APP_SHELL = ['/', '/assets/app.8d31c2.js', '/assets/app.4fa91e.css']
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(APP_SHELL))
)
})
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys
.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
))
)
})
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return
event.respondWith(
caches.match(event.request).then(cached => {
const network = fetch(event.request).then(response => {
if (response.ok && response.type === 'basic') {
const copy = response.clone()
caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy))
}
return response
})
return cached || network
})
)
})
真实项目还要处理离线失败、缓存版本、更新提示、Opaque Response、空间配额和用户数据隐私。缓存静态资源时优先使用内容指纹 URL,避免旧缓存覆盖新版本。
五、缓存策略
原文给出了“cache-control > ecprise > etag > last-modified”的固定优先级。这个表存在拼写和概念问题:Expires 拼写错误,且新鲜度指令、验证器和请求条件不是同一层面的优先级链。
推荐按资源类型设计:
# 内容指纹 JS/CSS/字体
Cache-Control: public, max-age=31536000, immutable
# HTML 入口:允许存储,但复用前验证
Cache-Control: no-cache
# 个性化 API:只允许浏览器缓存并验证
Cache-Control: private, no-cache
# 不应被缓存保存的敏感响应
Cache-Control: no-store
ETag、Last-Modified 配合 If-None-Match、If-Modified-Since 可以返回 304 Not Modified。no-cache 不等于禁止存储,no-store 也不会自动删除已经存在的旧响应。完整缓存解释见 前端也要懂 HTTP 缓存机制。
六、浏览器解析、CSS、JavaScript 和渲染
1. JavaScript 会不会影响 HTML 解析和渲染?
原文示例:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>脚本加载</title>
</head>
<body>
<h1>标题</h1>
<script>
prompt('测试')
</script>
</body>
</html>
经典脚本在没有 async/defer 时会暂停 HTML 解析,下载和执行期间也会阻塞主线程;prompt() 还会暂停当前页面。它当然可能影响后续 HTML 解析和页面渲染。现代页面应避免同步长任务,并根据依赖关系使用:
<script src="/assets/analytics.js" async></script>
<script src="/assets/app.js" defer></script>
<script type="module" src="/assets/entry.js"></script>
async脚本下载不阻塞解析,下载完成后立即执行,多个脚本顺序不保证;defer经典脚本在解析完成后执行,并保持文档顺序;- 模块脚本默认延迟执行并支持依赖图;
- 模块或延迟脚本仍可能因 CSS、依赖和执行时间影响 DCL 和渲染。
2. CSS 会不会影响 HTML 解析和渲染?
CSS 通常是渲染阻塞资源:浏览器需要 CSSOM 才能安全构建渲染树。CSS 下载本身不一定让 HTML 解析完全停止,但会影响首次绘制;位于样式表之后的经典脚本可能等待样式表,因为脚本可能读取样式相关信息。
<link rel="stylesheet" href="/assets/app.css">
<script>
// 在这个位置的经典脚本可能等待前面的样式表完成
console.log(getComputedStyle(document.body).display)
</script>
CSS 后没有脚本时,样式表不一定阻止 HTML 解析;但“DOMContentLoaded 不直接等待样式表”也不代表 CSS 与 DCL 永远无关。延迟脚本会等待此前需要的样式表,样式计算和脚本执行可能间接延后 DCL。
不要用 setTimeout 证明 CSS 不阻塞解析:定时器只是把代码放进任务队列,不能消除资源依赖、主线程竞争或浏览器实现差异。
3. 关键渲染路径

可以把首次渲染简化为:
HTML -> DOM
CSS -> CSSOM
DOM + CSSOM -> Render Tree
Render Tree -> Style/Layout -> Paint -> Composite -> 屏幕
实际浏览器还会处理字体、图片、动画、滚动、合成层、可见区域和 GPU/CPU 任务。主线程、合成线程、栅格化线程和浏览器进程的划分属于实现细节,不应把某个版本的内部线程图当成所有浏览器都固定遵循的规则。
七、渲染性能优化
1. Layout、Paint 和 Composite
- Style/Recalculate Style:计算元素最终样式;
- Layout(布局):计算几何位置和尺寸;
- Paint(绘制):把文本、边框、背景和阴影等绘制命令记录下来;
- Rasterization(栅格化):把绘制命令变成像素,可能由多个线程或 GPU 协作完成;
- Composite(合成):把不同合成内容按位置、透明度和变换组合到屏幕。
修改 width、height、字体、内容和布局属性可能触发布局;修改颜色、阴影可能触发绘制;transform、opacity 在满足条件时通常更容易只触发合成。但这些是常见优化路径,不是绝对规则,元素复杂度和浏览器实现都会影响结果。
2. 什么会创建合成层?
根元素、视频、canvas、3D transform、动画中的 opacity/transform、固定定位、滤镜和 will-change 等都可能影响层化,但“写了 position 就一定独立成层”“写了 transform 就一定绕过重绘”都不准确。浏览器会基于内容、动画、滚动和内存成本做启发式决策。
will-change 是给浏览器的提前提示,不是通用加速开关:
.carousel {
will-change: transform;
}
.carousel.is-idle {
will-change: auto;
}
长期给大量元素设置 will-change 会占用内存,甚至让性能变差。不要为了“GPU 加速”到处添加 translateZ(0);优先使用合适的 CSS 动画并用 Performance 面板验证。
3. 轮播图为什么常用 transform?

使用 left/top 移动元素可能触发布局或绘制;使用 transform: translateX() 在很多场景下更容易走合成路径:
.carousel-track {
transform: translateX(var(--offset));
transition: transform 240ms ease;
}
这不是“GPU 直接跳过 DOM、Layout、Paint 的万能方案”:
- transform 只改变视觉变换,不改变文档流布局;
- 大面积图层会增加栅格化和显存成本;
- 文字、滤镜、透明度和重叠内容仍可能需要绘制;
- 要限制同时存在的图层数量,并在真实设备上验证。
4. 避免强制同步布局
读取几何属性不是必然触发布局;当浏览器有未提交的样式/DOM 写入且读取需要最新几何结果时,可能发生 forced synchronous layout:
// 较差:读写交替,可能反复触发布局
for (const item of items) {
item.style.width = `${container.offsetWidth}px`
}
// 较好:先读取,再集中写入
const width = container.offsetWidth
for (const item of items) {
item.style.width = `${width}px`
}
offsetWidth、scrollTop、getBoundingClientRect() 等读取要结合当前布局脏状态判断。box-sizing: border-box 方便尺寸计算,但不会让布局免费,也不能单独解决回流问题。
可以使用 requestAnimationFrame 把视觉写入安排到渲染帧附近,但不要把所有逻辑机械塞进 rAF:
let pending = false
function updatePosition() {
if (pending) return
pending = true
requestAnimationFrame(() => {
pending = false
track.style.transform = `translateX(${offset}px)`
})
}
5. 非可见内容
.long-section {
content-visibility: auto;
contain-intrinsic-size: 800px;
}
content-visibility: auto 可以让浏览器跳过远离视口内容的部分渲染工作,但要为滚动高度提供合理的估算,并测试查找、打印、辅助技术和动态内容。虚拟列表、分页和折叠内容也应根据实际 UX 使用。
八、从网络到渲染的实用优化清单
HTML 和资源加载
- 服务器尽早返回 HTML,减少 TTFB 和不必要重定向;
- 为关键 CSS、字体、LCP 图片规划加载优先级;
- 非首屏图片使用
loading="lazy",首屏图片不要误用 lazy; - 使用
srcset和sizes发送合适尺寸的图片; - 给图片写宽高或使用
aspect-ratio,减少 CLS; preconnect只用于少量关键跨源,preload要匹配最终as、类型和 CORS;- 使用
fetchpriority只调整确实重要的资源,不要给所有请求 high。
<img
src="/images/hero-1280.avif"
srcset="/images/hero-640.avif 640w, /images/hero-1280.avif 1280w"
sizes="(max-width: 768px) 100vw, 1200px"
width="1200"
height="675"
alt="产品介绍"
fetchpriority="high"
>
CSS、JavaScript 和第三方脚本
- 删除未使用 CSS 和重复依赖;
- 通过
defer、模块、路由级代码分割减少阻塞; - 降低水合范围,必要时使用 islands、部分水合或静态 HTML;
- 对第三方脚本设置加载时机、超时、权限和预算;
- 把大计算拆成小任务,避免单个任务长期占用主线程;
- 使用 Worker 处理适合并行的解析和计算,但注意消息复制、序列化和内存成本;
- 使用 DevTools Performance、Coverage、Network 和 Lighthouse 找到真正瓶颈。
缓存和部署
- 内容指纹静态资源并设置长缓存;
- HTML 使用协商缓存,确保新版本能获取新的资源 URL;
- 压缩文本,正确设置
Vary: Accept-Encoding; - CDN 缓存公共资源,个性化响应使用
private或no-store; - Service Worker 采用明确的版本和更新策略;
- source map、调试接口和管理端资源不能泄露敏感信息。
九、SPA、MPA、SSR 和混合架构
1. 纯 SPA
优点:
- 路由切换可以复用已加载的应用和状态;
- 适合交互密集、登录后使用、前后端分离的应用;
- 可以按路由和组件懒加载。
缺点:
- 首次加载需要下载、解析和执行 JavaScript,并可能进行水合或渲染;
- 首屏数据和应用初始化可能造成空白或 loading;
- SEO、分享预览和无 JavaScript 降级需要额外设计;
- 长时间运行的状态和事件监听可能产生内存问题。
2. 纯 MPA
优点:
- 服务端可以直接输出每个页面的 HTML;
- 内容首屏和 SEO 通常更直接;
- 每个页面边界清晰。
缺点:
- 跨页面导航可能重新加载文档和公共资源;
- 需要处理页面间状态、过渡和重复模板;
- 通过把所有页面资源合成一个大文件并不一定更快。
3. SSR、SSG、ISR 和渐进增强
现代框架可以组合:
- SSR:请求时生成 HTML,适合动态内容;
- SSG:构建时生成 HTML,适合内容站;
- ISR/增量生成:按时间或事件更新部分页面;
- 流式 SSR:逐步发送可用 HTML,但数据依赖和错误边界更复杂;
- 客户端增强:先提供可读 HTML,再加载交互脚本。
原文提到的“站内切换返回 JS/CSS/HTML,刷新时服务端渲染”可以理解为混合架构思路。现在应由框架的路由、数据加载、预取和缓存机制实现,不要自行根据一个简单的 URL 判断就返回不完整的页面。
十、性能预算和持续回归
可以按页面类型制定预算:
首页:LCP p75 <= 2.5s,CLS p75 <= 0.1
初始 JS:不超过团队约定大小
关键请求:不超过团队约定数量
长任务:限制 >50ms 任务数量和总阻塞时间
图片:每个断点使用合适尺寸,禁止无意发送原图
预算不是越小越好,而是让新增功能显式承担成本。CI 可以检查构建产物大小和 Lighthouse 实验室指标;线上 RUM 用版本维度比较 p75/p95;出现回归时结合资源、长任务和错误详情定位。
十一、原文中的错误和过时结论
- “网页请求并发上限一般固定为五个”已过时;浏览器连接和流管理受协议、来源、代理和实现影响;
- CDN 不只是为了突破连接数,也不是自动去 Cookie 的万能手段;
- Brotli 对图片压缩效果好不准确,Brotli/Gzip 主要用于文本;
Expires拼写应正确,缓存没有固定的cache-control > ecprise > etag总优先级表;- 同步经典脚本会阻塞 HTML 解析,不能说“只影响渲染不影响解析”;
- CSS 通常阻塞首次渲染,CSS 后的脚本可能等待样式表,但 CSS 不会在所有情况下阻止 HTML 解析;
- “所有 position、overflow 都独立成层”“使用 transform 就跳过重排重绘”都过于绝对;
- 读取
offset等属性可能强制布局,但不是每次读取都必然回流; translateZ(0)、gpu.js和把所有内容放进 GPU 不是通用性能方案;- localStorage 不适合保存大体积资源,现代离线缓存优先考虑 Cache API 和 IndexedDB;
- SPA、MPA、SSR 各有取舍,不能把某一种架构当作所有页面的最优解。
总结
- 性能优化必须以测量和用户体验为中心,而不是机械背规则;
- HTTP/2/3 改变了请求复用方式,但减少关键字节、合理缓存和资源优先级仍然重要;
- Brotli/Gzip 主要压缩文本,图片要从格式、尺寸、质量和响应式资源优化;
- 现代渲染包括样式、布局、绘制、栅格化和合成,GPU 加速不是万能开关;
- 通过
defer、模块、代码分割、懒加载、srcset、缓存和第三方脚本治理降低关键路径成本; - Service Worker/Cache API 比 localStorage 更适合离线资源,仍需版本、更新和隐私策略;
- SPA、MPA、SSR、SSG 和混合架构应按业务、SEO、交互、缓存和部署成本选择;
- 用实验室指标发现问题,用 RUM 验证真实收益,并持续进行性能预算回归。