zoukankan      html  css  js  c++  java
  • webpack4+koa2+vue 实现服务器端渲染(详解)

    阅读目录

    一:什么是服务器端渲染?什么是客户端渲染?他们的优缺点?

    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 等这些。并且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,一样会看到如下信息:如下所示:

    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中的 
          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资源文件会动态的渲染到页面上去。

    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序列化 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代码如下:

    github上的源码 (ssr-demo4) 

    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 的时候,可以看到如下页面:

    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)

  • 相关阅读:
    global
    myTimer
    SQL SERVER 2008 阻止保存要求重新创建表的更改
    Singleton
    logger
    多线程编写
    如何:设置 Silverlight 应用程序以进行CodeUI自动化测试
    【Android】Application is not installed on your phone
    【转载】sql2005中判读视图、表、存储过程等是否存在的语句
    Windows7(win7)系统重装与破解
  • 原文地址:https://www.cnblogs.com/tugenhua0707/p/11048465.html
Copyright © 2011-2022 走看看