你放好了 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 Policy | CSP 拦了 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 island(client: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、未批准域名上投真广告。两步走:
- 本地临时给
<ins>加data-adtest="on"。Google 会返回带标记的测试广告,证明你的标签、client ID、push 时机都对。部署前删掉这个属性。 - 然后部署到你真实的、已批准的域名再验证。真广告只在已批准域名上填充。
第 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"。
如何确认已修复
在你已批准的线上域名打开渲染后的页面,按顺序检查:
document.querySelectorAll('ins.adsbygoogle').length和你预期的 slot 数量一致。- 每个
<ins>都带上了data-adsbygoogle-status="done"(AdSense 已处理)。 - 每个
<ins>都带上了data-ad-status="filled"(返回了广告)。出现"unfilled"说明处理成功但没广告可投——那是库存问题,不是代码 bug。 - 控制台没有
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 出现在很怪的位置。