Vue.js SSR Step by Step (2) – 一個簡單的同構DEMO

wheato發表於2019-03-01

上一篇文章中介紹瞭如何從零開始搭建一個簡單的 client-only webpack 配置。
接下來我們在前面程式碼的基礎上寫一個簡單的前後端同構的DEMO。

改寫入口

當編寫純客戶端(client-only)程式碼時,我們習慣於每次在新的上下文中對程式碼進行取值。但是,Node.js 伺服器是一個長期執行的程式。當我們的程式碼進入該程式時,它將進行一次取值並留存在記憶體中。這意味著如果建立一個單例物件,它將在每個傳入的請求之間共享。

為了避免狀態單例,改寫入口, Vue SSR 官方文件介紹的比較詳細了,一定要去看一看。
建立對應的檔案後,src 目錄是這樣的:

.
├── App.vue
├── app.js
├── assets
│   └── logo.png
├── entry-client.js
└── entry-server.js複製程式碼

改寫 app.js 把裡面建立 Vue 例項的部分改寫一個工廠函式,用於建立返回 Vue 例項。

// 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`)複製程式碼
// entry-server.js
import { createApp } from `./app.js`

export default context => {
  const app = createApp()
  return app
}複製程式碼

改寫 webpack 配置

因為伺服器渲染的配置和客戶端的配置略有不同,但其中有很多共用的配置,官方建議我們使用三個不同的配置檔案:base、client、server, 通過 webpack-merge 外掛來實現對 base 配置檔案的覆蓋和擴充套件。

build 目錄下的檔案目錄
.
├── webpack.base.conf.js
├── webpack.client.conf.js
└── webpack.server.conf.js複製程式碼

再把之前 webpack.config.js 中的內容複製到 webpack.base.conf.js 中。在 webpack.server.conf.js 中加入 SSR 的 client 外掛。

const webpack = require(`webpack`)
const path = require(`path`)
const merge = require(`webpack-merge`)
const baseConfig = require(`./webpack.base.conf`)
const VueSSRClientPlugin = require(`vue-server-renderer/client-plugin`)

module.exports = merge(baseConfig, {
  plugins: [
    new VueSSRClientPlugin()
  ]
})複製程式碼

客戶端的配置就完成了。server 端需要修改輸入和輸出的配置,還有 source-map 輸出的格式,module 中 引入的 css 檔案不打包到 module 中,增加 SSR 的 server 端外掛。

const webpack = require(`webpack`)
const path = require(`path`)
const merge = require(`webpack-merge`)
const baseConfig = require(`./webpack.base.conf`)
const nodeExternals = require(`webpack-node-externals`)
const VueSSRServerPlugin = require(`vue-server-renderer/server-plugin`)

module.exports = merge(baseConfig, {
  entry: `./src/entry-server.js`,
  output: {
    filename: `server-bundle.js`,
    libraryTarget: `commonjs2` // 程式碼中模組的實現方式,Node.js 使用 commonjs2
  },
  target: `node`, // 指定程式碼的執行環境是 node
  devtool: `#source-map`,
  externals: nodeExternals({
    whitelist: /.css$/
  }),
  plugins: [
    new VueSSRServerPlugin()
  ]
})複製程式碼

然後在 package.json 中新增編譯的命令:

"scripts": {
  "test": "",
  "dev": "cross-env NODE_ENV=development webpack-dev-server --open --hot --config build/webpack.client.conf.js",
  "server": "node server.js",
  "build": "rimraf dist && npm run build:client && npm run build:server",
  "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.conf.js --progress --hide-modules",
  "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.conf.js --progress --hide-modules"
},複製程式碼

執行 nom run build 在dist 目錄裡就會生成構建後的檔案,然後把 index.html 修改為 indext.template.html 這個檔名隨便,不改也行。dist 目錄中有兩個不一樣的檔案,vue-ssr-client-manifest.json 和 vue-ssr-server-bundle.json。具體的使用方法和實現方式,文件寫的很清楚,先去 Bundle Renderer 指引 · GitBook 看看。

server.js

然後在寫一個簡單 Node Server,我這裡使用 Koa,其他的都是一樣。server.js 的內容如下:

const Koa = require(`koa`)
const Vue = require(`vue`)
const { createBundleRenderer } = require(`vue-server-renderer`)
const path = require(`path`)
const fs = require(`fs`)
const serverBundle = require(`./dist/vue-ssr-server-bundle.json`)
const clientManifest = require(`./dist/vue-ssr-client-manifest.json`)
const app = new Koa()
const template = fs.readFileSync(path.resolve(__dirname, `./index.template.html`), `utf-8`)
const renderer = createBundleRenderer(serverBundle, {
  basedir: path.resolve(__dirname, `./dist`),
  runInNewContext: false,
  template,
  clientManifest
})

