帶你五步學會Vue SSR

前言

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上,如果對你有幫助的話,就來點一個贊吧~~

參考鏈接

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章