SPA SEO 预渲染方案 Prerender SPA Plugin 实践总结

2,282 阅读4分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

在写文的当下 2022,绝大多数前端页面都以 SPA(Single Page Application) 的形式进行开发,可以说是前端怎么舒服怎么来。但 SPA 在 C 端页面下会遇到一个最直接的问题 ------ SEO

因为 SPA 是单文件入口,对于不同路由的不同 SEO 信息,无法简单地地直接在 html 里返回。我们只能寄希望于构建时。这时候我们的 Prerender SPA Plugin 就出现了。

介绍 Prerender SPA Plugin 的文章已经很多了,本文主要侧重实际项目中遇到的几个问题,故分以下小节,可以选择感兴趣的段落进行阅读:

Prerender vs SSR | SEO 方案的选择

在开始介绍 Prerender(预渲染) 前,我们不妨先比较另一个 SEO 方案 ------ SSR(Server Side Render),方便小伙伴们做 技术选型 的时候进行参考。

SSR 说白了是有个用来返回用户内容的服务(一般是 Node),它解析用户的请求内容(URL、Cookies ...),然后实时渲染内容并以 html 的形式返回给用户。用户拿到的 html 里就附带了 SEO 所需的那些字段(title、description、h1、img ...)。

Prerender 的 SEO 字段生成则发生在构建时。当我们在本地开发时,还是按 SPA 的方式开发。只有进行生产构建的时候,才会启用 Prerender SPA Plugin ------ 一个基于 puppeteer 的 Webpack 插件,它会在后台起一个静态文件服务(默认是使用 8000 之后的第 1 个可用端口),通过 headless 浏览器(无界面)访问这个服务里我们指定的路由,当页面在无头浏览器渲染完成后就将整个 html document 保存到本地。因为页面渲染已经完成了,我们保存的页面里就有不再只有 SPA 用来渲染应用的 root 元素。

未使用 Prerender SPA Plugin 构建的 html

使用 Prerender SPA Plugin 构建的 html

SPA 应用如何接入 Prerender SPA Plugin

截至写文的时候,Prerender SPA Plugin 与 Webpack 5 是不兼容的(可以使用 prerender-spa-plugin-next),详情可参考:github.com/chrisvfritz…

Prerender SPA Plugin 的接入很简单,跟其他 Webpack 的 Plugin 相似:

// 在生产模式的 webpack 配置文件中创建 PrerenderSPAPlugin

const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')

module.exports = {
  plugins: [
    ...
    new PrerenderSPAPlugin({
      // 输出路径
      staticDir: path.join(__dirname, 'dist'),
      // 需要预渲染的路由
      routes: [ '/', '/about', '/some/deep/nested/route' ],
    })
  ]
}

如果是 vue-cli 创建的项目,配置大概是这样:

const path = require("path");
const PrerenderSPAPlugin = require("prerender-spa-plugin");

module.exports = {
  ...
  configureWebpack: (config) => {
    if (process.env.NODE_ENV === "production") {
      // push PrerenderSPAPlugin only in "production" env
      config.plugins.push(
        new PrerenderSPAPlugin({
          // 输出路径
          staticDir: path.join(__dirname, "dist"),
          // 需要预渲染的路由
          routes: [ '/', '/about', '/some/deep/nested/route' "/", "/about"],
        })
      );
    }
  },
};

再介绍几个关键的参数:

new PrerenderSPAPlugin({
  server: {
    // 预渲染时启动的静态文件服务的端口
    port: 8888,
  },

  // 指定渲染器
  renderer: new Renderer({
    // 是否无界面,如果 build 异常,可以改为 false 进行 debug
    headless: true,
    // 进行预渲染的时机,在收到 document 的指定事件后进行(一般会使用这个,时机更可控
    renderAfterDocumentEvent: "render-event",
    // 进行预渲染的时机,在某个元素存在后进行预渲染
    renderAfterElementExists: "#app > div",
    // 进行预渲染的时机,在页面打开一定时间(单位:ms)后进行预渲染
    renderAfterTime: 3000,
  }),

  // 后处理,在 prerender 将文件输出到磁盘前提供一个时机进行内容处理
  // renderedRoute format:
  // {
  //   route: String, // Where the output file will end up (relative to outputDir)
  //   originalRoute: String, // The route that was passed into the renderer, before redirects.
  //   html: String, // The rendered HTML for this route.
  //   outputPath: String // The path the rendered HTML will be written to.
  // }
  postProcess(renderedRoute) {
    // 如这里将 html 里的 localhost 替换为 cdn 地址
    renderedRoute.html = renderedRoute.html.replace(
      new RegExp(`http://localhost:8888`, "g"),
      'https://fake-cdn.com/prerender-demo'
    );

    return renderedRoute;
  },
})

插入 Prerender SPA Plugin 后,如果使用了renderAfterDocumentEvent 参数,还需要在对应页面里触发对应的事件,如:

new Vue({
  router,
  render: (h) => h(App),
  mounted() {
    // You'll need this for renderAfterDocumentEvent.
    document.dispatchEvent(new Event("render-event"));
  },
}).$mount("#app");

