做前端这些年,最常被问到的就是“页面为什么这么卡”“首屏为什么这么慢”,而答案十有八九绕不开两个词:异步加载、性能优化。尤其是当你面对一个加载了十几个脚本、几十张图片、还有一堆第三方SDK的页面时,那些“优化”其实都是在跟浏览器的工作原理打交道。这也就是为什么我坚持认为:不懂原理的优化都是瞎调参。
这篇是系列里的“原理篇”,我会把异步加载和性能优化背后那层窗户纸捅破,不讲虚的,直接讲清楚浏览器到底怎么加载资源、哪些环节会卡住渲染、异步到底在异步什么、以及怎么用工程化手段把页面速度提上来。适合被首屏性能困扰的前端开发者,也适合刚接触性能优化、想搞懂底层逻辑而不是只会用工具跑分的同学。
1. 先弄明白浏览器加载页面时到底在忙什么
1.1 关键渲染路径:从URL到画面的完整流程
性能优化不管怎么绕,最终都要回到一个概念:关键渲染路径(Critical Rendering Path)。你输入一个URL,按下回车,浏览器干的第一件事是DNS解析、TCP连接、TLS握手——这些是网络层的开销,后续我们做资源加载优化也要基于这个前提,但真正影响页面「看起来有多快」的,是从响应拿到HTML开始的那条流水线。
浏览器拿到HTML后,会逐字节解析,生成DOM树。这个过程不是一次性的,而是一个流式过程:解析到一个标签就生成一个节点,构建中的DOM树会不断被后续内容更新。注意这里有个容易被忽略的点:HTML解析器是边下载边解析的(首包到达就开始),不是等到整个HTML全部下载完才动手。这意味着,只要服务端返回了头部,浏览器就能开始工作,所以首字节时间(TTFB)是前端性能的下限来源之一。
和DOM树并行构建的,是CSSOM树,也就是解析CSS生成的样式结构。注意它的特殊性:CSSOM构建完成后才能进入下一步。因为布局阶段需要知道每个元素的最终样式,如果样式表还没解析完就急着布局,算出来的尺寸就可能是错的。这也是CSS为什么会被定义成「渲染阻塞资源」的根本原因。
再往后是合并DOM和CSSOM生成渲染树,只包含可见元素;然后布局,计算每个节点的几何位置和尺寸;最后绘制并合成,把像素交给显卡。整个链路一步都不能缺。所谓「性能优化」,本质上就是让这条流水线上每一个环节都尽量早开始、早完成,同时减少不必要的阻塞。
1.2 同步脚本为什么要了渲染的半条命
DOM解析是边下载边进行的,但有一个东西可以强行打断这个过程:同步JavaScript脚本。当HTML解析器遇到一个不带任何特殊属性的script标签(也就是同步脚本),它会立刻停下手里的活,去下载这个脚本,下载完再执行,执行完才能继续解析后续的HTML。
为什么会设计成这么「霸道」的机制?因为脚本可能在执行时做两件事:一是通过document.write向当前文档流里写新的HTML,二是查询DOM和样式的当前状态。如果解析器不停下来,改写文档流的操作就会导致解析状态错乱,查询DOM也可能拿到半截数据。所以浏览器做了一个简单粗暴的决定:遇到同步脚本,全流程让路。这就是为什么很多人把script放在head里没有加async/defer,页面首屏会白屏很久的直接原因——HTML解析被卡在脚本那里了。
同样的逻辑也适用于CSS。如果脚本执行时可能需要读取样式(比如getComputedStyle),浏览器就必须保证在执行脚本前,前面的CSS已经全部下载并解析完。所以你会看到一种经典现象:样式表放在head里,同步脚本放在样式表后面时,脚本不仅阻塞HTML,还额外等着CSS下载——这就是所谓的「脚本等待样式表」链路。很多优化教程说「把CSS放前面,JS放后面」,原理就是尽量减少这种等待链条的长度和宽度。
理解了这两个阻塞源头,你就能看懂异步加载的所有招数了:全局目标只有一个——让同步脚本的数量尽可能少,让所有不关键的资源都走向异步路径。
2. 异步加载的三种基础形态能解决什么
2.1 defer:延迟执行,但保证顺序
脚本异步化的第一种形态是defer属性。加了defer的script,下载过程完全异步,不会阻塞HTML解析;等整个文档解析完毕后,在触发DOMContentLoaded事件之前,按文档顺序依次执行。所有defer脚本都保证按出现顺序执行,这一点很重要。
用场景来理解:如果你的页面里有一段操作DOM的工具脚本,放在head里用defer加载,那么脚本执行时DOM必然已经构建完了,直接操作即可,不需要再包一层DOMContentLoaded监听。这里我提醒一句,别在defer脚本里用document.write,因为解析已经结束,write等于重新开一个文档,会清空整个页面,这个坑踩一次就长记性了。defer最大的优点是既拿到了异步下载的好处,又不牺牲执行顺序,很适合依赖关系明确的脚本组。
2.2 async:下载不阻塞,执行即插队
async跟defer最大的区别在执行时机。async脚本一旦下载完成,会立即插入当前解析位置执行——如果HTML还没解析完,就会阻塞解析;如果已经解析完了,就随机插入执行。不保证执行顺序,谁先下载完谁先执行。
这就决定了async适合什么场景:独立的、不依赖其他脚本、也不需要被其他脚本依赖的工具代码。比如埋点统计、错误上报、A/B测试脚本——它们不需要等DOM,也不关心页面里其他脚本是否就绪,自己跑完就结束。反过来,如果你有两个async脚本A和B,A依赖B的功能,那A就可能报错,因为在网络环境下B完全可能晚于A下载完成。所以现在主流的前端构建工具默认都不会把代码拆成多个async脚本,而是把它们合并成带defer引用的bundle,目的就是这个——保住执行顺序。
2.3 动态创建script:完全掌控时机
除了两个属性,还有一种历史悠久的异步加载方式:用JavaScript动态向页面里插入script标签。写法很简单:
const s = document.createElement('script'); s.src = '/path/to/bundle.js'; s.async = true; s.onload = () => { // 脚本加载并执行完毕,可以做后续初始化 }; document.head.appendChild(s);动态插入的script默认就是异步下载的,不管有没有async属性,它都不会阻塞当前文档的解析。真正的价值在于:完全把加载时机交给你来控制。你可以设定一个空闲时刻再去加载,可以用onload精确感知执行完成,还可以组合多个资源的加载顺序、做失败重试、做依赖等待。
我实际项目里用得最多的一个模式是:先加载核心框架脚本,onload之后再动态插入业务模块脚本,业务脚本里再做内部模块的异步加载。这个链路层级清晰,可观测性强,也方便后续替换成更现代的module loader。
2.4 三个方案的选型对比:什么时候用哪个
做个简单的对比表格,方便工作中快速决策:
| 方案 | 是否阻塞HTML解析 | 执行时机 | 是否保证顺序 | 适用场景 |
|---|---|---|---|---|
| 同步script | 阻塞 | 遇到即执行 | 自然保证 | 内联关键逻辑、必须最早执行的代码 |
| defer script | 不阻塞 | DOM解析完成后 | 按文档顺序 | 主业务包、框架脚本、有依赖关系的脚本组 |
| async script | 下载不阻塞、执行可能阻塞 | 下载完成后立即 | 不保证 | 独立第三方工具、埋点、监控、无依赖脚本 |
| 动态script | 不阻塞 | 由代码控制 | 可控 | 运行时按需加载、二次加载、依赖链控制 |
我个人的经验法则是:页面必须的核心逻辑用内联或defer,业务代码跟着构建工具的拆分走,第三方无依赖的SDK用async,真正按需的东西才用动态创建。实际区别可能在网络环境好的时候不明显,一旦网络变差,这个选型的差异会被急剧放大。
3. 现代工程中的异步加载:模块与路由的精细化拆分
3.1 ES Modules与动态import的底层原理
前面说的都是针对独立脚本文件的加载,但在现代前端工程化里,代码早就不以「几个文件」为单位了。你写的那几百个模块会被打成几十个甚至上百个chunk,加载策略由构建工具决定。这里面最核心的机制是ES Modules和动态import()。
ES Modules天生就是异步的。浏览器遇到<script type="module">,会用异步方式下载依赖图,而且模块内部默认开启严格模式,强制执行模块级作用域。它跟普通script另一个关键区别是:模块脚本自动延迟执行(等效defer),因为模块的执行依赖所有依赖模块先完成下载和实例化。
动态import()返回一个Promise,这使得模块加载从「预先声明」变成「真正运行时按需」:
// 路由级懒加载:进入某个路由才加载对应页面模块 const { default: Dashboard } = await import('./pages/Dashboard.js'); // 组件级按需:用户第一次点击弹窗时才加载弹窗逻辑 btn.addEventListener('click', async () => { const { showModal } = await import('./components/modal.js'); showModal(); });从性能优化角度看,动态import的核心价值不是「懒」,而是「优先级的重新分配」。首屏不需要的代码被挪到用户真正需要执行它的时候才下载,首屏网络带宽被让渡给了关键图片、关键CSS和关键JS。这背后是构建工具的配合:Vite、webpack会把动态import的模块单独切分成独立chunk,天然形成子资源边界。
3.2 路由级懒加载:首屏只加载当前页面需要的代码
SPA是性能问题的重灾区,因为传统的SPA会把所有页面的代码打包进一个巨大的bundle,首屏加载时虽然只渲染了首页,但按需的、压根没访问到的页面代码也全部下载了。路由级懒加载就是针对这个痛点设计的标准解。
以Vue Router为例:
const routes = [ { path: '/', component: () => import('./views/Home.vue') }, { path: '/user', component: () => import('./views/User.vue') } ];这种写法下,构建工具会把Home.vue和User.vue分别切成独立chunk。浏览器首次打开只加载Home这个chunk,访问/user时浏览器才会发起请求加载User的chunk。我用这个模式改造过一个后台管理系统:首屏JS体积从3.8MB降到860KB,首屏可交互时间减少约40%。体积下降就这么直接。
但要注意,路由懒加载不是零成本的。网上有不少人反馈懒加载后页面切换变慢——因为每次切路由都多了一次网络请求。解决方案是:用prefetch预取策略。像webpack的魔法注释/* webpackPrefetch: true */或Vite的import.meta.glob配合prefetch选项,浏览器会在空闲时间里提前把用户「下一跳」很可能需要的chunk下载到缓存。配合现代浏览器的空闲调度机制,几乎不会抢占首屏带宽。
3.3 组件级异步加载与Suspense
路由懒加载解决的是「页面」级别,页面内部凡是体积大、不首屏可见的组件,也可以按需加载。典型场景是富文本编辑器、图表库、代码高亮、地图SDK这类「大块头」。把它们全部打进主bundle是常见的前端性能杀手。
React的React.lazy和Suspense是这套玩法里的标杆实现:
import { lazy, Suspense } from 'react'; const RichEditor = lazy(() => import('./components/RichEditor.jsx')); function App() { return ( <Suspense fallback={<div>编辑器加载中...</div>}> <RichEditor visible={open} /> </Suspense> ); }关键点在于Suspense不只是一个loading UI的容器。它向React传达了「这个组件可能需要异步加载」的信号。React会在调度层面配合,帮你在渲染树挂到DOM前处理完异步状态,避免用户看到半成品UI。Vue侧用defineAsyncComponent配合Suspense也能达到同样效果。组件级异步化的取舍要看清:如果组件体积小于20KB且是高频使用,硬上懒加载反而多了请求开销,不如直接打包进去。
3.4 资源加载的优先级控制:preload、prefetch与Priority Hints
异步加载讲到现在,都是在解决「要不要现在加载」的问题。还有一个层面是「如果现在就要加载,谁先谁后」。浏览器对资源请求有自己的优先级,但默认逻辑不一定符合你的业务诉求。这时候就需要三种手段:preload强制预加载、prefetch空闲预加载、fetchpriority调整请求优先级。
<!-- 首屏关键字体:必须尽快拿到 --> <link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin> <!-- 下一页面很可能需要的东西:空闲再拉 --> <link rel="prefetch" href="/chunk/user-page.js"> <!-- 首屏最大图片:告诉浏览器它优先级最高 --> <img src="/hero.jpg" fetchpriority="high" alt="主视觉">preload最典型误用场景就是字体。不preload的字体通常要等CSS解析完触发字体请求,这中间可能已经白屏或用了fallback字体。preload可以让字体请求提前到跟CSS同批,大幅缩短文字闪变窗口。要注意的是preload不是越多越好,它是「加速一个指定资源」,每个preload都是一笔额外开销,用多了会挤占关键资源的带宽。高性能站点的习惯是:全站preload资源数量控制在个位数,且只preload首屏必经资源。
4. 图片与媒体资源的懒加载实战
4.1 从loading="lazy"到IntersectionObserver
图片占了大多数站点传输体积的50%以上,图片加载策略直接影响首屏速度和流量消耗。原生方案是loading="lazy"属性:
<img src="photo.jpg" loading="lazy" width="400" height="300" alt="描述" />浏览器自动判断图片是否进入视口附近才加载。这个「附近」不是严格视口边缘,而是有一个预加载距离,Chrome的实现大约是1250px。所以不仅用户「看到」的图片能加载,即将滚入视口的图片也会提前加载,视觉上基本无感。使用loading="lazy"有两个硬性条件:必须同时写width和height,或由CSS指定宽高比。否则浏览器不知道图片占地多大,懒加载会退化且触发严重的布局偏移。
如果需要对加载时机做更精密的控制,就用IntersectionObserver(IO),实践代码如下:
const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); // 加载完成后停止观察,防止重复触发 } } }, { rootMargin: '200px', // 距离视口还有200px时就开始加载 threshold: 0 }); document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));IO跟原生的loading="lazy"相比,核心优势是rootMargin可调:你可以为不同页面区域设置不同的预加载策略。比如首屏附近的图提前多预加载一些,页面底部的图则严格等近身再加载。但IO方案写起来麻烦,还需要处理Web Components、动态插入节点等边界,所以实际项目我的默认策略是:能用原生就不用IO,只有复杂业务定制时机才用IO。
4.2 占位策略:避免布局抖动CLS
懒加载最容易引发的副作用是布局抖动。图片没加载完时高度为0,等图片到达时突然把下面的内容往下推,用户正在阅读的位置整个跳掉,体验极差。这个现象在性能指标里叫CLS(Cumulative Layout Shift),对SEO、对用户体验评分都有实打实的影响。
最稳妥的解决方案:给图片写上width和height属性。注意在现在的规范里,浏览器会自动从属性计算宽高比,并用这个比例提前预留对应空间:
<img src="hero.jpg" width="1920" height="1080" alt="封面" loading="lazy" />某些场景我们只知道比例不知道具体尺寸,比如响应式下图片区域宽度是100%,高度按16:9自适应。此时用CSS的aspect-ratio + 100%配合:
.img-container { aspect-ratio: 16 / 9; width: 100%; background: #f0f0f0; /* 先铺个底色,视觉上不太突兀 */ }还有一层进阶的占位思路:先用极低分辨率的占位图(LQIP,通常是几十字节的模糊小图)占住位置,原图加载完后渐变替换。Medium的模糊占位、微博的灰色占位都是这个思路。占位策略的目的是让用户在等待图片时看到的不是「闪动的空白」,而是「明确的即将出现图片的区域」,这直接决定了等待的烦躁感上限。
4.3 字体与视频的异步加载细节
说完图片,字体和视频的异步加载是另外两个高频低关注度场景。字体的最大痛点是FOIT(Flash of Invisible Text,文字不可见闪烁)。默认行为是字体没加载完就不显示文字,在弱网下可能白屏好几秒。解法是font-display: swap,让浏览器先用fallback字体渲染文字,等自定义字体加载完成后替换:
@font-face { font-family: 'CustomFont'; src: url('/fonts/custom.woff2') format('woff2'); font-display: swap; }但swap也有自己的问题:文字先用A字体显示,加载完突然跳成B字体,用户感知的「视觉跳动」还是存在。进一步优化是用size-adjust调整fallback字体和自定义字体的度量差异,让两个字体占位尽可能一致,把跳变从「肉眼可见」压到「难以察觉」。视频素材的懒加载逻辑跟图片类似,额外多一步:用preload="none"阻止浏览器自动缓冲视频元数据,真正播放时才请求实际视频数据:
<video controls preload="none" poster="cover.jpg"> <source src="movie.mp4" type="video/mp4"> </video>这里的逻辑很直白:视频是纯用户主动行为,没有任何理由在用户点击播放之前消耗带宽。poster占位图让用户先看到完整UI,点击播放后浏览器才拉取视频流,带宽利用率最大化。
5. 性能优化的衡量:用什么数据说话
5.1 核心Web指标:LCP、INP与CLS
不能量化的优化等于白做。现在整个行业通用的度量框架是Web Vitals,重点关注三个指标:LCP(Largest Contentful Paint,最大内容绘制)、INP(Interaction to Next Paint,交互到下一次绘制)、CLS(Cumulative Layout Shift,累计布局偏移)。
LCP衡量的是「用户感知的加载完成点」。首屏最大那块内容——通常是主图、大标题、Hero区域——绘制出来的时间。它不是白屏结束时间,而是「用户觉得主要东西出来了」的时间。优化LCP的着力点就在前三章讲的内容:减少关键阻塞、提前关键资源、降低主包体积。2.5秒以内的LCP算优秀,4秒以上基本就是拖累留存的问题级体验。
INP取代了旧的FID(First Input Delay),衡量的是页面从用户发生交互到界面产生视觉反馈的延迟。它比FID更能反映真实体验,因为它覆盖了所有交互的百分点延迟,而不是只取第一次输入的最坏情况。INP差通常意味着主线程被长任务霸占,老长任务会阻塞渲染和事件响应。优化的手段是拆分长任务(把大计算切成多个setTimeout片段)、压缩JS执行时间、把非关键计算塞进requestIdleCallback。
CLS衡量视觉稳定性。我们前面讲的图片占位、字体度量调整、iframe占位全部都服务于它。0.1以下为优秀。三个指标分别对应三个维度:加载、交互、稳定。优化一个指标容易,难的是三者同时达标,这要求的不只是局部技巧,而是从架构到细节的系统工程。
5.2 性能监控与实验对比方法
要验证优化效果,得有一个标准化的测量方法。本地层面,Chrome DevTools的Performance面板录制是最直接的:
- 打开DevTools切到Performance面板,点击录制,刷新页面,等加载完成后停止。
- 看「Summary」里的耗时占比:脚本执行、渲染、绘制、加载各占多少。
- 重点看主干时间线上有没有「Long Task」(长任务)——超过50ms的任务会被标记为红色,它们直接拉高交互延迟。
- 用Network面板配合,区分「等待(TTFB、内容下载)」和「解析执行」的时间,分别对治。
线上层面,推荐RUM(Real User Monitoring)方案,用Web Vitals的JavaScript库将真实用户的性能数据收集并上报到合规可用的监控平台:
import { onLCP, onINP, onCLS } from 'web-vitals'; onCLS(console.log); onINP(console.log); onLCP(console.log);光看数值不行,优化前后的对比必须「同基准」。建议全程用无痕窗口、关闭浏览器缓存模拟慢网络(DevTools网络面板里的Fast 4G或Slow 4G预设)、固定CPU降速(比如6倍减速模拟中低端手机)。控制变量以后,优化前后的LCP、TTI、总请求数、总字节数才有可比意义。我习惯的结论格式是一个对比表格:指标、优化前、优化后、变化率。这样整个优化工作量才有交付物。
5.3 引入性能预算:防止回归
性能优化最难的不是做一次,是保持住。很多项目优化完一亮相很亮眼,三个月后新功能一加又回到原始状态。我强烈建议在CI/CD流程里引入性能预算(Performance Budget)。思路是给关键指标定一个硬上限,超了就构建失败。预算可以分两个维度:
| 预算维度 | 示例 | 超过即失败 |
|---|---|---|
| 体积预算 | 首屏JS总包 ≤ 180KB(gzip) | 任何新增导致超出 |
| 请求预算 | 首屏关键资源请求 ≤ 25个 | 新增了第三方脚本导致超出 |
| 时间预算 | 模拟Slow 4G下LCP ≤ 3s | Lighthouse跑分超时 |
体积预算好用又便宜,大部分构建工具都能通过脚本统计打包输出大小,失败了直接报错。时间预算稍微准一些,需要额外跑Lighthouse CI。执行上不用太苛刻,留出一个合理余量,不然团队会被预算卡得没法发文。但正是这条「红线」,保护了前面所有努力不会被一两个新依赖、一段复制粘贴的第三方脚本给毁掉。
6. 常见问题与排查技巧实录
6.1 异步脚本执行顺序错乱
这几乎是我被问得最多的一个问题:加了async之后,脚本执行顺序变了,某个功能报错。原因前面已经讲了——async不保证执行顺序。排查时先看脚本之间有没有依赖关系:
- 有依赖关系:改成defer,保证按DOMContentLoaded前按序执行。
- 无依赖关系:保持async,但要确认内部逻辑不依赖「某个脚本已加载」的全局变量。
还有一个根因容易被忽略:页面里混用了defer和async。defer脚本保证在文档解析后按序执行,async脚本可能在defer之前或之间执行,由此产生的互相覆盖全局变量问题很难查。经验是混用时给脚本加防御性检查:
// 在每个脚本开头检查依赖是否就绪 if (typeof window.Dependency !== 'undefined') { // 依赖已存在,正常执行 } else { console.warn('[loader] Dependency missing, script skipped'); }6.2 懒加载导致的内容闪烁
有人反馈图片做了懒加载以后,滚到图片位置时先看到空白,再刷一下图片才出来。这不是浏览器懒加载失效,而是你的触发时机太晚了。原生loading="lazy"的预加载距离是固定的,如果你希望提前更多,就换成IntersectionObserver方案并调大rootMargin。比如:
rootMargin: '600px 0px', // 视口下方600px就触发加载另一个常见的闪烁原因是LQIP方案下占位图已经加载完,原图还在下载,用户会看到一张模糊图「突然变清晰」。这个体验在慢网络下很糟。解决思路是:占位图不是「图片」,而是一个跟原图等高的纯色块加loading动画,等原图就绪后再一次性替换。视觉上从「骨架屏」过渡到「完整内容」,感知更平滑。
6.3 过度异步化带来的问题
异步加载不是越多越好。有些团队为了追求性能,把几乎所有的东西都搞成按需加载:首屏的主组件也用懒加载、公共工具库也异步、连图标库都首屏不加载。结果首屏是轻了,但用户每次跟页面打交道都卡一下——每个交互动作的背后都藏着一个网络请求。这属于「用加载速度换交互速度」,典型的本末倒置。
性能优化的实质是资源优先级的分配,而不是「所有资源都无限期推迟」。按需加载只应该用于「真的可能用不到」的资源。凡是用户一定会与它交互的组件(比如导航、表格、弹窗关闭逻辑),都应该首屏加载或至少prefetch预热。检验标准很简单:你问自己一个问题——「如果用户第一次进入页面就点了这个区域,他需要等多久才能看到反馈?」答案超过500ms,说明这个异步化做过头了。
6.4 性能面板的分析技巧:从耗时排查到根源定位
拿到一次完整录制后,不要只盯整体的性能分数,要会看具体的瓶颈段。我常用的排查次序如下:
第一,看网络瀑布图里那条长长的「等待」阶段(TTFB)。如果服务端响应时间占了总耗时一半以上,那不是前端优化能解决的,该找后端或网关问题。表现为瀑布图里每个资源请求的绿色「等待」条都很长。
第二,看主线程上有没有一个大红块,旁边标注「Evaluate Script」或「Function Call」。这通常指向某段JS执行时间过长。点进去定位到具体的脚本文件和函数,考虑拆分或延迟执行。
第三,看渲染和绘制耗时。如果脚本和网络都正常,但动画掉帧,大概率是频繁触发了Layout和Paint。排查是否有「强制同步布局」——比如在循环里反复读写offsetHeight,或者样式变化过于频繁导致重排。这种问题在Performance里表现为主线程上连续密集的紫色Layout事件。
第四,看是否有「空闲但高负载」的脚本。有些第三方的轮询脚本、长连接脚本在页面静止时也在消耗CPU。Confirmed by Empty的CPU占比会很高。这类脚本直接考虑换成事件驱动或加长轮询间隔。
这些排查步骤不是死的,实际项目里瓶颈往往同时存在两个甚至三个,按影响面从大到小依次解决才是效率最高的路径。
写在最后:我在实际优化中的一点体会
异步加载与性能优化做久了,我最大的体会是:别把优化当成最后一步,它是开发过程中持续存在的默认思维。每次写一个新组件前先问一句「这个组件首屏必须出现吗」,每次引入一个新依赖前先看一下它的体积预算,长期坚持下来,页面性能根本不会劣化到需要大动干戈抢救的地步。
另外有一个小技巧分享:把性能优化跟业务功能分开测试。优化代码上线前,先单独跑一遍性能数据,给业务方看「纯优化、不带新功能」对页面速度的提升幅度。独立量化优化的价值,这样后续要资源、要排期都名正言顺。别问我怎么知道的,被质疑过「这不就是换了个写法吗」你就懂了。性能优化不是玄学,每一步都可以被度量,也都值得被度量。