天天看點

大前端學習筆記 -- 搭建自己的伺服器端渲染 (SSR)

搭建自己的伺服器端渲染 (SSR)

一、渲染一個Vue執行個體

  • mkdir vue-ssr

  • cd vue-ssr

  • npm init -y

  • npm i vue vue-server-renderder

  • server.js
    const Vue = require('vue')
    const renderer = require('vue-server-renderer').createRenderer()
    const app = new Vue({
      template: `
        <div id="app">
          <h1>{{message}}</h1>
        </div>
      `,
      data: {
        message: '拉鈎教育'
      }
    })
    
    renderer.renderToString(app, (err, html) => {
      if (err) throw err
      console.log(html)
    })
               
  • node server.js

    ,運作結果:

    data-server-rendered="true"

    這個屬性是為了将來用戶端渲染激活接管的接口

二、結合到Web伺服器中

server.js

const Vue = require('vue')
const express = require('express')

const renderer = require('vue-server-renderer').createRenderer()

const server = express()

server.get('/', (req, res) => {
  const app = new Vue({
    template: `
      <div id="app">
        <h1>{{message}}</h1>
      </div>
    `,
    data: {
      message: '拉鈎教育'
    }
  })
  renderer.renderToString(app, (err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 設定編碼,防止亂碼
    res.end(`
      <!DOCTYPE html>
      <html >
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
      </head>
      <body>
        ${html}
      </body>
      </html>
    `)
  })
})

server.listen(3000, () => {
  console.log('server running at port 3000...')
})

           

三、使用HTML模闆

1. 建立HTML模闆檔案

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>
           

<!--vue-ssr-outlet-->

是占位符,為了接收将來要渲染的變量,不能寫錯,不能有多餘的空格

2. js代碼中的createRenderer方法指定模闆檔案

server.js

const Vue = require('vue')
const express = require('express')
const fs = require('fs')

const renderer = require('vue-server-renderer').createRenderer({
  // 這裡指定模闆檔案
  template: fs.readFileSync('./index.template.html', 'utf-8')
})

const server = express()

server.get('/', (req, res) => {
  const app = new Vue({
    template: `
      <div id="app">
        <h1>{{message}}</h1>
      </div>
    `,
    data: {
      message: '拉鈎教育'
    }
  })
  renderer.renderToString(app, (err, html) => { // 此處的html參數是被模闆檔案處理過了的,可以直接輸出到使用者的頁面上
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 設定編碼,防止亂碼
    res.end(html)
  })
})

server.listen(3000, () => {
  console.log('server running at port 3000...')
})

           

四、在模闆中使用外部資料

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 }}}
  <title>{{ title }}</title>
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>
           
使用兩個花括号可以資料外部資料變量,而标簽也會進行轉義後輸出在頁面上。此時可以使用三個花括号原樣輸出資料,不會對标簽進行轉義處理

在js代碼中給

renderer.renderToString

增加第二個參數為外部資料對象

renderer.renderToString(app, {
    title: '拉勾教育',
    meta: `
      <meta name="description" content="拉勾教育" >
    `
  }, (err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 設定編碼,防止亂碼
    res.end(html)
  })
           

五、建構配置

1. 基本思路

大前端學習筆記 -- 搭建自己的伺服器端渲染 (SSR)

2. 源碼結構

src
├── components
│   ├── Foo.vue
│   ├── Bar.vue
│   └── Baz.vue
├── App.vue
├── app.js # 通用 entry(universal entry)
├── entry-client.js # 僅運作于浏覽器
└── entry-server.js # 僅運作于伺服器
           

App.vue

<template>
  <div id="app">
    <h1>{{message}}</h1>
    <h2>用戶端動态互動</h2>
    <div>
      <input v-model="message">
    </div>
    <div>
      <button @click="onClick">點選測試</button>
    </div>
  </div>
</template>

<script>
export default {
  name: 'App',
  data: function () {
    return {
      message: '拉勾教育'
    }
  },
  methods: {
    onClick () {
      console.log('Hello World!')
    }
  }
}
</script>
           

app.js

是我們應用程式的「通用 entry」。在純用戶端應用程式中,我們将在此檔案中建立根 Vue 執行個體,并直接挂載到 DOM。但是,對于伺服器端渲染(SSR),責任轉移到純用戶端 entry 檔案。

