1. 程式人生 > >vue-ssr的實現原理連載(二)

vue-ssr的實現原理連載(二)

 

在上一篇文章中,通過一個koa服務和vue-server-renderer建立了一個簡單vue服務端渲染。這一片文章讓我們來進一步填充,並在vue開發中使用ssr。

這一節我們來繼續實現vue的ssr,本節所有原始碼可以檢視我的github: https://github.com/Jasonwang911/vue-ssr/tree/master/step2 ,歡迎star

首先在上一篇程式碼中繼續搭建一個簡單的vue專案: 

  我們建立src目錄和public目錄,public目錄存放一個index.html,包含一個id為app的div元素;src目錄中有一個main.js入口檔案和一個App.vue入口檔案並且包含一個components資料夾用於放置我們建立的元件。

  main.js用於建立vue例項: 

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

const vm = new Vue({
  el: "#app",
  render: h => h(App)
});

  App.vue作為vue的元件的入口,分別引入了Bar元件和Foo元件,這兩個元件都放置在src下的components資料夾中

<template>
 <div>
   <div>我是App.vue, {{msg}}</div>
   <bar />
   <foo />
 </div>
</template>

<script>
import Bar from "./components/Bar";
import Foo from "./components/Foo";

export default {
  data() {
    return {
      msg: "hello vue-ssr"
    };
  },
  components: {
    Bar,
    Foo
  }
};
</script>

  

接下來我們要使用webpack來進行檔案的處理和組織等一系列的工程化操作,首先需要安裝一些後面需要的依賴: 

yarn add webpack webpack-cli webpack-dev-server babel-loader @babel/preset-env @babel/core vue-style-loader css-loader vue-loader vue-template-compiler html-webpack-plugin webpack-merge

 簡單的分別介紹一下每個包的作用: 

webpack-cli webpack 的命令列解析工具 webpack-dev-server 用來建立一個開發環境,webpack 開發服務 babel-loader 解析 js 語法,主要是進行轉化操作 @babel/preset-env 解析 es6 語法並轉化為 es5 @babel/core babel 的核心模組 vue-style-loader vue 中為 ssr 提供的解析 css 的工具,不適用 ssr 的話可以使用 style-loader css-loader 處理 css 檔案 vue-loader 處理 vue 檔案 vue-template-compiler 處理 vue 模板編譯的 html-webpack-plugin 一個處理 html 的外掛 webpack-merge 合併 webpack 配置檔案的   然後我們在根目錄建立一個webpack的配置檔案 webpack.config.js 並進行一些常規的配置:   
const path = require("path");
const VueLoader = require("vue-loader/lib/plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");

const resolve = dir => {
  return path.resolve(__dirname, dir);
};

module.exports = {
  mode: "development",
  // webpack的入口檔案
  entry: resolve("./src/main.js"),
  // webpack的出口
  output: {
    // 打包完成的檔名
    filename: "bundle.js",
    // 打包完成的輸出路徑
    path: resolve("./dist")
  },
  resolve: {
    // 引用檔案的時候副檔名的尋找順序
    extensions: [".js", ".vue"]
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"]
          }
        },
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        // 注意: ssr 中使用的 vue-style-loader, vue專案中使用的 style-loader,效果相同,只是前者支援服務端渲染
        use: ["vue-style-loader", "css-loader"]
      },
      {
        test: /\.vue$/,
        // 對vue檔案使用vue-loader需要使用vue-loader的一個外掛 vue-loader/lib/plugin
        use: "vue-loader"
      }
    ]
  },
  plugins: [
    new VueLoader(),
    new HtmlWebpackPlugin({
      // 模板的名稱
      filename: "index.html",
      // 模板的路徑
      template: resolve("./public/index.html")
    })
  ]
};

  

