前端资源加载优先级控制的实战经验与优化策略
项目初期的技术选型
去年年底接手一个政府侧的政务服务平台H5页面,目标用户是基层工作人员,用的是老旧安卓平板(很多还是Android 6.0),Chrome内核版本卡在53左右。首页要加载地图、实时工单列表、通知弹窗、三个异步图表,首屏白屏时间一度飙到4.8秒——运营天天在群里艾特我:「用户反馈点开就转圈,是不是挂了?」
一开始我以为是接口慢,加了loading、优化了API聚合,结果Lighthouse跑下来,首屏JS解析耗时占了72%。打开Network面板一看,charts.js(1.2MB)、map-sdk.min.js(890KB)、moment-timezone.js(420KB)全在DOMContentLoaded前堵着,连<link rel="stylesheet">都得排队等JS执行完才开始下载。
这时候我才想起:哦对,现代浏览器有资源优先级这玩意儿。以前只在文档里扫过fetchpriority、preload、media属性,这次真得动真格了。
最大的坑:performance.now()测出来的“假快”
第一版我直接给关键CSS和首屏图片加了<link rel="preload" as="style">和<link rel="preload" as="image">,然后把非首屏JS改成<script type="module" defer>。Lighthouse分数从42跳到78,我高兴地发了周报。
结果上线第二天,一线反馈更糟了:「首页能进,但点「工单列表」要等3秒才响应」。抓包一看,workorder-list.js虽然标记了defer,但它依赖的utils.js被我设成了preload,而utils.js里又动态import了lodash-es——浏览器拼命提前下lodash-es,却卡在解析阶段,导致整个模块链阻塞。
折腾了半天发现:preload不是万能加速键,它只是让资源**下载早**,不代表**执行早**。尤其当预加载资源体积大、解析慢、又有深层依赖时,反而会挤占主线程,拖累真实交互。
最终的解决方案
后来我把策略拆成三层:
- 必须首屏渲染的:内联关键CSS +
fetchpriority="high"图片 +<link rel="preload" as="font">字体 - 交互前需就绪的:
<script type="module" fetchpriority="low">+import()动态加载 - 纯后台服务的:
<script type="module" fetchpriority="auto" async>,配合document.readyState === 'interactive'时机触发
重点说说那个救命的fetchpriority。它得配合type="module"一起用,不然老浏览器不认。我们主入口HTML里这么写:
<!-- 关键资源 -->
<link rel="preload" href="/css/critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<link rel="preload" href="/fonts/icon.woff2" as="font" type="font/woff2" crossorigin>
<!-- 首屏JS:高优先级,但只加载必要逻辑 -->
<script type="module" fetchpriority="high" src="/js/entry-core.js"></script>
<!-- 非首屏JS:低优先级,延迟加载 -->
<script type="module" fetchpriority="low" src="/js/charts-init.js"></script>
<script type="module" fetchpriority="low" src="/js/map-loader.js"></script>
<!-- 后台任务JS:完全不抢资源 -->
<script type="module" fetchpriority="auto" async src="/js/analytics.js"></script>
关键在于entry-core.js里做了手脚——它不干别的,就做三件事:
- 初始化基础UI骨架(带骨架屏)
- 用
fetch('https://jztheme.com/api/home')拉首页数据 - 数据回来后,再
import('./workorder-list.js'),确保用户点击前模块已缓存
核心代码就这几行:
// entry-core.js
const renderSkeleton = () => { /* 渲染骨架 */ };
const initApp = async () => {
renderSkeleton();
const data = await fetch('https://jztheme.com/api/home').then(r => r.json());
// 只在此时加载业务模块,避免预加载污染
if (data.hasWorkorder) {
const { renderWorkorderList } = await import('./workorder-list.js');
renderWorkorderList(data.workorders);
}
};
initApp();
这里注意我踩过好几次坑:import()返回的Promise必须await,否则renderWorkorderList可能还没定义就调用了;另外fetchpriority="low"对<script>生效的前提是type="module",普通<script>加了也白加。
回顾与反思
改完后实测效果:首屏FCP从2.1s压到0.8s,TTI(可交互时间)从5.3s降到1.9s,基层平板上滑动流畅度明显提升。Lighthouse性能分稳定在92+,最关键是——没人再在群里问「是不是挂了」。
但也有没搞定的细节:地图SDK内部自己搞了一堆document.write注入脚本,fetchpriority对它完全无效;还有个第三方统计SDK硬编码了<script src="...">,我们没法插手。最后只能用setTimeout(() => { injectScript() }, 3000)延后加载,牺牲一点数据完整性换体验,反正上报失败率<0.3%,PM点头放行了。
这个方案不是最优解,比如Service Worker缓存+优先级调度会更稳,但项目排期紧,上线前两周才定下优化方向,这种「够用+可控+易回滚」的方案反而是最实际的。毕竟开发不是写论文,能解决用户手指头点下去的等待感,就是最大胜利。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的动态资源分级加载实践,尤其是针对老安卓WebView的兼容方案,欢迎评论区交流。
