# 目录结构
服务端渲染会在用户首次访问页面的时候返回一整个页面数据(不会像 spa 一样只返回包含待执行 js 文件的 html),当浏览器接收到页面代码后,会进行客户端激活。所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。这样,就会存在两端:客户端和服务端。
项目目录结构划分如下:
└───build
| └───setup-dev-server.js // 开发环境的 webpack 构建逻辑
| └───webpack.base.config.js // 通用 webpack 配置
| └───webpack.client.config.js // 客户端 webpack 配置
| └───webpack.server.config.js // 服务端 webpack 配置
|───src
| └───router
| └───index.js
| └───routes.js // 路由表
| └───store
| └───index.js
| └───mutations.js
| └───actions.js
| └───getters.js
| └───views // 路由组件目录
| └───App.vue
| └───app.js // 客户端和服务端共用逻辑
| └───client-entry.js // 客户端入口
| └───server-entry.js // 服务端入口
└───package.json
└───server.js // node 服务启动逻辑
# vue-server-render (opens new window)
vue-server-render 的作用是建立客户端和服务端的联系。
在客户端 webpack 打包通过使用 vue-server-render/client-plugin 插件生成客户端的资源清单 vue-ssr-client-manifest.json;在服务端 webpack 打包通过使用 vue-server-render/server-plugin 插件,将服务器的整个输出构建为单个 JSON 文件 vue-ssr-server-bundle.json;然后在 node server 通过 createBundleRenderer 方法使用客户端清单 vue-ssr-client-manifest.json 和服务端 bundle vue-ssr-server-bundle.json 构建 renderer,renderer 有了服务器和客户端的构建信息,因此它可以自动推断和注入资源预加载 / 数据预取指令(preload / prefetch directive),以及 css 链接 / script 标签到所渲染的 HTML。
# 共用逻辑
共用逻辑指的是在客户端和服务端公用的代码逻辑,有:App.vue、通用逻辑 app.js、webpack 基础配置 webpack.base.config.js、路由配置、store 配置。
# App.vue
提供根 Vue 实例挂载点及路由渲染组件。
<template>
<div id="app">
<router-view />
</div>
</template>
# app.js
如果我们像写 SPA 代码那样全局共享一个根 vue 单例,由于服务端渲染是一个 node server 服务,每个请求会将对该单例进行取值存值,这样就会存在不同请求数据共享的问题。所以需要通过工厂模式为每个请求创建新的 Vue 实例,路由和 store 同理。
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router/index.js'
export function createApp() {
const router = createRouter()
const app = new Vue({
router,
render: (h) => h(App)
})
return { app, router }
}
# 路由
router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import routes from './routes.js'
Vue.use(Router)
export function createRouter() {
return new Router({
mode: 'history',
routes
})
}
router/routes.js
export default [
{
path: '/',
component: () => import(/* webpackChunkName: "Home" */ '@/views/Home.vue')
}
]
# store
store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import actions from './actions'
import mutations from './mutations'
import getters from './getters'
Vue.use(Vuex)
export function createStore() {
return new Vuex.Store({
state: {},
actions,
mutations,
getters
})
}
# webpack.base.config.js
常规的 webpack 配置。
const { VueLoaderPlugin } = require('vue-loader')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const path = require('path')
const isProd = process.env.NODE_ENV === 'production'
module.exports = {
devtool: isProd ? false : '#cheap-module-source-map',
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/',
chunkFilename: 'common/[name].[chunkhash:8].js',
filename: 'common/[name].[chunkhash].js'
},
resolve: {
extensions: ['.js', '.json', '.vue', '.scss', '.css'],
alias: {
'@': path.resolve(__dirname, '..', 'src')
}
},
optimization: {
minimize: isProd,
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
compress: {
warnings: false,
drop_console: isProd, // Pass true to discard calls to console.* functions
drop_debugger: isProd // Pass true to remove debugger; statements
},
output: {
comments: false
}
},
extractComments: false
})
]
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-env'],
plugins: [
'@babel/plugin-transform-runtime',
'@babel/plugin-transform-modules-commonjs'
]
}
},
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.(c|sa|sc)ss$/,
use: [
!isProd ? 'vue-style-loader' : MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: { minimize: isProd }
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {}
}
},
{
loader: 'sass-loader'
}
]
}
]
},
plugins: isProd
? [
new MiniCssExtractPlugin({
filename: 'common/[name].[contenthash:8].css',
ignoreOrder: true
}),
new VueLoaderPlugin()
]
: [new VueLoaderPlugin()]
}
# 数据存储
由于存在客户端和服务端,需要保持两端数据同步,不然在激活客户端的时候,会导致混合失败。数据存储使用的是 vuex store,使用方式是通过在 vue 中注入 asyncData 方法。
在服务端, asyncData 方法会在路由器完成初始化导航后执行,通过 asyncData 获取数据后会获取存储到 store 中,然后将数据注入到上下文 context.state 中,context.state 会在客户端自动序列化为window.__INITIAL_STATE__
;在客户端,会获取window.__INITIAL_STATE__
并同步到客户端的 store 中。这样就实现了两端数据同步。
# 服务端入口 server-entry.js
由于是服务端,没有像客户端一样有浏览器的 url,所以需要手动触发当前路由的渲染。
当路由器完成初始化导航后,获取对应路由组件的 asyncData 方法并执行 asyncData 方法,同时将数据注入到上下文 context.state 中,context.state 会在客户端自动序列化为window.__INITIAL_STATE__
,执行结束才渲染。
import { createApp } from './app'
const isProd = process.env.NODE_EVN === 'production'
export default (context) => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url) // 手动添加当前的访问 url,该 context 为 vue-server-render 注入的 context
router.onReady(() => {
const matchedComponents = router.getMatchedComponents() // 匹配对应的路由组件
if (!matchedComponents.length) {
return reject({ code: 404 })
}
Promise.all(
// 执行 asyncData 逻辑
matchedComponents.map(
({ asyncData }) =>
asyncData &&
asyncData({
store,
route: router.currentRoute
})
)
)
.then(() => {
context.state = store.state // context.state 在 client 自动序列化为 window.__INITIAL_STATE__
resolve(app)
})
.catch(reject)
}, reject)
})
}
# 客户端入口 client-entry.js
在客户端,获取window.__INITIAL_STATE__
并同步到客户端的 store 中,实现两端数据的同步。
当路由跳转时,需要执行对应的 asyncData 方法,所以需要在全局路由守卫 beforeResolve(导航即将解析之前执行)获取对应路由组件的 asyncData 方法并执行。
当路由更新时,需要对当前路由组件的数据进行更新,所以需要通过 mixin 方法注入到每个路由组件中,通过对应的 beforeRouteUpdate 路由守卫执行对应的 asyncData 方法。
import Vue from 'vue'
import { createApp } from './app'
Vue.mixin({
beforeRouteUpdate(to, from, next) {
const { asyncData } = this.$options
if (asyncData) {
asyncData({
store: this.$store,
route: to
})
.then(next)
.catch(next)
} else {
next()
}
}
})
const { app, router, store } = createApp()
if (window.__INITIAL_STATE__) {
// 同步服务端数据到客户端的 store 中
store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
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)
})
const asyncDataHooks = activated.map((c) => c.asyncData).filter((_) => _)
if (!asyncDataHooks.length) {
return next()
}
Promise.all(
asyncDataHooks.map((hook) =>
hook({
store,
route: to
})
)
)
.then(() => {
next()
})
.catch(next)
})
app.$mount('#app')
})
# 客户端 webpack 配置
webpack.client.config.js 主要配置客户端的 entry,和使用 vue-server-render/client-plugin 插件生成客户端的资源清单。
const webpack = require('webpack')
const { merge } = require('webpack-merge')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const baseConfig = require('./webpack.base.config.js')
module.exports = merge(baseConfig, {
mode: 'production',
entry: {
app: require('path').resolve(__dirname, '../src/client-entry.js')
},
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(
process.env.NODE_ENV || 'development'
),
'process.env.VUE_ENV': '"client"'
}),
// 生成客户端资源清单 `vue-ssr-client-manifest.json`。
new VueSSRClientPlugin()
]
})
# 服务端 webpack 配置
webpack.server.config.js 主要配置服务端入口,还有使用 vue-server-render/server-plugin 生成服务端输出 bundle JSON 文件。
const webpack = require('webpack')
const { merge } = require('webpack-merge')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const baseConfig = require('./webpack.base.config.js')
module.exports = merge(baseConfig, {
mode: 'production',
entry: {
app: require('path').resolve(__dirname, '../src/client-entry.js')
},
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(
process.env.NODE_ENV || 'development'
),
'process.env.VUE_ENV': '"client"'
}),
// 服务端输出 bundle JSON 文件 `vue-ssr-client-manifest.json`。
new VueSSRServerPlugin()
]
})
# 生产环境
在 package.json 配置 script 命令。
{
"clean": "rimraf ./dist",
"prebuild": "npm run clean",
"build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
"build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
"build": "npm run build:client & npm run build:server",
}
执行 npm run build
,生成打包目录:
在 server.js 中,添加逻辑:
- 获取客户端 webpack 资源清单和服务端打包输出的 bundle JSON 文件,通过 vue-server-render 输出 html。
- 启动静态资源服务器。
- 启动 node server 服务器。
const fs = require('fs')
const { resolve } = require('path')
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')
const app = express()
const serve = (path, cache) =>
express.static(resolve(path), {
maxAge: 0
})
const isProd = process.env.NODE_ENV === 'production'
const resolvePath = (str) => resolve(__dirname, str)
const templatePath = resolvePath('./src/template.index.html')
const createHtml = (renderer, context) =>
new Promise((resolve, reject) => {
renderer.renderToString(context, (err, html) => {
if (err) {
return reject(err)
}
resolve(html)
})
})
let renderer = null
const serverBundle = require('./dist/vue-ssr-server-bundle.json') // 获取服务端打包输出 bundle JSON 文件
const clientManifest = require('./dist/vue-ssr-client-manifest.json') // 获取客户端打包静态资源清单
const template = fs.readFileSync(templatePath, {
encoding: 'utf-8'
})
renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
clientManifest,
template
})
app
.use('/dist', serve('./dist', true)) // 静态资源服务器
.get('*', async (req, res) => {
try {
if (req.path === '/favicon.ico') {
return
}
// context 全局上下文数据,也可用于替换 template html 模版中的 {{xxx}}
const context = {
title: 'My tiny vue ssr project.',
url: req.url
}
const html = await createHtml(renderer, context)
res.send(html)
} catch (error) {
console.error('>> res :', error)
}
})
.listen(8080, () => {
console.log(`服务器地址: localhost:8080`)
})
# 开发环境
在 package.json 配置 script 命令。
{
// ...
"dev": "cross-env NODE_ENV=dev node server.js"
}
由于是 node server 服务,所以需要调用 webpack 的 node API 对两端进行打包。
开发环境需要实现热更新,使用 webpack-dev-middleware、webpack-hot-middleware、webpack 内置插件 HotModuleReplacementPlugin 实现。
- webpack-dev-middleware (opens new window):与webpack (opens new window)包一起使用的 express 样式的开发中间件,主要提供从 webpack 发出的文件。
- webpack-hot-middleware (opens new window):配合 webpack 内置插件 HotModuleReplacementPlugin 可实现热更新。
# webpack 配置
const webpack = require('webpack')
const MFS = require('memory-fs')
const fs = require('fs')
const chokidar = require('chokidar')
const readFile = (fs, file, outputPath) => {
try {
return fs.readFileSync(require('path').join(outputPath, file), 'utf-8')
} catch (err) {
console.error(err)
}
}
function createClientWebpackConfig() {
const clientWebpackConfig = require('./webpack.client.config.js')
clientWebpackConfig.mode = 'development' // 修改 mode 为 development
clientWebpackConfig.output.filename = '[name].js'
// 设置 webpack-hot-middleware
clientWebpackConfig.entry.app = [
'webpack-hot-middleware/client', // webpack-hot-middleware/client 配置
clientWebpackConfig.entry.app
]
// 使用 webpack 内置插件 HotModuleReplacementPlugin
clientWebpackConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
if (!('optimization' in clientWebpackConfig)) {
clientWebpackConfig.optimization = {}
}
clientWebpackConfig.optimization.noEmitOnErrors = true
return clientWebpackConfig
}
function createServerWebpackConfig() {
const serverWebpackConfig = require('./webpack.server.config.js')
serverWebpackConfig.mode = 'development' // 修改 mode 为 development
return serverWebpackConfig
}
module.exports = async function setupDevServer(app, templatePath, cb) {
try {
let bundle
let template
let clientManifest
let ready
const readyPromise = new Promise((r) => {
ready = r
})
const update = () => {
if (bundle && clientManifest) {
ready()
// 执行外部回调,这里执行逻辑是通过 vue-server-render 生成 renderer
cb(bundle, {
template,
clientManifest
})
}
}
// 监听 html template 模版文件的变更
template = fs.readFileSync(templatePath, 'utf-8')
chokidar.watch(templatePath).on('change', () => {
template = fs.readFileSync(templatePath, 'utf-8')
console.log('index.html template updated.')
update()
})
// 初始化 client webpack
const clientWebpackConfig = createClientWebpackConfig()
const outputPath = clientWebpackConfig.output.path
const clientCompiler = webpack(clientWebpackConfig)
const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
publicPath: clientWebpackConfig.output.publicPath
})
app
.use(devMiddleware) // 使用 webpack-dev-middleware 中间件
.use(
// 使用 webpack-hot-middleware 中间件
require('webpack-hot-middleware')(clientCompiler, {
heartbeat: 5000
})
)
clientCompiler.hooks.done.tap('done', (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.context.outputFileSystem,
'vue-ssr-client-manifest.json',
outputPath
)
)
update()
})
// 初始化 server webpack
const serverWebpackConfig = createServerWebpackConfig()
const serverCompiler = webpack(serverWebpackConfig)
const mfs = new MFS() // 这里 server 端的文件读写方式修改为内存,提高读写效率
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
if (stats.errors.length) return
bundle = JSON.parse(
readFile(mfs, 'vue-ssr-server-bundle.json', outputPath)
)
update()
})
return readyPromise
} catch (error) {
console.error('>> setupDevServer error :', error)
}
}
# 修改 server.js
const fs = require('fs')
const { resolve } = require('path')
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')
const app = express()
const serve = (path, cache) =>
express.static(resolve(path), {
maxAge: 0
})
const isProd = process.env.NODE_ENV === 'production'
const resolvePath = (str) => resolve(__dirname, str)
const templatePath = resolvePath('./src/template.index.html')
const createHtml = (renderer, context) =>
new Promise((resolve, reject) => {
renderer.renderToString(context, (err, html) => {
if (err) {
return reject(err)
}
resolve(html)
})
})
let renderer = null
if (isProd) {
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const template = fs.readFileSync(templatePath, {
encoding: 'utf-8'
})
renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
clientManifest,
template
})
} else {
require('./build/setup-dev-server.js')(
app,
templatePath,
(bundle, options) => {
renderer = createBundleRenderer(bundle, options)
}
)
}
app
.use('/dist', serve('./dist', true))
.get('*', async (req, res) => {
try {
if (req.path === '/favicon.ico') {
return
}
const context = {
title: 'My mini vue ssr project.',
url: req.url
}
const html = await createHtml(renderer, context)
res.send(html)
} catch (error) {
console.error('>> res :', error)
}
})
.listen(8080, () => {
console.log(`服务器地址: localhost:8080`)
})
参考内容:
- https://ssr.vuejs.org/zh/
- https://github.com/vuejs/vue-hackernews-2.0/