静态站手动广告位空白:Astro / Next 导出修复

Astro、Next.js 导出、SvelteKit、Hugo 上 AdSense `<ins>` 块一直空白。诊断 push() 时机、重复 push、SSG 没跑 JS 三类坑并修好广告位。

你放好了 AdSense 的 <ins class="adsbygoogle"> 块、也加载了 adsbygoogle.js,但广告位一直空白。查看源代码,标签都在。控制台里 window.adsbygoogle 还是个空数组([]),或者 window.adsbygoogle.loaded 返回 false,而且 <ins> 上始终没出现 data-ad-status。这就是静态站 / SPA 的 AdSense 经典坑。

最快的修法: (adsbygoogle = window.adsbygoogle || []).push({}) 这个调用必须在对应的 <ins> 提交进 DOM 之后、每个 slot 精确执行一次。在框架代码里,这意味着要在组件挂载的 effect 里 push(并加上防重复运行的保护),而不是放在一个可能比 slot 早跑、也可能比 slot 晚跑的全局脚本里。直接看最短修复路径拿组件代码。

先把一个关键区别讲清楚,它能把整个问题一分为二:

  • <ins> 完全没有 data-ad-status 属性 —— AdSense 从没把任何 push() 匹配到你的 slot。这是代码 / 时机 bug,也是本文要修的。
  • <ins> 上是 data-ad-status="unfilled" —— AdSense 确实处理了这个 slot,只是没有广告可投。这属于账号 / 库存 / 政策问题,不是代码 bug。见审核通过后广告不展示

你属于哪一类?

看一眼控制台和 <ins> 元素,对号入座:

你看到的现象最可能的原因跳转
加载完 window.adsbygoogle 仍是 [],没有 data-ad-status客户端没跑 push(SSG / 缺 client:*原因 3
<ins> 在,没有 data-ad-status,push 跑早了push 在 <ins> 挂载前就触发原因 1
控制台 TagError: ...already have ads in them重复 push(StrictMode 或路由切换)原因 2
<ins> 加载后 1-2 秒才出现,仍空白client:only 时机竞态原因 4
生产域名正常,localhost / *.vercel.app 空白未批准域名,不投广告原因 5
Refused to load script... Content Security PolicyCSP 拦了 googlesyndication.com原因 6
data-ad-status="unfilled"没库存 / 账号问题,不是代码不属本文

常见原因

按命中率从高到低。

1. push 在 <ins> 挂载前就触发(竞态)

像这样的裸 effect:

useEffect(() => {
  (window.adsbygoogle = window.adsbygoogle || []).push({});
}, []);

如果它跑在一个比 <ins> 提交进 DOM 还早的 layout 或父组件里(或者在 hydration 之前),AdSense 把 push 排进队列却找不到对应的 <ins> 去绑定。这次 push 被静默丢弃。

怎么判断: 控制台里运行 document.querySelector('ins.adsbygoogle')。如果返回了元素、但元素上没有 data-ad-status 属性,说明 push 跑太早了(或者从没匹配上)。

2. 同一 slot 触发多次 push

React 18+ 在开发模式下 StrictMode 会把 effect 跑两遍;SPA 切路由也会在前一个 <ins> 还在的情况下重跑你的 effect。AdSense 看到一个已处理过的 slot 又被 push 一次,就抛错。

怎么判断: 控制台先是 Failed to load resource,紧接着 TagError: adsbygoogle.push() error: All ins elements in the DOM with class=adsbygoogle already have ads in them.——这个原文字符串就是 double-push。AdSense 处理过一个 slot 后会给它标上 data-adsbygoogle-status="done",对同一元素再 push 一次正是触发该错误的原因。

3. SSG 只渲染 HTML——没有客户端 push

Astro 或 Next 静态导出,广告组件以纯静态 HTML 出现、没带客户端指令(Astro 的 client:load,或 Next App Router 里的 Client Component)。HTML 里有 <ins>,但从没有 JS 去跑 push({})

怎么判断: 页面完全加载后 window.adsbygoogle 仍是 [](空数组),push 从未执行。Astro 这边是 island 没加 client:* 指令;Next 这边是文件少了 "use client"

4. 组件用 client:only 但时机不对

Astro 的 client:only 把渲染推迟到 JS 跑起来后,所以 <ins> 出现得很晚。如果全局 adsbygoogle.js 已经执行、你的 push 在一个还没有 slot 的空页面上跑了,它就找到了 0 个 slot。

怎么判断: Elements 时间线里 <ins> 在加载后 1-2 秒才出现。广告 island 优先用 client:load,让 <ins> 尽早提交、push 对着真实元素跑。

5. AdSense 只在已批准的生产域名投放

localhost*.vercel.app / *.netlify.app 等预览 URL,以及任何没在 AdSense 账号里添加并通过审核的域名,都不会投真广告。slot 空白是预期行为。

怎么判断: 同样的代码在真实域名上能填充、staging/preview 不行。别把这当代码 bug 调。如果你只是想在本地确认标签接对了,给 <ins> 临时加上 data-adtest="on",Google 会在 localhost 上返回测试广告(部署前务必删掉——在生产环境留着测试广告属于违反政策)。

6. 容器拦截脚本(CSP、iframe sandbox)

如果你发了 Content-Security-Policy 头但没放行 pagead2.googlesyndication.com 和广告 iframe 来源,脚本会被拦、或广告框渲染不出来。

怎么判断: 控制台出现 Refused to load the script 'https://pagead2.googlesyndication.com/...' because it violates the following Content Security Policy directive。被 sandbox 的 iframe 需要 allow-scripts allow-same-origin 才能跑广告框。

最短修复路径

第 1 步:建一个统一的可复用 AdSense 组件

让所有 slot 都走同一个组件,push 逻辑只存在一处。

Astro + React islandclient:load):

