弱网适配:让站点在地铁和电梯里也能打开
我们做性能优化的时候,习惯在机房宽带或者公司 Wi-Fi 上测,跑出来的 LCP 是 0.9 秒,于是觉得「已经够快了」。但真实用户的分布完全不是这样:地铁隧道里 3G 飘忽、电梯里信号只剩一格、三四线城市的移动网络 RTT 稳定在 300ms 以上、还有大量在 4G 套餐限速之后被降到 128kbps 的用户。这些人才是「弱网」,而他们在弱网下看到的东西,和你在 DevTools 里看到的完全是两个站。
这篇文章讲的不是「把图片压小」这种泛泛的优化,而是弱网环境下特有的几个失效点,以及我们在一台日均 IP 不足五千的小站上实际做过的改动。全部改动加起来不到 200 行配置,但对弱网首屏的改善比换一台更好的服务器明显得多。
一、先能量化:用 DevTools 的限速档位当基线
Chrome DevTools 的 Network 面板可以自定义限速,但内置档位不够真实——「Slow 3G」是 400kbps 下行 + 400ms RTT,「Fast 3G」是 1.6Mbps + 150ms RTT,都偏乐观。我建议自建两个档位:
地铁档:下行 200 kbps / 上行 100 kbps / RTT 500 ms(丢包 2%)
限速档:下行 128 kbps / 上行 64 kbps / RTT 800 ms(丢包 5%)在 DevTools → Network → Throttling → Add custom profile 里填进去,然后打开站点首页,看三件事:LCP 出现在第几秒、首屏完整渲染需要几个请求、有没有任何一个请求超过 5 秒还在 pending。这三个数字比 Lighthouse 的总分有用得多,因为它们直接对应「用户会不会关掉页面」。
我们第一次这样测的时候发现:首页在「地铁档」下 LCP 是 11.4 秒,其中 4.2 秒耗在一个 38KB 的广告脚本上,另外 3 秒耗在三张还没进首屏的封面图上。问题不是总字节大,而是关键路径上排了不该排的东西。
二、第一刀砍掉首屏不需要的第三方脚本
弱网下第三方脚本的伤害是双倍的:它自己不产生首屏内容,却要占带宽、占连接数、而且常常是同步阻塞的。检查办法是打开 DevTools 的 Coverage 面板,或者直接看 HTML 里所有 <script> 的位置。
三个改动按优先级排:
- AdSense 和统计脚本改成懒加载。AdSense 官方代码是
async的,但它在页面 DOMContentLoaded 附近就会执行,弱网下会和你的 CSS、首图抢带宽。我们的做法是监听scroll或者用IntersectionObserver,等广告位进入视口前 200px 才注入脚本。这一条对 LCP 提升最直接,实测从 11.4 秒降到 6.8 秒。 - 统计脚本延后到
load之后 3 秒。不要用setTimeout直接塞在主流程里,写在window.addEventListener('load', ...)里,再加一个requestIdleCallback兜底。 - 字体加载不要阻塞渲染。
@font-face加font-display: swap,并在<head>里对字体文件加preconnect,但不要给字体加preload——字体通常不是 LCP 元素,preload 会抢走首图的关键带宽。
三、首图是弱网里最贵的单个资源
弱网下「一张 200KB 的图」和「一百个 2KB 的请求」代价完全不同:前者是一个 TCP 连接上慢慢传,后者是上百个 RTT 的等待。所以优化顺序是「先减少请求数,再压单个体积」。
减少请求数:把列表页里的小图标全部合并成 CSS sprite 或者直接用内联 SVG。我们的侧边栏原来有 7 个 SVG 请求,合并成一个内联 SVG 之后,弱网首屏少了 7 个 RTT,约 1.2 秒。
压单个体积:首图用 srcset 提供三档宽度,移动端默认取最小档。同时用 loading="lazy" 处理首屏以下的图,但首屏第一张图绝对不能加 lazy——它会把 LCP 推到 2 秒以后。这个错误极其常见,因为大家看到「lazy 能提速」就全站加了。
<img src="/img/cover-480.webp"
srcset="/img/cover-480.webp 480w,
/img/cover-800.webp 800w,
/img/cover-1200.webp 1200w"
sizes="(max-width: 600px) 100vw, 600px"
width="600" height="320"
decoding="async"
alt="文章封面">width 和 height 一定要写,否则弱网下图还没到,浏览器不知道要留多大位置,内容会跳来跳去,CLS 直接爆掉。decoding="async" 让解码不阻塞主线程。
四、弱网特有的坑:连接建立本身就很贵
在 RTT 500ms 的网络里,一次 TLS 握手(TCP 三次握手 + TLS 1.3 的 1-RTT)= 大约 1 秒纯等待。你的页面如果有 3 个不同域名(主站、CDN、字体),就是 3 秒的握手开销,才刚开始传数据。
所以弱网优化的第一原则是收敛域名:
- 把静态资源统一到一个 CDN 域名,别按文件类型分
img.example.com/css.example.com/js.example.com。HTTP/2 之后,多域名并发的收益远小于多一次握手的成本。 - 所有关键请求都用
preconnect提前建连:<link rel="preconnect" href="https://cdn.example.com" crossorigin>。注意crossorigin属性不能漏——字体和跨域脚本的连接池是分开的,漏了等于白写。 - 服务器开 TLS 1.3 并开
ssl_session_cache shared:SSL:10m,让返回用户复用会话,省掉完整握手。
我们实测把三个静态域名合到一个之后,「限速档」下首屏完整时间从 14 秒降到 9.1 秒。这个改动的成本是改一遍 HTML 模板,收益却比换服务器大。
还有一个经常被忽略的细节:弱网下 DNS 解析本身可能就是主要延迟。移动网络切换基站时会重置 DNS 缓存,一次冷解析在 4G 下约 100~300ms,在信号差的隧道里可能超过 1 秒且需要重试。所以静态资源域名能少一个就少一个,能用同一个 CDN 域名覆盖就不要拆成多个——每多一个域名,就多一次独立解析和一次独立握手的风险。这也是我们把图片、CSS、JS 全部收拢到同一个域名下的直接原因,收益在弱网档位下比在宽带下明显得多。
五、HTML 本身的传输:先发关键部分
弱网下有个常被忽略的事实:浏览器要收到足够多的 HTML 才能开始渲染首屏。如果你的 <head> 里塞了两个阻塞的 CSS 和三个同步脚本,那浏览器得等这些全部下载完才开始画第一帧。
两个具体改动:
- CSS 内联首屏部分。把渲染首屏必需的那部分样式(大约 4~6KB)内联进
<head>的<style>,剩下的主样式表用media="print" onload="this.media='all'"异步加载。注意这个 hack 要配<noscript>兜底,否则禁用 JS 的用户会看到无样式页面。 - 服务器开
sendfile和tcp_nodelay,并适当调大output_buffers。Nginx 默认的缓冲策略在高延迟链路上会让首字节到达得比预期晚,因为它在攒够一个缓冲区才发。
# /etc/nginx/nginx.conf 里 http 段
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
output_buffers 4 64k;
# gzip 至少开 text/html、css、js、json、svg
gzip on;
gzip_comp_level 5;
gzip_min_length 512;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;gzip_comp_level 5 是个务实的选择:6 以上压缩率提升有限,但 CPU 时间线性增长,在小机器上反而会增加首字节时间。
六、超时和重试:弱网里失败是常态,别让它变成白屏
弱网下请求超时是必然发生的,问题在于超时之后用户看到什么。默认行为是「一直转圈,最后白屏」或者「整页 502」,这两种体验都会让人直接关掉页面。
三个层次的处理:
Nginx 层:把 proxy_connect_timeout 和 proxy_read_timeout 设成合理值(5s / 30s),并配一个静态的错误页 error_page 502 503 504 /maintenance.html。错误页本身要极小(我们的是 2KB 纯 HTML),否则弱网下连错误页都加载不出来。
前端层:所有 fetch 加 AbortController 超时,超时后显示「网络不太好,点击重试」而不是无限 loading。
async function getJSON(url, ms = 8000) {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), ms);
try {
const r = await fetch(url, { signal: ac.signal, cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return await r.json();
} finally {
clearTimeout(t);
}
}表单层:弱网下最痛的是「提交了但不知道成没成」。解决办法是提交按钮在请求发出后立即置灰并显示「提交中」,请求完成或超时后再恢复,同时把结果写进 localStorage,页面重开后能提示「上次的评论可能已提交」。这比任何加载动画都有用。
七、怎么验证改动真的有效
三件事,按成本从低到高:
- 固定档位复测。每次改完都用工信部那两个自定义档位(地铁/限速)在同一台机器上重测,记录 LCP、首屏请求数、总传输字节。不要只看一次的数字,看趋势。
- 真实用户数据。如果站点有统计,把
effectiveType(navigator.connection.effectiveType)作为自定义维度上报,就能知道有多少用户是3g/2g。我们站的数据是 3g 及以下占 18%,这个比例足够支撑「为弱网专门优化」的决策。 - 服务端访问日志。看
$request_time的 p95 和 p99 分布。如果 p99 明显高于 p95,说明有一批请求在卡,常见原因是某个外部接口(统计、字体、广告)超时。
最后说一句判断标准:弱网优化的目标不是「让 Lighthouse 分数变高」,而是「让用户在 200kbps 的网络里 5 秒内看到正文」。这个目标是可以量化、可以复测、也可以达成的。我们现在的数字是:地铁档下 LCP 3.9 秒、首屏完整 5.2 秒,改之前是 11.4 秒和 14 秒。改动清单就是上面这六条,没有一条需要动数据库或者换服务器。