webpack4、Koa配置Vue伺服器端渲染(SSR)

lihaozecq發表於2018-10-29

閱讀前

  1. 為什麼使用伺服器渲染? ?官方解釋
  2. 對VueSSR指南簡單瞭解?官方文件
  3. 對webpack簡單瞭解?官方文件
  4. Node.js框架Koa簡單瞭解?官方文件

基礎用法

構建伺服器端渲染(SSR)我們需要藉助vue-server-renderer ,我們先嚐試一下官方文件的一段demo,編寫server.js

// 第 1 步:建立一個 Vue 例項
const Vue = require('vue')
const app = new Vue({
  template: `<div>Hello World</div>`
})

// 第 2 步:建立一個 renderer
const renderer = require('vue-server-renderer').createRenderer()

// 第 3 步:將 Vue 例項渲染為 HTML
renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html)
  // => <div data-server-rendered="true">Hello World</div>
})
複製程式碼

執行node server.js可以看到控制檯列印<div data-server-rendered="true">Hello World</div>

webpack4、Koa配置Vue伺服器端渲染(SSR)

從這段程式碼我們應該可以明白vue-server-renderer的作用是拿到vue例項並渲染成html結構,但它不僅僅只做著一件事,後面會介紹其他配置引數和配合webpack進行構建。


拿到html結構渲染到頁面上是我們接下來要做的事情,這裡官方事例用的是express搭建伺服器,我這裡採用Koa,為什麼用Koa?我不會express?。Koa起一個服務非常簡單,我們還需要藉助Koa-router來做路由的處理。修改server.js

const Vue = require('vue')
const Koa = require('koa')
const Router = require('koa-router')
const renderer = require('vue-server-renderer').createRenderer()

//  第 1 步:建立koa、koa-router 例項
const app = new Koa()
const router = new Router()

// 第 2 步:路由中介軟體
router.get('*', async (ctx, next) => {
  // 建立Vue例項
  const app = new Vue({
    data: {
      url: ctx.url
    },
    template: `<div>訪問的 URL 是: {{ url }}</div>`
  })

  // 有錯誤返回500,無錯誤返回html結構
  try {
    const html = await renderer.renderToString(app)
    ctx.status = 200
    ctx.body = `
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `
  } catch (error) {
    console.log(error)
    ctx.status = 500
    ctx.body = 'Internal Server Error'
  }
})

app
  .use(router.routes())
  .use(router.allowedMethods())

// 第 3 步:啟動服務,通過http://localhost:3000/訪問
app.listen(3000, () => {
  console.log(`server started at localhost:3000`)
})
複製程式碼

從上段程式碼我們就可以看出伺服器端渲染的基本原理了,其實說白了,無伺服器端渲染時,前端打包後的html只是包含head部分,body部分都是通過動態插入到id為#app的dom中。如圖:

webpack4、Koa配置Vue伺服器端渲染(SSR)

而伺服器端渲染(SSR)就是伺服器來提前編譯Vue生成HTML返回給web瀏覽器,這樣網路爬蟲爬取的內容就是網站上所有可呈現的內容。?


為了可以個性化頁面,我們可以把html結構抽成一個模板template,通過雙花括號{{}}進行傳值,新建index.template.html按照官網編寫如下程式碼

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- 三花括號不進行html轉義 -->
    {{{ meta }}}
    <title>{{ title }}</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>
複製程式碼

我們需要通過Node模組fs讀取模板,作為vue-server-renderer的template引數傳入,修改程式碼:

const renderer = require('vue-server-renderer').createRenderer({
  // 讀取傳入template引數
  template: require('fs').readFileSync('./index.template.html', 'utf-8')
})

