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)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM