這篇文章給大家介紹Vue中怎么搭建一個服務(wù)端渲染項目,內(nèi)容非常詳細(xì),感興趣的小伙伴們可以參考借鑒,希望對大家能有所幫助。
10年積累的網(wǎng)站建設(shè)、成都網(wǎng)站制作經(jīng)驗,可以快速應(yīng)對客戶對網(wǎng)站的新想法和需求。提供各種問題對應(yīng)的解決方案。讓選擇我們的客戶得到更好、更有力的網(wǎng)絡(luò)服務(wù)。我雖然不認(rèn)識你,你也不認(rèn)識我。但先網(wǎng)站設(shè)計后付款的網(wǎng)站建設(shè)流程,更有浦城免費網(wǎng)站建設(shè)讓你可以放心的選擇與我們合作。
客戶端渲染過程
訪問客戶端渲染的網(wǎng)站。
服務(wù)器返回一個包含了引入資源語句和
的 HTML 文件。客戶端通過 HTTP 向服務(wù)器請求資源,當(dāng)必要的資源都加載完畢后,執(zhí)行 new Vue() 開始實例化并渲染頁面。
服務(wù)端渲染過程
訪問服務(wù)端渲染的網(wǎng)站。
服務(wù)器會查看當(dāng)前路由組件需要哪些資源文件,然后將這些文件的內(nèi)容填充到 HTML 文件。如果有 asyncData() 函數(shù),就會執(zhí)行它進行數(shù)據(jù)預(yù)取并填充到 HTML 文件里,最后返回這個 HTML 頁面。
3. 當(dāng)客戶端接收到這個 HTML 頁面時,可以馬上就開始渲染頁面。與此同時,頁面也會加載資源,當(dāng)必要的資源都加載完畢后,開始執(zhí)行 new Vue() 開始實例化并接管頁面。
從上述兩個過程中,可以看出,區(qū)別就在于第二步??蛻舳虽秩镜木W(wǎng)站會直接返回 HTML 文件,而服務(wù)端渲染的網(wǎng)站則會渲染完頁面再返回這個 HTML 文件。
這樣做的好處是什么?是更快的內(nèi)容到達(dá)時間 (time-to-content)。
假設(shè)你的網(wǎng)站需要加載完 abcd 四個文件才能渲染完畢。并且每個文件大小為 1 M。
這樣一算:客戶端渲染的網(wǎng)站需要加載 4 個文件和 HTML 文件才能完成首頁渲染,總計大小為 4M(忽略 HTML 文件大?。?。而服務(wù)端渲染的網(wǎng)站只需要加載一個渲染完畢的 HTML 文件就能完成首頁渲染,總計大小為已經(jīng)渲染完畢的 HTML 文件(這種文件不會太大,一般為幾百K,我的個人博客網(wǎng)站(SSR)加載的 HTML 文件為 400K)。這就是服務(wù)端渲染更快的原因。
客戶端接管頁面
對于服務(wù)端返回來的 HTML 文件,客戶端必須進行接管,對其進行 new Vue() 實例化,用戶才能正常使用頁面。
如果不對其進行激活的話,里面的內(nèi)容只是一串字符串而已,例如下面的代碼,點擊是無效的:
那客戶端如何接管頁面呢?下面引用一篇文章中的內(nèi)容:
客戶端 new Vue() 時,客戶端會和服務(wù)端生成的DOM進行Hydration對比(判斷這個DOM和自己即將生成的DOM是否相同(vuex store 數(shù)據(jù)同步才能保持一致)
如果相同就調(diào)用app.$mount('#app')將客戶端的vue實例掛載到這個DOM上,即去“激活”這些服務(wù)端渲染的HTML之后,其變成了由Vue動態(tài)管理的DOM,以便響應(yīng)后續(xù)數(shù)據(jù)的變化,即之后所有的交互和vue-router不同頁面之間的跳轉(zhuǎn)將全部在瀏覽器端運行。
如果客戶端構(gòu)建的虛擬 DOM 樹與服務(wù)器渲染返回的HTML結(jié)構(gòu)不一致,這時候,客戶端會請求一次服務(wù)器再渲染整個應(yīng)用程序,這使得SSR失效了,達(dá)不到服務(wù)端渲染的目的了
小結(jié)
不管是客戶端渲染還是服務(wù)端渲染,都需要等待客戶端執(zhí)行 new Vue() 之后,用戶才能進行交互操作。但服務(wù)端渲染的網(wǎng)站能讓用戶更快的看見頁面。
從零開始搭建 SSR 項目
配置 weback
webpack 配置文件共有 3 個:
鴻蒙官方戰(zhàn)略合作共建——HarmonyOS技術(shù)社區(qū)
webpack.base.config.js,基礎(chǔ)配置文件,客戶端與服務(wù)端都需要它。
webpack.client.config.js,客戶端配置文件,用于生成客戶端所需的資源。
webpack.server.config.js,服務(wù)端配置文件,用于生成服務(wù)端所需的資源。
webpack.base.config.js 基礎(chǔ)配置文件
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 只有文件自己的內(nèi)容變化了,文件名才會變化 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 對象,所以報錯。 // 解決方案:樣式相關(guān)的 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()], }
基礎(chǔ)配置文件比較簡單,output 屬性的意思是打包時根據(jù)文件內(nèi)容生成文件名稱。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(), // 該插件會根據(jù)模塊的相對路徑生成一個四位數(shù)的hash作為模塊id, 用于生產(chǎn)環(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 屬性是打包時分割代碼用的。它的作用是將第三方庫都打包在一起。
其他插件作用:
鴻蒙官方戰(zhàn)略合作共建——HarmonyOS技術(shù)社區(qū)
MiniCssExtractPlugin 插件, 將 css 提取出來單獨打包。
CssMinimizerPlugin 插件,壓縮 css。
CompressionPlugin 插件,將資源壓縮成 gzip 格式(大大提升傳輸效率)。另外還需要在 node 服務(wù)器上引入 compression 插件配合使用。
WebpackBar 插件,打包時顯示進度條。
webpack.server.config.js 服務(wù)端配置文件
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' ] } ] }, })
服務(wù)端打包和客戶端不同,它將所有文件一起打包成一個文件 server-bundle.js。同時解析 css 需要使用 vue-style-loader,這一點在官方指南中有說明:
配置服務(wù)器
生產(chǎn)環(huán)境
pro-server.js 生產(chǎn)環(huán)境服務(wù)器配置文件
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()) // 設(shè)置 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)
從代碼中可以看到,當(dāng)首次加載頁面時,需要調(diào)用 createBundleRenderer() 生成一個 renderer,它的參數(shù)是打包生成的 vue-SSR-server-bundle.json 和 vue-SSR-client-manifest.json 文件。當(dāng)返回 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)境的服務(wù)器配置和生產(chǎn)環(huán)境沒什么不同,區(qū)別在于開發(fā)環(huán)境下的服務(wù)器有熱更新。
一般用 webpack 進行開發(fā)時,簡單的配置一下 dev server 參數(shù)就可以使用熱更新了,但是 SSR 項目需要自己配置。
由于 SSR 開發(fā)環(huán)境服務(wù)器的配置文件 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') // 這個配置和熱更新無關(guān),可忽略 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 中調(diào)用這個中間件。
這個中間件的作用呢,簡單總結(jié)為以下三點:通過watch mode,監(jiān)聽資源的變更,然后自動打包; 快速編譯,走內(nèi)存;返回中間件,支持express 的 use 格式。
webpack-hot-middleware 插件的作用就是熱更新,它需要配合 HotModuleReplacementPlugin 和 webpack-dev-middleware 一起使用。
打包文件 vue-SSR-client-manifest.json 和 vue-SSR-server-bundle.json
webpack 需要對源碼打包兩次,一次是為客戶端環(huán)境打包的,一次是為服務(wù)端環(huán)境打包的。
為客戶端環(huán)境打包的文件,和以前我們打包的資源一樣,不過多出了一個 vue-SSR-client-manifest.json 文件。服務(wù)端環(huán)境打包只輸出一個 vue-SSR-server-bundle.json 文件。
vue-SSR-client-manifest.json 包含了客戶端環(huán)境所需的資源名稱:
從上圖中可以看到有三個關(guān)鍵詞:
鴻蒙官方戰(zhàn)略合作共建——HarmonyOS技術(shù)社區(qū)
all,表示這是打包的所有資源。
initial,表示首頁加載必須的資源。
async,表示需要異步加載的資源。
vue-SSR-server-bundle.json 文件:
鴻蒙官方戰(zhàn)略合作共建——HarmonyOS技術(shù)社區(qū)
entry, 服務(wù)端入口文件。
files,服務(wù)端依賴的資源。
填坑記錄
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 對象,所以報錯。
解決方案:樣式相關(guān)的 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)境下跳轉(zhuǎn)頁面樣式不生效,但生產(chǎn)環(huán)境正常。
由于開發(fā)環(huán)境使用的是 memory-fs 插件,打包文件是放在內(nèi)存中的。如果此時 dist 文件夾有剛才打包留下的資源,就會使用 dist 文件夾中的資源,而不是內(nèi)存中的資源。并且開發(fā)環(huán)境和打包環(huán)境生成的資源名稱是不一樣的,所以就造成了這個 BUG。
解決方法是執(zhí)行 npm run dev 時,刪除 dist 文件夾。所以要在 npm run dev 對應(yīng)的腳本中加上 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
不要在有可能使用到服務(wù)端渲染的頁面訪問 DOM,如果有這種操作請放在 mounted() 鉤子函數(shù)里。
如果你引入的數(shù)據(jù)或者接口有訪問 DOM 的操作也會報這種錯,在這種情況下可以使用 require()。因為 require() 是運行時加載的,所以可以這樣使用:
修改后:
修改后可以正常使用。
5. 開發(fā)環(huán)境下,開啟服務(wù)器后無任何反應(yīng),也沒見控制臺輸出報錯信息。
這個坑其實是有報錯信息的,但是沒有輸出,導(dǎo)致以為沒有錯誤。
在 setup-dev-server.js 文件中有一行代碼 if (stats.errors.length) return,如果有報錯就直接返回,不執(zhí)行后續(xù)的操作。導(dǎo)致服務(wù)器沒任何反應(yīng),所以我們可以在這打一個 console.log 語句,打印報錯信息。
關(guān)于Vue中怎么搭建一個服務(wù)端渲染項目就分享到這里了,希望以上內(nèi)容可以對大家有一定的幫助,可以學(xué)到更多知識。如果覺得文章不錯,可以把它分享出去讓更多的人看到。