vue ssr 实践

为了更好的 SEO 排名性能优化 等要求,往往需要 服务端渲染 的支持。我的公司项目都是基于 vue 的单页面应用(那时候太年轻),那怎么实现服务端渲染呢?

社区早已有了解决方案,只是我还没有用过罢了,借这个机会快速的看下这块的实践,做些技术储备。

vue-ssr api 的介绍

有关 vue ssr 的实现都是依靠 vue-server-renderer 模块提供的功能,可以看如下 Demo:

点击访问:https://gitee.com/eminoda/ssr-learn/tree/vue-ssr-api

下面会对 简单的渲染结合 bundle&manifest 文件做的渲染 做说明。

当然你可以看 Vue SSR 官方文档说明,真的是非常详细。

简单渲染

这是个非常简单的例子,主要在没有其他代码的干扰下更可能示范 vue ssr 的本质。只需要 vue + htmlTemplate 就能搞定。

createRenderer

创建一个(模板)渲染器:

1
2
3
4
const { createRenderer } = require("vue-server-renderer");
let renderer = createRenderer({
template: templateFile
});

这个 templateFile 是字符串,而不是 require 的引用。这也好理解,毕竟服务端渲染的本质就是响应返回字符串。

创建一个 Vue 实例

1
2
3
4
5
6
let app = new Vue({
data: {
msg: data.msg
},
template: `<div>数据绑定:{{ msg }}</div>`
});

renderToString

通过渲染器 renderer ,调用 renderToString ,把 vue 实例注入进去,得到字符串。

当然还可以通过 content 设置一些信息,比如 SEO 的 TDK。

1
2
3
4
5
6
7
8
9
10
let context = {
title: "SSR-templateRender"
};
renderer.renderToString(app, content, (err, html) => {
if (err) {
reject(err);
} else {
resolve(html);
}
});

最后你就能访问 url 看到页面内容(注意这并不是客户端 js mount 上去的)

模板渲染

JsonBundle + Manifest

当然真实的项目还更复杂些:

  • Html 引用的资源需要“动态”的挂到标签上
  • 服务端 router 每次解析不同地址,还需要“配合” vue router ,做对应的模板渲染
  • 甚至还需要保持 vuex 的状态;异步请求的处理

详细的项目配置,你可以在上面的 Demo 中看到 看到,下面只是说核心部分。

webpack build

首先我们需要通过 webpack 不同的入口文件 entry 来构建 客户端的 manifest 文件服务端的 server.bundle 文件

这里就涉及 webpack 的两个 plugin:vue-server-renderer/client-pluginvue-server-renderer/server-plugin ,分别生成 manifest 和 bundle:

1
2
3
4
5
// webpack.client.config
plugins: [new VueSSRClientPlugin()];

// webpack.server.config
plugins: [new VueSSRServerPlugin()];

构建成功就会在 output 目录下生成:vue-ssr-client-manifest.json、vue-ssr-server-bundle.json 。

通过 manifest ,vue 就知道如何往模板上挂在资源文件;同时每次渲染时,vue 也能依靠 bundle 知道取那部分 js 逻辑。

修改服务端路由逻辑

拦截服务端所有的请求,交给 getResponseDataByBundleRender 处理,context 内包含当前请求的 url :

1
2
3
4
5
6
7
8
router.get("*", async (ctx, next) => {
let ssr = new SSRService({});
let context = {
title: "SSR-jsonBundleRender",
url: ctx.url
};
ctx.body = await ssr.getResponseDataByBundleRender(context, {});
});

getResponseDataByBundleRender 是一个封装的方法,和上面的简单渲染实现一样,只是额外需要 bundle、manifest 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
getResponseDataByBundleRender(content) {
let renderer = createBundleRenderer(serverBundle, {
runInNewContext: false, // 推荐
template: template,
clientManifest: clientManifest
});
return new Promise((resolve, reject) => {
renderer.renderToString(content, (err, html) => {
if (err) {
reject(err);
} else {
resolve(html);
}
});
});
}

客户端和服务端功能的衔接

那客户端的请求地址怎么和 vue 的路由模式搭上关系?这就要回到上面的 webpack 配置不同的入口文件:

独立创建一个创建 vue 实例的文件,用于对不同端的 entry 入口文件解耦:

1
2
3
4
5
6
// src\app.js
export function createApp() {
const app = new Vue({ router, store, render: h => h(App) });
// 额外导出 router ,供 ssr 使用
return { app, router, store };
}

server.entry.js 会将 store 注入到 window.__INITIAL_STATE__ 中,并且会根据请求地址,切换 vue 当前路由地址:

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
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp();

router.push(context.url);