webpack 啟動: npx webpack-dev-server 會啟動 node-modules/.bin/webpack-dev-server,然後會自動根據根目錄下 webpack.config.js 的配置去啟動 webpack      啟動後頁面會正常顯示vue的專案的內容。   然後我們來分析一下vue-ssr, 下面的圖片是官方提供的架構圖:   上圖表明瞭,vue-ssr是同構框架,即我們開發的同一份程式碼會被執行在服務端和客戶端兩個環境中。實現是依賴於webpack的編譯的,並且有兩個入口,server-entry 和 client-entry , 通過webpack把你的程式碼編譯成兩個bundle: server-bundle 和 client-bundle ;   然後我們來進一步拆分我們上面的程式碼,首先從入口檔案main.js入手:main.js是專案的入口檔案,作用是提供vue的例項。作為入口,有可能是在服務端,有可能是在客戶端,在客戶端只調用一次,負責將建立vue例項,並且掛載元素,渲染根元件;在服務端呢,不能掛載元素,並且每次呼叫都要產生一個新的例項,給每個訪問的使用者的使用。於是,我們將 main.js 改造為一個函式,在呼叫這個函式的時候返回一個物件,這個物件包含了一個vue的例項,返回一個物件的作用是為了後續的擴充套件,改造為函式的原因是解決上述的問題:    1.根據客戶端或者服務端來新增或不新增el;   2.每次呼叫都產生一個新的例項,服務端的根本要求
import Vue from "vue";
import App from "./App";

// const vm = new Vue({
//   el: "#app",
//   render: h => h(App)
// });

// main.js是專案的入口檔案,作用是提供vue的例項
// 將入口檔案改造為一個函式,每次呼叫都返回一個vue的例項,這樣做可以 1.根據客戶端或者服務端來新增或不新增el; 2.每次呼叫都產生一個新的例項,服務端的根本要求
export default () => {
  const app = new Vue({
    render: h => h(App)
  });
  return { app };
};

  然後我們在src下先建立客戶端的入口檔案 client-entry.js。在這個檔案中我們引入入口檔案main.js,執行mian.js匯出的函式獲取vue例項,並通過$mount的方法進行掛載元素: 

// 客戶端
import createApp from "./main";

const { app } = createApp();
// 掛載到 #app 的元素上
app.$mount("#app");

 入口檔案改造完成後,可以修改一下webpack的配置檔案,檢視是否還執行正常: 修改 webpack.config.js 檔案的 入口為 ./src/client-entry.js  

entry: resolve("./src/client-entry.js")

 使用 npx webpack-dev-server 重啟專案,在瀏覽器檢視一切正常,說明我們的配置是有效的。

再回來看webpack的配置,現在我們只是配置了客戶端的webpakc配置,後續肯定會分別打包客戶端和服務端,所以一般情況下,webpack的配置檔案不會寫到專案的根目錄下面。我們建立build資料夾來獨立放置構建相關的配置檔案,並將webpack.config.js檔案移入build資料夾並改名為webpack.base.js。同時,我們新建一個檔案 webpack.client.js 將客戶端獨有的配置拆分到webpack.client.js,在webpack.client.js中使用webpack-merge來合併webpack的配置:

webpack.base.js: 

const path = require("path");
const VueLoader = require("vue-loader/lib/plugin");

const resolve = dir => {
  return path.resolve(__dirname, dir);
};

module.exports = {
  // webpack的出口
  output: {
    // 打包完成的檔名
    filename: "[name].bundle.js",
    // 打包完成的輸出路徑
    path: resolve("../dist")
  },
  resolve: {
    // 引用檔案的時候副檔名的尋找順序
    extensions: [".js", ".vue"]
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"]
          }
        },
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        // 注意: ssr 中使用的 vue-style-loader, vue專案中使用的 style-loader,效果相同,只是前者支援服務端渲染
        use: ["vue-style-loader", "css-loader"]
      },
      {
        test: /\.vue$/,
        // 對vue檔案使用vue-loader需要使用vue-loader的一個外掛 vue-loader/lib/plugin
        use: "vue-loader"
      }
    ]
  },
  plugins: [new VueLoader()]
};

  webpack.client.js: 