// ...忽略無關程式碼
router.get('*', async (ctx, next) => {
  // title、meta會插入模板中
  const context = {
    title: ctx.url,
    meta: `
	<meta charset="UTF-8">
    <meta name="descript" content="基於webpack、koa搭建的SSR">
  `
  }
  try {
    // 傳入context渲染上下文物件
    const html = await renderer.renderToString(app, context)
    ctx.status = 200
    // 傳入了template, html結構會插入到<!--vue-ssr-outlet-->
    ctx.body = html
  } catch (error) {
    ctx.status = 500
    ctx.body = 'Internal Server Error'
  }
})
// ...忽略無關程式碼
複製程式碼

webpack4、Koa配置Vue伺服器端渲染(SSR)

可以看到我們的標題和meta都被插入啦!???。到這裡,我們才實現了最基本的用法,接下來我們終於要使用webpack來構建我們專案。

正式環境構建

Node.js伺服器是一個長期執行的程式、當我們的程式碼進入該程式時,它將進行一次取值並留存在記憶體中。這意味著如果建立一個單例物件,它將在每個傳入的請求之間共享,所以我們需要為每個請求建立一個新的根 Vue 例項

不僅vue例項,接下來要用到的vuex、vue-router也是如此。我們利用webpack需要分別對客戶端程式碼和伺服器端程式碼分別打包, 伺服器需要「伺服器 bundle」然後用於伺服器端渲染(SSR),而「客戶端 bundle」會傳送給瀏覽器,用於混合靜態標記。這裡貼一下官方構建圖:

webpack4、Koa配置Vue伺服器端渲染(SSR)

我們可以大致的理解為伺服器端、客戶端通過倆個入口Server entryClinet entry 獲取原始碼,再通過webpack打包變成倆個bundlevue-ssr-server-bundle.jsonvue-ssr-client-manifest.json,配合生成完成HTML,而app.js是倆個入口通用的程式碼部分,其作用是暴露出vue例項。所以我們可以按照官方建議整理檔案目錄,並按照官方事例程式碼編寫,其中起服務的server.js我們用的是Koa,所以可以先不用改。

webpack4、Koa配置Vue伺服器端渲染(SSR)

上面程式碼需要注意的是entry-server.js,它是暴露出一個函式,接受渲染上下文context引數,然後根據url匹配元件。所以說引數需要在我們呼叫renderToString傳入context,幷包括url屬性。


生成的倆個bundle其實是作為引數傳入到createBundleRenderer()函式中,然後在renderToString變成html結構,與createRenderer不同的是前者是通過bundle引數獲取vue元件編譯,後者是需要在renderToString時傳入vue例項?文件。我們先編寫webpack成功生成bundle後,再去編寫server.js,這樣有利於我們更好的理解和測試。


首先我們建立build資料夾,用於存放webpack相關配置,在vue-cli3之前,vue init 初始化後的專案都是有build資料夾的,可以清楚看到webpack配置。而vue-cli3後,使用webpack4,並將配置隱藏了起來,如果想了解webpack4構建vue單頁面應用可以去我的github上檢視?地址。我們可以模仿vue-cli,建立通用配置webpack.base.conf.js、客戶端配置webpack.client.conf.js、服務端配置webpack.server.conf.js。檔案目錄為

├── build
│   ├── webpack.base.conf.js   # 基本webpack配置
│   ├── webpack.client.conf.js # 客戶端webpack配置
│   └── webpack.server.conf.js # 伺服器端webpack配置
├── src
├── index.template.html
└── server.js 
複製程式碼

webpack.base.conf.js配置主要定義通用的rules,例如vue-loader對.vue檔案編譯,對js檔案babel編譯,處理圖片、字型等。其基本配置如下:

const path = require('path')
// vue-loader v15版本需要引入此外掛
const VueLoaderPlugin = require('vue-loader/lib/plugin')

// 用於返回檔案相對於根目錄的絕對路徑
const resolve = dir => path.posix.join(__dirname, '..', dir)

