简介
Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。
Vue SSR 指南是这样介绍的。
通俗点讲,就是在服务端渲染好 html 模版返回给浏览器。
不同端渲染的区别
| 区别 | 客户端渲染 | 服务端渲染 |
|---|---|---|
| SEO 搜索引擎优化 | 不利于 | 利于 |
| 爬虫抓取 | 很难 | 可以 |
| 首屏加载时间 | 慢 | 快 |
| CPU 和内存资源 | 正常 | 更多 |
| 浏览器 API | 正常 | 部分无法正常使用 |
| 框架生命周期 | 正常 | 部分无法正常使用 |
手写 ssr
核心原理
通过 webpack 打包两个入口文件,生成各自的 js 和 html。用 createRenderer 将 server.bundle 返回的 vue 实例转化为字符串,插入到 index.ssr.html文件中,并在 html 中引入 client.bundle.js,返回给浏览器。
下面让我们通过手写 ssr,来更加深入地理解其中的实现原理吧。
ssr 目录
├── config
│ ├── webpack.base.js
│ ├── webpack.client.js
│ └── webpack.server.js
├── dist
│ ├── client.bundle.js
│ ├── index.ssr.html
│ └── server.bundle.js
├── 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
入口文件
app.js
为了保证实例的唯一性所以导出一个创建实例的函数。
import Vue from "vue";
import App from "./App.vue";
export default () => {
const app = new Vue({
render: h => h(App)
});
return { app };
};
client-entry.js
客户端渲染手动挂载到 dom 元素上
import createApp from './app.js';
let {app} = createApp();
app.$mount('#app');
server-entry.js
服务端渲染只需将渲染的实例导出即可
import createApp from "./app";
export default () => {
const { app } = createApp();
return app;
};
配置客户端打包和服务端打包
webpack.base.js
基础打包配置
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
mode: 'development',
output: {
filename: '[name].bundle.js' ,
path:path.resolve(__dirname,'../dist')
},
module: {
rules: [{
test: /\.vue$/,
use: 'vue-loader'
}, {
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
}
},
exclude: /node_modules/
}, {
test: /\.css$/,
use: ['vue-style-loader', {
loader: 'css-loader',
options: {
esModule: false,
}
}]
}]
},
plugins: [
new VueLoaderPlugin()
]
}
webpack.client.js
客户端打包配置
const {merge} = require('webpack-merge');
const base =require('./webpack.base');
const path = require('path');
module.exports = merge(base,{
entry: {
client:path.resolve(__dirname, '../src/client-entry.js')
}
})
webpack.server.js
服务端打包配置
const base =require('./webpack.base');
const {merge} = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = merge(base,{
target:'node',
entry: {
server:path.resolve(__dirname, '../src/server-entry.js')
},
output:{
libraryTarget:"commonjs2" // module.exports 导出
},
plugins:[
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../public/index.ssr.html'),
filename:'server.html',
excludeChunks:['server'],//忽略server.js
minify:false,//不压缩
client:'/client.bundle.js'
}),
]
})
index.ssr.html 模版
-
<!--vue--ssr--outlet>占位符 -
之后生成的字符串会自动插入到
index.ssr.html的占位符的位置 -
通过
htmlWebpackPlugin插件上定义的变量 client 引入客户端打包的client.bundle.js文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!--vue-ssr-outlet-->
<script src="<%=htmlWebpackPlugin.options.client%>"></script>
</body>
</html>
配置运行脚本 package.json
安装concurrently 插件,可以同时运行多个命令
"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" // 服务端打包环境
"run:all": "concurrently \"npm run client:build\" \"npm run server:build\""
},
server.js 配置服务端
-
通过 server.js 开启服务
-
创建一个打包的渲染器
vueServerrender.createBundleRenderer -
createBundleRenderer找到 webpack 打包的后的函数(server.bundle.js), 内部会调用这个函数获取到 vue 的实例 -
render.renderToString()生成字符串
const Koa = require("koa");
const app = new Koa();
const Router = require("koa-router");
const router = new Router();
const VueServerRenderer = require("vue-server-renderer");
const static = require("koa-static");
const fs = require("fs");
const path = require("path");
const serverBundle = fs.readFileSync(
path.resolve(__dirname, "dist/server.bundle.js"),
"utf8"
);
const template = fs.readFileSync(
path.resolve(__dirname, "dist/server.html"),
"utf8"
);
//根据实例产生template模版字符串插入到server.html中
const render = VueServerRenderer.createBundleRenderer(serverBundle, {
template,
});
router.get("/", async ctx => {
ctx.body = await new Promise((resolve, reject) => {
render.renderToString((err, html) => {
// 必须写成回调函数的方式否则样式不生效
resolve(html);
});
});
});
// 当客户端发送请求时会先去dist目录下查找
app.use(static(path.resolve(__dirname, "dist")));
app.use(router.routes());
app.listen(3000);
集成 VueRouter配置
router.js
导出路由配置
import Vue from "vue";
import VueRouter from "vue-router";
import Foo from "./components/Foo.vue";
Vue.use(VueRouter);
export default ()=>{
let router = new VueRouter({
mode:'history',
routes:[
{path:'/',component:Foo},
{path:'/bar',component:Bar},
]
});
return router;
}
修改入口文件
每个人访问服务器都需要产生一个路由系统
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 };
};
App.vue
<template>
<div id="app">
<router-link to="/">foo</router-link>
<router-link to="/bar">bar</router-link>
<router-view></router-view>
</div>
</template>
三个小问题
1. 路由 history 模式,刷新页面不存在
路由 history 模式,有一个问题就是刷新会 404。
也就是说当用户访问了一个服务器不存在的页面(路径),服务器如何匹配到前端路由?
愚蠢的方法:不管访问什么服务端都加载到首页,前端 js 渲染的时候,会重新根据路径渲染组件。当然,我们不会采取这么愚蠢的办法。
解决方案:在浏览器发起请求时,用户访问路径被拦截,rendertostring({传入路径}),路由指向传入路径,匹配出组件,渲染出对应 url 的字符串,出入模版html返回给浏览器。
举个栗子🌰吧
比如用户渲染 bar,我们把/bar 传入到 renderString 中去。
服务端渲染后的结果就是 bar 组件,render.renderToString 生成对应 bar 组件的字符串,插入到 index.ssr.html 中返回给浏览器。
浏览器根据路径加载 js 脚本,发现路径也是/bar,就只渲染一次了。
2. 非页面级组件,页面 404
还有一个问题就是 就是用户访问,非页面级组件,我们需要返回页面 404。
解决方案:我们在入口文件中判断 matchComponents.length == 0,是的话返回 404。
server.js中 根据 code==404 返回 not found
3. 如何保证异步路由加载完成,服务端再进行渲染
解决方案:
+ router.onReady(() => {})
具体实现
修改 server.js
// 只要用户刷新就会像服务器发请求
router.get('/(.*)',async (ctx)=>{
ctx.body = await new Promise((resolve, reject) => {
render.renderToString({url:ctx.url},(err, html) => { // 通过服务端渲染 渲染后返回
if (err && err.code == 404) resolve(`not found`);
resolve(html)
})
})
})
修改入口 server.entry.js
import createApp from "./index.js";
export default ({ url }) => {
return new Promise((resolve, reject) => {
+ let { app, router } = createApp();
+ router.push(url);
+ router.onReady(() => {
+ const matchComponents = router.getMatchedComponents();
+ if (matchComponents.length == 0) {
+ return reject({ code: 404 });
+ } else {
+ resolve(app);
}
});
});
};
集成 vuex 配置
增加 store.js
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
export default ()=>{
let store = new Vuex.Store({
state:{
username:'早上吃包子'
},
mutations:{
changeName(state){
state.username = 'hello';
}
},
actions:{
changeName({commit}){
return new Promise((resolve,reject)=>{
setTimeout(() => {
commit('changeName');
resolve();
}, 1000);
})
}
}
});
return store
}
修改 app.js
引入 vuex
import Vue from 'vue';
import App from './App.vue'
import createRouter from './router.js'
+import createStore from './store.js'
export default () => {
const router = createRouter();
+ const store = createStore()
const app = new Vue({
+ router,
store,
render: h => h(App)
});
+ return { app, router,store }
}
服务端的 vue 实例和客户端的 vue 实例上都注册了 vuex 注意:vuex 服务端获取数据只能在页面级组件中使用 (带路由的) 那么问题就来了
两个问题
1. 服务端确保怎么返回的vuex数据是最新的呢?
在组件的中写一个 asyncData 方法 供服务端调用。当我们向服务端发起请求跳转到页面级组件时,服务端在渲染的时候,会获取当前路由匹配到的组件,调用组件内的 asyncData 方法,把服务端的 store 传入 asyncData。
然后再把更新了vuex的数据的字符串返回给浏览器。
2. 服务端的 vuex 里的数据怎么传递给客户端的 vuex 呢?
这里借助于window全局变量来实现。
context.state=store.state
服务端中使用 vuex,将数据保存到全局变量 window 中,浏览器用服务端渲染好的数据,替换掉 store.state。
具体实现
在服务端更新 vuex
修改 server.entry.js
import createApp from "./index.js";
export default (context) => {
const { url } = context;
return new Promise((resolve, reject) => {
let { app, router, store } = createApp();
router.push(url);
router.onReady(() => {
const matchComponents = router.getMatchedComponents();
if (matchComponents.length == 0) {
return reject({ code: 404 });
} else {
+ Promise.all(
+ matchComponents.map((component) => {
+ if (component.asyncData) {
+ return component.asyncData(store);
+ }
+ })
+ ).then(() => {
+ context.state = store.state;
+ resolve(app);
});
}
});
});
};
如上图中所示
context.state = store.state; 会在自动在返回 html 中插入
<script>window.__INITIAL_STATE__={"name":"baozi"}</script>
在浏览器运行时替换 store
window.__INITIAL_STATE__存在,则把当前客户端vuex中的state替换成window.__INITIAL_STATE__上的数据。
store.js
if(typeof window!='undefined'&&window.__INITIAL_STATE__){
store.replaceState(window.__INITIAL_STATE__)
}
小结
服务器渲染的核心是解析 vue 的实例,生成一个字符串返回给浏览器。
createRender.renderToString(vm)
let vm=new Vue({
template:`<div>hello world</div>`
})
上面我们通过 webpack 打包 client-entry.js 后,返回了一个函数,函数执行后的结果是一个 promise->vue 的实例
createBundleRenderer 找到 client-entry.js 打包后的函数,内部会调用这个函数获取到 vue 的实例
.renderToString(vm)=>生成一个字符串返回给浏览器,并集成了vueRouter、vuex配置,解决了页面刷新404,vuex数据传递等问题。
芜湖 终于写完了 哈哈~