无限滚动列表页(首页、博客列表、商品分类)用起来很爽,但有个结构性的 SEO 问题:Googlebot 不滚动、不点击,也不会触发 scroll 事件。它加载页面、渲染一次、拍下快照就走人。任何只有在用户滚动后才出现的内容,它都看不到。
最快修复(TL;DR):保留无限滚动的交互,但给每一”页”结果一个可爬的、带 ?page=n 查询参数的独立 URL;把这些 URL 都做成服务端渲染,让完整的 20 条(或你的每页条数)出现在原始 HTML 里;每页 self-canonical 指向自己;并把所有分页 URL 写进 sitemap。然后在 Search Console 重新提交 sitemap。无限滚动留给人看,?page=n URL 是 Google 发现第 21 条及之后内容的唯一途径。
这正是 Google 在它的分页与增量加载文档里推荐的做法:“Give each page a unique URL. For example, include a ?page=n query parameter, as URLs in a paginated sequence are treated as separate pages by Google.”
典型症状:
- 只有前 ~20 篇文章会被发现,其余的卡在 “Crawled - currently not indexed”,或者在 Search Console 里根本不出现
- 第 21 篇之后的文章即使上线了、UI 里也有链接,却被报成 “URL is unknown to Google”
/blog?page=2、/blog?page=3从不被爬,因为它们根本不是真实的、服务端渲染的页面
你属于哪一类?
跑两个快速检查,再跳到对应的原因。
| 检查 | 结果 | 最可能的原因 |
|---|---|---|
curl -sL https://yoursite.com/blog | grep -c "<article" | 返回 ~20(你的首屏条数),但实际页面显示几百条 | 滚动加载纯靠 JS(原因 1) |
| 滚过第 1 屏,盯着地址栏 | URL 永远停在 /blog | 没有可爬的分页 URL(原因 2) |
curl -sL "https://yoursite.com/blog?page=2" | grep -c "<article" | 返回 0 | ?page=2 存在但只在客户端渲染(原因 3) |
“下一页”链接是 /blog#page=2 | — | hash/fragment URL(原因 4) |
?page=2 在 curl 里返回 20,但 Search Console 说 “unknown to Google” | — | 没进 sitemap / 没被链接(原因 5) |
常见原因
1. 滚动加载纯靠 JS,而 Googlebot 不滚动
// React 典型实现:sentinel 滚进视口时触发 fetch
useEffect(() => {
const observer = new IntersectionObserver(([e]) => {
if (e.isIntersecting) loadMore();
});
observer.observe(sentinelRef.current);
}, []);
Googlebot 不会跑这个循环,因为它根本不产生 scroll 事件。它渲染出来的快照里只有最初的 20 条 <article>。Google 自己的懒加载文档说得很明确:内容应该在”它出现在视口里时”加载——“The methods mentioned don’t rely on user actions, such as scrolling or clicking, to load content, which is important as Google Search does not interact with your page.”
所以 IntersectionObserver 用来懒加载视口内已经存在的图片没问题,但用它去加载只有用户滚动才能露出的新列表项,就是用错了机制。
如何判断:
curl -sL "https://yoursite.com/blog" | grep -c "<article"
# 例如返回 20,说明 Google 的 HTML 快照里只有 20 条
2. 没有可爬的分页 URL
很多 SPA 用”加载更多”按钮(或自动滚动)去 fetch 数据,但从不改变 URL,你永远停在 /blog。Google 没法请求”第 2 页”,因为那个 URL 作为独立地址压根不存在。
如何判断: 滚过第 1 屏,盯着地址栏。如果它始终不从 /blog 变化,就没有可供爬取的分页 URL。
3. 内容首次出现在 client-only 渲染里
哪怕 /blog?page=2 作为路由存在,如果那个页面的列表也是在 useEffect(或任何客户端数据 hook)里 fetch 的,Googlebot 渲染出的 HTML 仍是一个空列表。你需要服务端渲染(SSR)或静态生成(SSG),让这 20 条出现在最初的 HTML 里。
如何判断: curl -sL "https://yoursite.com/blog?page=2" | grep -c "<article" 返回 0,尽管页面在浏览器里看着是满的。
4. 分页 URL 用了 hash 片段(#page=2)
Google 会忽略 # 之后的一切。在它眼里,/blog#page=2 和 /blog#page=3 都是同一个 URL:/blog。文档原话:“Don’t use URL fragment identifiers (the text after a # in a URL) for page numbers in a collection. Google ignores fragment identifiers.” 改用查询参数(?page=2)或路径段(/blog/page/2)。
5. 分页 URL 不可被发现
即使 /blog?page=2 是服务端渲染、返回 200 的,如果没有任何东西指向它——sitemap 条目,或某个可爬页面上的真 <a href> 链接——Google 也找不到。只靠 JavaScript 绑定的按钮不算数:Google 跟的是 <a href> 链接,不是 click 处理函数。
最短修复路径
Step 1:给每个无限滚动页一个可爬的分页 URL
保留无限滚动的交互,但在新页加载时同步更新 URL。Google 在懒加载文档里明确认可用 History API 做这件事(“Using the History API to update displayed URLs when chunks load”):
// 第 2 页进入视口时,更新 URL 但不整页刷新
window.history.pushState({}, '', '/blog?page=2');
/blog?page=2 同时也必须是一个真实、可直接访问、独立就能渲染出同一份列表(SSR/SSG)的 URL,这样无论是分享/刷新链接的用户,还是 Googlebot,拿到的都是正确内容。
Step 2:服务端渲染分页 URL
Next.js(App Router):
// app/blog/page.jsx
export default async function BlogPage({ searchParams }) {
const page = parseInt(searchParams.page) || 1;
const PER_PAGE = 20;
const posts = await db.posts.findMany({
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
orderBy: { publishedAt: 'desc' },
});
return (
<>
{posts.map(p => <article key={p.id}><a href={`/blog/${p.slug}`}>{p.title}</a></article>)}
<nav>
{page > 1 && <a href={`?page=${page - 1}`}>上一页</a>}
<a href={`?page=${page + 1}`}>下一页</a>
</nav>
</>
);
}
Astro 里用 getStaticPaths(或 Astro 自带的 paginate() helper)在构建时生成 /blog/page/1、/blog/page/2 等静态文件。
Step 3:放真正的上下页链接(用 <a href>,不是 button)
最关键的一点:每一页都要用可爬的 <a href> 链接指向上一页和下一页——底部一个带数字的 <nav> 最理想:
<nav aria-label="Pagination">
<a href="/blog?page=1">1</a>
<a href="/blog?page=2">2</a>
<a href="/blog?page=3">3</a>
<a href="/blog?page=4" rel="next">下一页</a>
</nav>
你仍然可以为其他爬虫和无障碍输出 <link rel="prev"> / <link rel="next">,但要知道 Google 从 2019 年起就不再把它们当作收录信号了(“Google no longer uses these tags, although these links may still be used by other search engines.”)。别依赖它们,要靠上面那种可见的 <a href> 链接。
Step 4:把所有分页 URL 写进 sitemap
// scripts/generate-sitemap.mjs
const totalPosts = await db.posts.count();
const totalPages = Math.ceil(totalPosts / 20);
for (let i = 1; i <= totalPages; i++) {
urls.push(`https://yoursite.com/blog?page=${i}`);
}
部署后在 Search Console 重新提交 sitemap(Sitemaps → 输入路径 → Submit)。Google 通常几天内就开始爬这些新 URL;电商目录还可以额外用 Merchant Center feed 喂商品 URL。
Step 5:每一页 self-canonical
每个分页 URL 的 canonical 必须指向它自己,而不是 page 1。把 /blog?page=2 的 canonical 指向 /blog,等于告诉 Google “第 2 页是第 1 页的副本”——这是让第 21 条之后内容进不了索引的最快方式。Google 文档:“Don’t use the first page of a paginated sequence as the canonical page. Instead, give each page its own canonical URL.”
<!-- on /blog?page=2 -->
<link rel="canonical" href="https://yoursite.com/blog?page=2" />
Step 6:验证是否修好
两个检查——一个在本地,一个在 Search Console。
# 1. page 2 的原始 HTML 里应该已经包含完整列表
curl -sL "https://yoursite.com/blog?page=2" | grep -c "<article"
# 期望返回 20(或你的每页条数),而不是 0
然后在 Search Console 打开 URL Inspection,粘贴 https://yoursite.com/blog?page=2,点 Test Live URL,再点 View Tested Page → HTML,确认渲染后的 HTML 里包含全部 20 条(Google:“Check the rendered HTML to make sure your content is in the rendered HTML”)。列表都在的话,点 Request Indexing。再抽查一两个更深的页面(比如 ?page=5)。
预防建议
- 每个列表页 = 无限滚动交互 加上 一套可爬的
?page=n(或/page/n)URL;两者并存,不是二选一 - 生成 sitemap 时,内容数量每越过一个分页边界就自动追加新的分页 URL
- 分页 URL 用查询参数或路径段——绝不用
#page=2这种 hash 片段 - 每页都 self-canonical,没有任何一页 canonical 指向 page 1
- 每周用
curl抽查一次,确认各分页 URL 的 HTML 里仍然包含完整列表(能抓到某天有人把列表重构回客户端 fetch 的情况)
常见问题
Googlebot 最终会不会渲染 JavaScript、把滚动加载进来的内容看到?
Google 确实会渲染 JavaScript,但它只渲染一次、不滚动、不点击、不触发 scroll 事件——所以只有用户滚动才会加载的内容它依然看不到。而且渲染是排队进行的,并不保证每次爬取都执行。别指望它,把内容放进可爬的 ?page=n URL 里。
对 SEO 来说,“加载更多”按钮比自动滚动好还是差?
略好,但前提是这个按钮本身是(或旁边配有)一个真正的 <a href="?page=2"> 链接。纯靠 JavaScript click 处理函数绑定的按钮,对 Google 来说和自动滚动一样不可见。可靠的模式是:按钮负责交互,HTML 里的 <a href> 分页链接负责给爬虫。
rel=“next” / rel=“prev” 还需要吗?
对 Google 不需要——它早在 2019 年就把这两个当作收录信号给放弃了。它们没什么成本,其他搜索引擎和部分辅助技术还在读,所以输出它们无害,但单靠它们解决不了你的收录问题。真正起作用的是可爬的 <a href> 链接和 self-canonical。
是不是干脆做一个 “View all” 整页,而不要分页?
可以,Google 也说用户往往更喜欢单页内容——但前提是那一页加载得快、且所有条目都服务端渲染出来。对于有几百条内容的大目录或博客,“View all” 页通常太重,所以分页的 ?page=n URL 是更稳妥的默认选择。
部署修复后,第 21 条之后多久会被收录? 重新提交 sitemap 后通常几天内开始被发现,但每一页都被完整收录可能要几周,取决于你的爬取预算和站点权重。可以对最重要的页面用 URL Inspection → Request Indexing 推一把,并在 Search Console 的 Pages 报告里盯着 “Crawled - currently not indexed” 的数字往下掉。