帶你五步學會Vue SSR

leocoder發表於2018-10-10

前言

SSR大家肯定都不陌生,通過服務端渲染,可以優化SEO抓取,提升首頁載入速度等,我在學習SSR的時候,看過很多文章,有些對我有很大的啟發作用,有些就只是照搬官網文件。通過幾天的學習,我對SSR有了一些瞭解,也從頭開始完整的配置出了SSR的開發環境,所以想通過這篇文章,總結一些經驗,同時希望能夠對學習SSR的朋友起到一點幫助。

我會通過五個步驟,一步步帶你完成SSR的配置:

  1. 純瀏覽器渲染
  2. 服務端渲染,不包含Ajax初始化資料
  3. 服務端渲染,包含Ajax初始化資料
  4. 服務端渲染,使用serverBundleclientManifest進行優化
  5. 一個完整的基於Vue + VueRouter + Vuex的SSR工程

如果你現在對於我上面說的還不太瞭解,沒有關係,跟著我一步步向下走,最終你也可以獨立配置一個SSR開發專案,所有原始碼我會放到github上,大家可以作為參考

正文

1. 純瀏覽器渲染

這個配置相信大家都會,就是基於weback + vue的一個常規開發配置,這裡我會放一些關鍵程式碼,完整程式碼可以去github檢視。

目錄結構
- node_modules
- components  
    - Bar.vue
    - Foo.vue
- App.vue
- app.js
- index.html
- webpack.config.js
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
複製程式碼
app.js
import Vue from 'vue';
import App from './App.vue';

let app = new Vue({
  el: '#app',
  render: h => h(App)
});
複製程式碼
App.vue
<template>
  <div>
    <Foo></Foo>
    <Bar></Bar>
  </div>
</template>

<script>
import Foo from './components/Foo.vue';
import Bar from './components/Bar.vue';

export default {
  components: {
    Foo, Bar
  }
}
</script>
複製程式碼
index.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>
</head>
<body>
  <div id="app"></div>
</body>
</html>
複製程式碼
components/Foo.vue
<template>
  <div class="foo">
    <h1>Foo Component</h1>
  </div>
</template>

<style>
.foo {
  background: yellowgreen;
}
</style>
複製程式碼
components/Bar.vue
<template>
  <div class="bar">
    <h1>Bar Component</h1>
  </div>
</template>

<style>
.bar {
  background: bisque;
}
</style>
複製程式碼
webpack.config.js
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
  mode: 'development',

  entry: './app.js',

  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },

  module: {
    rules: [
      {
        test: /\.js$/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader']
        // 如果需要單獨抽出CSS檔案,用下面這個配置
        // use: ExtractTextPlugin.extract({
        //   fallback: 'vue-style-loader',
        //   use: [
        //     'css-loader',
        //     'postcss-loader'
        //   ]
        // })
      },
      {
        test: /\.(jpg|jpeg|png|gif|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10000    // 10Kb
          }
        }
      },
      {
        test: /\.vue$/,
        use: 'vue-loader'
      }
    ]
  },

  plugins: [
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({
      template: './index.html'
    }),
    // 如果需要單獨抽出CSS檔案,用下面這個配置
    // new ExtractTextPlugin("styles.css")
  ]
};
複製程式碼
postcss.config.js
module.exports = {
  plugins: [
    require('autoprefixer')
  ]
};
複製程式碼
.babelrc
{
  "presets": [
    "@babel/preset-env"
  ],
  "plugins": [
    // 讓其支援動態路由的寫法 const Foo = () => import('../components/Foo.vue')
    "dynamic-import-webpack"    
  ]
}
複製程式碼
package.json
{
  "name": "01",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "yarn run dev",
    "dev": "webpack-dev-server",
    "build": "webpack"
  },
  "dependencies": {
    "vue": "^2.5.17"
  },
  "devDependencies": {
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "babel-plugin-dynamic-import-webpack": "^1.1.0",
    "autoprefixer": "^9.1.5",
    "babel-loader": "^8.0.4",
    "css-loader": "^1.0.0",
    "extract-text-webpack-plugin": "^4.0.0-beta.0",
    "file-loader": "^2.0.0",
    "html-webpack-plugin": "^3.2.0",
    "postcss": "^7.0.5",
    "postcss-loader": "^3.0.0",
    "url-loader": "^1.1.1",
    "vue-loader": "^15.4.2",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.5.17",
    "webpack": "^4.20.2",
    "webpack-cli": "^3.1.2",
    "webpack-dev-server": "^3.1.9"
  }
}
複製程式碼
命令
啟動開發環境
yarn start
複製程式碼
構建生產環境
yarn run build
複製程式碼

