所谓服务端渲染就是将代码的渲染交给服务器,服务器将渲染好的html字符串返回给客户端,再由客户端进行显示。javascript
使用Vue的服务端渲染功能,须要引入Vue提供的服务端渲染模块vue-server-renderer,其做用是建立一个渲染器,该渲染器能够将Vue实例渲染成html字符串。css
用Koa来搭建一个web服务器来实现:
① 目录结构html
② 建立一个server.js 文件vue
const Koa = require("koa"); const Router = require("koa-router"); const fs = require("fs"); const app = new Koa(); // 建立服务器端app实例 const router = new Router(); // 建立服务器端路由 const Vue = require("vue"); const VueServerRender = require("vue-server-renderer"); // 引入服务端渲染模块 const vm = new Vue({ // 建立Vue实例 data() { return {msg: "hello vm"} }, template: `<div>{{msg}}</div>` // 渲染器会将vue实例中的数据填入模板中并渲染成对应的html字符串 }); const template = fs.readFileSync("./server.template.html", "utf8"); // 读取基本的html结构 const render = VueServerRender.createRenderer({ template }); // 建立渲染器并以server.template.html做为html页面的基本结构 router.get("/", async ctx => { // ctx.body = await render.renderToString(vm); ctx.body = await new Promise((resolve, reject) => { render.renderToString(vm, (err, html) => { // 将vm实例渲染成html并插入到server.template.html模板中 console.log(`${html}`); }); ); }); app.use(router.routes()); // 添加路由中间件 app.listen(3000, () => { console.log("node server listening on port 3000."); }); // 监听3000端口
注意:java
<div data-server-rendered="true">hello vm</div>
上面初体验中,咱们已经实现了一个简单的Vue服务端渲染,可是咱们实际中Vue是一个很大的项目,里面是包含了不少组件的大型应用,而不是像初体验中的一个简单的Vue实例,因此咱们必须引入一个Vue项目,包括Vue的入口文件main.js、App.vue、components、public/index.html等,如:node
经过webpack来打包咱们的整个Vue项目,webpack将以Vue的根实例main.js做为入口文件,打包出一个合并的最终的bundle.js和一个页面入口index.html文件,该index.html文件引入bundle.js后就能加载整个Vue项目中的页面以及页面中的事件等等,这里咱们的Vue项目是一个很简单的模板项目,关键在于webpack的配置
// webpack.config.jswebpack
const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const VueLoader = require("vue-loader/lib/plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = { entry: resolve("./src/main.js"), // webpack 入口, 即Vue的入口文件main.js output: { filename: "bundle.js", // 打包后输出的结果文件名 path: resolve("./dist") // 打包后输出结果存放目录 }, resolve: { extensions: [".js", ".vue"] // 没有写扩展名的时候,解析顺序 }, module: { rules: [ { test: /\.js$/, use: { loader: "babel-loader", // 将全部的js文件经过babel-loader转换为ES5代码 options: { presets: ["@babel/preset-env"] } }, exclude: /node_modules/ }, { test: /\.css$/, // 解析.vue文件中的css use: [ "vue-style-loader", "css-loader" ] }, { test: /\.vue$/, // 解析.vue文件,须要配合其中的插件进行使用 use: "vue-loader" } ] }, plugins: [ new VueLoader(), // 解析.vue文件的插件 new HtmlWebpackPlugin({ filename: 'index.html', // 打包后输出的html文件名 template: resolve("./public/index.html") // 该模板文件在哪 }) ] }
打包输出后的dist目录中会出现两个文件: bundle.js和index.html, 直接在本地点击index.html文件便可执行并呈现整个Vue项目
① 在非服务端渲染的时候,咱们使用的打包入口文件是main.js,其主要就是建立了一个Vue实例,而且渲染App.vue,而后将渲染好的App.vue挂载到index.html文件#app元素中,可是咱们的服务端渲染是没法mount的,也就是说没法将渲染结果渲染到#app元素上,因此须要改造main.js文件web
// 改造后的main.js文件vue-router
import Vue from "vue"; import App from "./App"; /** 1. main.js在服务端渲染中的做用就是提供一个Vue项目的根实例,因此导出一个函数 2. 让客户端和服务端都能获取到Vue项目的根实例,而后根据须要, 3. 客户端经过手动调用$mount()进行挂载 4. */ export default () => { const app = new Vue({ render: h => h(App) }); return {app}; // 返回整个Vue根实例 }
② 新建两个入口文件: client-entry.js 和 server-entry.js
// client-entry.jsvuex
import createApp from "./main"; const {app} = createApp(); // 获取到Vue项目根实例 app.$mount("#app"); // 将根实例挂载到#app上
此时将webpack.config.js的入口文件改为client-entry.js应该和以前是同样的
// server-entry.js
import createApp from "./main"; /** * 服务端须要调用当前这个文件产生一个Vue项目的根实例 * 因为服务端与客户端是1对多的关系,因此不能每一个客户端访问都返回同一个Vue项目根实例 * 因此须要返回一个函数,该函数返回一个新的Vue项目根实例 * */ export default () => { const {app} = createApp(); // 获取到Vue项目根实例 return app; }
为何客户端入口文件就不须要暴露一个一个函数?由于客户端能够被访问屡次,即屡次执行,每次执行返回的都是一个新的Vue项目实例了。而服务器只会启动一次,可是却须要每次客户端访问都返回一个新的Vue项目实例,因此必须放到函数中
③ 拆分webapck.config.js, 将其分红两个配置文件,一样一个用于客户端,一个用于服务端打包
因为客户端和服务端的webpack配置文件有不少是相同的,因此能够抽取出一个webpack.base.js
// webpack.base.js
const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const VueLoader = require("vue-loader/lib/plugin"); module.exports = { output: { filename: "[name].bundle.js", // 打包后输出的结果文件名 path: resolve("./../dist/") // 打包后输出结果存放目录 }, resolve: { extensions: [".js", ".vue"] // 没有写扩展名的时候,解析顺序 }, module: { rules: [ { test: /\.js$/, use: { loader: "babel-loader", // 将全部的js文件经过babel-loader转换为ES5代码 options: { presets: ["@babel/preset-env"] } }, exclude: /node_modules/ }, { test: /\.css$/, // 解析.vue文件中的css use: [ "vue-style-loader", "css-loader" ] }, { test: /\.vue$/, // 解析.vue文件,须要配合其中的插件进行使用 use: "vue-loader" } ] }, plugins: [ new VueLoader(), // 解析.vue文件的插件 ] }
// webpack-client.js
const merge = require("webpack-merge"); const base = require("./webpack.base"); const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = merge(base, { entry: { client: resolve("./../src/client-entry.js"), // 给客户端入口文件取名client,output的时候能够获取到该名字动态输出 }, plugins: [ new HtmlWebpackPlugin({ filename: 'index.html', // 打包后输出的html文件名 template: resolve("./../public/index.html") // 该模板文件在哪 }) ] });
// webpack-server.js
const merge = require("webpack-merge"); const base = require("./webpack.base"); const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = merge(base, { entry: { server: resolve("./../src/server-entry.js"), // 给客户端入口文件取名client,output的时候能够获取到该名字动态输出 }, target: "node", // 给node使用 output: { libraryTarget: "commonjs2" // 把最终这个文件导出的结果放到module.exports上 }, plugins: [ new HtmlWebpackPlugin({ filename: 'index.server.html', // 打包后输出的html文件名 template: resolve("./../public/index.server.html"), // 该模板文件在哪 excludeChunks: ["server"] // 排除某个模块, 不让打包输出后的server.bundle.js文件引入到index.server.html文件中 }) ] });
服务端webpack配置文件比较特殊,在output的时候须要配置一个libraryTarget,由于默认webpack输出的时候是将打包输出结果放到一个匿名自执行函数中的,经过将libraryTarget设置为commonjs2,就会将整个打包结果放到module.exports上;
服务端webpack打包后输出的server.bundle.js文件不是直接引入到index.server.html文件中使用的,还须要通过处理渲染成html字符串才能插入到index.server.html文件中,因此打包输出后,要在html-webpack-plugin中排除对该模块的引用
因为webpack配置文件被分割,因此启动webapck-dev-server的时候须要指定配置文件,在package.json文件中添加脚本
"scripts": { "client:dev": "webpack-dev-server --config ./build/webpack.client.js --mode development", "client:build": "webpack --config ./build/webpack.client.js --mode development", "server:build": "webpack --config ./build/webpack.server.js --mode development" },
此时分别指向npm run client:build 和 npm run server:build便可在dist目录下生成index.html、client.bundle.js, index.server.html、server.bundle.js,其中client.bundel.js被index.html引用,server.bundle.js没有被index.server.html引入,index.server.html仅仅是拷贝到了dist目录下,同时server.bundle.js的整个输出结果是挂在module.exports下的
④ 将打包好的server.bundle.js交给服务器进行渲染并生成html字符串返回给客户端,和以前初体验同样,建立一个web服务器,只不过,此次不是渲染一个简单的Vue实例,而是渲染整个打包好的server.bundle.js
vue-server-renderer提供了两种渲染方式:
// 获取server.bundle.js中的Vue实例进行渲染 const VueServerRender = require("vue-server-renderer"); // 引入服务端渲染模块 const template = fs.readFileSync("./server.template.html", "utf8"); // 读取基本的html结构 const render = VueServerRender.createRenderer({ template }); // 建立渲染器并以server.template.html做为html页面的基本结构 router.get("/", async ctx => { const vm = require("./dist/server.bundle").default(); // 执行server.budle的default方法获取Vue实例,每次请求获取一个新的Vue实例 ctx.body = await new Promise((resolve, reject) => { render.renderToString(vm, (err, html) => { // 将vm实例渲染成html并插入到server.template.html模板中 if (err) reject(err); console.log(`${html}`); resolve(html); }); }); });
require server.bunlde.js以后调用default属性获取的方法, 其实就是server.entry.js中导出的方法,这个方法能够接收路由参数,后面集成路由的时候会用到
// 直接渲染server.bundle.js const VueServerRender = require("vue-server-renderer"); // 引入服务端渲染模块 // 读取server.bundle.js中的内容,即文件中的字符串 const ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8"); const template = fs.readFileSync("./dist/index.server.html", "utf8"); // 读取基本的html结构 const render = VueServerRender.createBundleRenderer(ServerBundle, { // 传入server.bundle.js字符串建立渲染器 template }); router.get("/", async ctx => { ctx.body = await new Promise((resolve, reject) => { render.renderToString((err, html) => { // 将server.bundle.js渲染成html字符串 if (err) reject(err); resolve(html); }); }); });
render.renderToString()执行的时候内部也是要经过ServerBundle获取到server.entry.js中导出的default()方法获取到Vue项目实例进行渲染的,总之就是要获取到Vue项目的实例进行渲染
重启服务器,再次访问,查看源码,能够看到页面已经不是一个空的基础页面了,而是真实包含html内容的页面,可是仍然存在一个问题,那就是以前的事件并不起做用了,由于服务器将sever.bundle.js渲染成的是html字符串返回给客户端的,是不包含事件的,其中的事件执行函数在client.bundle.js中,因此咱们能够在index.server.html文件中经过script标签显式地引入client.bundle.js,如:
<body> <!--vue-ssr-outlet--> <script src="client.bundle.js"></script> </body>
注意: 当访问页面的时候,就会向服务器请求client.bundle.js文件,因此服务器须要将client.bundle.js以静态资源的方式发布出去。
刚才咱们是手动在index.server.html中经过script标签引入client.bundle.js, 很是的不方便,vue-server-renderer给咱们提供了两个插件,vue-server-renderer/client-plugin和vue-server-renderer/server-plugin,能够在webpack配置文件中引入,那么打包的时候,会分别生成两个json文件,vue-ssr-client-manifest.json和vue-ssr-server-bundle.json,这两个文件主要是生成客户端和服务端bundle的对应关系,这样就不须要咱们收到引入client.bundle.js了。
以前是经过读取server.bundle.js的内容来渲染的,如今能够直接requirevue-ssr-server-bundle.json文件便可,同时在渲染的时候再添加vue-ssr-client-manifest.json便可,如:
// 直接渲染server.bundle.js const VueServerRender = require("vue-server-renderer"); // 引入服务端渲染模块 // 读取server.bundle.js中的内容,即文件中的字符串 // const ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8"); const ServerBundle = require("./dist/vue-ssr-server-bundle.json"); const clientManifest = require("./dist/vue-ssr-client-manifest.json"); const template = fs.readFileSync("./dist/index.server.html", "utf8"); // 读取基本的html结构 const render = VueServerRender.createBundleRenderer(ServerBundle, { // 传入server.bundle.js字符串建立渲染器 template, clientManifest });
使用者两个插件以后,就不会生成server.bundle.js文件了
要集成路由,那么须要在Vue项目中加入路由功能,和客户端路由配置同样,只不过不是直接导出路由实例,而是和main.js同样导出一个方法返回一个新的路由实例,如:
import Vue from "vue"; import VueRouter from "vue-router"; import Foo from "./components/Foo"; Vue.use(VueRouter); export default () => { // 导出函数返回路由实例 const router = new VueRouter({ mode: "history", routes: [ { path: "/", component: Foo }, { path: "/bar", component: () => import("./components/Bar.vue") } ] }); return router; }
而后在main.js中调用路由方法获取路由实例并挂到Vue实例上,同时对外暴露,如:
export default () => { const router = createRouter(); const app = new Vue({ router, // 挂在路由实例到Vue实例上 render: h => h(App) }); return {app, router}; // 对外暴露路由实例 }
此时Vue项目已经实现路由功能,可是访问的时候却会报错, The client-side rendered virtual DOM tree is not matching server-rendered content,即 客户端和服务端渲染的页面不一致,之因此出现这种状况是由于, 客户端加了路由功能进行了相应的路由跳转,可是服务端没有进行路由跳转,因此页面会不一致,解决方法就是, 服务器也要进行相应的路由跳转
前面提到过createBundleRenderer()方法建立的渲染器在执行renderToString()方法的时候,能够传递一个context上下文对象,能够将客户端的访问url保存到context对象上,而这个context对象会传到server.entry.js对外暴露函数中,而后在该函数中获取路由进行相应跳转便可,如:
// server.entry.js
export default (context) => { const {app, router} = createApp(); // 获取到Vue项目根实例server console.log("至关于新建立了一个服务端"); router.push(context.url); // 在服务端进行路由跳转 return app; }
此时再访问页面,就不会出现上述客户端和服务端渲染页面不一致的状况了,可是还有一个问题,那就是咱们在浏览器中直接访问路由路径的时候,会提示404,由于咱们服务器并无配置相应的路由,因此客户端定义的路由路径,须要在服务器端进行相应的配置
还有就是异步组件渲染的问题,咱们如今的server.entry.js中是直接返回Vue实例的,同时在其中进行router跳转,若是路由跳转的那个是异步组件,可能还没跳转完成,就返回了Vue实例,而出现渲染异常的状况,因此咱们要返回一个Promise对象,等路由跳转完成后再返回Vue实例,如:
// 改造后的sever.entry.js
export default (context) => { return new Promise((resolve, reject) => { const {app, router} = createApp(); // 获取到Vue项目根实例server router.push(context.url); router.onReady(() => { // 等路由跳转完成 let matchs = router.getMatchedComponents(); if (matchs.length === 0) { reject({code: 404}); } resolve(app); }, reject); }); }
404页面的处理,咱们能够在router.onReady回调中进行处理,能够根据路由匹配结果进行提示,若是路由匹配结果为0,那么就是没有匹配成功则reject一个错误,服务器捕获到错误后进行404提示便可
一样,要集成Vuex,首先和客户端渲染同样,引入Vuex并建立store,只不过是对外暴露一个函数,而后在函数中返回新的store对象,如:
// store.js
import Vue from "vue"; import Vuex from "vuex"; Vue.use(Vuex); export default () => { const store = new Vuex.Store({ state: { name: "even" }, mutations: { changeName(state) { state.name = "lhb"; } }, actions: { changeName({commit}) { console.log("changeName action"); return new Promise((resolve, reject) => { setTimeout(() => { commit("changeName"); resolve(); }, 3000); }); } } }); return store; }
而后在main.js中引入并注入到Vue实例中,跟Vue根实例和路由同样对外暴露。服务端渲染集成Vuex关键在于服务端渲染的时候执行mutaion或者action后,Vuex中数据仅在服务器端改变,因此须要将服务器端的状态数据保存起来,实际上会保存到window对象的__INITIAL_STATE__属性上,客户端渲染的时候只须要从window.__INITIAL_STATE__数据中获取到服务端Vuex的状态而后进行替换便可。
① 在Foo.vue组件中添加一个asyncData()方法,用于派发action,如:
// Foo.vue
export default { asyncData(store) { // asyncData只在服务端执行 console.log("asyncData"); return store.dispatch('changeName'); } }
② 在server-entry.js中,若是匹配到了Foo.Vue组件,那么执行该组件的asyncData()方法,此时服务器端的Vuex的状态就会发生改变,如:
// server-entry.js
export default (context) => { return new Promise((resolve, reject) => { console.log(context.url); const {app, router, store} = createApp(); // 获取到Vue项目根实例server router.push(context.url); router.onReady(() => { // 等路由跳转完成 let matchs = router.getMatchedComponents(); Promise.all(matchs.map((component) => { if (component.asyncData) { // 若是匹配的组件中含有asyncData方法则执行 return component.asyncData(store); // 服务器端Vuex状态会发生改变 } })).then(() => { console.log("success"); context.state = store.state; // 服务器端store状态改变后将其挂载到context上,而后会挂载到window的__INITIAL_STATE__上 resolve(app); }); if (matchs.length === 0) { reject({code: 404}); } }, reject); }); }
将服务器Vuex状态保存的时候,必须是保存到context的state属性上,服务器端渲染完成后,会添加一个script标签其中只有一行代码,就是将服务器端Vuex状态保存到window.__INITIAL_STATE__上
<script>window.__INITIAL_STATE__={"name":"lhb"}</script>
③ 接下来就是须要客户端去取出window.__INITIAL_STATE__中的状态数据并替换,在store.js中返回store对象前进行判断,若是是客户端执行Vuex,那么取出window.__INITIAL_STATE__中的状态数据并替换,如:
if(typeof window !== "undefined" && window.__INITIAL_STATE__) { // 若是是客户端执行 store.replaceState(window.__INITIAL_STATE__); // 将服务器端store状态替换掉客户端状态 } return store;
将Vuex中的数据显示出来,此时再访问Foo.vue就能够看到name数据的变化了,咱们如今只有在进行服务器端渲染Foo.vue的时候才会执行asyncData()方法,数据才会发生变化,若是在客户端进行渲染Foo.vue组,那么不会执行asyncData(),因此能够在Foo.vue组件mounted的时候派发一个相同的action进行数据改变便可
// Foo.vue
export default { mounted () { this.$store.dispatch("changeName"); } }