app.js

簡單地使用 export 導出一個

createApp

函數:

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

// 導出一個工廠函數,用于建立新的
// 應用程式、router 和 store 執行個體
export function createApp () {
  const app = new Vue({
    // 根執行個體簡單的渲染應用程式元件。
    render: h => h(App)
  })
  return { app }
}
           

entry-client.js

用戶端 entry 隻需建立應用程式,并且将其挂載到 DOM 中:

import { createApp } from './app'

// 用戶端特定引導邏輯……

const { app } = createApp()

// 這裡假定 App.vue 模闆中根元素具有 `id="app"`
app.$mount('#app')
           

entry-server.js

伺服器 entry 使用 default export 導出函數,并在每次渲染中重複調用此函數。此時,除了建立和傳回應用程式執行個體之外,它不會做太多事情 - 但是稍後我們将在此執行伺服器端路由比對 (server-side route matching) 和資料預取邏輯 (data pre-fetching logic)。

import { createApp } from './app'

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

3. 安裝依賴

(1) 安裝生産依賴

npm i vue vue-server-renderer express cross-env
           
說明
vue Vue.js核心庫
vue-server-renderer Vue服務端渲染工具
express 基于Node的webpack服務架構
cross-env 通過npm scripts設定跨平台環境變量

(2) 安裝開發依賴

npm i -D webpack webpack-cli webpack-merge webpack-node-externals @babel/core @babel/plugin-transform-runtime @babel/preset-env babel-loader css-loader url-loader file-loader rimraf vue-loader vue-template-compiler friendly-errors-webpack-plugin
           
說明
webpack webpack核心包
webpack-cli webpack的指令行工具
webpack-merge webpack配置資訊合并工具
webpack-node-externals 排除webpack中的Node子產品
rimraf 基于Node封裝的一個跨平台rm -rf工具
friendly-errors-webpack-plugin 友好的webpack錯誤提示

@babel/core

@babel/plugin-transform-runtime

@babel/preset-env

babel-loader

Babel相關工具

vue-loader

vue-template-compiler

處理.vue資源
file-loader 處理字型資源
css-loader 處理CSS資源
url-loader 處理圖檔資源

4. webpack配置檔案及打包指令

(1) 初始化webpack打包配置檔案

build
|---webpack.base.config.js # 公共配置
|---webpack.client.config.js # 用戶端打包配置檔案
|---webpack.server.config.js # 服務端打包配置檔案
           

webpack.base.config.js

/**
 * 公共配置
 */
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const path = require('path')
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
const resolve = file => path.resolve(__dirname, file)

const isProd = process.env.NODE_ENV === 'production'

module.exports = {
  mode: isProd ? 'production' : 'development',
  output: {
    path: resolve('../dist/'),
    publicPath: '/dist/',
    filename: '[name].[chunkhash].js'
  },
  resolve: {
    alias: {
      // 路徑别名,@ 指向 src
      '@': resolve('../src/')
    },
    // 可以省略的擴充名
    // 當省略擴充名的時候,按照從前往後的順序依次解析
    extensions: ['.js', '.vue', '.json']
  },
  devtool: isProd ? 'source-map' : 'cheap-module-eval-source-map',
  module: {
    rules: [
      // 處理圖檔資源
      {
        test: /\.(png|jpg|gif)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
            },
          },
        ],
      },

      // 處理字型資源
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/,
        use: [
          'file-loader',
        ],
      },

      // 處理 .vue 資源
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },

      // 處理 CSS 資源
      // 它會應用到普通的 `.css` 檔案
      // 以及 `.vue` 檔案中的 `<style>` 塊
      {
        test: /\.css$/,
        use: [
          'vue-style-loader',
          'css-loader'
        ]
      },
      
      // CSS 預處理器,參考:https://vue-loader.vuejs.org/zh/guide/pre-processors.html
      // 例如處理 Less 資源
      // {
      //   test: /\.less$/,
      //   use: [
      //     'vue-style-loader',
      //     'css-loader',
      //     'less-loader'
      //   ]
      // },
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
    new FriendlyErrorsWebpackPlugin()
  ]
}

           

webpack.client.config.js