最終效果截圖:

純瀏覽器渲染

完整程式碼檢視github

2. 服務端渲染,不包含Ajax初始化資料

服務端渲染SSR,類似於同構,最終要讓一份程式碼既可以在服務端執行,也可以在客戶端執行。如果說在SSR的過程中出現問題,還可以回滾到純瀏覽器渲染,保證使用者正常看到頁面。

那麼,順著這個思路,肯定就會有兩個webpack的入口檔案,一個用於瀏覽器端渲染weboack.client.config.js,一個用於服務端渲染webpack.server.config.js,將它們的公有部分抽出來作為webpack.base.cofig.js,後續通過webpack-merge進行合併。同時,也要有一個server來提供http服務,我這裡用的是koa

我們來看一下新的目錄結構:

- node_modules
- config    // 新增
    - webpack.base.config.js
    - webpack.client.config.js
    - webpack.server.config.js
- src
    - components  
        - Bar.vue
        - Foo.vue
    - App.vue
    - app.js
    - entry-client.js   // 新增
    - entry-server.js   // 新增
    - index.html
    - index.ssr.html    // 新增
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
複製程式碼

在純客戶端應用程式(client-only app)中,每個使用者會在他們各自的瀏覽器中使用新的應用程式例項。對於伺服器端渲染,我們也希望如此:每個請求應該都是全新的、獨立的應用程式例項,以便不會有交叉請求造成的狀態汙染(cross-request state pollution)。

所以,我們要對app.js做修改,將其包裝為一個工廠函式,每次呼叫都會生成一個全新的根元件。

app.js

import Vue from 'vue';
import App from './App.vue';

export function createApp() {
  const app = new Vue({
    render: h => h(App)
  });

  return { app };
}
複製程式碼

在瀏覽器端,我們直接新建一個根元件,然後將其掛載就可以了。

entry-client.js

import { createApp } from './app.js';

const { app } = createApp();

app.$mount('#app');
複製程式碼

在伺服器端,我們就要返回一個函式,該函式的作用是接收一個context引數,同時每次都返回一個新的根元件。這個context在這裡我們還不會用到,後續的步驟會用到它。

entry-server.js

import { createApp } from './app.js';

export default context => {
  const { app } = createApp();

  return app;
}
複製程式碼

然後再來看一下index.ssr.html

index.ssr.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>
</head>
<body>
  <!--vue-ssr-outlet-->

  <script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
</body>
</html>
複製程式碼

<!--vue-ssr-outlet-->的作用是作為一個佔位符,後續通過vue-server-renderer外掛,將伺服器解析出的元件html字串插入到這裡。

<script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>是為了將webpack通過webpack.client.config.js打包出的檔案放到這裡(這裡是為了簡單演示,後續會有別的辦法來做這個事情)。

因為服務端吐出來的就是一個html字串,後續的Vue相關的響應式、事件響應等等,都需要瀏覽器端來接管,所以就需要將為瀏覽器端渲染打包的檔案在這裡引入。

用官方的詞來說,叫客戶端啟用(client-side hydration)

所謂客戶端啟用,指的是 Vue 在瀏覽器端接管由服務端傳送的靜態 HTML,使其變為由 Vue 管理的動態 DOM 的過程。

在 entry-client.js 中,我們用下面這行掛載(mount)應用程式:

// 這裡假定 App.vue template 根元素的 `id="app"`
app.$mount('#app')
複製程式碼

由於伺服器已經渲染好了 HTML,我們顯然無需將其丟棄再重新建立所有的 DOM 元素。相反,我們需要"啟用"這些靜態的 HTML,然後使他們成為動態的(能夠響應後續的資料變化)。

如果你檢查伺服器渲染的輸出結果,你會注意到應用程式的根元素上新增了一個特殊的屬性:

