
你的 Hugo 博客是否因为广告位过多导致页面加载缓慢?当 12 个 AdSense 广告位同时初始化,首屏加载时间飙升至 5 秒以上, 广告拦截检测 弹窗迟迟无法出现,访客在弹窗出来前就已经浏览完内容离开。
本文将分享一套零侵入原有广告模板的懒加载方案:利用 HTML5 <code><template></code> 标签 冻结广告代码,通过 Intersection Observer API 检测可视区域,分批激活执行脚本。实测首屏请求减少 68%,广告收入基本不受影响。
一、问题背景:为什么需要懒加载?
1.1 广告过多拖慢页面
我的 Hugo 博客侧边栏有 12 个广告位,每个广告位包含:
- Google AdSense 自动广告 (
adsbygoogle.js) - 第三方网盟脚本(如 highperformanceformat、slotbydup 等)
- 推广卡片(时间区间内显示)
页面加载时,12 个广告位同时初始化,产生大量并行请求:
adsbygoogle.push({}) × 12
highperformanceformat.com/invoke.js × N
slotbydup.push({}) × N
结果:首屏加载时间 > 5 秒,广告拦截检测弹窗迟迟不出现,访客在弹窗出来前已经看完文章走了。
1.2 核心矛盾
| 需求 | 现实 |
|---|---|
| 广告位多 = 收入多 | 广告位多 = 加载慢 |
| 广告拦截检测要尽快弹窗 | 广告脚本阻塞了检测 |
| 第三方脚本必须加载 | 第三方脚本不可控 |
1.3 解决思路
懒加载(Lazy Loading):非首屏广告位不立即加载,等用户滚动到可视区域再激活。
二、技术方案设计
2.1 方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| iframe 延迟加载 | 广告放 iframe,src 留空 | 隔离性好 | AdSense 不支持 iframe,收入下降 |
| data-src 替换 | 脚本 src 改 data-src,滚动后替换 | 简单 | 内联脚本无法处理,AdSense 初始化复杂 |
<template> 克隆 ⭐ | 服务端渲染到 template 标签,前端克隆激活 | 完美支持所有广告类型,兼容性好 | 需要处理脚本重新执行 |
| Intersection Observer + 分批 | 检测可见性,分批激活 | 性能最优 | 实现稍复杂 |
最终选择 方案三 + 方案四 结合。
三、核心原理:为什么 <template> 能解决问题?
3.1 <template> 的特殊性
<template id="ad-template">
<div class="ad-content">
<ins class="adsbygoogle"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
</div>
</template>
- 内容不渲染:
<template>内的 DOM 不参与页面布局,不阻塞渲染 - 脚本不执行:
<script>标签在<template>内不会执行 - 可克隆使用:通过
template.content.cloneNode(true)取出内容,插入 DOM 后才激活
3.2 执行流程
服务端渲染
│
▼
┌─────────────────┐
│ <template> │ ← 广告 HTML 存储于此,不执行
│ <script>...</script>
│ <ins class="adsbygoogle">
└─────────────────┘
│
▼
Intersection Observer 检测到可视
│
▼
cloneNode(true) 克隆到 placeholder
│
▼
重新创建 <script> 元素触发执行
│
▼
adsbygoogle.push({}) 初始化广告
四、Hugo 模板层实现
4.1 目录结构
layouts/
├── partials/
│ ├── lazy-ad.html # 懒加载容器模板(新建)
│ ├── payads/
│ │ ├── ad_5_1.html # 首屏广告(直接加载)
│ │ ├── ad_5_2.html # 懒加载广告(不改)
│ │ ├── ad_5_3.html # 懒加载广告(不改)
│ │ └── ... # ad_5_5 ~ ad_5_12
│ └── sidebar.html # 侧边栏(修改调用方式)
└── _default/
└── baseof.html # 基础模板
static/
└── js/
└── lazy-ads.js # 懒加载逻辑(新建)
4.2 懒加载容器模板:lazy-ad.html
{{/*
懒加载广告位容器
用法: {{ partial "lazy-ad.html" (dict "slot" "5_2" "context" .) }}
核心逻辑:
1. 推广卡片期(时间区间内):直接显示,无需懒加载
2. 广告代码期:渲染到 <template>,前端检测可见后激活
*/}}
{{ $slot := .slot }}
{{ $context := .context }}
{{ $partialName := printf "payads/ad_%s.html" $slot }}
{{ $now := now }}
{{ $start := time "2025-07-01T00:00:00+08:00" }}
{{ $end := time "2025-12-31T23:59:59+08:00" }}
{{ $showPromo := and (ge $now $start) (le $now $end) }}
{{/* 推广卡片期:直接显示,不懒加载 */}}
{{ if $showPromo }}
<div class="sidebar-module">
<h4>广告赞助</h4>
{{ partial $partialName $context }}
</div>
{{/* 广告代码期:懒加载 */}}
{{ else }}
<div class="sidebar-module lazy-ad-container" data-ad-slot="{{ $slot }}">
<h4>广告赞助</h4>
{{/* 占位符:首屏可见,提示加载中 */}}
<div class="lazy-ad-placeholder">
<i class="fas fa-ad"></i>
广告加载中...
</div>
{{/* 广告内容存储在 template 中,不执行 */}}
<template class="lazy-ad-template">
{{ partial $partialName $context }}
</template>
</div>
{{ end }}
{{ end }}
关键设计决策:
| 决策 | 原因 |
|---|---|
| 时间判断提到容器层 | 避免 12 个广告模板重复逻辑 |
<template> 存储 | 脚本不执行,不阻塞首屏 |
| 占位符显示"加载中" | 避免布局抖动(Layout Shift) |
data-ad-slot 标识 | 前端 JS 识别哪个广告位 |
4.3 侧边栏调用:sidebar.html
只保留 1 个首屏广告直接加载,其他全部懒加载:
<!-- ═══════════════════════════════════════════════ -->
<!-- 首屏直接加载:ad_5_1(第一个广告位,用户最先看到) -->
<!-- ═══════════════════════════════════════════════ -->
<div class="sidebar-module">
<h4>广告赞助</h4>
{{ partial "payads/ad_5_1.html" . }}
</div>
<!-- ... 其他内容模块(分类、标签等)... -->
<!-- ═══════════════════════════════════════════════ -->
<!-- 非首屏广告位:全部懒加载 -->
<!-- ═══════════════════════════════════════════════ -->
{{ partial "lazy-ad.html" (dict "slot" "5_2" "context" .) }}
<!-- ... 其他内容模块(最新文章)... -->
{{ partial "lazy-ad.html" (dict "slot" "5_3" "context" .) }}
<!-- ... 以此类推 ... -->
{{ partial "lazy-ad.html" (dict "slot" "5_12" "context" .) }}
为什么不全部懒加载?
ad_5_1在首屏,用户一进来就能看到,需要立即加载- 其余广告位在首屏下方,滚动后才可见,适合懒加载
五、前端 JavaScript 实现
5.1 核心挑战:<template> 内的脚本如何执行?
直接从 <template> 克隆的 <script> 元素不会执行。需要手动重新创建:
// 错误:克隆的 script 不执行
var clone = template.content.cloneNode(true);
placeholder.appendChild(clone); // 脚本不执行!
// 正确:重新创建 script 元素
var oldScript = clone.querySelector("script");
var newScript = document.createElement("script");
newScript.textContent = oldScript.textContent;
oldScript.parentNode.replaceChild(newScript, oldScript); // 执行!
5.2 完整实现:lazy-ads.js
/**
* Hugo 侧边栏广告位懒加载
*
* 功能:
* 1. Intersection Observer 检测广告位可见性
* 2. 从 <template> 克隆内容到 DOM
* 3. 重新创建 <script> 元素触发执行
* 4. 分批初始化 adsbygoogle,避免冲突
*
* @version 1.0.0
*/
(function () {
"use strict";
// ═══════════════════════════════════════════════
// 配置
// ═══════════════════════════════════════════════
var CONFIG = {
// 提前预加载距离(px):距离视口 400px 时开始加载
rootMargin: "400px",
// 每批最多激活的广告位数量
// AdSense 建议同时 push 不超过 3 个,设为 2 更保守
batchSize: 2,
// 批次间隔(ms):避免 adsbygoogle 初始化冲突
batchInterval: 500,
// adsbygoogle push 延迟(ms):等 DOM 稳定后再 push
adsbygoogleDelay: 100,
};
// ═══════════════════════════════════════════════
// 状态
// ═══════════════════════════════════════════════
var pending = []; // 待处理队列
var isProcessing = false; // 是否正在处理批次
var activatedSlots = {}; // 已激活的广告位(防重复)
// ═══════════════════════════════════════════════
// 核心函数:执行容器内的所有脚本
// ═══════════════════════════════════════════════
/**
* 重新创建 <script> 元素以触发执行
*
* 原理:浏览器只对通过 document.createElement 创建的 script
* 执行解析。cloneNode 或 innerHTML 插入的 script 不执行。
*/
function executeScripts(container) {
var scripts = container.querySelectorAll("script");
for (var i = 0; i < scripts.length; i++) {
var oldScript = scripts[i];
var newScript = document.createElement("script");
// 复制所有属性(src, type, async 等)
for (var j = 0; j < oldScript.attributes.length; j++) {
var attr = oldScript.attributes[j];
newScript.setAttribute(attr.name, attr.value);
}
// 复制脚本内容(内联脚本)
newScript.textContent = oldScript.textContent;
// 替换旧元素 → 触发浏览器执行
oldScript.parentNode.replaceChild(newScript, oldScript);
}
}
// ═══════════════════════════════════════════════
// 核心函数:激活单个广告位
// ═══════════════════════════════════════════════
function activateAd(container) {
var slot = container.getAttribute("data-ad-slot");
// 防重复激活
if (!slot || activatedSlots[slot]) return;
activatedSlots[slot] = true;
var template = container.querySelector("template.lazy-ad-template");
var placeholder = container.querySelector(".lazy-ad-placeholder");
if (!template || !placeholder) {
console.warn("[LazyAds] Missing template or placeholder for slot:", slot);
return;
}
// 1. 从 <template> 克隆内容(DocumentFragment)
var content = template.content.cloneNode(true);
// 2. 清空占位符,插入真实广告内容
placeholder.innerHTML = "";
placeholder.appendChild(content);
// 3. 移除占位符样式,恢复普通容器样式
placeholder.classList.remove("lazy-ad-placeholder");
placeholder.style.cssText = "";
// 4. 执行所有脚本(关键步骤!)
executeScripts(placeholder);
// 5. 延迟初始化 adsbygoogle
// 原因:等 DOM 插入完成、脚本执行完毕后,再 push
setTimeout(function () {
try {
var ins = placeholder.querySelector("ins.adsbygoogle");
if (
ins &&
window.adsbygoogle &&
ins.getAttribute("data-adsbygoogle-status") !== "done"
) {
(window.adsbygoogle = window.adsbygoogle || []).push({});
}
} catch (e) {
console.warn("[LazyAds] adsbygoogle push failed:", e);
}
}, CONFIG.adsbygoogleDelay);
console.log("[LazyAds] Activated slot:", slot);
}
// ═══════════════════════════════════════════════
// 分批处理:避免同时初始化多个 adsbygoogle
// ═══════════════════════════════════════════════
/**
* 为什么需要分批?
*
* Google AdSense 的 adsbygoogle.push({}) 是全局队列。
* 如果同时 push 多个,可能导致:
* 1. 广告填充率下降(竞争同一个请求槽)
* 2. 控制台报错(某些情况下)
* 3. 页面卡顿(大量 iframe 同时创建)
*/
function processBatch() {
if (isProcessing || pending.length === 0) return;
isProcessing = true;
// 取出本批次
var batch = pending.splice(0, CONFIG.batchSize);
var done = 0;
batch.forEach(function (container) {
// 使用 requestIdleCallback 或 setTimeout 让出主线程
// 避免阻塞用户滚动
var schedule =
window.requestIdleCallback ||
function (cb) {
setTimeout(cb, 1);
};
schedule(function () {
activateAd(container);
done++;
// 本批次完成,检查是否还有下一批
if (done >= batch.length) {
isProcessing = false;
if (pending.length > 0) {
setTimeout(processBatch, CONFIG.batchInterval);
}
}
});
});
}
// ═══════════════════════════════════════════════
// Intersection Observer:检测可见性
// ═══════════════════════════════════════════════
function onIntersection(entries, observer) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
// 进入视口,加入待处理队列
observer.unobserve(entry.target);
pending.push(entry.target);
processBatch();
}
});
}
// ═══════════════════════════════════════════════
// 初始化
// ═══════════════════════════════════════════════
function init() {
var containers = document.querySelectorAll(".lazy-ad-container");
if (containers.length === 0) return;
console.log("[LazyAds] Found", containers.length, "lazy ad containers");
// 降级处理:不支持 IntersectionObserver 的浏览器直接加载
if (!window.IntersectionObserver) {
for (var i = 0; i < containers.length; i++) {
activateAd(containers[i]);
}
return;
}
var observer = new IntersectionObserver(onIntersection, {
root: null, // 视口为根
rootMargin: CONFIG.rootMargin, // 提前预加载
threshold: 0, // 任意像素可见即触发
});
for (var i = 0; i < containers.length; i++) {
observer.observe(containers[i]);
}
}
// DOM 就绪后初始化
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
5.3 CSS 样式
/* 懒加载占位符样式 */
.lazy-ad-placeholder {
background: #f8f9fa;
border: 1px dashed #dee2e6;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
color: #adb5bd;
font-size: 13px;
min-height: 250px; /* 预留空间,避免布局抖动 */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.lazy-ad-placeholder .fas {
font-size: 24px;
margin-bottom: 8px;
opacity: 0.5;
}
/* 已激活的广告位 */
.lazy-ad-container:not(:has(.lazy-ad-placeholder)) {
/* 广告加载完成后的样式 */
}
六、广告位模板:为什么不需要改动?
6.1 原有模板示例(ad_5_2.html)
{{/* 侧边栏广告位-2 */}}
{{/* 获取当前时间 */}}
{{ $now := now }}
{{/* 设置显示推广卡片的时间区间 */}}
{{ $start := time "2025-07-01T00:00:00+08:00" }}
{{ $end := time "2025-12-31T23:59:59+08:00" }}
{{ if and (ge $now $start) (le $now $end) }}
<!-- 推广卡片 -->
{{ end }}
6.2 不需要改动的理由
| 原模板特性 | 懒加载方案如何处理 |
|---|---|
| 时间判断逻辑 | 提到 lazy-ad.html 容器层统一处理 |
内联 <script> | executeScripts() 重新创建执行 |
外部 <script src> | replaceChild 后浏览器自动加载 |
adsbygoogle.push | 延迟到 DOM 插入后执行 |
| 多种第三方脚本混用 | 统一通过 executeScripts 处理 |
核心:<template> 只是"冻结"了 HTML 字符串,不执行任何脚本。等克隆到 DOM 后再用标准方式重新创建 script,浏览器会正常处理所有类型。
七、效果验证
7.1 浏览器 DevTools 验证
Elements 面板:
<div class="lazy-ad-container" data-ad-slot="5_2">
<h4>广告赞助</h4>
<div class="lazy-ad-placeholder">广告加载中...</div> ← 初始可见
<template class="lazy-ad-template"> ← 内容隐藏
<div class="auto-refresh-gad">...</div>
<script>...</script>
</template>
</div>
滚动后:
<div class="lazy-ad-container" data-ad-slot="5_2">
<h4>广告赞助</h4>
<div> ← placeholder 样式移除
<div class="auto-refresh-gad">...</div> ← 广告内容
<script>...</script> ← 已执行
</div>
</div>
Network 面板:
- 初始:只加载
ad_5_1相关的请求 - 滚动后:逐个出现其他广告位的请求
Console:
[LazyAds] Found 10 lazy ad containers
[LazyAds] Activated slot: 5_2
[LazyAds] Activated slot: 5_3
...
7.2 性能对比
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 首屏请求数 | ~25 个 | ~8 个 | -68% |
| 首屏加载时间 | 5.2s | 1.8s | -65% |
| 可交互时间 (TTI) | 6.1s | 2.3s | -62% |
| 广告拦截检测弹窗 | 3-5s | < 1s | -80% |
| 总广告收入 | 基准 | 基准 ±5% | 基本不变 |
八、进阶优化
8.1 与广告拦截检测联动
在 adblock-shield.js 中检测懒加载广告位是否被拦截:
// 检测懒加载广告位是否被提前移除
function checkLazyAdsBlocked() {
var containers = document.querySelectorAll(".lazy-ad-container");
var blocked = 0;
containers.forEach(function (c) {
// 如果容器被隐藏或移除,说明拦截器在工作
var style = window.getComputedStyle(c);
if (style.display === "none" || !document.body.contains(c)) {
blocked++;
}
});
return blocked > 0;
}
8.2 根据用户行为预加载
// 用户开始滚动时,提前加载下一屏广告
var scrollTimeout;
window.addEventListener(
"scroll",
function () {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(function () {
// 预加载视口下方 2 屏的广告
preloadAds(2);
}, 150);
},
{ passive: true },
);
8.3 广告位合并减少请求
如果多个广告位使用相同的第三方脚本,可以合并加载:
<!-- 只在第一个需要 slotbydup 的广告位加载脚本 -->
<script>
// 标记已加载
window._slotbydupLoaded = true;
</script>
总结
Hugo 静态博客实现广告懒加载的核心思路:
- 服务端:用
<template>“冻结"广告 HTML,不执行脚本 - 前端:Intersection Observer 检测可见性
- 激活:
cloneNode+ 重新创建script元素触发执行 - 优化:分批处理避免 adsbygoogle 冲突
这个方案的最大优势是对原有广告模板零侵入——12 个广告模板完全不用改,只需要在调用层包装一层容器。这对于维护大量广告位的博客来说,迁移成本极低。
本文代码已在我的博客 人言兑 上线运行,欢迎查看实际效果。如有问题,欢迎在评论区留言讨论。








