帶你五步學會Vue SSR

前言

SSR你們確定都不陌生,經過服務端渲染,能夠優化SEO抓取,提高首頁加載速度等,我在學習SSR的時候,看過不少文章,有些對我有很大的啓發做用,有些就只是照搬官網文檔。經過幾天的學習,我對SSR有了一些瞭解,也從頭開始完整的配置出了SSR的開發環境,因此想經過這篇文章,總結一些經驗,同時但願可以對學習SSR的朋友起到一點幫助。javascript

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

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

若是你如今對於我上面說的還不太瞭解,沒有關係,跟着我一步步向下走,最終你也能夠獨立配置一個SSR開發項目,全部源碼我會放到github上,你們能夠做爲參考html

正文

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

最終效果截圖:vue

純瀏覽器渲染

完整代碼查看githubjava

2. 服務端渲染,不包含Ajax初始化數據

服務端渲染SSR,相似於同構,最終要讓一份代碼既能夠在服務端運行,也能夠在客戶端運行。若是說在SSR的過程當中出現問題,還能夠回滾到純瀏覽器渲染,保證用戶正常看到頁面。node

那麼,順着這個思路,確定就會有兩個webpack的入口文件,一個用於瀏覽器端渲染weboack.client.config.js,一個用於服務端渲染webpack.server.config.js,將它們的公有部分抽出來做爲webpack.base.cofig.js,後續經過webpack-merge進行合併。同時,也要有一個server來提供http服務,我這裏用的是koawebpack

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

- 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)。github

因此,咱們要對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)完成後,才能開始使用這些上下文數據。

所以,若是你依賴由組件生命週期鉤子函數填充的上下文數據,則不建議使用流式傳輸模式。

  • webpack優化

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

2. 思考
  • 是否必須使用vuex

答案是不用。Vuex只是爲了幫助你實現一套數據存儲、更新、獲取的機制,入股你不用Vuex,那麼你就必須本身想一套方案能夠將異步獲取到的數據存起來,而且在適當的時機將它注入到組件內,有一些文章提出了一些方案,我會放到參考文章裏,你們能夠閱讀一下。

  • 是否使用SSR就必定好?

這個也是不必定的,任何技術都有使用場景。SSR能夠幫助你提高首頁加載速度,優化搜索引擎SEO,但同時因爲它須要在node中渲染整套Vue的模板,會佔用服務器負載,同時只會執行beforeCreatecreated兩個生命週期,對於一些外部擴展庫須要作必定處理才能夠在SSR中運行等等。

結語

本文經過五個步驟,從純瀏覽器端渲染開始,到配置一個完整的基於Vue + vue-router + Vuex的SSR環境,介紹了不少新的概念,也許你看完一遍不太理解,那麼結合着源碼,去本身手敲幾遍,而後再來看幾遍文章,相信你必定能夠掌握SSR

最後,本文全部源代碼都放在個人github上,若是對你有幫助的話,就來點一個贊吧~~

歡迎關注個人公衆號

微信公衆號

參考連接

相關文章
相關標籤/搜索