<div id="app" data-server-rendered="true">
複製程式碼

Vue在瀏覽器端就依靠這個屬性將伺服器吐出來的html進行啟用,我們一會自己構建一下就可以看到了。

接下來我們看一下webpack相關的配置:

webpack.base.config.js

const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');

module.exports = {
  mode: 'development',

  resolve: {
    extensions: ['.js', '.vue']
  },

  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: '[name].bundle.js'
  },

  module: {
    rules: [
      {
        test: /\.vue$/,
        use: 'vue-loader'
      },
      {
        test: /\.js$/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader']
      },
      {
        test: /\.(jpg|jpeg|png|gif|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10000    // 10Kb
          }
        }
      }
    ]
  },

  plugins: [
    new VueLoaderPlugin()
  ]
};
複製程式碼

webpack.client.config.js

const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  entry: {
    client: path.resolve(__dirname, '../src/entry-client.js')
  },

  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.html'),
      filename: 'index.html'
    })
  ]
});
複製程式碼

注意,這裡的入口檔案變成了entry-client.js,將其打包出的client.bundle.js插入到index.html中。

webpack.server.config.js

const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  target: 'node',
  entry: {
    server: path.resolve(__dirname, '../src/entry-server.js')
  },
  output: {
    libraryTarget: 'commonjs2'
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.ssr.html'),
      filename: 'index.ssr.html',
      files: {
        js: 'client.bundle.js'
      },
      excludeChunks: ['server']
    })
  ]
});
複製程式碼

這裡有幾個點需要注意一下:

  1. 入口檔案是 entry-server.js
  2. 因為是打包伺服器端依賴的程式碼,所以target要設為node,同時,outputlibraryTarget要設為commonjs2

這裡關於HtmlWebpackPlugin配置的意思是,不要在index.ssr.html中引入打包出的server.bundle.js,要引為瀏覽器打包的client.bundle.js,原因前面說過了,是為了讓Vue可以將伺服器吐出來的html進行啟用,從而接管後續響應。

那麼打包出的server.bundle.js在哪用呢?接著往下看就知道啦~~

package.json

{
  "name": "01",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "yarn run dev",
    "dev": "webpack-dev-server",
    "build:client": "webpack --config config/webpack.client.config.js",
    "build:server": "webpack --config config/webpack.server.config.js"
  },
  "dependencies": {
    "koa": "^2.5.3",
    "koa-router": "^7.4.0",
    "koa-static": "^5.0.0",
    "vue": "^2.5.17",
    "vue-server-renderer": "^2.5.17"
  },
  "devDependencies": {
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "autoprefixer": "^9.1.5",
    "babel-loader": "^8.0.4",
    "css-loader": "^1.0.0",
    "extract-text-webpack-plugin": "^4.0.0-beta.0",
    "file-loader": "^2.0.0",
    "html-webpack-plugin": "^3.2.0",
    "postcss": "^7.0.5",
    "postcss-loader": "^3.0.0",
    "style-loader": "^0.23.0",
    "url-loader": "^1.1.1",
    "vue-loader": "^15.4.2",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.5.17",
    "webpack": "^4.20.2",
    "webpack-cli": "^3.1.2",
    "webpack-dev-server": "^3.1.9",
    "webpack-merge": "^4.1.4"
  }
}

複製程式碼

接下來我們看server端關於http服務的程式碼:

server/server.js

const Koa = require('koa');
const Router = require('koa-router');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const backendApp = new Koa();
const frontendApp = new Koa();
const backendRouter = new Router();
const frontendRouter = new Router();

const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

// 後端Server
backendRouter.get('/index', (ctx, next) => {
  // 這裡用 renderToString 的 promise 返回的 html 有問題,沒有樣式
  renderer.renderToString((err, html) => {
    if (err) {
      console.error(err);
      ctx.status = 500;
      ctx.body = '伺服器內部錯誤';
    } else {
      console.log(html);
      ctx.status = 200;
      ctx.body = html;
    }
  });
});

backendApp.use(serve(path.resolve(__dirname, '../dist')));

backendApp
  .use(backendRouter.routes())
  .use(backendRouter.allowedMethods());

backendApp.listen(3000, () => {
  console.log('伺服器端渲染地址: http://localhost:3000');
});


