首先,咱们须要先明白什么是 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