const renderToString = function (context) {
  return new Promise((resolve, reject) => {
    renderer.renderToString(context, (err, html) => {
      if (err) reject(err)
      resolve(html)
    })
  })
}

app.use(async ctx => {
  console.log(ctx.req.url)
  if (ctx.req.url === `/favicon.ico` || ctx.req.url === `/robots.txt`) {
    ctx.body = ``
    return 
  }
  // 簡單的靜態檔案處理
  if (ctx.req.url.indexOf(`/dist/`) > -1) {
    const urlpath = ctx.req.url.split(`?`)[0].slice(1)
    const filepath = path.resolve(__dirname, `./`, urlpath)
    ctx.body = fs.readFileSync(filepath)
    return
  }
  let html = ``
  try {
    html = await renderToString({})
  } catch(err) {
    ctx.throw(500, err)
  }
  ctx.body = html  
})

app.listen(3000)
console.log(`Server listening on http://localhost:3000.`)複製程式碼

執行 nom run server 就可以看到伺服器渲染出來的頁面了。

增加前端方法

這只是一個簡單的靜態頁面,沒有 js 方法動態建立一些內容,我們再新增一些前端方法,看看渲染出來的頁面中客戶端 js 的執行是不是可以的。
修改 App.vue 檔案:

<template>
  <div class="demo" id="app">
    <h1>Simple-webpack demo</h1>
    <p>這是一個簡單的 Vue demo</p>
    <img src="./assets/logo.png" alt="">
    <p>測試一下 SSR</p>
    <p v-for="(text, index) in textArr" :key="index">{{ text }}</p>
    <button @click="clickHandler">新增一個行文字</button>
  </div>
</template>
<script>
export default {
  data () {
    return {
      textArr: []
    }
  },
  methods: {
    clickHandler () {
      this.textArr.push(`${this.textArr.length + 1}. 這是新增的文字。`)
    }
  }
}
</script>複製程式碼

然後再次構建整個工程,重新啟動伺服器。

Success!

簡單的資料注入

比如渲染一個新聞頁面,希望網頁的標題是頁面直接渲染出來的?應該怎麼做?Vue.js SSR 提供了方法,能夠插入模板變數。只要在 index.template.html 中加入模板變數就可以像其他的後端模板一樣插入資料。首先修改一下 index.template.html 中,增加 title 變數,<title>SSR demo - {{ title }}</title>
然後在 server.js 中的 renderToString方法中的第一個引數傳入 { title: `第一個 SSR Demo`}
最後再重啟一下後臺服務,如下圖,我們的頁面標題變成了我們定義的了。

如果還想更復雜的資料我們只能用注入一個 window 全域性變數了。這個時候我們還沒辦法用元件的靜態方法,通過後臺服務去注入,因為我們沒有用到router,不知道app中的元件是不是已經例項化,沒辦法去獲取元件裡的靜態方法。借鑑 SSR 官方中的 window.__INIT_STATE 的方式,先在 index.template.html 中 增加一個 script 標籤加入模板變數,然後在 server.js 中傳入資料,最後修改 App.vue 檔案在 mounted 中判斷獲取這個變數,將變數賦值給元件的 data 屬性中,具體的程式碼如下:

<!-- 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>SSR demo - {{ title }}
  </title>
  <script>
    {{{ injectData }}}
  </script>
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>複製程式碼
// server.js
html = await renderToString({
  title: `第一個 SSR Demo`,
  injectData: `window.__INIT_DATA__ = ` + JSON.stringify({
    text: `這是伺服器注入的資料。`
  }) 
})複製程式碼
<!-- App.vue -->
<template>
  <div class="demo" id="app">
    <h1>Simple-webpack demo</h1>
    <p>這是一個簡單的 Vue demo</p>
    <img src="./assets/logo.png" alt="">
    <p>測試一下 SSR</p>
    <p> {{ serverData.text }}</p>
    <p v-for="(text, index) in textArr" :key="index">{{ text }}</p>
    <button @click="clickHandler">新增一個行文字</button>
  </div>
</template>

<script>
export default {
  data () {
    return {
      textArr: [],
      serverData: ``
    }
  },
  mounted () {
    this.serverData = window.__INIT_DATA__
  },
  methods: {
    clickHandler () {
      this.textArr.push(`${this.textArr.length + 1}. 這是新增的文字。`)
    }
  }
}
</script>複製程式碼

重新編譯,重啟服務後,頁面上就會多一段文字了,如下圖所示:

Success!
所有的程式碼都在這個上面 wheato/ssr-step-by-step

參考

Vue.js 伺服器端渲染指南

相關文章