微信公众号二维码
本文已同步发布到微信公众号「人言兑
👈 扫描二维码关注,第一时间获取更新!

你的 Hugo 博客是否因为广告位过多导致页面加载缓慢?当 12 个 AdSense 广告位同时初始化,首屏加载时间飙升至 5 秒以上, 广告拦截检测 弹窗迟迟无法出现,访客在弹窗出来前就已经浏览完内容离开。

本文将分享一套零侵入原有广告模板的懒加载方案:利用 HTML5 <code>&lt;template&gt;</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.2s1.8s-65%
可交互时间 (TTI)6.1s2.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 静态博客实现广告懒加载的核心思路:

  1. 服务端:用 <template> “冻结"广告 HTML,不执行脚本
  2. 前端:Intersection Observer 检测可见性
  3. 激活cloneNode + 重新创建 script 元素触发执行
  4. 优化:分批处理避免 adsbygoogle 冲突

这个方案的最大优势是对原有广告模板零侵入——12 个广告模板完全不用改,只需要在调用层包装一层容器。这对于维护大量广告位的博客来说,迁移成本极低。


本文代码已在我的博客 人言兑 上线运行,欢迎查看实际效果。如有问题,欢迎在评论区留言讨论。


也可以看看