服務器渲染 --- Vue+Koa從零搭建成功輸出頁面

webpack從零搭建

webpack4從零開始構建(一)
webpack4+React16項目構建(二)
webpack4功能配置劃分細化(三)
webpack4引入Ant Design和Typescript(四)
webpack4代碼去重,簡化信息和構建優化(五)
webpack4配置Vue版腳手架(六)javascript

服務器渲染系列

服務器渲染 --- Vue+Koa從零搭建成功輸出頁面
服務器渲染 --- 數據預取和狀態
本文最終代碼倉庫在Vue-ssr-demo/demo1css

爲何使用服務器端渲染 (SSR)?

優點

  • 利於搜索引擎優化(Search EngineOptimization)即SEO, 針對搜索引擎爬蟲抓取工具優化,傳統的SPA通常在Loading期間經過AJAX請求獲取內容,因此抓取工具沒法抓取到頁面內容
  • 首屏渲染, 無需等待全部資源下載執行可直接看到完整渲染頁面,提升用戶體驗
  • 無需解析,直接拿到標準的html頁面
  • 服務器可合理利用緩存,減小查詢數據

侷限

  • 瀏覽器和服務器環境不同,某些特定代碼只能在某些特定生命週期函數運行,部分擴展庫須要特殊處理才能在服務器渲染中使用
  • 服務器渲染應用程序須要出於Nodejs server運行環境
  • 在Nodejs渲染會佔用大量CPU資源,須要準備相應負載和藹用緩存
  • 不利於維護開發,學習成本高

基礎示例

yarn add --dev vue-server-renderer koa Vue

這些是實現服務器渲染的關鍵庫,先安裝,而後建立一個server.js建立Vue實例並輸出步驟:html

  1. 新建Koa實例,接收請求返回數據
  2. 接收請求後生成新的Vue實例
  3. 利用插件vue-server-renderer建立一個 Renderer 實例, 將 Vue 實例渲染爲字符串插入Html
  4. 服務器將Html返回
const Koa = require('koa')
const Vue = require('Vue')
const renderer = require('vue-server-renderer').createRenderer()

// 建立Koa實例
const app = new Koa()
app.use(async ctx => {
  // 建立Vue實例
  const app = new Vue({
    template: `<div>SSR_DEMO</div>`
  })

  // 將 Vue 實例渲染爲字符串, 回調函數第一個參數是可能拋出的錯誤,第二個參數是渲染完畢的字符串.
  renderer.renderToString(app, (err, html) => {
    // 發生錯誤輸出500
    if (err) {
      ctx.throw(500, 'Internal Server Error')
      return
    }
    // 響應返回html格式
    ctx.body = (`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>demo</title></head>
        <body>${html}</body>
      </html>
    `)
  })
}).listen(3000);

console.log('已創建鏈接,效果請看http://127.0.0.1:3000/');

保存以後,打開終端運行文件vue

node server.js
// 已創建鏈接,效果請看http://127.0.0.1:3000/

打開瀏覽器訪問地址,輸出SSR_DEMO文字咱們就算完成第一步了java

客戶端渲染

簡單搭建一個Vue+Webpack4的demo,大體目錄以下
圖片描述node

裏面東西不少,咱們不用一下都看完,先慢慢補起來,webpack4的基本配置就不說了,只說關鍵位置webpack

基本頁面結構

configalias.js

自定義的模塊簡化路徑git

const path = require("path");

// 建立 import 或 require 的別名,來確保模塊引入變得更簡單
module.exports = {
  "@": path.resolve(__dirname, "../src/"),
  IMG: path.resolve(__dirname, "../src/img"),
  ROUTER: path.resolve(__dirname, "../src/router"),
  VUEX: path.resolve(__dirname, "../src/vuex"),
  PAGE: path.resolve(__dirname, "../src/page"),
  CMT: path.resolve(__dirname, "../src/component"),
};

src/App.vue

渲染基本界面導航切換驗證github

<template>
  <div id="app">
    <h2>歡迎來到SSR渲染頁面</h2>
    <router-link to="/view1">view1</router-link>
    <router-link to="/view2">view2</router-link>
    <router-view></router-view>
  </div>
</template>
<script>
export default {};
</script>

page/view1.vue

因爲沒有動態更新,全部的生命週期鉤子函數中,只有 beforeCreatecreated 會在服務器端渲染 (SSR) 過程當中被調用.這就是說任何其餘生命週期鉤子函數中的代碼,只會在客戶端執行.你應該避免在 beforeCreatecreated 生命週期時產生全局反作用的代碼,例如定時器,由於沒法在beforeDestroydestroyed 清除.web

