本文旨在探讨和分享多种预加载技术及其在提升网站性能、优化用户体验方面的应用。在性能优化过程中,开发者通常会集中精力在以下几个方面:服务器响应时间(RT)优化、服务端渲染(SSR)与客户端渲染优化、以及静态资源体积的减少。然而,对于许多用户进入网站的第一个页面(如首页),网络开销也是一个不容忽视的问题。
由于新用户可能从未与网站建立连接,从DNS查询到TCP连接,再到下载服务器返回的内容,这些步骤的耗时通常远远超过服务器的响应时间。而多数情况下开发者无法通过代码优化来减少这部分时间消耗。
为了解决新用户访问网站时可能遇到的网络开销问题,我们可以借助多种预加载技术在用户实际需要之前提前加载资源,从而减少等待时间,实现更流畅的用户体验。接下来本文将详细探讨几种常见的预加载方法,并在 prefetch、preload等基础上,结合流式渲染、HTTP Early Hints、HTTP/2 push 等技术,对预加载技术灵活运用,从而在用户到达网站的瞬间就提供无缝、快速的访问体验。
在开始介绍预加载之前,其实开发者可以通过 CDN 动态加速优化用户与服务器的建连、内容传输时间。CDN 通常被用来加速静态资源的传输,比如图像、JavaScript 和 CSS,这个大部分开发者非常熟悉,但现代的 CDN 技术已经不仅仅局限于静态内容的优化,大部分 CDN 厂商可以利用其全球广泛分布的边缘节点服务器为网站提供动态内容的访问加速。
用户访问网站动态内容需要通过互联网连接到源站服务器,这个过程中数据需要经过多个网络节点和长距离传输,容易受到各种网络拥塞和延迟的影响。
使用 CDN 动态加速时,CDN 通过在全球分布的边缘节点缓存和处理用户请求,显著缩短了从用户到服务器的物理距离,减少了传输延迟。同时 CDN 服务商会实时监控全球的网络状态,通过智能路由技术选择当前最优的路径传输数据,这避免了网络中的拥塞和瓶颈,确保数据以最快的速度传输到用户端。
当然如果使用了 CDN 提供的边缘计算能力,可以让用户直接从 CDN 边缘节点获取动态内容,进一步加速动态内容的访问。
当浏览器需要访问特定域名时,必须先将先将域名解析为 IP 地址,这一步骤就是 DNS 解析。dns -prefetch 可以让浏览器提前在后台完成这一解析工作,避免用户在实际请求资源时等待 DNS 解析的时间。
在 HTML 顶部通过标签来指示浏览器对接下来要是用的静态资源、动态接口等域名提前进行 DNS 解析。
"dns-prefetch" href="//example.com">
当浏览器解析了域名后,接下来需要通过TCP协议和服务器建立连接,并在使用 HTTPS 的情况下进行 TLS 握手,这些步骤通常需要较多往返时间(RTT)。preconnect 通过提前完成这些连接步骤,可以减少用户真正需要请求资源时的等待时间。
可以在HTML中通过标签来指示浏览器进行预连接,使用 preconnect 之后浏览器不仅会解析域名的 DNS,还会提前与服务器建立 TCP 连接,并完成 TLS 握手。
"preconnect" href="//example.com">
除了对域名进行解析、建连,还可以通过 preload 和 prefetch 对页面将要使用的资源提前下载。
preload 是一种声明式资源引入方式,用来强制浏览器在合适的时机加载指定资源,通常用于关键资源(如字体、脚本、样式表等)的预加载,以确保这些资源能够尽快被使用。
"preload" href="styles.css" as="style">
"preload" href="main.js" as="script">
"preload" href="image.jpg" as="image">
prefetch 同样是一种声明式资源请求方式,用于提示浏览器在空闲时下载未来可能用到的资源,适合作为页面未来使用的资源或者当前页面下一跳页面要使用的资源预加载。
"prefetch" href="next-page-image.jpg">
"prefetch" href="next-page-script.js">
两个标签在优先级上有一定的区别:
两者在浏览器支持上各有千秋:
preload | prefetch |
| |
使用prerender 可以将目标页面上近乎所有资源(HTML、CSS、JavaScript、图像等)和内容在后台提前下载并渲染,浏览器在用户首次访问该页面之前已经完全准备好了该页面的视图。这样当用户跳转到该页面时,使用户在实际跳转到这个页面时能够立即呈现,不需要再等待加载和渲染的时间。
"prerender" href="https://example.com/next-page">
听起来 prerender 是预加载的终极方案了,但在实际性能优化方案中却很少被使用,使用 preload 有几个弊端:
无脑对页面进行 prefetch 会造成巨大的资源浪费,但很多时候我们可以根据用户行为更精准的预测用户接下来的动作,再进行 prefetch 可以很大程度上减少资源浪费。
举个例子,在 PC 页面当用户鼠标悬停在某个商品图片上时候,我们可以大胆预测用户及大概率要点击页面,这时候可以对页面进行 prefetch。如果希望进一步细化,用户点击鼠标的动作会依次触发 mousedown、mouseup、click事件,我们可以在 mousedown 事件中对页面进行预载,这样可以节省人点击鼠标的 200ms 左右。
function App() {
return (
"App">
Product List
"product-list">
"1" name="Product 1" imageUrl="https://via.placeholder.com/150" prefetchUrl="/next-page-1" />
"2" name="Product 2" imageUrl="https://via.placeholder.com/150" prefetchUrl="/next-page-2" />
"3" name="Product 3" imageUrl="https://via.placeholder.com/150" prefetchUrl="/next-page-3" />
);
}
const Product = ({ id, name, imageUrl, prefetchUrl, delay=200 }) => {
const [prefetchTimeout, setPrefetchTimeout] = useState(null);
const handleMouseOver = () => {
const timeout = setTimeout(() => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = prefetchUrl;
link.credentials = 'include';
document.head.appendChild(link);
}, delay);
setPrefetchTimeout(timeout);
};
const handleMouseOut = () => {
clearTimeout(prefetchTimeout);
};
return (
"product"
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}>
"lazy" />
{name}
);
}
添加 credentials 属性,携带 cookie
安全原因 prefetch 请求默认不携带 cookie,为了让 prefetch 请求携带 cookie, 可以在 prefetch 的 link 标签中添加 credentials 属性,并将其设置为 "include"。
"prefetch" href="..." as="script" credentials="include">
因为大部分动态页面为了给用户传输动态内容是禁用客户端缓存的,所以即使发了 prefetch 请求也无法做到用户真实点击的时候复用 prefetch 请求,反而会重新发请求造成资源浪费。
因此需要在服务端识别 prefetch 请求,设置短时间的客户端缓存,当用户很快真实访问 prefetch 的页面后可以复用缓存。
浏览器发送的 prefetch 请求会携带 HTTP Header Sec-Purpose: prefetch或Purpose: prefetch,服务端根据这个属性识别 prefetch 请求。
app.get('/next-page', (req, res) => {
const purposeHeader = req.headers['purpose'] || req.headers['sec-purpose'];
if (purposeHeader === 'prefetch') {
res.set('Cache-Control', 'max-age=10');
console.log('Prefetch request detected, setting cache.');
} else {
console.log('Regular request detected, no cache.');
}
res.send(`
Next Page Content
This is the next page that was prefetched.
`
);
});
上述方案在 SSR 页面效果显著,但在 CSR 页面可能优化效果有限,主要原因是 CSR 页面内容存储在 CDN 甚至客户端本地缓存,本身加载很快,页面的渲染主要依赖动态接口的返回。
如果我们可以知道页面首屏渲染需要发起的请求,其实可以利用和上面类似的原理,在用户点击页面的瞬间同时发起异步请求,当解析执行 JavaScript 脚本发送异步请求时可以判断本地已经有缓存,直接使用结果。
原理非常类似,不再代码演示,核心还是请求:
这样的方案最大程度利用了浏览器的特性实现起来比较简单,对 Service Worker 熟悉的话可以利用 Service 做更复杂的控制。
Speculation Rules API 是一个新的 Web API,提供一种声明式的方法来指示浏览器应该对哪些链接进行预取操作,通过这个 API,开发者可以更精确地指示浏览器在何时和如何预取资源,从而显著提升网页性能和用户体验。
"en">
"UTF-8">
Speculation Rules API
"/page1.html" data-prefetch-url="/page1.html">Go to Page 1
"/page2.html" data-prefetch-url="/page2.html">Go to Page 2
"/page3.html" data-prefetch-url="/page3.html">Go to Page 3
function addPrefetchRule(url) {
const speculationRulesScript = document.querySelector('script[type="speculationrules"]');
const rules = JSON.parse(speculationRulesScript.textContent);
if (!rules.prefetch.some(rule => rule.urls.includes(url))) {
rules.prefetch.push({
"source": "list",
"urls": [url]
});
speculationRulesScript.textContent = JSON.stringify(rules);
console.log(`Prefetch rule added for: ${url}`);
}
}
document.querySelectorAll('a[data-prefetch-url]').forEach(link => {
link.addEventListener('mouseover', () => {
const url = link.getAttribute('data-prefetch-url');
addPrefetchRule(url);
});
});
Speculation Rules API 目前还处于早期阶段,未来可能会看到更多的浏览器开始支持这一 API,并且 API 本身也可能会引入更多的功能和配置选项。
流式渲染(Streaming Rendering)是指在服务器上生成页面内容时,逐步将已准备好的部分内容立刻发送到客户端,而不是等待页面所有内容全部生成才开始发送,使客户端可以更快的接收数据渲染页面,而不必等待整个页面的内容完全下载,从而实现快速的页面加载和用户可视化体验。这个过程像是水管中的水一样流动起来源源不断,因此被称为流式渲染。
流式渲染实际上一个非常古老的技术,早在 HTTP 1.1 规范中就已经引入了 Transfer-Encoding: chunked 头字段,允许服务器将响应内容分批返回给客户端。服务器可以在生成响应内容的同时,将其分成小块,逐步传输给客户端,而不是等待所有内容生成完成后再返回。
在浏览器端,早期的浏览器(如 Netscape Navigator 和 IE)就已经支持对部分 HTML 内容进行解析和执行。当浏览器接收到服务器返回的部分 HTML 内容时,它可以立即开始解析和执行该内容,而不需要等待所有内容加载完成。
const http = require('http');
http
.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/html',
'Transfer-Encoding': 'chunked',
});
function renderChunk(chunk) {
res.write(`${chunk}
`);
}
renderChunk('Loading...');
setTimeout(() => {
renderChunk('Chunk 1');
}, 1000);
setTimeout(() => {
renderChunk('Chunk 2');
}, 2000);
setTimeout(() => {
renderChunk('Chunk 3');
}, 3000);
setTimeout(() => {
renderChunk('done!');
res.write('