如何構(gòu)建 vue-ssr 項(xiàng)目的方法步驟
如何通過 web 服務(wù)器去渲染一個 vue 實(shí)例
構(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 實(shí)例
- renderer.renderToString 將 vue 實(shí)例解析為 html 字符串
- 通過 ctx.body ,拼接成一個完整的 html 字符串模版返回。
相信經(jīng)過上面的代碼實(shí)例可得知,即使你沒有使用過 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 實(shí)例
- callback, 解析 app 后執(zhí)行的回調(diào),回調(diào)的第二個參數(shù)就是解析完實(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
})
})
項(xiàng)目級
上面的實(shí)例是 demo 的展示,在實(shí)際項(xiàng)目中開發(fā)的話我們會根據(jù)客戶端和服務(wù)端將它們分別劃分在不同的區(qū)塊中。
項(xiàng)目結(jié)構(gòu)
// 一個基本項(xiàng)目可能像是這樣: 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 # 僅運(yùn)行于瀏覽器 -- 將 vue 實(shí)例掛載,作為 webpack 的入口 |── entry-server.js # 僅運(yùn)行于服務(wù)器 -- 數(shù)據(jù)預(yù)處理邏輯,作為 webpack 的入口 |-- server.js -- web 服務(wù)器啟動入口 |-- store.js -- 服務(wù)端數(shù)據(jù)預(yù)處理存儲容器 |-- router.js -- vue 路由表
加載一個vue-ssr應(yīng)用整體流程
首先根據(jù)上面的項(xiàng)目結(jié)構(gòu)我們可以大概知道,我們的服務(wù)端和客戶端分別以 entry-client.js 和 entry-server.js 為入口,通過 webpack 打包出對應(yīng)的 bundle.js 文件。
首先不考慮 entry-client.js 和 entry-server.js 做了什么(后續(xù)會補(bǔ)充),我們需要知道,它們經(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,其實(shí)它和我們上面創(chuàng)建一個簡單的服務(wù)端渲染步驟基本相同
- 創(chuàng)建一個 renderer 對象,不同點(diǎn)在于創(chuàng)建這個對象是根據(jù)已經(jīng)打包好的 .json 文件去找到真正起作用.js 文件去生成的。
- 由于在 createBunldeRenderer 創(chuàng)建 renderer 對象的時候同時傳入了 server.json 和 client-mainfest.json 兩個部分,所以我們在使用 renderer.renderToString() 的時候也不需要去傳入 vue實(shí)例了。
- 最終得到 html 字符串和上面相同,返回客戶端就完成了服務(wù)端渲染的部分。接下來就是客戶端解析渲染 dom 的過程。
流程梳理
有了對項(xiàng)目結(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) 實(shí)例來訪問相同的路徑。
- 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 => {
// 因?yàn)橛锌赡軙钱惒铰酚摄^子函數(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,因?yàn)槁酚善鞅仨氁崆敖馕雎酚膳渲弥械漠惒浇M件,才能正確地調(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 運(yùn)行時使用。
修改 server.js
server.js 作為 web 服務(wù)器的入口文件,我們需要判斷當(dāng)前運(yùn)行的環(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 運(yùn)行時被執(zhí)行:
• 方式一:添加回調(diào)函數(shù)
const webpackConfig = {
// ...配置項(xiàng)
}
const callback = (err,stats) => {}
webpack(webpackConfig, callback)
err對象 不包含 編譯錯誤,必須使用 stats.hasErrors() 單獨(dú)處理,文檔的 錯誤處理 將對這部分將對此進(jìn)行詳細(xì)介紹。err 對象只包含 webpack 相關(guān)的問題,例如配置錯誤等。
方式二:得到一個 compiler 實(shí)例
你可以通過手動執(zhí)行它或者為它的構(gòu)建時添加一個監(jiān)聽器,compiler 提供以下方法
compiler.run(callback)
compiler.watch(watchOptions,handler) 啟動所有編譯工作
const webpackConfig = {
// ...配置項(xiàng)
}
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 等)來更改輸入或輸出行為。為了實(shí)現(xiàn)這一點(diǎ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 項(xiàng)目的文章就介紹到這了,更多相關(guān)如何構(gòu)建 vue-ssr 項(xiàng)目內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- Egg Vue SSR 服務(wù)端渲染數(shù)據(jù)請求與asyncData
- 15分鐘學(xué)會vue項(xiàng)目改造成SSR(小白教程)
- vue ssr+koa2構(gòu)建服務(wù)端渲染的示例代碼
- Vue SSR 即時編譯技術(shù)的實(shí)現(xiàn)
- Vue使用預(yù)渲染代替SSR的方法
- vuecli項(xiàng)目構(gòu)建SSR服務(wù)端渲染的實(shí)現(xiàn)
- vue的ssr服務(wù)端渲染示例詳解
- vue中vue-router的使用說明(包括在ssr中的使用)
- 關(guān)于VueSSR的一些理解和詳細(xì)配置
- Vue.js?狀態(tài)管理及?SSR解析
相關(guān)文章
Vue組件實(shí)現(xiàn)旋轉(zhuǎn)木馬動畫
這篇文章主要為大家詳細(xì)介紹了Vue組件實(shí)現(xiàn)旋轉(zhuǎn)木馬動畫效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-07-07
Vue組件庫ElementUI實(shí)現(xiàn)表格列表分頁效果
這篇文章主要為大家詳細(xì)介紹了Vue組件庫ElementUI實(shí)現(xiàn)表格列表分頁效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-06-06
vant之關(guān)于van-list的使用以及一些坑的解決方案
這篇文章主要介紹了vant之關(guān)于van-list的使用以及一些坑的解決方案,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-06-06
vue如何根據(jù)網(wǎng)站路由判斷頁面主題色詳解
這篇文章主要給大家介紹了關(guān)于vue如何根據(jù)網(wǎng)站路由判斷頁面主題色的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2018-11-11
el-menu如何根據(jù)多層樹形結(jié)構(gòu)遞歸遍歷展示菜單欄
這篇文章主要介紹了el-menu根據(jù)多層樹形結(jié)構(gòu)遞歸遍歷展示菜單欄,本文給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友參考下吧2024-07-07
Vue項(xiàng)目打包部署的實(shí)戰(zhàn)過程記錄
我們使用nginx部署Vue項(xiàng)目,實(shí)質(zhì)上就是將Vue項(xiàng)目打包后的內(nèi)容同步到nginx指向的文件夾,下面這篇文章主要給大家介紹了關(guān)于Vue項(xiàng)目打包部署的相關(guān)資料,需要的朋友可以參考下2021-09-09