// 前端Server
frontendRouter.get('/index', (ctx, next) => {
  let html = fs.readFileSync(path.resolve(__dirname, '../dist/index.html'), 'utf-8');
  ctx.type = 'html';
  ctx.status = 200;
  ctx.body = html;
});

frontendApp.use(serve(path.resolve(__dirname, '../dist')));

frontendApp
  .use(frontendRouter.routes())
  .use(frontendRouter.allowedMethods());

frontendApp.listen(3001, () => {
  console.log('瀏覽器端渲染地址: http://localhost:3001');
});
複製程式碼

這裡對兩個埠進行監聽,3000埠是服務端渲染,3001埠是直接輸出index.html,然後會在瀏覽器端走Vue的那一套,主要是為了和服務端渲染做對比使用。

這裡的關鍵程式碼是如何在服務端去輸出``html```字串。

const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.bundle.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});
複製程式碼

可以看到,server.bundle.js在這裡被使用啦,因為它的入口是一個函式,接收context作為引數(非必傳),輸出一個根元件app

這裡我們用到了vue-server-renderer外掛,它有兩個方法可以做渲染,一個是createRenderer,另一個是createBundleRenderer

const { createRenderer } = require('vue-server-renderer')
const renderer = createRenderer({ /* 選項 */ })
複製程式碼
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, { /* 選項 */ })
複製程式碼

createRenderer無法接收為服務端打包出的server.bundle.js檔案,所以這裡只能用createBundleRenderer

serverBundle 引數可以是以下之一:

  • 絕對路徑,指向一個已經構建好的 bundle 檔案(.js.json)。必須以 / 開頭才會被識別為檔案路徑。
  • webpack + vue-server-renderer/server-plugin 生成的 bundle 物件。
  • JavaScript 程式碼字串(不推薦)。

這裡我們引入的是.js檔案,後續會介紹如何使用.json檔案以及有什麼好處。

renderer.renderToString((err, html) => {
    if (err) {
      console.error(err);
      ctx.status = 500;
      ctx.body = '伺服器內部錯誤';
    } else {
      console.log(html);
      ctx.status = 200;
      ctx.body = html;
    }
});
複製程式碼

使用createRenderercreateBundleRenderer返回的renderer函式包含兩個方法renderToStringrenderToStream,我們這裡用的是renderToString成功後直接返回一個完整的字串,renderToStream返回的是一個Node流。

renderToString支援Promise,但是我在使用Prmoise形式的時候樣式會渲染不出來,暫時還不知道原因,如果大家知道的話可以給我留言啊。

配置基本就完成了,來看一下如何執行。

yarn run build:client       // 打包瀏覽器端需要bundle
yarn run build:server       // 打包SSR需要bundle

yarn start      // 其實就是 node server/server.js,提供http服務
複製程式碼

最終效果展示:

訪問http://localhost:3000/index

SSR沒有ajax資料

我們看到了前面提過的data-server-rendered="true"屬性,同時會載入client.bundle.js檔案,為了讓Vue在瀏覽器端做後續接管。

訪問http://localhost:3001/index還和第一步實現的效果一樣,純瀏覽器渲染,這裡就不放截圖了。

完整程式碼檢視github

3. 服務端渲染,包含Ajax初始化資料

如果SSR需要初始化一些非同步資料,那麼流程就會變得複雜一些。

我們先提出幾個問題:

  1. 服務端拿非同步資料的步驟在哪做?
  2. 如何確定哪些元件需要獲取非同步資料?
  3. 獲取到非同步資料之後要如何塞回到元件內?

帶著問題我們向下走,希望看完這篇文章的時候上面的問題你都找到了答案。

伺服器端渲染和瀏覽器端渲染元件經過的生命週期是有區別的,在伺服器端,只會經歷beforeCreatecreated兩個生命週期。因為SSR伺服器直接吐出html字串就好了,不會渲染DOM結構,所以不存在beforeMountmounted的,也不會對其進行更新,所以也就不存在beforeUpdateupdated等。

我們先來想一下,在純瀏覽器渲染的Vue專案中,我們是怎麼獲取非同步資料並渲染到元件中的?一般是在created或者mounted生命週期裡發起非同步請求,然後在成功回撥裡執行this.data = xxxVue監聽到資料發生改變,走後面的Dom Diff,打patch,做DOM更新。

那麼服務端渲染可不可以也這麼做呢?答案是不行的

  1. mounted裡肯定不行,因為SSR都沒有mounted生命週期,所以在這裡肯定不行。
  2. beforeCreate裡發起非同步請求是否可以呢,也是不行的。因為請求是非同步的,可能還沒有等介面返回,服務端就已經把html字串拼接出來了。

所以,參考一下官方文件,我們可以得到以下思路:

  1. 在渲染前,要預先獲取所有需要的非同步資料,然後存到Vuexstore中。
  2. 在後端渲染時,通過Vuex將獲取到的資料注入到相應元件中。
  3. store中的資料設定到window.__INITIAL_STATE__屬性中。
  4. 在瀏覽器環境中,通過Vuexwindow.__INITIAL_STATE__裡面的資料注入到相應元件中。

正常情況下,通過這幾個步驟,服務端吐出來的html字串相應元件的資料都是最新的,所以第4步並不會引起DOM更新,但如果出了某些問題,吐出來的html字串沒有相應資料,Vue也可以在瀏覽器端通過````Vuex注入資料,進行DOM```更新。