下面的輸出用於測試

<template>
  <div>
    <p>Page1</p>
  </div>
</template>

<script>
export default {
  created() {
    console.log('created')
  },
  mounted() {
    console.log('mounted')
  },
};
</script>

page/view2.vue

避免使用特定平臺的 API,例如windowdocument,這種僅瀏覽器可用的全局變量,則會在 Node.js 中執行時拋出錯誤,反之也是如此, 官方推薦方案:

  • 使用多端兼容的庫
  • 第三方庫模擬變量進行hack
  • 僅支持瀏覽器的API進行惰性訪問
<template>
  <div>
    <p>Page2</p>
  </div>
</template>

<script>
export default {
  created() {
    try {
      console.log(window);
    } catch (err) {
      console.log(err);
    }
  },
};
</script>

router/index.js

使用history模式方便服務器渲染.

路由作惰性加載,有助於減小瀏覽器在初始渲染中下載的資源體積

由於客戶端和服務端要共用同一份路由配置,因此不要直接導出實例,而是導出一個建立函數

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export default function createRouter () {
  return new Router({
    // 要記得增長mode屬性,由於#後面的內容不會發送至服務器,服務器不知道請求的是哪個路由
    mode: 'history',
    routes: [
      {
        // 首頁
        alias: '/',
        path: '/view1',
        component: () => import('../page/view1.vue')
      },
      {
        path: '/view2',
        component: () => import('../page/view2.vue')
      },
      { path: '*', redirect: '/view1' }
    ]
  })
}

src/app.js

服務端針對每一個請求都應該建立一個全新獨立的Vue實例,由於它們須要在服務器裏預先請求對應的數據,這樣能夠避免狀態污染

// app.js
import Vue from 'vue'
import App from './App.vue'
import createRouter from './router'

export default function createApp () {
  // 建立 router 實例
  const router = createRouter()

  const app = new Vue({
    // 注入 router 到根 Vue 實例
    router,
    render: (h) => h(App)
  })

  // 返回 app 和 router
  return { app, router }
}

渲染模板

由於新版的HtmlWebpackPlugin不支持html變量編譯,須要轉成ejs模板,而後直接輸出html格式

ejs/client.ejs

做爲瀏覽器渲染的模板,很是常規的一種寫法,無需複述

<!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><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
  <div id="app"></div>
</body>
</html>

ejs/server.ejs

服務器渲染頁面模板,注意 <!--vue-ssr-outlet--> 註釋, 這裏將是應用程序 HTML 標記注入的地方,很重要!!!

裏面引入的變量htmlWebpackPlugin.options.files.js後面再詳解

<!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><%= htmlWebpackPlugin.options.title %></title>
</head>

<body>
    <!--vue-ssr-outlet-->
    <script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
</body>

</html>

構建步驟

圖片描述

官方圖例

  1. 業務代碼寫成通用模式,導出單例app.js
  2. 使用webpack分開服務端和客戶端配置入口進行打包構建
  3. 分別打包出Server BundleClient Bundle
  4. 服務端渲染生成靜態頁面發送到客戶端混合靜態標記

客戶端激活

上面說的混合靜態標記,由於服務器已經預先渲染好靜態HTMl給到客戶端,即Vue在瀏覽器接管由服務端發送的靜態HTML,使其變成由Vue管理的動態DOM過程.

客戶端會直接掛載到根元素

// 這裏假定 App.vue template 根元素的 `id="app"`
app.$mount('#app')

而從服務端獲取到的HTML裏能夠看到該根元素多了特殊屬性

<div id="app" data-server-rendered="true">

這屬性是讓客戶端知道這部分HTML是有服務器渲染無需再執行,而是應該以激活模式進行掛載.

在沒有該屬性的狀況下也還能夠向 $mount 函數的 hydrating參數位置傳入 true,來強制使用激活模式(hydration):

// 強制使用應用程序的激活模式
app.$mount('#app', true)

在開發模式下,Vue 將推斷客戶端生成的虛擬 DOM 樹 (virtual DOM tree),是否與從服務器渲染的 DOM 結構 (DOM structure) 匹配。若是沒法匹配,它將退出混合模式,丟棄現有的 DOM 並從頭開始渲染。在生產模式下,此檢測會被跳過,以免性能損耗。

切記: 瀏覽器可能會更改的一些特殊的 HTML 結構

