首先,咱們須要先明白什麼是 spa (single page application),以及基於 vue 的 spa 是如何工做的,這裏不展開,請參考:單頁應用、vue 實例javascript
基於同構代碼的 SSR 指的是同一份代碼(spa代碼),既能在客戶端運行,並渲染出頁面,也能夠在服務器端渲染爲 html 字符串,並響應給客戶端。css
它與傳統的服務器直出不一樣,傳統的服務器直出指的是路由系統只存在於服務器端,在服務器端,任何一個頁面都須要服務器響應內容。html
下圖是一個實際項目中,在弱網環境(3g)中接入 ssr
服務以前和以後的請求耗時對比:前端
工程背景:實際項目在微信環境內提供h5頁面,爲提升用戶體驗,咱們將其接入 ssr
服務,並代理微信 OAuth 的部分過程vue
測量範圍:新客戶從第一個http請求發出,到入口頁面的內容下載完畢爲止java
接入 ssr
服務前,此測量範圍內會經歷:node
接入 ssr
服務後,此測量範圍內會經歷:webpack
咱們能夠看到,接入 ssr
服務後,客戶理論上能更早得看到頁面了ios
根據上圖能夠看到,在接入 ssr
服務後,客戶能更早得看到頁面內容,客戶感知到的性能提升了。nginx
今天,咱們使用新版的 cli 工具(v3.x),搭建一個基於 vue 同構代碼的 ssr 工程項目。
咱們的目標:使用 @vue/cli v3.x 與 koa v2.x 建立一個 ssr 工程
咱們的步驟以下:
咱們須要的工具以下:
yarn global add @vue/cli
筆者安裝的 @vue/cli 的版本爲: v3.6.2
vue create ssr-demo
建立完畢以後, ssr-demo 的目錄結構以下:
./ssr-demo
├── README.md
├── babel.config.js
├── package.json
├── public
│ ├── favicon.ico
│ └── index.html
├── src
│ ├── App.vue
│ ├── assets
│ │ └── logo.png
│ ├── components
│ │ └── HelloWorld.vue
│ ├── main.js
│ ├── router.js
│ ├── store.js
│ └── views
│ ├── About.vue
│ └── Home.vue
└── yarn.lock
複製代碼
進入 srr-demo ,安裝 vue-server-renderer
yarn add vue-server-renderer
複製代碼
筆者建立的 ssr-demo 中,各主要工具庫的版本以下:
v2.6.10
v3.0.3
v3.0.1
v2.5.21
v2.6.10
執行 yarn serve ,在瀏覽器上看一下效果。
至此,spa 工程就建立完畢了,接下來咱們在此基礎上,將此 spa 工程逐步轉換爲 ssr 工程模式。
在 spa 工程中,每一個客戶端都會擁有一個新的 vue 實例。
所以,在 ssr 工程中,咱們也須要爲每一個客戶端請求分配一個新的 vue 實例(包括 router 和 store)。
咱們的步驟以下:
src/store.js
src/router.js
src/main.js
改造前,咱們看下 src/store.js
的內容:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
mutations: {
},
actions: {
}
})
複製代碼
src/store.js
的內部只返回了一個 store 實例。
若是這份代碼在服務器端運行,那麼這個 store 實例會在服務進程的整個生命週期中存在。
這會致使全部的客戶端請求都共享了一個 store 實例,這顯然不是咱們的目的,所以咱們須要將狀態存儲文件改形成工廠函數,代碼以下:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export function createStore () {
return new Vuex.Store({
state: {
},
mutations: {
},
actions: {
}
})
}
複製代碼
目錄結構一樣有變化:
# 改造前
./src
├── ...
├── store.js
├── ...
# 改造後
./src
├── ...
├── store
│ └── index.js
├── ...
複製代碼
改造前,咱們看下 src/router.js
的內容:
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
Vue.use(Router)
export default new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
}
]
})
複製代碼
相似 src/store.js
, 路由文件:src/router.js
的內部也只是返回了一個 router 實例。
若是這份代碼在服務器端運行,那麼這個 router 實例會在服務進程的整個生命週期中存在。
這會致使全部的客戶端請求都共享了一個 router 實例,這顯然不是咱們的目的,所以咱們須要將路由改形成工廠函數,代碼以下:
import Vue from 'vue'
import Router from 'vue-router'
import Home from '../views/Home.vue'
Vue.use(Router)
export function createRouter () {
return new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
}
]
})
}
複製代碼
目錄結構也有變化:
# 改造前
./src
├── ...
├── router.js
├── ...
# 改造後
./src
├── ...
├── router
│ └── index.js
├── ...
複製代碼
由於咱們須要在服務器端運行與客戶端相同的代碼,因此免不了須要讓服務器端也依賴 webpack 的構建過程。
借用官方文檔的示意圖:
咱們看到:
源代碼分別爲客戶端和服務器提供了獨立的入口文件:server entry 和 client entry
經過 webpack 的構建過程,構建完成後,也對應得輸出了兩份 bundle 文件,分別爲客戶端和服務器提供了:
等功能。
所以,咱們接下來先改造 src/main.js
,而後再建立 entry-client.js
和 entry-server.js
改造 src/main.js
前,咱們先來看看 src/main.js
的內容:
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
複製代碼
與 src/store.js
和 src/router.js
相似,src/main.js
一樣也是單例模式,所以咱們將它改造爲工廠函數:
import Vue from 'vue'
import App from './App'
import { createRouter } from './router'
import { createStore } from './store'
export function createApp () {
const router = createRouter()
const store = createStore()
const app = new Vue({
router,
store,
render: h => h(App)
})
return { app, router, store }
}
複製代碼
將 src/main.js
改造完畢後,咱們來分別建立 entry-client.js
和 entry-server.js
咱們先來看 entry-client.js
:
import { createApp } from './main.js'
const { app, router, store } = createApp()
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
app.$mount('#app')
})
複製代碼
在服務器端渲染路由組件樹,所產生的 context.state
將做爲脫水數據掛載到 window.__INITIAL_STATE__
在客戶端,只須要將 window.__INITIAL_STATE__
從新注入到 store 中便可(經過 store.replaceState
函數)
最後,咱們須要將 mount 的邏輯放到客戶端入口文件內。
建立完畢客戶端入口文件後,讓咱們來看服務端的入口文件 entry-server.js
:
import { createApp } from './main.js'
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
router.onReady(() => {
context.rendered = () => {
context.state = store.state
}
resolve(app)
}, reject)
})
}
複製代碼
上面的 context.rendered
函數會在應用完成渲染的時候調用
在服務器端,應用渲染完畢後,此時 store 可能已經從路由組件樹中填充進來一些數據。
當咱們將 state 掛載到 context ,並在使用 renderer 的時候傳遞了 template
選項,
那麼 state 會自動序列化並注入到 HTML 中,做爲 window.__INITIAL_STATE__
存在。
接下來,咱們來給 store 添加獲取數據的邏輯,並在首頁調用其邏輯,方便後面觀察服務器端渲染後的 window.__INITIAL_STATE__
改造後的目錄結構:
src/store
├── index.js
└── modules
└── book.js
複製代碼
src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import { Book } from './modules/book.js'
Vue.use(Vuex)
export function createStore () {
return new Vuex.Store({
modules: {
book: Book
},
state: {
},
mutations: {
},
actions: {
}
})
}
複製代碼
src/store/modules/book.js
import Vue from 'vue'
const getBookFromBackendApi = id => new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ name: '《地球往事》', price: 100 })
}, 300)
})
export const Book = {
namespaced: true,
state: {
items: {}
},
actions: {
fetchItem ({ commit }, id) {
return getBookFromBackendApi(id).then(item => {
commit('setItem', { id, item })
})
}
},
mutations: {
setItem (state, { id, item }) {
Vue.set(state.items, id, item)
}
}
}
複製代碼
改造前,咱們先看一下 src/views/Home.vue
的代碼
<template>
<div class="home"> <img alt="Vue logo" src="../assets/logo.png"> <HelloWorld msg="Welcome to Your Vue.js App"/> </div> </template> <script> // @ is an alias to /src import HelloWorld from '@/components/HelloWorld.vue' export default { name: 'home', components: { HelloWorld } } </script> 複製代碼
改造後的代碼以下:
<template>
<div class="home"> <img alt="Vue logo" src="../assets/logo.png"> <HelloWorld msg="Welcome to Your Vue.js App"/> <div v-if="book">{{ book.name }}</div> <div v-else>nothing</div> </div> </template> <script> // @ is an alias to /src import HelloWorld from '@/components/HelloWorld.vue' export default { name: 'home', computed: { book () { return this.$store.state.book.items[this.$route.params.id || 1] } }, // 此函數只會在服務器端調用,注意,只有 vue v2.6.0+ 才支持此函數 serverPrefetch () { return this.fetchBookItem() }, // 今生命週期函數只會在客戶端調用 // 客戶端須要判斷在 item 不存在的場景再去調用 fetchBookItem 方法獲取數據 mounted () { if (!this.item) { this.fetchBookItem() } }, methods: { fetchBookItem () { // 這裏要求 book 的 fetchItem 返回一個 Promise return this.$store.dispatch('book/fetchItem', this.$route.params.id || 1) } }, components: { HelloWorld } } </script> 複製代碼
至此,客戶端源代碼的改造告一段落,咱們接下來配置構建過程
基於 @vue/cli v3.x
建立的客戶端工程項目中再也不有 webpack.xxx.conf.js
這類文件了。
取而代之的是 vue.config.js
文件,它是一個可選的配置文件,默認在工程的根目錄下,由 @vue/cli-service
自動加載並解析。
咱們對於 webpack
的全部配置,都經過 vue.config.js
來實現。
關於 vue.config.js
內部配置的詳細信息,請參考官方文檔:cli.vuejs.org/zh/config/#…
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const nodeExternals = require('webpack-node-externals')
const merge = require('lodash.merge')
const TARGET_NODE = process.env.TARGET_NODE === 'node'
const DEV_MODE = process.env.NODE_ENV === 'development'
const config = {
publicPath: process.env.NODE_ENV === 'production'
// 在這裏定義產品環境和其它環境的 publicPath
// 關於 publicPath 請參考:
// https://webpack.docschina.org/configuration/output/#output-publicpath
? '/'
: '/',
chainWebpack: config => {
if (DEV_MODE) {
config.devServer.headers({ 'Access-Control-Allow-Origin': '*' })
}
config
.entry('app')
.clear()
.add('./src/entry-client.js')
.end()
// 爲了讓服務器端和客戶端可以共享同一份入口模板文件
// 須要讓入口模板文件支持動態模板語法(這裏選了 ejs)
.plugin('html')
.tap(args => {
return [{
template: './public/index.ejs',
minify: {
collapseWhitespace: true
},
templateParameters: {
title: 'spa',
mode: 'client'
}
}]
})
.end()
// webpack 的 copy 插件默認會將 public 文件夾中全部的文件拷貝到輸出目錄 dist 中
// 這裏咱們須要將 index.ejs 文件排除
.when(config.plugins.has('copy'), config => {
config.plugin('copy').tap(([[config]]) => [
[
{
...config,
ignore: [...config.ignore, 'index.ejs']
}
]
])
})
.end()
// 默認值: 當 webpack 配置中包含 target: 'node' 且 vue-template-compiler 版本號大於等於 2.4.0 時爲 true。
// 開啓 Vue 2.4 服務端渲染的編譯優化以後,渲染函數將會把返回的 vdom 樹的一部分編譯爲字符串,以提高服務端渲染的性能。
// 在一些狀況下,你可能想要明確的將其關掉,由於該渲染函數只能用於服務端渲染,而不能用於客戶端渲染或測試環境。
config.module
.rule('vue')
.use('vue-loader')
.tap(options => {
merge(options, {
optimizeSSR: false
})
})
config.plugins
// Delete plugins that are unnecessary/broken in SSR & add Vue SSR plugin
.delete('pwa')
.end()
.plugin('vue-ssr')
.use(TARGET_NODE
// 這是將服務器的整個輸出構建爲單個 JSON 文件的插件。
// 默認文件名爲 `vue-ssr-server-bundle.json`
? VueSSRServerPlugin
// 此插件在輸出目錄中生成 `vue-ssr-client-manifest.json`
: VueSSRClientPlugin)
.end()
if (!TARGET_NODE) return
config
.entry('app')
.clear()
.add('./src/entry-server.js')
.end()
.target('node')
.devtool('source-map')
.externals(nodeExternals({ whitelist: /\.css$/ }))
.output.filename('server-bundle.js')
.libraryTarget('commonjs2')
.end()
.optimization.splitChunks({})
.end()
.plugins.delete('named-chunks')
.delete('hmr')
.delete('workbox')
}
}
module.exports = config
複製代碼
至此,客戶端部分的改造告一段落,當前 ssr-demo
的目錄以下:
./ssr-demo
├── README.md
├── babel.config.js
├── package.json
├── public
│ ├── favicon.ico
│ └── index.ejs
├── src
│ ├── App.vue
│ ├── assets
│ │ └── logo.png
│ ├── components
│ │ └── HelloWorld.vue
│ ├── entry-client.js
│ ├── entry-server.js
│ ├── main.js
│ ├── router
│ │ └── index.js
│ ├── store
│ │ ├── index.js
│ │ └── modules
│ │ └── book.js
│ └── views
│ ├── About.vue
│ └── Home.vue
├── vue.config.js
└── yarn.lock
複製代碼
接下來,讓咱們來搭建 NodeJS 服務端部分。
在搭建服務端以前,咱們先安裝服務端須要的依賴:
yarn add koa koa-send memory-fs lodash.get axios ejs
複製代碼
安裝完畢後,對應的版本以下:
v2.7.0
v5.0.0
v0.4.1
v4.4.2
v0.18.0
v2.6.1
在 ssr-demo
跟目錄下建立文件夾 app
,而後建立文件 server.js
,內容以下:
const Koa = require('koa')
const app = new Koa()
const host = '127.0.0.1'
const port = process.env.PORT
const productionEnv = ['production', 'test']
const isProd = productionEnv.includes(process.env.NODE_ENV)
const fs = require('fs')
const PWD = process.env.PWD
// 產品環境:咱們在服務端進程啓動時,將客戶端入口文件讀取到內存中,當 發生異常 或 須要返回客戶端入口文件時響應給客戶端。
const getClientEntryFile = isProd => isProd ? fs.readFileSync(PWD + '/dist/index.html') : ''
const clientEntryFile = getClientEntryFile(isProd)
app.use(async (ctx, next) => {
if (ctx.method !== 'GET') return
try {
await next()
} catch (err) {
ctx.set('content-type', 'text/html')
if (err.code === 404) {
ctx.body = clientEntryFile
return
}
console.error(' [SERVER ERROR] ', err.toString())
ctx.body = clientEntryFile
}
})
app.use(require('./middlewares/prod.ssr.js'))
app.listen(port, host, () => {
console.log(`[${process.pid}]server started at ${host}:${port}`)
})
複製代碼
其中,須要注意的是:應該捕獲服務端拋出的任何異常,並將客戶端入口文件響應給客戶端。
在 app
內建立文件夾 middlewares
,並建立文件 prod.ssr.js
:
const path = require('path')
const fs = require('fs')
const ejs = require('ejs')
const get = require('lodash.get')
const resolve = file => path.resolve(__dirname, file)
const PWD = process.env.PWD
const enableStream = +process.env.ENABLESTREAM
const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require(PWD + '/dist/vue-ssr-server-bundle.json')
const clientManifest = require(PWD + '/dist/vue-ssr-client-manifest.json')
const tempStr = fs.readFileSync(resolve(PWD + '/public/index.ejs'), 'utf-8')
const template = ejs.render(tempStr, { title: '{{title}}', mode: 'server' })
const renderer = createBundleRenderer(bundle, {
runInNewContext: false,
template: template,
clientManifest: clientManifest,
basedir: PWD
})
const renderToString = context => new Promise((resolve, reject) => {
renderer.renderToString(context, (err, html) => err ? reject(err) : resolve(html))
})
const renderToStream = context => renderer.renderToStream(context)
const main = async (ctx, next) => {
ctx.set('content-type', 'text/html')
const context = {
title: get(ctx, 'currentRouter.meta.title', 'ssr mode'),
url: ctx.url
}
ctx.body = await renderToString(context)
}
module.exports = main
複製代碼
而後,咱們爲 package.json 配置新的打包命令和啓動 ssr
服務的命令:
...
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build && TARGET_NODE=node vue-cli-service build --no-clean",
"start": "NODE_ENV=production TARGET_NODE=node PORT=3000 node ./app/server.js"
},
...
複製代碼
這裏須要注意一下:
在 build
命令中,先執行客戶端的構建命令,而後再執行服務端的構建命令。
服務端的構建命令與客戶端的區別只有一個環境變量:TARGET_NODE
,當將此變量設置值爲 node
,則會按照服務端配置進行構建。
另外,在服務端構建命令中有一個參數:--no-clean
,這個參數表明不要清除 dist 文件夾,保留其中的文件。
之因此須要 --no-clean
這個參數,是由於服務端構建不該該影響到客戶端的構建文件。
這樣能保證客戶端即便脫離了服務端,也能經過 nginx
提供的靜態服務向用戶提供完整的功能(也就是 spa 模式)。
至此,生產環境已經搭建完畢。接下來,讓咱們來搭建開發環境的服務端。
開發環境的服務功能其實是生產環境的超集。
除了生產環境提供的服務以外,開發環境還須要提供:
生產環境中的靜態資源由於都會放置到 CDN 上,所以並不須要 NodeJS 服務來實現靜態資源服務器,通常都由 nginx 靜態服務提供 CDN 的回源支持。
但生產環境若是依賴獨立的靜態服務器,可能致使環境搭建成本太高,所以咱們建立一個開發環境的靜態資源服務中間件來實現此功能。
咱們的 spa 模式在開發環境經過命令 serve
啓動後,就是一個自帶 hot reload 功能的服務。
所以,服務端在開發環境中提供的靜態資源服務,能夠經過將靜態資源請求路由到 spa 服務,來提供靜態服務功能。
須要注意的是:開發環境中,服務端在啓動以前,須要先啓動好 spa 服務。
稍後咱們會在 package.js
中建立 dev
命令來方便啓動開發環境的 spa 與 ssr 服務。
在 ./ssr-demo/app/middlewares/
中建立文件 dev.static.js
,內容以下:
const path = require('path')
const get = require('lodash.get')
const send = require('koa-send')
const axios = require('axios')
const PWD = process.env.PWD
const clientPort = process.env.CLIENT_PORT || 8080
const devHost = `http://localhost:${clientPort}`
const resolve = file => path.resolve(__dirname, file)
const staticSuffixList = ['js', 'css', 'jpg', 'jpeg', 'png', 'gif', 'map', 'json']
const main = async (ctx, next) => {
const url = ctx.path
if (url.includes('favicon.ico')) {
return send(ctx, url, { root: resolve(PWD + '/public') })
}
// In the development environment, you need to support every static file without CDN
if (staticSuffixList.includes(url.split('.').pop())) {
return ctx.redirect(devHost + url)
}
const clientEntryFile = await axios.get(devHost + '/index.html')
ctx.set('content-type', 'text/html')
ctx.set('x-powered-by', 'koa/development')
ctx.body = clientEntryFile.data
}
module.exports = main
複製代碼
而後將中間件 dev.static.js
註冊到服務端入口文件 app/server.js
中:
...
if (process.env.NODE_ENV === 'production') {
app.use(require('./middlewares/prod.ssr.js'))
}else{
app.use(require('./middlewares/dev.static.js'))
// TODO:在這裏引入開發環境請求處理中間件
}
app.listen(port, host, () => {
console.log(`[${process.pid}]server started at ${host}:${port}`)
})
複製代碼
由於咱們須要在開發環境同時啓動 spa 服務和 ssr 服務,所以須要一個工具輔助咱們同時執行兩個命令。
咱們選擇 concurrently
,關於此工具的具體細節請參照:github.com/kimmobrunfe…
安裝 concurrently
:
yarn add concurrently -D
複製代碼
而後改造 package.json
中的 serve
命令:
...
"scripts": {
"serve": "vue-cli-service serve",
"ssr:serve": "NODE_ENV=development PORT=3000 CLIENT_PORT=8080 node ./app/server.js",
"dev": "concurrently 'npm run serve' 'npm run ssr:serve'",
...
複製代碼
其中:
serve
開發環境啓動 spa 服務ssr:serve
開發環境啓動 ssr 服務dev
開發環境同時啓動 spa 服務於 ssr 服務啓動 ssr 服務的命令中:
NODE_ENV
是環境變量PORT
是 ssr 服務監聽的端口CLIENT_PORT
是 spa 服務監聽的端口由於靜態資源須要從 spa 服務中獲取,因此 ssr 服務須要知道 spa 服務的 host 、端口 和 靜態資源路徑
至此,靜態服務器搭建完畢,接下來咱們來搭建開發環境的請求處理中間件。(此中間件包含 hot reload 功能)
在 ./ssr-demo/app/middlewares/
中建立文件 dev.ssr.js
,內容以下:
const path = require('path')
const fs = require('fs')
const ejs = require('ejs')
const PWD = process.env.PWD
const webpack = require('webpack')
const axios = require('axios')
// memory-fs is a simple in-memory filesystem.
// Holds data in a javascript object
// See: https://github.com/webpack/memory-fs
const MemoryFS = require('memory-fs')
// Use parsed configuration as a file of webpack config
// See: https://cli.vuejs.org/zh/guide/webpack.html#%E5%AE%A1%E6%9F%A5%E9%A1%B9%E7%9B%AE%E7%9A%84-webpack-%E9%85%8D%E7%BD%AE
const webpackConfig = require(PWD + '/node_modules/@vue/cli-service/webpack.config')
// create a compiler of webpack config
const serverCompiler = webpack(webpackConfig)
// create the memory instance
const mfs = new MemoryFS()
// set the compiler output to memory
// See: https://webpack.docschina.org/api/node/#%E8%87%AA%E5%AE%9A%E4%B9%89%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F-custom-file-systems-
serverCompiler.outputFileSystem = mfs
let serverBundle
// Monitor webpack changes because server bundles need to be dynamically updated
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
stats.errors.forEach(error => console.error('ERROR:', error))
stats.warnings.forEach(warn => console.warn('WARN:', warn))
const bundlePath = path.join(webpackConfig.output.path, 'vue-ssr-server-bundle.json')
serverBundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8'))
console.log('vue-ssr-server-bundle.json updated')
})
const resolve = file => path.resolve(__dirname, file)
const { createBundleRenderer } = require('vue-server-renderer')
const renderToString = (renderer, context) => new Promise((resolve, reject) => {
renderer.renderToString(context, (err, html) => err ? reject(err) : resolve(html))
})
const tempStr = fs.readFileSync(resolve(PWD + '/public/index.ejs'), 'utf-8')
const template = ejs.render(tempStr, { title: '{{title}}', mode: 'server' })
const clientHost = process.env.CLIENT_PORT || 'localhost'
const clientPort = process.env.CLIENT_PORT || 8080
const clientPublicPath = process.env.CLIENT_PUBLIC_PATH || '/'
const main = async (ctx, next) => {
if (!serverBundle) {
ctx.body = 'Wait Compiling...'
return
}
ctx.set('content-type', 'text/html')
ctx.set('x-powered-by', 'koa/development')
const clientManifest = await axios.get(`http://${clientHost}:${clientPort}${clientPublicPath}vue-ssr-client-manifest.json`)
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
template: template,
clientManifest: clientManifest.data,
basedir: process.env.PWD
})
const context = {
title: 'ssr mode',
url: ctx.url
}
const html = await renderToString(renderer, context)
ctx.body = html
}
module.exports = main
複製代碼
在開發環境,咱們經過 npm run dev
命令,啓動一個 webpack-dev-server 和一個 ssr 服務
經過官方文檔可知,咱們能夠經過一個文件訪問解析好的 webpack 配置,這個文件路徑爲:
node_modules/@vue/cli-service/webpack.config.js
使用 webpack 編譯此文件,並將其輸出接入到內存文件系統(memory-fs
)中
監聽 webpack,當 webpack 從新構建時,咱們在監聽器內部獲取最新的 server bundle 文件
並從 webpack-dev-server 獲取 client bundle 文件
在每次處理 ssr 請求的中間件邏輯中,使用最新的 server bundle 文件和 client bundle 文件進行渲染
最後,將中間件 dev.ssr.js
註冊到服務端入口文件 app/server.js
中
...
if (process.env.NODE_ENV === 'production') {
app.use(require('./middlewares/prod.ssr.js'))
}else{
app.use(require('./middlewares/dev.static.js'))
app.use(require('./middlewares/dev.ssr.js'))
}
app.listen(port, host, () => {
console.log(`[${process.pid}]server started at ${host}:${port}`)
})
複製代碼
至此,咱們基於 @vue/cli v3
完成了一個簡易的 ssr 工程項目,目錄結構以下:
./ssr-demo
├── README.md
├── app
│ ├── middlewares
│ │ ├── dev.ssr.js
│ │ ├── dev.static.js
│ │ └── prod.ssr.js
│ └── server.js
├── babel.config.js
├── package.json
├── public
│ └── index.ejs
├── src
│ ├── App.vue
│ ├── assets
│ │ └── logo.png
│ ├── components
│ │ └── HelloWorld.vue
│ ├── entry-client.js
│ ├── entry-server.js
│ ├── main.js
│ ├── router
│ │ └── index.js
│ ├── store
│ │ ├── index.js
│ │ └── modules
│ │ └── book.js
│ └── views
│ ├── About.vue
│ └── Home.vue
├── vue.config.js
└── yarn.lock
複製代碼
以上,是咱們基於 @vue/cli v3
構建 ssr
工程的所有過程。
雖然咱們已經有了一個基礎的 ssr
工程,但這個工程項目還有如下缺失的地方:
ssr
服務出現異常,整個服務就會受到影響,咱們須要考慮在 ssr
服務出現問題時,如何將其降級爲 spa
服務ssr
服務內部接收到的請求信息、出現的異常信息、關鍵業務的信息,這些都須要記錄日誌,方便維護與追蹤定位錯誤。ssr
服務對於每一次的請求,都會耗費服務器資源去渲染,這對於那些一段時間內容不會變化的頁面來講,浪費了資源。ssr
服務是常駐內存的,咱們須要儘量實時得知道它當前的健康情況,力求在出現問題以前,獲得通知,並快速作出調整。輕盈
的頁面,以便讓弱網環境下的用戶也能正常使用服務。所以,將此工程應用到產品項目中以前,還須要對 ssr
工程再作一些改進,將來,咱們會逐步爲 ssr
服務提供如下配套設施:
下一篇文章,咱們講解如何研發一個基於 @vue/cli v3
的插件,並將 ssr
工程項目中服務器端的功能整合進插件中。
水滴前端團隊招募夥伴,歡迎投遞簡歷到郵箱:fed@shuidihuzhu.com