IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    SPA 的 SEO 实践 · 看不见我的美 · 是你瞎了眼

    馬腊咯稽发表于 2020-11-15 00:00:00
    love 0
    SPA 的 SEO 实践

    SEO 就是让搜索引擎了解网站,在用户进行关键词搜索时,网站能出现在搜索结果中比较靠前的位置。

    搜索引擎如何工作

    • 找出存在的网页:跟踪已知网页上的链接、与网站托管服务合作、站长提交站点地图;
    • 编入索引:解析网页内容并构建搜索索引,网页的标题、关键词和描述可以改善编入效果;
    • 呈现:根据搜索内容和其他因素(位置、语言、设备等)从索引中找到最相关的信息返回给用户;
    • 排名:提高网页加载速度并保持一定的更新频率有利于网站排名。

    搜索引擎蜘蛛

    蜘蛛抓取过程

    蜘蛛是搜索引擎抓用来页面信息和构建信息索引的工具,例如: Baiduspider 和 Googlebot ;蜘蛛在访问页面的过程中,处理文字内容、关键内容标记和属性,例如网站标题、Alt 属性、图片、视频等;此外,蜘蛛还会查找页面上的链接,并将这些链接添加到它要抓取的网页的列表中;这张列表记录着新建立的网站、对现有网站进行的更改以及无效链接,并据此更新索引。

    百度曾发布过一个 公告 ,提到 Baiduspider 会访问页面的 CSS、JS 和图片资源,但是 JS 会不会执行、JS 的执行结果会不会被编入索引则没有明说;Google 在 文档 中指出:Googlebot 支持对 SPA 和动态内容的抓取,在抓取过程中会使用 Chrome 加载网页并运行 JS。

    显然,搜索引擎更喜欢“有内容的”HTML。

    SPA 如何产生“有内容的”HTML

    预渲染

    在客户端访问之前提前渲染好 HTML(一般是在打包阶段);在客户端初次访问页面时,会从静态服务器请求事先渲染好的 HTML;之后 JS 会接管交互、渲染和路由等工作;这是一种混合模式,从客户端来看,页面的行为就像 SPA 一样。

    优点:

    • 更好的 SEO,爬虫可以直接抓取页面内容;
    • 更快的(非用户相关内容的)首屏渲染。

    缺点:

    • 无法实时更新静态服务器端 HTML 的内容(除非在数据发生变动时重新打包部署)。
    • 无法用于不可穷举的动态路由场景;
    • 不支持哈希路由。

    服务端同构渲染

    在客户端发起请求时实时渲染 HTML,服务端总会请求最新的数据完成渲染;加载完第一个页面之后,客户端还是会进入 JS 渲染的模式(混合渲染,例如:Nuxt 和 Next);这种方式和预渲染模式的差别在于渲染 HTML 的时机不同。

    优点:

    • 更好的 SEO,爬虫可以直接抓取页面内容;
    • 更快的首屏渲染。

    缺点:

    • 需要动态服务,不能 CDN 部署,有可能出现服务只能部署在 A 地,但用户却在 B 地的情况;
    • 不是所有的开发依赖都支持在服务端运行;
    • 动态服务器比静态服务器更耗资源;
    • 客户端与服务端的状态同步;
    • 不支持哈希路由。

    基于 UA 的动态渲染

    Google 搜索中心提到的一种模式,根据 UA 判断,客户端是不是“蜘蛛”;蜘蛛访问的是渲染之后(不一定是动态渲染)的 HTML,普通用户访问的还是一个 SPA。这种模式将客户端分为“用户”和“蜘蛛”两种,可以减轻服务器压力、对搜索引擎抓取的内容进行针对性的优化。

    预渲染配置

    通过 HtmlWebpackPlugin 配置项目 TWD:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    const title = '我是标题...';
    const meta = {
     keywords: '我是,关键词,...',
     description: '我是描述...'
    };
    module.exports = {
     plugins: [
     new HtmlWebpackPlugin({
     entry: 'src/project/home/main.js',
     template: 'public/main.html',
     filename: 'index.html',
     chunks: ['chunk-vendors', 'chunk-common', 'home'],
     title,
     meta
     })
     ]
    };
    

    配置 main.js 触发预渲染事件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    new Vue({
     el: '#app',
     router,
     store,
     render: h => h(App),
     mounted() {
     document.dispatchEvent(new Event('render-event'));
     }
    });
    

    预渲染完成之后,根结点会被替换掉;需要在项目根组件添加对应的 id,否则会报“Cannot find element: #xxx”错误:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    <template>
     <div id="app" class="app">
     <top-header :isHome="true" />
     <div class="router-view">
     <transition name="rv">
     <router-view />
     </transition>
     </div>
     <bottom-footer />
     </div>
    </template>
    

    引入 PrerenderSPAPlugin 插件:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    
    const PrerenderSPAPlugin = require('prerender-spa-plugin');
    const Renderer = PrerenderSPAPlugin.PuppeteerRenderer;
    module.exports = {
     plugins: [
     // 在非开发环境下执行
     !process.env.LOCAL_DEVELOP &&
     new PrerenderSPAPlugin({
     // 打包输出目录,也可以配置 outputDir 将预渲染文件输出到单独的目录
     staticDir: path.join(__dirname, 'dist'),
     // 对输出的 HTML 压缩配置(html-minifier)
     minify: {
     collapseBooleanAttributes: true,
     removeEmptyAttributes: false,
     removeComments: true,
     minifyCSS: true,
     minifyJS: true
     },
     // 渲染器配置
     renderer: new Renderer({
     // 挂载在 window 上属性
     injectProperty: '__PRERENDER__INJECTED__',
     // 挂载在 window.__PRERENDER__INJECTED__ 的对象
     inject: {
     hostname: prerenderHostname()
     },
     // 是否显示浏览器(多用于调试,开启之后部分打包环境会打包失败)
     headless: true,
     // 在预渲染之前等待的事件
     renderAfterTime: 500,
     // 最多同时渲染多少个路由
     maxConcurrentRoutes: 1,
     // 触发渲染的事件名称
     renderAfterDocumentEvent: 'render-event',
     // 浏览器运行参数
     args: ['--no-sandbox', '--disable-setuid-sandbox']
     }),
     // 请求代理配置
     proxy: {
     '^/api/exp': {
     target: 'https://www.example.com'
     },
     changeOrigin: true
     },
     // 要预渲染的路由
     routes: [
     '/',
     '/introduction/service-one',
     '/introduction/service-two',
     '/solution/way-one',
     '/solution/way-two',
     '/support/ask',
     '/about/us'
     ]
     })
     ]
    };
    

    为了 SEO,你可能需要 extract-text-webpack-plugin 将样式单独打包。

    PrerenderSPAPlugin 逻辑

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    
    function PrerenderSPAPlugin(...args) {
     // 对入参做处理
     this._options = args[0] || {};
    }
    PrerenderSPAPlugin.prototype.apply(compiler => {
     // apply 方法在安装插件时,会被 Webpack 编译器调用一次,在异步操作完成之后调用 callback
     const afterEmit = (compilation, callback) => {
     // Prerenderer 会开启 express 服务器和 puppeteer 实例
     const prender = new Prerenderer(this._options);
     prender
     .initialize()
     // express 会代理静态目录,通过 puppeteer 访问页面并将页面保存
     .then(() => prender.renderRoutes(this._options.routes || []))
     .then(renderedRoutes => {
     /**
     * [{
     * originalRoute: route,
     * route: await page.evaluate('window.location.pathname'),
     * html: await page.content()
     * }, {...}...]
     */
     const isValid = renderedRoutes.every(r => typeof r === 'object');
     if (isValid) {
     return renderedRoutes;
     } else {
     throw new Error('...');
     }
     })
     .then(renderedRoutes => {
     if (this._options.minify) {
     // minify...
     return renderedRoutes;
     }
     return renderedRoutes;
     })
     .then(renderedRoutes =>
     Promise.all(
     renderedRoutes.map(
     renderedRoute =>
     new Promise((resolve, reject) => {
     // write files...
     resolve();
     })
     )
     )
     )
     .then(() => {
     // 完成文件写入之后退出
     prender.destory();
     callback();
     })
     .catch(error => {
     prender.destory();
     // 如果有报错,在编译过程抛出
     compilation.errors.push(new Error(error));
     callback();
     });
     };
     const plugin = { name: 'PrerenderSPAPlugin' };
     // 向编译器的钩子上注册异步方法,可以对当前编译实例(过程)做操作
     compiler.hooks.afterEmit.tapAsync(plugin, afterEmit);
    });
    

    两种文件

    公司的 SEO 服务商让在服务器根目录放了几个“文件”,最有用的是两种是 Sitemap 和 robots。

    Sitemap 是用来告诉蜘蛛网站上有哪些可供抓取的网页;Sitemap 可以是 XML、TXT、HTML 文件,其中包含了站点中的网址以及关于每个网址的元数据(更新时间、更新频率、重要程度、语言版本等),以便搜索引擎可以更加智能地抓取网站;Sitemap 并不能保证网页会被蜘蛛抓取,但可向蜘蛛提供一些提示以便网站被更有效地抓取:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    
    <?xml version='1.0' encoding='UTF-8'?>
    <urlset>
    <url>
    <loc><![CDATA[https://www.example.com]]></loc>
    <lastmod>2020-10-28</lastmod>
    <changefreq>weekly</changefreq>
    </url>
    <url>
    <loc><![CDATA[https://www.example.com/about/contact]]></loc>
    <lastmod>2020-10-28</lastmod>
    <changefreq>weekly</changefreq>
    </url>
    <url>
    <loc><![CDATA[https://www.example.com/about/us]]></loc>
    <lastmod>2020-10-28</lastmod>
    <changefreq>weekly</changefreq>
    </url>
    <url>
    <loc><![CDATA[https://www.example.com/solution/agriculture]]></loc>
    <lastmod>2020-10-28</lastmod>
    <changefreq>weekly</changefreq>
    </url>
    <url>
    <loc><![CDATA[https://www.example.com/solution/hybrid]]></loc>
    <lastmod>2020-10-28</lastmod>
    <changefreq>weekly</changefreq>
    </url>
    <url>
    <loc><![CDATA[https://www.example.com/support/ask]]></loc>
    <lastmod>2020-10-28</lastmod>
    <changefreq>weekly</changefreq>
    </url>
    </urlset>
    

    Sitemap 适用情形:

    • 网站规模很大,蜘蛛有可能在抓取时漏掉部分新网页或最近更新的网页;
    • 有大量内容页归档,并且内容页之间互不关联或缺少有效链接;
    • 网站为新网站且指向该网站的外部链接不多;
    • 您的网站包含大量媒体内容(视频、图片)。

    robots 文件是用来告诉搜索引擎,网站上的哪些页面可以抓取,哪些页面不能抓取;User-agent、Allow、Disallow 等字段精确地对不同搜索引擎的抓取工作进行控制:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    User-agent: Googlebot
    Disallow: /
    Allow: /content/
    
    User-agent: Baiduspider
    Disallow: /admin/
    
    Sitemap: https://www.example.com/sitemap.txt
    Sitemap: https://www.example.com/sitemap.html
    

    robots 适用情形:

    • 在某个阶段不公开某些网页 (例如:在搭建网站雏形时);
    • 防止抓取内部搜索页面;
    • 防止抓取重复页面;
    • 防止服务器过载。

    参考

    • 关于 Robots.txt 和 SEO:你所需要知道的一切
    • 什么是 Sitemap?
    • Google 搜索中心
    • 百度站长平台


沪ICP备19023445号-2号
友情链接