module.exports = {
  // 入口暫定客戶端入口,服務端配置需要更改它
  entry: resolve('src/entry-client.js'),
  // 生成檔案路徑、名字、引入公共路徑
  output: {
    path: resolve('dist'),
    filename: '[name].js',
    publicPath: '/'
  },
  resolve: {
    // 對於.js、.vue引入不需要寫字尾
    extensions: ['.js', '.vue'],
    // 引入components、assets可以簡寫,可根據需要自行更改
    alias: {
      'components': resolve('src/components'),
      'assets': resolve('src/assets')
    }
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          // 配置哪些引入路徑按照模組方式查詢
          transformAssetUrls: {
            video: ['src', 'poster'],
            source: 'src',
            img: 'src',
            image: 'xlink:href'
          }
        }
      },
      {
        test: /\.js$/, // 利用babel-loader編譯js,使用更高的特性,排除npm下載的.vue元件
        loader: 'babel-loader',
        exclude: file => (
          /node_modules/.test(file) &&
          !/\.vue\.js/.test(file)
        )
      },
      {
        test: /\.(png|jpe?g|gif|svg)$/, // 處理圖片
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 10000,
              name: 'static/img/[name].[hash:7].[ext]'
            }
          }
        ]
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, // 處理字型
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'static/fonts/[name].[hash:7].[ext]'
        }
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
}
複製程式碼

webpack.client.conf.js主要是對客戶端程式碼進行打包,它是通過webpack-merge實現對基礎配置的合併,其中要實現對css樣式的處理,此處我用了stylus,同時要下載對應的stylus-loader來處理。在這裡我們先不考慮開發環境,後面會針對開發環境對webpack進行修改。

const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
// css樣式提取單獨檔案
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
// 服務端渲染用到的外掛、預設生成JSON檔案(vue-ssr-client-manifest.json)
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(baseWebpackConfig, {
  mode: 'production',
  output: {
    // chunkhash是根據內容生成的hash, 易於快取,
    // 開發環境不需要生成hash,目前先不考慮開發環境,後面詳細介紹
    filename: 'static/js/[name].[chunkhash].js',
    chunkFilename: 'static/js/[id].[chunkhash].js'
  },
  module: {
    rules: [
      {
        test: /\.styl(us)?$/,
        // 利用mini-css-extract-plugin提取css, 開發環境也不是必須
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader']
      },
    ]
  },
  devtool: false,
  plugins: [
    // webpack4.0版本以上採用MiniCssExtractPlugin 而不使用extract-text-webpack-plugin
    new MiniCssExtractPlugin({
      filename: 'static/css/[name].[contenthash].css',
      chunkFilename: 'static/css/[name].[contenthash].css'
    }),
    //  當vendor模組不再改變時, 根據模組的相對路徑生成一個四位數的hash作為模組id
    new webpack.HashedModuleIdsPlugin(),
    new VueSSRClientPlugin()
  ]
})
複製程式碼

編寫完,我們需要在package.json定義命令來執行webpack打包命令。如果沒有該檔案,需要通過npm init初始化生成

// package.json
"scripts": {
  "build:client": "webpack --config build/webpack.client.conf.js", # 打包客戶端程式碼
  "build:server": "webpack --config build/webpack.server.conf.js", # 打包服務端程式碼
  "start": "node server.js" # 啟動服務
}
複製程式碼

我們現在可以通過npm run build:client執行打包命令,執行命令之前要把依賴的npm包下載好,目前所需要到的依賴見下圖:

webpack4、Koa配置Vue伺服器端渲染(SSR)

當打包命令執行完畢後,我們會發現多了一個dist資料夾,其中除了靜態檔案以外,生成了用於服務端渲染的JSON檔案:vue-ssr-client-manifest.json。

webpack4、Koa配置Vue伺服器端渲染(SSR)

同理,我們需要編寫服務端webpack配置,同樣打包生成vue-ssr-server-bundle.json。配置程式碼如下:

