广州的十一月到了夜里才勉强有些凉意。室友们大都已经睡下,宿舍里只有机箱风扇转动的低鸣,和偶尔敲击红轴键盘的沉闷声响。
屏幕上静静躺着我的简历。广州商学院,软件工程,大四。GPA 4.02,两次国家奖学金。这些字眼在白色的背景下显得很平静,像是一些已经完成的、被压缩打包好的静态资源。在很多人眼里,民办三本的学历标签就像是一个初始体积过大的 bundle 文件,在社会的网络环境里,加载起来总是比别人慢一些,甚至容易触发超时中断。
但我渐渐明白,初始的响应时间并不能决定整个应用的最终体验。这四年里,我花了很多个这样的夜晚在图书馆和宿舍的电脑前,试图把那些看起来庞大且混乱的东西,一点点拆解、压缩、优化。写代码是这样,生活也是这样。
前端性能优化,在这个圈子里是个被咀嚼过无数遍的话题。面试的时候总会被问起,大家都能背出几条军规。但在实际的项目里,优化从来不是纸上谈兵的八股文,而是在一次次看着白屏发呆、看着页面卡顿掉帧后,逼出来的生存本能。这里整理的一些实践,大多源于我平时写项目时遇到的真实困境。
从加载开始说起。
以前写 React 和 Vue 的时候,总喜欢把所有的组件和依赖一股脑地 import 进来。直到有一天,我尝试把一个稍微复杂的后台管理系统打包,终端里跳出警告,告诉我的 vendor.js 已经超过了 2MB。在弱网环境下,这意味着用户需要盯着白屏看上好几秒。那几秒钟的空白,对开发者来说是一种煎熬。
后来我开始习惯性地做代码分割。路由级别的懒加载是最基础的。
// React
const Dashboard = React.lazy(() => import('./Dashboard'))
// Vue
const routes = [
{ path: '/dashboard', component: () => import('./Dashboard.vue') }
]
只是加了这么几行代码,配合构建工具的分包策略,首屏不需要的组件就被拆分到了独立的 chunk 里。看着 Network 面板里按需加载的细碎文件,会有一种把杂乱无章的房间收拾妥当的舒适感。
关于图片优化,我曾经吃过很大的亏。大二接了一个校园摄影墙的外包,几百张高清原图直接塞进 DOM 里,不仅服务器带宽瞬间跑满,浏览器也直接卡死。那时候我才意识到,前端不仅仅是把页面画出来而已。
后来我把所有图片都转成了 WebP 甚至 AVIF 格式,体积直接缩减了一大半。对于那些不在首屏的图片,加上 loading="lazy" 属性。这是一个很优雅的 HTML 原生属性,不需要写复杂的 Intersection Observer,浏览器自己就会在图片即将进入可视区域时才去请求资源。再配合 CDN 的图片处理服务,根据屏幕尺寸动态裁剪,整个瀑布流的加载变得像呼吸一样自然。
资源的预加载也是一种对用户行为的预判。当你大概率知道用户下一步会做什么时,可以提前把资源拉过来。
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
<link rel="prefetch" href="/next-page.js">
preload 用来处理当前页面必须要用的关键资源,比如自定义字体,避免文字出现闪烁。而 prefetch 则是在浏览器空闲的时候,悄悄去下载下一个页面可能用到的脚本。这种藏在暗处的体贴,用户虽然看不见,但能感觉得到流畅。
等资源都加载完了,接下来就是渲染。
不知道你有没有这样的体验,正在看一篇长文章,突然顶部加载出来一张图片,把原本在看的内容挤到了下面。这种布局抖动(CLS)非常破坏阅读心流。解决起来其实出奇地简单。
一个 aspect-ratio 属性,就能在图片真正下载完之前,在页面上撑开一块属于它的占位空间。万物皆有其位,页面就不会再因为未知的大小而局促不安。
长列表渲染是另一个经常让人头疼的问题。大三做数据大屏的时候,有一张表需要展示几万条日志。一开始直接用 map 渲染,DOM 节点数量爆炸,滚动一下鼠标滚轮,页面要迟钝半秒才有反应。后来手写了虚拟滚动,计算可视区域的高度,监听 scroll 事件,只渲染出现在屏幕里的那几十个元素。上下滑动的时候,节点在复用,数据在更替。其实人眼能看到的世界也就这么大,浏览器也不需要去记住所有的过去和未来,只管好当下可视区域里的 DOM 就够了。
防抖和节流,这两个概念在课本里很抽象,但在输入框里很具体。
用户在搜索框里飞快敲下几个字,如果没有防抖,每一次按键都会向后端发起一次请求。服务器会接到很多毫无意义的中间态查询。加上 300 毫秒的防抖,就像是告诉程序,别着急,等他把话说完。这是一种很好的留白。
再往深处走,前端的边界其实会延伸到网络层。
我有一台自己折腾的便宜 VPS,上面部署着我的个人博客。为了让它快一点,我学着去配置 Nginx。开启 HTTP/2 之后,多路复用的特性让所有的静态资源都可以通过一个 TCP 连接并发传输,再也不需要像以前那样搞域名分片了。
打开 Gzip 或者 Brotli 压缩,看着原本 100KB 的文本文件在传输时被压缩到了 20KB。这种物理规则上的体积缩减,比绞尽脑汁去删减几行业务代码来得直接得多。再加上 Service Worker 的离线缓存,当用户第二次访问网站,甚至在断网的情况下,页面依然能瞬间呈现出来。那种感觉,就像是你在荒郊野外,发现有人为你提前建好了一个避风港。
做这些优化的时候,我经常会盯着各种度量工具看。
Lighthouse 就像是一面镜子。它会冷酷地给你的页面打分。我曾经有一段时间对那四个绿色的 100 分有着强迫症般的执念。为了提高哪怕 1 分的性能评分,去扣每一行代码的执行时间。
Web Vitals 里的 LCP(最大内容绘制)、FID(首次输入延迟)和 CLS(累积布局偏移),这些指标把主观的“卡”和“慢”,量化成了客观的毫秒数。Chrome DevTools 的 Performance 面板里,火焰图记录了主线程上发生的一切,哪里有长任务(Long Task)阻塞了渲染,哪里发生了强制同步布局,一目了然。
但后来我慢慢觉得,工具只是工具。跑分再高,如果牺牲了代码的可维护性,或者砍掉了必要的业务逻辑,那也是本末倒置。真正的优化,不是追求跑分软件上的满分,而是在现有的资源和限制下,寻找一个最平衡的解。
合上电脑前,我又看了一眼简历上的学校和绩点。
优化是没有银弹的。不管是前端页面,还是一个普通学生的履历。你只能先去度量,找到瓶颈在哪里,然后有针对性地一点点去解决。三本的出身是改不掉的初始网络环境,但我可以把自己的内核压缩得更紧实一点,把知识的缓存做得更长效一点。
只要一直在跑,总会变快的。