尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

减少不必要的传输:前端性能优化的架构级实践

减少不必要的传输:前端性能优化的架构级实践 最近在优化一个老项目的前端性能时同事问了我一个很实在的问题“后端接口已经很快了为什么页面还是慢”我让他开 Chrome DevTools 的 Network 面板截图里最扎眼的不是某一个请求多慢而是密密麻麻几十个请求、每个响应头里都带着大大的content-encoding: gzip但资源本身却依然像“注水”一样臃肿。这件事让我想起很多备考软考系统架构师的朋友经常在案例题里看到“前端优化”四个字第一反应就是上 CDN、加缓存却忽略了最底层也最容易被架构视角覆盖的那件事——减少不必要的传输。“减少不必要的传输”不是单纯把图片压小一点、把 JS 合并一下那么简单。它牵扯到网络协议、缓存策略、代码结构、服务端协作甚至产品需求本身。这篇文章我想用做过的项目里的真实经验和踩过的坑把这节内容拆开讲透。适合正在准备系统架构师考试的同学更适合那些写页面时觉得“网络请求太多”“包太大”却不知道怎么系统下手的前端工程师。1. 先想清楚为什么“减少不必要的传输”是架构师的第一功课1.1 传输开销的构成远比“带宽”两个字复杂很多开发者在评估页面性能时本能地只看“这个文件多少 KB”。但站在架构师的角度一次资源传输的成本应该拆成三块连接建立的开销、数据到达的开销、浏览器渲染前等待的开销。以最普通的 HTTPS 请求为例。用户点击页面浏览器先要做 DNS 解析拿到 IP然后和服务器建立 TCP 连接完成 TLS 握手。这一套流程哪怕服务器在本地也能吃掉几十毫秒如果用户网络是跨地域的真实移动网络轻松超过 200 毫秒。之后才开始真正传输响应体。这还没算上浏览器对同一域名并发连接数的限制以及 HTTP/2 下多路复用里队头阻塞的残留影响。所以“减少不必要的传输”里的“传输”两个字不单指字节数还指传输的次数、传输的时机、传输的链路层级。我见过一个极端案例某项目每次进入首页光基础配置接口就调用 5 次返回内容几乎完全一样。优化前大家都觉得接口本身只有十几 KB没什么大不了。但算上重复的 TLS 握手和排队等待5 次请求在慢网环境下多消耗了近 2 秒。这种成本只盯着单个文件体积是永远看不见的。1.2 用“字节-时间-金钱”三维模型说服团队做架构设计不能靠“感觉慢”要让数据说话。我自己的习惯是给优化项建一个三维评估模型字节数减少多少关键耗时减少多少运维成本变化多少。举个例子。某个管理后台首页加载了一个 2MB 的完整 Ant Design 组件库代码接口返回 300 条业务数据但表格只展示前 20 条。从字节角度看代码改成按需引入后首屏 JS 从 2MB 降到 400KB接口改成只返回当前页数据后响应体从 300KB 降到 20KB。从时间角度看慢网环境下首屏可交互时间从 8 秒降到 3 秒。从成本角度看CDN 流量费直接下降了 60%。这个模型最有用的一点是能帮你说服产品经理和运维。因为“减少不必要的传输”往往会涉及接口结构变更、缓存规则调整甚至有短期风险。如果没有量化目标很容易被一句“先这样吧”打回来。我在内部评审时会把每个优化项写成表格标明预期收益和回滚方案这样推动落地的阻力会小很多。2. 从源头减少字节数内容体积的压缩与消重2.1 代码层按需加载才是最大头压缩只是“锦上添花”很多同学以为减小传输就是把代码跑一遍压缩插件。实际做下来压缩minify带来的收益通常只有 20% 到 30%而按需加载和消除重复代码带来的收益可能超过 70%。先说压缩。现在主流的构建工具 Webpack、Vite 在 production 模式下默认都会做 JS 压缩和 tree-shaking。Tree-shaking 能帮你移除那些 import 了但没有真正使用的模块导出。但要注意它对有副作用的模块、CommonJS 格式的模块支持并不完美。我在一个老项目中就遇到过第三方库是通过require方式引入的tree-shaking 完全没有生效最后手动改成按需引用包体积立减一半。所以如果你发现压缩后体积还是大先检查是不是有旧模块没有 ES Module 化。代码分割才是控制传输的关键。架构层面要把“路由级别懒加载”当作底线。做法很简单// 原来一次性引入 import UserManage from ./pages/UserManage.vue // 优化后路由懒加载 const UserManage () import(./pages/UserManage.vue)这样用户访问首页时只有首页相关代码会被下载进入用户管理页面时才加载对应的 JS chunk。对于大型后台系统这是收益最高、成本最低的改造之一。再来是“消重”。很多项目在多个页面里各自封装了 request 函数、日期格式化工具结果同一个工具函数被打进了不同的 chunk。架构上应该把这些公共逻辑抽成 shared 模块或组件库并确保构建工具的splitChunks规则把公共依赖单独打包。Webpack 中的splitChunks.cacheGroups配置可以这么写// webpack.config.js optimization: { splitChunks: { cacheGroups: { vendor: { test: /node_modules/, name: vendor, chunks: all, }, shared: { test: /src\/shared/, name: shared, minChunks: 2, chunks: all, } } } }不要盲目把 node_modules 全打成一个包。如果某个第三方库只在用户中心用到而它又被塞进基础 vendor 里首屏传输就会被白白拖累。按minChunks: 2的方式提取共享代码通常更合理。2.2 资源层图片、字体和 CSS 的那些“隐形胖子”前端页面里最容易“注水”的资源是图片。一个桌面端背景图原图 3MB把它交给浏览器原样展示是最典型的不必要传输。架构上要建立一套静态资源处理规范图片必须经过压缩、格式转换、尺寸适配三步。现在主流格式是 WebP 和 AVIF。以同样视觉效果为例WebP 通常比 JPEG 小 25% 到 35%AVIF 比 WebP 还能再小约 20%。但 AVIF 的兼容性目前仍然不如 WebP所以稳妥做法是使用picture元素配合type属性做降级picture source typeimage/avif srcsetbanner.avif source typeimage/webp srcsetbanner.webp img srcbanner.jpg altbanner /picture如果项目里图片是后端上传的建议在服务端做动态图片处理通过 URL 参数指定宽高和质量。上传原图只存一份输出时按需裁剪既节省存储又避免把 4000px 宽的原图传给只显示 400px 的移动端。字体也是被忽略的大头。使用中文网站的完整字体文件动辄几 MB哪怕只用了十几个汉字也会被当作全量字体传输。解决办法是字体子集化用工具如 Fontmin、glyphhanger把字体文件裁切成只包含用到的字符。更进一步可以使用font-display: swap让字体加载阶段先显示降级字体避免阻塞渲染。CSS 层面要小心“合并所有 CSS 到一个文件”这种过时思路。HTTP/2 时代连接复用能力强文件合并反而破坏浏览器缓存粒度。比如某个页面只需要一部分 CSS强行合并后所有页面都要下载同一份全量 CSS。更合理的做法是按页面或组件维度拆分 CSS配合代码分割自动加载。2.3 传输层压缩Gzip 与 Brotli 的选型和配置“减少不必要的传输”绕不开文本压缩。HTTP 响应体中的 JS、CSS、HTML 和 JSON 都是文本压缩率极高。目前最常用的两种算法是 Gzip 和 Brotli。Brotli 在压缩率和解码速度上通常优于 Gzip但在较老的浏览器上支持不全且对服务器 CPU 压力稍高。我的实践建议是面向桌面端管理系统、用户浏览器版本较新时优先开启 Brotli面向兼容要求极高的场景至少开启 Gzip 作为兜底。Nginx 中的典型配置如下gzip on; gzip_static on; gzip_comp_level 6; gzip_types text/plain text/css application/json application/javascript application/xml; # 如果开启 brotli 模块 brotli on; brotli_static on; brotli_comp_level 5; brotli_types text/plain text/css application/json application/javascript application/xml;这里要注意gzip_static on的作用它会直接使用构建目录里预生成的.gz文件而不是每次请求时都实时压缩。这样做能大幅降低服务器 CPU 消耗。同理构建工具方面Vite 的vite-plugin-compression或 Webpack 的CompressionPlugin可以在构建阶段同时产出.gz和.br文件。我还踩过一个坑对本来已经压缩过的图片、PDF 等二进制资源再做 Gzip纯属浪费服务器时间体积也几乎不会变小。所以gzip_types里别加image/jpeg加了也没用。3. 避免重复传输缓存策略的架构级设计3.1 强缓存与协商缓存本质是“要不要发请求”的问题前端优化里经常被误解的一点是配置了 HTTP 缓存请求就变少了。其实不是。HTTP 缓存分为强缓存和协商缓存两档。强缓存生效时浏览器根本不发请求直接使用本地副本。对应响应头是Cache-Control: max-age31536000这类指令。协商缓存生效时浏览器会发送一个请求但服务器可以返回304 Not Modified响应体不再传输。对应响应头是Last-Modified/ETag。对于架构师来说策略要分层带版本号的静态资源如app.a1b2c3.js使用强缓存Cache-Control: max-age31536000, immutable。因为文件内容变了文件名就变了不存在更新问题。不带版本号的 HTML 文档一般不能强缓存或只能短缓存。用协商缓存或Cache-Control: no-cache不是no-store是允许协商。需要实时性的接口不允许浏览器缓存避免数据过期。响应头一般设Cache-Control: no-store。这个分层听起来简单但很多项目把 HTML 也设置成max-age86400结果上线后生产环境用户把旧页面缓存了一天静态资源和页面版本对不上白屏一片。缓存配置涉及发布联动一定要在设计评审阶段定好。3.2 版本化文件名、CDN 和缓存失效的协作上面提到的带版本号文件名实现方式已经集成在现代构建工具里。例如 Vite 构建后会生成index-8f9a2b.js这个哈希是根据文件内容生成的。只要内容没变文件名就不变浏览器和 CDN 就能放心命中缓存。CDN 是把“减少不必要的传输”做到极致的环节。互联网长距离传输再快也不如让用户从就近节点拿资源。架构上一般用两到三层缓存CDN 边缘节点缓存静态资源源站设置合理响应头浏览器本地缓存兜底。这里分享一个容易踩坑的细节CDN 回源时会改写部分请求头导致缓存未能生效。曾经排查一个项目明明静态资源带了Cache-Control: max-age31536000但 CDN 仍然频繁回源。最后发现是 CDN 默认忽略源站的Cache-Control需要单独在 CDN 控制台配置“缓存源站响应头”或者手动设置 CDN 缓存规则。另外如果使用 CDN 但新版本带上哈希后旧版本文件不会立即失效占用 CDN 空间但这个空间成本通常可控不要因此放弃强缓存策略。比较建议在发布系统里增加一个“CDN 刷新”步骤只刷新 HTML 入口资源静态哈希文件不需要刷新。3.3 动态数据和用户私有内容的缓存边界不是所有接口都不能缓存。有些数据变化频率低比如“省市区列表”“系统配置项”“用户权限字典”完全可以让浏览器或 CDN 缓存。可以在服务端接口返回里设置Cache-Control: public, max-age3005 分钟内用户重复请求浏览器直接返回缓存根本不走网络。这个改动比优化接口响应体更“暴力”因为它连请求都省了。但用户私有数据必须带上Cache-Control: private, no-store或者至少private, max-age0。有一次我见过管理后台的订单接口被某个代理层默认加了公共缓存结果出现了别的用户的数据串号的问题。这属于安全问题不只是性能优化。所以在做接口缓存规范时数据类型和安全边界一定要明确。3.4 如何发现正在发生的“重复传输”Chrome DevTools 的 Network 面板能直接看到哪些资源是从memory cache、disk cache命中哪些是200 OK真实传输哪些是304。但更细致的方法是看“Size”列如果显示(from disk cache)表示没有网络传输如果显示(from service worker)说明由 Service Worker 兜底。我自己常做的一个操作是在 Network 面板中筛选“资源大小”和“传输大小”。这两个数值的差距能直观反映压缩率。例如一个 JS 文件资源大小是 300KB传输大小只有 80KB说明压缩生效了。如果两者几乎一样你就要怀疑是不是忘了开压缩或者这个资源本身已经是压缩格式。对于接口层面的重复传输我建议在服务端日志里记录每个接口的请求次数、响应大小和调用来源。优化前先统计一遍就会发现某个列表页 10 分钟内同一个用户请求了同一接口 20 次而数据根本没有变化。这种时候不要急着加缓存先问产品经理为什么轮询这么频繁很多时候是代码里setInterval写错了甚至组件卸载没有清理定时器。4. 控制请求的数量与时机合并、并发与懒加载4.1 HTTP/1.1 时代的“合并请求”思维在 HTTP/2 下要重新审视我刚入行那会前端优化的金科玉律是“减少请求数”雪碧图、合并 CSS、合并 JS。这套思路放在 HTTP/1.1 下是对的因为浏览器对同一域名最多同时建立 6 个左右的 TCP 连接请求多了就要排队。但 HTTP/2 普及后多路复用允许在同一个连接上同时传输多个请求和响应队头阻塞问题大幅缓解。这时候再盲目把所有 JS 合并成一个 5MB 的超大文件反而会带来两个坏处首屏必须等全量代码加载完才能执行发布时一个文件内容变化整个缓存都失效。所以现在做架构方案我会优先把资源切成合理粒度的多个 chunk而不是追求“一个文件”。例如基础运行库Vue/React 全家桶单独一个 vendor chunk每个路由页面一个独立 chunk公共业务工具一个 shared chunk首屏关键路径的资源用 preload 提前加载。HTTP/2 下的请求数不再稀缺但也不是无限免费。每个请求仍然有完整 HTTP 头部的开销单个请求过大同样会拖慢首屏。所以“数量”和“体积”要一起权衡。4.2 图片懒加载与路由懒加载的落地姿势图片懒加载能显著减少首屏传输量——但要注意“懒”到什么程度。最简单的是给img标签加loadinglazy浏览器原生支持。但原生懒加载通常只能做到进入视口附近才开始加载对于商品列表页够用对于真正的长列表和滚动流场景建议用IntersectionObserver自己控制const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: 200px }); document.querySelectorAll(img[data-src]).forEach((img) observer.observe(img));像这种方案最好封装成通用指令或组件避免每个页面复制一套。同时要小心首屏图片的“懒加载反效果”如果首屏可见区域内的图片也懒加载会导致加载时机被推迟实际感知速度反而下降。通常首屏内图片应该预加载首屏外的才懒加载。路由懒加载在 2.1 节已经提到了这里补充一个细节不要在懒加载路由的 chunk 上再做全量 preload。很多人听说 preload 能提速就把所有 chunk 都 preload结果就是所有路由代码都在首屏被下载懒加载形同虚设。Preload 只应该用于当前页面确定会用到的关键资源。4.3 预连接和预解析把“未来的传输”提前准备DNS 解析、TCP 连接、TLS 握手这些等待时间可以通过preconnect和dns-prefetch提前完成。比如页面确定会请求api.example.com可以加link relpreconnect hrefhttps://api.example.com link reldns-prefetch href//api.example.compreconnect会提前完成 DNS TCP TLS 握手代价是浏览器会在空闲时占用一个连接。对于未来一定会请求的域名收益明显。对于第三方统计、字体 CDN 这类可延迟资源可以用dns-prefetch只提前解析 DNS成本更低。我见过过度使用的情况一个页面写了十几个preconnect包括未来可能根本不会访问的域名。这样反而浪费移动端宝贵的连接资源。架构上应该有一个“可信域名清单”只对首屏必须访问的域名开启preconnect。还有preload和prefetch的区别preload是当前页面高优先级资源例如首屏渲染需要的 CSS、字体prefetch是空闲时提前下载用户“可能”访问的下一个页面资源。Preload 用错了会抢占带宽Prefetch 用对了可以让下一个页面“秒开”但也不要一次性 prefetch 太多否则会消耗用户当月的流量套餐。5. 内容协商与服务端配合从接口层面削减传输5.1 接口返回字段裁剪能传 20KB绝不传 300KB很多时候前端慢不是因为代码多而是接口返回了一堆用不上的字段。后端一个SELECT *把整个订单对象所有关联信息都吐出来前端只在列表里展示五列。这类情况属于典型的“不必要的传输”。架构上应该建立接口字段最小化规范。比如 REST 接口里增加fields参数或者把列表接口和详情接口彻底拆开。列表接口只返回 id 和展示列详情接口按需返回完整对象。如果项目还在早期我比较推荐引入 GraphQL 或 JSON:API 这类带字段选择能力的方案。但也要注意GraphQL 不是银弹。它的优点是把字段选择权交给前端缺点在于缓存策略更复杂后端需要处理 N1 查询和权限控制。对于简单后台系统用 REST fields参数足够没必要为了“先进”而增加复杂度。我在重构一个报表系统时把 30 个字段的列表接口裁剪成 8 个字段接口响应体从 120KB 降到 25KB。配合分页一页数据只要 3KB。用户切页时体感从“能感觉到加载”变成“几乎瞬时”这个优化没有改任何页面布局只动了接口传输协议。5.2 服务端渲染和数据预取把多次请求合并成一次用 Vue 或 React 做纯前端渲染时页面加载一般要经历拿到 HTML 壳子 - 执行 JS - 请求接口 - 渲染列表。等于首屏需要一次 HTML 传输加一次 JS 传输再加一次接口数据传输至少三趟网络往返。服务端渲染SSR可以在服务器上直接完成数据查询和组件渲染输出带内容的 HTML。用户浏览器拿到 HTML 时首屏内容已经在了接口请求环节被前置到了服务器内部。这样一来用户侧少了一次关键请求体感尤其是弱网环境下会大幅改善。但 SSR 也有成本服务器渲染耗时、首屏与二次渲染的数据一致性问题。所以架构师要分场景。纯展示类页面比如官网首页、文章详情非常适合 SSR对交互复杂、依赖浏览器 API 的后台系统SSR 收益有限且可能让架构复杂度飙升。更轻量的方案是“数据预取”。在页面构建阶段把首屏接口数据内联到 HTML 的script标签里前端初始化时直接读取script window.__INITIAL_STATE__ { list: [...] }; /script这样用户打开页面时不需要再等接口响应直接从全局变量里读取数据。这种做法的本质是把原本单独一次请求的传输融入首次 HTML 响应用“一次传输”替代“两次传输”。5.3 WebSocket、SSE 和增量更新什么时候才值得用接口轮询是“不必要的传输”的重灾区。比如一个订单状态页面前端每 5 秒调一次接口用户盯着看 10 分钟不动数据也没变白白传输了 120 次请求。与其轮询不如用服务器推送。WebSocket 适合双向实时通信比如在线聊天、协作编辑。SSEServer-Sent Events适合单向服务端推送比如通知、状态更新且它是标准 HTTP 协议的一部分浏览器原生支持比 WebSocket 更轻。但鼓吹每个项目都升级到 WebSocket 也不现实。我一般会先问两个问题数据变化频率高吗用户真的需要实时看到变化吗如果只是“刷新后能看到最新状态”用条件请求轮询足够了。加一个If-Modified-Since或ETag数据没变时返回 304传输量从全量 JSON 变成几十字节响应头比 WebSocket 简单得多。所以这里的原则是能用条件请求解决的就不要用推送能用推送解决的就不要高频轮询。6. 常见问题与排查技巧实录6.1 现象用户打开页面白屏Network 里 HTML 返回 200 但内容是旧的这个案例是典型的缓存分层设计失误。项目把 index.html 设置成Cache-Control: max-age86400同时静态资源用了带哈希的文件名。正常情况下HTML 变了文件名会变用户会拿到新页面并加载新资源。但问题在于CDN 把 HTML 也缓存了用户访问的 HTML 永远是昨天那份而资源文件又没有被缓存或已过期结果加载了旧 HTML 引用的新资源路径找不到文件白屏。排查时看响应头里 HTML 的cache-control和x-cache-status一查果然是 CDN 命中。解决方式很简单HTML 入口文件设置Cache-Control: no-cache并且发布后主动刷新 CDN 上的 HTML。现在大多数部署平台如 Nginx 发布系统都支持对 HTML 的 “绕过缓存” 规则一定要在初始架构里就规划好。这类问题在软考系统架构师的案例题中经常出现考官想看的往往不是“怎么删缓存”而是“为什么缓存策略会失败”以及“如何设计一套版本化文件 非缓存 HTML 的分层方案”。6.2 现象Gzip 已经开启但某个接口响应体仍然巨大且不被压缩有一次我们优化一个报表导出接口明明 Nginx 已经开启了 Gzip但接口返回的 JSON 数据传输还是 200MB。查到最后发现后端语言框架里的中间件优先级把Content-Type设成了application/octet-stream而 Nginx 的gzip_types只匹配了application/json导致了不压缩。这类问题很难通过肉眼发现最好用curl -H Accept-Encoding: gzip -I看响应头里有没有Content-Encoding: gzip。如果没有再逐层排查是网关、负载均衡还是源站没处理对。架构上应该形成一份“压缩链路检查清单”浏览器是否支持 - CDN 是否透传 Accept-Encoding - 源站是否产生压缩 - 压缩类型是否在允许列表里。6.3 现象压缩和缓存都做了Lighthouse 性能评分还是不高Lighthouse 里常出现一个提示“Avoid enormous network payloads”。有些团队做了一堆优化体积已经降下来了但评分还是差。原因往往是 3G 模拟和真实网络环境下的缓存命中率不一致。Lighthouse 默认会禁用缓存所以它能查到所有资源从网络加载——如果你的页面依赖浏览器缓存提高二次访问速度评分自然会偏低。这时别只盯着评分要看优化目标。给团队定性能预算时我会同时定义一个“首次无缓存加载”和“二次有缓存加载”两个指标。前者考察真实网络传输上界后者考察缓存设计是否合理。不要因为 Lighthouse 一次跑分不好就推翻整个缓存方案那是用错了尺子。6.4 实战排查步骤速查表步骤操作目的1打开 Network 面板开启缓存禁用看最差情况下的传输总量2勾选“Big requests”和“Slow 3G”模拟定位大头资源和慢请求3查看每个资源的“Resource Size”和“Transfer Size”判断压缩是否生效以及是否命中缓存4在响应头里确认 cache-control / etag / content-encoding直接定位缓存和压缩配置是否正确5用 WebPageTest 或 Lighthouse 跑分从第三方视角观察关键指标6拉取服务端访问日志统计同一用户重复请求频率发现接口轮询和数据重复传输问题这个表格看起来简单但真正照着做过一遍的人基本都能找到至少两三个可以立刻优化的点。大多数“高性能页面”不是靠一个惊艳的大招而是靠把这些基础步骤打磨到位。个人经验收尾做了这么多年前端和架构设计我越来越觉得“减少不必要的传输”本质上是一种设计理念每多传一个字节都要能说出它的用途。这并不是要求前端工程师单纯地压缩文件而是站在用户网络环境、服务器成本、业务更新频率的整体视角对每一段传输进行审计。如果你正准备软考系统架构师建议把本章内容当成案例题的分析框架而不只是知识点。考试里问“如何优化前端性能”能从 HTTP 缓存分层、资源体积压缩、接口字段裁剪、服务端配合这几个维度展开比只答“上 CDN、开 Gzip”要立体得多。最后再分享一个小技巧我每个项目都会准备一份“传输预算清单”把首次页面加载的字节数、请求数、关键请求耗时贴在团队文档里。每迭代一个版本就跑一遍对比超了就回滚或者重新讨论。坚持一个季度你会发现团队对网络传输的敏感度明显提升这才是这项优化长期有效的关键。
返回列表