如何構(gòu)建 vue-ssr 項目的方法步驟
如何通過 web 服務(wù)器去渲染一個 vue 實例
構(gòu)建一個極簡的服務(wù)端渲染需要什么
- web 服務(wù)器
- 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 服務(wù)器,這個服務(wù)器接受任何路徑
- 創(chuàng)建了一個renderer對象,創(chuàng)建一個 vue 實例
- renderer.renderToString 將 vue 實例解析為 html 字符串
- 通過 ctx.body ,拼接成一個完整的 html 字符串模版返回。
相信經(jīng)過上面的代碼實例可得知,即使你沒有使用過 vue-ssr 的經(jīng)歷,但是你簡單地使用過 vue 和 koa 的同學(xué)都可以看出來這個代碼非常明了。
唯一要注意的地方就是,我們是通過 require('vue-server-renderer').createRenderer() 來創(chuàng)建一個 renderer 對象 . 這個renderer 對象有一個 renderToString 的方法
renderer.renderToString(app,(err,html)=>{})
- app 就是創(chuàng)建的 vue 實例
- callback, 解析 app 后執(zhí)行的回調(diào),回調(diào)的第二個參數(shù)就是解析完實例得到的 html 字符串,這個的 html 字符串是掛載到 #app 那部分,是不包含 head、body 的,所以我們需要將它拼接成完整的 html 字符串返回給客戶端。
使用 template 用法
上面方法中 ctx.body 的部分需要手動去拼接模版,vue-ssr 支持使用模版的方式。
來看下模版長啥樣,發(fā)現(xiàn)出來多一行 <!--vue-ssr-outlet--> 注釋,和普通的html文件沒有差別
<!--vue-ssr-outlet--> 注釋 -- 這里將是應(yīng)用程序 HTML 標(biāo)記注入的地方。也就是 renderToString 回調(diào)中的 html 會被注入到這里。
<!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body> <!--vue-ssr-outlet--> </body> </html>
有了模版該如何使用它呢?
只需要在創(chuàng)建 renderer 之前給 createRenderer 函數(shù)傳遞 template 參數(shù)即可。
看下使用模版和自定義模版的區(qū)別,可以看到通過其他部分都相同,只是我們指定了 template 后,ctx.body 返回的地方我們不需要手動去拼接一個完整的 html 結(jié)構(gòu)了。
const renderer = require('vue-server-renderer').createRenderer({ template: fs.readFileSync('./index.template.html','utf-8') }) router.get(/./, (ctx)=>{ const app = new Vue({ data: { url: ctx.request.url }, template:"<div>訪問路徑{{url}}</div>" }) renderer.renderToString(app, (err, html) => { if (err) { ctx.status = 500 ctx.body = err.toString() } ctx.body = html }) })
項目級
上面的實例是 demo 的展示,在實際項目中開發(fā)的話我們會根據(jù)客戶端和服務(wù)端將它們分別劃分在不同的區(qū)塊中。
項目結(jié)構(gòu)
// 一個基本項目可能像是這樣: 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 的工廠函數(shù) ├── entry-client.js # 僅運行于瀏覽器 -- 將 vue 實例掛載,作為 webpack 的入口 |── entry-server.js # 僅運行于服務(wù)器 -- 數(shù)據(jù)預(yù)處理邏輯,作為 webpack 的入口 |-- server.js -- web 服務(wù)器啟動入口 |-- store.js -- 服務(wù)端數(shù)據(jù)預(yù)處理存儲容器 |-- router.js -- vue 路由表
加載一個vue-ssr應(yīng)用整體流程
首先根據(jù)上面的項目結(jié)構(gòu)我們可以大概知道,我們的服務(wù)端和客戶端分別以 entry-client.js 和 entry-server.js 為入口,通過 webpack 打包出對應(yīng)的 bundle.js 文件。
首先不考慮 entry-client.js 和 entry-server.js 做了什么(后續(xù)會補充),我們需要知道,它們經(jīng)過 webpack 打包后生成了我們需要的創(chuàng)建 ssr 的依賴 .js 文件。 可以看下圖打包出來的文件,.json 文件是用來關(guān)聯(lián) .js 文件的,就是一個輔助文件,真正起作用的還是兩個 .js 文件。
假設(shè)我們以及打包好了這兩份文件,我們來看 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'), // 客戶端構(gòu)建 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,其實它和我們上面創(chuàng)建一個簡單的服務(wù)端渲染步驟基本相同
- 創(chuàng)建一個 renderer 對象,不同點在于創(chuàng)建這個對象是根據(jù)已經(jīng)打包好的 .json 文件去找到真正起作用.js 文件去生成的。
- 由于在 createBunldeRenderer 創(chuàng)建 renderer 對象的時候同時傳入了 server.json 和 client-mainfest.json 兩個部分,所以我們在使用 renderer.renderToString() 的時候也不需要去傳入 vue實例了。
- 最終得到 html 字符串和上面相同,返回客戶端就完成了服務(wù)端渲染的部分。接下來就是客戶端解析渲染 dom 的過程。
流程梳理
有了對項目結(jié)構(gòu)的了解,和 server.js 的基本了解后來梳理下 vue-ssr 整個工作流程是怎么樣的?
首先我們會啟動一個 web 服務(wù),也就上面的 server.js ,來查看一個服務(wù)端路徑
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') })
當(dāng)我們訪問 http://localhost:4000/home 就會命中該路由,執(zhí)行 renderer.renderToString(context) ,renderer 是根據(jù)我們已經(jīng)打包好的 bundle 文件生成的 renderer對象。相當(dāng)于去執(zhí)行 entry-server.js 服務(wù)端數(shù)據(jù)處理和存儲的操作
根據(jù)模版文件,得到 html 文件后返回給客戶端,Vue 在瀏覽器端接管由服務(wù)端發(fā)送的靜態(tài) HTML,使其變?yōu)橛?Vue 管理的動態(tài) DOM 的過程。相當(dāng)于去執(zhí)行 entry-client.js 客戶端的邏輯
由于服務(wù)器已經(jīng)渲染好了 HTML,我們顯然無需將其丟棄再重新創(chuàng)建所有的 DOM 元素。相反,我們需要"激活"這些靜態(tài)的 HTML,然后使他們成為動態(tài)的(能夠響應(yīng)后續(xù)的數(shù)據(jù)變化)。 如果你檢查服務(wù)器渲染的輸出結(jié)果,你會注意到應(yīng)用程序的根元素上添加了一個特殊的屬性:
<div id="app" data-server-rendered="true">
entry-client.js 和 entry-server.js
經(jīng)過上面的流程梳理我們知道了當(dāng)訪問一個 vue-ssr 的整個流程: 訪問 web 服務(wù)器地址 > 執(zhí)行 renderer.renderToString(context) 解析已經(jīng)打包的 bunlde 返回 html 字符串 > 在客戶端激活這些靜態(tài)的 html,使它們成為動態(tài)的。
接下來我們需要看看 entry-client.js 和 entry-server.js 做了什么。
entry-server.js
- 這里的 context 就是 renderer.renderToString(context) 傳遞的值,至于你想傳遞什么是你在 web 服務(wù)器中自定義的,可以傳遞任何你想給客戶端的值。
- 這里我們可以通過 context 來獲取到客戶端返回 web 服務(wù)器的地址,通過 context.url (需要你在服務(wù)端傳遞該值)獲取到該路徑,并且通過 router.push(context.url) 實例來訪問相同的路徑。
- context.url 對應(yīng)的組件中會定義一個 asyncData 的靜態(tài)方法,并且將服務(wù)端存儲在 store 的值傳遞給該方法。
- 將 store 中的值存儲給 context.state ,context.state 將作為 window. INITIAL_STATE 狀態(tài),自動嵌入到最終的 HTML 中。就是一個全局變量。
import { createApp } from './app' export default context => { // 因為有可能會是異步路由鉤子函數(shù)或組件,所以我們將返回一個 Promise, // 以便服務(wù)器能夠等待所有的內(nèi)容在渲染前, // 就已經(jīng)準(zhǔn)備就緒。 return new Promise((resolve, reject) => { const { app, router,store } = createApp() // 設(shè)置服務(wù)器端 router 的位置 router.push(context.url) // 等到 router 將可能的異步組件和鉤子函數(shù)解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents() // 匹配不到的路由,執(zhí)行 reject 函數(shù),并返回 404 if (!matchedComponents.length) { return reject({ code: 404 }) } // 對所有匹配的路由組件調(diào)用 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(() => { // 當(dāng)使用 template 時,context.state 將作為 window.__INITIAL_STATE__ 狀態(tài),自動嵌入到最終的 HTML 中。 context.state = store.state resolve(app) }).catch(reject) }, reject) }) }
entry-client.js
執(zhí)行匹配到的組件中定義的 asyncData 靜態(tài)方法,將 store 中的值取出來作為客戶端的數(shù)據(jù)。
import { createApp } from './app' // 你仍然需要在掛載 app 之前調(diào)用 router.onReady,因為路由器必須要提前解析路由配置中的異步組件,才能正確地調(diào)用組件中可能存在的路由鉤子。 const { app,router,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) // 我們只關(guān)心非預(yù)渲染的組件 // 所以我們對比它們,找出兩個匹配列表的差異組件 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') })
構(gòu)建配置
webpack.base.config.js
服務(wù)端和客戶端相同的配置一些通用配置,和我們平時使用的 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:path.resolve('__dirname','../entry-server.js'), target:'node', devtool:'source-map', // 告知 server bundle 使用 node 風(fēng)格導(dǎo)出模塊 output:{ libraryTarget:'commonjs2' }, externals: nodeExternals({ allowlist:/\.css$/ }), plugins:[ new VueSSRServerPlugin() ] })
開發(fā)環(huán)境配置
webpack 提供 node api可以在 node 運行時使用。
修改 server.js
server.js 作為 web 服務(wù)器的入口文件,我們需要判斷當(dāng)前運行的環(huán)境是開發(fā)環(huán)境還是生產(chǎn)環(huán)境。
const isProd = process.env.NODE_ENV === 'production' async function prdServer(ctx) { // ...生產(chǎn)環(huán)境去讀取 dist/ 下的 bundle 文件 } async function devServer(ctx){ // 開發(fā)環(huán)境 } router.get('/home',isProd ? prdServer : devServer) app.use(router.routes()) app.listen(4000,()=>{ console.log('listen 4000') })
dev-server.js
生產(chǎn)環(huán)境中是通過讀取內(nèi)存中 dist/ 文件夾下的 bundle 來解析生成 html 字符串的。在開發(fā)環(huán)境中我們該怎么拿到 bundle 文件呢?
- webpack function 讀取 webpack 配置來獲取編譯后的文件
- memory-fs 來讀取內(nèi)存中的文件
- koa-webpack-dev-middleware 將 bundle 寫入內(nèi)存中,當(dāng)客戶端文件發(fā)生變化可以支持熱更新
webpack 函數(shù)使用
導(dǎo)入的 webpack 函數(shù)會將 配置對象 傳給 webpack,如果同時傳入回調(diào)函數(shù)會在 webpack compiler 運行時被執(zhí)行:
• 方式一:添加回調(diào)函數(shù)
const webpackConfig = { // ...配置項 } const callback = (err,stats) => {} webpack(webpackConfig, callback)
err對象 不包含 編譯錯誤,必須使用 stats.hasErrors() 單獨處理,文檔的 錯誤處理 將對這部分將對此進行詳細(xì)介紹。err 對象只包含 webpack 相關(guān)的問題,例如配置錯誤等。
方式二:得到一個 compiler 實例
你可以通過手動執(zhí)行它或者為它的構(gòu)建時添加一個監(jiān)聽器,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) // 編譯完成時觸發(fā) 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() })
默認(rèn)情況下,webpack 使用普通文件系統(tǒng)來讀取文件并將文件寫入磁盤。但是,還可以使用不同類型的文件系統(tǒng)(內(nèi)存(memory), webDAV 等)來更改輸入或輸出行為。為了實現(xiàn)這一點,可以改變 inputFileSystem 或 outputFileSystem。例如,可以使用 memory-fs 替換默認(rèn)的 outputFileSystem,以將文件寫入到內(nèi)存中。
koa-webpack-dev-middleware 內(nèi)部就是用 memory-fs 來替換 webpack 默認(rèn)的 outputFileSystem 將文件寫入內(nèi)存中的。
讀取內(nèi)存中的 vue-ssr-client-mainfest.json
調(diào)用 update 封裝好的更新方法
服務(wù)端配置
讀取內(nèi)存中的vue-ssr-server-bundle.json文件
調(diào)用 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({}, (err, 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,'utf-8'), clientManifest }) // 自定義上下文 html = await renderer.renderToString({url:ctx.url,title:'這里是標(biāo)題'}) ready() } }
總結(jié)
本文將自己理解的 vue-ssr 構(gòu)建過程做了梳理,到此這篇關(guān)于如何構(gòu)建 vue-ssr 項目的文章就介紹到這了,更多相關(guān)如何構(gòu)建 vue-ssr 項目內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Vue組件庫ElementUI實現(xiàn)表格列表分頁效果
這篇文章主要為大家詳細(xì)介紹了Vue組件庫ElementUI實現(xiàn)表格列表分頁效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-06-06vant之關(guān)于van-list的使用以及一些坑的解決方案
這篇文章主要介紹了vant之關(guān)于van-list的使用以及一些坑的解決方案,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-06-06vue如何根據(jù)網(wǎng)站路由判斷頁面主題色詳解
這篇文章主要給大家介紹了關(guān)于vue如何根據(jù)網(wǎng)站路由判斷頁面主題色的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2018-11-11el-menu如何根據(jù)多層樹形結(jié)構(gòu)遞歸遍歷展示菜單欄
這篇文章主要介紹了el-menu根據(jù)多層樹形結(jié)構(gòu)遞歸遍歷展示菜單欄,本文給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友參考下吧2024-07-07