大屏第一次上墙那天,我在客户会议室盯着 23 秒的白屏,空调声都听得见。后端接口很快,服务器就在隔壁机柜,ping 值 0.4 毫秒,可前端就是一片黑。打开 DevTools 的 Network 面板一看,瀑布图整整齐齐排成了十层,每一层最多 6 条,剩下的全在 Queueing 状态等着。那一刻我才真正意识到,压着这支大屏的不是带宽,不是接口,而是Chrome 对同一域名只开 6 个并发连接这条从 HTTP/1.1 时代延续下来的老规矩。
这篇内容就是围绕“大屏加载速度优化”里的这一个具体切口展开的:怎么确认瓶颈真的出在 Chrome 请求线程限制上,怎么用域名分片、HTTP/2、资源合并、加载时序编排这几套组合拳把它打穿,以及每一步为什么这么做、代价是什么、什么时候不该做。适合正在做 Vue3 + ECharts 可视化大屏、遇到首屏加载慢、看到瀑布图一排排排队却不知道从哪下手的前端同学,也适合需要给客户交付“开屏即用”大屏的工程同学。大屏和普通网页最大的区别在于,它没有“用户会滚动、会等待、会刷新”这个缓冲带——它是要投到 4K 屏幕上去的,切页签那一瞬间卡不卡,全场几十号人都看得见。
1. 大屏首屏为什么会被“6”这个数字卡住
1.1 浏览器并发限制到底限制的是什么
先把概念对齐,很多同学把“6 个请求线程”理解成“同一时刻只能发 6 个请求”,这个说法不够准确。准确的说法是:在 HTTP/1.1 下,Chrome 对同一个源(scheme + host + port 三者完全相同)最多维持 6 条 TCP 连接。注意是“同一个源”,不是“同一个页面”。你把静态资源换到另一个二级域名,Chrome 会认为那是另一个源,于是再给你 6 条连接。这就是域名分片能生效的根本原因。
那 HTTP/1.1 为什么不干脆一条连接上多塞几个请求?因为协议本身有队头阻塞(Head-of-Line Blocking)的问题。HTTP/1.1 虽然名义上支持 pipelining,但实际浏览器几乎全部默认关闭,原因是响应必须按请求顺序返回,只要第一个请求慢,后面所有响应全卡住,反而更糟。所以浏览器选择了一个朴素但有效的折中:开多条连接,每条连接上一次只跑一个请求。6 这个数字是 Chrome 权衡了服务器压力、内存开销、拥塞控制效率之后定的经验值,Firefox 历史上也用过 6,老 IE 是 2。
关键推论来了:如果你首屏有 60 个 HTTP/1.1 请求,且全部指向同一个源,那么它们会被拆成 10 轮串行执行。我在大屏项目里见过最夸张的一个,首屏 138 个请求,同一域名,等于 23 轮。假设公网环境单次往返 40 毫秒,光排队就要 920 毫秒;再加上服务端处理、资源下载、图片解码,白屏时间轻松上十几秒。
1.2 大屏和普通网页在加载模型上的差异
普通后台管理页可以“先出来骨架,用户慢慢点”,大屏不行。大屏的加载模型有三个很鲜明的特征,直接决定了优化策略要往哪个方向走。
第一个特征是首屏资源种类高度集中。一个典型大屏首屏要加载的东西包括:一张全屏背景图(PNG 或 JPG,2 到 8 MB 是常态)、一套数字字体(ECharts 大屏爱用 DIN、Bebas 这类字体,woff2 一般 30 到 80 KB,但中文字体动辄几 MB)、ECharts 主包加若干图表组件、地图 GeoJSON、若干图标精灵图、三到八个数据接口。种类集中意味着“合并同类项”的收益特别高——图标能合并成雪碧图,小图能内联成 base64,接口能合并成一个聚合接口。
第二个特征是渲染压力远大过普通页面。大屏通常开着几十个 ECharts 实例,每个都带轮播、渐变、动画。我遇到过好几次,请求全发完了、资源全下载了,但首屏仍然卡 3 秒,原因是 ECharts 初始化是同步阻塞的,一堆实例串在主线程上排队。这种情况你就算把 6 连接限制彻底打穿也没用,因为瓶颈换了地方。
第三个特征,也是最容易被忽略的:大屏的部署环境往往在内网。内网意味着 RTT 极低(0.3 到 2 毫秒),带宽往往是千兆起步,但同时也意味着很多公网方案用不上——没有 CDN、没有云对象存储、HTTPS 证书可能都是自签的、HTTP/2 需要在 Nginx 上自己开。这直接导致一个反直觉的结论:在纯内网大屏场景里,6 连接限制带来的绝对耗时损失,其实比公网小得多。
我给你算一笔账。同样 60 个请求、同一域名、6 条连接:
| 场景 | 单次 RTT | 轮数 | 纯排队理论最小值 | 实际白屏 |
|---|---|---|---|---|
| 公网 + CDN | 40 ms | 10 轮 | 400 ms | 6 到 15 s |
| 公网直连 | 80 ms | 10 轮 | 800 ms | 12 到 25 s |
| 内网千兆 | 1 ms | 10 轮 | 10 ms | 2 到 6 s |
看到差别了吗?内网里 6 连接限制只贡献了 10 毫秒的理论损失,真正吃掉时间的是别的东西。所以在动手之前,你必须先做一件事:用瀑布图确认,你的瓶颈到底是排队,还是文件体积,还是主线程阻塞。跳过这一步直接去搞域名分片,很可能折腾两天,白屏时间只降了 200 毫秒。
2. 先用瀑布图确认瓶颈:排队时间才是元凶
2.1 DevTools Network 面板里该看哪几列
打开 DevTools,切到 Network,勾选 Disable cache,刷新,然后重点看这几列。
Queueing(排队):这个就是被 6 连接限制卡住的时间。Chrome 把它单独列出来,说明它认为这是一个可优化的独立阶段。当某个请求前面的同源请求把 6 个槽位占满了,后来的就只能等。Queueing 时间越长,说明并发槽位竞争越激烈。
Stalled(停滞):跟 Queueing 有点像但不完全一样。Stalled 通常出现在代理协商、TLS 握手等待、或者连接池里没有可用 socket 的时候。区分 Queueing 和 Stalled 有个简单方法:Queueing 往往跟同源请求的数量强相关,你把域名换掉它就消失;Stalled 跟连接建立过程相关,跟请求数量关系不大。
Waiting (TTFB):从请求发出到收到第一个字节。这个高,说明是后端慢或者网络慢,跟 6 连接没关系,别在这瞎优化。
Content Download:下载资源本身。这个高说明文件太大或者带宽不够。
具体操作上,点开任意一条请求,切到 Timing 标签,会看到一个横向分解图。如果 Queueing 那一小段占了总耗时的 30% 以上,那你就找到真凶了。
2.2 一个真实的排队特征对照表
下面这张表是我从几个大屏项目里攒出来的经验值,用于快速判断“这到底是不是 6 连接的问题”。
| 现象 | 大概率原因 | 验证方式 | 对应手段 |
|---|---|---|---|
| 瀑布图呈规则阶梯状,每层 6 条 | 同源并发限制 | 数一数每层是不是正好 6 | 域名分片 / HTTP/2 |
| Queueing 列普遍 200 ms 以上 | 同源并发限制 | 看 Queueing 总量 | 域名分片 / 减少请求数 |
| 前 6 个很快,第 7 个开始慢 | 同源并发限制 | 看请求序号 | 域名分片 |
| 每个请求 TTFB 都高 | 后端慢 | 看服务器日志 | 接口聚合 / 缓存 |
| 大文件下载时间长,小文件正常 | 带宽或体积 | 看 Size 列 | 压缩 / 换格式 |
| 请求全绿但首屏还是慢 | 主线程阻塞 | Performance 面板 | 懒加载 / 分帧渲染 |
2.3 区分排队、TTFB 和带宽不足
这一步看着简单,但我在团队里带过的人,十个里有六个会搞混。举个真实案例:有个同学跟我说“大屏 8 秒白屏,肯定是 6 连接问题”,我让他把瀑布图截图给我,一看,请求总共只有 14 个,其中 3 个接口的 Waiting 都超过 2000 毫秒。这压根不是并发问题,是后端三个接口各自在等不同的数据库。
判断逻辑我一般按这个顺序走:
先数请求总数。如果首屏请求总数低于 12 个,同源 6 连接最多让你排两轮,收益上限极低,优先看别的方向。如果超过 40 个,那 6 连接基本一定是主要矛盾之一。
再看 Queueing 占总耗时的比例。在 Network 面板底部有一行汇总,把 Duration 加起来对比一下 Queueing 的占比。占比超过 25% 就值得动手。
最后看这些被排队的请求是什么类型。如果被排队的是 30 张 200 KB 的小图标,那答案是“不该排队,该合并”;如果被排队的是 30 个独立接口,那答案是“该聚合接口”;如果被排队的是几个大 JS chunk,那答案是“该拆该懒加载”。同样是排队,解药完全不同。
还有一个容易被忽略的细节:Chrome 在 HTTP/1.1 下的 6 连接是“按源”的,但 WebSocket 连接也占用这个额度。大屏项目里实时数据经常用 WebSocket,一个页面开两三个 WebSocket 是很常见的,那实际能用于加载资源的槽位就只剩三四个了。这一点在排查时特别容易漏。
3. 域名分片:老办法为什么现在还有效,以及它的边界
3.1 分片原理与收益计算
域名分片的逻辑非常直白:既然 Chrome 限制的是“每源 6 条”,那就造出多个源。把静态资源拆到static1.example.com、static2.example.com、static3.example.com上,每个域名各得 6 条连接,理论并发就变成 18。
这里有个必须说清楚的点:分片数量不是越多越好。每多一个域名,就多一次 DNS 解析、多一次 TCP 握手、多一次 TLS 握手(如果不同域名不共用证书还会额外多)。在 HTTPS 场景下,一次完整的连接建立大约要 2 到 3 个 RTT,公网按 40 毫秒算,就是 80 到 120 毫秒。你用第 3 个域名换来 6 条额外连接,如果这 6 条连接上只放了 3 个小图标,那这笔账是亏的。
我的经验阈值是这样:总请求数 40 到 80 个,分 2 到 3 个域名比较合适;超过 100 个请求,再考虑 4 个。超过 4 个域名,收益基本被握手开销吃光,还会拖长 DNS 解析的尾巴。而且现实中现代浏览器对 DNS 有并发限制,你开 6 个域名解析,DNS 那一层自己就先排队了。
再给一个粗略的收益估算公式,方便你判断值不值得做:
优化前总耗时 ≈ ceil(N / 6) × (TTFB + 传输时间) 优化后总耗时 ≈ ceil(N / (6 × D)) × (TTFB + 传输时间) + 握手开销 × (D - 1) N = 同源请求数 D = 分片域名数还是 60 个请求、TTFB 加传输按 60 毫秒算:优化前ceil(60/6) × 60 = 600 ms;分成 3 个域名后,ceil(60/18) × 60 + 100 × 2 = 240 + 200 = 440 ms。公网环境里这个差距会被 RTT 放大好几倍,内网里则几乎可以忽略——这也是为什么我说内网项目要谨慎上分片。
3.2 分域名配置的具体做法
落地的时候,一般是在构建产物这一层做,而不是手工改代码。以 Vite 为例,通过base或者build.rollupOptions.output.assetFileNames把不同类型资源指向不同前缀是最省事的做法。
// vite.config.js const CDN_HOSTS = { img: 'https://static1.example.com', js: 'https://static2.example.com', font: 'https://static3.example.com', }; export default defineConfig({ build: { rollupOptions: { output: { assetFileNames: (assetInfo) => { const name = assetInfo.name || ''; if (/\.(png|jpe?g|webp|avif|gif|svg)$/.test(name)) { return 'img/[name]-[hash][extname]'; } if (/\.(woff2?|ttf|otf|eot)$/.test(name)) { return 'font/[name]-[hash][extname]'; } return 'assets/[name]-[hash][extname]'; }, }, }, }, // 运行时通过 publicPath 或自定义插件替换前缀 });然后写一个小的 Vite 插件,在generateBundle阶段把不同类型的文件前缀替换掉:
function multiHostPlugin(hosts) { return { name: 'multi-host', enforce: 'post', apply: 'build', generateBundle(_, bundle) { for (const [fileName, chunk] of Object.entries(bundle)) { if (chunk.type === 'asset' && /\.(woff2?|ttf|otf)$/i.test(fileName)) { // 其余资源引用替换到字体域名 } } }, }; }实际上更常见、也更稳的做法是在 Nginx 层做 location 分流,同时用同一个域名下的不同路径配合多域名指向同一份静态目录:
# 三个域名都指向同一个静态根目录,DNS 里都解析到同一台机器 server { listen 80; server_name static1.example.com static2.example.com static3.example.com; root /data/bigscreen/dist; location ~* \.(js|css)$ { expires 30d; add_header Cache-Control "public, immutable"; } location ~* \.(png|jpg|jpeg|webp|avif|svg|woff2?)$ { expires 30d; add_header Cache-Control "public, immutable"; } }这样运维成本最低:一份文件、一台机器、三个解析。而且注意,这三个域名必须共用同一张证书或者用泛域名证书,否则 TLS 握手会各走各的,白白多花钱。
3.3 分片的代价:DNS、TLS、缓存与运维
分片不是白拿的,代价清单我列在这儿,你评估的时候逐条对。
第一是DNS 解析开销。浏览器对同一域名的解析结果会缓存,但首次解析每个域名都要一次查询(内网 DNS 通常 1 到 5 毫秒,公网递归查询可能 20 到 100 毫秒)。解决方式是在 HTML 头部加dns-prefetch:
<link rel="dns-prefetch" href="//static1.example.com"> <link rel="dns-prefetch" href="//static2.example.com"> <link rel="dns-prefetch" href="//static3.example.com">dns-prefetch的代价极低(就是一个 DNS 查询),收益是提前把解析做完,属于性价比很高的优化,甚至在你没做域名分片的时候也值得对后端接口域名加一条。
第二是TLS 握手开销。如果三个域名三张证书,那就是三次完整握手。用泛域名证书*.example.com,配合 HTTP/2 的连接合并(Connection Coalescing),Chrome 在证书覆盖范围内、DNS 解析到同一 IP 时,可能复用同一条连接,这时候分片就自动失效了——这是分片方案最尴尬的一个坑:你费劲拆了域名,Chrome 用连接合并又给你合回去了。
第三是缓存分散。资源被拆到三个域名,各自的缓存独立。用户第二次访问时命中率理论上不变,但如果你做了错误的拆分,比如同一个 bundle 被拆到两个域名,那缓存反而更差。
第四是运维复杂度。多域名意味着证书要续、DNS 要维护、CDN 要配多份、日志要分开看。小团队做这件事之前一定要想清楚值不值——很多时候,把这些精力放到 HTTP/2 上,收益更大、成本更低。
3.4 什么时候不该用分片
有三种情况我明确不建议做域名分片。
第一种:已经启用了 HTTP/2 或者 HTTP/3。这是最重要的一条。HTTP/2 的多路复用从机制上取消了 6 连接限制,分片不但没收益,反而因为多建连接而变慢。如果你现在的服务器已经开了 HTTP/2,那这篇文章你只需要看第 4 章和第 5 章。
第二种:内网部署且请求数少于 40。前面算过,内网 RTT 极低,6 连接带来的损失微乎其微。
第三种:首屏请求数已经被压到 15 个以内。这时候的瓶颈一定在别处——大文件、慢接口、主线程阻塞。先去砍文件体积,别折腾域名。
4. HTTP/2 多路复用能替代分片吗
4.1 多路复用的机制与真实收益
HTTP/2 的核心变化之一,是把“一条连接跑一个请求”改成“一条连接上跑多个并行的流(Stream)”。每个请求分配一个 Stream ID,帧可以交错发送,接收端按 Stream ID 重新组装。这意味着同源并发请求数不再受 6 的限制,理论上受SETTINGS_MAX_CONCURRENT_STREAMS控制,Nginx 默认给的是 128。
这个数字对比一下就知道量级差异了:
| 方案 | 同源并发上限 | 额外连接开销 | 队头阻塞 |
|---|---|---|---|
| HTTP/1.1 单域名 | 6 | 无 | 请求级 |
| HTTP/1.1 + 3 域名分片 | 18 | 3 次握手 | 请求级 |
| HTTP/2 | 128(默认) | 1 次握手 | TCP 层 |
我实测过一个 78 请求的大屏,在同一个 Nginx 上切换 HTTP/1.1 和 HTTP/2,内网环境首屏load事件差了 1.8 秒;换成公网 CDN(RTT 约 35 毫秒),差了接近 5 秒。差别主要就来自瀑布图里那些整整齐齐的阶梯消失了。
4.2 为什么开了 HTTP/2 还是慢:服务端并发与队头阻塞
但 HTTP/2 不是银弹,有两个坑必须知道。
第一个坑是TCP 层队头阻塞。HTTP/2 的多路复用是在一条 TCP 连接上做的,一旦有丢包,TCP 的重传机制会让整条连接上的所有流一起等。所以在网络质量差的环境里(弱网、跨区域),HTTP/2 反而可能比 HTTP/1.1 多连接更慢。HTTP/3 用 QUIC 换掉 TCP 就是为了解决这个问题。不过在大屏场景里,要么是内网(不丢包),要么是有线网络投屏(稳定),这个坑踩到的概率不高。
第二个坑是服务端并发处理能力。这个坑我在一个项目上真真切切踩了。当时把 Nginx 升到 HTTP/2,以为万事大吉,结果首屏从 8 秒变成了 9 秒。排查了半天发现:Nginx 前面的应用服务是单进程单线程的 Node,SETTINGS_MAX_CONCURRENT_STREAMS开到了 128,128 个请求一股脑涌过去,Node 那边瞬间堆积,事件循环被压死,每个请求的处理时间反而被拉长。多路复用解除的是客户端侧的排队,如果服务端扛不住高并发,瓶颈只是从浏览器搬到了服务器。
解法通常是两件事:一是把http2_max_concurrent_streams调到合理值(我一般设 24 到 48,而不是默认 128),二是确保静态资源和动态接口走不同上游,静态走 Nginx 本地磁盘(几乎不消耗后端),动态接口单独限流。
4.3 大屏项目里 HTTP/2 的落地配置要点
Nginx 开启 HTTP/2 的关键配置。注意 Nginx 1.25.1 之后推荐用http2 on的新语法,旧语法listen 443 ssl http2已废弃。
server { listen 443 ssl; http2 on; # Nginx 1.25.1+ server_name bigscreen.example.com; ssl_certificate /etc/nginx/certs/wildcard.crt; ssl_certificate_key /etc/nginx/certs/wildcard.key; # 只保留 TLS 1.3 和 1.2,关掉老协议 ssl_protocols TLSv1.2 TLSv1.3; # 并发流数量,按后端能力调 http2_max_concurrent_streams 32; # 启用 Brotli(需编译 ngx_brotli 模块)或 gzip brotli on; brotli_types text/css application/javascript application/json image/svg+xml; brotli_comp_level 5; gzip on; gzip_types text/css application/javascript application/json image/svg+xml; gzip_min_length 1024; location / { root /data/bigscreen/dist; try_files $uri $uri/ /index.html; } }几个配置上的经验。brotli_comp_level别拉到 11,编译期 CPU 会很高,尤其是大屏那种几 MB 的 JSON 数据文件,压一次要几百毫秒,反而拖慢 TTFB。我一般用 4 到 5,压缩率和速度平衡得比较好。gzip和brotli同时开是可以的,Nginx 会按客户端Accept-Encoding优先选 Brotli。
还有一个细节:HTTP/2 下不要再做域名分片,但preconnect依然有用——预连接可以省掉 DNS 和握手的往返。写法是:
<link rel="preconnect" href="https://bigscreen.example.com" crossorigin>如果后端接口在另一个源上(很常见,比如api.example.com),一定要给接口域名也加preconnect,这个在慢网络下能省 100 毫秒以上。
注意:HTTP/2 和域名分片是互斥方案,同时用会互相抵消。已经在 HTTP/2 上的项目,把分片域名合并回主域名,反而更快。
5. 资源侧的减法:把请求数从几十降到个位数
5.1 图片、字体、图标的处理策略
不管有没有 HTTP/2,减少请求数永远是正经事。我个人在大屏项目上的优先级排序是:先砍体积,再砍数量,最后才谈并发。
先说图片,这是大屏里的大头。一张 3840×2160 的背景图,如果设计师给的是一张 6 MB 的 PNG,那不管你并发多少路,光下载就够呛。处理路径我一般这么走:
- 背景图优先转WebP,质量 80 到 85,体积通常能降到 PNG 的 15% 到 25%。遇到不支持的老浏览器(大屏项目里很少见了,但如果客户用的是老版本工业机)再回退 JPG。
- 装饰性图片(边框、光效、纹理)如果是纯色或渐变,直接用 CSS 画,别用图,一张都不用发。
- 小图标(小于 8 KB)全部内联成 base64 或者雪碧图。内联走 CSS,雪碧图走一张文件。注意内联会让 CSS 文件变大且无法单独缓存,所以只对确定会被用到的图标做内联。
字体是第二个大头,也是最多人忽略的。大屏爱用数字字体展示指标数值,如果直接引一个完整的中文字体,那是几 MB 起步。我的做法是:
- 中文用系统字体,不要引外部中文字体。
- 数字字体如果实在要用,一定要做子集化(subset)。只保留 0 到 9、小数点、百分号、加减号、冒号,一个 woff2 大概只有 3 到 6 KB。
- 用
font-display: swap或者optional,避免字体下载阻塞文字渲染。
子集化用fonttools一条命令就够了:
pip install fonttools brotli pyftsubset DINPro-Bold.ttf \ --text="0123456789.,%+-:/" \ --flavor=woff2 \ --output-file=din-digits.woff2实测一个 68 KB 的 DIN 字体,子集化之后是 4.2 KB,请求数没变但传输时间几乎归零。
5.2 JS/CSS 分包与关键资源内联
Vue3 + ECharts 的打包产物,如果不做任何处理,主包轻松上到 2 MB。ECharts 单独就要 700 KB 以上(全量版)。这里必须做三件事。
第一,ECharts 按需引入。大屏虽然图表多,但类型其实就那几种(柱状、折线、饼图、地图),全量引入纯属浪费。
// echarts 按需引入 import * as echarts from 'echarts/core'; import { BarChart, LineChart, PieChart, MapChart } from 'echarts/charts'; import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, VisualMapComponent, GeoComponent, } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; echarts.use([ BarChart, LineChart, PieChart, MapChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent, VisualMapComponent, GeoComponent, CanvasRenderer, ]); export default echarts;这一招通常能把 ECharts 相关体积从 700 KB 以上压到 300 KB 左右。
第二,Vite 手动分包。把不会变动的第三方库单独拆出来,利用长期缓存。
// vite.config.js build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('node_modules')) { if (id.includes('echarts')) return 'vendor-echarts'; if (id.includes('vue') || id.includes('pinia')) return 'vendor-vue'; if (id.includes('lodash')) return 'vendor-lodash'; return 'vendor'; } }, }, }, chunkSizeWarningLimit: 800, }分包的判断标准很简单:这个库多久会升一次版本?半年不动一次的就拆出去,天天改的留在主包。这样大屏更新时用户只需要拉业务代码那几十 KB。
第三,关键 CSS 内联。首屏渲染需要的样式(背景、栅格、加载动画)直接写进index.html的<style>里。这样 HTML 一到就能画出骨架,不用等 CSS 文件。代价是 HTML 变大、CSS 无法单独缓存,所以只内联首屏必需的,我一般控制在 4 KB 以内。
5.3 接口合并与数据预热
大屏的接口通常按模块拆,一个页面八个模块就可能八个接口。八个接口如果都指向api.example.com,HTTP/1.1 下要排两轮。内网虽然快,但接口串行加起来的 TTFB 也是一笔账。
处理方式有两种。一是后端做聚合接口,前端一次调用拿到全部首屏数据;二是前端用Promise.all并行——注意,Promise.all解决的是 JS 层面的等待,不解决浏览器连接层的排队,所以如果接口同源且多于 6 个,你还是会看到 Queueing。
还有个更容易见效的做法:给接口加本地缓存兜底。大屏的数据通常是分钟级更新,首屏完全可以先渲染上一次的数据,再异步拉新数据覆盖。这样用户看到的是“秒开 + 数据自动刷新”,体验差异是断层级的。
// 首屏先用 localStorage 里的快照渲染 function bootstrap() { const snapshot = localStorage.getItem('dashboard-snapshot'); if (snapshot) { render(JSON.parse(snapshot)); } // 再异步拉最新数据 fetchAll().then((data) => { render(data); localStorage.setItem('dashboard-snapshot', JSON.stringify(data)); }); }这个方案的实测效果比我预期好很多:某项目首屏从 4.2 秒降到 0.6 秒(有快照的情况下),代价是数据有几分钟延迟,业务上完全可接受。
6. 加载时序编排:preload、懒加载与分级渲染
6.1 关键资源优先级编排
把请求数压下来之后,还有一个提升空间:让浏览器先下载最该下载的东西。默认情况下,Chrome 按资源在 HTML 里出现的顺序和类型来排优先级,这个默认策略对大屏往往不合适。
preload是用得最多也最容易用错的一个。它的作用是告诉浏览器“这个资源马上会用到,请立刻用高优先级去拿”。但如果你 preload 了七八个资源,等于没有优先级。
<!-- 只 preload 这三类:主背景图、首屏必需字体、主 JS --> <link rel="preload" as="image" href="/img/bg-main.webp" fetchpriority="high"> <link rel="preload" as="font" type="font/woff2" href="/font/din-digits.woff2" crossorigin> <link rel="preload" as="script" href="/assets/index-abc123.js" fetchpriority="high"> <!-- 非首屏的用 prefetch,低优先级 --> <link rel="prefetch" as="image" href="/img/map-overlay.webp">几个坑要提醒。as属性必须写对,as="font"的时候必须带crossorigin,否则浏览器会下载两次(一次带凭证一次不带)。preload的资源如果在 3 秒内没被使用,Chrome 会打控制台警告,说明你 preload 错了对象。fetchpriority目前 Chrome 支持良好,用来把首屏大图提到 High,把非关键资源降到 Low。
反过来,非首屏的图片一定要加loading="lazy":
<img src="/img/panel-a.webp" loading="lazy" decoding="async" width="480" height="270" alt="">decoding="async"这个属性在大屏上很重要——它让图片解码发生在主线程之外,避免大图解码卡住 ECharts 的动画。配合width和height一起写,还能避免布局抖动。
6.2 大屏的分层渲染与骨架屏
大屏的首屏其实是可以拆层的:背景层、框架层(边框、标题栏)、图表层、数据层。我一般按这个顺序渲染:
第一帧只画背景和框架,这部分用内联 CSS 就能完成,几乎零等待。第二帧挂载图表容器,但先不初始化 ECharts,只画占位。第三帧数据到了之后再setOption。
关键技巧是用requestIdleCallback或者requestAnimationFrame把 ECharts 初始化切成小批,别一口气初始化 20 个实例。
function initChartsLazily(containers) { const queue = [...containers]; function step() { const batch = queue.splice(0, 3); // 每帧最多初始化 3 个 batch.forEach((el) => { const chart = echarts.init(el, null, { renderer: 'canvas' }); chart.setOption(el.__option); }); if (queue.length) { requestAnimationFrame(step); } } requestAnimationFrame(step); }每帧 3 个实例是我的经验值。小于 3 效果不明显,大于 5 会掉帧。如果你的图表特别复杂(比如带地图的),降到每帧 1 个更稳。
还有个细节:ECharts 图表的动画在大屏上建议关掉或者缩短。animationDuration默认 1000 毫秒,20 个图表同时跑动画,主线程压力很大。首屏我用animation: false,等用户交互或者第二次刷新再开动画。
6.3 Service Worker 与本地缓存(内网部署场景)
内网大屏特别适合用 Service Worker 做缓存,因为内网有个天然优势:没有跨域问题,没有证书困扰,缓存策略可以做得非常激进。
但有个必须注意的点:大屏通常要频繁更新(数据、图表配置),Service Worker 的缓存策略必须是“HTML 走网络优先,静态资源走缓存优先”。
// sw.js const CACHE = 'bigscreen-v1'; const STATIC = ['/assets/index-abc123.js', '/assets/vendor-echarts-def456.js']; self.addEventListener('install', (e) => { e.waitUntil(caches.open(CACHE).then((c) => c.addAll(STATIC))); self.skipWaiting(); }); self.addEventListener('fetch', (e) => { const url = new URL(e.request.url); if (url.pathname.endsWith('.html') || url.pathname === '/') { // HTML 网络优先 e.respondWith( fetch(e.request).catch(() => caches.match(e.request)) ); return; } // 带 hash 的静态资源缓存优先 if (/\/assets\/|\/img\/|\/font\//.test(url.pathname)) { e.respondWith( caches.match(e.request).then((r) => r || fetch(e.request)) ); } });有个坑我踩过:Service Worker 更新是异步的,大屏上线新版本时,如果用户(或者墙上那台机器)一直不关闭页面,可能还在跑旧版 SW。解法是在activate里clients.claim(),或者在页面里监听updatefound提示刷新。大屏这种长期不关页面的场景,建议加一个定时检查更新的逻辑,比如每小时registration.update()一次。
7. 实测对比与踩过的坑
7.1 优化前后数据对照
把上面这些手段串起来,我用一个真实项目的数据做个整体对照。项目是 Vue3 + ECharts 的工厂看板,投在 4K 屏上,内网部署,Nginx 在同一台机器。
| 阶段 | 请求数 | 首屏 load | 首屏可见(骨架) | 主要瓶颈 |
|---|---|---|---|---|
| 优化前 | 138 | 9.4 s | 3.8 s | 6 连接排队 + 图片体积 |
| 砍请求数(图标合并、字体子集化) | 46 | 6.1 s | 2.4 s | 6 连接排队 |
| ECharts 按需 + 分包 | 41 | 5.2 s | 1.9 s | 图片下载 |
| 背景图转 WebP + preload 编排 | 39 | 3.6 s | 1.2 s | 图表初始化 |
| 分帧初始化 + 首屏动画关闭 | 39 | 3.5 s | 0.6 s | 无 |
| 开 HTTP/2(关掉分片) | 39 | 2.1 s | 0.5 s | 无 |
注意最后一行:在做了前面所有事情之后,HTTP/2 依然贡献了 1.4 秒。因为此时剩下 39 个请求里,大部分是地图 GeoJSON 分片和几个大图片,同源 6 连接下还是要排 7 轮。所以正确的顺序是:先把请求数和体积压下来,再开 HTTP/2 收尾。
7.2 几个把我坑了半天的细节
第一个坑,Chrome 的 6 连接限制在 HTTP/1.1 下还有个隐性行为:如果某个域的连接建立失败或者超过一定时间没有响应,Chrome 会缩短连接复用窗口。我遇到过一次,某个域名的连接池一直只维持在 2 到 3 条,瀑布图上永远排不满 6 条。排查了很久,最后发现是那个域名的部分请求命中了 Nginx 的一个return 301重定向,重定向之后源变了,连接池自然不一样。
第二个坑,preload和prefetch用反了会拖慢首屏。我在一个项目里手滑把地图 GeoJSON 写成了preload,结果它和主背景图抢带宽,首屏可见时间从 1.2 秒涨到 2.1 秒。记住原则:preload只给首屏必需且体积不大的资源,大文件一律用prefetch。
第三个坑,HTTP/2 开启后,Nginx 对同一个域名的连接合并会让preconnect的收益变小。因为本来就只有一条连接,你preconnect只是提前建了这条连接,省的是握手。这个收益依然存在,但别指望它像 HTTP/1.1 时代那样显著。
第四个坑,也是最容易翻车的:大屏上墙的机器往往是老版本浏览器。我见过工业一体机上跑的还是 Chrome 80 多,fetchpriority、content-visibility、aspect-ratio这些新属性全不支持。所以上线前一定要在目标机器上实测,别只在开发机的 Chrome 最新版上验证。有没有优雅降级,比用了多少新特性重要得多。
最后一个经验:优化前一定要量基线。我现在的习惯是在项目一开始就写一个简单的埋点,记录performance.timing里的domContentLoadedEventEnd、loadEventEnd,以及自己打的“首屏渲染完成”时间戳,上报到内网一个接口里存着。这样优化效果有数据对比,跟客户汇报的时候也有东西拿得出手,不用靠感觉说“好像快了”。
// 首屏埋点,简单但足够用 window.addEventListener('load', () => { const t = performance.timing; const metrics = { dns: t.domainLookupEnd - t.domainLookupStart, tcp: t.connectEnd - t.connectStart, ttfb: t.responseStart - t.requestStart, domReady: t.domContentLoadedEventEnd - t.navigationStart, loaded: t.loadEventEnd - t.navigationStart, firstPaint: performance.getEntriesByType('paint')[0]?.startTime || 0, }; navigator.sendBeacon('/api/metrics', JSON.stringify(metrics)); });调 Chrome 的并发限制这件事,最忌讳的就是一上来就搜“域名分片怎么配”,然后照着抄。真正省时间的路径是:先看瀑布图确认排队是不是主要矛盾,判断部署环境是公网还是内网,再决定走“压缩请求数 + HTTP/2”还是“压缩请求数 + 域名分片”。我做过五六个大屏项目下来,绝大多数情况下,把请求数从一百多压到三四十,比任何并发技巧都管用——因为那一步同时干掉了排队、下载、解析三笔账,而域名分片只解决其中一笔。