入口

entryentry-client.js

客戶端的入口文件只需建立應用程序,而且將其掛載到 DOM 中, 在路由完成初始導航時調用,這意味着它能夠解析全部的異步進入鉤子和路由初始化相關聯的異步組件,這能夠有效確保服務端渲染時服務端和客戶端輸出的一致.

import createApp from '../src/app'

const { app, router } = createApp()
// 路由完成初始導航時調用
router.onReady(() => {
  // 掛載App.vue模板中根元素
  app.$mount('#app')
})

entryentry-server.js

服務器的入口文件作了如下幾個步驟:

  1. 動態的導航到接收來自客戶端的請求路由
  2. 路由完成初始導航後經過getMatchedComponents返回當前路由匹配的組件數組
  3. 無匹配組件時作失敗處理
  4. 不然返回成功狀態Promise
import createApp from '../src/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)
  })
}

Webpack關鍵配置

經過使用 webpack 的自定義插件,server bundle將生成爲可傳遞到 bundle renderer 的特殊 JSON 文件,它相比直接打包成js有如下優點:

  • 內置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map')
  • 在開發環境甚至部署過程當中熱重載(經過讀取更新後的 bundle,而後從新建立 renderer 實例)
  • 關鍵 CSS(critical CSS) 注入(在使用 *.vue 文件時):自動內聯在渲染過程當中用到的組件所需的CSS.
  • 使用 clientManifest 進行資源注入:自動推斷出最佳的預加載(preload)和預取(prefetch)指令,以及初始渲染所需的代碼分割 chunk.

configenv.js

主要是設置clientserver的全部關鍵配置了

const path = require('path')
const isDev = process.env.NODE_ENV === 'DEV'
const isProd = true || process.env.NODE_ENV === 'PROD'
const isServer = process.env.NODE_ENV === 'SERVER'
const client = {
  entry: {
    client: path.resolve(__dirname, '../entry/entry-client.js')
  },
  output: {
    // 打包文件名
    filename: 'bundle.client.js',
    // 輸出路徑
    path: path.resolve(__dirname, '../dist/client'),
    // 資源請求路徑
    publicPath: '/'
  },
  htmlPluginOpt: {
    title: "瀏覽器渲染",
    // 本地模板文件的位置
    template: path.resolve(__dirname, '../ejs/client.ejs'),
    // 輸出文件的文件名稱
    filename: 'client.html'
  }
}

const server = {
  entry: {
    server: path.resolve(__dirname, '../entry/entry-server.js')
  },
  output: {
    // 打包文件名
    filename: 'bundle.server.js',
    // 輸出路徑
    path: path.resolve(__dirname, '../dist/server'),
    // 資源請求路徑
    publicPath: '/',
    // 導出的是 module.exports.default
    libraryTarget: 'commonjs2'
  },
  htmlPluginOpt: {
    title: "服務端渲染",
    // 本地模板文件的位置
    template: path.resolve(__dirname, '../ejs/server.ejs'),
    // 輸出文件的文件名稱
    filename: 'server.html',
    // webpack的stats對象的assetsByChunkName屬性表明的值
    files: {
      js: 'bundle.client.js'
    },
    // 不容許注入
    excludeChunks: ['server']
  }
}

const title = 'test'

module.exports = {
  isDev,
  isProd,
  isServer,
  client,
  server,
  title
}

須要注意的是server.htmlPluginOpt的配置,它控制模板禁止注入自己的chunk,而後手動注入客戶端的bundle,

configwebpack-client.js

客戶端執行入口,忽略一些webpack的配置,最終生成客戶端構建清單vue-ssr-client-manifest.json文件

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const merge = require('webpack-merge')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const common = require('./webpack.common.js')
const dev_conf = require('./webpack.dev.js')
const { client } = require('./env')

module.exports = merge(common, dev_conf, {
  // 入口
  entry: client.entry,
  // 輸出
  output: client.output,
  plugins: [
    // 生成客戶端構建清單 (client build manifest)
    // 默認文件名爲 `vue-ssr-client-manifest.json`
    new VueSSRClientPlugin(),
    new HtmlWebpackPlugin(client.htmlPluginOpt)
  ]
})

entryentry-server.js

服務端執行入口,跟客戶端相比有幾個不一樣

  • 指定構建環境爲node
  • 指定輸出模塊適用於用於 CommonJS 環境
  • 引入webpack-node-externals',將須要打包的模塊加入白名單
  • 輸出成vue-ssr-server-bundle.json文件
  • 避免使用CommonsChunkPlugin
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const common = require('./webpack.common.js')
const dev_conf = require('./webpack.dev.js')
const { server } = require('./env')