/**
 * 用戶端打包配置
 */
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(baseConfig, {
  entry: {
    app: './src/entry-client.js'
  },

  module: {
    rules: [
      // ES6 轉 ES5
      {
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
            cacheDirectory: true,
            plugins: ['@babel/plugin-transform-runtime']
          }
        }
      },
    ]
  },

  // 重要資訊:這将 webpack 運作時分離到一個引導 chunk 中,
  // 以便可以在之後正确注入異步 chunk。
  optimization: {
    splitChunks: {
      name: "manifest",
      minChunks: Infinity
    }
  },

  plugins: [
    // 此插件在輸出目錄中生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ]
})

           

webpack.server.config.js

/**
 * 服務端打包配置
 */
const { merge } = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.config.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(baseConfig, {
  // 将 entry 指向應用程式的 server entry 檔案
  entry: './src/entry-server.js',

  // 這允許 webpack 以 Node 适用方式處理子產品加載
  // 并且還會在編譯 Vue 元件時,
  // 告知 `vue-loader` 輸送面向伺服器代碼(server-oriented code)。
  target: 'node',

  output: {
    filename: 'server-bundle.js',
    // 此處告知 server bundle 使用 Node 風格導出子產品(Node-style exports)
    libraryTarget: 'commonjs2'
  },

  // 不打包 node_modules 第三方包,而是保留 require 方式直接加載
  externals: [nodeExternals({
    // 白名單中的資源依然正常打包
    allowlist: [/\.css$/]
  })],

  plugins: [
    // 這是将伺服器的整個輸出建構為單個 JSON 檔案的插件。
    // 預設檔案名為 `vue-ssr-server-bundle.json`
    new VueSSRServerPlugin()
  ]
})

           

5. 配置建構指令

"scripts": {
    "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
    "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
    "build": "rimraf dist && npm run build:client && npm run build:server"
  }
           

6. 啟動應用

erver.js

const Vue = require('vue')
const express = require('express')
const fs = require('fs')

const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const { static } = require('express')
const template = fs.readFileSync('./index.template.html', 'utf-8')
const renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, {
  template,
  clientManifest 
})

const server = express()

// 請求字首,使用express中間件的static處理
server.use('/dist', express.static('./dist'))

server.get('/', (req, res) => {
  
  renderer.renderToString({
    title: '拉勾教育',
    meta: `
      <meta name="description" content="拉勾教育" >
    `
  }, (err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 設定編碼,防止亂碼
    res.end(html)
  })
})

server.listen(3001, () => {
  console.log('server running at port 3001...')
})

           

7. 解析渲染流程

六、建構配置開發模式

1. 基本思路

生産模式直接渲染,開發模式監視打包建構,重新生成Renderer渲染器

2. 提取處理子產品

server.js

const Vue = require('vue')
const express = require('express')
const fs = require('fs')
const createBundleRenderer = require('vue-server-renderer')
const setupDevServer = require('./build/setup-dev-server')

const server = express()

// 請求字首,使用express中間件的static處理
server.use('/dist', express.static('./dist'))

const isProd = process.env.NODE_ENV === 'production'

let renderer
let onReady
if (isProd) {
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  const { static } = require('express')
  const template = fs.readFileSync('./index.template.html', 'utf-8')
  renderer = createBundleRenderer(serverBundle, {
    template,
    clientManifest 
  })
} else {
  // 開發模式 -> 監視打包建構 -> 重新生成Renderer渲染器
  onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
    renderer = createBundleRenderer(serverBundle, {
      template,
      clientManifest 
    })
  })
}

