溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務條款》

Vue中怎么搭建一個服務端渲染項目

發(fā)布時間:2021-07-21 13:51:52 來源:億速云 閱讀:176 作者:Leah 欄目:web開發(fā)

這篇文章給大家介紹Vue中怎么搭建一個服務端渲染項目,內容非常詳細,感興趣的小伙伴們可以參考借鑒,希望對大家能有所幫助。

客戶端渲染過程

  1.  訪問客戶端渲染的網站。

  2.  服務器返回一個包含了引入資源語句和 <div id="app"></div> 的 HTML 文件。

  3.  客戶端通過 HTTP 向服務器請求資源,當必要的資源都加載完畢后,執(zhí)行 new Vue() 開始實例化并渲染頁面。

服務端渲染過程

  1.  訪問服務端渲染的網站。

  2.  服務器會查看當前路由組件需要哪些資源文件,然后將這些文件的內容填充到 HTML 文件。如果有 asyncData() 函數(shù),就會執(zhí)行它進行數(shù)據預取并填充到 HTML 文件里,最后返回這個 HTML 頁面。

   3.  當客戶端接收到這個 HTML 頁面時,可以馬上就開始渲染頁面。與此同時,頁面也會加載資源,當必要的資源都加載完畢后,開始執(zhí)行 new Vue() 開始實例化并接管頁面。

從上述兩個過程中,可以看出,區(qū)別就在于第二步??蛻舳虽秩镜木W站會直接返回 HTML 文件,而服務端渲染的網站則會渲染完頁面再返回這個 HTML 文件。

這樣做的好處是什么?是更快的內容到達時間 (time-to-content)。

假設你的網站需要加載完 abcd 四個文件才能渲染完畢。并且每個文件大小為 1 M。