更新後的目錄結構:

- node_modules
- config
   - webpack.base.config.js
   - webpack.client.config.js
   - webpack.server.config.js
- src
   - components  
       - Bar.vue
       - Foo.vue
   - store             // 新增
       store.js
   - App.vue
   - app.js
   - entry-client.js
   - entry-server.js   
   - index.html
   - index.ssr.html
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
複製程式碼

先來看一下store.js:

store/store.js

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const fetchBar = function() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('bar 元件返回 ajax 資料');
    }, 1000);
  });
};

function createStore() {
  const store = new Vuex.Store({
    state: {
      bar: ''
    },

    mutations: {
      'SET_BAR'(state, data) {
        state.bar = data;
      }
    },

    actions: {
      fetchBar({ commit }) {
        return fetchBar().then((data) => {
          commit('SET_BAR', data);
        }).catch((err) => {
          console.error(err);
        })
      }
    }
  });

  if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
    console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
    store.replaceState(window.__INITIAL_STATE__);
  }
  
  return store;
}

export default createStore;

typeof window
複製程式碼

如果不太瞭解Vuex,可以去Vuex官網先看一些基本概念。

vuex

這裡fetchBar可以看成是一個非同步請求,這裡用setTimeout模擬。在成功回撥中commit相應的mutation進行狀態修改。

這裡有一段關鍵程式碼:

if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
    console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
    store.replaceState(window.__INITIAL_STATE__);
}
複製程式碼

因為store.js同樣也會被打包到伺服器執行的server.bundle.js中,所以執行環境不一定是瀏覽器,這裡需要對window做判斷,防止報錯,同時如果有window.__INITIAL_STATE__屬性,說明伺服器已經把所有初始化需要的非同步資料都獲取完成了,要對store中的狀態做一個替換,保證統一。

components/Bar.vue

<template>
  <div class="bar">
    <h1 @click="onHandleClick">Bar Component</h1>
    <h2>非同步Ajax資料:</h2>
    <span>{{ msg }}</span>
  </div>
</template>

<script>
  const fetchInitialData = ({ store }) => {
    store.dispatch('fetchBar');
  };

  export default {
    asyncData: fetchInitialData,

    methods: {
      onHandleClick() {
        alert('bar');
      }
    },

    mounted() {
      // 因為服務端渲染只有 beforeCreate 和 created 兩個生命週期,不會走這裡
      // 所以把呼叫 Ajax 初始化資料也寫在這裡,是為了供單獨瀏覽器渲染使用
      let store = this.$store;
      fetchInitialData({ store });
    },

    computed: {
      msg() {
        return this.$store.state.bar;
      }
    }
  }
</script>

<style>
.bar {
  background: bisque;
}
</style>

複製程式碼

這裡在Bar元件的預設匯出物件中增加了一個方法asyncData,在該方法中會dispatch相應的action,進行非同步資料獲取。

