本文已参与「新人创作礼」活动,一起开启掘金创作之路。
在写文的当下 2022,绝大多数前端页面都以 SPA(Single Page Application) 的形式进行开发,可以说是前端怎么舒服怎么来。但 SPA 在 C 端页面下会遇到一个最直接的问题 ------ SEO。
因为 SPA 是单文件入口,对于不同路由的不同 SEO 信息,无法简单地地直接在 html 里返回。我们只能寄希望于构建时。这时候我们的 Prerender SPA Plugin 就出现了。
介绍 Prerender SPA Plugin 的文章已经很多了,本文主要侧重实际项目中遇到的几个问题,故分以下小节,可以选择感兴趣的段落进行阅读:
- Prerender vs SSR | SEO 方案的选择
- SPA 应用如何接入 Prerender SPA Plugin
- 问题一:本地可以 build,使用生产环境的镜像 build 失败
- 问题二:静态资源使用 CDN 地址后 build 失败
- 最后顺便做个字体压缩吧
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;
}
});