手写Vue服务端渲染
概念:放在浏览器进行就是浏览器渲染,放在服务器进行就是服务器渲染。
- 客户端渲染不利于 SEO 搜索引擎优化
- 服务端渲染是可以被爬虫抓取到的,客户端异步渲染是很难被爬虫抓取到的
- SSR直接将HTML字符串传递给浏览器。大大加快了首屏加载时间。
- SSR占用更多的CPU和内存资源
- 一些常用的浏览器API可能无法正常使用
- 在vue中只支持beforeCreate和created两个生命周期
一.开始vue-ssr之旅
yarn add vue-server-renderer vue
yarn add koa koa-router
1
2
2
createRenderer,创建一个渲染函数 renderToString, 渲染出一个字符串
const Vue = require('vue');
const render = require('vue-server-renderer');
const Koa = require('koa');
const Router = require('koa-router');
const app = new Koa();
const router = new Router();
const vm = new Vue({
data(){
return {msg:"hello world"}
},
template:`<div>{{msg}}</div>`
});
router.get('/',async (ctx)=>{
let r = await render.createRenderer().renderToString(vm);
ctx.body = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
${r}
</body>
</html>
`
});
app.use(router.routes());
app.listen(4000);
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
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
二.采用模板渲染
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
1
2
3
4
5
6
7
2
3
4
5
6
7
传入template 替换掉注释标签
const Vue = require('vue');
const render = require('vue-server-renderer');
const Koa = require('koa');
const Router = require('koa-router');
const app = new Koa();
const router = new Router();
const vm = new Vue({
data(){
return {msg:"hello world"}
},
template:`<div>{{msg}}</div>`
});
const template = require('fs').readFileSync('./index.html','utf8');
router.get('/',async (ctx)=>{
let r = await render.createRenderer({
template
}).renderToString(vm);
ctx.body = r;
});
app.use(router.routes());
app.listen(4000);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
三.ssr目录创建
├── config
│ ├── webpack.base.js
│ ├── webpack.client.js
│ └── webpack.server.js
├── dist
│ ├── client.bundle.js
│ ├── index.html
│ ├── index.ssr.html
│ ├── server.bundle.js
│ ├── vue-ssr-client-manifest.json
│ └── vue-ssr-server-bundle.json
├── package.json
├── public
│ ├── index.html
│ └── index.ssr.html
├── server.js
├── src
│ ├── App.vue
│ ├── components
│ │ ├── Bar.vue
│ │ └── Foo.vue
│ ├── entry-client.js
│ ├── entry-server.js
│ ├── app.js
│ ├── router.js
│ └── store.js
├── webpack.config.js
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
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
四.通过webpack实现编译vue项目
安装插件
yarn add webpack webpack-cli webpack-dev-server vue-loader vue-style-loader css-loader html-webpack-plugin @babel/core @babel/preset-env babel-loader vue-template-compiler webpack-merge
1
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const resolve = (dir)=>{
return path.resolve(__dirname,dir)
}
module.exports = {
entry: resolve('./src/client-entry.js'),
output:{
filename:'[name].bundle.js',
path:resolve('dist')
},
module:{
rules:[
{
test:/\.css$/,
use:['vue-style-loader','css-loader']
},
{
test:/\.js$/,
use:{
loader:'babel-loader',
options:{
presets:['@babel/preset-env']
}
},
exclude:/node_modules/
},
{
test:/.vue$/,
use:'vue-loader'
}
]
},
plugins:[
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template:'./index.html'
})
]
}
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
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
app.js
import Vue from "vue";
import App from "./App.vue";
export default () => { // 为了保证实例的唯一性所以导出一个创建实例的函数
const app = new Vue({
render: h => h(App)
});
return { app };
};
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
client-entry.js
import createApp from "./app";
const { app } = createApp();
app.$mount("#app"); // 客户端渲染手动挂载到dom元素上
1
2
3
2
3
server-entry.js
import createApp from "./app";
export default () => {
const { app } = createApp();
return app; // 服务端渲染只需将渲染的实例导出即可
};
1
2
3
4
5
2
3
4
5
五.配置客户端打包和服务端打包
- webpack.base.js
let path = require('path');
let VueLoaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
output:{
filename:'[name].bundle.js',
path:path.resolve(__dirname,'../dist')
},
module:{
rules:[
{test:/\.css/,use:['vue-style-loader','css-loader']},
{
test:/\.js/,
use:{
loader:'babel-loader',
options:{
presets:['@babel/preset-env']
},
},
exclude:/node_modules/,
},
{test:/\.vue/,use:'vue-loader'}
]
},
plugins:[
new VueLoaderPlugin()
]
}
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
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
- webpack.client.js
const merge = require("webpack-merge");
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const base = require("./webpack.base");
const resolve = filepath => {
return path.resolve(__dirname, filepath);
};
module.exports = merge(base, {
entry: {
client: resolve("../src/client-entry.js")
},
plugins: [
new HtmlWebpackPlugin({
template: resolve("../template/index.client.html")
})
]
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- webpack.server.js
const merge = require("webpack-merge");
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const base = require("./webpack.base");
const resolve = filepath => {
return path.resolve(__dirname, filepath);
};
module.exports = merge(base, {
entry: {
server: resolve("../src/server-entry.js")
},
target: "node",
output: {
libraryTarget: "commonjs2" // 导出供服务端渲染来使用
},
plugins: [
new HtmlWebpackPlugin({
filename: "index.ssr.html",
template: resolve("../template/index.ssr.html"),
excludeChunks: ["server"]
})
]
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
六.配置运行脚本
"scripts": {
"client:dev": "webpack-dev-server --config ./build/webpack.client.js", // 客户端开发环境
"client:build": "webpack --config ./build/webpack.client.js", // 客户端打包环境
"server:build": "webpack --config ./build/webpack.server.js" // 服务端打包环境
},
1
2
3
4
5
2
3
4
5
七.服务端配置
在App.vue上增加id="app"可以保证元素被正常激活
const Koa = require("koa");
const Router = require("koa-router");
const static = require("koa-static");
const path = require("path");
const app = new Koa();
const router = new Router();
const VueServerRenderer = require("vue-server-renderer");
const fs = require("fs");
// 服务端打包的结果
const serverBundle = fs.readFileSync("./dist/server.bundle.js", "utf8");
const template = fs.readFileSync("./dist/index.ssr.html", "utf8");
const render = VueServerRenderer.createBundleRenderer(serverBundle, {
template
});
router.get("/", async ctx => {
ctx.body = await new Promise((resolve, reject) => {
render.renderToString((err, html) => {
// 必须写成回调函数的方式否则样式不生效
resolve(html);
});
});
});
app.use(router.routes());
app.use(static(path.resolve(__dirname, "dist")));
app.listen(3000);
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
在index.ssr.html中需要手动引入客户端打包后的结果
七.通过json配置createBundleRenderer方法
实现热更新,自动增加preload和prefetch,以及可以使用sourceMap
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin'); // 在客户端打包时增加插件
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); // 在服务端打包时增加插件
const Koa = require("koa");
const Router = require("koa-router");
const static = require("koa-static");
const path = require("path");
const app = new Koa();
const router = new Router();
const VueServerRenderer = require("vue-server-renderer");
const fs = require("fs");
// 服务端打包的结果
// const serverBundle = fs.readFileSync("./dist/server.bundle.js", "utf8");
const template = fs.readFileSync("./dist/index.ssr.html", "utf8");
const serverBundle = require("./dist/vue-ssr-server-bundle.json");
const clientManifest = require("./dist/vue-ssr-client-manifest.json");
const render = VueServerRenderer.createBundleRenderer(serverBundle, {
template,
clientManifest // 自动注入客户端打包后的文件
});
router.get("/", async ctx => {
ctx.body = await new Promise((resolve, reject) => {
render.renderToString((err, html) => {
// 必须写成回调函数的方式否则样式不生效
resolve(html);
});
});
});
app.use(router.routes());
app.use(static(path.resolve(__dirname, "dist")));
app.listen(3000);
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
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
八.集成VueRouter
yarn add vue-router
1
import Vue from "vue";
import VueRouter from "vue-router";
import Foo from "./components/Foo.vue";
Vue.use(VueRouter);
export default () => {
const router = new VueRouter({
mode: "history",
routes: [
{ path: "/", component: Foo },
{ path: "/bar", component: () => import("./components/Bar.vue") }
]
});
return router;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
导出路由配置
配置入口文件
import Vue from "vue";
import App from "./App.vue";
import createRouter from "./router";
export default () => {
const router = createRouter();
const app = new Vue({
router,
render: h => h(App)
});
return { app, router };
};
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
配置组件信息
<template>
<div id="app">
<router-link to="/"> foo</router-link>
<router-link to="/bar"> bar</router-link>
<router-view></router-view>
</div>
</template>
1
2
3
4
5
6
7
2
3
4
5
6
7
防止刷新页面不存在
router.get("*", async ctx => {
ctx.body = await new Promise((resolve, reject) => {
render.renderToString({ url: ctx.url }, (err, html) => {
// 必须写成回调函数的方式否则样式不生效
resolve(html);
});
});
});
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
保证异步路由加载完成
export default ({ url }) => {
return new Promise((resolve, reject) => {
const { app, router } = createApp();
router.push(url);
router.onReady(() => {
const matchComponents = router.getMatchedComponents();
if (!matchComponents.length) {
return reject({ code: 404 });
}
resolve(app);
}, reject);
});
};
// 服务器可以监控到错误信息,返回404
render.renderToString({ url: ctx.url }, (err, html) => {
// 必须写成回调函数的方式否则样式不生效
if (err && err.code == 404) {
resolve("404 Not Found");
}
resolve(html);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
十.集成vuex配置
yarn add vuex
1
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
export default ()=>{
let store = new Vuex.Store({
state:{
username:'song'
},
mutations:{
changeName(state){
state.username = 'hello';
}
},
actions:{
changeName({commit}){
return new Promise((resolve,reject)=>{
setTimeout(() => {
commit('changeName');
resolve();
}, 1000);
})
}
}
});
return store
}
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
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
// 引用vuex
import createRouter from './router';
import createStore from './store'
export default ()=>{
let router = createRouter();
let store = createStore();
let app = new Vue({
router,
store,
render:(h)=>h(App)
})
return {app,router,store}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
在后端更新vuex
import createApp from './main';
export default (context)=>{
return new Promise((resolve)=>{
let {app,router,store} = createApp();
router.push(context.url); // 默认访问到/a就跳转到/a
router.onReady(()=>{
let matchComponents = router.getMatchedComponents(); // 获取路由匹配到的组件
Promise.all(matchComponents.map(component=>{
if(component.asyncData){
return component.asyncData(store);
}
})).then(()=>{
context.state = store.state; // 将store挂载在window.__INITIAL_STATE__
resolve(app);
});
})
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
在浏览器运行时替换store
// 在浏览器运行代码
if(typeof window !== 'undefined' && window.__INITIAL_STATE__){
store.replaceState(window.__INITIAL_STATE__);
}
1
2
3
4
2
3
4
需要执行的钩子函数
export default {
mounted() {
return this.$store.dispatch("changeName");
},
asyncData(store) {
return store.dispatch("changeName");
}
};
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
← Vuex原理剖析 手撸一个自己的前端脚手架 →