// 客戶端的webpack配置
const path = require("path");
const merge = require("webpack-merge");
const base = require("./webpack.base");
const HtmlWebpackPlugin = require("html-webpack-plugin");

const resolve = dir => {
  return path.resolve(__dirname, dir);
};

module.exports = merge(base, {
  // webpack的入口檔案
  entry: {
    // 給每個模組起名,方便在bundle進行標識
    client: resolve("../src/client-entry.js")
  },
  plugins: [
    new HtmlWebpackPlugin({
      // 模板的名稱
      filename: "index.html",
      // 模板的路徑
      template: resolve("../public/index.html")
    })
  ]
});

  配置完成後,我們來啟動webpack對結果進行檢視: 這時我們不能通過之前的 npx webpack-dev-server 來啟動了,我們需要給webpack指定配置檔案,為了方便,可以在package.json中進行配置: 

"scripts": {
    "client:dev": "webpack-dev-server --config ./build/webpack.client.js --mode development",
    "client:build": "webpack --config ./build/webpack.client.js --mode production"
  }

  這樣我們就可以通過 npm run client:dev 來啟動了,執行命令並檢視瀏覽器對應的埠,結果應該和之前是相同的。npm run client:build 可以來進行打包客戶端的操作;

截止到此,客戶端的配置和打包就完成了,接下來我們來對服務端進行配置

首先,在src下簡歷一個服務端的入口檔案 server-entry.js ,通樣的引用main.js中的函式來建立vue例項,不同的是服務端的入口建立的vue例項不能掛載元素,並且也需要建立一個函式,方便在呼叫的時候建立一個新的例項返回給node服務端來使用,以保證每個瀏覽器端請求的都是一個新的例項。

// 服務端入口
import createApp from "./main";

const { app } = createApp();
// 呼叫當前這個檔案產生一個vue的例項,並且需要匯出給node服務端使用
export default () => {
  const { app } = createApp();
  return app;
};

  接下來我們在build資料夾下建立打包服務端的webpack的配置檔案,相對於客戶端,服務端配置不同的是,target 配置為node,並且配置 output 的 libraryTarget 為 commonjs2 用來表明打包出來的檔案需要給node使用,給node使用的程式碼要遵守commonjs規範,也就是將export default 轉變為 module.exports, 配置 libraryTarget: 'commonjs2' 會將檔案最終匯出的結果,放到 module.exports上,根據vue架構圖的顯示,服務端只是打包出靜態的字串檢視,並不需要解將打包後的結果通過 html-webpack-plugin外掛掛載到html上面,所以在服務端的html-webpack-plugin外掛中配置了 excludeChunks: ["server"] 來去除服務端打包出來的bundle: 

// 客戶端的webpack配置
const path = require("path");
const merge = require("webpack-merge");
const base = require("./webpack.base");
const HtmlWebpackPlugin = require("html-webpack-plugin");

const resolve = dir => {
  return path.resolve(__dirname, dir);
};

module.exports = merge(base, {
  // webpack的入口檔案
  entry: {
    // 給每個模組起名,方便在bundle進行標識
    server: resolve("../src/server-entry.js")
  },
  // 確定是給node提供
  target: "node",
  // 給node使用的程式碼要遵守commonjs規範,也就是將export default 轉變為 module.exports, 配置 libraryTarget: 'commonjs2' 會將檔案最終匯出的結果,放到 module.exports上
  output: {
    libraryTarget: "commonjs2"
  },
  plugins: [
    new HtmlWebpackPlugin({
      // 模板的名稱
      filename: "index.ssr.html",
      // 模板的路徑
      template: resolve("../public/index.ssr.html"),
      // 排除服務端打包後的結果
      excludeChunks: ["server"]
    })
  ]
});

 根據配置,我們繼續在public資料夾下新建  index.ssr.html , 並在此模板中新增 vue-server-renderer 的魔法註釋 vue-ssr-outlet

