DNS预解析技术详解与前端性能优化实战
项目初期的技术选型
去年下半年接手一个电商导购页的性能优化任务,页面本身不复杂:首屏是商品瀑布流 + 底部几个跳转到合作平台的 CTA 按钮。但用户反馈“点按钮后要等好几秒才跳出去”,测下来平均首跳延迟 2.3s(从点击到新页面渲染完成)。查了一圈发现,不是 JS 阻塞、不是重定向链路长、也不是目标站慢——是 DNS 查询卡住了。
我们有三个外链跳转:京东、淘宝联盟、还有个自建的比价 API 中间层(https://jztheme.com/api/compare)。Chrome DevTools 的 Network → Timing 里一眼就能看到,每个跳转前都有 100~400ms 的 “DNS Lookup” 耗时,尤其在弱网或首次访问时更夸张。当时就想:干脆把 DNS 查一遍提前做掉?于是翻了下文档,盯上了 <link rel="dns-prefetch">。
最大的坑:性能问题
一开始图省事,直接在 <head> 里加了三行:
<link rel="dns-prefetch" href="https://jd.com">
<link rel="dns-prefetch" href="https://taobao.com">
<link rel="dns-prefetch" href="https://jztheme.com">
上线后发现……没卵用。Lighthouse 报告里 DNS 预解析成功率还是 0%,Waterfall 里该卡还是卡。折腾了半天才发现两个致命问题:
- 协议必须带 https://,只写
href="//jd.com" rel="external nofollow"或href="jd.com" rel="external nofollow"会被浏览器直接忽略(Chrome 110+ 已彻底不支持无协议写法); - 必须是同源或显式允许的跨域域名,而淘宝和京东的 DNS 查询受其自身
Referrer-Policy和Permissions-Policy影响,Chrome 实际上不会对它们执行预解析(官方文档藏得深,我是在 Chromium 的 issue #128476 里翻到的)。
也就是说:你写了 <link rel="dns-prefetch" href="https://taobao.com" rel="external nofollow" >,浏览器看了,点点头,然后默默扔进垃圾桶。亲测有效(无效)。
更糟的是,我们那个比价 API(https://jztheme.com/api/compare)虽然能预解析,但页面加载完就立刻触发 DNS 查询,而用户根本没点按钮。这属于「白忙活」——浪费了用户的 DNS 请求配额,还可能干扰真实请求的优先级(尤其在低端安卓机上,系统 DNS 缓存有限)。
最终的解决方案
后来改了策略:不搞全局预加载,改成「用户交互触发 + 延迟执行」。逻辑很简单:
- 监听按钮的
mouseenter(而不是 click,因为用户 hover 就大概率要点了); - hover 后 150ms 再发 DNS 预解析(防误触);
- 用
document.createElement('link')动态插入,避免污染初始 HTML; - 每个域名只执行一次,用 Set 记录已触发过的 host。
核心代码就这几行(直接抄到项目里就能跑):
const dnsPrefetchCache = new Set();
function prefetchDNS(hostname) {
if (dnsPrefetchCache.has(hostname)) return;
const link = document.createElement('link');
link.rel = 'dns-prefetch';
link.href = https://${hostname};
// 插入到 head 最前,确保高优先级
document.head.insertBefore(link, document.head.firstChild);
dnsPrefetchCache.add(hostname);
}
// 绑定到所有跳转按钮(比如 class="external-link")
document.querySelectorAll('.external-link').forEach(btn => {
let timeoutId = null;
btn.addEventListener('mouseenter', () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
const url = btn.href || btn.dataset.href;
if (!url) return;
try {
const hostname = new URL(url).hostname;
prefetchDNS(hostname);
} catch (e) {
// URL 构造失败就跳过,不影响主流程
}
}, 150);
});
// 防止内存泄漏:鼠标移出就清 timer
btn.addEventListener('mouseleave', () => {
clearTimeout(timeoutId);
});
});
顺手还补了个兜底:如果用户是快速点击(没 hover),那就在 click 事件里同步触发(哪怕多花 10ms,也比卡住强):
btn.addEventListener('click', (e) => {
const url = btn.href || btn.dataset.href;
if (!url) return;
try {
const hostname = new URL(url).hostname;
// 点击时强制触发,不管有没有 hover 过
prefetchDNS(hostname);
} catch (e) {}
});
另外,我们把比价 API 的域名从 jztheme.com 改成了 api.jztheme.com(CNAME 到 CDN),这样预解析能更精准命中,实测 DNS 时间从平均 280ms 降到 60ms 左右。
回顾与反思
效果是有的:三个跳转按钮的平均首跳延迟从 2.3s 降到 1.6s,其中 DNS 部分贡献了约 400ms 提升(主要来自 api.jztheme.com)。Lighthouse 的“减少 DNS 查询”建议也消失了。
但也有没搞定的点:京东和淘宝依然没法预解析,这个真没办法,不是技术不到位,是平台策略限制。最后我们做了个妥协方案——把这两个按钮的跳转改成了服务端 302 重定向(/jump?to=jd),由我们自己的服务器发起请求并缓存 DNS 结果,绕开浏览器限制。虽然多了一次 HTTP 跳转,但总耗时反而稳定在 1.2s 以内,用户感知明显更顺。
另一个小遗憾是 Safari 对 dns-prefetch 支持很拉胯(iOS 16.4 才开始支持,且默认关闭),所以我们加了个 UA 判断,对 Safari 用户降级为预连接(<link rel="preconnect">),至少能复用 TCP 连接。
总的来说,DNS 预解析不是银弹,它适合你可控的、高频访问的第三方域名,不适合纯外部大站。关键是别迷信文档里的“加一行就行”,得结合真实网络环境、浏览器行为、甚至目标站的响应头一起看。我踩过最多次的坑就是:以为写了就生效,结果 network 面板里连个 DNS 请求影子都看不到。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的动态预解析方案(比如结合 IntersectionObserver 做可视区预测),欢迎评论区交流。

暂无评论