const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseWebpackConfig = require('./webpack.base.conf')
const VueServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(baseWebpackConfig, {
  mode: 'production',
  target: 'node',
  devtool: 'source-map',
  entry: path.join(__dirname, '../src/entry-server.js'),
  output: {
    libraryTarget: 'commonjs2',
    filename: 'server-bundle.js',
  },
  // 這裡有個坑... 服務端也需要編譯樣式,但不能使用mini-css-extract-plugin,
  // 因為它會使用document,但服務端並沒document,導致打包報錯。詳情見
  // https://github.com/webpack-contrib/mini-css-extract-plugin/issues/48#issuecomment-375288454
  module: {
    rules: [
      {
        test: /\.styl(us)?$/,
        use: ['css-loader/locals', 'stylus-loader']
      }
    ]
  },
  // 不要外接化 webpack 需要處理的依賴模組
  externals: nodeExternals({
    whitelist: /\.css$/
  }),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.VUE_ENV': '"server"'
    }),
    // 預設檔名為 `vue-ssr-server-bundle.json`
    new VueServerPlugin()
  ]
})

複製程式碼

同上,我們執行命令後發現dist檔案下生成vue-ssr-server-bundle.json,我們可以新建build命令來一起執行打包。

webpack4、Koa配置Vue伺服器端渲染(SSR)

好了,現在我們可以修改我們的server.js來實現整個伺服器端渲染流程。我們需要獲取倆個JSON檔案、html模板作為引數傳入createBundleRenderer,vue例項不再需要,context需要url,因為服務端端入口(entry-server.js) 需要獲取訪問的路徑來匹配對應的vue元件(上面提到過)。部分改動程式碼如下:

/* 將createRenderer替換成createBundleRenderer,不同之處在上面提到過... */
const { createBundleRenderer } = require('vue-server-renderer')

// ...忽略無關程式碼

// 獲取客戶端、伺服器端生成的json檔案、html模板檔案
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const template = require('fs').readFileSync('./index.template.html', 'utf-8')

// 傳入 json檔案和template, 渲染上下文url需要傳入,服務端需要匹配路由
router.get('*', async (ctx, next) => {
  const renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false, // 推薦
    template, // 頁面模板
    clientManifest // 客戶端構建 manifest
  })

  const context = {
    url: ctx.url,
	// ...
  }

// ...忽略無關程式碼
複製程式碼

改動後,我們執行npm run start,發現頁面已經成功渲染出來,但這時有個問題,載入的資源都失敗了,檔案存在於dist中,很顯然,一定是路徑不對導致的。這時我們可以通過koa-send來實現靜態資源的傳送。我們需要在server.js中加入這行程式碼:

const send = require('koa-send')
// 引入/static/下的檔案都通過koa-send轉發到dist檔案目錄下
router.get('/static/*', async (ctx, next) => {
  await send(ctx, ctx.path, { root: __dirname + '/dist' });
})
複製程式碼

再重新執行,開啟控制檯可以看到資源載入成功,並且載入的doc裡面包含頁面上所有內容。?

webpack4、Koa配置Vue伺服器端渲染(SSR)

開發環境構建

我們跑通了基本的服務端渲染流程,但還沒有涉及到非同步資料、快取等問題。在此之前,我們需要先實現開發環境的搭建,因為我們不可能敲的每一行程式碼都需要重新打包並起服務。這是不利於除錯的。並且很?。


想一想vue-cli構建出來的專案,我們可以通過npm run dev(vue-cli3使用了npm run serve)起一個服務,然後更改檔案的時候,頁面也會自動的熱載入,不需要手動重新整理。我們也要實現一個類似的開發環境,所以我們需要利用node來構建webpack配置,並且實時監控檔案的改變,當改變時應該重新進行打包,重新生成倆個JSON檔案,並重新進行BundleRenderer.renderToString()方法。我們除了重新生成JSON檔案意外,其他邏輯和之前實現的邏輯大體相同。所以我們可以在server.js基礎上進行修改,在原基礎上進行環境的判斷,做不同的render。我們需要一個環境變數來決定執行哪個邏輯。

