menu
首页文章相册工具关于
search
...

Google SEO

2026/09/11
eye-

一份持续更新的 SEO 知识清单,每个知识点按同一个结构写:它是什么、通常怎么出错、怎么修。例子用一个部署在 example.com 上的双语 Next.js 站点,但规则本身和框架无关。遇到新的坑会继续往里加。

canonical

页面的 <head> 里可以放一行,声明"这个页面的正式地址是 X":

<link rel="canonical" href="https://www.example.com/posts/hello" />

rel 是 relationship 的缩写,描述当前页面和 href 里那个地址之间的关系。canonical 的意思是"那个地址是我的正式版本"。Google 收录页面、计算权重时,用的是 canonical 里写的地址,而不是用户实际访问的地址。

规则只有一条:canonical 必须指向一个能直接打开、返回 200 的地址,最省事的做法就是指向页面自己。

一个很常见的出错方式是这样的。假设站点的默认语言用裸路径(/posts/hello),其他语言带前缀(/zh/posts/hello)。一个中间件在内部把 /posts/x 改写成 /en/posts/x,让一个 [lng] 路由同时处理两种情况,同时把任何显式带 /en/ 的请求跳转回裸路径。文章页用的是 Next.js 的相对写法:

export const metadata = {
  alternates: { canonical: "./" },
}

在请求时渲染的页面上,"./" 会按真实 URL 解析,得到 /posts/hello,没有问题。但如果文章路由是 force-static,它在构建期预渲染,那时没有请求 URL,"./" 就按内部路径解析了。页面最终对外声明的是:

<link rel="canonical" href="https://www.example.com/en/posts/hello" />

跟着 Google 的动作走一遍:

  1. 抓 /posts/hello,得到 200。
  2. 读到 canonical,得知"正式地址是 /en/posts/hello"。
  3. 去抓那个地址,得到一个跳转,跳回第 1 步。

页面 A 说"我不是正式的,B 才是",B 说"去看 A"。Google 无法按声明收录,只能自己猜一个地址,并把页面标记为 canonical 配置有误。一个返回 200 却声称自己不是正式版本的页面,比不写 canonical 还糟。

修法是不再依赖 "./",用语言和路径显式拼出 canonical,前缀规则收在一个 helper 里:

// lib/seo.js
const langPrefix = (lng) => (lng === fallbackLanguage ? "" : `/${lng}`)

export const localizedUrl = (lng, path = "") =>
  `${siteUrl}${langPrefix(lng)}${path}`

然后每个页面通过这个 helper 声明自己的 canonical。请求时渲染的页面可以统一在 layout 里做一次,静态页面在自己的 generateMetadata 里做:

export const generateMetadata = async ({ params }) => {
  const { lng, id } = await params
  const post = await getPost(lng, id)
  return {
    alternates: { canonical: localizedUrl(lng, post.url) },
  }
}

现在 /posts/hello 声明的正式地址就是 /posts/hello,中文页声明的是 /zh/posts/hello,中间不再经过任何跳转。

另外两个经常出现的 canonical 错误:

  • 域名不一致。 站点把 example.com 跳转到 www.example.com,但 canonical 是从一个仍写着 https://example.com 的 metadataBase 生成的,于是所有 canonical 都指向一个会跳转的域名。选定一个域名,另一个用 301 或 308 跳过去,并让 base URL 和它保持一致。
  • 跨语言 canonical。 把中文页的 canonical 指向英文页,理由是"英文是原文",这等于告诉 Google 把中文页从索引里去掉。每个语言版本各自是自己的 canonical,它们之间的关系用 hreflang 来表达。

hreflang

hreflang 是 href language 的合写,是 <link rel="alternate"> 上的一个属性,意思是"href 里的地址是同一内容的另一个语言版本"。一个双语页面每种语言一行,再加一个兜底:

<link
  rel="alternate"
  hreflang="en"
  href="https://www.example.com/posts/hello"
/>
<link
  rel="alternate"
  hreflang="zh"
  href="https://www.example.com/zh/posts/hello"
/>
<link
  rel="alternate"
  hreflang="x-default"
  href="https://www.example.com/posts/hello"
/>

值是语言代码,比如 en、zh,需要区分地区时加地区(zh-CN、zh-TW、en-US)。x-default 是特殊值,意思是"用户的语言都不匹配时,给他看这个"。

没有 hreflang 时,Google 把英文版和中文版当成两个不相干的页面:可能给中文读者展示英文版,或者认定两者重复只收录一个。有了 hreflang,Google 知道这是同一篇内容的两个语言版本,会按用户的语言展示对应版本,两个页面的权重合并计算而不是互相竞争。

三条规则,任何一条不满足,整组标签都会被 Google 忽略:

  1. 必须互指。 英文页列出中文页,中文页也要列出英文页。单向声明会被丢弃。
  2. 必须包含自己。 英文页要有一条 hreflang="en" 指向自己的地址,不能只列其他语言。
  3. 必须是绝对地址。 写 https://www.example.com/zh/posts/hello,不能写 /zh/posts/hello。

有了上面的 helper,生成这组标签只需要一个小函数:

// lib/seo.js
export function localizedLanguages(path, availableLanguages = languages) {
  const map = Object.fromEntries(
    availableLanguages.map((lng) => [lng, localizedUrl(lng, path)])
  )
  return { ...map, "x-default": localizedUrl(fallbackLanguage, path) }
}

export function localizedAlternates(lng, path, availableLanguages = languages) {
  return {
    canonical: localizedUrl(lng, path),
    languages: localizedLanguages(path, availableLanguages),
  }
}

每个页面在 metadata 里返回 alternates: localizedAlternates(lng, path) 即可,Next.js 会把 languages 这个映射渲染成 <link rel="alternate" hreflang> 标签。一个值得保留的细节:一个页面可能只有部分语言版本,所以要传入它实际存在的语言列表,只声明这些。列出一个会 404 的语言地址,会让整组标签违反第 1 条规则。

关于 hreflang 可以放在哪,还有两点:

  • 同一份映射可以放进 sitemap.xml,以 <xhtml:link rel="alternate" hreflang="..."> 的形式出现。Next.js 直接支持:在每条 sitemap 记录上返回 alternates: { languages } 就行。这是 Google 读取的第二个来源,和 HTML 里的标签不冲突。
  • Next.js 里页面级的 alternates 会整个替换掉 layout 的 alternates,不是合并。如果 layout 在里面声明了别的东西,比如 types 下的 RSS 链接,页面必须重新声明一遍,否则它会悄悄消失。

验证的时候抓一个页面找这些标签。Next.js 输出的属性名是驼峰的 hrefLang,所以要忽略大小写:

curl -s https://www.example.com/posts/hello | grep -i hreflang
网页打开微信小程序一些有用的 Git 命令
目录
  • canonical
  • hreflang