
大屏第一次上墙那天我在客户会议室盯着 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 或 JPG2 到 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轮数纯排队理论最小值实际白屏公网 CDN40 ms10 轮400 ms6 到 15 s公网直连80 ms10 轮800 ms12 到 25 s内网千兆1 ms10 轮10 ms2 到 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/2Queueing 列普遍 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-prefetchlink reldns-prefetch href//static1.example.com link reldns-prefetch href//static2.example.com link reldns-prefetch href//static3.example.comdns-prefetch的代价极低就是一个 DNS 查询收益是提前把解析做完属于性价比很高的优化甚至在你没做域名分片的时候也值得对后端接口域名加一条。第二是TLS 握手开销。如果三个域名三张证书那就是三次完整握手。用泛域名证书*.example.com配合 HTTP/2 的连接合并Connection CoalescingChrome 在证书覆盖范围内、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 域名分片183 次握手请求级HTTP/2128默认1 次握手TCP 层我实测过一个 78 请求的大屏在同一个 Nginx 上切换 HTTP/1.1 和 HTTP/2内网环境首屏load事件差了 1.8 秒换成公网 CDNRTT 约 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 前面的应用服务是单进程单线程的 NodeSETTINGS_MAX_CONCURRENT_STREAMS开到了 128128 个请求一股脑涌过去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/svgxml; brotli_comp_level 5; gzip on; gzip_types text/css application/javascript application/json image/svgxml; 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 relpreconnect hrefhttps://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 \ --text0123456789.,%-:/ \ --flavorwoff2 \ --output-filedin-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.comHTTP/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 relpreload asimage href/img/bg-main.webp fetchpriorityhigh link relpreload asfont typefont/woff2 href/font/din-digits.woff2 crossorigin link relpreload asscript href/assets/index-abc123.js fetchpriorityhigh !-- 非首屏的用 prefetch低优先级 -- link relprefetch asimage href/img/map-overlay.webp几个坑要提醒。as属性必须写对asfont的时候必须带crossorigin否则浏览器会下载两次一次带凭证一次不带。preload的资源如果在 3 秒内没被使用Chrome 会打控制台警告说明你 preload 错了对象。fetchpriority目前 Chrome 支持良好用来把首屏大图提到 High把非关键资源降到 Low。反过来非首屏的图片一定要加loadinglazyimg src/img/panel-a.webp loadinglazy decodingasync width480 height270 altdecodingasync这个属性在大屏上很重要——它让图片解码发生在主线程之外避免大图解码卡住 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首屏可见骨架主要瓶颈优化前1389.4 s3.8 s6 连接排队 图片体积砍请求数图标合并、字体子集化466.1 s2.4 s6 连接排队ECharts 按需 分包415.2 s1.9 s图片下载背景图转 WebP preload 编排393.6 s1.2 s图表初始化分帧初始化 首屏动画关闭393.5 s0.6 s无开 HTTP/2关掉分片392.1 s0.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”还是“压缩请求数 域名分片”。我做过五六个大屏项目下来绝大多数情况下把请求数从一百多压到三四十比任何并发技巧都管用——因为那一步同时干掉了排队、下载、解析三笔账而域名分片只解决其中一笔。