這裡我們使用cross-env來設定process.env.NODE_ENV變數:

webpack4、Koa配置Vue伺服器端渲染(SSR)

我們把build、start命令都設定了process.env.NODE_ENV為production生產環境,這樣我們在檔案中可以獲取到該值,如果沒有我們就預設是development開發環境。那我們的server.js都需要修改哪裡呢?

1. 首先是生成BundleRenderer例項,之前我們是通過固定路徑(打包後的dist資料夾下)獲取JSON檔案
// 之前程式碼邏輯
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const template = require('fs').readFileSync('./index.template.html', 'utf-8')

//...忽略無關程式碼 

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  template, // 頁面模板
  clientManifest // 客戶端構建 manifest
})
複製程式碼

我們需要按照環境變數更改邏輯,如果是生產環境上述程式碼不變,如果是開發環境,我們需要有一個函式來動態的獲取打包的JSON檔案並且重新生成BundleRenderer例項,我們先定義好這個函式為setupDevServer,顧名思義這個函式是構建開發環境的,它的作用是nodeAPI構建webpack配置,並且做到監聽檔案。我們server.js中可以通過傳遞個回撥函式來做重新生成BundleRenderer例項的操作。而接受的引數就是倆個新生成的JSON檔案。

// 假設已經實現
const setupDevServer = require('./build/setup-dev-server')
// 生成例項公共函式,開發、生產環境只是傳入引數不同
const createBundle = (bundle, clientManifest) => {
  return createBundleRenderer(bundle, {
    runInNewContext: false,
    template,
    clientManifest
  })
}
let renderer // 將例項變數提到全域性變數,根據環境變數賦值
const template = require('fs').readFileSync('./index.template.html', 'utf-8') // 模板

// 第 2步:根據環境變數生成不同BundleRenderer例項
if (process.env.NODE_ENV === 'production') {
  // 獲取客戶端、伺服器端打包生成的json檔案
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  // 賦值
  renderer = createBundle(serverBundle, clientManifest)
  // 靜態資源,開發環境不需要指定
  router.get('/static/*', async (ctx, next) => {
    console.log('進來')
    await send(ctx, ctx.path, { root: __dirname + '/dist' });
  })
} else {
  // 假設setupDevServer已經實現,並傳入的回撥函式會接受生成的json檔案
  setupDevServer(app, (bundle, clientManifest) => {
    // 賦值
    renderer = createBundle(bundle, clientManifest)
  })
}
複製程式碼
2. 其次我們可以把中介軟體函式也提取出來,命名成render函式
const setupDevServer = require('./build/setup-dev-server')
// 第 2步:根據環境變數生成不同BundleRenderer例項
if (process.env.NODE_ENV === 'production') {
  // 獲取客戶端、伺服器端打包生成的json檔案
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  // 賦值
  renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false,
    template,
    clientManifest
  })
  // 靜態資源,開發環境不需要指定
  router.get('/static/*', async (ctx, next) => {
    console.log('進來')
    await send(ctx, ctx.path, { root: __dirname + '/dist' });
  })
} else {
  // 假設setupDevServer已經實現,並傳入的回撥函式會接受生成的json檔案
  setupDevServer(app, (bundle, clientManifest) => {
    // 賦值
    renderer = createBundleRenderer(bundle, {
      runInNewContext: false,
      template,
      clientManifest
    })
  })
}
複製程式碼

這裡我們先假設已實現setupDevServer的功能,後面我們再來仔細講其中的程式碼邏輯。 我們可以在判斷生產環境的地方加上log,列印一下是否如我們所願,針對不同的NODE_ENV環境執行不同的邏輯。

webpack4、Koa配置Vue伺服器端渲染(SSR)