// render 是路由函數
const render = (req, res) => {
  // renderer是Vue SSR的渲染器
  renderer.renderToString({
    title: '拉勾教育',
    meta: `
      <meta name="description" content="拉勾教育" >
    `
  }, (err, html) => {
    if (err) {
      return res.status(500).end('Internal Server Error.')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 設定編碼,防止亂碼
    res.end(html)
  })
}

server.get('/', isProd ? render : async (req, res) => {
  // 等待有了Renderer渲染器以後,調用render進行渲染
  await onReady
  render()
})

server.listen(3001, () => {
  console.log('server running at port 3001...')
})

           

build/setup-dev-server.js

module.exports = (server, callback) => {
  let ready // ready就是promise中的resolve
  const onReady = new Promise(r => ready = r)

  // 監視建構 -> 更新 Renderer

  let template
  let serverBundle
  let clientManifest
  
  return onReady
  
}

           

3. update更新函數

const update = () => {
  if (template && serverBundle && clientManifest) {
    ready()
    callback(serverBundle, template, clientManifest)
  }
}
           

4. 處理模闆檔案

// 監視建構 template -> 調用 update -> 更新 Renderer 渲染器
const templatePath = path.resolve(__dirname, '../index.template.html')
template = fs.readFileSync(templatePath, 'utf-8')
update()
// fs.watch、fs.watchFile
chokidar.watch(templatePath).on('change', () => {
  template = fs.readFileSync(templatePath, 'utf-8')
  update()
})
           

5. 服務端監視打包

// 監視建構 serverBundle -> 調用 update -> 更新 Renderer 渲染器
const serverConfig = require('./webpack.server.config')
// serverCompiler是一個webpack編譯器,直接監聽資源改變,進行打包建構
const serverCompiler = webpack(serverConfig)
serverCompiler.watch({}, (err, stats) => {
  if (err) throw err
  if (stats.hasErrors()) return
  serverBundle = JSON.parse(
    fs.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
  )
  console.log(serverBundle)
  update()
})

           

6. 把資料寫到記憶體中

// 監視建構 serverBundle -> 調用 update -> 更新 Renderer 渲染器
const serverConfig = require('./webpack.server.config')
const serverCompiler = webpack(serverConfig)
const serverDevMiddleware = devMiddleware(serverCompiler, {
  logLevel: 'silent' // 關閉日志輸出,由 FriendlyErrorsWebpackPlugin 處理
})
serverCompiler.hooks.done.tap('server', () => {
  serverBundle = JSON.parse(
    serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
  )
  update()
})
           

7. 用戶端建構

// 監視建構 clientManifest -> 調用 update -> 更新 Renderer 渲染器
const clientConfig = require('./webpack.client.config')
clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
clientConfig.entry.app = [
  'webpack-hot-middleware/client?quiet=true&reload=true', // 和服務端互動處理熱更新一個用戶端腳本
  clientConfig.entry.app
]
clientConfig.output.filename = '[name].js' // 熱更新模式下確定一緻的 hash
const clientCompiler = webpack(clientConfig)
const clientDevMiddleware = devMiddleware(clientCompiler, {
  publicPath: clientConfig.output.publicPath,
  logLevel: 'silent' // 關閉日志輸出,由 FriendlyErrorsWebpackPlugin 處理
})
clientCompiler.hooks.done.tap('client', () => {
  clientManifest = JSON.parse(
    clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
  )
  update()
})
           

8. 熱更新

clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())

clientConfig.entry.app = [
  'webpack-hot-middleware/client?quiet=true&reload=true', // 和服務端互動處理熱更新一個用戶端腳本
  clientConfig.entry.app
]
clientConfig.output.filename = '[name].js' // 熱更新模式下確定一緻的 hash

const hotMiddleware = require('webpack-hot-middleware')

server.use(hotMiddleware(clientCompiler, {
  log: false // 關閉它本身的日志輸出
}))
           

七、編寫通用應用注意事項

八、路由處理

1. 配置Vue-Router

router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '@/src/pages/Home'

Vue.use(VueRouter)

export const createRouter = () => {
  const router = new VueRouter({
    mode: 'history', // 相容前後端,
    routes: [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/about',
        name: 'about',
        component: () => import('@/src/pages/About')
      },
      {
        path: '*',
        name: 'error404',
        component: () => import('@/src/pages/404')
      }
    ]
  })
  return router // 千萬别忘了傳回router
}
           

2. 将路由注冊到根執行個體

app.js

/**
 * 同構應用通用啟動入口
 */
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router/'

// 導出一個工廠函數,用于建立新的
// 應用程式、router 和 store 執行個體
export function createApp () {
  const router = createRouter()
  const app = new Vue({
    router, // 把路由挂載到Vue根執行個體當中
    // 根執行個體簡單的渲染應用程式元件。
    render: h => h(App)
  })
  return { app, router }
}
           

3. 适配服務端入口

拷貝官網上提供的entry-server.js

// entry-server.js
import { createApp } from './app'