需要注意的是,我在mounted中也寫了獲取資料的程式碼,這是為什麼呢? 因為想要做到同構,程式碼單獨在瀏覽器端執行,也應該是沒有問題的,又由於伺服器沒有mounted生命週期,所以我寫在這裡就可以解決單獨在瀏覽器環境使用也可以發起同樣的非同步請求去初始化資料。

components/Foo.vue

<template>
  <div class="foo">
    <h1 @click="onHandleClick">Foo Component</h1>
  </div>
</template>

<script>
export default {
  methods: {
    onHandleClick() {
      alert('foo');
    }
  },
}
</script>

<style>
.foo {
  background: yellowgreen;
}
</style>

複製程式碼

這裡我對兩個元件都新增了一個點選事件,為的是證明在伺服器吐出首頁html後,後續的步驟都會被瀏覽器端的Vue接管,可以正常執行後面的操作。

app.js

import Vue from 'vue';
import createStore from './store/store.js';
import App from './App.vue';

export function createApp() {
  const store = createStore();

  const app = new Vue({
    store,
    render: h => h(App)
  });

  return { app, store, App };
}
複製程式碼

在建立根元件的時候,要把Vuex的store傳進去,同時要返回,後續會用到。

最後來看一下entry-server.js,關鍵步驟在這裡:

entry-server.js

import { createApp } from './app.js';

export default context => {
  return new Promise((resolve, reject) => {
    const { app, store, App } = createApp();

    let components = App.components;
    let asyncDataPromiseFns = [];
  
    Object.values(components).forEach(component => {
      if (component.asyncData) {
        asyncDataPromiseFns.push(component.asyncData({ store }));
      }
    });
  
    Promise.all(asyncDataPromiseFns).then((result) => {
      // 當使用 template 時,context.state 將作為 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中
      context.state = store.state;
  
      console.log(222);
      console.log(store.state);
      console.log(context.state);
      console.log(context);
  
      resolve(app);
    }, reject);
  });
}
複製程式碼

我們通過匯出的App拿到了所有它下面的components,然後遍歷,找出哪些componentasyncData方法,有的話呼叫並傳入store,該方法會返回一個Promise,我們使用Promise.all等所有的非同步方法都成功返回,才resolve(app)

context.state = store.state作用是,當使用createBundleRenderer時,如果設定了template選項,那麼會把context.state的值作為window.__INITIAL_STATE__自動插入到模板html中。

這裡需要大家多思考一下,弄清楚整個服務端渲染的邏輯。

如何執行:

yarn run build:client
yarn run build:server

yarn start
複製程式碼

最終效果截圖:

服務端渲染:開啟http://localhost:3000/index

server-remder-ajax

可以看到window.__INITIAL_STATE__被自動插入了。

我們來對比一下SSR到底對載入效能有什麼影響吧。

服務端渲染時performance截圖

sercer_render_ajax_performance

純瀏覽器端渲染時performance截圖

client_render_ajax_performance

同樣都是在fast 3G網路模式下,純瀏覽器端渲染首屏載入花費時間2.9s,因為client.js載入就花費了2.27s,因為沒有client.js就沒有Vue,也就沒有後面的東西了。

服務端渲染首屏時間花費0.8s,雖然client.js載入扔花費2.27s,但是首屏已經不需要它了,它是為了讓Vue在瀏覽器端進行後續接管。

從這我們可以真正的看到,服務端渲染對於提升首屏的響應速度是很有作用的。

當然有的同學可能會問,在服務端渲染獲取初始ajax資料時,我們還延時了1s,在這個時間使用者也是看不到頁面的。沒錯,介面的時間我們無法避免,就算是純瀏覽器渲染,首頁該調介面還是得調,如果介面響應慢,那麼純瀏覽器渲染看到完整頁面的時間會更慢。

完整程式碼檢視github

4. 使用serverBundle和clientManifest進行優化

前面我們建立服務端renderer的方法是:

const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});
複製程式碼

serverBundle我們用的是打包出的server.bundle.js檔案。這樣做的話,在每次編輯過應用程式原始碼之後,都必須停止並重啟服務。這在開發過程中會影響開發效率。此外,Node.js 本身不支援 source map。