在之前,我們實現的webpack配置並沒有對生產環境與開發環境做區別,但其實,我們應該像vue-cli一樣針對環境來做不同的優化,比如開發環境devtool我們可以使用cheap-module-eval-source-map編譯會更快,css樣式沒有必要打包單獨檔案,使用vue-style-loader做處理就好,並且因為開發環境需要模組熱過載,所以不提取檔案是必要的。開發環境可以做更友好的錯誤提示。還有就是生產環境需要做更多的打包優化,比如壓縮,快取之類。在這個系列文章中,我們就不對生產環境做更好的優化,因為我自己對這方面知識也是很懵懂?。我們先修改webpack.base.conf.js:

// ...
// 定義是否是生產環境的標誌位,用於配置中
const isProd = process.env.NODE_ENV === 'production'

module.exports = {
  // 這裡使用物件的格式,因為在setDevServer.js中需要新增一個熱過載的入口
  entry: {
    app: resolve('src/entry-client.js')
  },
  // 開發環境啟動sourcemap可以更好地定位錯誤位置
  devtool: isProd
    ? false
    : 'cheap-module-eval-source-map',
  // ...... 省略
}
複製程式碼

我們在對webpack.client.conf.js進行修改:

// 定義是否是生產環境的標誌位,用於配置中
const isProd = process.env.NODE_ENV === 'production'