export default context => {
  // 因為有可能會是異步路由鈎子函數或元件,是以我們将傳回一個 Promise,
    // 以便伺服器能夠等待所有的内容在渲染前,
    // 就已經準備就緒。
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()

    // 設定伺服器端 router 的位置
    router.push(context.url)

    // 等到 router 将可能的異步元件和鈎子函數解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 比對不到的路由,執行 reject 函數,并傳回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }

      // Promise 應該 resolve 應用程式執行個體,以便它可以渲染
      resolve(app)
    }, reject)
  })
}
           

路由表裡已經配置過404頁面了,是以不用額外判斷404,然後将Promise改成async/await的形式,最終如下:

// entry-server.js
import { createApp } from './app'

export default async context => {
  // 因為有可能會是異步路由鈎子函數或元件,是以我們将傳回一個 Promise,
    // 以便伺服器能夠等待所有的内容在渲染前,
    // 就已經準備就緒。
    const { app, router } = createApp()

    // 設定伺服器端 router 的位置
    router.push(context.url)

    // 等到 router 将可能的異步元件和鈎子函數解析完
    await new Promise(router.onReady.bind(router))

    return app
}

           

4. 服務端server适配

我們的伺服器代碼使用了一個

*

處理程式,它接受任意 URL。這允許我們将通路的 URL 傳遞到我們的 Vue 應用程式中,然後對用戶端和伺服器複用相同的路由配置!

server.js處理

// ...

// render 是路由函數
const render =async (req, res) => {
  // renderer是Vue SSR的渲染器
  try {
    const html = await renderer.renderToString({
      title: '拉勾教育',
      meta: `
        <meta name="description" content="拉勾教育" >
      `,
      url: req.url
    })
    res.setHeader('Content-Type', 'text/html; charset=utf8') // 設定編碼,防止亂碼
    res.end(html)
  }catch(err) {
    res.status(500).end('Internal Server Error.')
  }
}

// 服務端路由比對為*,意味着所有的路由都會進入這裡
server.get('*', isProd ? render : async (req, res) => {
  // 等待有了Renderer渲染器以後,調用render進行渲染
  await onReady
  render(req, res)
})

// ...
           

5. 适配用戶端入口

需要注意的是,你仍然需要在挂載 app 之前調用

router.onReady

,因為路由器必須要提前解析路由配置中的異步元件,才能正确地調用元件中可能存在的路由鈎子。這一步我們已經在我們的伺服器入口 (server entry) 中實作過了,現在我們隻需要更新用戶端入口 (client entry):

// entry-client.js

import { createApp } from './app'

const { app, router } = createApp()

router.onReady(() => {
  app.$mount('#app')
})
           

6. 處理完成

路由出口:

App.vue

<div id="app">
  <ul>
    <li>
      <router-link to="/">Home</router-link>
    </li>
    <li>
      <router-link to="/about">About</router-link>
    </li>
  </ul>

  <!-- 路由出口 -->
  <router-view/>
</div>
           

八、管理頁面

1. Head 内容

npm install vue-meta
           

在src/app.js裡面,增加代碼

import VueMeta from 'vue-meta'

Vue.use(VueMeta)

Vue.mixin({
  metaInfo: {
    titleTemplate: '%s - 拉勾教育'
  }
})
           

在entry-server.js的導出函數裡,增加代碼:

const meta = app.$meta()
context.meta = meta
           

将meta資料注入到模闆頁面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.inject().title.text() }}}
  {{{ meta.inject().meta.text() }}}
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>
           

在vue頁面中的應用:

export default {
  name: 'Home',
  metaInfo: {
    title: '首頁'
  }
}
           
export default {
  name: 'About',
  metaInfo: {
    title: '關于'
  }
}
           

九、資料預取和狀态管理

1. 思路分析

在伺服器端渲染(SSR)期間,我們本質上是在渲染我們應用程式的"快照",是以如果應用程式依賴于一些異步資料,那麼在開始渲染過程之前,需要先預取和解析好這些資料。

另一個需要關注的問題是在用戶端,在挂載 (mount) 到用戶端應用程式之前,需要擷取到與伺服器端應用程式完全相同的資料 - 否則,用戶端應用程式會因為使用與伺服器端應用程式不同的狀态,然後導緻混合失敗。

