带你一步步搭建 Vue2 SSR 的工程
开篇
先看文档: 了解什么是 SSR;为什么要使用 SSR
Vue.js 服务器端渲染指南 | Vue SSR 指南 (vuejs.org)
使用 vue-server-renderer 去渲染 vue 组件
- 了解 vue-server-renderer 的使用;
// 第 1 步:创建一个 Vue 实例
const Vue = require('vue')
const app = new Vue({
template: `<div>Hello World</div>`
})
// 第 2 步:创建一个 renderer
const renderer = require('vue-server-renderer').createRenderer()
// 第 3 步:将 Vue 实例渲染为 HTML
renderer.renderToString(app, (err, html) => {
if (err) throw err
console.log(html)
// => <div data-server-rendered="true">Hello World</div>
})
// 在 2.5.0+,如果没有传入回调函数,则会返回 Promise:
renderer.renderToString(app).then(html => {
console.log(html)
}).catch(err => {
console.error(err)
})
- 与服务器集成
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()
server.get('*', (req, res) => {
const app = new Vue({
data: {
url: req.url
},
template: `<div>访问的 URL 是: {{ url }}</div>`
})
renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.end(`
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>${html}</body>
</html>
`)
})
})
server.listen(8080)
- 引入一个模版
index.template.html
<!DOCTYPE html>
<html>
<head>
<!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
<title>{{ title }}</title>
<!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
{{{ meta }}}
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
完整代码如下:
const Vue = require('vue');
const server = require('express')();
const template = require('fs').readFileSync('./index.template.html', 'utf-8');
const renderer = require('vue-server-renderer').createRenderer({
template,
});
const context = {
title: 'vue ssr',
meta: `
<meta name="keyword" content="vue,ssr">
<meta name="description" content="vue srr demo">
`,
};
server.get('*', (req, res) => {
const app = new Vue({
data: {
url: req.url
},
template: `<div>访问的 URL 是: {{ url }}</div>`,
});
renderer
.renderToString(app, context, (err, html) => {
console.log(html);
if (err) {
res.status(500).end('Internal Server Error')
return;
}
res.end(html);
});
})
server.listen(8080);
编写通用代码
我们搭建一个本地项目,首先我们需要一个入口文件,app.js。
import Vue from 'vue'
import App from './App.vue'
//这里需要返回一个函数,避免单例状态,我们需要知道 node 服务是一个长期运行的进程,当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。
export function createApp() {
const app = new Vue({
store,
router,
render: h => h(App)
})
return { app }
}
创建 client-entry.js 用于服务端渲染后客户端激活。
import { createApp } from './app.js';
const { app } = createApp();
app.$mount('#app');
它需要做的事情很简单,直接挂载到根标签即可。
创建 server-entry.js 用于服务端渲染
import { createApp } from './app.js';
export default function (context) {
//这个方法服务端渲染会调用 renderer.renderToString() 时调用
return new Promise((reslove, reject
) => {
const { app } = createApp();
reslove(app)
})
};
和客户端代码不同的是,这里需要返回一个工厂函数,保证用户每次访问服务端都是一个全新的 vue。
创建 index.template.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<!--vue-ssr-outlet-->
</div>
</body>
</html>
服务端会把 server-entry.js 里的 vue 对象通过 vue-server-renderer 解析成字符串放在这里 <!--vue-ssr-outlet-->
。
创建 build 文件夹,使用 webpack 打包客户端代码 vue 和 服务端 vue
创建 webpack.base.config.js ( 通用配置 )
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const path = require('path');
module.exports = {
resolve: {
extensions: ['.js', '.vue', '.json'],// 自动补全后缀
alias: {
'@': path.resolve(__dirname, '../src'),// 路径别名
}
},
mode: 'development',
output: {//打包的出口
path: path.resolve(__dirname, '../dist'),
filename: '[name].js'
},
module: {
rules: [
{// 配置 vue-loader 才能正常解析 .vue 文件
test: /.vue$/,
loader: 'vue-loader'
},
{// vue-style-loader 支持服务端渲染啊 style 样式
test: /.css$/,
use: ['vue-style-loader', 'css-loader']
},
{// 配置 babel es6 转 es5
test: /.js$/,
loader: 'babel-loader',
exclude: /node_modules/, // 排除第三方的包
options: {
presets: [
[
'@babel/preset-env',
{
modules: false, // 不转换成 commonjs 模块
useBuiltIns: 'usage', // 按需 polyfill
corejs: 3,
}
]
]
}
}
]
},
plugins: [
new VueLoaderPlugin(), // 这个和 vue-loader 一起的用于解析 .vue 文件
]
};
创建 webpack.client.config.js ( 打包客户端代码 )
const { merge } = require('webpack-merge');
const base = require('./webpack.base.config.js');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = merge(base, {
entry: {
client: path.join(__dirname, '../src/client-entry.js'), // 指定客户端代码入口
},
plugins: [
new HtmlWebpackPlugin({ //使用 index.template.html 模板
template: path.join(__dirname, '../src/index.template.html'),
filename: 'index.ssr.html',
minify: false,
})
]
})
创建 webpack.server.config.js( 打包服务端代码给 node 使用 )
const { merge } = require('webpack-merge');
const base = require('./webpack.base.config.js');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = merge(base, {
target: 'node', // 指定是node环境
entry: { // 指定客户端代码入口
server: path.join(__dirname, '../src/server-entry.js')
},
output: {
libraryTarget: 'commonjs2' // 必须按照 commonjs规范打包才能被服务器调用。
},
})
创建 server.js 服务端渲染
当然,要返回的html字符串可以是由vue模板生成的,这就需要用到vue-server-renderer,它会基于Vue实例生成html字符串,是Vue SSR的核心。server.js中使用
const fs = require('fs');
const path = require('path');
const express = require('express');
const server = express();
//设置静态目录
server.use(express.static('dist'));
//获取到打包后的服务端vue代码
const bundle = fs.readFileSync(path.resolve(__dirname, './dist/server.js'), 'utf-8');
//拿着vue代码和模板生成字符串
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, './dist/index.ssr.html'), 'utf-8'),
});
server.get('*', (req, res) => {
if (req.url !== "/favicon.ico") {
// 调用 server-entry.js 触发工厂函数,服务端渲染数据
renderer.renderToString().then((html) => {
res.end(html)
})
}
});
server.listen(8011, () => {
console.log('http://127.0.0.1:8011');
});
此时一个简单的模块化SSR已经好了,但是目前还不支持vue-router 和 状态管理 vuex ,现在对上面的代码改造一下。
在 src 下创建 router 和 store。
// router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import baz from '@/components/baz'
import foo from '@/components/foo'
Vue.use(VueRouter)
// 返回一个函数,避免单例状态
export default function createRouter() {
return new VueRouter({
mode: "history",
routes: [
{
path: "/",
name: 'baz',
component: baz
},
{
path: "/foo",
name: 'foo',
component: foo
}
]
})
}
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
// 返回一个函数,避免单例状态
export default function createStore() {
return new Vuex.Store({
state: {
article: '数据'
},
actions: {
GET_ARTICLE({ commit }) {
return new Promise((r) => {
setTimeout(() => {
commit('SET_ARTICLE', 'vuex数据')
r()
}, 1000);
})
}
},
mutations: {
SET_ARTICLE(state, data) {
state.article = data
}
}
})
}
router 和 vuex 需要给服务端使用,返回一个函数,避免单例状态。
对 app.js 改造
import Vue from 'vue'
import App from './App.vue'
import createStore from './store/index.js';
import createRouter from './router/index.js';
//每次访问都创建一个新的vue 主要用于服务端
export function createApp() {
//创建 store
const store = createStore();
//创建 router
const router = createRouter();
const app = new Vue({
store,
router,
render: h => h(App)
})
return { app, store, router }
}
对 server-entry 改造
import { createApp } from './app.js';
// 服务端渲染会调用此方法
export default function (context) {
return new Promise((reslove, reject) => {
const { app, store, router } = createApp();
//context.url => renderer.renderToString({url: req.url})
//服务端跳转页面
router.push(context.url);
//服务端跳转页面完成
router.onReady(() => {
//获取页面级组件
const matchedComponents = router.getMatchedComponents();
//没有匹配成功返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
//对所有匹配的路由组件调用 `asyncData()`
//拿到页面组件上的asyncData调用
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
})
}
})).then(() => {
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
reslove(app)
})
})
})
};
对 client-entry 改造
import { createApp } from './app.js';
const { app, store } = createApp();
//客户端激活时替换state状态
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__);
};
app.$mount('#app');
对 server.js 改造
const fs = require('fs');
const path = require('path');
const express = require('express');
const server = express();
server.use(express.static('dist'));
//获取到服务端vue代码
const bundle = fs.readFileSync(path.resolve(__dirname, './dist/server.js'), 'utf-8');
//拿着js代码和模板生成字符串
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, './dist/index.ssr.html'), 'utf-8'), // 服务端渲染数据
});
server.get('*', (req, res) => {
if (req.url !== "/favicon.ico") {
renderer.renderToString({// 这个 url 会传给 server-entry.js 里的 context
url: req.url
}).then((html) => {
res.end(html)
}).catch((error) => {
if (error.code == 404) {
res.writeHead(404, {
"content-type": "text/html;charset=utf8"
})
res.end('找不到页面')
}
})
}
});
server.listen(8011, () => {
console.log('http://127.0.0.1:8011');
});
vue-ssr 本质上就是通过 webpack 打包 client-entry.js 和 server-entry.js 代码,首次进入页面通过 vue-server-renderer 把 server-entry.js 的 vue 生成字符串返回给客户端渲染,后续通过 client-entry.js 进行客户端激活。
所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。
-
现在项目可以 build 出来文件,进行 server render;
-
进行开发的话,肯定是不适合的,并不会热更新,我们来搭建本地开发服务;
搭建本地开发服务
- 这块直接照抄
vue-hackernews-2.0
的思路。
已知,SSR
的渲染关键在于renderer.renderToString
,renderer
是由createBundleRenderer
创建而来,createBundleRenderer
函数需要传入bundle clientManifest
等实参。
因此,如果想在改完代码后拿到最新的内容,我们需要做以下事情:
- 声明一个
renderer
- 构建server,在文件修改时重新编译,构建完成时想办法拿到最新的
bundle
- 构建client,在文件修改时重新编译,构建完成时想办法拿到最新的
clientManifest
- 拿到最新的
bundle
和clientManifest
后,通过createBundleRenderer
将renderer
替换 - 拿到最新的
renderer
执行renderer.renderToString
以上就是setup-dev-server.js
要做的工作。
setup-dev-server.js
通常情况下我们构建webpack
, 通过命令指定一个wepback
配置文件进行构建。
webpack --config webpack.xxx.config.js
renderer
的更新关键在setupDevServer
传入的实参cb
的执行时机。
- 使用到了两个 插件
"webpack-dev-middleware": "^3.7.3","webpack-hot-middleware": "^2.25.4",
复制添加到 package.json 里,然后npm i
安装一下,使用 webpack-dev-middleware 时注意!!!
webpack-dev-middleware
旧版本的fileSystem
直接挂在devMiddleware
下。新版本通过
devMiddleware.context.outputFileSystem
访问
const webpack = require('webpack')
const fs = require('fs')
const path = require('path')
const middleware = require("webpack-dev-middleware")
const HMR = require("webpack-hot-middleware")
const MFS = require('memory-fs')
const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config')
const readFile = (fs, file) => {
try {
return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf8')
} catch (error) {
console.log('error: ', error);
}
}
const setupServer = (app, templatePath, cb) => {
let bundle
let clientManifest
let template
let ready
const readyPromise = new Promise(r => ready = r)
template = fs.readFileSync(templatePath, 'utf8')
const update = () => {
if (bundle && clientManifest) {
// 通知 server 进行渲染
// 执行 createRenderer -> RenderToString
ready()
cb(bundle, {
template,
clientManifest
})
}
}
// modify client config to work with hot middleware
clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
clientConfig.output.filename = '[name].js'
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
)
// dev middleware
// console.log("clientCompiler start")
const clientCompiler = webpack(clientConfig);
const devMiddleware = middleware(clientCompiler, {
noInfo: true,
publicPath: clientConfig.output.publicPath,
logLevel: 'silent',
serverSideRender: true
})
app.use(devMiddleware);
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.fileSystem,
'vue-ssr-client-manifest.json'
))
update()
})
// hot middleware
app.use(HMR(clientCompiler));
// webpack -> entry-server -> bundle
const mfs = new MFS();
const serverCompiler = webpack(serverConfig);
// watch and update server renderer
serverCompiler.outputFileSystem = mfs;
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
// 之后读取输出:
stats = stats.toJson()
stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(err => console.warn(err))
if (stats.errors.length) return
// read bundle generated by vue-ssr-webpack-plugin
bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
update()
});
return readyPromise
}
module.exports = setupServer
- 然后修改
server.js
// server.js
const app = require('express')()
const { createBundleRenderer } = require('vue-server-renderer')
const fs = require('fs')
const path = require('path')
const chalk = require("chalk")
const address = require("address")
const resolve = file => path.resolve(__dirname, file)
const isProd = process.env.NODE_ENE === "production"
const createRenderer = (bundle, options) => {
return createBundleRenderer(bundle, Object.assign(options, {
basedir: resolve('./dist'),
runInNewContext: false,
}))
}
let renderer, readyPromise
const templatePath = resolve('../src/index.template.html')
<>
if (isProd) {
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const template = fs.readFileSync(templatePath, 'utf-8')
renderer = createRenderer(bundle, {
// 推荐
template, // (可选)页面模板
clientManifest // (可选)客户端构建 manifest
})
} else {
// 开发模式
readyPromise = require('../config/setup-dev-server')(app, templatePath, (bundle, options) => {
renderer = createRenderer(bundle, options)
})
}
const render = (req, res) => {
const context = {
title: 'hello ssr with webpack',
meta: `
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
`,
url: req.url
}
renderer.renderToString(context, (err, html) => {
if (err) {
if (err.code === 404) {
res.status(404).end('Page not found')
} else {
res.status(500).end('Internal Server Error')
}
} else {
res.end(html)
}
})
}
// 在服务器处理函数中……
app.get('*', isProd ? render : (req, res) => {
readyPromise.then(() => render(req, res))
})
const port = '8081';
app.listen(port, () => {
const appRunningStr = chalk.green("App running at")
const LOCAL_IP = address.ip()
const localStr = chalk.green("Local: ") + chalk.cyan(`http://localhost:${port}`)
const netWorkStr = chalk.green("Network:") + chalk.cyan(`http://${LOCAL_IP}:${port}`)
console.log(appRunningStr)
console.log(localStr)
console.log(netWorkStr)
})
综上一个基础的 webpack5 + Vue2 + express
的 SSR 项目就搭建好了
源码地址:vue-ssr-test: 使用 webpack 5 + express 搭建vue ssr
如果服务端改成 koa 渲染时, 需要注意
由于webpack-dev-middleware
是一个标准的express
中间件,在 Koa
中不能直接使用它,因此需要将webpack-dev-middleware封装一下,以便Koa能够直接使用。
koa-dev-middleware
const koaDevMiddleware = (expressDevMiddleware) => {
return function middleware(ctx, next) {
return new Promise((resolve, reject) => {
expressDevMiddleware(ctx.req, {
end: (content) => {
ctx.body = content
resolve()
},
setHeader: (name, value) => {
ctx.set(name, value)
}
}, reject)
}).catch(next)
}
}
module.exports = koaDevMiddleware
koa-dev-middleware
const koaDevMiddleware = (expressDevMiddleware) => {
return function middleware(ctx, next) {
return new Promise((resolve, reject) => {
expressDevMiddleware(ctx.req, {
end: (content) => {
ctx.body = content
resolve()
},
setHeader: (name, value) => {
ctx.set(name, value)
}
}, reject)
}).catch(next)
}
}
module.exports = koaDevMiddleware
koa-hot-middleware
const koaHotMiddleware = (expressHotMiddleware) => {
return function middleware(ctx, next) {
return new Promise((resolve) => {
expressHotMiddleware(ctx.req, ctx.res, resolve)
}).then(next)
}
}
module.exports = koaHotMiddleware;
vue-server-renderer源码解析
-
创建渲染器createBundleRenderer是创建渲染器的主要方法。它接受一个包含服务器端编译好的Vue包的bundle对象,以及一个可选的模板。renderToString函数需要访问到组件的代码,所以需要预编译生成bundle。
-
渲染流程 (1) bundleRender函数会先初始化一个沙盒环境,然后执行bundle代码,生成根组件的vnode。(2) renderToString函数会递归遍历vnode树,生成HTML字符串。过程中会调用组件生命周期钩子,管理状态等。
-
组件生命周期 (1) beforeRender和afterRender钩子会在渲染前后调用。 (2) 会再次执行beforeCreate和created钩子,因为服务端和客户端都是独立运行的。
-
状态管理initState可以注入预设的状态,实现服务端和客户端状态一致。renderContext允许挂载共享的数据,实现状态共享。
-
异步组件支持返回Promise的组件,等待Promise resolve后再渲染,用于处理异步数据。
-
流式渲染renderToStream方法会以流的方式写入渲染结果,比直接生成字符串更高效。
使用 koa 当做开发服务器请看这里 vue-ssr-test: 使用 webpack 5 + koa2 搭建vue ssr
参考文章:
转载自:https://juejin.cn/post/7267737437953228839