這樣一算:客戶端渲染的網站需要加載 4 個文件和 HTML 文件才能完成首頁渲染,總計大小為 4M(忽略 HTML 文件大?。?。而服務端渲染的網站只需要加載一個渲染完畢的 HTML 文件就能完成首頁渲染,總計大小為已經渲染完畢的 HTML 文件(這種文件不會太大,一般為幾百K,我的個人博客網站(SSR)加載的 HTML 文件為 400K)。這就是服務端渲染更快的原因。

客戶端接管頁面

對于服務端返回來的 HTML 文件,客戶端必須進行接管,對其進行 new Vue() 實例化,用戶才能正常使用頁面。

如果不對其進行激活的話,里面的內容只是一串字符串而已,例如下面的代碼,點擊是無效的:

<button @click="sayHi">如果不進行激活,點我是不會觸發(fā)事件的</button>

那客戶端如何接管頁面呢?下面引用一篇文章中的內容:

客戶端 new Vue() 時,客戶端會和服務端生成的DOM進行Hydration對比(判斷這個DOM和自己即將生成的DOM是否相同(vuex store 數(shù)據同步才能保持一致)

如果相同就調用app.$mount('#app')將客戶端的vue實例掛載到這個DOM上,即去“激活”這些服務端渲染的HTML之后,其變成了由Vue動態(tài)管理的DOM,以便響應后續(xù)數(shù)據的變化,即之后所有的交互和vue-router不同頁面之間的跳轉將全部在瀏覽器端運行。

如果客戶端構建的虛擬 DOM 樹與服務器渲染返回的HTML結構不一致,這時候,客戶端會請求一次服務器再渲染整個應用程序,這使得SSR失效了,達不到服務端渲染的目的了

小結

不管是客戶端渲染還是服務端渲染,都需要等待客戶端執(zhí)行 new Vue() 之后,用戶才能進行交互操作。但服務端渲染的網站能讓用戶更快的看見頁面。

從零開始搭建 SSR 項目

配置 weback

webpack 配置文件共有 3 個:

  1. 鴻蒙官方戰(zhàn)略合作共建——HarmonyOS技術社區(qū)

  2.  webpack.base.config.js,基礎配置文件,客戶端與服務端都需要它。

  3.  webpack.client.config.js,客戶端配置文件,用于生成客戶端所需的資源。

  4.  webpack.server.config.js,服務端配置文件,用于生成服務端所需的資源。

webpack.base.config.js 基礎配置文件

const path = require('path')  const { VueLoaderPlugin } = require('vue-loader')  const isProd = process.env.NODE_ENV === 'production'  function resolve(dir) {      return path.join(__dirname, '..', dir)  }  module.exports = {      context: path.resolve(__dirname, '../'),      devtool: isProd ? 'source-map' : '#cheap-module-source-map',      output: {          path: path.resolve(__dirname, '../dist'),          publicPath: '/dist/',          // chunkhash 同屬一個 chunk 中的文件修改了,文件名會發(fā)生變化           // contenthash 只有文件自己的內容變化了,文件名才會變化          filename: '[name].[contenthash].js',          // 此選項給打包后的非入口js文件命名,與 SplitChunksPlugin 配合使用          chunkFilename: '[name].[contenthash].js',      },      resolve: {          extensions: ['.js', '.vue', '.json', '.css'],          alias: {              public: resolve('public'),              '@': resolve('src')          }      },      module: {          // https://juejin.im/post/6844903689103081485          // 使用 `mini-css-extract-plugin` 插件打包的的 `server bundle` 會使用到 document。          // 由于 node 環(huán)境中不存在 document 對象,所以報錯。          // 解決方案:樣式相關的 loader 不要放在 `webpack.base.config.js` 文件          // 將其分拆到 `webpack.client.config.js` 和 `webpack.client.server.js` 文件          // 其中 `mini-css-extract-plugin` 插件要放在 `webpack.client.config.js` 文件配置。          rules: [              {                  test: /\.vue$/,                  loader: 'vue-loader',                  options: {                      compilerOptions: {                          preserveWhitespace: false                      }                  }              },              {                  test: /\.js$/,                  loader: 'babel-loader',                  exclude: /node_modules/              },              {                  test: /\.(png|svg|jpg|gif|ico)$/,                  use: ['file-loader']              },              {                  test: /\.(woff|eot|ttf)\??.*$/,                  loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]'              },          ]      },      plugins: [new VueLoaderPlugin()],  }

基礎配置文件比較簡單,output 屬性的意思是打包時根據文件內容生成文件名稱。module 屬性配置不同文件的解析 loader。

webpack.client.config.js 客戶端配置文件

const webpack = require('webpack')  const merge = require('webpack-merge')  const base = require('./webpack.base.config')  const CompressionPlugin = require('compression-webpack-plugin')  const WebpackBar = require('webpackbar')  const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')  const MiniCssExtractPlugin = require('mini-css-extract-plugin')  const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')  const isProd = process.env.NODE_ENV === 'production'  const plugins = [      new webpack.DefinePlugin({          'process.env.NODE_ENV': JSON.stringify(              process.env.NODE_ENV || 'development'          ),          'process.env.VUE_ENV': '"client"'      }),      new VueSSRClientPlugin(),      new MiniCssExtractPlugin({          filename: 'style.css'      })  ]  if (isProd) {      plugins.push(          // 開啟 gzip 壓縮 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md          new CompressionPlugin(),          // 該插件會根據模塊的相對路徑生成一個四位數(shù)的hash作為模塊id, 用于生產環(huán)境。          new webpack.HashedModuleIdsPlugin(),          new WebpackBar(),      )  }  const config = {      entry: {          app: './src/entry-client.js'      },      plugins,      optimization: {          runtimeChunk: {              name: 'manifest'          },          splitChunks: {              cacheGroups: {                  vendor: {                      name: 'chunk-vendors',                      test: /[\\/]node_modules[\\/]/,                      priority: -10,                      chunks: 'initial',                  },                  common: {                      name: 'chunk-common',                      minChunks: 2,                      priority: -20,                      chunks: 'initial',                      reuseExistingChunk: true                  }              },          }      },      module: {          rules: [              {                  test: /\.css$/,                  use: [                      {                          loader: MiniCssExtractPlugin.loader,                          options: {                              // 解決 export 'default' (imported as 'mod') was not found                              // 啟用 CommonJS 語法                              esModule: false,                          },                      },                      'css-loader'                  ]              }          ]      },  }  if (isProd) {      // 壓縮 css      config.optimization.minimizer = [          new CssMinimizerPlugin(),      ]  }  module.exports = merge(base, config)

客戶端配置文件中的 config.optimization 屬性是打包時分割代碼用的。它的作用是將第三方庫都打包在一起。

其他插件作用:

  1. 鴻蒙官方戰(zhàn)略合作共建——HarmonyOS技術社區(qū)

  2.  MiniCssExtractPlugin 插件, 將 css 提取出來單獨打包。

  3.  CssMinimizerPlugin 插件,壓縮 css。

  4.  CompressionPlugin 插件,將資源壓縮成 gzip 格式(大大提升傳輸效率)。另外還需要在 node 服務器上引入 compression 插件配合使用。

  5.  WebpackBar 插件,打包時顯示進度條。

webpack.server.config.js 服務端配置文件

const webpack = require('webpack')  const merge = require('webpack-merge')  const base = require('./webpack.base.config')  const nodeExternals = require('webpack-node-externals') // Webpack allows you to define externals - modules that should not be bundled.  const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')  const WebpackBar = require('webpackbar')  const plugins = [      new webpack.DefinePlugin({          'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),          'process.env.VUE_ENV': '"server"'      }),      new VueSSRServerPlugin()  ]  if (process.env.NODE_ENV == 'production') {      plugins.push(          new WebpackBar()      )  }  module.exports = merge(base, {      target: 'node',      devtool: '#source-map',      entry: './src/entry-server.js',      output: {          filename: 'server-bundle.js',          libraryTarget: 'commonjs2'      },      externals: nodeExternals({          allowlist: /\.css$/ // 防止將某些 import 的包(package)打包到 bundle 中,而是在運行時(runtime)再去從外部獲取這些擴展依賴      }),      plugins,      module: {          rules: [              {                  test: /\.css$/,                  use: [                      'vue-style-loader',                      'css-loader'                  ]              }          ]      },  })

服務端打包和客戶端不同,它將所有文件一起打包成一個文件 server-bundle.js。同時解析 css 需要使用 vue-style-loader,這一點在官方指南中有說明:

Vue中怎么搭建一個服務端渲染項目

配置服務器

生產環(huán)境

pro-server.js 生產環(huán)境服務器配置文件

const fs = require('fs')  const path = require('path')  const express = require('express')  const setApi = require('./api')  const LRU = require('lru-cache') // 緩存  const { createBundleRenderer } = require('vue-server-renderer')  const favicon = require('serve-favicon')  const resolve = file => path.resolve(__dirname, file)  const app = express()  // 開啟 gzip 壓縮 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md  const compression = require('compression')  app.use(compression())  // 設置 favicon  app.use(favicon(resolve('../public/favicon.ico')))  // 新版本 需要加 new,舊版本不用  const microCache = new LRU({      max: 100,      maxAge: 60 * 60 * 24 * 1000 // 重要提示:緩存資源將在 1 天后過期。  })  const serve = (path) => {      return express.static(resolve(path), {          maxAge: 1000 * 60 * 60 * 24 * 30      }) }  app.use('/dist', serve('../dist', true))  function createRenderer(bundle, options) {      return createBundleRenderer(          bundle,          Object.assign(options, {              basedir: resolve('../dist'),              runInNewContext: false          })      )  } function render(req, res) {      const hit = microCache.get(req.url)      if (hit) {          console.log('Response from cache')          return res.end(hit)      }      res.setHeader('Content-Type', 'text/html')      const handleError = err => {          if (err.url) {              res.redirect(err.url)          } else if (err.code === 404) {              res.status(404).send('404 | Page Not Found')          } else {              res.status(500).send('500 | Internal Server Error~')              console.log(err)          }      }      const context = {          title: 'SSR 測試', // default title          url: req.url     }      renderer.renderToString(context, (err, html) => {          if (err) {              return handleError(err)          }          microCache.set(req.url, html)          res.send(html)      })  }  const templatePath = resolve('../public/index.template.html')  const template = fs.readFileSync(templatePath, 'utf-8')  const bundle = require('../dist/vue-SSR-server-bundle.json')  const clientManifest = require('../dist/vue-SSR-client-manifest.json') // 將js文件注入到頁面中  const renderer = createRenderer(bundle, {      template,      clientManifest  })  const port = 8080  app.listen(port, () => {      console.log(`server started at localhost:${ port }`)  })  setApi(app)  app.get('*', render)

從代碼中可以看到,當首次加載頁面時,需要調用 createBundleRenderer() 生成一個 renderer,它的參數(shù)是打包生成的 vue-SSR-server-bundle.json 和 vue-SSR-client-manifest.json 文件。當返回 HTML 文件后,頁面將會被客戶端接管。

在文件的最后有一行代碼 app.get('*', render),它表示所有匹配不到的請求都交給它處理。所以如果你寫了 ajax 請求處理函數(shù)必須放在前面,就像下面這樣:

app.get('/fetchData', (req, res) => { ... })  app.post('/changeData', (req, res) => { ... })  app.get('*', render)

否則你的頁面會打不開。

開發(fā)環(huán)境

開發(fā)環(huán)境的服務器配置和生產環(huán)境沒什么不同,區(qū)別在于開發(fā)環(huán)境下的服務器有熱更新。

一般用 webpack 進行開發(fā)時,簡單的配置一下 dev server 參數(shù)就可以使用熱更新了,但是 SSR 項目需要自己配置。

由于 SSR 開發(fā)環(huán)境服務器的配置文件 setup-dev-server.js 代碼太多,我對其進行簡化后,大致代碼如下:

// dev-server.js  const express = require('express')  const webpack = require('webpack')  const webpackConfig = require('../build/webpack.dev') // 獲取 webpack 配置文件  const compiler = webpack(webpackConfig)  const app = express()  app.use(require('webpack-hot-middleware')(compiler))  app.use(require('webpack-dev-middleware')(compiler, {      noInfo: true,      stats: {          colors: true      }  }))

同時需要在 webpack 的入口文件加上這一行代碼 webpack-hot-middleware/client?reload=true。

// webpack.dev.js  const merge = require('webpack-merge')  const webpackBaseConfig = require('./webpack.base.config.js') // 這個配置和熱更新無關,可忽略  module.exports = merge(webpackBaseConfig, {      mode: 'development',      entry: {          app: ['webpack-hot-middleware/client?reload=true' , './client/main.js'] // 開啟熱模塊更新      },      plugins: [new webpack.HotModuleReplacementPlugin()]  })

然后使用 node dev-server.js 來開啟前端代碼熱更新。

熱更新主要使用了兩個插件:webpack-dev-middleware 和 webpack-hot-middleware。顧名思義,看名稱就知道它們的作用,

webpack-dev-middleware 的作用是生成一個與 webpack 的 compiler 綁定的中間件,然后在 express 啟動的 app 中調用這個中間件。

這個中間件的作用呢,簡單總結為以下三點:通過watch mode,監(jiān)聽資源的變更,然后自動打包; 快速編譯,走內存;返回中間件,支持express 的 use 格式。

webpack-hot-middleware 插件的作用就是熱更新,它需要配合 HotModuleReplacementPlugin 和 webpack-dev-middleware 一起使用。

打包文件 vue-SSR-client-manifest.json 和 vue-SSR-server-bundle.json

webpack 需要對源碼打包兩次,一次是為客戶端環(huán)境打包的,一次是為服務端環(huán)境打包的。

為客戶端環(huán)境打包的文件,和以前我們打包的資源一樣,不過多出了一個 vue-SSR-client-manifest.json 文件。服務端環(huán)境打包只輸出一個 vue-SSR-server-bundle.json 文件。

vue-SSR-client-manifest.json 包含了客戶端環(huán)境所需的資源名稱:

Vue中怎么搭建一個服務端渲染項目

從上圖中可以看到有三個關鍵詞:

  1. 鴻蒙官方戰(zhàn)略合作共建——HarmonyOS技術社區(qū)

  2.  all,表示這是打包的所有資源。

  3.  initial,表示首頁加載必須的資源。

  4.  async,表示需要異步加載的資源。

vue-SSR-server-bundle.json 文件: 

Vue中怎么搭建一個服務端渲染項目

  1. 鴻蒙官方戰(zhàn)略合作共建——HarmonyOS技術社區(qū)

  2. entry, 服務端入口文件。

  3. files,服務端依賴的資源。

填坑記錄

1. [vue-router] failed to resolve async component default: referenceerror: window is not defined

由于在一些文件或第三方文件中可能會用到 window 對象,并且 node 中不存在 window 對象,所以會報錯。

此時可在 src/app.js 文件加上以下代碼進行判斷:

// 在 app.js 文件添加上這段代碼,對環(huán)境進行判斷  if (typeof window === 'undefined') {      global.window = {}  }

2. mini-css-extract-plugin 插件造成 ReferenceError: document is not defined

使用 mini-css-extract-plugin 插件打包的的 server bundle, 會使用到 document。由于 node 環(huán)境中不存在 document 對象,所以報錯。

解決方案:樣式相關的 loader 不要放在 webpack.base.config.js 文件,將其分拆到 webpack.client.config.js 和 webpack.client.server.js 文件。其中 mini-css-extract-plugin 插件要放在 webpack.client.config.js 文件配置。

base

module: {      rules: [          {              test: /\.vue$/,              loader: 'vue-loader',              options: {                  compilerOptions: {                      preserveWhitespace: false                  }              }          },          {              test: /\.js$/,              loader: 'babel-loader',              exclude: /node_modules/          },          {              test: /\.(png|svg|jpg|gif|ico)$/,              use: ['file-loader']          },          {              test: /\.(woff|eot|ttf)\??.*$/,              loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]'          },      ]  }

client

module: {      rules: [          {              test: /\.css$/,              use: [                  {                      loader: MiniCssExtractPlugin.loader,                      options: {                          // 解決 export 'default' (imported as 'mod') was not found                          esModule: false,                      },                  },                  'css-loader'              ]          }      ]  }

server

module: {      rules: [          {              test: /\.css$/,              use: [                  'vue-style-loader',                  'css-loader'              ]          }      ]  }

3. 開發(fā)環(huán)境下跳轉頁面樣式不生效,但生產環(huán)境正常。

由于開發(fā)環(huán)境使用的是 memory-fs 插件,打包文件是放在內存中的。如果此時 dist 文件夾有剛才打包留下的資源,就會使用 dist 文件夾中的資源,而不是內存中的資源。并且開發(fā)環(huán)境和打包環(huán)境生成的資源名稱是不一樣的,所以就造成了這個 BUG。

解決方法是執(zhí)行 npm run dev 時,刪除 dist 文件夾。所以要在 npm run dev 對應的腳本中加上 rimraf dist。

"dev": "rimraf dist && node ./server/dev-server.js --mode development",

4. [vue-router] Failed to resolve async component default: ReferenceError: document is not defined

不要在有可能使用到服務端渲染的頁面訪問 DOM,如果有這種操作請放在 mounted() 鉤子函數(shù)里。

如果你引入的數(shù)據或者接口有訪問 DOM 的操作也會報這種錯,在這種情況下可以使用 require()。因為 require() 是運行時加載的,所以可以這樣使用:

<script>  // 原來報錯的操作,這個接口有 DOM 操作,所以這樣使用的時候在服務端會報錯。 import { fetchArticles } from '@/api/client'  export default {    methods: {      getAppointArticles() {        fetchArticles({          tags: this.tags,          pageSize: this.pageSize,          pageIndex: this.pageIndex,        })        .then(res => {            this.$store.commit('setArticles', res)        })      },    }  }  </script>

修改后:

<script>  // 先定義一個外部變量,在 mounted() 鉤子里賦值  let fetchArticles  export default {    mounted() {      // 由于服務端渲染不會有 mounted() 鉤子,所以在這里可以保證是在客戶端的情況下引入接口        fetchArticles = require('@/api/client').fetchArticles    },    methods: {      getAppointArticles() {        fetchArticles({          tags: this.tags,          pageSize: this.pageSize,          pageIndex: this.pageIndex,        })        .then(res => {            this.$store.commit('setArticles', res)        })      },    }  }  </script>

修改后可以正常使用。

5. 開發(fā)環(huán)境下,開啟服務器后無任何反應,也沒見控制臺輸出報錯信息。

這個坑其實是有報錯信息的,但是沒有輸出,導致以為沒有錯誤。

在 setup-dev-server.js 文件中有一行代碼 if (stats.errors.length) return,如果有報錯就直接返回,不執(zhí)行后續(xù)的操作。導致服務器沒任何反應,所以我們可以在這打一個 console.log 語句,打印報錯信息。

關于Vue中怎么搭建一個服務端渲染項目就分享到這里了,希望以上內容可以對大家有一定的幫助,可以學到更多知識。如果覺得文章不錯,可以把它分享出去讓更多的人看到。

向AI問一下細節(jié)

免責聲明:本站發(fā)布的內容(圖片、視頻和文字)以原創(chuàng)、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。

vue
AI