vue-server-renderer 提供一個名為 createBundleRenderer 的 API,用於處理此問題,通過使用 webpack 的自定義外掛,server bundle 將生成為可傳遞到 bundle renderer 的特殊 JSON 檔案。所建立的 bundle renderer,用法和普通 renderer 相同,但是 bundle renderer 提供以下優點:

  • 內建的 source map 支援(在 webpack 配置中使用 devtool: 'source-map'
  • 在開發環境甚至部署過程中熱過載(通過讀取更新後的 bundle,然後重新建立 renderer 例項)
  • 關鍵 CSS(critical CSS) 注入(在使用 *.vue 檔案時):自動內聯在渲染過程中用到的元件所需的CSS。更多細節請檢視 CSS 章節。
  • 使用 clientManifest 進行資源注入:自動推斷出最佳的預載入(preload)和預取(prefetch)指令,以及初始渲染所需的程式碼分割 chunk

preloadprefetch有不瞭解的話可以自行查一下它們的作用哈。

那麼我們來修改webpack配置:

webpack.client.config.js

const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  entry: {
    client: path.resolve(__dirname, '../src/entry-client.js')
  },

  plugins: [
    new VueSSRClientPlugin(),   // 新增
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.html'),
      filename: 'index.html'
    })
  ]
});
複製程式碼

webpack.server.config.js

const path = require('path');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  target: 'node',
   // 對 bundle renderer 提供 source map 支援
  devtool: '#source-map',
  entry: {
    server: path.resolve(__dirname, '../src/entry-server.js')
  },
  externals: [nodeExternals()],     // 新增
  output: {
    libraryTarget: 'commonjs2'
  },
  plugins: [
    new VueSSRServerPlugin(),   // 這個要放到第一個寫,否則 CopyWebpackPlugin 不起作用,原因還沒查清楚
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.ssr.html'),
      filename: 'index.ssr.html',
      files: {
        js: 'client.bundle.js'
      },
      excludeChunks: ['server']
    })
  ]
});
複製程式碼

因為是服務端引用模組,所以不需要打包node_modules中的依賴,直接在程式碼中require引用就好,所以配置externals: [nodeExternals()]

兩個配置檔案會分別生成vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json。作為createBundleRenderer的引數。

來看server.js

server.js

const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'));
const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'));
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8');

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  template: template,
  clientManifest: clientManifest
});
複製程式碼

效果和第三步就是一樣的啦,就不截圖了,完整程式碼檢視github

5. 配置一個完整的基於Vue + VueRouter + Vuex的SSR

這裡和第四步不一樣的是引入了vue-router,更接近於實際開發專案。

src下新增router目錄。

router/index.js

import Vue from 'vue';
import Router from 'vue-router';
import Bar from '../components/Bar.vue';

Vue.use(Router);

function createRouter() {
  const routes = [
    {
      path: '/bar',
      component: Bar
    },
    {
      path: '/foo',
      component: () => import('../components/Foo.vue')   // 非同步路由
    }
  ];

  const router = new Router({
    mode: 'history',
    routes
  });

  return router;
}

export default createRouter;
複製程式碼

這裡我們把Foo元件作為一個非同步元件引入,做成按需載入。

app.js中引入router,並匯出:

app.js

import Vue from 'vue';
import createStore from './store/store.js';
import createRouter from './router';
import App from './App.vue';

export function createApp() {
  const store = createStore();
  const router = createRouter();

  const app = new Vue({
    router,
    store,
    render: h => h(App)
  });

  return { app, store, router, App };
}
複製程式碼

修改App.vue引入路由元件:

App.vue

<template>
  <div id="app">
    <router-link to="/bar">Goto Bar</router-link> 
    <router-link to="/foo">Goto Foo</router-link> 
    <router-view></router-view>
  </div>
</template>

<script>
export default {
  beforeCreate() {
    console.log('App.vue beforeCreate');
  },

  created() {
    console.log('App.vue created');
  },

  beforeMount() {
    console.log('App.vue beforeMount');
  },

  mounted() {
    console.log('App.vue mounted');
  }
}
</script>
複製程式碼

最重要的修改在entry-server.js中,

entry-server.js

import { createApp } from './app.js';

