回到顶部
一: 什么是服务器端渲染? 什么是客户端渲染? 他们的优缺点?
1. 服务器端渲染及客户端渲染.
在互联网早期, 前端页面都是一些简单的页面, 那么前端页面都是后端将 HTML 拼接好, 然后将它返回给前端完整的 HTML 文件. 浏览器拿到这个 HTML 文件之后就可以直接显示了, 这就是我们所谓的服务器端渲染. 比如典型的 java + velocity.node + jade 进行 HTML 模板拼接及渲染. velocity 语法在后端编写完成后, 后端会重新编译后, 将一些 vm 页面的变量编译成真正值的时候, 把 HTML 页面返回给浏览器, 浏览器就能直接解析和显示出来了. 这种模式就是服务器端渲染. 而随着前端页面复杂性越来越高, 前端就不仅仅是页面展现了, 还有可能需要添加更多复杂功能的组件. 及 2005 年前后, Ajax 兴起, 就逐渐出现前端这个行业, 前后端分离就变得越来越重要. 因此这个时候后端它就不提供完整的 HTML 页面, 而是提供一些 API 接口, 返回一些 JSON 数据, 我们前端拿到该 JSON 数据之后再使用 HTML 对数据进行拼接, 然后展现在浏览器上.
那么这种方式就是客户端渲染了, 因此这样我们前端就不需要去编写 velocity 语法, 前端可以专注于 UI 的开发. 后端专注于逻辑的开发.
2. 服务器端渲染和客户端渲染的区别?
服务器端渲染和客户端的渲染的本质区别是谁来渲染 HTML 页面, 如果 HTML 页面在服务器端那边拼接完成后, 那么它就是服务器端渲染, 而如果是前端做的 HTML 拼接及渲染的话, 那么它就属于客户端渲染的.
3. 服务器端渲染的优点和缺点?
优点:
1. 有利于 SEO 搜索引擎, 后端直接返回 HTML 文件, 爬虫可以获取到信息.
2. 前端耗时少, 首屏性能更好, 因此页面是服务器端输出的, 前端不需要通过 Ajax 去动态加载.
3. 不需要占用客户端的资源, 因为解析 HTML 模板的工作是交给服务器端完成的, 客户端只需要解析标准的 HTML 页面即可. 这样客户端占用的资源会变少.
4. 后端生成静态文件, 即生成缓存片段, 这样就可以减少数据库查询的时间.
缺点:
1. 不利于前后端分离, 开发效率比较低. 比如我们前端需要编写 velocity 语法, 如果对该语法不熟悉的话, 还需要去学习下, 并且编写完成后, 还需要调用后端的变量, 把变量输出到 HTML 对应位置上, 编写完成后, 要在 HTML 模板中加入一些资源文件路径, 所有工作完成后, 把 HTML 模板交给后端, 后端再对该模板进行服务器端编译操作. 那么等以后维护的时候, 我们前端需要在某块 HTML 中插入其他的东西, 由于之前编写的页面没有对应的标识, 比如 id 等, 那么我们现在又需要去修改 vm 模板页面等等这样的事情. 也就是说工作效率非常低. 维护不方便.
4. 客户端渲染的优点和缺点?
优点:
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 等这些. 并且 Windows,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, 一样会看到如下信息: 如下所示:
GitHub 源码查看(ssr-demo1)
回到顶部
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 中的
- Windows.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 资源文件会动态的渲染到页面上去.
GitHub 源码查看(ssr-demo2)
回到顶部
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 文件后, 然后命令会重新进行打包, 如下所示:
如上就是我们所有处理开发环境和正式环境的配置代码.
GitHub 源码查看(ssr-demo3)
回到顶部
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 序列化 Windows.__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 中的
- Windows.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 将作为 Windows.__INITIAL_STATE__ 状态, 自动嵌入到最终的 HTML 中. 而在客户端, 在挂载到应用程序之前, store 就应该获取到状态:
因此我们的 entry-client.JS 代码先变成这样. 如下所示:
- import { createApp } from './app';
- const { App, router, store } = createApp();
- if (Windows.__INITIAL_STATE__) {
- store.replaceState(Windows.__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 (Windows.__INITIAL_STATE__) {
- store.replaceState(Windows.__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 (Windows.__INITIAL_STATE__) {
- store.replaceState(Windows.__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 代码如下:
GitHub 上的源码 (ssr-demo4)
回到顶部
4.5 页面注入不同的 Head
官方文档(https://ssr.vuejs.org/zh/guide/head.html) 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 的时候, 可以看到如下页面:
GitHub 源码查看 (ssr-demo5)
回到顶部
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)
来源: https://www.cnblogs.com/tugenhua0707/p/11048465.html