这个站上线初期,在搜索引擎眼里就是一个空壳:Vue SPA 功能齐全、动效精致,可百度搜不到站点,Google Search Console 抓回来的页面里也只有一行 <div id="app"></div>,其余全是空的。你精心写的文章、标题、描述,对搜索引擎来说,等于不存在。
这不是你的页面"没内容",是搜索引擎的程序不执行 JavaScript——准确说,抓取阶段不执行。蜘蛛下载 HTML,解析标签,读完就走。你的所有内容都是 JS 在浏览器里渲染出来的,对蜘蛛而言,你交付的就是一个空壳。
更讽刺的是,这种"空壳"还不是空手而归——它连标题都是写死的默认标题。搜索引擎爬了你一百个页面,得到一百个一模一样的 title 和一百个空壳,它只会得出一个结论:这是个低质量模板站。收录不收录,也就看心情了。所以别赌"反正 Google 会二次渲染"——那不是你该依赖的路径,把静态 HTML 做好才是正道。
这问题怎么破?主流答案有三条路:上 SSR,让服务器渲染好完整 HTML;上无头浏览器,抓取时用 puppeteer 之类现场执行 JS;或者像这篇文章讲的——构建期预渲染。这篇拆的是这个项目真实在跑的方案:不引 SSR 框架,不养无头浏览器,纯靠几条构建脚本,让百度、必应、Google 都能爬到文章全文。
先把问题钉死。一个 Vite 打包出来的 SPA,产物 HTML 大概长这样:
<!doctype html>
<html lang="zh-CN">
<head>
<title>悦目图库 - 高清图片、壁纸与摄影素材</title>
<meta name="description" content="..." />
</head>
<body>
<div id="app"></div>
<script type="module" src="/assets/index-xxxx.js"></script>
</body>
</html>
首页的 title 和 description 是有的,蜘蛛能看到。但正文呢?什么都没有,就一个空空的 #app。指南文章页更惨——它连 title 都是写死的首页标题,每篇文章的标题、描述、正文,全部要等 JS 跑起来才存在。
先想明白一个关键事实:蜘蛛确实执行 JS,但不是所有蜘蛛都在抓取阶段执行。Google 会用无头浏览器做二次渲染,但那是"爬取之后"的事,而且是有配额的;百度对 JS 的渲染一直不积极;还有一堆小搜索引擎根本不渲染。你不能赌"反正 Google 会二次渲染"——那样你的内容在百度里就是裸奔的。
所以目标很朴素:让抓取阶段拿到的 HTML 本身就是完整的,标题、描述、正文,一样不缺。
要做到这一点,就得回答一个问题:这些 HTML 是谁、在什么时候生成的?答案有三种——服务器每次请求时现场渲染(SSR)、专门的渲染服务在抓取时执行 JS(无头浏览器)、构建打包时一次性生成(预渲染)。第三条路是这个项目的选择,也是这篇文章的主角。
方案落地在构建脚本里。看 package.json 里这条命令链:
"build": "vite build && node scripts/generate-locale-html.mjs",
"build:seo": "npm run build && npm run prerender && npm run sitemap:static",
"prerender": "node scripts/prerender.mjs",
"sitemap:static": "node scripts/generate-sitemap.mjs --static-only"
拆开看,四步各干一件事:
vite build 正常打包,产出 dist;generate-locale-html 生成中文、英文两套首页 index.html,给 Nginx 的 try_files 做多语言兜底;prerender 遍历所有静态路由和每篇指南文章,往 dist 里写"带完整 TDK + 真实正文"的静态 HTML;sitemap 生成 sitemap.xml,把目录递给搜索引擎。这个顺序也是讲究的:先 build 出真实的 SPA 产物,再在产物基础上做 locale HTML 和预渲染,最后出 sitemap。每一步都依赖上一步的产物,串成一条流水线。谁要是把顺序搞反(比如先预渲染再 build),预渲染生成的文件会被后续的 build 覆盖,等于白做。
每一步都是构建期的一次性动作,运行时零开销——没有服务端渲染进程,没有无头浏览器常驻,用户访问的还是那个快得像闪电的 SPA。这就是预渲染路线最吸引人的地方:SEO 的成本,全部花在发布时,而不是每次请求时。
顺带说一句,这套命令是串在 npm run build:seo 里的,一键跑完整个 SEO 管线。日常开发跑 npm run dev 完全不受影响,预渲染只在发布构建时介入。这是它比 SSR 友好的地方——开发体验不变,只是多了一个发布前的打包步骤。
核心是 prerender.mjs。它读构建产物的 dist/index.html 当模板,然后按"语言 × 路由"两层循环,给每一个页面写一份独立的 index.html:
for (const locale of ['zh', 'en']) {
for (const path of STATIC_PATHS) {
// 静态页:注入站点描述的 SEO 正文,生成 dist/{locale}{path}/index.html
const seoIntro = PAGE_INTRO[path] ? PAGE_INTRO[path][locale] : ''
writeFileSync(resolve(dir, 'index.html'), generateHTML(locale, path, { seoIntro }), 'utf-8')
}
// 指南文章:注入每篇文章的真实标题/描述 + markdown 渲染后的全文
const guideArticles = discoverArticles(locale)
for (const article of guideArticles) {
const contentHtml = loadArticleContentHtml(locale, article)
writeFileSync(resolve(dir, 'index.html'),
generateHTML(locale, path, { title: article.title, desc: article.desc, contentHtml, jsonLd }), 'utf-8')
}
}
两个循环值得对比着看。静态页走"功能路由"逻辑:首页、关于、隐私政策这些页面,注入一段站点描述当 SEO 正文,让蜘蛛至少能读到"这个站是什么"。指南文章走"内容路由"逻辑:每篇文章用自己的标题、描述,加上 markdown 渲染后的全文,这才是让蜘蛛能爬到你文章的关键。
为什么不用 puppeteer 现场渲染?项目代码注释里写得很清楚:"Unlike puppeteer-based approaches, this reads locale files directly." 无头浏览器渲染有几个痛点:慢(每页要起浏览器、等网络、等 JS)、脆(某个异步接口慢一下整页渲染失败)、贵(服务器内存被浏览器吃掉)。而预渲染是读源码生成 HTML——文章内容就在 src/locales/{zh|en}/pages/guides/a*.ts 里躺着,直接读、直接渲染、直接写文件,几秒钟跑完整个站,稳如老狗。
预渲染的产物结构也值得一提:每个页面在 dist 里都是一个目录加一份 index.html,比如 dist/zh/guides/a1/index.html、dist/en/about/index.html。这种"目录 + index.html"的结构,配合 Nginx 的 try_files,让任何路径都能命中对应的静态文件。也正因为每个页面都是独立的 HTML,搜索引擎可以用一个 URL 对应一份完整内容,hreflang、canonical 也都能按页面精确设置——这是"一个 SPA 单文件"做不到的粒度。
SEO 有个容易被忽略的坑:整个站几百个页面,title 全一样。搜索引擎看到几百个相同标题的页面,会觉得这是模板站,权重全被稀释。所以每页唯一 TDK 是必须的。
prerender.mjs 里维护了一张路由映射表和一套页面标题表:
function pathToRouteName(path) {
const m = { '/': 'Home', '/about': 'About', '/guides': 'Guides', '/ranking': 'Ranking', ... }
return m[path] || null
}
// 每页唯一 description:指南用文章自带描述,其余页面 = 「标题 — 站点短描述」
const title = opts.title || (routeName && PAGE_TITLES[locale][routeName]) || seo.defaultTitle
const rawDesc = opts.desc || `\${title} — \${seo.shortDescription}`
const desc = rawDesc.length > 150 ? rawDesc.slice(0, 149) + '…' : rawDesc
套路是:首页用默认标题;功能页从 PAGE_TITLES 表里按路由名取标题;指南文章直接用文章自己的 title 和 desc。这样每篇文章的 <title> 都是它自己的标题,杜绝"全站一个标题"。
描述里还有一个细节:150 字符截断。Google 在搜索结果里显示的 description 上限大概在 158 个字符,超了会被截断成省略号,看着不完整。所以这里统一 slice(0, 149) + '…',主动控制在 150 以内——与其让 Google 截,不如自己截得体面。
还有个绕不过去的点:所有插入 HTML 的字符串都要转义。escapeXml 把 &、<、>、引号替换成实体,防止文章标题里带个 & 就把整个 HTML 弄脏。这行代码看着不起眼,但 SEO 数据里混入特殊字符导致页面结构错乱,是真实会发生的事。
预渲染最核心的一步:怎么把文章正文变成 HTML 塞进空空的 #app。分两步——先从 .ts 源文件里把 content 抠出来,再用 markdown-it 渲染。
从 TypeScript 源文件里提取内容,靠的是一个正则:
function extractContentFromSource(source) {
const m = source.match(/content:\s*`((?:[^`\\]|\\.)*)`\s*\}/)
if (!m) return ''
// 还原模板字符串里的转义
return m[1].replace(/\\`/g, '`').replace(/\\\\/g, '\\')
}
文章内容在 .ts 文件里是个模板字符串,里面嵌着代码块(反引号)和各种转义。这个正则是"匹配 content 冒号后面那个模板字符串,但要能跨过内部的反引号和反斜杠转义",再反向还原一遍。顺带说一句,写这些指南文章的时候,我天天和反引号转义打交道(上一篇文章里专门吐槽过),这个正则就是为了在构建期把这些转义安全地解出来。
抠出来之后,用 markdown-it 渲染成 HTML,再做一次媒体修复,注入到模板的 #app 里:
if (opts.contentHtml) {
html = html.replace('<div id="app"></div>',
() => `<div id="app">\n\${opts.contentHtml}\n</div>`)
}
注入之后,蜘蛛下载这个页面,#app 里就是完完整整的文章正文——标题、段落、代码块,全是可抓取的文本。Google 能从正文里提取关键词、判断主题、建立索引,百度也能读到全文。这比"空壳 + 靠 JS 渲染"强了不知道多少倍。
markdown-it 在这里用的是默认配置加几个开关:html: false(不允许文章里写原生 HTML,防注入)、linkify: true(裸链接自动转成可点击链接)、breaks: false(不把换行自动转成段落,保证 markdown 的换行语义)。这三个开关不是随便设的,html: false 尤其重要——文章内容是创作者写的,如果允许 HTML,一个恶意脚本就可能被塞进页面。预渲染注入的内容同样要过这一道安全闸。
文章里的动图大多是 webm 格式,但 markdown-it 会把图片语法渲染成 <img>,而 webm 是视频不是图片,<img> 会直接破图。预渲染的时候顺手把它修成 <video>:
function fixMediaTags(html) {
return html
.replace(
/<img src="([^"]*\.(?:webm|mp4|ogg|mov))"([^>]*?)alt="([^"]*)"([^>]*?)\/?>/gi,
(_m, src, _p1, alt, _p2) =>
`<video src="\${src}" controls muted loop playsinline preload="metadata" aria-label="\${escapeXml(alt)}">...`
)
.replace(/(<(?:img|video)[^>]*\s(?:src|poster)=")\/(guides|webm)\//g,
'$1../../../$2/')
}
第二行替换更值得说:把绝对路径 /guides/xxx、/webm/xxx 改成相对路径 ../../../guides/xxx。因为预渲染生成的 HTML 放在 dist/{locale}/guides/{id}/ 这种三层深的目录里,图片文件在 dist 根下的 guides/ 和 webm/,用相对路径才能从任意深度都正确指向。这个细节的意义是:就算你直接双击打开本地 HTML 文件(file://),图片也能加载——方便本地验证爬虫视角,也方便搜索引擎抓取时不用依赖站点根路径。
光有正文还不够。搜索引擎喜欢结构化数据——它用 JSON-LD 这种格式告诉引擎"这篇文章的作者是谁、发布时间、属于哪个站"。prerender.mjs 给每篇文章生成一份 Article 结构:
const jsonLd = JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.desc,
datePublished: article.date,
url: `\${BASE_URL}/\${locale}\${path}`,
author: {
'@type': 'Person',
name: 'LuMeng',
url: 'https://github.com/humenglover',
email: "mailto:shengqiangwang666{'@'}gmail.com",
sameAs: ['https://github.com/humenglover', 'https://www.douyin.com/user/68316901625', ...],
},
publisher: { '@type': 'Organization', name: '悦目图库', ... },
})
这堆字段不是随便填的。Google 有个概念叫 E-E-A-T(经验、专业、权威、可信),它倾向于给"能确认作者身份"的内容加分。所以这里的作者被完整地描述成一个人——名字、GitHub、抖音、邮箱,而且在 Contact 页、About 页、隐私政策里用的是同一个邮箱。代码注释里写了:建立"作者名 ↔ 邮箱"的机器可读映射。搜索引擎读到多个页面的邮箱一致,就更容易确认"这个作者是真实存在的"。
这个身份一致性是很多独立站最容易漏的:作者名在文章里叫一个,在关于页叫另一个,邮箱各写各的,搜索引擎想确认身份都对不上号。统一身份,是低成本高回报的 E-E-A-T 操作。
还有人会问,JSON-LD 到底有没有用?答案是:它本身不直接提升排名,但它让搜索引擎理解你的页面——知道这是篇文章、作者是谁、什么时候发的。理解了,才有资格进精选摘要、富媒体结果这些高曝光位。对独立开发者来说,这是花十分钟就做完、但长期回报稳定的投资。
SEO 不是把所有页面都推给搜索引擎。有些页面压根不该被收录——登录页、注册页、上传页,这些功能页被搜出来毫无价值,还稀释权重。预渲染里专门维护了一张"不收录清单":
const NOINDEX_PATHS = new Set([
'/add_picture', '/user/login', '/user/register',
'/invite', '/creator/analytics', ...
])
const INDEX_META = '<meta name="robots" content="index,follow,max-image-preview:large">'
const NOINDEX_META = '<meta name="robots" content="noindex,nofollow">'
// 生成 HTML 时按路径决定给哪个 robots 指令
.replace(/<meta name="robots"[^>]*>\s*/g,
NOINDEX_PATHS.has(path) ? NOINDEX_META : INDEX_META)
这套逻辑有两层意思。第一,noindex 告诉搜索引擎"这个页面别收录",但它仍然生成静态 HTML、仍然有每页独立的 TDK——因为路由要能访问,Nginx 的 try_files 要能找到文件,不能让用户访问登录页直接 404。第二,max-image-preview:large 允许搜索引擎在搜索结果里展示大图预览,对图片站是实打实的点击率提升。
"收录什么、不收录什么"是 SEO 里容易被忽略的战略选择。全站一股脑提交索引,看似公平,实则是把有限的抓取预算浪费在登录页上。
这里还有个容易被忽略的点:noindex 不是不生成页面。这些功能页依然要能被用户访问、要有独立的 TDK,只是告诉搜索引擎"别收录"。生成 HTML 和禁止收录是两件事,很多人一听说"这个页面不该被收录",就顺手把它从预渲染里删了,结果用户访问直接 404——那才是真的灾难。预渲染的覆盖范围,要大于"应该被收录的范围"。
这个站有中英双语。多语言 SEO 有个麻烦:搜索引擎要区分"中文版"和"英文版",靠的是 hreflang 和 canonical,而这两者都要有对应的独立 URL。generate-locale-html.mjs 干的就是这件事——给中英文各生成一份首页 index.html:
// 中英文各一份,硬编码各自的 title/desc/H1
const zhDir = resolve(DIST, 'zh')
writeFileSync(resolve(zhDir, 'index.html'), localize(html, ZH), 'utf-8')
const enDir = resolve(DIST, 'en')
writeFileSync(resolve(enDir, 'index.html'), localize(html, EN), 'utf-8')
配合 Nginx 的配置规则(脚本注释里写得很清楚):
try_files $uri $uri/ /$1/index.html /index.html;
这条规则的意思是:访问 /zh/xxx,先找 dist/zh/xxx,找不到就回退到 dist/zh/index.html;访问 /en/xxx 同理回退到英文首页。这样 /zh/ 和 /en/ 是两个独立的 URL 空间,各自有自己的 lang、canonical、hreflang,搜索引擎能清楚地知道"这个站有中文版和英文版,对应关系如下"。localize 函数就是一堆正则替换——把 <html lang>、title、og、twitter、canonical、JSON-LD 里的中文内容按语言换掉。之所以用正则而不是模板替换,是为了扛住 Vite 每次打包产出的 hash 文件名变化,正则按结构匹配,怎么变都能对上。
这背后是一个值得学的思路:多语言不只是一个翻译问题,还是一个路由和 SEO 问题。给每种语言一个独立 URL 前缀,是搜索引擎能理解你的多语言站的前提;共用一套 URL 靠 JS 切语言,搜索引擎永远分不清。
这里还有个细节:hreflang 不光有 zh-CN 和 en-US,还加了 x-default,指向默认语言版本。x-default 是告诉搜索引擎"当用户的语言不是中英任意一种时,给这个版本",防止它因为找不到合适的语言版本而乱配。另外,首页的 canonical 在根路径做了特殊处理——/zh/ 的 canonical 是 /zh/,但根 index.html(无语言前缀的兜底页)的 canonical 被改写成了不带语言前缀的域名根,避免根页面和中文首页出现两个 canonical 指向同一个内容。
sitemap.xml 相当于给搜索引擎递一份"站点头目录",告诉它有哪些页面值得抓、多久更新一次、优先级多高。generate-sitemap.mjs 分三层生成:静态路由、指南文章、动态内容(图片、空间、用户这些从后端 API 拉)。
静态路由里能看到一个细节——每个页面配了 changefreq(更新频率)和 priority(优先级):
const STATIC_ROUTES = [
{ path: '/', changefreq: 'hourly', priority: '1.0' },
{ path: '/forum', changefreq: 'hourly', priority: '0.9' },
{ path: '/ranking', changefreq: 'daily', priority: '0.8' },
...
]
首页 hourly(每小时更新?其实首页并不会每小时变,但搜索引擎给的预算更高)、论坛 hourly、榜单 daily——这些值不是随便填的,是告诉搜索引擎"这个页面的内容多久会变一次,你该多久回来抓一次"。priority 则是相对权重,1.0 是最重要的首页。
更厉害的是动态内容部分:脚本会去调后端 API,把真实存在的图片、空间、用户、帖子、活动的 URL 拉出来,全部写进 sitemap。这样连动态生成的页面也能被收录,而且脚本建议"每天跑一次",保证新内容及时进 sitemap。这个站点做到了"静态 + 动态全量进 sitemap",收录的地基就稳了。
sitemap 的生成建议挂进 CI/CD,每天跑一次。因为站点的内容是动态的——用户今天上传的图片、新建的空间,明天就该进 sitemap 让搜索引擎知道。不更新的话,sitemap 就是一份过期目录,新内容要等搜索引擎自己发现,收录周期长很多。定时跑这个脚本,是让"新内容尽快被收录"性价比最高的动作,比任何手动提交都管用。
预渲染解决的是"蜘蛛抓取时"的问题,但用户浏览器里跑的还是 SPA。SPA 里怎么给动态生成的页面(比如某个图片详情页)注入 JSON-LD?答案是运行时注入,项目里封装了一个 composable:
export function useStructuredData(data) {
let scriptEl = null
onMounted(() => {
const initial = typeof data === 'function' ? data() : data
scriptEl = injectStructuredData(initial)
if (typeof data === 'function') {
watchEffect(() => { scriptEl.textContent = JSON.stringify(data(), null, 2) })
}
})
onUnmounted(() => { scriptEl?.parentNode?.removeChild(scriptEl) })
}
用法很简单:组件里 useStructuredData(() => ({ '@type': 'Article', headline: article.title, ... })),组件挂载时把 JSON-LD 脚本插进 <head>,组件销毁时清掉。这样动态页面的结构化数据也能实时维护。watchEffect 让数据变化时脚本内容自动更新——比如文章标题异步加载完,JSON-LD 跟着刷新。
一个工具,两条路径:构建期静态注入给蜘蛛,运行期动态注入给浏览器。两者互补,覆盖了"静态页 + 动态页"的全部场景。
这两条路径还有一个共同点:注入的都是页面真实要展示的内容,只是形式不同。别为了"看起来有结构化数据"而硬造一套用户看不到的假 schema——那跟预渲染正文越线是同一个性质的错误。
写到这里,得停下来面对一个绕不开的问题:预渲染往 #app 里塞的正文,用户是看不见的(Vue 挂载后 #app 内容被替换),但蜘蛛能看见。这在 SEO 圈里有个词叫 cloaking(伪装)——给搜索引擎看一套、给用户看另一套,是 Google 明令禁止的作弊。
这个方案算不算 cloaking?诚实地说,它在灰色地带。但这里有一个关键区别,代码注释里也强调过:注入的内容是真实的——静态页注入的是站点的真实描述,文章页注入的是文章的完整正文。用户和蜘蛛看到的内容本质上是一致的,只是"展示方式"不同:用户看的是 Vue 渲染的交互界面,蜘蛛读的是预渲染的静态版本。这不是"给蜘蛛看假内容",而是"用两种方式展示同一份真内容"。
为什么敢这么做?因为搜索引擎的意图是"给用户提供有价值的内容",而这里的正文就是用户真正会读到的内容。它的风险在于机械判断规则可能误伤,所以注释里特别注明"注入内容为站点的真实描述,非欺骗性内容"——这是给自己留的底,也是给搜索引擎看的诚意。
这套权衡没有完美答案。上 SSR 可以彻底绕开这个争议,但代价是要维护一套服务端渲染架构。预渲染是在"收录效果"和"工程复杂度"之间的一个务实平衡,代价就是必须诚实面对这个灰色地带。
说到底,搜索引擎的规则是写给"想钻空子的人"的,而你的目的是"让真内容被读到"。只要注入的是真实、完整、和用户看到的一致的内容,这个方案就是在灰色地带里站得最直的那种。我给自己定了一条底线:凡是注入爬虫的内容,必须同时是用户能看到的内容。这条底线守住了,预渲染就是工具;守不住,就是作弊。工具和作弊的差别,不在技术,在用心。
这条 SEO 链路的坑,我回头分成三组——标签的冲突、多语言和路径、以及构建流程的"记性"。分组看,每组的问题根源其实是同一个。
标签的冲突。 模板里自带固定的 canonical 和 hreflang,预渲染每页又追加一套,结果每页出现两个 canonical、两个 og:title——搜索引擎遇到冲突的 canonical 只信先读到的那个,等于白做。预渲染和 SPA 的 title 不一致也是同类:预渲染用文章标题、运行时用另一种拼接,爬虫看到一个标题、用户看到另一个。修法都是"来源统一"——预渲染时先把模板自带的标签正则删掉再追加唯一一套,标题则都从同一份页面标题表里取。标签和标题,一定要"一套来源",两套必错一处。
多语言和路径。 JSON-LD 的 Organization description 模板里写死中文,英文页面不替换就成了"中英串味";预渲染的 HTML 放在三层深的目录,图片用绝对路径,本地双击验证全破图。修法是按语言替换描述、路径改相对 ../../../。这类坑是预渲染特有的——你生成的是"放在任意深度的静态文件",就必须想清楚从任意深度访问对不对。
构建流程的"记性"。 extractContentFromSource 的正则一开始没处理反引号转义,文章一有代码块内容就被截断——后来改成能跨转义的正则才稳。动态 sitemap 没限速,把后端接口打得嗷嗷叫——加了 requestDelay 和 maxPerType 才消停。最没技术含量但最坑的是"忘了重新跑预渲染":改了文章标题直接部署,线上还是旧标题。预渲染是构建期动作,改内容必须重跑 build:seo——建议把它写进部署流程,别指望人记得。
把这套方案收起来看,它的核心哲学就四个字:够用且省。
不上 SSR,因为一个内容社区没必要为 SEO 维护整套服务端渲染;不上无头浏览器,因为慢、脆、贵;预渲染只做一次构建期动作,把完整的 HTML 写进 dist,让蜘蛛"抓取即有内容"。它不完美——有那个灰色地带的争议,也有多语言、路径、转义一堆细节要照顾——但它解决了一个 SPA 最要命的问题:你的内容,搜索引擎能读到了。
如果你也在做一个 SPA 站点,正在为收录发愁,这篇文章里这套"构建期预渲染 + 每页唯一 TDK + JSON-LD + sitemap"的组合,是可以直接抄的作业。
抄的时候记住三件事:每页 TDK 必须唯一,否则等于没做;注入的内容必须是真实的,别越线;sitemap 要定时更新,别让它过期。把这三点做到,你的 SPA 在搜索引擎眼里就不再是个空壳了。而那句最重要的话,我放在最后:最后想说的是一句心态层面的:SEO 不是玄学,也不是黑魔法,它是一连串"让真内容更容易被读懂"的工程动作。预渲染、唯一 TDK、结构化数据、sitemap,本质都是同一件事——降低搜索引擎理解你站点的成本。成本降得越低,你的内容被读到的概率就越高。就这么简单。
而那句最重要的话,我放在最后:**别让蜘蛛看到空壳,把你真正写的东西,给它看。**内容为王这句话在 SEO 里被说烂了,但预渲染让我第一次真正体会到了它的分量——先把内容好好写出来,剩下的交给工程去送信。
这套 SEO 方案写到这,该交代的都交代了。最后想提醒一句:它只解决"蜘蛛能读到",不解决"内容值得读"。预渲染、唯一 TDK、结构化数据、sitemap,都是把"好内容"递出去的手,但手再好,也得先有东西可递。SEO 的根,永远在内容本身——工程把路修好,走不走得远,看的是你写了什么。