插件的接入就介绍这些,其他详细信息可参考 官方仓库

问题一:本地可以 build,使用生产环境的镜像 build 失败

这个应该是很多人会遇到的问题,现在公司的运维体系里一般都会用 docker 来进行构建,而用于构建的 docker 镜像一般都会移除不必要的依赖来保持小体积。但往往就会导致镜像内缺失 puppeteer 运行所需要的依赖。

一般会报下图这种找不到依赖库的错误:

我们可以参考 github.com/chrisvfritz…,在公司的 node 前端镜像的基础上添加 puppeteer 启动所需的依赖,然后用新的镜像进行 build:

FROM harbor.***.com/nodejs

# Install puppeteer dependencies
RUN apt-get update \
    && apt-get install -y \
    gconf-service \
    libasound2 \
    libatk1.0-0 \
    libatk-bridge2.0-0 \
    libc6 \
    libcairo2 \
    libcups2 \
    libdbus-1-3 \
    libexpat1 \
    libfontconfig1 \
    libgcc1 \
    libgconf-2-4 \
    libgdk-pixbuf2.0-0 \
    libglib2.0-0 \
    libgtk-3-0 \
    libnspr4 \
    libpango-1.0-0 \
    libpangocairo-1.0-0 \
    libstdc++6 \
    libx11-6 \
    libx11-xcb1 \
    libxcb1 \
    libxcomposite1 \
    libxcursor1 \
    libxdamage1 \
    libxext6 \
    libxfixes3 \
    libxi6 \
    libxrandr2 \
    libxrender1 \
    libxss1 \
    libxtst6 \
    ca-certificates \
    fonts-liberation \
    libappindicator1 \
    libnss3 \
    lsb-release \
    xdg-utils \
    wget \
    && rm -rf /var/lib/apt/lists/*

# Install Node
RUN curl -sL https://deb.nodesource.com/setup_16.x | bash - \
    && apt-get install -y nodejs \
    && rm -rf /var/lib/apt/lists/*

RUN node -v \
    && npm -v \
    && npm install -g yarn \
    && yarn -v

问题二:静态资源使用 CDN 地址后 build 失败

一般项目里我们通过配置 publicPath 将静态资源构建后的访问地址替换为 CDN 地址来达到访问加速。

但是这么配置后你很可能发现项目 build 会一直在转菊花,或出来的产物并没有预渲染的效果。

我们可以把 Renderer 的 headless 改为 false 来看看 build 一直没结果的原因。

可以看到预渲染加载的 html 里,静态资源的域名已经被替换为 fake-cdn.com 了,但是 build 阶段静态资源还没有真正被上传到 CDN 上,当然无法被正常访问。

所以 renderAfterDocumentEvent 在等待的 render-event 事件永远不会被触发(在 app.*.js 或其他 chunk js中),像舔狗一样永远不会有结果。

解决方式1

前置静态资源的上传,保证预渲染的时候这些静态资源能被访问就行。(但需要注意 CDN 资源是否配置了禁止一些非法域名,如 localhost)

解决方式2

代理配置,将 CDN 地址全局代理到预渲染的静态资源地址。(fake-cdn.com -> http://localhost:${PRERENDER_PORT}

注意不能用 prerender-spa-plugin server 提供的 proxy,因为 fake-cdn 的请求不会进入到该 server,只有 localhost:${PRERENDER_PORT} 域名的请求才会进入该 server。

解决方式3

build 时将 publicPath 设置为预渲染的静态服务:

const PRERENDER_PORT = 8000;

const publicPath = 
  process.env.NODE_ENV === "development" ? "/" : `http://localhost:${PRERENDER_PORT}`;

在插件的 postProcess 传入函数,将 html 内的 http://localhost:${PRERENDER_PORT}全局替换为 CDN 域名:

最后记得将 js 中的地址也做个替换:

# 注意 mac 下 sed -i 要改成 sed -i "" -e

find ./dist -name '*.js' -exec sed -i 's#http://localhost:8000#https://deo.shopeemobile.com/shopee/shopee-sellerplatform-'"$env"'-sg/lovito#g' {} \;

最后顺便做个字体压缩吧

不建议用 font-spider 进行压缩,这个库已经没怎么维护,而且字体压缩之后样式可能有问题。

这部分与本文无关,顺便做个简单的记录吧~

const { execSync } = require('child_process');

const Fontmin = require('fontmin');
const { compile } = require('html-to-text');

const convert = compile({
  ignoreImage: true,
});

let htmlText = '';

const htmlFiles = execSync("find ./dist -name '*.html'")
  .toString()
  .split('\n')
  .filter((item) => Boolean(item));

htmlFiles.forEach((file) => {
  const html = execSync(`cat ${file}`).toString();
  htmlText = htmlText + convert(html);
});

const fontmin = new Fontmin()
  .src('./dist/fonts/*.ttf')
  .dest('./dist/fonts') // replace original output fonts
  .use(
    Fontmin.glyph({
      text: htmlText,
      hinting: false,
    })
  );

fontmin.run((err) => {
  if (err) {
    throw err;
  }
});