const pordWebpackConfig = merge(baseWebpackConfig, {
  mode: process.env.NODE_ENV || 'development',
  output: {
    // chunkhash是根據內容生成的hash, 易於快取。
    // 開發環境不需要生hash、這個我們在setDevServer函式裡面改
    filename: 'static/js/[name].[chunkhash].js',
    chunkFilename: 'static/js/[id].[chunkhash].js'
  },
  module: {
    rules: [
      {
        test: /\.styl(us)?$/,
        // 開發環境不需要提取css單獨檔案
        use: isProd 
          ? [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader']
          : ['vue-style-loader', 'css-loader', 'stylus-loader']
      },
    ]
  },
  // ... 省略
}
複製程式碼

關於伺服器端webpack的配置可以不進行修改,因為它的功能最後只打包出一個JSON檔案,並不需要針對環境做一些改變。


好的,接下來我們要編寫set-dev-server.js,setDevServer函式主要是利用webpack手動構建應用,並實現熱載入。首先我們需要倆箇中介軟體koa-webpack-dev-middlewarekoa-webpack-hot-middleware,前者是通過傳入webpack編譯好的compiler實現熱載入,而後者是實現模組熱更替,熱載入是監聽檔案變化,從而進行重新整理網頁,模組熱更替則在它的基礎上做到不需要重新整理頁面。我們客戶端webpack配置可以通過前面說的實現自動更新,而服務端compiler,我們通過watchAPI,進行監聽。當倆者其中有一個變化時,我們就需要呼叫傳入的回撥,將新生成的JSON檔案傳入。整個流程大致就是這樣,具體程式碼如下:

const fs = require('fs')
const path = require('path')
// memory-fs可以使webpack將檔案寫入到記憶體中,而不是寫入到磁碟。
const MFS = require('memory-fs')
const webpack = require('webpack')
const clientConfig = require('./webpack.client.conf')
const serverConfig = require('./webpack.server.conf')
// webpack熱載入需要
const webpackDevMiddleware = require('koa-webpack-dev-middleware')
// 配合熱載入實現模組熱替換
const webpackHotMiddleware = require('koa-webpack-hot-middleware')

// 讀取vue-ssr-webpack-plugin生成的檔案
const readFile = (fs, file) => {
  try {
    return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
  } catch (e) {
    console.log('讀取檔案錯誤:', e)
  }
}

module.exports = function setupDevServer(app, cb) {
  let bundle
  let clientManifest

  // 監聽改變後更新函式
  const update = () => {
    if (bundle && clientManifest) {
      cb(bundle, clientManifest)
    }
  }

  // 修改webpack配合模組熱替換使用
  clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
  clientConfig.output.filename = '[name].js'
  clientConfig.plugins.push(
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin()
  )
  

  // 編譯clinetWebpack 插入Koa中介軟體
  const clientshh = webpack(clientConfig)
  const devMiddleware = webpackDevMiddleware(clientCompiler, {
    publicPath: clientConfig.output.publicPath,
    noInfo: true
  })
  app.use(devMiddleware)

  clientCompiler.plugin('done', stats => {
    stats = stats.toJson()
    stats.errors.forEach(err => console.error(err))
    stats.warnings.forEach(err => console.warn(err))
    if (stats.errors.length) return
    clientManifest = JSON.parse(readFile(
      devMiddleware.fileSystem,
      'vue-ssr-client-manifest.json'
    ))
    update()
  })

  // 插入Koa中介軟體(模組熱替換)
  app.use(webpackHotMiddleware(clientCompiler))

  const serverCompiler = webpack(serverConfig)
  const mfs = new MFS()
  serverCompiler.outputFileSystem = mfs
  serverCompiler.watch({}, (err, stats) => {
    if (err) throw err
    stats = stats.toJson()
    if (stats.errors.length) return

    //  vue-ssr-webpack-plugin 生成的bundle
    bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
    update()
  })
}
複製程式碼

我們用到了memory-fs將生成的JSON檔案寫入記憶體中,而不是磁碟中,是為了更快的讀寫。客戶端不需要是因為webpack-dev-middleware已經幫我們完成了。這就是為什麼我們在開發環境並有dist資料夾生成。我們現在可以通過npm run dev訪問localhost:3000,更改程式碼,可以實現熱載入。

webpack4、Koa配置Vue伺服器端渲染(SSR)

資料預取

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

正如官方文件解釋的,SSR本質上就是先執行應用程式並返回HTML,所以我們需要服務端處理資料,客戶端與之同步。資料預取官方文件例項程式碼很詳細,我們照著實現一下即可。這裡不得不說,vue生態的文件一向都是很友好。並且都配有中文文件,對於跟我一樣的小白來說,真是太好不過啦?

服務端資料預取

我們像官網一樣引入vuex對編寫示例程式碼,並進行修改。修改store/index.js

// ...

export function createStore() {
  return new Vuex.Store({
    state: {
      movie: {}
    },
    actions: {
      // 通過傳入id請求電影資料,這裡我們模擬一下,先返回id
      fetchMovie({ commit }, id) {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            resolve({ id })
          }, 500)
        }).then(res => {
          commit('setMoive', { res })
        })
      }
    },
    mutations: {
      // 設定state
      setMoive(state, { res }) {
        state.movie = res
      }
    }
  })
}
複製程式碼

修改A.vue

<template>
  <div>
    A頁 請求電影資料結果:{{  this.$store.state.movie }}
  </div>
</template>

<script>
export default {
  name: 'A',
  // 定義asyncData, entry-server.js會編譯所有匹配的元件中是否包含,包含則執行
  // 將state值掛在到context上,會被序列化為window.__INITIAL_STATE__
  // 
  asyncData ({ store, route }) {
    // 請求電影資料, 傳入 ID : 12345
    return store.dispatch('fetchMovie', 12345)
  },
}
</script>

<style lang="stylus" scoped>
h1
  color blue
</style>


複製程式碼

服務端預取的原理就是,通過在元件內定義asyncData函式用於非同步請求,在entry-server.js服務端中遍歷所有匹配到的元件,如果包含asyncData則執行,並將state掛載到context上下文,vue-server-renderer會將state序列化為window.__ INITIAL_STATE __,這樣,entry-client.js客戶端就可以替換state,實現同步。我們執行程式碼,開啟瀏覽器會看到

webpack4、Koa配置Vue伺服器端渲染(SSR)

客戶端資料預取