為了解決這個問題,擷取的資料需要位于視圖元件之外,即放置在專門的資料預取存儲容器(data store)或"狀态容器(state container))"中。首先,在伺服器端,我們可以在渲染之前預取資料,并将資料填充到 store 中。此外,我們将在 HTML 中序列化(serialize)和内聯預置(inline)狀态。這樣,在挂載(mount)到用戶端應用程式之前,可以直接從 store 擷取到内聯預置(inline)狀态。

2. 資料預取

npm install vuex
           

建立src/store/index.js

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

export const createStore = () => {
  return new Vuex.Store({
    state: () => ({
      posts: []
    }),
    mutations: {
      setPosts (state, data) {
        state.posts = data
      }
    },
    actions: {
      // 在服務端渲染期間,務必讓action傳回一個promise 
      async getPosts ({commit}) { // async預設傳回Promise
        // return new Promise()
        const { data } = await axios.get('https://cnodejs.org/api/v1/topics')
        commit('setPosts', data.data)
      }
    }
  })
}
           

将容器注入到入口檔案src/app.js

/**
 * 同構應用通用啟動入口
 */
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router/'
import VueMeat from 'vue-meta'
import { createStore } from './store'

Vue.use(VueMeta)

Vue.mixin({
  metaInfo: {
    titleTemplate: '%s - 拉勾教育'
  }
})

// 導出一個工廠函數,用于建立新的
// 應用程式、router 和 store 執行個體
export function createApp () {
  const router = createRouter()
  const store = createStore()
  const app = new Vue({
    router, // 把路由挂載到Vue根執行個體當中
    store, // 把容器挂載到Vue根執行個體中
    // 根執行個體簡單的渲染應用程式元件。
    render: h => h(App)
  })
  return { app, router, store }
}
           

頁面pages/Posts.vue,使用serverPrefetch方法在服務端發起異步請求。

<template>
  <div>
    <h1>Post List</h1>
    <ul>
      <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
    </ul>
  </div>
</template>

<script>
// import axios from 'axios'
import { mapState, mapActions } from 'vuex'

export default {
  name: 'PostList',
  metaInfo: {
    title: 'Posts'
  },
  data () {
    return {
      // posts: []
    }
  },
  computed: {
    ...mapState(['posts'])
  },

  // Vue SSR 特殊為服務端渲染提供的一個生命周期鈎子函數
  serverPrefetch () {
    // 發起 action,傳回 Promise
    // this.$store.dispatch('getPosts')
    return this.getPosts()
  },
  methods: {
    ...mapActions(['getPosts'])
  }

  // 服務端渲染
  //     隻支援 beforeCreate 和 created
  //     不會等待 beforeCreate 和 created 中的異步操作
  //     不支援響應式資料
  // 所有這種做法在服務端渲染中是不會工作的!!!
  // async created () {
  //   console.log('Posts Created Start')
  //   const { data } = await axios({
  //     method: 'GET',
  //     url: 'https://cnodejs.org/api/v1/topics'
  //   })
  //   this.posts = data.data
  //   console.log('Posts Created End')
  // }
}
</script>

<style>

</style>

           

3. 将資料預取同步到用戶端

entry-server.js

// entry-server.js
import { createApp } from './app'

export default async context => {
  // 因為有可能會是異步路由鈎子函數或元件,是以我們将傳回一個 Promise,
    // 以便伺服器能夠等待所有的内容在渲染前,
    // 就已經準備就緒。
    const { app, router, store } = createApp()

    const meta = app.$meta()

    // 設定伺服器端 router 的位置
    router.push(context.url)

    context.meta = meta

    // 等到 router 将可能的異步元件和鈎子函數解析完
    await new Promise(router.onReady.bind(router))

    // 這個rendered函數會在服務端渲染完畢之後被調用
    context.rendered = () => {
      // Renderer會把 context.state 資料對象内聯到頁面模闆中
      // 最終發送到用戶端的頁面中會包含一段腳本:window.__INITIAL_STATE__ = context.state
      // 用戶端就要把頁面中的 window.__INITIAL_STATE__ 拿出來填充到用戶端 store 容器中 
      context.state = store.state
    }

    return app
}

           

entry-client.js

// entry-client.js

import { createApp } from './app'

const { app, router, store } = createApp()

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
  app.$mount('#app')
})
           

繼續閱讀