如何構建 vue-ssr 專案的方法步驟
如何通過 web 伺服器去渲染一個 vue 例項
構建一個極簡的服務端渲染需要什麼
- web 伺服器
- vue-server-renderer
- vue
const Vue = require('vue') const Koa = require('koa') const app = new Koa() const Router = require('koa-router') const router = new Router() const renderer = require('vue-server-renderer').createRenderer() router.get(/./,(ctx)=>{ const app = new Vue({ data: { url: ctx.request.url },template: `<div>訪問的 URL 是: {{ url }}</div>` }) renderer.renderToString(app,(err,html) => { if (err) { ctx.status = 500 ctx.body = err.toString() } ctx.body = ` <!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body>${html}</body> </html> ` }) }) app.use(router.routes()) app.listen(4000,()=>{ console.log('listen 4000') })
- 首先通過 koa、koa-router 快速起了一個 web 伺服器,這個伺服器接受任何路徑
- 建立了一個renderer物件,建立一個 vue 例項
- renderer.renderToString 將 vue 例項解析為 html 字串
- 通過 ctx.body ,拼接成一個完整的 html 字串模版返回。
相信經過上面的程式碼例項可得知,即使你沒有使用過 vue-ssr 的經歷,但是你簡單地使用過 vue 和 koa 的同學都可以看出來這個程式碼非常明瞭。
唯一要注意的地方就是,我們是通過 require('vue-server-renderer').createRenderer()
renderer.renderToString(app,html)=>{})
- app 就是建立的 vue 例項
- callback,解析 app 後執行的回撥,回撥的第二個引數就是解析完例項得到的 html 字串,這個的 html 字串是掛載到 #app 那部分,是不包含 head、body 的,所以我們需要將它拼接成完整的 html 字串返回給客戶端。
使用 template 用法
上面方法中 ctx.body 的部分需要手動去拼接模版,vue-ssr 支援使用模版的方式。
來看下模版長啥樣,發現出來多一行 <!--vue-ssr-outlet--> 註釋,和普通的html檔案沒有差別
<!--vue-ssr-outlet--> 註釋 -- 這裡將是應用程式 HTML 標記注入的地方。也就是 renderToString 回撥中的 html 會被注入到這裡。
<!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body> <!--vue-ssr-outlet--> </body> </html>
有了模版該如何使用它呢?
只需要在建立 renderer 之前給 createRenderer 函式傳遞 template 引數即可。
看下使用模版和自定義模版的區別,可以看到通過其他部分都相同,只是我們指定了 template 後,ctx.body 返回的地方我們不需要手動去拼接一個完整的 html 結構了。
const renderer = require('vue-server-renderer').createRenderer({ template: fs.readFileSync('./index.template.html','utf-8') }) router.get(/./,template:"<div>訪問路徑{{url}}</div>" }) renderer.renderToString(app,html) => { if (err) { ctx.status = 500 ctx.body = err.toString() } ctx.body = html }) })
專案級
上面的例項是 demo 的展示,在實際專案中開發的話我們會根據客戶端和服務端將它們分別劃分在不同的區塊中。
專案結構
// 一個基本專案可能像是這樣: build -- webpack配置 |——- client.config.js |——- server.config.js |——- webpack.base.config.js src ├── components │ ├── Foo.vue │ ├── Bar.vue │ └── Baz.vue ├── App.vue ├── app.js # 通用 entry(universal entry) -- 生成 vue 的工廠函式 ├── entry-client.js # 僅運行於瀏覽器 -- 將 vue 例項掛載,作為 webpack 的入口 |── entry-server.js # 僅運行於伺服器 -- 資料預處理邏輯,作為 webpack 的入口 |-- server.js -- web 伺服器啟動入口 |-- store.js -- 服務端資料預處理儲存容器 |-- router.js -- vue 路由表
載入一個vue-ssr應用整體流程
首先根據上面的專案結構我們可以大概知道,我們的服務端和客戶端分別以 entry-client.js 和 entry-server.js 為入口,通過 webpack 打包出對應的 bundle.js 檔案。
首先不考慮 entry-client.js 和 entry-server.js 做了什麼(後續會補充),我們需要知道,它們經過 webpack 打包後生成了我們需要的建立 ssr 的依賴 .js 檔案。 可以看下圖打包出來的檔案,.json 檔案是用來關聯 .js 檔案的,就是一個輔助檔案,真正起作用的還是兩個 .js 檔案。
假設我們以及打包好了這兩份檔案,我們來看 server.js 中做了什麼。
server.js
// ... 省略不重要步驟 const renderer = require('vue-server-renderer').createBundleRenderer(require('./dist/vue-ssr-server-bundle.json'),{ runInNewContext:false,template: fs.readFileSync('./index.template.html','utf-8'),// 客戶端構建 clientManifest:require('./dist/vue-ssr-client-manifest.json') }) router.get('/home',async (ctx)=>{ ctx.res.setHeader('Content-Type','text/html') const html = await renderer.renderToString() ctx.body = html }) app.listen(4000,()=>{ })
省略了一些不重要的步驟,來看 server.js,其實它和我們上面建立一個簡單的服務端渲染步驟基本相同
- 建立一個 renderer 物件,不同點在於建立這個物件是根據已經打包好的 .json 檔案去找到真正起作用.js 檔案去生成的。
- 由於在 createBunldeRenderer 建立 renderer 物件的時候同時傳入了 server.json 和 client-mainfest.json 兩個部分,所以我們在使用 renderer.renderToString() 的時候也不需要去傳入 vue例項了。
- 最終得到 html 字串和上面相同,返回客戶端就完成了服務端渲染的部分。接下來就是客戶端解析渲染 dom 的過程。
流程梳理
有了對專案結構的瞭解,和 server.js 的基本瞭解後來梳理下 vue-ssr 整個工作流程是怎麼樣的?
首先我們會啟動一個 web 服務,也就上面的 server.js,來檢視一個服務端路徑
router.get('/home',async (ctx)=>{ const context = { title:'template render',url:ctx.request.url } ctx.res.setHeader('Content-Type','text/html') const html = await renderer.renderToString(context) ctx.body = html }) app.listen(4000,()=>{ console.log('listen 4000') })
當我們訪問 http://localhost:4000/home 就會命中該路由,執行 renderer.renderToString(context) ,renderer 是根據我們已經打包好的 bundle 檔案生成的 renderer物件。相當於去執行 entry-server.js 服務端資料處理和儲存的操作
根據模版檔案,得到 html 檔案後返回給客戶端,Vue 在瀏覽器端接管由服務端傳送的靜態 HTML,使其變為由 Vue 管理的動態 DOM 的過程。相當於去執行 entry-client.js 客戶端的邏輯
由於伺服器已經渲染好了 HTML,我們顯然無需將其丟棄再重新建立所有的 DOM 元素。相反,我們需要"啟用"這些靜態的 HTML,然後使他們成為動態的(能夠響應後續的資料變化)。 如果你檢查伺服器渲染的輸出結果,你會注意到應用程式的根元素上添加了一個特殊的屬性:
<div id="app" data-server-rendered="true">
entry-client.js 和 entry-server.js
經過上面的流程梳理我們知道了當訪問一個 vue-ssr 的整個流程: 訪問 web 伺服器地址 > 執行 renderer.renderToString(context) 解析已經打包的 bunlde 返回 html 字串 > 在客戶端啟用這些靜態的 html,使它們成為動態的。
接下來我們需要看看 entry-client.js 和 entry-server.js 做了什麼。
entry-server.js
- 這裡的 context 就是 renderer.renderToString(context) 傳遞的值,至於你想傳遞什麼是你在 web 伺服器中自定義的,可以傳遞任何你想給客戶端的值。
- 這裡我們可以通過 context 來獲取到客戶端返回 web 伺服器的地址,通過 context.url (需要你在服務端傳遞該值)獲取到該路徑,並且通過 router.push(context.url) 例項來訪問相同的路徑。
- context.url 對應的元件中會定義一個 asyncData 的靜態方法,並且將服務端儲存在 store 的值傳遞給該方法。
- 將 store 中的值儲存給 context.state,context.state 將作為 window. INITIAL_STATE 狀態,自動嵌入到最終的 HTML 中。就是一個全域性變數。
import { createApp } from './app' export default context => { // 因為有可能會是非同步路由鉤子函式或元件,所以我們將返回一個 Promise, // 以便伺服器能夠等待所有的內容在渲染前, // 就已經準備就緒。 return new Promise((resolve,reject) => { const { app,router,store } = createApp() // 設定伺服器端 router 的位置 router.push(context.url) // 等到 router 將可能的非同步元件和鉤子函式解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents() // 匹配不到的路由,執行 reject 函式,並返回 404 if (!matchedComponents.length) { return reject({ code: 404 }) } // 對所有匹配的路由元件呼叫 asyncData // Promise.all([p1,p2,p3]) const allSyncData = matchedComponents.map(Component => { if(Component.asyncData) { return Component.asyncData({ store,route:router.currentRoute }) } }) Promise.all(allSyncData).then(() => { // 當使用 template 時,context.state 將作為 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中。 context.state = store.state resolve(app) }).catch(reject) },reject) }) }
entry-client.js
執行匹配到的元件中定義的 asyncData 靜態方法,將 store 中的值取出來作為客戶端的資料。
import { createApp } from './app' // 你仍然需要在掛載 app 之前呼叫 router.onReady,因為路由器必須要提前解析路由配置中的非同步元件,才能正確地呼叫元件中可能存在的路由鉤子。 const { app,store } = createApp() if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) } router.onReady(() => { // 使用 `router.beforeResolve()`,以便確保所有非同步元件都 resolve。 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() } Promise.all(activated.map(c => { if (c.asyncData) { return c.asyncData({ store,route: to }) } })).then(() => { next() }).catch(next) }) app.$mount('#app') })
構建配置
webpack.base.config.js
服務端和客戶端相同的配置一些通用配置,和我們平時使用的 webpack 配置相同,擷取部分展示
module.exports = { mode:isProd ? 'production' : 'development',devtool: isProd ? false : '#cheap-module-source-map',output: { path: path.resolve(__dirname,'../dist'),publicPath: '/dist/',filename: '[name].[chunkhash].js' },module: { rules: [ { test: /\.vue$/,loader: 'vue-loader',options: { compilerOptions: { preserveWhitespace: false } } },{ test: /\.js$/,loader: 'babel-loader',exclude: /node_modules/ },{ test: /\.(png|jpg|gif|svg)$/,loader: 'url-loader',options: { limit: 10000,name: '[name].[ext]?[hash]' } },{ test: /\.styl(us)?$/,use: isProd ? ExtractTextPlugin.extract({ use: [ { loader: 'css-loader',options: { minimize: true } },'stylus-loader' ],fallback: 'vue-style-loader' }) : ['vue-style-loader','css-loader','stylus-loader'] },] },plugins: [ new VueLoaderPlugin() ] }
client.config.js
const webpack = require('webpack') const {merge} = require('webpack-merge') const baseConfig = require('./webpack.base.config') const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') const path = require('path') module.exports = merge(baseConfig,{ entry:path.resolve('__dirname','../entry-client.js'),plugins:[ // 生成 `vue-ssr-client-manifest.json`。 new VueSSRClientPlugin() ] })
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') const path = require('path') module.exports = merge(baseConfig,'../entry-server.js'),target:'node',devtool:'source-map',// 告知 server bundle 使用 node 風格匯出模組 output:{ libraryTarget:'commonjs2' },externals: nodeExternals({ allowlist:/\.css$/ }),plugins:[ new VueSSRServerPlugin() ] })
開發環境配置
webpack 提供 node api可以在 node 執行時使用。
修改 server.js
server.js 作為 web 伺服器的入口檔案,我們需要判斷當前執行的環境是開發環境還是生產環境。
const isProd = process.env.NODE_ENV === 'production' async function prdServer(ctx) { // ...生產環境去讀取 dist/ 下的 bundle 檔案 } async function devServer(ctx){ // 開發環境 } router.get('/home',isProd ? prdServer : devServer) app.use(router.routes()) app.listen(4000,()=>{ console.log('listen 4000') })
dev-server.js
生產環境中是通過讀取記憶體中 dist/ 資料夾下的 bundle 來解析生成 html 字串的。在開發環境中我們該怎麼拿到 bundle 檔案呢?
- webpack function 讀取 webpack 配置來獲取編譯後的檔案
- memory-fs 來讀取記憶體中的檔案
- koa-webpack-dev-middleware 將 bundle 寫入記憶體中,當客戶端檔案發生變化可以支援熱更新
webpack 函式使用
匯入的 webpack 函式會將 配置物件 傳給 webpack,如果同時傳入回撥函式會在 webpack compiler 執行時被執行:
• 方式一:添加回調函式
const webpackConfig = { // ...配置項 } const callback = (err,stats) => {} webpack(webpackConfig,callback)
err物件 不包含 編譯錯誤,必須使用 stats.hasErrors() 單獨處理,文件的 錯誤處理 將對這部分將對此進行詳細介紹。err 物件只包含 webpack 相關的問題,例如配置錯誤等。
方式二:得到一個 compiler 例項
你可以通過手動執行它或者為它的構建時新增一個監聽器,compiler 提供以下方法
compiler.run(callback)
compiler.watch(watchOptions,handler) 啟動所有編譯工作
const webpackConfig = { // ...配置項 } const compiler = webpack(webpackConfig)
客戶端配置
const clientCompiler = webpack(clientConfig) const devMiddleware = require('koa-webpack-dev-middleware')(clientCompiler,{ publicPath:clientConfig.output.publicPath,noInfo:true,stats:{ colors:true } }) app.use(devMiddleware) // 編譯完成時觸發 clientCompiler.hooks.done.tap('koa-webpack-dev-middleware',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() })
預設情況下,webpack 使用普通檔案系統來讀取檔案並將檔案寫入磁碟。但是,還可以使用不同型別的檔案系統(記憶體(memory),webDAV 等)來更改輸入或輸出行為。為了實現這一點,可以改變 inputFileSystem 或 outputFileSystem。例如,可以使用 memory-fs 替換預設的 outputFileSystem,以將檔案寫入到記憶體中。
koa-webpack-dev-middleware 內部就是用 memory-fs 來替換 webpack 預設的 outputFileSystem 將檔案寫入記憶體中的。
讀取記憶體中的 vue-ssr-client-mainfest.json
呼叫 update 封裝好的更新方法
服務端配置
讀取記憶體中的vue-ssr-server-bundle.json檔案
呼叫 update 封裝好的更新方法
// hot middleware app.use(require('koa-webpack-hot-middleware')(clientCompiler,{ heartbeat: 5000 })) // watch and update server renderer const serverCompiler = webpack(serverConfig) serverCompiler.outputFileSystem = mfs serverCompiler.watch({},stats) => { if (err) throw err stats = stats.toJson() if (stats.errors.length) return // read bundle generated by vue-ssr-webpack-plugin bundle = JSON.parse(readFile(mfs,'vue-ssr-server-bundle.json')) update() })
update 方法
const update = async () => { if(bundle && clientManifest) { const renderer = createRenderer(bundle,{ template:require('fs').readFileSync(templatePath,clientManifest }) // 自定義上下文 html = await renderer.renderToString({url:ctx.url,title:'這裡是標題'}) ready() } }
總結
本文將自己理解的 vue-ssr 構建過程做了梳理,到此這篇關於如何構建 vue-ssr 專案的文章就介紹到這了,更多相關如何構建 vue-ssr 專案內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!