// AdSlot.jsx
import { useEffect, useRef } from 'react';

export default function AdSlot({ slotId, format = 'auto' }) {
  const insRef = useRef(null);
  useEffect(() => {
    const ins = insRef.current;
    // 元素不存在、或 AdSense 已处理过,就跳过。
    if (!ins || ins.getAttribute('data-adsbygoogle-status') === 'done') return;
    try {
      (window.adsbygoogle = window.adsbygoogle || []).push({});
    } catch (e) {
      console.warn('AdSense push failed', e);
    }
  }, []);

  return (
    <ins
      ref={insRef}
      className="adsbygoogle"
      style={{ display: 'block' }}
      data-ad-client={import.meta.env.PUBLIC_ADSENSE_CLIENT}
      data-ad-slot={slotId}
      data-ad-format={format}
      data-full-width-responsive="true"
    />
  );
}

用法 <AdSlot client:load slotId="1234567890" />。读 AdSense 自己写的 data-adsbygoogle-status 比用自定义标志位更可靠,因为 StrictMode 的双执行和路由重渲染看到的是同一个、由广告脚本设置的属性。

纯 Astro(不用 React),把 push 放进一个在元素之后运行的内联 <script>

---
const { slotId } = Astro.props;
---
<ins class="adsbygoogle"
     style="display:block"
     data-ad-client={import.meta.env.PUBLIC_ADSENSE_CLIENT}
     data-ad-slot={slotId}
     data-ad-format="auto"
     data-full-width-responsive="true"></ins>
<script is:inline>(adsbygoogle = window.adsbygoogle || []).push({});</script>

把库在基础 layout 的 <head> 里只加载一次,不要每个 slot 各加一遍:

<script async crossorigin="anonymous"
  src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"></script>

第 2 步:防 double-push

第 1 步里的 data-adsbygoogle-status === 'done' 判断就是防护:AdSense 一旦处理过一个 slot 就会设上这个属性,于是重跑(StrictMode、重渲染、路由切换)会提前 return 而不是再 push 一次。这正是让 TagError: ...already have ads in them 不再触发的关键。

第 3 步:处理 SPA 路由切换

Astro 的 View Transitions 或 Next.js App Router 下,新的 <ins> 不经过整页加载就出现,所以要在导航后重新扫描、只 push AdSense 还没处理过的那些:

import { useEffect } from 'react';
import { usePathname } from 'next/navigation';

useEffect(() => {
  document
    .querySelectorAll('ins.adsbygoogle:not([data-adsbygoogle-status])')
    .forEach(() => {
      try {
        (window.adsbygoogle = window.adsbygoogle || []).push({});
      } catch (e) {
        console.warn('AdSense re-push failed', e);
      }
    });
}, [pathname]);