module.exports = merge(common, dev_conf, {
  // 入口
  entry: server.entry,
  // 輸出
  output: server.output,
  // 對 bundle renderer 提供 source map 支持
  devtool: 'source-map',
  // 這容許 webpack 以 Node 適用方式(Node-appropriate fashion)處理動態導入(dynamic import),
  // 而且還會在編譯 Vue 組件時,
  // 告知 `vue-loader` 輸送面向服務器代碼(server-oriented code)。
  target: 'node',
  externals: nodeExternals({
    // 不要外置化 webpack 須要處理的依賴模塊。
    // 你能夠在這裏添加更多的文件類型。例如,未處理 *.vue 原始文件,
    // 你還應該將修改 `global`(例如 polyfill)的依賴模塊列入白名單
    whitelist: /\.css$/
  }),
  // 這是將服務器的整個輸出
  // 構建爲單個 JSON 文件的插件。
  // 默認文件名爲 `vue-ssr-server-bundle.json`
  plugins: [
    new VueSSRServerPlugin(),
    new HtmlWebpackPlugin(server.htmlPluginOpt)
  ]
})

構建文件

package.json裏咱們配置幾個簡單的命令

"scripts": {
  "client": "cross-env NODE_ENV=PROD webpack --config ./config/webpack-client.js",
  "server": "cross-env NODE_ENV=PROD webpack --config ./config/webpack-server.js",
  "build": "yarn client && yarn server",
  "start": "node server",
  "rnm": "rimraf node_modules"
},

運行命令,生成dist/client/vue-ssr-client-manifest.jsondist/server/vue-ssr-server-bundle.json

yarn build

圖片描述

服務器構建

server/router.js

服務器官方教程選擇Express,可是我以爲過重了,換成同個團隊開發的Koa

  • 使用createBundleRenderer建立一個 BundleRenderer 實例
  • 使用客戶端清單 (client manifest) 和服務器 bundle(server bundle),renderer 如今具備了服務器和客戶端的構建信息,所以它能夠自動推斷和注入資源預加載 / 數據預取指令(preload / prefetch directive),以及 css 連接 / script 標籤到所渲染的 HTML
  • 傳輸URL返回匹配路由組件發送給客戶端
const path = require('path')
const Router = require('koa-router')
const router = new Router()
const { createBundleRenderer } = require('vue-server-renderer')
const { client, server } = require('../config/env')

// 服務器 bundle
const serverBundle = require(`${server.output.path}/vue-ssr-server-bundle.json`);
// 客戶端清單, 自動推斷和注入資源預加載 / 數據預取指令(preload / prefetch directive),以及 css 連接 / script 標籤到所渲染的 HTML
const clientManifest = require(`${client.output.path}/vue-ssr-client-manifest.json`);
const template = require('fs').readFileSync(path.resolve(__dirname, '../dist/server/ssr.html'), 'utf-8');
const renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false, // 推薦, 默認狀況下,對於每次渲染,bundle renderer 將建立一個新的 V8 上下文並從新執行整個 bundle
    template,
    clientManifest, // (可選)客戶端構建 manifest
});

class Server {
  static async createHtml (ctx, next) {
    // 將 Vue 實例渲染爲字符串, 回調函數第一個參數是可能拋出的錯誤,第二個參數是渲染完畢的字符串。
    try {
      const html = await renderer.renderToStream({
        url: ctx.request.url
      })
      ctx.status = 200
      ctx.type = 'html'
      ctx.body = html
    } catch (err) {
      console.log('err: ', err)
      ctx.throw(500, 'Internal Server Error')
    }
  }
}

router.get('*', Server.createHtml)
module.exports = router

server/index.js

  • 加入日誌中間件輸出
  • 設定訪問資源路徑
  • 加入路由
const path = require('path')
const Koa = require('koa')
const logger = require('koa-logger')
const serve = require('koa-static')
const router = require('./router')

// 建立Koa實例
const app = new Koa()
app
  .use(logger())
  .use(serve(path.resolve(__dirname, '../dist/client')))
  .use(router.routes())
  .use(router.allowedMethods())
  .listen(3005)

console.log('已創建鏈接,效果請看http://127.0.0.1:3005/')

運行文件啓動服務器便可查看效果

yarn start

圖片描述
圖片描述

相關文章
相關標籤/搜索