因為入口只會在第一次進入應用時執行一次,頁面的跳轉不會再執行服務端資料預取的邏輯,所以說我們需要客戶端資料預取,官網文件實現有倆種方式,這裡就只嘗試一種,利用router的導航守衛,原理就是在每次進行跳轉時,執行沒有執行過的asyncData函式,

// 官方程式碼
router.onReady(() => {
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to)
    const prevMatched = router.getMatchedComponents(from)

    // 我們只關心非預渲染的元件
    // 所以我們對比它們,找出兩個匹配列表的差異元件
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })

    if (!activated.length) {
      return next()
    }

    // 這裡如果有載入指示器(loading indicator),就觸發

    Promise.all(activated.map(c => {
      if (c.asyncData) {
        return c.asyncData({ store, route: to })
      }
    })).then(() => {

      // 停止載入指示器(loading indicator)

      next()
    }).catch(next)
  })
  app.$mount('#app')
})
複製程式碼

這回,我們複製貼上A.vue修改為B.vue作為B頁面傳入不同id(如666666),執行命令,檢視結果,可以看到在跳轉時,state.movie已經被賦予不同的值

webpack4、Koa配置Vue伺服器端渲染(SSR)

設定Head和快取

title注入

我們做服務端渲染,根據不同的頁面會有不同的meta、title。所以我們還需要注入不同的Head。可以用到強大的vue-meta 配合SSR使用。這裡我們就按照官方文件來實現一個簡單的title注入,首先你需要在你的template模板中定義<title>{{ title }}</title> 基本原理跟資料預取類似,我們在特定的時機來獲取元件內title函式,或者字串,然後將它掛載到context上,這樣,就可以實現動態改變標題。客戶端直接呼叫document.title = title就可以。我們將官方示例程式碼title-mixin.js放到mixin資料夾下。app.js中引用,呼叫Vue.mixin(titleMixin),在訪問A頁面時,title就變成A頁面

// app.js

import titleMixin from './mixins/title-mixin'
Vue.mixin(titleMixin)


// A.vue
export default {
  title: 'A頁面', // 或者是 title () { return 'A頁面' }
  // ...
}
複製程式碼
頁面級別快取

快取的基本原理官方程式碼寫的也是一目瞭然。官方程式碼如下:

// server.js

// 設定快取引數
const microCache = LRU({
  max: 100, // 最大快取數
  maxAge: 10000 //  10s過期,意味著10s內請求統一路徑,快取中都有
})

// 判斷是否可以快取,這裡先模擬,當訪問B就快取
const isCacheable = ctx => {
  return ctx.url === '/b'
}

const render = async (ctx) => {
  // ...忽略無關程式碼
    
  // 判斷是否可快取,如果可快取則先從快取中查詢
  const cacheable = isCacheable(ctx)
  if (cacheable) {
    const hit = microCache.get(ctx.url)
    if (hit) {
      console.log('取到快取') // 便於除錯
      ctx.body = hit
      return
    }
  }
    
  // 存入快取, 只有當快取中沒有 && 可以快取
  if (cacheable) {
    console.log('設定快取') // 便於除錯
    microCache.set(ctx.url, html)
  }
}
複製程式碼

我們執行程式碼,重新整理頁面,檢視命令列,可以看到,第一次進入B設定了快取,10s內無論怎麼重新整理頁面,都是取得快取。反而A頁面不會被快取。

總結

關於配置Vue服務端渲染到此就結束啦?。文章主要偏重的還是利用webpack來構建開發環境和生產環境的SSR,因為就我而言在這地方花費時間比較多。像資料的預取、Head的動態設定、路由的快取基本上都是按照官方文件走下來的,理解起來並不難。但真要是做成可以用於線上專案開發還是有許多要做的。比如nuxt就已經做到很好了,我所在公司也在使用nuxt。之所以有這篇文章,也是想對服務端渲染有更好的理解。專案的完整程式碼?地址,如果對你有幫助,別忘給個star哈~

相關文章