阅读目录javascript
一:什么是服务器端渲染?什么是客户端渲染?他们的优缺点?css
1. 服务器端渲染及客户端渲染。html
在互联网早期,前端页面都是一些简单的页面,那么前端页面都是后端将html拼接好,而后将它返回给前端完整的html文件。浏览器拿到这个html文件以后就能够直接显示了,这就是咱们所谓的服务器端渲染。好比典型的 java + velocity。node + jade 进行html模板拼接及渲染。velocity语法在后端编写完成后,后端会从新编译后,将一些vm页面的变量编译成真正值的时候,把html页面返回给浏览器,浏览器就能直接解析和显示出来了。这种模式就是服务器端渲染。而随着前端页面复杂性愈来愈高,前端就不只仅是页面展示了,还有可能须要添加更多复杂功能的组件。及2005年先后,ajax兴起,就逐渐出现前端这个行业,先后端分离就变得愈来愈重要。所以这个时候后端它就不提供完整的html页面,而是提供一些api接口, 返回一些json数据,咱们前端拿到该json数据以后再使用html对数据进行拼接,而后展示在浏览器上。
那么这种方式就是客户端渲染了,所以这样咱们前端就不须要去编写velocity语法,前端能够专一于UI的开发。后端专一于逻辑的开发。前端
2. 服务器端渲染和客户端渲染的区别?vue
服务器端渲染和客户端的渲染的本质区别是谁来渲染html页面,若是html页面在服务器端那边拼接完成后,那么它就是服务器端渲染,而若是是前端作的html拼接及渲染的话,那么它就属于客户端渲染的。java
3. 服务器端渲染的优势和缺点?node
优势:
1. 有利于SEO搜索引擎,后端直接返回html文件,爬虫能够获取到信息。
2. 前端耗时少,首屏性能更好,所以页面是服务器端输出的,前端不须要经过ajax去动态加载。
3. 不须要占用客户端的资源,由于解析html模板的工做是交给服务器端完成的,客户端只须要解析标准的html页面便可。这样客户端占用的资源会变少。
4. 后端生成静态文件,即生成缓存片断,这样就能够减小数据库查询的时间。react
缺点:
1. 不利于先后端分离,开发效率比较低。好比咱们前端须要编写 velocity语法,若是对该语法不熟悉的话,还须要去学习下,而且编写完成后,还须要调用后端的变量,把变量输出到html对应位置上,编写完成后,要在html模板中加入一些资源文件路径,全部工做完成后,把html模板交给后端,后端再对该模板进行服务器端编译操做。那么等之后维护的时候,咱们前端须要在某块html中插入其余的东西,因为以前编写的页面没有对应的标识,好比id等,那么咱们如今又须要去修改vm模板页面等等这样的事情。也就是说工做效率很是低。维护不方便。webpack
4. 客户端渲染的优势和缺点?ios
优势:
1. 先后端分离,前端只专一于前端UI开发,后端专一于API开发。
2. 用户体验更好,好比咱们前端页面能够作成spa页面。体验能够更接近原生的app.
缺点:
1. 不利于SEO,由于html页面都是经过js+dom异步动态拼接加载的,当使用爬虫获取的时候,因为js异步加载,因此获取抓取不到内容的。或者说,爬虫没法对JS爬取的能力。
2. 前端耗时多,响应比较慢,由于html模板页面放在前端去经过dom去拼接及加载,须要额外的耗时。没有服务器端渲染快。
5. 什么时候使用服务器端渲染、什么时候场景使用客户端渲染呢?
对于咱们常见的后端系统页面,交互性强,不须要考虑SEO搜索引擎的,咱们只须要客户端渲染就好,而对于一些企业型网站,没有不少复杂的交互型功能,而且须要很好的SEO(由于人家经过百度能够搜索到你的官网到),所以咱们须要服务器端渲染。另外还须要考虑的是,好比App里面的功能,首页性能很重要,好比淘宝官网等这些都须要作服务器渲染的。服务器渲染对于SEO及性能是很是友好的。
所以为了实现服务器端渲染的模式,咱们的vue2.0 和 react就加入了服务器端渲染的方式,下面咱们这边先来看看vue如何实现服务器端渲染的。
使用客户端的渲染,就有以下图所示:页面上有一个id为app的标签,而后下面就是由js动态渲染的。以下基本结构:
而后咱们能够看下网络页面返回渲染的html代码以下所示:
如上就是由客户端渲染的方式。
咱们再来了解下服务器端渲染是什么样的?
咱们能够看下 https://cn.vuejs.org/ 这个官网,而后咱们右键查看源码,能够看到它不是客户端渲染的,而是服务器端渲染的,以下图所示:
咱们再接着能够看下网络请求,服务器端返回的html文档信息以下,能够看到是服务器端渲染的,由于html内容都是服务器端拼接完成后返回到客户端的。以下图所示:
二:了解 vue-server-renderer 的做用及基本语法。
在了解vue服务器端渲染以前,咱们先来了解vue中一个插件vue-server-renderer的基本用法及做用。
该软件包的做用是:vue2.0提供在node.js 服务器端呈现的。
咱们须要使用该 vue-server-renderer 包,咱们须要在咱们项目中安装该包。使用命令以下:
npm install --save vue-server-renderer vue
API
1. createRenderer()
该方法是建立一个renderer实列。以下代码:
const renderer = require('vue-server-renderer').createRenderer();
2. renderer.renderToString(vm, cb);
该方法的做用是:将Vue实列呈现为字符串。该方法的回调函数是一个标准的Node.js回调,它接收错误做为第一个参数。以下代码:
// renderer.js 代码以下: const Vue = require('vue'); // 建立渲染器 const renderer = require('vue-server-renderer').createRenderer(); const app = new Vue({ template: `<div>Hello World</div>` }); // 生成预渲染的HTML字符串. 若是没有传入回调函数,则会返回 promise,以下代码 renderer.renderToString(app).then(html => { console.log(html); // 输出:<div data-server-rendered="true">Hello World</div> }).catch(err => { console.log(err); }); // 固然咱们也可使用另一种方式渲染,传入回调函数, // 其实和上面的结果同样,只是两种不一样的方式而已 renderer.renderToString(app, (err, html) => { if (err) { throw err; return; } console.log(html) // => <div data-server-rendered="true">Hello World</div> })
如上代码,咱们保存为 renderer.js 后,咱们使用命令行中,运行 node renderer.js 后,输出以下所示:
如上咱们能够看到,在咱们div中有一个特殊的属性 data-server-rendered,该属性的做用是告诉VUE这是服务器渲染的元素。而且应该以激活的模式进行挂载。
3. createBundleRenderer(code, [rendererOptions])
Vue SSR依赖包 vue-server-render, 它的调用支持有2种格式,createRenderer() 和 createBundleRenderer(), 那么createRenderer()是以vue组件为入口的,而 createBundleRenderer() 以打包后的JS文件或json文件为入口的。因此createBundleRenderer()的做用和 createRenderer() 做用是同样的,无非就是支持的入口文件不同而已;咱们能够简单的使用 createBundleRenderer该方法来作个demo以下:
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer; // 绝对文件路径 let renderer = createBundleRenderer('./package.json'); console.log(renderer);
咱们把该js保存为 renderer.js, 而后咱们在node中运行该js文件。node renderer.js 后看到该方法也一样有 renderToString() 和 renderToStream() 两个方法。以下图所示:
三:与服务器集成
从上面的知识学习,咱们了解到要服务器端渲染,咱们须要用到 vue-server-renderer 组件包。该包的基本的做用是拿到vue实列并渲染成html结构。
所以咱们须要在咱们项目的根目录下新建一个叫app.js ,而后代码以下:
const Vue = require('vue'); const Koa = require('koa'); const Router = require('koa-router'); const renderer = require('vue-server-renderer').createRenderer(); // 1. 建立koa koa-router实列 const app = new Koa(); const router = new Router(); // 2. 路由中间件 router.get('*', async(ctx, next) => { // 建立vue实列 const app = new Vue({ data: { url: ctx.url }, template: `<div>访问的URL是:{{url}}</div>` }) try { // vue 实列转换成字符串 const html = await renderer.renderToString(app); ctx.status = 200; ctx.body = ` <!DOCTYPE html> <html> <head><title>vue服务器渲染组件</title></head> <body>${html}</body> </html> ` } catch(e) { console.log(e); ctx.status = 500; ctx.body = '服务器错误'; } }); // 加载路由组件 app .use(router.routes()) .use(router.allowedMethods()); // 启动服务 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
所以当咱们访问页面的时候,好比访问:http://localhost:3000/xx 的时候,就能够看到以下所示:
如上就是一个简单服务器端渲染的简单页面了,为了简化页面代码,咱们能够把上面的html代码抽离出来成一个 index.template.html, 代码以下:
<!DOCTYPE html> <html> <head> <!-- 三花括号不会进行html转义 --> {{{ meta }}} <title>{{title}}</title> </head> <body> <!--vue-ssr-outlet--> </body> </html>
如今咱们再来改下 app.js 代码,咱们能够经过node中的 fs模块读取 index.template.html 页面代码进去,以下所示的代码:
const Vue = require('vue'); const Koa = require('koa'); const Router = require('koa-router'); const renderer = require('vue-server-renderer').createRenderer({ // 读取传入的template参数 template: require('fs').readFileSync('./index.template.html', 'utf-8') }); // 1. 建立koa koa-router实列 const app = new Koa(); const router = new Router(); // 2. 路由中间件 router.get('*', async(ctx, next) => { // 建立vue实列 const app = new Vue({ data: { url: ctx.url }, template: `<div>访问的URL是:{{url}}</div>` }); const context = { title: 'vue服务器渲染组件', meta: ` <meta charset="utf-8"> <meta name="" content="vue服务器渲染组件"> ` }; try { // 传入context 渲染上下文对象 const html = await renderer.renderToString(app, context); ctx.status = 200; ctx.body = html; } catch (e) { ctx.status = 500; ctx.body = '服务器错误'; } }); // 加载路由组件 app .use(router.routes()) .use(router.allowedMethods()); // 启动服务 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
而后咱们继续运行 node app.js ,而后咱们访问 http://localhost:3000/xx1 能够看到以下信息,以下所示:
也是能够访问的。
注意:html中必须包含 <!--vue-ssr-outlet--> ,renderer.renderToString函数把这行代码替换成HTML. 我以前觉得这只是一个注释,而后随便写一个注释上去,结果运行命令报错,改为这个 <!--vue-ssr-outlet--> 就能够了,所以这个的做用就是当作占位符,等 renderer.renderToString函数 真正渲染成html后,会把内容插入到该地方来。
4.1 为每一个请求建立一个新的根vue实列
在vue服务器渲染以前,咱们须要了解以下:
组件生命周期钩子函数
服务器渲染过程当中,只会调用 beforeCreate 和 created两个生命周期函数。其余的生命周期函数只会在客户端调用。
所以在created生命周期函数中不要使用的不能销毁的变量存在。好比常见的 setTimeout, setInterval 等这些。而且window,document这些也不能在该两个生命周期中使用,由于node中并无这两个东西,所以若是在服务器端执行的话,也会发生报错的。可是咱们可使用 axios来发请求的。由于它在服务器端和客户端都暴露了相同的API。可是浏览器原生的XHR在node中也是不支持的。
官方的SSR-demo
咱们如今须要把上面的实列一步步分开作demo。那么假如咱们如今的项目目录架构是以下:
|---- ssr-demo1 | |--- src | | |--- app.js # 为每一个请求建立一个新的根vue实列 | | |--- index.template.html | |--- .babelrc # 处理 ES6 的语法 | |--- .gitignore # github上排除一些文件 | |--- server.js # 服务相关的代码 | |--- package.json # 依赖的包文件
app.js 代码以下:
const Vue = require('vue'); module.exports = function createApp (ctx) { return new Vue({ data: { url: ctx.url }, template: `<div>访问的URL是:{{url}}</div>` }) }
它的做用是避免状态单列,单列模式看我这篇文章(https://www.cnblogs.com/tugenhua0707/p/4660236.html#_labe4). 单列模式最大的特色是 单例模式只会建立一个实例,且仅有一个实例。可是咱们Node.js 服务器是一个长期运行的进程,当咱们运行到该进程的时候,它会将进行一次取值而且留在内存当中,若是咱们用单列模式来建立对象的话,那么它的实列,会让每一个请求之间会发生共享。也就是说实列发生共享了,那么这样很容易致使每一个实列中的状态值会发生混乱。所以咱们这边把app.js代码抽离一份出来,就是须要为每一个请求建立一个新的实列。所以咱们会把上面的demo代码分红两部分。
server.js 代码以下:
const Vue = require('vue'); const Koa = require('koa'); const Router = require('koa-router'); const renderer = require('vue-server-renderer').createRenderer({ // 读取传入的template参数 template: require('fs').readFileSync('./src/index.template.html', 'utf-8') }); // 1. 建立koa koa-router实列 const app = new Koa(); const router = new Router(); // 引入 app.js const createApp = require('./src/app'); // 2. 路由中间件 router.get('*', async(ctx, next) => { // 建立vue实列 const app = createApp(ctx); const context = { title: 'vue服务器渲染组件', meta: ` <meta charset="utf-8"> <meta name="" content="vue服务器渲染组件"> ` }; try { // 传入context 渲染上下文对象 const html = await renderer.renderToString(app, context); ctx.status = 200; ctx.body = html; } catch (e) { ctx.status = 500; ctx.body = '服务器错误'; } }); // 加载路由组件 app .use(router.routes()) .use(router.allowedMethods()); // 启动服务 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
如上server.js 代码会引用 app.js,如代码:const createApp = require('./src/app'); 而后在 router.get('*', async(ctx, next) => {}) 里面都会调用下 const app = createApp(ctx); 这句代码,建立一个新的实列。
注意:下面讲解的 router 和 store 也会是这样作的。
src/index.template.html 代码以下:
<!DOCTYPE html> <html> <head> <!-- 三花括号不会进行html转义 --> {{{ meta }}} <title>{{title}}</title> </head> <body> <!--vue-ssr-outlet--> </body> </html>
package.json 代码以下:
{ "name": "ssr-demo1", "version": "1.0.0", "description": "", "main": "server.js", "scripts": {}, "author": "", "license": "ISC", "dependencies": { "fs": "0.0.1-security", "koa": "^2.7.0", "koa-router": "^7.4.0", "vue": "^2.6.10", "vue-server-renderer": "^2.6.10" } }
当咱们运行 node server.js 的时候,会启动3000 端口,当咱们访问 http://localhost:3000/xxx,同样会看到以下信息:以下所示:
4.2 使用vue-router路由实现和代码分割
如上demo实列,咱们只是使用 node server.js 运行服务器端的启动程序,而后进行服务器端渲染页面,可是咱们并无将相同的vue代码提供给客户端,所以咱们要实现这一点的话,咱们须要在项目中引用咱们的webpack来打包咱们的应用程序。
而且咱们还须要在项目中引入前端路由来实现这么一个功能,所以咱们项目中整个目录架构多是以下这样的:
|----- ssr-demo2 | |--- build | | |--- webpack.base.conf.js # webpack 基本配置 | | |--- webpack.client.conf.js # 客户端打包配置 | | |--- webpack.server.conf.js # 服务器端打包配置 | |--- src | | |--- assets # 存放css,图片的目录文件夹 | | |--- components # 存放全部的vue页面,固然咱们这边也能够新建文件夹分模块 | | | |--- home.vue | | | |--- item.vue | | |--- app.js # 建立每个实列文件 | | |--- App.vue | | |--- entry-client.js # 挂载客户端应用程序 | | |--- entry-server.js # 挂载服务器端应用程序 | | |--- index.template.html # 页面模板html文件 | | |--- router.js # 全部的路由 | |--- .babelrc # 支持es6 | |--- .gitignore # 排除github上的一些文件 | |--- server.js # 启动服务程序 | |--- package.json # 全部的依赖包
注意:这边会参看下官网的demo代码,可是会尽可能一步步更详细讲解,使你们更好的理解。
src/App.vue 代码以下所示:
<style lang="stylus"> h1 color red font-size 22px </style> <template> <div id="app"> <router-view></router-view> <h1>{{ msg }}</h1> <input type="text" v-model="msg" /> </div> </template> <script type="text/javascript"> export default { name: 'app', data() { return { msg: '欢迎光临vue.js App' } } } </script>
src/app.js
如上咱们知道,app.js 最主要作的事情就是 为每一个vue创造一个新的实列,在该项目中,咱们但愿建立vue实列后,而且把它挂载到DOM上。所以咱们这边先简单的使用 export 导出一个 createApp函数。基本代码以下:
import Vue from 'vue'; import App from './App.vue'; // 导出函数,用于建立新的应用程序 export function createApp () { const app = new Vue({ // 根据实列简单的渲染应用程序组件 render: h => h(App) }); return { app }; }
src/entry-client.js
该文件的做用是建立应用程序,而且将其挂载到DOM中,目前基本代码以下:
import { createApp } from './app'; const { app } = createApp(); // 假设 App.vue 模板中根元素 id = 'app' app.$mount('#app');
如上能够看到,咱们以前挂载元素是以下这种方式实现的,以下代码所示:
new Vue(Vue.util.extend({ router, store }, App)).$mount('#app');
如今呢?无非就是把他们分红两块,第一块是 src/app.js 代码实例化一个vue对象,而后返回实例化对象后的对象,而后在src/entry-client.js 文件里面实现 app对象挂载到 id 为 'app' 这个元素上。
src/entry-server.js
import { createApp } from './app'; export default context => { const { app } = createApp(); return app; }
如上是服务器端的代码,它的做用是 导出函数,而且建立vue实现,而且返回该实列后的对象。如上代码所示。可是在每次渲染中会重复调用此函数。
src/router.js
在上面的server.js 代码中会有这么一段 router.get('*', async(ctx, next) => {}) 代码,它的含义是接收任意的URL,这就容许咱们将访问的URL传递到咱们的VUE应用程序中。而后会对客户端和服务端复用相同的路由配置。所以咱们如今须要使用vue-router. router.js 文件也和app.js同样,须要为每一个请求建立一个新的 Router的实列。因此咱们的router.js 也须要导出一个函数,好比叫 createRouter函数吧。所以router.js 代码以下所示:
// router.js import Vue from 'vue'; import Router from 'vue-router'; Vue.use(Router); export function createRouter () { return new Router({ mode: 'history', routes: [ { path: '/home', component: resolve => require(['./components/home'], resolve) }, { path: '/item', component: resolve => require(['./components/item'], resolve) }, { path: '*', redirect: '/home' } ] }); }
而后咱们这边须要在 src/app.js 代码里面把 router 引用进去,所以咱们的app.js 代码须要更新代码变成以下:
import Vue from 'vue'; import App from './App.vue'; // 引入 router import { createRouter } from './router'; // 导出函数,用于建立新的应用程序 export function createApp () { // 建立 router的实列 const router = createRouter(); const app = new Vue({ // 注入 router 到 根 vue实列中 router, // 根实列简单的渲染应用程序组件 render: h => h(App) }); return { app, router }; }
更新 entry-server.js
如今咱们须要在 src/entry-server.js 中须要实现服务器端的路由逻辑。更新后的代码变成以下:
import { createApp } from './app'; export default context => { /* const { app } = createApp(); return app; */ /* 因为 路由钩子函数或组件 有多是异步的,好比 同步的路由是这样引入 import Foo from './Foo.vue' 可是异步的路由是这样引入的: { path: '/index', component: resolve => require(['./views/index'], resolve) } 如上是 require动态加载进来的,所以咱们这边须要返回一个promise对象。以便服务器可以等待全部的内容在渲染前 就已经准备好就绪。 */ return new Promise((resolve, reject) => { const { app, router } = createApp(); // 设置服务器端 router的位置 router.push(context.url); /* router.onReady() 等到router将可能的异步组件或异步钩子函数解析完成,在执行,就比如咱们js中的 window.onload = function(){} 这样的。 官网的解释:该方法把一个回调排队,在路由完成初始导航时调用,这意味着它能够解析全部的异步进入钩子和 路由初始化相关联的异步组件。 这能够有效确保服务端渲染时服务端和客户端输出的一致。 */ router.onReady(() => { /* getMatchedComponents()方法的含义是: 返回目标位置或是当前路由匹配的组件数组 (是数组的定义/构造类,不是实例)。 一般在服务端渲染的数据预加载时使用。 有关 Router的实列方法含义能够看官网:https://router.vuejs.org/zh/api/#router-forward */ const matchedComponents = router.getMatchedComponents(); // 若是匹配不到路由的话,执行 reject函数,而且返回404 if (!matchedComponents.length) { return reject({ code: 404 }); } // 正常的状况 resolve(app); }, reject); }).catch(new Function()); }
src/entry-client.js
因为路由有多是异步组件或路由钩子,所以在 src/entry-client.js 中挂载元素以前也须要 调用 router.onReady.所以代码须要改为以下所示:
import { createApp } from './app'; const { app, router } = createApp(); // App.vue 模板中根元素 id = 'app' router.onReady(() => { app.$mount('#app'); });
webpack 配置
如上基本的配置完成后,咱们如今须要来配置webpack打包配置,这边咱们使用三个webpack的配置文件,其中 webpack.base.config.js 是基本的配置文件,该配置文件主要是js的入口文件和打包后的目录文件,及通用的rules。
webpack.client.config.js 是打包客户端的vue文件。webpack.server.config.js 是打包服务器端的文件。
所以webpack.base.config.js 基本配置代码以下:
const path = require('path') // vue-loader v15版本须要引入此插件 const VueLoaderPlugin = require('vue-loader/lib/plugin') // 用于返回文件相对于根目录的绝对路径 const resolve = dir => path.posix.join(__dirname, '..', dir) module.exports = { // 入口暂定客户端入口,服务端配置须要更改它 entry: resolve('src/entry-client.js'), // 生成文件路径、名字、引入公共路径 output: { path: resolve('dist'), filename: '[name].js', publicPath: '/' }, resolve: { // 对于.js、.vue引入不须要写后缀 extensions: ['.js', '.vue'], // 引入components、assets能够简写,可根据须要自行更改 alias: { 'components': resolve('src/components'), 'assets': resolve('src/assets') } }, module: { rules: [ { test: /\.vue$/, loader: 'vue-loader', options: { // 配置哪些引入路径按照模块方式查找 transformAssetUrls: { video: ['src', 'poster'], source: 'src', img: 'src', image: 'xlink:href' } } }, { test: /\.js$/, // 利用babel-loader编译js,使用更高的特性,排除npm下载的.vue组件 loader: 'babel-loader', exclude: file => ( /node_modules/.test(file) && !/\.vue\.js/.test(file) ) }, { test: /\.(png|jpe?g|gif|svg)$/, // 处理图片 use: [ { loader: 'url-loader', options: { limit: 10000, name: 'static/img/[name].[hash:7].[ext]' } } ] }, { test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, // 处理字体 loader: 'url-loader', options: { limit: 10000, name: 'static/fonts/[name].[hash:7].[ext]' } } ] }, plugins: [ new VueLoaderPlugin() ] }
而后咱们再进行对 webpack.client.config.js 代码进行配置,该配置主要对客户端代码进行打包,而且它经过 webpack-merge 插件来对 webpack.base.config.js 代码配置进行合并。webpack.client.config.js 基本代码配置以下:
const path = require('path') const webpack = require('webpack') const merge = require('webpack-merge') const baseWebpackConfig = require('./webpack.base.config.js') // css样式提取单独文件 const MiniCssExtractPlugin = require('mini-css-extract-plugin') // 服务端渲染用到的插件、默认生成JSON文件(vue-ssr-client-manifest.json) const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') module.exports = merge(baseWebpackConfig, { mode: 'production', output: { // chunkhash是根据内容生成的hash, 易于缓存, // 开发环境不须要生成hash,目前先不考虑开发环境,后面详细介绍 filename: 'static/js/[name].[chunkhash].js', chunkFilename: 'static/js/[id].[chunkhash].js' }, module: { rules: [ { test: /\.styl(us)?$/, // 利用mini-css-extract-plugin提取css, 开发环境也不是必须 use: [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader'] }, ] }, devtool: false, plugins: [ // webpack4.0版本以上采用MiniCssExtractPlugin 而不使用extract-text-webpack-plugin new MiniCssExtractPlugin({ filename: 'static/css/[name].[contenthash].css', chunkFilename: 'static/css/[name].[contenthash].css' }), // 当vendor模块再也不改变时, 根据模块的相对路径生成一个四位数的hash做为模块id new webpack.HashedModuleIdsPlugin(), new VueSSRClientPlugin() ] })
webpack配置完成后,咱们须要在package.json定义命令来配置webpack打包命令,以下配置:
"scripts": { "build:client": "webpack --config ./build/webpack.client.config.js" },
如上配置完成后,咱们在命令行中,运行 npm run build:client 命令便可进行打包,当命令执行打包完成后,咱们会发现咱们项目的根目录中多了一个dist文件夹。除了一些css或js文件外,咱们还能够看到dist文件夹下多了一个 vue-ssr-client-manifest.json 文件。它的做用是用于客户端渲染的json文件。它默认生成的文件名就叫这个名字。
以下所示:
如上,客户端渲染的json文件已经生成了,咱们如今须要生成服务器端渲染的文件,所以咱们如今须要编写咱们服务器端的webpack.server.config.js 文件。咱们也想打包生成 vue-ssr-server-bundle.json. 服务器端渲染的文件默认也叫这个名字。所以配置代码须要编写成以下:
const path = require('path'); const webpack = require('webpack'); const merge = require('webpack-merge'); const nodeExternals = require('webpack-node-externals'); const baseConfig = require('./webpack.base.config'); const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); module.exports = merge(baseConfig, { entry: path.resolve(__dirname, '../src/entry-server.js'), /* 容许webpack以Node适用方式(Node-appropriate fashion)处理动态导入(dynamic import), 编译vue组件时,告知 vue-loader 输送面向服务器代码 */ target: 'node', devtool: 'source-map', // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports) output: { libraryTarget: 'commonjs2', filename: '[name].server.js' }, /* 服务器端也须要编译样式,不能使用 mini-css-extract-plugin 插件 ,由于该插件会使用document,可是服务器端并无document, 所以会致使打包报错,咱们能够以下的issues: https://github.com/webpack-contrib/mini-css-extract-plugin/issues/48#issuecomment-375288454 */ module: { rules: [ { test: /\.styl(us)?$/, use: ['css-loader/locals', 'stylus-loader'] } ] }, // https://webpack.js.org/configuration/externals/#function // https://github.com/liady/webpack-node-externals // 外置化应用程序依赖模块。可使服务器构建速度更快, // 并生成较小的 bundle 文件。 externals: nodeExternals({ // 不要外置化 webpack 须要处理的依赖模块。 // 你能够在这里添加更多的文件类型。例如,未处理 *.vue 原始文件, // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单 whitelist: /\.css$/ }), // 这是将服务器的整个输出 // 构建为单个 JSON 文件的插件。 // 默认文件名为 `vue-ssr-server-bundle.json` plugins: [ new webpack.DefinePlugin({ 'process.env.VUE_ENV': '"server"' }), new VueSSRServerPlugin() ] });
而后咱们须要在package.json 再加上服务器端打包命令,所以scripts配置代码以下:
"scripts": { "build:server": "webpack --config ./build/webpack.server.config.js", "build:client": "webpack --config ./build/webpack.client.config.js" },
所以当咱们再运行 npm run build:server 命令的时候,咱们就能够在dist目录下生成 渲染服务器端的json文件了,以下所示:
如上,两个文件经过打包生成完成后,咱们如今能够来编写 server.js 来实现整个服务器端渲染的流程了。
咱们在server.js 中须要引入咱们刚刚打包完的客户端的 vue-ssr-client-manifest.json 文件 和 服务器端渲染的vue-ssr-server-bundle.json 文件,及 html模板 做为参数传入 到 createBundleRenderer 函数中。所以server.js 代码改为以下:
const Vue = require('vue'); const Koa = require('koa'); const Router = require('koa-router'); const send = require('koa-send'); // 引入客户端,服务端生成的json文件, html 模板文件 const serverBundle = require('./dist/vue-ssr-server-bundle.json'); const clientManifest = require('./dist/vue-ssr-client-manifest.json'); let renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, { runInNewContext: false, // 推荐 template: require('fs').readFileSync('./src/index.template.html', 'utf-8'), // 页面模板 clientManifest // 客户端构建 manifest }); // 1. 建立koa koa-router实列 const app = new Koa(); const router = new Router(); const render = async (ctx, next) => { ctx.set('Content-Type', 'text/html') const handleError = err => { if (err.code === 404) { ctx.status = 404 ctx.body = '404 Page Not Found' } else { ctx.status = 500 ctx.body = '500 Internal Server Error' console.error(`error during render : ${ctx.url}`) console.error(err.stack) } } const context = { url: ctx.url, title: 'vue服务器渲染组件', meta: ` <meta charset="utf-8"> <meta name="" content="vue服务器渲染组件"> ` } try { const html = await renderer.renderToString(context); ctx.status = 200 ctx.body = html; } catch(err) { handleError(err); } next(); } // 设置静态资源文件 router.get('/static/*', async(ctx, next) => { await send(ctx, ctx.path, { root: __dirname + '/./dist' }); }); router.get('*', render); // 加载路由组件 app .use(router.routes()) .use(router.allowedMethods()); // 启动服务 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
所以咱们须要在package.json 加上 dev 命令,以下所示:
"scripts": { "build:server": "webpack --config ./build/webpack.server.config.js", "build:client": "webpack --config ./build/webpack.client.config.js", "dev": "node server.js" }
而后咱们在命令行控制台中 运行 npm run dev 命令后,就能够启动3000服务了。而后咱们来访问下 http://localhost:3000/home 页面就能够看到页面了。在查看效果以前,咱们仍是要看看 home 和 item 路由页面哦,以下:
src/components/home.vue 代码以下:
<template> <h1>home</h1> </template> <script> export default { name: "home", data(){ return{ } } } </script> <style scoped> </style>
src/components/item.vue 代码以下:
<template> <h1>item</h1> </template> <script> export default { name: "item", data(){ return{ } } } </script> <style scoped> </style>
而后咱们访问 http://localhost:3000/home 页面的时候,以下所示:
当咱们访问 http://localhost:3000/item 页面的时候,以下所示:
咱们能够看到 咱们的 src/App.vue 页面以下:
<style lang="stylus"> h1 color red font-size 22px </style> <template> <div id="app"> <router-view></router-view> <h1>{{ msg }}</h1> <input type="text" v-model="msg" /> </div> </template> <script type="text/javascript"> export default { name: 'app', data() { return { msg: '欢迎光临vue.js App' } } } </script>
src/index.template.html 模板页面以下:
<!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>{{ title }}</title> </head> <body> <div id="app"> <!--vue-ssr-outlet--> </div> </body> </html>
对比上面的图能够看到,咱们的App.vue 入口文件的页面内容会插入到咱们的模板页面 src/index.template.html 中的<!--vue-ssr-outlet--> 这个占位符中去。而后对应的路由页面就会插入到 src/App.vue 中的 <router-view> 这个位置上了。而且如上图能够看到,咱们的dist中的css,js资源文件会动态的渲染到页面上去。
4.3 开发环境配置
咱们如上代码是先改完vue代码后,先运行 npm run build:client 命令先打包客户端的代码,而后运行 npm run build:server 命令打包服务器端的代码,而后再就是 执行 npm run dev 命令启动 node 服务,而且每次改完代码都要重复该操做,而且在开发环境里面,这样操做很烦很烦,所以咱们如今须要弄一个开发环境,也就是说当咱们修改了vue代码的时候,咱们但愿能自动打包客户端和服务器端代码,而且能从新进行 BundleRenderr.renderToString()方法。而且能从新启动 server.js 代码中的服务。所以咱们如今须要更改server.js代码:
首先咱们来设置下是不是开发环境仍是正式环境。所以在咱们的package.json 打包配置代码变成以下:
"scripts": { "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js", "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js", "dev": "node server.js", "build": "npm run build:client && npm run build:server", "start": "cross-env NODE_ENV=production node server.js" }
咱们在 start 命令 和 build命令中增长 cross-env NODE_ENV=production 这样的配置代码,说明是正式环境下的。想要了解 webpack之process.env.NODE_ENV, 请看这篇文章。
而后当咱们在命令打包中运行 npm run dev 后,就会打包开发环境,而后咱们修改任何一个vue组件的话,或者 html文件的话,它都会自动打包生成客户端和服务器端的json文件,而后会进行自动编译,打包完成后,咱们只要刷新下页面便可生效。当咱们运行npm run start 的时候,它就会在正式环境进行打包了,当咱们运行 npm run build 后,它会从新进行打包客户端和服务器端的用于服务器端渲染的json文件的代码。
package.json配置完成后,咱们如今须要在 src/server.js 服务器端代码中区分下是 开发环境仍是正式环境,如今 server.js 代码改为以下:
src/server.js 代码
const Vue = require('vue'); const Koa = require('koa'); const path = require('path'); const Router = require('koa-router'); const send = require('koa-send'); const { createBundleRenderer } = require('vue-server-renderer'); // 动态监听文件发生改变的配置文件 const devConfig = require('./build/dev.config.js'); // 设置renderer为全局变量,根据环境变量赋值 let renderer; // 1. 建立koa koa-router实列 const app = new Koa(); const router = new Router(); // 下面咱们根据环境变量来生成不一样的 BundleRenderer 实列 if (process.env.NODE_ENV === 'production') { // 正式环境 const template = require('fs').readFileSync('./src/index.template.html', 'utf-8'); // 引入客户端,服务端生成的json文件 const serverBundle = require('./dist/vue-ssr-server-bundle.json'); const clientManifest = require('./dist/vue-ssr-client-manifest.json'); renderer = createBundleRenderer(serverBundle, { runInNewContext: false, // 推荐 template: template, // 页面模板 clientManifest // 客户端构建 manifest }); // 设置静态资源文件 router.get('/static/*', async(ctx, next) => { await send(ctx, ctx.path, { root: __dirname + '/./dist' }); }); } else { // 开发环境 const template = path.resolve(__dirname, './src/index.template.html'); devConfig(app, template, (bundle, options) => { console.log('开发环境从新打包......'); const option = Object.assign({ runInNewContext: false // 推荐 }, options); renderer = createBundleRenderer(bundle, option); }); } const render = async (ctx, next) => { ctx.set('Content-Type', 'text/html'); const handleError = err => { if (err.code === 404) { ctx.status = 404 ctx.body = '404 Page Not Found' } else { ctx.status = 500 ctx.body = '500 Internal Server Error' console.error(`error during render : ${ctx.url}`) console.error(err.stack) } } const context = { url: ctx.url, title: 'vue服务器渲染组件', meta: ` <meta charset="utf-8"> <meta name="" content="vue服务器渲染组件"> ` } try { const html = await renderer.renderToString(context); ctx.status = 200 ctx.body = html; } catch(err) { handleError(err); } next(); } router.get('*', render); // 加载路由组件 app .use(router.routes()) .use(router.allowedMethods()); // 启动服务 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
如上就是 server.js 代码,咱们使用了 如代码:if (process.env.NODE_ENV === 'production') {} 来区分是正式环境仍是开发环境,若是是正式环境的话,仍是和以前同样编写代码,以下所示:
// 下面咱们根据环境变量来生成不一样的 BundleRenderer 实列 if (process.env.NODE_ENV === 'production') { // 正式环境 const template = require('fs').readFileSync('./src/index.template.html', 'utf-8'); // 引入客户端,服务端生成的json文件 const serverBundle = require('./dist/vue-ssr-server-bundle.json'); const clientManifest = require('./dist/vue-ssr-client-manifest.json'); renderer = createBundleRenderer(serverBundle, { runInNewContext: false, // 推荐 template: template, // 页面模板 clientManifest // 客户端构建 manifest }); // 设置静态资源文件 router.get('/static/*', async(ctx, next) => { await send(ctx, ctx.path, { root: __dirname + '/./dist' }); }); }
不然的话,就是开发环境,开发环境配置代码变成以下:
// 开发环境 // 动态监听文件发生改变的配置文件 const devConfig = require('./build/dev.config.js'); const template = path.resolve(__dirname, './src/index.template.html'); devConfig(app, template, (bundle, options) => { console.log('开发环境从新打包......'); const option = Object.assign({ runInNewContext: false // 推荐 }, options); renderer = createBundleRenderer(bundle, option); });
所以在开发环境下,咱们引入了一个 build/dev.config.js文件。该文件是针对开发环境而作的配置,它的做用是nodeAPI构建webpack配置,而且作到监听文件。咱们能够经过在server.js中传递个回调函数来作从新生成BundleRenderer实例的操做。而接受的参数就是俩个新生成的JSON文件。所以 build/dev.config.js 代码配置以下:
build/dev.config.js 全部代码以下:
const fs = require('fs') const path = require('path') // memory-fs可使webpack将文件写入到内存中,而不是写入到磁盘。 const MFS = require('memory-fs') const webpack = require('webpack') // 监听文件变化,兼容性更好(比fs.watch、fs.watchFile、fsevents) const chokidar = require('chokidar') const clientConfig = require('./webpack.client.config'); const serverConfig = require('./webpack.server.config'); // webpack热加载须要 const webpackDevMiddleware = require('koa-webpack-dev-middleware') // 配合热加载实现模块热替换 const webpackHotMiddleware = require('koa-webpack-hot-middleware') // 读取vue-ssr-webpack-plugin生成的文件 const readFile = (fs, file) => { try { return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8'); } catch (e) { console.log('读取文件错误:', e); } } module.exports = function devConfig(app, templatePath, cb) { let bundle let template let clientManifest // 监听改变后更新函数 const update = () => { if (bundle && clientManifest) { cb(bundle, { template, clientManifest }) } }; // 监听html模板改变、需手动刷新 template = fs.readFileSync(templatePath, 'utf-8'); chokidar.watch(templatePath).on('change', () => { template = fs.readFileSync(templatePath, 'utf-8'); update(); }); // 修改webpack入口配合模块热替换使用 clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] // 编译clinetWebpack 插入Koa中间件 const clientCompiler = webpack(clientConfig) const devMiddleware = webpackDevMiddleware(clientCompiler, { publicPath: clientConfig.output.publicPath, noInfo: true }) app.use(devMiddleware) 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( devMiddleware.fileSystem, 'vue-ssr-client-manifest.json' )) update(); }) // 插入Koa中间件(模块热替换) app.use(webpackHotMiddleware(clientCompiler)) 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 // vue-ssr-webpack-plugin 生成的bundle bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json')) update() }); }
如上配置代码用到了 koa-webpack-dev-middleware 该插件,该插件的做用是:经过传入webpack编译好的compiler实现热加载,也就是说能够监听文件的变化,从而进行刷新网页。koa-webpack-hot-middleware 该插件的做用是:实现模块热替换操做,热模块替换在该基础上作到不须要刷新页面。所以经过该两个插件,当咱们就能够作到监听文件的变化,而且文件变化后不会自动刷新页面,可是当文件编译完成后,咱们须要手动刷新页面,内容才会获得更新。
在build/webpack.base.config.js 和 build/webpack.client.config.js 中须要判断是不是开发环境和正式环境的配置:
build/webpack.base.config.js 配置代码以下:
// 是不是生产环境 const isProd = process.env.NODE_ENV === 'production'; module.exports = { // 判断是开发环境仍是正式环境 devtool: isProd ? false : 'cheap-module-eval-source-map', }
如上 开发环境devtool咱们可使用cheap-module-eval-source-map编译会更快,css样式没有必要打包单独文件。使用vue-style-loader作处理就好,而且由于开发环境须要模块热重载,因此不提取文件是必要的。开发环境能够作更友好的错误提示。
build/webpack.client.config.js 配置代码以下:
// 是不是生产环境 const isProd = process.env.NODE_ENV === 'production'; module.exports = merge(baseWebpackConfig, { mode: process.env.NODE_ENV || 'development', module: { rules: [ { test: /\.styl(us)?$/, // 利用mini-css-extract-plugin提取css, 开发环境也不是必须 // use: [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader'] // 开发环境不须要提取css单独文件 use: isProd ? [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader'] : ['vue-style-loader', 'css-loader', 'stylus-loader'] }, ] }, });
当咱们在node命令中 运行npm run dev 后,咱们修改任何一个vue文件后,而后命令会从新进行打包,以下所示:
如上就是咱们全部处理开发环境和正式环境的配置代码。
4.4 数据预获取和状态
1. 数据预取存储容器
在服务器端渲染(SSR)期间,好比说咱们的应用程序有异步请求,在服务器端渲染以前,咱们但愿先返回异步数据后,咱们再进行SSR渲染,所以咱们须要的是先预取和解析好这些数据。
而且在客户端,在挂载(mount)到客户端应用程序以前,须要获取到与服务器端应用程序彻底相同的数据。不然的话,客户端应用程序会由于使用与服务器端应用程序不一样的状态。会致使混合失败。
所以为了解决上面的两个问题,咱们须要把专门的数据放置到预取存储容器或状态容器中,所以store就这样产生了。咱们能够把数据放在全局变量state中。而且,咱们将在html中序列化和内联预置状态,这样,在挂载到客户端应用程序以前,能够直接从store获取到内联预置状态。
所以咱们须要在咱们项目 src/store 中新建 store文件夹。所以咱们项目的目录架构就变成以下这个样子了。以下所示:
|----- ssr-demo4 | |--- build | | |--- webpack.base.conf.js # webpack 基本配置 | | |--- webpack.client.conf.js # 客户端打包配置 | | |--- webpack.server.conf.js # 服务器端打包配置 | |--- src | | |--- assets # 存放css,图片的目录文件夹 | | |--- components # 存放全部的vue页面,固然咱们这边也能够新建文件夹分模块 | | | |--- home.vue | | | |--- item.vue | | |--- app.js # 建立每个实列文件 | | |--- App.vue | | |--- entry-client.js # 挂载客户端应用程序 | | |--- entry-server.js # 挂载服务器端应用程序 | | |--- index.template.html # 页面模板html文件 | | |--- router.js # 全部的路由 | | |--- store # 存放全部的全局状态 | | | |-- index.js | | |--- api | | | |-- index.js | |--- .babelrc # 支持es6 | |--- .gitignore # 排除github上的一些文件 | |--- server.js # 启动服务程序 | |--- package.json # 全部的依赖包
如上目录架构,咱们新增了两个目录,一个是 src/store 另外一个是 src/api.
咱们按照官网步骤来编写代码,咱们在 src/store/index.js 文件里面编写一些代码来模拟一些数据。好比以下代码:
import Vue from 'vue'; import Vuex from 'vuex'; Vue.use(vuex); // 假定咱们有一个能够返回 Promise 的 import { fetchItem } from '../api/index'; export function createStore() { return new Vuex.Store({ state: { items: {} }, actions: { fetchItem({ commit }, id) { // `store.dispatch()` 会返回 Promise, // 以便咱们可以知道数据在什么时候更新 return fetchItem(id).then(item => { commit('setItem', { id, item }); }); } }, mutations: { setItem(state, { id, item }) { Vue.set(state.items, id, item); } } }); }
src/api/index.js 代码假如是以下这个样子:
export function fetchItem(id) { return Promise.resolve({ text: 'kongzhi' }) }
而后咱们的 src/app.js 代码须要更新成以下这个样子:
import Vue from 'vue'; import App from './App.vue'; // 引入 router import { createRouter } from './router'; // 引入store import { createStore } from './store/index'; import { sync } from 'vuex-router-sync'; // 导出函数,用于建立新的应用程序 export function createApp () { // 建立 router的实列 const router = createRouter(); // 建立 store 的实列 const store = createStore(); // 同步路由状态 (route state) 到 store sync(store, router); const app = new Vue({ // 注入 router 到 根 vue实列中 router, store, // 根实列简单的渲染应用程序组件 render: h => h(App) }); // 暴露 app, router, store return { app, router, store }; }
如上配置完成后,咱们须要在什么地方使用 dispatch来触发action代码呢?
按照官网说的,咱们须要经过访问路由,来决定获取哪部分数据,这也决定了哪些组件须要被渲染。所以咱们在组件 Item.vue 路由组件上暴露了一个自定义静态函数 asyncData.
注意:asyncData函数会在组件实例化以前被调用。所以不能使用this,须要将store和路由信息做为参数传递进去。
所以 src/components/item.vue 代码变成以下:
<template> <h1>{{item.title}}</h1> </template> <script> export default { asyncData ({ store, route }) { // 触发action代码,会返回 Promise return store.dispatch('fetchItem', route.params.id); }, computed: { // 从 store 的 state对象中获取item item() { return this.$store.state.items[this.$route.params.id] } } } </script>
2. 服务器端数据预取
服务器端预取的原理是:在 entry-server.js中,咱们能够经过路由得到与 router.getMatchedComponents() 相匹配的组件,该方法是获取到全部的组件,而后咱们遍历该全部匹配到的组件。若是组件暴露出 asyncData 的话,咱们就调用该方法。并将咱们的state挂载到context上下文中。vue-server-renderer 会将state序列化 window.__INITAL_STATE__. 这样,entry-client.js客户端就能够替换state,实现同步。
所以咱们的 src/entry-server.js 代码改为以下:
import { createApp } from './app'; export default context => { /* const { app } = createApp(); return app; */ /* 因为 路由钩子函数或组件 有多是异步的,好比 同步的路由是这样引入 import Foo from './Foo.vue' 可是异步的路由是这样引入的: { path: '/index', component: resolve => require(['./views/index'], resolve) } 如上是 require动态加载进来的,所以咱们这边须要返回一个promise对象。以便服务器可以等待全部的内容在渲染前 就已经准备好就绪。 */ return new Promise((resolve, reject) => { const { app, router, store } = createApp(); // 设置服务器端 router的位置 router.push(context.url); /* router.onReady() 等到router将可能的异步组件或异步钩子函数解析完成,在执行,就比如咱们js中的 window.onload = function(){} 这样的。 官网的解释:该方法把一个回调排队,在路由完成初始导航时调用,这意味着它能够解析全部的异步进入钩子和 路由初始化相关联的异步组件。 这能够有效确保服务端渲染时服务端和客户端输出的一致。 */ router.onReady(() => { /* getMatchedComponents()方法的含义是: 返回目标位置或是当前路由匹配的组件数组 (是数组的定义/构造类,不是实例)。 一般在服务端渲染的数据预加载时使用。 有关 Router的实列方法含义能够看官网:https://router.vuejs.org/zh/api/#router-forward */ const matchedComponents = router.getMatchedComponents(); // 若是匹配不到路由的话,执行 reject函数,而且返回404 if (!matchedComponents.length) { return reject({ code: 404 }); } // 对全部匹配的路由组件 调用 'asyncData()' Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute }); } })).then(() => { // 在全部预取钩子(preFetch hook) resolve 后, // 咱们的 store 如今已经填充入渲染应用程序所需的状态。 // 当咱们将状态附加到上下文, // 而且 `template` 选项用于 renderer 时, // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。 context.state = store.state resolve(app); }).catch(reject) // 正常的状况 // resolve(app); }, reject); }).catch(new Function()); }
如上官网代码,当咱们使用 template 时,context.state 将做为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中。而在客户端,在挂载到应用程序以前,store 就应该获取到状态:
所以咱们的 entry-client.js 代码先变成这样。以下所示:
import { createApp } from './app'; const { app, router, store } = createApp(); if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); } // App.vue 模板中根元素 id = 'app' router.onReady(() => { app.$mount('#app'); });
3. 客户端数据预取
在客户端,处理数据预取有2种方式:分别是:在路由导航以前解析数据 和 匹配要渲染的视图后,再获取数据。
1. 在路由导航以前解析数据 (根据官网介绍)
在这种方式下,应用程序会在所须要的数据所有解析完成后,再传入数据并处理当前的视图。它的优势是:能够直接在数据准备就绪时,传入数据到视图渲染完整的内容。可是若是数据预取须要很长时间的话,那么用户在当前视图会感觉到 "明显卡顿"。所以,若是咱们使用这种方式预取数据的话,咱们可使用一个菊花加载icon,等全部数据预取完成后,再把该菊花消失掉。
为了实现这种方式,咱们能够经过检查匹配的组件,而且在全局路由钩子函数中执行 asyncData 函数,来在客户端实现此策略。
所以咱们的 src/entry-client.js 代码更新变成以下:
import { createApp } from './app'; const { app, router, store } = createApp(); if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); } router.onReady(() => { // 添加路由钩子,用于处理 asyncData // 在初始路由 resolve 后执行 // 以便咱们不会二次预取已有的数据 // 使用 router.beforeResolve(), 确保全部的异步组件都 resolve router.beforeResolve((to, from, next) => { const matched = router.getMatchedComponents(to); const prevMatched = router.getMatchedComponents(from); // 咱们只关心非预渲染的组件 // 全部咱们须要对比他们,找出两个品牌列表的差别组件 let diffed = false const activated = matched.filter((c, i) => { return diffed || (diffed = (prevMatched[i] !== c)) }) if (!activated.length) { return next() } // 这里若是有加载指示器 (loading indicator),就触发 Promise.all(activated.map(c => { if (c.asyncData) { return c.asyncData({ store, route: to }) } })).then(() => { // 中止加载指示器(loading indicator) next() }).catch(next) }); app.$mount('#app') });
2. 匹配渲染的视图后,再获取数据。
根据官网介绍:该方式是将客户端数据预取,放在视图组件的 beforeMount 函数中。当路由导航被触发时,咱们能够当即切换视图,所以应用程序具备更快的响应速度。可是,传入视图在渲染时不会有完整的可用数据。所以,对于使用此策略的每一个视图组件,都须要具备条件的加载状态。所以这能够经过纯客户端的全局mixin来实现,所以 src/entry-client.js 代码更新成以下所示:
import { createApp } from './app'; import Vue from 'vue'; Vue.mixin({ beforeRouteUpdate (to, from, next) { const { asyncData } = this.$options; if (asyncData) { asyncData({ store: this.$store, route: to }).then(next).catch(next) } else { next(); } } }) const { app, router, store } = createApp(); if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); } router.onReady(() => { // 添加路由钩子,用于处理 asyncData // 在初始路由 resolve 后执行 // 以便咱们不会二次预取已有的数据 // 使用 router.beforeResolve(), 确保全部的异步组件都 resolve router.beforeResolve((to, from, next) => { const matched = router.getMatchedComponents(to); const prevMatched = router.getMatchedComponents(from); // 咱们只关心非预渲染的组件 // 全部咱们须要对比他们,找出两个品牌列表的差别组件 let diffed = false const activated = matched.filter((c, i) => { return diffed || (diffed = (prevMatched[i] !== c)) }) if (!activated.length) { return next() } // 这里若是有加载指示器 (loading indicator),就触发 Promise.all(activated.map(c => { if (c.asyncData) { return c.asyncData({ store, route: to }) } })).then(() => { // 中止加载指示器(loading indicator) next() }).catch(next) }); app.$mount('#app') });
在上面全部配置完成后,咱们再来看看 item.vue 代码改为以下来简单测试下,以下代码所示:
<template> <div>item页 请求数据结果:{{ item.name.text }}</div> </template> <script> export default { name: "item", asyncData ({ store, route }) { // 触发action代码,会返回 Promise return store.dispatch('fetchItem', 'name'); }, computed: { // 从 store 的 state 对象中的获取 item。 item () { console.log(this.$store.state); return this.$store.state.items; } } } </script> <style scoped> </style>
而后咱们访问 http://localhost:3000/item 就能够看到 数据能从 store中获取到了。以下所示:
如上咱们能够看到 console.log(this.$store.state); 会打印两个对象,一个是items, 另外一个是 route。
页面渲染出的html代码以下:
4.5 页面注入不一样的Head
官方文档(https://ssr.vuejs.org/zh/guide/head.html)
在如上服务器端渲染的时候,咱们会根据不一样的页面会有不一样的meta或title。所以咱们须要注入不一样的Head内容, 咱们按照官方
文档来实现一个简单的title注入。如何作呢?
1. 咱们须要在咱们的template模块中定义 <title>{{ title }}</title>, 它的基本原理和数据预取是相似的。
所以咱们项目中的 index.template.html 页面代码变成以下:
<!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>{{ title }}</title> </head> <body> <div id="app"> <!--vue-ssr-outlet--> </div> </body> </html>
注意:
1. 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation),以免 XSS 攻击。
2. 应该在建立 context 对象时提供一个默认标题,以防在渲染过程当中组件没有设置标题。
咱们按照官网来作下demo,所以咱们须要在 src/mixins 下 新建 title-mixins.js,所以咱们项目的结构目录变成以下:
|----- ssr-demo5 | |--- build | | |--- webpack.base.conf.js # webpack 基本配置 | | |--- webpack.client.conf.js # 客户端打包配置 | | |--- webpack.server.conf.js # 服务器端打包配置 | |--- src | | |--- assets # 存放css,图片的目录文件夹 | | |--- components # 存放全部的vue页面,固然咱们这边也能够新建文件夹分模块 | | | |--- home.vue | | | |--- item.vue | | |--- app.js # 建立每个实列文件 | | |--- App.vue | | |--- entry-client.js # 挂载客户端应用程序 | | |--- entry-server.js # 挂载服务器端应用程序 | | |--- index.template.html # 页面模板html文件 | | |--- router.js # 全部的路由 | | |--- store # 存放全部的全局状态 | | | |-- index.js | | |--- api | | | |-- index.js | | |---- mixins | | | |--- title-mixins.js # 管理title | |--- .babelrc # 支持es6 | |--- .gitignore # 排除github上的一些文件 | |--- server.js # 启动服务程序 | |--- package.json # 全部的依赖包
src/mixins/title-mixins.js 代码以下:
function getTitle (vm) { // 组件能够提供一个 `title` 选项 // 此选项能够是一个字符串或函数 const { title } = vm.$options; if (title) { return typeof title === 'function' ? title.call(vm) : title; } else { return 'Vue SSR Demo'; } } const serverTitleMixin = { created () { const title = getTitle(this); if (title && this.$ssrContext) { this.$ssrContext.title = title; } } }; const clientTitleMixin = { mounted () { const title = getTitle(this); if (title) { document.title = title; } } }; // 咱们能够经过 'webpack.DefinePlugin' 注入 'VUE_ENV' export default process.env.VUE_ENV === 'server' ? serverTitleMixin : clientTitleMixin;
build/webpack.server.config.js 配置代码以下:
plugins: [ // 定义全局变量 new webpack.DefinePlugin({ 'process.env.VUE_ENV': '"server"' }) ]
src/components/item.vue 代码改为以下:
<template> <div>item页 请求数据结果:{{ item.name.text }}</div> </template> <script> import titleMixin from '../mixins/title-mixins.js'; export default { name: "item", mixins: [titleMixin], title() { return 'item页面'; }, asyncData ({ store, route }) { // 触发action代码,会返回 Promise return store.dispatch('fetchItem', 'name'); }, computed: { // 从 store 的 state 对象中的获取 item。 item () { console.log(this.$store.state); return this.$store.state.items; } } } </script> <style scoped> </style>
而后咱们从新打包,访问:http://localhost:3000/item 能够看到以下页面:
src/components/home.vue 代码改为以下:
<template> <h1>home222</h1> </template> <script> import titleMixin from '../mixins/title-mixins.js'; export default { name: "home", mixins: [titleMixin], title() { return 'Home页面'; }, data(){ return{ } } } </script> <style scoped> </style>
而后咱们访问 http://localhost:3000/home 的时候,能够看到以下页面:
4.6 页面级别的缓存
缓存相关的,能够看官网这里
缓存(官网介绍):虽然vue的服务器端渲染很是快,可是因为建立组件实列和虚拟DOM节点的开销,没法与纯基于字符串拼接
的模板性能至关。所以咱们须要使用缓存策略,能够极大的提升响应时间且能减小服务器的负载。
1. 页面级别缓存
缓存,咱们可使用 micro-caching的缓存策略,来大幅提升应用程序处理高流量的能力。通常状况下须要在nginx服务器配置完成的,可是在这边咱们能够在Node.js中实现。
所以咱们这边须要在 server.js 添加官方网站代码,server.js 全部代码以下:
const Vue = require('vue'); const Koa = require('koa'); const path = require('path'); const Router = require('koa-router'); const send = require('koa-send'); // 引入缓存相关的模块 const LRU = require('lru-cache'); const { createBundleRenderer } = require('vue-server-renderer'); // 动态监听文件发生改变的配置文件 const devConfig = require('./build/dev.config.js'); // 缓存 const microCache = new LRU({ max: 100, maxAge: 1000 * 60 // 在1分钟后过时 }); const isCacheable = ctx => { // 假如 item 页面进行缓存 if (ctx.url === '/item') { return true; } return false; }; // 设置renderer为全局变量,根据环境变量赋值 let renderer; // 1. 建立koa koa-router实列 const app = new Koa(); const router = new Router(); // 下面咱们根据环境变量来生成不一样的 BundleRenderer 实列 if (process.env.NODE_ENV === 'production') { // 正式环境 const template = require('fs').readFileSync('./src/index.template.html', 'utf-8'); // 引入客户端,服务端生成的json文件 const serverBundle = require('./dist/vue-ssr-server-bundle.json'); const clientManifest = require('./dist/vue-ssr-client-manifest.json'); renderer = createBundleRenderer(serverBundle, { runInNewContext: false, // 推荐 template: template, // 页面模板 clientManifest // 客户端构建 manifest }); // 设置静态资源文件 router.get('/static/*', async(ctx, next) => { await send(ctx, ctx.path, { root: __dirname + '/./dist' }); }); } else { // 开发环境 const template = path.resolve(__dirname, './src/index.template.html'); devConfig(app, template, (bundle, options) => { console.log('开发环境从新打包......'); const option = Object.assign({ runInNewContext: false // 推荐 }, options); renderer = createBundleRenderer(bundle, option); }); } const render = async (ctx, next) => { ctx.set('Content-Type', 'text/html'); const handleError = err => { if (err.code === 404) { ctx.status = 404 ctx.body = '404 Page Not Found' } else { ctx.status = 500 ctx.body = '500 Internal Server Error' console.error(`error during render : ${ctx.url}`) console.error(err.stack) } } const context = { url: ctx.url, title: 'vue服务器渲染组件', meta: ` <meta charset="utf-8"> <meta name="" content="vue服务器渲染组件"> ` } // 判断是否可缓存,可缓存,且缓存中有的话,直接把缓存中返回 const cacheable = isCacheable(ctx); if (cacheable) { const hit = microCache.get(ctx.url); if (hit) { console.log('从缓存中取', hit); return ctx.body = hit; } } try { const html = await renderer.renderToString(context); ctx.body = html; if (cacheable) { console.log('设置缓存:', ctx.url); microCache.set(ctx.url, html); } } catch(err) { console.log(err); handleError(err); } next(); } router.get('*', render); // 加载路由组件 app .use(router.routes()) .use(router.allowedMethods()); // 启动服务 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
咱们运行代码,进入 http://localhost:3000/item 页面刷新,查看命令行,能够看到,第一次进入 item页面提示设置了缓存,1分钟内不管咱们怎么刷新页面,都是拿到缓存的数据。以下所示:
组件级别的缓存也能够查看官网的demo
页面级别的缓存能够查看github(ssr-demo6)