最近在做的项目都需要做SSR优化搜索引擎索引和用户体验.
如果只需要优化SEO那就直接挂个白屏屏蔽掉没加载完但是有内容的丑陋网页就行了, 搜索引擎能够抓取数据, 用户以为还在加载.
但是如果需要优化用户体验, 那就需要下一些功夫了.
这篇教程基于没有使用任何SSR框架(比如nuxt/vite-ssr), 纯手搓的SSR服务端, 其他框架请自行迁移学习, 难度应该不大, 实在不行我们可以改源码(x).
核心逻辑如下:
import { renderToString } from "@vue/server-render";
const initApp = await import("server/main.mjs");
const template = fs.readfileSync("index.html");
export async function render(url) {
const { app, router } = await initApp();
await router.push(url);
await router.isReady();
const html = renderToString(app);
return template.replace("<!--ssr-outlet-->", html);
}
使用SSR的其中一个目的是让搜索引擎可以索引到网页的内容, 因此数据获取的是非常重要的.但是渲染到HTML之后, 获取的数据不能传递到客户端, 客户端需要重新从接口请求这些数据. 重新请求会导致客户端最初渲染的内容跟服务端不一致(客户端最初渲染的内容不包括从接口获得的数据, 需要显示加载状态, 而服务端提供的html是已经获取到数据的状态), 导致页面内容大面积的抖动, 以及无意义的请求.
因此需要设法将数据传递到客户端.
在没有SSR的情况下, 如果启用了CSS代码分割, CSS默认不会被加载, 而是由JS决定哪些CSS会与JS同时加载.
JS加载好时, CSS一般也加载完成了. 此时JS将页面内容挂载到DOM上, CSS已经加载完成, 赋予内容样式.
但是在SSR的情况下, HTML会被预先渲染好, 但此时CSS还没加载, 就会使无样式的内容渲染到屏幕上, 不仅非常丑, 而且等待CSS加载后, 元素被赋予样式, 页面布局可能会产生非常大的抖动.
比较简单的方法时在渲染时直接给body上一个display: none
, 等待挂载时再移除, 不影响爬虫爬取内容, 但这抛弃了SSR带来的用户体验优化, 需要找到一个方法在不舍弃的用户体验的同时实现SSR.
统一包装API请求方法(大型项目应该都有做, 没做的赶紧做), 然后在服务端请求时将每个响应内容都存起来, 序列化后嵌入到HTML中:
const RequsetCaches = window.REQ_CACHE || {};
hash(str) {
// ...
// 为请求计算一个独立的hash
// ...
return hash
}
// ...
// API包装实现
getArticleData(id) {
return get("/article/" + id);
}
get(url, options = {}) {
if (client) {
// 客户端
const resp = RequestCaches[hash(url+json(options))]
if (resp) {
// 如果有缓存, 返回缓存, 判断是否需要清理缓存
if (need_clean) delete RequestCaches[hash(url+json(options))]
return resp;
}
}
// 获取数据
const res = fetch(url, opt);
if (server) {
// 缓存数据
RequestCaches[hash(url+json(options))] = res.data;
}
return res.data;
}
// ...
// 渲染App
const html = renderToString(app);
return {
html: html,
RequestCache: RequestCache
}
// ...
// 响应, 发送到客户端
// !!! 不要直接使用JS的JSON序列化, 存在XSS风险 !!!
res.end(html.replace("state", json(RequestCache))) // <html><head><script>window.REQ_CACHE=<JSON ReqestCache>
Critical CSS是一种做法, 把页面首屏需要的CSS预先直接嵌入到HTML中, 在CSS未加载完全时也能正确的展示页面.
Web.dev上对Critical CSS的介绍
一般的做法是在编译打包期将就关键的CSS提取出来, 但是这只适合SPA应用, 对于SSR应用, 页面路由都是动态的, 页面内容也是动态的, 不可能在编译期就把CSS提取出来, 因此我们需要找到一种办法动态提取CSS.
在选型后, 大部分的Critical CSS的提取器依赖无头浏览器来准确确定哪些样式需要被加载(只加载首屏上能看见的元素的CSS), 这开销非常大, 基本只适合在编译器处理.
来自谷歌实验室的Critters
, 使用AST直接解析HTML所需的CSS, 不再依赖无头浏览器, 但是无法确定首屏上哪些元素可以看见, 只能将HTML内元素所需的样式全数嵌入, 但由于不依赖无头浏览器, 处理速度远高于其他实现, 开销很低, 几乎可以直接用于SSR.
NPM Critters
import { renderToString } from "@vue/server-render";
import Critters from "critters";
const initApp = await import("server/main.mjs");
const template = fs.readfileSync("index.html");
const critters = new Critters({
path: "dist/client/assets",
publicPath: "assets",
additionStyleSheets: [
...fs.readDirSync("dist/client/assets").filter(file => file.endsWith(".css"))
]
});
export async function render(url) {
const { app, router } = await initApp();
await router.push(url);
await router.isReady();
const html = renderToString(app);
const result = await critters.process(template.replace("<!--ssr-outlet-->", html));
return result;
}
但经过实践, Critters虽然很快, 但还不够快, 使得无法直接用于SSR期间提取内容.
即使不再使用无头浏览器, 对于一个小型应用(仅两千行的代码量), 处理一个单页仍然需要超过200ms(将页面渲染成HTML只需要50ms), 使得QPS只有个位数, 这显然对性能影响非常严重.
Critters没有过多的暴露API去控制他的处理流程, 故考虑直接修改Critters的源码, 使用patch-package
来在外部保存对依赖的修改.
项目的编译目标是module
, 故修改node_modules/critters/dist/critters.mjs
预先读取CSS, 减少读取压力, 并直接返回样式, 而不是嵌入后返回
修改constructor
, 读取所有的CSS到内存中, 之后不再从磁盘读取样式表.
critters.mjs
class Critters {
constructor(options) {
// ...
this.logger = this.options.logger || createLogger(this.options.logLevel);
// 读取样式表到内存
this.sheets = [];
this.options.additionalStylesheets.forEach((file) => {
this.sheets.push({
name: file,
content: this.getCssAsset(file)
})
});
// ...
// 修改getCssAsset, 使其同步读取, 因为在constructor里无法async, 且之后不需要再次读取, 因此直接使用sync读取更好
getCssAsset(href) {
// ...
let sheet;
try {
// 修改为readFileSync
sheet = readFileSync(filename);
} catch (e) {
this.logger.warn(`Unable to locate stylesheet: ${filename}`);
}
// ....
// 修改process方法直接返回提取后的样式, 而不是嵌入到html中再序列化成字符串, 开销较大
async process(html) {
// ...
const document = createDocument(html);
// 移除原有的嵌入CSS并处理的逻辑, 直接使用内存里读取好的CSS并处理
const result = (await Promise.all(
this.sheets.map(async (v) => {
// 通过查看processStyle方法, 仅提供正常工作所需的属性, 而不使用virtual-dom的createElement创建元素
// 可以避免修改DOM树
const style = {
$$name: v.name,
textContent: v.content,
parentNode: true,
remove() {
// 模拟remove方法, 在样式为空时Critters会移除这个DOM
// 通过置空内容模拟移除
style.textContent = "";
}
};
await this.processStyle(style, document)
// 直接返回样式字符串, 而不是嵌入到virtual-dom里
return style.textContent;
})
)).join("");
// 移除将style嵌入回virtual-dom的逻辑, 以及将virtual-dom序列化回html的过程, 加速处理
// ...
// ...
const end = process.hrtime.bigint();
this.logger.info('Time ' + parseFloat(end - start) / 1000000.0);
// 直接返回CSS字符串
return result;
}
render.mjs
import { renderToString } from "@vue/server-render";
import Critters from "critters";
const initApp = await import("server/main.mjs");
const template = fs.readfileSync("index.html");
const critters = new Critters({
path: "dist/client/assets",
publicPath: "assets",
additionStyleSheets: [
...fs.readDirSync("dist/client/assets").filter(file => file.endsWith(".css"))
]
});
export async function render(url) {
const { app, router } = await initApp();
await router.push(url);
await router.isReady();
const html = renderToString(app);
const result = await critters.process(template.replace("<!--ssr-outlet-->", result));
return result;
}
这样处理后, 单次无缓存提取处理所需时间只要将近150ms(渲染页面该需要50ms), 存在缓存就是普通的拼接字符串, 非常接近不处理直接渲染所需的时间.
这样处理后, 基本可以将Critters用于生产环境了.
由于预先嵌入了样式, 输入框看起来就像已经加载好了, 用户可能会在里面输入数据.
但由于js没加载, 一旦js加载, 输入框被重新挂载, 用户输入的内容就会被清空.
很简单, 给所有input上一个pointer-events: none
, 阻止用户与其交互, 再在挂载时移除.
.not-mount input {
pointer-events: none;
}
// ...
app.mount("#app", true)
document.body.classList.remove("not-mount")