export default context => {
  return new Promise((resolve, reject) => {
    const { app, store, router, App } = createApp();

    router.push(context.url);

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();

      console.log(context.url)
      console.log(matchedComponents)

      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }

      Promise.all(matchedComponents.map(component => {
        if (component.asyncData) {
          return component.asyncData({ store });
        }
      })).then(() => {
        // 當使用 template 時,context.state 將作為 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中
        context.state = store.state;

        // 返回根元件
        resolve(app);
      });
    }, reject);
  });
}
複製程式碼

這裡前面提到的context就起了大作用,它將使用者訪問的url地址傳進來,供vue-router使用。因為有非同步元件,所以在router.onReady的成功回撥中,去找該url路由所匹配到的元件,獲取非同步資料那一套還和前面的一樣。

於是,我們就完成了一個基本完整的基於Vue + VueRouter + VuexSSR配置,完成程式碼檢視github

最終效果演示:

訪問http://localhost:3000/bar

ssr-router

完整程式碼檢視github

後續

上面我們通過五個步驟,完成了從純瀏覽器渲染到完整服務端渲染的同構,程式碼既可以執行在瀏覽器端,也可以執行在伺服器端。那麼,回過頭來我們在看一下是否有優化的空間,又或者有哪些擴充套件的思考。

優化:
  1. 我們目前是使用renderToString方法,完全生成html後,才會向客戶端返回,如果使用renderToStream,應用bigpipe技術可以向瀏覽器持續不斷的返回一個流,那麼檔案的載入瀏覽器可以儘早的顯示一些東西出來。
const stream = renderer.renderToStream(context)
複製程式碼

返回的值是 Node.js stream

let html = ''

stream.on('data', data => {
  html += data.toString()
})

stream.on('end', () => {
  console.log(html) // 渲染完成
})

stream.on('error', err => {
  // handle error...
})
複製程式碼

在流式渲染模式下,當 renderer 遍歷虛擬 DOM 樹(virtual DOM tree)時,會盡快傳送資料。這意味著我們可以儘快獲得"第一個 chunk",並開始更快地將其傳送給客戶端。

然而,當第一個資料 chunk 被髮出時,子元件甚至可能不被例項化,它們的生命週期鉤子也不會被呼叫。這意味著,如果子元件需要在其生命週期鉤子函式中,將資料附加到渲染上下文(render context),當流(stream)啟動時,這些資料將不可用。這是因為,大量上下文資訊(context information)(如頭資訊(head information)或內聯關鍵 CSS(inline critical CSS))需要在應用程式標記(markup)之前出現,我們基本上必須等待流(stream)完成後,才能開始使用這些上下文資料。

因此,如果你依賴由元件生命週期鉤子函式填充的上下文資料,則不建議使用流式傳輸模式。

  1. webpack優化

webpack優化又是一個大的話題了,這裡不展開討論,感興趣的同學可以自行查詢一些資料,後續我也可能會專門寫一篇文章來講webpack優化。

思考
  1. 是否必須使用vuex

答案是不用。Vuex只是為了幫助你實現一套資料儲存、更新、獲取的機制,入股你不用Vuex,那麼你就必須自己想一套方案可以將非同步獲取到的資料存起來,並且在適當的時機將它注入到元件內,有一些文章提出了一些方案,我會放到參考文章裡,大家可以閱讀一下。

  1. 是否使用SSR就一定好?

這個也是不一定的,任何技術都有使用場景。SSR可以幫助你提升首頁載入速度,優化搜尋引擎SEO,但同時由於它需要在node中渲染整套Vue的模板,會佔用伺服器負載,同時只會執行beforeCreatecreated兩個生命週期,對於一些外部擴充套件庫需要做一定處理才可以在SSR中執行等等。

結語

本文通過五個步驟,從純瀏覽器端渲染開始,到配置一個完整的基於Vue + vue-router + Vuex的SSR環境,介紹了很多新的概念,也許你看完一遍不太理解,那麼結合著原始碼,去自己手敲幾遍,然後再來看幾遍文章,相信你一定可以掌握SSR

最後,本文所有原始碼都放在我的github上,如果對你有幫助的話,就來點一個贊吧~~

歡迎關注我的公眾號

微信公眾號

參考連結

我的部落格即將同步至騰訊雲+社群,邀請大家一同入駐:cloud.tencent.com/developer/s…

相關文章