router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
if (!matchedComponents.length) {
return reject(new Error("访问页面不存在"));
}
Promise.all(
matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
});
}
})
)
.then(() => {
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state;
resolve(app);
})
.catch(reject);
}, reject);
});
};

client.entry.js 会加入如下逻辑,使客户端能将 window.__INITIAL_STATE__ 替换成 vue store :

1
2
3
4
5
6
// src\entry-client.js
const { app, store } = createApp();

if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__);
}

这样 vue 的状态机制就得以还原。

之后你就能看到这样的效果:

vue-ssr

koa + vue-ssr 的开发模式

在实际开发中,上面这些还远远不够。因为 manifest 和 bundle 的更新都需要重新加载到服务中;为了开发体验还需要 HMR 的开发方式

Vue 的作者 尤大大 给我们了个例子 vue-hackernews-2.0,参考它,就能很大程度解决上面的问题。

同样对其中的重点做个说明,备注:服务端用的 koa :

一个延迟启动

因为要把 webpack 构建包含到服务中,就要在服务启动前完成构建工作。

提供一个 ready 完成这项工作:

1
2
3
4
5
6
7
8
ready(app).then(renderer => {
app.use(async (ctx, next) => {
// vue render
});
app.listen(3000, () => {
console.log("server is started on 3000 port ", "http://127.0.0.1:3000");
});
});

在 ready 中完成 vue ssr 需要的 bundle.json 和 manifest.json 构建:

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
ready(app, cb) {
// 获取 webpack compiler 编译实例
const clientCompiler = webpack(clientConfig);

clientCompiler.plugin('done', stats => {
stats = stats.toJson();
stats.errors.forEach(err => console.error(err));
stats.warnings.forEach(err => console.warn(err));
if (stats.errors.length) return;
clientManifest = JSON.parse(readFile(webpackDevMiddlewareWrap.fileSystem, 'vue-ssr-client-manifest.json'));
update();
});

const serverCompiler = webpack(serverConfig);
const mfs = new MFS();
serverCompiler.outputFileSystem = mfs;
serverCompiler.watch({}, (err, stats) => {
if (err) throw err;
stats = stats.toJson();
if (stats.errors.length) return;
bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'));
update();
});

return readyPromise;
}

拿到 clientManifest 和 bundle 再交给 update() 获取渲染解析器,就回归到 vue ssr api 那部分的运用:

1
2
3
4
5
6
7
const update = () => {
return createBundleRenderer(bundle, {
runInNewContext: false,
template,
clientManifest
});
};

准备做完后,就根据 request 地址生成对应的 html 字符串。这些都是在服务启动前搞定了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ready(app).then(renderer => {
app.use(async (ctx, next) => {
const context = {
title: 'vue ssr by koa server', // default title
url: ctx.request.url
};
ctx.body = await new Promise((resolve, reject) => {
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err);
}
resolve(html);
});
});
}
// 启动服务
app.listen(3000, () => {
console.log('server is started on 3000 port ', 'http://127.0.0.1:3000');
});
}

开启 HMR

我们已经知道了 HMR 的原理

首先模拟 webpack-dev-server (内存)静态文件服务,这样每次来次客户端的请求都会被 devMiddleware 拦截,根据请求响应对应的资源文件,包括热更新资源:

1
2
3
4
5
6
7
let webpackDevMiddlewareWrap = webpackDevMiddleware(clientCompiler, {
publicPath: clientConfig.output.publicPath,
noInfo: true
});

// 注册中间件,webpack-dev-middleware
app.use(devMiddleware(webpackDevMiddlewareWrap));

再是创建通过 clientHotMiddleware 创建 socket 连接,时刻向服务端推送信息,告知本地代码更新状态:

1
app.use(clientHotMiddleware(clientCompiler, { heartbeat: 5000 }));

这样一个 koa + vue 的 ssr 开发架构就初步成型。

nuxt

2016 年 10 月 25 日,zeit.co 背后的团队对外发布了 Next.js,一个 React 的服务端渲染应用框架。几小时后,与 Next.js 异曲同工,一个基于 Vue.js 的服务端渲染应用框架应运而生,我们称之为:Nuxt.js。

主流 vue ssr 框架了,github 高 star 为使用它提供了足够的保障。

但我展示不打算使用它,因为上面的开发方式以及满足我的基本需求,过多的框架封装会让我迷失技术的原有“味道”,等有时间时再来填这里的坑。

参考

我只是知识点的“加工者”, 更多内容请查阅原文链接 :thought_balloon: , 同时感谢原作者的付出:

【长按关注】看看↓↓↓?
Eminoda wechat
【前端雨爸】分享前端技术实践,持续输出前端技术文章
欢迎留言,评论交流,一起讨论前端问题
📢 因为是开源博客,为避免 Gitalk授权 带来的 安全风险,也可访问