Astro View Transitions 则把同样的扫描挂在 astro:page-load 事件上,而不是 React effect。

第 4 步:本地用测试广告确认接线,再上生产测真广告

AdSense 不会在 localhost、预览 URL、未批准域名上投真广告。两步走:

  1. 本地临时给 <ins>data-adtest="on"。Google 会返回带标记的测试广告,证明你的标签、client ID、push 时机都对。部署前删掉这个属性。
  2. 然后部署到你真实的、已批准的域名再验证。真广告只在已批准域名上填充。

第 5 步:CSP 放行 AdSense

如果你发了 Content-Security-Policy 头,需要放行广告来源:

script-src 'self' https://pagead2.googlesyndication.com https://googleads.g.doubleclick.net;
frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com;
img-src 'self' data: https://*.googlesyndication.com https://*.doubleclick.net;

内联 push 脚本优先用 nonce,别用 'unsafe-inline'。由于 AdSense 用到的域名会随时间变化,Google 推荐用基于 nonce 的 strict CSP,而不是固定的域名白名单;如果广告框仍失败,参见它的 CSP 指南

第 6 步:等报告同步

哪怕修对了,AdSense 收益面板反映真实填充也有大约 24-48 小时延迟,而且全新的 slot 在”预热”期间填充率可能偏低。别用上线第一天的收益来判断修没修好——要看 DOM 里有没有 data-ad-status="filled"

如何确认已修复

在你已批准的线上域名打开渲染后的页面,按顺序检查:

  1. document.querySelectorAll('ins.adsbygoogle').length 和你预期的 slot 数量一致。
  2. 每个 <ins> 都带上了 data-adsbygoogle-status="done"(AdSense 已处理)。
  3. 每个 <ins> 都带上了 data-ad-status="filled"(返回了广告)。出现 "unfilled" 说明处理成功但没广告可投——那是库存问题,不是代码 bug。
  4. 控制台没有 TagError

为了让某个 slot 没填上时不留空白占位,用 AdSense 自己设的属性把它隐藏:

ins.adsbygoogle[data-ad-status="unfilled"] { display: none !important; }

容易误判的情况

发布者常以为 slot “坏了”,其实只是客户端没在对的时机 push。Network 面板里脚本标签返回 200 看着很健康,但 200 只代表库下载成功——它完全不说明 push({}) 有没有匹配到你的 <ins>。一定要看元素上的 data-adsbygoogle-status / data-ad-status,别只看网络请求。

预防建议

  • 所有 slot 标签都走同一个可复用的 <AdSlot> 组件。
  • 用检查 data-adsbygoogle-status 的方式给 push({}) 加保护,让重渲染、StrictMode、路由切换都无法重复 push。
  • SPA 项目每次路由切换都重新扫描、给未处理的 <ins> 补 push。
  • 本地用 data-adtest="on" 确认标签;真广告填充只在已批准的生产域名上验证。
  • 加一条 CI 冒烟测试:部署 -> curl 页面 -> 断言 HTML 里出现 <ins class="adsbygoogle"

FAQ

  • 静态站到底能用 AdSense 吗? 能。只要 push({})<ins> 挂载后每个 slot 跑一次,静态的 Astro/Hugo/Next 导出站照常投广告。大多数大型内容站都是静态生成的。
  • 我的 <ins> 显示 data-ad-status="unfilled",是代码错了吗? 不是。unfilled 表示 AdSense 处理了这个 slot 但没广告可投。那是库存、地区或账号 / 政策状态,不是时机 bug。看审核通过后广告不展示
  • 为什么只在开发模式出 TagError React StrictMode 故意在开发模式把 effect 跑两遍,第二次 push 撞上了已处理的 <ins>。加 data-adsbygoogle-status 判断即可解决;在生产构建里通常不会复现。
  • 能不能就在 localhost 上测? 只有加 data-adtest="on" 才行,它返回 Google 的测试广告、证明你接线对了。真广告永远不会在 localhost 或预览 URL 上投,而且把 data-adtest="on" 留在生产环境违反 AdSense 政策。
  • 是不是改用 Auto Ads 更省事? Auto Ads 会替你注入并 push,省掉手动管理时机这一摊,对静态站确实更简单。代价是对位置的控制更少——见 Auto Ads 出现在很怪的位置

相关阅读

标签: #AdSense #变现 #排查 #静态站