<!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>服務端ssr</title>
  </head>
  <body>
    <!-- vue-ssr-outlet -->
  </body>
</html>

  然後我們在package.json檔案中新增服務端的打包命令

"server:build": "webpack --config ./build/webpack.server.js --mode production"

  我們分別執行客戶端和服務端打包命令: npm run client:build  和 npm run server:build 並檢視打包結果

 

這樣我們分別打包出了客戶端和服務端的程式碼;

目前我們得到的結果是有一個客戶端的正常打包,還有一個服務端的打包,服務端打包出來的 index.ssr.html 其實什麼都沒有做,只是一個直接拷貝,服務端的 server.bundle.js 打包呢結果如下: 

 

  請注意 module.exports = {}  這個正是 libraryTarget: "commonjs2" 設定的結果,返回去看vue-ssr官網的架構圖, 服務端打包出來的靜態字串,然後引入客戶端打包出來的動態方法就是最後的結果, 現在來回想一下教程一,通過 vue-server-renderer 提供的的渲染函式的createRenderer() 方法實現了服務端的渲染,同樣的vue-server-renderer 提供的的渲染函式也提供了對bundle的渲染,下面繼續來改造一下koa服務端, 將我們服務端打包的結果渲染成字串: 

const Koa = require("koa");
const Router = require("koa-router");
const fs = require("fs");
const VueServerRender = require("vue-server-renderer");

const app = new Koa();
const router = new Router();

// 讀取服務端打包完成的結果字串
let ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8");
// 讀取模板
let template = fs.readFileSync("./dist/index.ssr.html", "utf8");
// 建立bundle渲染器, 進行渲染並插入模板
let render = VueServerRender.createBundleRenderer(ServerBundle, {
  template
});

// render.renderToString() 接收一個vue的例項並返回一個promise的字串,將返回的字串直接渲染到頁面上,注意這個方法是個非同步操作
router.get("/", async ctx => {
  // css樣式只能通過回撥,同步的話會有問題
  ctx.body = await new Promise((resolve, reject) => {
    render.renderToString((err, data) => {
      if (err) reject(err);
      resolve(data);
    });
  });
});

app.use(router.routes());

app.listen(3000, () => {
  console.log(`node serve run at port 3000`);
});

  

我們只做了兩件事,1.分別讀取服務端打包後的模板和bundle  2.使用vue-server-renderer提供的createBundleRenderer(bundle:string, {template}) 來渲染打包後的結果,並轉化為字串傳送給瀏覽器。

使用 nodemon serve.js 啟動koa服務,並在瀏覽器檢視,頁面順利的渲染了, 檢視網頁原始碼: 

 

的確把服務端打包後的結果插入了 <!--vue-ssr-outlet-->佔位符的位置。但是點選事件是沒有作用的,原因是renderToString()返回的是字串,並沒有動態功能。這時候我們在dist目錄下的index.ssr.html中引入客戶端打包好的js檔案: 

<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>服務端ssr</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
    <script src="client.bundle.js"></script>
  </body>
</html>

  檢視瀏覽器,出現404,script標籤的src會向伺服器傳送一個請求,但是伺服器並沒有這個靜態檔案,我們繼續新增koa的靜態檔案功能: 

const path = require("path");
const static = require("koa-static");

// koa 靜態服務中介軟體
app.use(static(path.resolve(__dirname, "dist")));

  然後我們重新打包客戶端和服務端的程式碼,分別執行

  npm run client:build -- --watch       

  npm run server:build -- --watch 

  然後還要手動的在打包出來的服務端index.ssr.html中引入客戶端的js: client.bundle.js 

  然後啟動node服務: nodemon server.js

重新整理瀏覽器,事件成功了,到此,新一版vue 服務端渲染就完成,也算是整個走完了一遍vue-ssr官網的架構圖。