前端 devs 必看:SPA 页面 SEO 难搞?3 招让百度谷歌秒收录,流量直接起飞
前端 devs 必看:SPA 页面 SEO 难搞?3 招让百度谷歌秒收录,流量直接起飞
- 前端 devs 必看:SPA 页面 SEO 难搞?3 招让百度谷歌秒收录,流量直接起飞
前端 devs 必看:SPA 页面 SEO 难搞?3 招让百度谷歌秒收录,流量直接起飞
咱先唠唠为啥你写的代码明明美如画,搜索引擎却是个"瞎子"
说实话,这事儿我踩过太多次坑了。记得去年接了个项目,客户是个做高端定制家具的,页面做得那叫一个炫酷——滚动视差、3D 模型展示、丝滑的页面切换动画,Vue3 + Three.js 整得明明白白。本地跑起来我自己都感动,觉得这波稳了,客户肯定满意。
结果上线两周,客户打电话过来:“为啥百度搜我们公司名,翻到第三页都找不到我们官网?”
我当时就懵了。赶紧打开百度搜索,输入 site:他们域名,好家伙,就收录了一个首页,还是个空白标题。再点进去看缓存,百度蜘蛛看到的页面长这样:
<!DOCTYPEhtml><html><head><title>Loading...</title></head><body><divid="app"></div><scriptsrc="/app.js"></script></body></html>看到没?就是一个空 div。我那些花里胡哨的家具展示、产品介绍、联系方式,全在 JS 文件里等着被执行呢。但百度蜘蛛它老人家可没耐心等你的 Vue 实例挂载完,抓到这个空壳子就走了,心想着"这网站咋啥也没有",然后给你打个低分,甚至不收录。
这就是 SPA(单页应用)的祖传难题。你说 SPA 香不香?当然香啊!用户体验丝滑,切换页面不用刷新,状态管理方便,开发效率杠杠的。但搜索引擎它就是个"直男",你给它看啥它就认啥,它才不管你的 JS 有多牛掰。
有个特别经典的尴尬瞬间,我估计很多前端都遇到过:你在公司内网或者本地开发环境,页面完美运行,SEO 工具检测也一切正常。一部署到生产环境,用了 CDN、加了 gzip、上了 HTTPS,心想这波总该起飞了吧?结果搜索引擎收录量不升反降。你抓耳挠腮想了一晚上,最后发现是 robots.txt 里写了个 Disallow: /,或者是测试环境遗留的 <meta name="robots" content="noindex"> 没删掉。
还有一种更隐蔽的情况:你的 JS 代码里有个未捕获的异常,导致整个应用挂载失败。用户浏览器可能容错性好,勉强能看;但搜索引擎的爬虫遇到这种错误,直接判定你这个页面不可用。我就见过一个项目,因为引用了一个第三方的地图 API,那个 API 在某些地区被墙了,导致页面初始化时报错,结果谷歌收录量直接腰斩。
所以啊,都 2026 年了,别再天真地以为搜索引擎会像用户一样,耐心地等你那几百 KB 的 JS 下载完、解析完、执行完,然后再去分析渲染出来的 DOM 结构。Google 确实进化了不少,它的爬虫现在能执行 JavaScript 了,但也是有条件的——它不会无限期等你,而且执行环境跟真实浏览器还是有差异。至于百度、搜狗、360 这些,虽然也在进步,但说实话,它们的爬虫很多还停留在"看 HTML 源码"的阶段。
这就引出了一个灵魂拷问:你辛辛苦苦写的 React、Vue、Angular 代码,在搜索引擎眼里可能就是个"盲盒",里面装的是啥它根本不知道。那咋办?往下看,我给你整几招硬核的。
扒一扒搜索引擎到底是怎么"看"你的网站的
要想解决问题,得先理解敌人(不对,是理解朋友)的工作原理。搜索引擎的工作流程其实挺像你去餐厅吃饭的过程:
第一步:抓取(Crawling)
搜索引擎会派出一堆爬虫(也叫蜘蛛、机器人),它们就像一群饿了的顾客,顺着链接到处爬。你网站上的每个链接都是一道菜,爬虫看到链接就点进去看看。但这里有个问题:如果这道菜是用 JS 动态生成的菜单,而爬虫看不懂 JS,那它就只能看到个空盘子。
第二步:索引(Indexing)
爬虫把抓到的内容带回去,搜索引擎会对这些内容进行分析、提取关键词、建立索引。这个过程就像餐厅把每道菜的食材、口味、价格录入系统。如果你的页面内容是 JS 执行后才出现的,而爬虫没执行 JS,那录入系统的信息就是空的或者残缺的。
第三步:排名(Ranking)
当用户搜索某个关键词时,搜索引擎会从索引库里找出相关的页面,按照一堆复杂的算法排序。这里面的因素包括内容相关性、网站权威性、用户体验等等。Core Web Vitals(CWV,核心网页指标)就是这里面的重要一环,后面会详细说。
现在说说渲染的事儿。传统的爬虫只解析 HTML,不看 JS。但 Google 在 2018 年左右开始搞"动态渲染"(Dynamic Rendering),后来又进化到"移动优先索引"和"执行 JavaScript"。简单来说,Google 的爬虫现在有两套模式:
- 基础模式:直接看 HTML 源码,不执行 JS。这是为了效率,毕竟互联网上的页面数以万亿计,每个页面都跑一遍 JS 太费时间了。
- 高级模式:如果基础模式发现页面需要 JS 才能展示内容,它会把这个页面丢进一个基于 Chrome 的渲染引擎里执行 JS,然后再分析渲染后的结果。
听起来很美好对吧?但这里有三个大坑:
坑一:渲染队列排队
Google 不会立即执行你的 JS,而是把页面放进一个渲染队列里,可能几天甚至几周后才轮到你的页面。这意味着新发布的内容不会马上被收录,对于新闻类、时效性强的内容,这简直是灾难。
坑二:资源限制
即使 Google 执行了你的 JS,它也不会像真实用户那样无限制地等待。如果你的 JS 文件太大、执行时间太长,或者依赖的外部资源加载失败,Google 可能执行到一半就放弃了,结果还是看不到完整内容。
坑三:百度等其他搜索引擎
Google 确实先进,但国内流量大户百度呢?百度也在进步,推出了"移动适配"、“MIP”(虽然 MIP 已经凉了)、"小程序"等等,但它的爬虫对 JS 的支持还是相对有限。特别是对于一些复杂的交互逻辑、异步加载的数据,百度蜘蛛很可能抓不到。至于搜狗、360、神马搜索这些,那就更"传统"了。
再说说 Core Web Vitals(CWV),这是 Google 2021 年开始大力推的一套衡量用户体验的指标,现在已经是排名因素之一了。CWV 主要包含三个指标:
- LCP(Largest Contentful Paint):最大内容绘制时间,衡量页面主要内容加载速度。理想情况下应该在 2.5 秒内。
- FID(First Input Delay):首次输入延迟,衡量页面交互响应速度。理想情况下应该小于 100 毫秒。现在逐渐被 INP(Interaction to Next Paint)取代。
- CLS(Cumulative Layout Shift):累积布局偏移,衡量页面视觉稳定性。理想情况下应该小于 0.1。
对于 SPA 来说,这三个指标往往是重灾区。因为 JS 执行需要时间,LCP 很容易超标;如果代码写得不好,FID 也会很高;而动态加载的内容如果处理不当,CLS 就会爆表(比如图片加载后把下面的内容挤下去)。
所以啊,做 SEO 不能光盯着关键词密度、标题标签这些传统的玩意儿,页面性能同样重要。你内容再好,加载慢得像蜗牛,搜索引擎也会觉得你用户体验差,排名自然就上不去。
给 SPA 动手术的几种硬核方案,总有一款适合你
既然知道了问题在哪,就得对症下药。下面这几招,是我这些年摸爬滚打总结出来的,从简单粗暴到精细复杂,总有一款适合你的项目。
第一招:SSR(服务端渲染)—— 虽然麻烦,但真香
SSR 大概是解决 SPA SEO 问题最正统的方案了。原理很简单:以前浏览器请求页面,服务器丢给它一个空 HTML 和一堆 JS,让浏览器自己去渲染;现在服务器先把 JS 执行一遍,生成完整的 HTML 字符串,再发给浏览器。浏览器收到的是已经填充好内容的页面,搜索引擎爬虫来了也能直接看到东西。
以 Vue 为例,如果你用 Nuxt.js,开启 SSR 简直不要太简单:
// nuxt.config.tsexportdefaultdefineNuxtConfig({// 开启 SSR,默认为 true,但如果你之前关了,现在打开ssr:true,// 一些优化配置nitro:{// 启用压缩compressPublicAssets:true,// 路由规则,可以针对特定页面设置缓存routeRules:{// 首页缓存 1 小时'/':{cache:{maxAge:60*60}},// 商品详情页,根据参数缓存'/product/**':{cache:{maxAge:60*5}},// 5 分钟缓存,因为价格可能变动// 用户中心不做缓存,因为每个用户看到的不一样'/user/**':{cache:false}}},// SEO 相关配置app:{head:{charset:'utf-8',viewport:'width=device-width, initial-scale=1',titleTemplate:'%s - 我的网站',meta:[{name:'description',content:'这是一个超级棒的网站'}]}}})在页面组件里,你可以这样获取数据并确保服务端能渲染:
<!-- pages/product/[id].vue --> <template> <div> <!-- 这里的内容会在服务端渲染时就有 --> <h1>{{ product.name }}</h1> <p>{{ product.description }}</p> <img :src="product.image" :alt="product.name" /> <!-- 价格信息,注意这里用了客户端 hydration 处理 --> <div> <span v-if="isClient">¥{{ product.price }}</span> <span v-else>加载中...</span> </div> </div> </template> <script setup> // 这个 composable 会在服务端和客户端都执行 // 但 Nuxt 会确保服务端渲染时数据已经准备好 const { params } = useRoute() const { data: product, error } = await useFetch(`/api/products/${params.id}`, { // 关键配置:确保服务端获取数据 server: true, // 客户端 hydration 时复用服务端数据,不再请求 default: () => ({}) }) // 处理错误 if (error.value) { throw createError({ statusCode: 404, message: '商品不存在' }) } // SEO 元信息,这些会在服务端注入到 HTML head 中 useHead({ title: product.value?.name || '商品详情', meta: [ { name: 'description', content: product.value?.description?.slice(0, 200) }, // Open Graph 标签,分享到社交媒体时用到 { property: 'og:title', content: product.value?.name }, { property: 'og:image', content: product.value?.image }, { property: 'og:type', content: 'product' } ] }) // 标记客户端挂载完成 const isClient = ref(false) onMounted(() => { isClient.value = true }) </script> React 这边用 Next.js 也是类似的逻辑:
// app/product/[id]/page.js (Next.js 13+ App Router)// 这个组件默认就是服务端组件,直接查数据库或调 API 都行exportdefaultasyncfunctionProductPage({ params }){// 直接在服务端获取数据const product =awaitgetProduct(params.id)if(!product){notFound()// 404 页面}return(<div><h1>{product.name}</h1><p>{product.description}</p>{/* 图片组件自带优化,生成正确的 srcset 和 loading="lazy" */}<Image src={product.image} alt={product.name} width={800} height={600} priority={true}// 首屏图片优先加载,优化 LCP/>{/* 客户端交互组件用 'use client' 标记 */}<AddToCartButton productId={product.id}/></div>)}// 生成元数据,支持异步exportasyncfunctiongenerateMetadata({ params }){const product =awaitgetProduct(params.id)return{title: product?.name,description: product?.description?.slice(0,200),openGraph:{title: product?.name,images:[product?.image],},}}// 静态生成参数,如果你知道所有商品 ID,可以预生成页面exportasyncfunctiongenerateStaticParams(){const products =awaitgetAllProductIds()return products.map((id)=>({ id }))}SSR 的好处是显而易见的:首屏速度快、SEO 友好、社交媒体分享时能抓到正确的标题和图片。但代价也是有的:
服务器压力山大。以前你前端静态文件往 CDN 一扔,服务器基本没啥压力;现在每个请求都要在服务器上跑一遍 JS,渲染 HTML,CPU 和内存消耗直线上升。用户量大的时候,Node 服务很容易成为瓶颈。
水合(Hydration)问题。服务端渲染的 HTML 到了浏览器里,还要再跑一遍 JS,把静态 HTML 变成可交互的页面。这个过程如果处理不好,会出现"闪烁"或者"状态不一致"的问题。比如服务端渲染时用户未登录,显示的是"登录按钮";但客户端 JS 执行时发现用户有 cookie,应该显示"个人中心",这时候页面就会闪一下。
开发复杂度增加。你要考虑哪些代码只在服务端跑、哪些只在客户端跑、哪些两边都要跑。用到了浏览器 API(比如 window、document)的代码,在服务端执行时会报错,得用 typeof window !== 'undefined' 来判断,或者用框架提供的 onMounted 这类钩子。
但说实话,对于企业级应用、电商网站、内容平台这些对 SEO 要求高的场景,SSR 还是最优解。Nuxt 和 Next 这两个框架经过这么多年的迭代,生态已经很成熟了,该踩的坑前人都踩过了。
第二招:SSG(静态站点生成)—— 适合内容不咋变的场景
如果你的网站内容更新不频繁,比如个人博客、文档站点、企业官网,那 SSG 可能是更好的选择。原理是在构建时就把所有页面渲染成静态 HTML,部署时直接扔 CDN 上,访问速度极快,服务器成本几乎为零。
还是以 Nuxt 为例:
// nuxt.config.tsexportdefaultdefineNuxtConfig({// 改为静态生成模式ssr:true,// 或者 false,取决于你是否需要客户端渲染nitro:{// 启用静态生成static:true,// 预渲染路由prerender:{// 抓取所有链接并生成静态页面crawlLinks:true,// 指定要预渲染的路由routes:['/','/about','/blog',// 动态路由需要指定参数'/blog/hello-world','/blog/vue3-tips',]}}})对于动态路由,你需要告诉 Nuxt 有哪些页面要生成:
// server/api/sitemap.get.ts// 生成 sitemap,同时给 Nuxt 提供预渲染路由列表exportdefaultdefineEventHandler(async()=>{const posts =awaitfetchPosts()// 从 CMS 或数据库获取文章列表const routes = posts.map(post=>`/blog/${post.slug}`)// 返回 sitemap XMLreturn`<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> ${routes.map(route=>` <url> <loc>https://mysite.com${route}</loc> <lastmod>${newDate().toISOString()}</lastmod> </url> `).join('')} </urlset>`})构建时 Nuxt 会调用这个接口,获取所有路由,然后逐个渲染成静态 HTML。生成的文件长这样:
dist/ ├── index.html # 首页 ├── about/ │ └── index.html # 关于页面 ├── blog/ │ ├── index.html # 博客列表 │ ├── hello-world/ │ │ └── index.html # 文章详情 │ └── vue3-tips/ │ └── index.html └── _nuxt/ # JS 和 CSS 资源 每个 HTML 文件都是完整的、可独立访问的页面,搜索引擎爬虫来了直接就能索引。
Next.js 的 SSG 更简单,App Router 下默认就是静态生成,除非你用了动态数据获取方法:
// 这个页面是静态生成的,构建时就确定了内容exportdefaultfunctionAboutPage(){return<div>关于我们...</div>}// 如果你需要动态数据,但想在构建时获取,用 generateStaticParams// 或者标记为动态渲染exportconst dynamic ='force-static'// 强制静态生成SSG 的优点是速度极快、成本低、SEO 完美。缺点也很明显:
构建时间随页面数量爆炸。如果你有几千个页面,每次构建都要渲染几千次,CI/CD pipeline 跑个十几二十分钟是常态。内容一多,构建时间呈线性增长,等得花儿都谢了。
内容更新麻烦。每次改个标点符号都要重新构建整个站点,然后重新部署。对于大型站点,这意味着几分钟甚至几十分钟的延迟。
动态内容支持有限。如果页面内容依赖用户状态、实时数据,SSG 就不太适用了。比如你不可能为每个用户生成一个专属的"个人中心"静态页面。
第三招:ISR(增量静态再生)—— 动态和静态的"混血儿"
ISR 是 Next.js 首创的概念,Nuxt 后来也跟进了,叫"混合渲染"或"增量静态生成"。它的思路是:页面先静态生成,部署后如果有用户访问了过期页面,后台自动重新生成新的静态页面,实现"静态页面的动态更新"。
// Next.js 示例// app/blog/[slug]/page.jsexportconst revalidate =60// 60 秒后重新验证,后台自动更新exportdefaultasyncfunctionBlogPost({ params }){const post =awaitgetPost(params.slug)return(<article><h1>{post.title}</h1><div dangerouslySetInnerHTML={{__html: post.content }}/></article>)}这里的 revalidate = 60 意思是:这个页面会静态生成,但每 60 秒,如果有新请求进来,Next.js 会在后台重新获取数据并生成新的静态页面,下次访问的用户就能看到最新内容了。
Nuxt 的实现稍微复杂点,需要配合 Nitro 的缓存策略:
// nuxt.config.tsexportdefaultdefineNuxtConfig({routeRules:{'/blog/**':{isr:true,// 启用 ISR// 或者指定过期时间cache:{maxAge:60,// 缓存 60 秒staleMaxAge:60*60,// 过期后 1 小时内仍返回旧内容,同时后台更新}}}})ISR 的好处是兼顾了静态页面的速度和动态内容的实时性。但坑也不少:
缓存策略难调。过期时间设太短,服务器压力大;设太长,内容更新不及时。得根据业务场景反复试验。
首次访问延迟。如果页面过期了,第一个访问的用户会触发重新生成,等待时间会比较长(虽然后续用户能拿到缓存版本)。
一致性难题。如果页面之间有依赖关系(比如文章列表页和文章详情页),一个更新了另一个没更新,就会出现数据不一致。
第四招:预渲染(Prerendering)—— 老项目的救命稻草
如果你接手的是一个祖传老项目,技术栈可能是 Vue2 + Vue Router,甚至 jQuery,重构成本太高,那可以考虑预渲染方案。
原理是用一个无头浏览器(比如 Puppeteer、Playwright)在构建时或者请求时,把你的 SPA 跑一遍,抓取渲染后的 HTML,然后提供给爬虫。
最简单的方案是用 prerender-spa-plugin(虽然这个插件已经不怎么维护了,但思路可以参考):
// vue.config.js (Vue CLI 项目)const PrerenderSPAPlugin =require('prerender-spa-plugin')const Renderer = PrerenderSPAPlugin.PuppeteerRenderer const path =require('path') module.exports ={configureWebpack:()=>{if(process.env.NODE_ENV!=='production')returnreturn{plugins:[newPrerenderSPAPlugin({// 输出目录staticDir: path.join(__dirname,'dist'),// 要预渲染的路由routes:['/','/about','/contact','/products'],renderer:newRenderer({// 等页面渲染完成的事件renderAfterDocumentEvent:'render-event',// 或者等某个元素出现// renderAfterElementExists: '#app',// 注入一些代码,比如隐藏某些只在客户端显示的弹窗inject:{foo:'bar'},// 无头浏览器配置headless:true,}),// 后置处理,比如替换某些内容postProcess(renderedRoute){// 去掉 script 标签(可选,如果你想纯静态展示)// renderedRoute.html = renderedRoute.html.replace(/<script[^>]*>.*?<\/script>/g, '')return renderedRoute }})]}}}在 Vue 应用里,你需要在挂载完成后触发事件:
// main.jsnewVue({ router, store,render:h=>h(App),mounted(){// 告诉 prerender 页面已经渲染完成 document.dispatchEvent(newEvent('render-event'))}}).$mount('#app')更灵活的方案是用中间件动态预渲染。比如用 Express + Puppeteer:
// server.jsconst express =require('express')const puppeteer =require('puppeteer')const app =express()const browserPromise = puppeteer.launch({headless:'new',args:['--no-sandbox','--disable-setuid-sandbox']})// 中间件:检测爬虫请求,返回预渲染内容 app.use(async(req, res, next)=>{const userAgent = req.headers['user-agent']||''// 检测是否是爬虫const isCrawler =/bot|crawler|spider|crawling/i.test(userAgent)// 只对爬虫预渲染,普通用户走正常流程if(!isCrawler || req.path.startsWith('/api')){returnnext()}try{const browser =await browserPromise const page =await browser.newPage()// 设置 UA 模拟真实浏览器,有些网站会检查await page.setUserAgent('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)')// 访问页面,等待网络空闲await page.goto(`http://localhost:3000${req.url}`,{waitUntil:'networkidle0',// 等所有网络请求完成timeout:30000})// 再等一秒,确保 JS 执行完毕await page.waitForTimeout(1000)// 获取 HTMLconst html =await page.content()await page.close()// 返回渲染后的 HTML res.send(html)}catch(error){ console.error('预渲染失败:', error)// 失败时回退到正常流程next()}})// 静态文件服务 app.use(express.static('dist'))// 所有路由都返回 index.html(SPA 模式) app.get('*',(req, res)=>{ res.sendFile(path.join(__dirname,'dist','index.html'))}) app.listen(3000,()=>{ console.log('Server running on port 3000')})这个方案的好处是无需改动现有代码,对老项目特别友好。缺点是:
服务器资源消耗大。每个爬虫请求都要开一个浏览器实例,跑一遍你的应用,内存和 CPU 占用都很高。并发一高服务器容易挂。
延迟问题。爬虫请求要等页面完全渲染,响应时间可能好几秒,影响爬虫的抓取效率(爬虫不会无限等待)。
稳定性问题。Puppeteer 偶尔会抽风,遇到复杂的 JS 或者内存泄漏,浏览器进程可能崩溃。
所以这种方案更适合作为过渡,或者流量不大的站点。如果流量大,还是得考虑 SSR 或 SSG。
别光听吹牛,这些方案的坑你也得知道
上面说的几种方案听起来都很美好,但实际操作起来,坑比想象中多。我给你列几个我踩过的,你提前有个心理准备。
SSR 的服务器压力:Node 挂了全站白板
前面说过,SSR 把渲染压力转移到了服务器。如果你的应用很复杂,每个请求都要在服务端跑一遍完整的 Vue/React 应用,内存占用是很可观的。
我曾经维护过一个电商 SSR 项目,高峰期 QPS 到 2000 左右,8 核 16G 的服务器直接被打满,响应时间从 200ms 飙升到 5 秒以上。后来不得不加服务器、上 Redis 缓存、优化代码,折腾了好一阵子。
更惨的是,如果 Node 服务因为内存泄漏或者未捕获的异常挂了,整个网站就不可用了。以前静态部署时,CDN 还能扛一扛;现在 SSR 模式下,服务器就是单点故障。
解决方案:
- 做好错误边界处理,组件报错不能导致整个页面 500
- 用 PM2 或者 Docker 做进程守护,挂了自动重启
- 上负载均衡,多开几个实例
- 缓存策略做好,能缓存的尽量缓存,减少重复渲染
SSG 的构建时间:几千个页面等到天荒地老
有个朋友做内容聚合站,每天从各个平台抓取几千篇文章,用 SSG 生成静态页面。一开始还好,后来文章数量到了 5 万篇,每次构建要 2 个多小时,CI/CD 跑完天都黑了。
而且构建过程中如果有一篇文章数据处理出错,整个构建就失败了,得从头再来。这种痛苦,经历过的人都懂。
解决方案:
- 增量构建:只重新构建有变动的页面,而不是全量构建
- 分布式构建:把页面分批,用多台机器并行构建
- 考虑 ISR 或者 SSR,放弃纯 SSG
动态路由的缓存策略:比解数学题还难
电商网站的商品详情页,URL 可能是 /product/12345,其中 12345 是商品 ID。这个 ID 可能有几十万个,你不可能每个都预渲染。
如果用 SSR,缓存策略怎么设?按 ID 缓存?那缓存键会爆炸。按类目缓存?不同商品可能属于多个类目。按时间缓存?价格变动了怎么办?
还有用户登录态的问题。页面头部显示"欢迎,XXX",这个 XXX 每个用户都不一样,你怎么缓存?
解决方案:
- 页面拆分成"静态部分"和"动态部分"。静态部分(商品信息)缓存,动态部分(用户信息)客户端渲染
- 用 Edge Side Includes (ESI) 或者 Ajax 分块加载
- 或者干脆不做缓存,靠服务器硬扛(有钱任性)
成本问题:老板觉得你在浪费钱
加了 SSR 服务器,每个月云费用多了几千块;用了预渲染服务,又要买几台高配机器。老板一看账单,问你:“以前不是几百块就搞定了吗?”
这时候你得解释:SEO 流量上来了,转化多了,这钱能赚回来。但老板可能不信,或者短期内看不到效果。
解决方案:
- 先用低成本方案(比如预渲染)验证 SEO 效果,有效果再上 SSR
- 监控数据,把 SEO 流量、收录量、排名的变化量化,给老板看 ROI
- 考虑 Serverless 方案(Vercel、Netlify、Cloudflare Pages),按量付费,成本低
真实搬砖场景里的那些"骚操作"
理论说完了,来点实战的。这些是我这些年积累的一些具体场景解决方案,你可以直接抄作业。
电商列表页的分页 SEO:别让爬虫在"下一页"迷路
传统的分页是 /list?page=2,但这种 URL 对 SEO 不友好,搜索引擎可能只收录第一页。更好的做法是用"虚拟分页"或者"加载更多"结合 History API,但保留独立的页面 URL。
// 商品列表页组件<template><div><!-- 筛选条件,用 URL query 参数,方便搜索引擎理解 --><div class="filters"><select v-model="selectedCategory" @change="updateFilter"><option value="">全部分类</option><option v-for="cat in categories":key="cat.id":value="cat.id">{{ cat.name }}</option></select><select v-model="sortBy" @change="updateFilter"><option value="default">默认排序</option><option value="price-asc">价格从低到高</option><option value="price-desc">价格从高到低</option><option value="sales">销量优先</option></select></div><!-- 商品列表 --><div class="product-list"><article v-for="product in products":key="product.id"class="product-card"><a :href="`/product/${product.id}`"><img :src="product.image":alt="product.name" loading="lazy" width="300" height="300"/><h3>{{ product.name }}</h3><p class="price">¥{{ product.price }}</p></a></article></div><!-- 分页:同时支持传统分页和加载更多 --><nav v-if="totalPages > 1"class="pagination" aria-label="分页导航"><!-- 上一页 --><NuxtLink v-if="currentPage > 1":to="getPageUrl(currentPage - 1)"class="prev"> 上一页 </NuxtLink><!-- 页码:只显示当前页附近的,避免链接过多 --><NuxtLink v-for="page in visiblePages":key="page":to="getPageUrl(page)":class="{ active: page === currentPage }">{{ page }}</NuxtLink><!-- 下一页 --><NuxtLink v-if="currentPage < totalPages":to="getPageUrl(currentPage + 1)"class="next"> 下一页 </NuxtLink></nav><!-- 加载更多按钮(用户体验更好,但 SEO 靠上面的分页链接) --><button v-if="hasMore" @click="loadMore":disabled="loading"class="load-more">{{ loading ?'加载中...':'加载更多'}}</button></div></template><script setup>const route =useRoute()const router =useRouter()// 从 URL 获取参数,确保刷新后状态不变,也方便搜索引擎抓取const currentPage =computed(()=>parseInt(route.query.page)||1)const selectedCategory =computed(()=> route.query.category ||'')const sortBy =computed(()=> route.query.sort ||'default')// 构建带参数的 URLconstgetPageUrl=(page)=>{const query ={...route.query, page }if(page ===1)delete query.page // 第一页去掉 page 参数,避免重复内容return{path: route.path, query }}// 更新筛选条件时更新 URL,但不刷新页面constupdateFilter=()=>{ router.push({query:{...route.query,category: selectedCategory.value ||undefined,sort: sortBy.value ==='default'?undefined: sortBy.value,page:1// 切换筛选条件回到第一页}})}// 获取数据const{data: listData, refresh }=awaituseFetch('/api/products',{query:computed(()=>({page: currentPage.value,category: selectedCategory.value,sort: sortBy.value })),watch:[currentPage, selectedCategory, sortBy]// 参数变化自动重新获取})// SEO:每个分页都有独立的标题和描述,避免重复内容useHead(()=>({title: currentPage.value >1?`商品列表 - 第${currentPage.value}页`:'商品列表 - 全场包邮',meta:[{name:'description',content: selectedCategory.value ?`${selectedCategory.value}类商品,第${currentPage.value}页`:`精选商品,共${listData.value?.total ||0}件,支持多种排序方式`}],// 分页的 prev/next 标签,帮助搜索引擎理解页面关系link:[ currentPage.value >1&&{rel:'prev',href:`https://mysite.com/list?page=${currentPage.value -1}`}, currentPage.value < totalPages.value &&{rel:'next',href:`https://mysite.com/list?page=${currentPage.value +1}`}].filter(Boolean)}))// 加载更多(客户端行为,不影响 SEO)constloadMore=async()=>{// 实现逻辑...}</script>关键点:
- 每个分页都有独立的 URL 和标题,避免重复内容
- 使用
rel="prev"和rel="next"帮助搜索引擎理解分页关系 - 筛选条件用 URL 参数,方便分享和收录
- 图片加
loading="lazy"和明确的宽高,优化 CLS
后台管理系统要不要做 SEO?
答案是:大部分不需要,但登录页和文档页需要做。
后台管理系统通常是给内部人员用的,不需要被搜索引擎收录。但你要确保:
<!-- 在不需要 SEO 的页面加上这个,告诉爬虫别收录 --><metaname="robots"content="noindex, nofollow">或者在 robots.txt 里:
User-agent: * Disallow: /admin/ Disallow: /api/ Disallow: /user/ 但有些页面是需要 SEO 的,比如系统的"帮助文档"、“API 文档”、“登录页”(方便用户搜索"XX系统登录")。这时候可以用条件判断:
// router.jsconst routes =[{path:'/login',component: LoginPage,meta:{seo:true,title:'用户登录 - XX管理系统',description:'登录XX管理系统,享受高效办公体验'}},{path:'/dashboard',component: Dashboard,meta:{seo:false}// 不需要 SEO},{path:'/docs/:id',component: DocPage,meta:{seo:true,// 动态标题,在组件里设置}}]// 路由守卫中统一处理 SEO router.beforeEach((to, from, next)=>{if(to.meta.seo ===false){// 添加 noindex 标签const meta = document.createElement('meta') meta.name ='robots' meta.content ='noindex, nofollow' document.head.appendChild(meta)}else{// 设置标题和描述if(to.meta.title){ document.title = to.meta.title }// 移除之前可能添加的 noindexconst existingNoindex = document.querySelector('meta[name="robots"][content="noindex"]')if(existingNoindex){ existingNoindex.remove()}}next()})Meta 标签、Open Graph 和结构化数据:让搜索结果精装修
基本的 title 和 description 就不说了,说点高级的。
Open Graph 协议:让链接分享到微信、微博、Facebook、Twitter 时显示卡片:
<!-- 基础 OG 标签 --><metaproperty="og:title"content="文章标题"><metaproperty="og:description"content="文章摘要..."><metaproperty="og:image"content="https://mysite.com/cover.jpg"><metaproperty="og:url"content="https://mysite.com/article/123"><metaproperty="og:type"content="article"><metaproperty="og:site_name"content="我的网站"><!-- 文章特有的 --><metaproperty="article:published_time"content="2026-02-25T10:00:00+08:00"><metaproperty="article:author"content="作者名"><metaproperty="article:tag"content="前端,SEO,Vue"><!-- Twitter 卡片(Twitter 有自己的一套,但也会读 OG) --><metaname="twitter:card"content="summary_large_image"><metaname="twitter:site"content="@mytwitter">结构化数据(Schema.org):让搜索引擎理解页面内容的语义,可能在搜索结果中显示富媒体片段(Rich Snippets)。
<!-- 商品页面的结构化数据 --><scripttype="application/ld+json">{"@context":"https://schema.org","@type":"Product","name":"iPhone 16 Pro","image":["https://example.com/iphone16-1.jpg","https://example.com/iphone16-2.jpg"],"description":"最新款 iPhone,搭载 A18 芯片...","sku":"IPHONE16PRO-256GB","brand":{"@type":"Brand","name":"Apple"},"offers":{"@type":"Offer","url":"https://example.com/product/iphone16pro","priceCurrency":"CNY","price":"8999.00","priceValidUntil":"2026-12-31","availability":"https://schema.org/InStock","seller":{"@type":"Organization","name":"XX数码专营店"}},"aggregateRating":{"@type":"AggregateRating","ratingValue":"4.8","reviewCount":"1250"}}</script><!-- 面包屑导航的结构化数据 --><scripttype="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"首页","item":"https://example.com/"},{"@type":"ListItem","position":2,"name":"手机","item":"https://example.com/category/phone"},{"@type":"ListItem","position":3,"name":"iPhone 16 Pro"}]}</script>在 Vue/Nuxt 中动态注入:
// composables/useSchemaOrg.jsexportconstuseProductSchema=(product)=>{const schema ={'@context':'https://schema.org','@type':'Product',name: product.name,image: product.images,description: product.description,sku: product.sku,offers:{'@type':'Offer',price: product.price,priceCurrency:'CNY',availability: product.stock >0?'https://schema.org/InStock':'https://schema.org/OutOfStock'}}useHead({script:[{type:'application/ld+json',children:JSON.stringify(schema)}]})}// 在页面中使用const product =awaitfetchProduct()useProductSchema(product)Sitemap 和 robots.txt:跟搜索引擎套近乎
sitemap.xml 告诉搜索引擎你有哪些页面,robots.txt 告诉它哪些能抓哪些不能抓。
<!-- public/sitemap.xml --><?xml version="1.0" encoding="UTF-8"?><urlsetxmlns="http://www.sitemaps.org/schemas/sitemap/0.9"xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"xmlns:xhtml="http://www.w3.org/1999/xhtml"xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"><!-- 首页,优先级最高 --><url><loc>https://mysite.com/</loc><lastmod>2026-02-25</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><!-- 商品列表页 --><url><loc>https://mysite.com/products</loc><lastmod>2026-02-25</lastmod><changefreq>hourly</changefreq><priority>0.9</priority></url><!-- 具体商品页,包含图片信息 --><url><loc>https://mysite.com/product/12345</loc><lastmod>2026-02-24</lastmod><changefreq>weekly</changefreq><priority>0.8</priority><image:image><image:loc>https://mysite.com/images/product-12345.jpg</image:loc><image:title>商品标题</image:title><image:caption>商品详细描述...</image:caption></image:image></url><!-- 文章页,包含多语言版本 --><url><loc>https://mysite.com/blog/how-to-seo</loc><lastmod>2026-02-20</lastmod><changefreq>monthly</changefreq><priority>0.6</priority><xhtml:linkrel="alternate"hreflang="zh-CN"href="https://mysite.com/blog/how-to-seo"/><xhtml:linkrel="alternate"hreflang="en"href="https://mysite.com/en/blog/how-to-seo"/></url></urlset>动态生成 sitemap(Nuxt 示例):
// server/routes/sitemap.xml.get.tsexportdefaultdefineEventHandler(async(event)=>{// 获取所有商品和文章const[products, posts]=await Promise.all([$fetch('/api/products/all'),$fetch('/api/posts/all')])const urls =[{loc:'/',priority:1.0,changefreq:'daily'},{loc:'/products',priority:0.9,changefreq:'hourly'},...products.map(p=>({loc:`/product/${p.id}`,lastmod: p.updatedAt,priority:0.8,changefreq:'weekly'})),...posts.map(p=>({loc:`/blog/${p.slug}`,lastmod: p.updatedAt,priority:0.6,changefreq:'monthly'}))]const sitemap =`<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> ${urls.map(u=>` <url> <loc>https://mysite.com${u.loc}</loc> ${u.lastmod ?`<lastmod>${u.lastmod}</lastmod>`:''}${u.changefreq ?`<changefreq>${u.changefreq}</changefreq>`:''}${u.priority ?`<priority>${u.priority}</priority>`:''} </url>`).join('\n')} </urlset>`setHeader(event,'content-type','application/xml')return sitemap })robots.txt:
User-agent: * Allow: / # 禁止抓取的路径 Disallow: /admin/ Disallow: /api/ Disallow: /user/ Disallow: /checkout/ # 结账流程,抓了也没意义 Disallow: /search?q= # 搜索结果页,避免重复内容 # 爬虫抓取间隔,别爬太猛把我服务器搞挂了 Crawl-delay: 1 # Sitemap 地址 Sitemap: https://mysite.com/sitemap.xml # 针对特定爬虫的规则 User-agent: Baiduspider Crawl-delay: 2 # 百度蜘蛛稍微慢点 User-agent: Googlebot Allow: /special-page/ # 给 Google 开特殊权限 遇到收录问题别慌,按这个路子排查能救命
上线了新功能,发现搜索引擎不收录?别急着改代码,先按这个流程排查:
第一步:用官方工具看报错
Google Search Console(谷歌搜索控制台):
- 进入"覆盖范围"报告,看哪些页面被收录了,哪些被排除了
- 看"抓取统计信息",有没有 5xx 错误
- 用"网址检查"工具,输入具体 URL,看 Google 看到的页面长啥样
百度资源平台:
- 类似的"索引量"和"抓取异常"报告
- 注意百度的"落地页体验"报告,看有没有加载慢或者布局问题
第二步:检查网络请求,确认爬虫拿到了啥
用 curl 模拟爬虫请求:
# 模拟 Googlebotcurl -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"\ -H "Accept: text/html"\ https://your-site.com/page # 模拟百度蜘蛛curl -A "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)"\ https://your-site.com/page # 对比普通浏览器请求curl -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"\ https://your-site.com/page 看看返回的 HTML 里有没有实际内容,还是只有一个 <div></div>。
第三步:检查 robots.txt 和 meta 标签
手滑在 robots.txt 里加了 Disallow: /,或者在测试环境加了 <meta name="robots" content="noindex"> 然后忘删了,这种低级错误我犯过不止一次。
第四步:诊断 JS 执行错误
打开浏览器控制台,看有没有报错。有时候一个未捕获的异常会导致整个应用挂载失败。用 Sentry 或者类似的错误监控工具,看看生产环境有没有 JS 错误。
第五步:检查 Core Web Vitals
用 PageSpeed Insights 或者 Lighthouse 跑一下,看 LCP、CLS 这些指标是否达标。如果加载太慢,搜索引擎可能会降低抓取频率。
几个能让同事喊你"大神"的私藏技巧
懒加载图片别忘了 SEO
<!-- 错误的懒加载 --><imgdata-src="image.jpg"class="lazyload"/><!-- 正确的懒加载 --><imgsrc="placeholder.jpg"data-src="real-image.jpg"alt="图片描述,要详细"width="800"height="600"loading="lazy"class="lazyload"/><!-- 或者用 picture 标签做响应式图片 --><picture><sourcemedia="(min-width: 800px)"srcset="large.jpg"><sourcemedia="(min-width: 400px)"srcset="medium.jpg"><imgsrc="small.jpg"alt="描述"loading="lazy"width="400"height="300"></picture>关键点:
- 一定要有
alt属性,这是图片 SEO 的基础 - 指定
width和height,避免布局偏移(CLS) - 用
loading="lazy"而不是自己写滚动监听,性能更好 - 占位符可以用低质量图片(LQIP)或者纯色背景
路由切换时动态更新 title 和 description
SPA 的路由切换不会刷新页面,所以 title 和 meta 标签不会自动更新。需要手动处理:
// Vue Router 示例import{ watch }from'vue'import{ useRoute }from'vue-router'const route =useRoute()watch(()=> route.meta,(meta)=>{// 更新标题 document.title = meta.title ||'默认标题'// 更新 descriptionlet descTag = document.querySelector('meta[name="description"]')if(!descTag){ descTag = document.createElement('meta') descTag.name ='description' document.head.appendChild(descTag)} descTag.content = meta.description ||'默认描述'// 更新 canonicallet canonicalTag = document.querySelector('link[rel="canonical"]')if(!canonicalTag){ canonicalTag = document.createElement('link') canonicalTag.rel ='canonical' document.head.appendChild(canonicalTag)} canonicalTag.href = window.location.href.split('?')[0]// 去掉 query 参数},{immediate:true})或者用 vue-meta/@vueuse/head 这类库更方便。
Canonical 标签避免重复内容
同一个内容可能有多个 URL:
https://site.com/product/123https://site.com/product/123?ref=homehttps://site.com/product/123?color=red
搜索引擎会认为这是三个不同的页面,导致权重分散。用 canonical 标签告诉搜索引擎哪个是"正统":
<linkrel="canonical"href="https://site.com/product/123"/>在 Nuxt 中:
useHead({link:[{rel:'canonical',href:`https://mysite.com${route.path}`}]})监控 Core Web Vitals
用 Web Vitals 库把数据上报到自己的监控系统:
// 安装:npm install web-vitalsimport{ getCLS, getFID, getFCP, getLCP, getTTFB }from'web-vitals'functionsendToAnalytics(metric){// 发送到 Google Analytics、Sentry 或自己的服务器const body =JSON.stringify(metric)// 用 navigator.sendBeacon 确保数据能发送成功(navigator.sendBeacon && navigator.sendBeacon('/analytics', body))||fetch('/analytics',{ body,method:'POST',keepalive:true})}// 监听各项指标getCLS(sendToAnalytics)getFID(sendToAnalytics)getFCP(sendToAnalytics)getLCP(sendToAnalytics)getTTFB(sendToAnalytics)最后啰嗦两句,SEO 不是玄学但也别太迷信
写这篇文章的时候,我想起刚入行时的一个项目。当时客户 obsessed with SEO,要求我们把关键词密度做到 5%,标题必须包含特定关键词,图片 alt 里也要塞关键词,甚至想在页脚隐藏一堆关键词文本(就是做成跟背景色一样的文字)。
结果网站被 Google 判定为"关键词堆砌",排名直接掉到十页开外。
所以啊,别为了 SEO 把代码写得像意大利面。关键词该出现的地方自然出现,title 写得通顺易懂比硬塞关键词重要得多。内容质量才是根本,技术只是让搜索引擎更好地理解你的内容。
还有,搜索引擎算法天天变。今天有效的招明天可能就废了,甚至可能起反作用。保持学习,关注 Google 的 Search Central Blog、百度的站长社区,及时调整策略。
最后,实在搞不定就摇人。SEO 是个跨领域的活儿,涉及前端、后端、运维、内容运营。找个懂后端的哥们儿一起喝顿酒,说不定就有灵感了。毕竟头发掉了还能长,流量没了可就真没了。
哦对了,记得定期看日志,看爬虫都在抓啥,有没有遇到 404,有没有卡在哪个环节。数据比感觉靠谱多了。
就这样吧,希望你的下一个项目,百度谷歌都能秒收录,流量直接起飞!🚀
