看了Vue SSR的文档,还是搞不太懂SSR要怎样配置,结合拉勾前端训练营所讲的内容,整理一篇笔记。这部分拉勾前端训练营讲得特别清晰,不少内容看文档没有注意到,讲课的老师真的很棒(毕竟是大厂出来的)。
基本使用
先建立一个项目,然后下载vue和vue-server-renderer:
npm install vue vue-server-renderer --save
简单构建一个例子吧:
const Vue = require(‘vue’)
const renderer = require(‘vue-server-renderer’)
const app = new Vue({
template: `<div>Hello World</div>`
})
renderer.renderToString(app, (err, html) => {
if (err) throw err
console.log(html)
})
执行打印的结果为:
<div data-server-rendered="true">Hello World</div>
data-server-rendered="true" 是用来作标记,标示是客户端渲染的入口。
把以上的代码放在web服务器,这里用express示范:
npm install express --save
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>The visited URL is: {{ 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)
我们已经完成了一个简单的SSR应用,然而,有一个问题,就是应用与html模板结合在一起,这样读写代码不方便。我们可以另外新建一个模板。在同目录下新建 index.template.html,把html模板放在这里:
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
vue-ssr-outlet 是之后vue注入的地方。改一下之前的应用:
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer({
template: require(‘fs’).readFileSync(‘./index.template.html’, ‘utf-8’)
})
server.get('*', (req, res) => {
const app = new Vue({
data: {
url: req.url
},
template: `<div>The visited URL is: {{ url }}</div>`
})
renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.end(html)
})
})
server.listen(8080)
现在vue渲染的模板就会注入至index.template.html。
动态渲染
不过现在仅仅是静态渲染,也就是如果vue中加上动态绑定方法等是没有效,服务器只输出html。这时候需要webpack帮忙打包。先看Vue SSR官网的一幅图:
左边是源码,分为服务和客户端两部分,中间是webpack打包,右边是输出的部分,服务器部分打包构成渲染,客户端打包的则之后负责动态渲染。 先看一下官网的项目架构:
src
├── components
│ ├── Foo.vue
│ ├── Bar.vue
│ └── Baz.vue
├── App.vue
├── app.js # universal entry
├── entry-client.js # runs in browser only
└── entry-server.js # runs on server only
App.vue是入口模板,内容如下:
<template>
<div id="app">
<ul>
<li>
<router-link to="/">Home</router-link>
</li>
<li>
<router-link to="/about">About</router-link>
</li>
<li>
<router-link to="/posts">Posts</router-link>
</li>
</ul>
<!-- 路由出口 -->
<router-view/>
</div>
</template>
<script>
export default {
name: 'App',
data () {
return {
message: 'hello'
}
},
methods: {
onClick () {
console.log('Hello World!')
}
}
}
</script>
<style>
</style>
接下来要写通用入口,可以理解为服务端和客户端共同需要的部分:
/**
* 通用启动入口
*/
import Vue from 'vue'
import App from './App.vue'
// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
const app = new Vue({
// 根实例简单的渲染应用程序组件。
render: h => h(App)
})
return { app}
}
最后输出一个对象是因爲之后可能有其他对象要添加,如router和vuex等。 然后是服务端与客户端相关的入口
// entry-client.js
/**
* 客户端入口
*/
import { createApp } from './app'
// 客户端特定引导逻辑……
const { app } = createApp()
app.$mount('#app')
现在客户端要做的是把vue实例挂载到对应的节点。
// entry-server.js
import { createApp } from './app'
export default context => {
const { app } = createApp()
return app
}
现在服务端只是创建和返回vue实例。
安装依赖
现在还是不能启动应用,还需要webpack打包。安装以下包:
npm i vue vue-server-renderer express cross-env
npm i -D webpack webpack-cli webpack-merge webpack-node-externals @babel/core
@babel/plugin-transform-runtime @babel/preset-env babel-loader css-loader url-
loader file-loader rimraf vue-loader vue-template-compiler friendly-errors-
webpack-plugin
cross-env可以设定环境变量,可以通过它设定开发或生产模式。开发依赖的包比较多,用的时候再作说明。
在项目中新建文件夹build,用来存放webpack配置文件,架构如下:
build
├── webpack.base.config.js # 公共配置
├── webpack.client.config.js # 客户端打包配置文件
└── webpack.server.config.js # 服务端打包配置文件
先看一下公共配置:
/**
* 公共配置
*/
const VueLoaderPlugin = require('vue-loader/lib/plugin') // 处理vue文件的插件
const path = require('path')
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin') //友好的 webpack 错误提示
const resolve = file => path.resolve(__dirname, file) // 封装函数,放入文件名为参数,返回项目文件路径
/**
* process.env.NODE_ENV是由cross-env所设定的
*/
const isProd = process.env.NODE_ENV === 'production'
module.exports = {
mode: isProd ? 'production' : 'development',
output: {
path: resolve('../dist/'),
publicPath: '/dist/',
filename: '[name].[chunkhash].js'
},
resolve: {
alias: {
// 路径别名,项目中可以用@ 指向 src。注意路径是相对于webpack当前路径,所以要 ..
'@': resolve('../src/')
},
// 可以省略的扩展名
// 当省略扩展名的时候,按照从前往后的顺序依次解析
extensions: ['.js', '.vue', '.json']
},
devtool: isProd ? 'source-map' : 'cheap-module-eval-source-map',
module: {
rules: [
// 处理图片资源
{
// i表示字符大小写不敏感
test: /\.(png|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192,
},
},
],
},
// 处理字体资源
{
test: /\.(woff|woff2|eot|ttf|otf)$/,
use: [
'file-loader',
],
},
// 处理 .vue 资源
{
test: /\.vue$/,
loader: 'vue-loader'
},
// 处理 CSS 资源
// 它会应用到普通的 `.css` 文件
// 以及 `.vue` 文件中的 `<style>` 块
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
]
},
// CSS 预处理器,参考:https://vue-loader.vuejs.org/zh/guide/pre-processors.html
// 例如处理 Less 资源
// {
// test: /\.less$/,
// use: [
// 'vue-style-loader',
// 'css-loader',
// 'less-loader'
// ]
// },
]
},
plugins: [
new VueLoaderPlugin(),
new FriendlyErrorsWebpackPlugin()
]
}
// webpack.server.config.js
const { merge } = require('webpack-merge') //webpack合并配置文件包
const nodeExternals = require('webpack-node-externals') //排除 webpack 中的 Node 模块
const baseConfig = require('./webpack.base.config.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') // Vue SSR 渲染插件
module.exports = merge(baseConfig, {
// 将 entry 指向应用程序的 server entry 文件
entry: './src/entry-server.js',
// 这允许 webpack 以 Node 适用方式处理模块加载
// 并且还会在编译 Vue 组件时,
// 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
target: 'node',
output: {
filename: 'server-bundle.js',
// 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
libraryTarget: 'commonjs2'
},
// 不打包 node_modules 第三方包,而是保留 require 方式直接加载
externals: [nodeExternals({
// 白名单中的资源依然正常打包
allowlist: [/\.css$/]
})],
plugins: [
// 这是将服务器的整个输出构建为单个 JSON 文件的插件。
// 默认文件名为 `vue-ssr-server-bundle.json`
new VueSSRServerPlugin()
]
})
// webpack.client.config.js
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') // 客户端渲染插件
module.exports = merge(baseConfig, {
entry: {
app: './src/entry-client.js'
},
module: {
rules: [
// ES6 转 ES5
{
test: /\.m?js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
cacheDirectory: true,
plugins: ['@babel/plugin-transform-runtime'] // 简单来说,就是处理浏覧器兼容性的插件
}
}
},
]
},
// 重要信息:这将 webpack 运行时分离到一个引导 chunk 中,
// 以便可以在之后正确注入异步 chunk。
optimization: {
splitChunks: {
name: "manifest",
minChunks: Infinity
}
},
plugins: [
// 此插件在输出目录中生成 `vue-ssr-client-manifest.json`。
new VueSSRClientPlugin()
]
})
webpack的配置文件写好后,可以在package.json写脚本,我们需要添加三条脚本:
"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": "rimraf dist && npm run build:client && npm run build:server",
**cross-env NODE_ENV=production ** 用来设置环境变量,rimraf可以删除指定文件。
在命令行切换至项目根目录,输入
npm run build
之后会生成dist文件夹,里面存有打包的文件。在根目录创建server.js引用它们:
const express = require('express')
const fs = require('fs')
const { createBundleRenderer } = require('vue-server-renderer')
const server = express()
server.use('/dist', express.static('./dist')) // 服务器引用dist的静态文件
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const template = fs.readFileSync('./index.template.html', 'utf-8')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(serverBundle, {
template,
clientManifest
})
const render = async (req, res) => {
try {
const html = await renderer.renderToString({
title: 'Hello World',
meta: `
<meta name="description" content="HelloWorld">
`,
url: req.url
})
res.setHeader('Content-Type', 'text/html; charset=utf8')
res.end(html)
} catch (err) {
res.status(500).end('Internal Server Error.')
}
}
server.get('*', render)
server.listen(3000, () => {
console.log('server running at port 3000.')
})
现在客户端可以动态渲染了。
开发模式
虽然是把应用构建好,但现在还没有热更新,我们希望在开发模式下有热更新。这需要创建build/ setup-dev-server.js。它是用来做什么呢?首先把热更新的插件挂载到server上,如果src或模板有变化,则重新创建渲染实例。由于服务器要等待渲染实例生成,所以setup-dev-server要返回promise,以便服务器等待它。 所以setup-dev-server.js大概是这样:
module.exports = (server, callback) => {
let ready
const onReady = new Promise(r => ready = r)
// 监视构建 -> 更新 Renderer
let template
let serverBundle
let clientManifest
const update = () => {
if (template && serverBundle && clientManifest) {
ready()
callback(serverBundle, template, clientManifest)
}
}
return onReady
}
所以setup-dev-server监视template, serverBundle和clientManifest,有变动,调用update,更新渲染实例。 监视的话需要安装 ** chokidar **,写下template相关代码调用:
// 监视构建 template -> 调用 update -> 更新 Renderer 渲染器
const templatePath = path.resolve(__dirname, '../index.template.html')
template = fs.readFileSync(templatePath, 'utf-8')
update()
// fs.watch、fs.watchFile
chokidar.watch(templatePath).on('change', () => {
template = fs.readFileSync(templatePath, 'utf-8')
update()
})
chokidar监视参数,如果有变化,根据指定的变化,调用回调函数,这里指的是文件发生变化。 接下来是服务端打包文件监视:
const webpack = require('webpack')
const devMiddleware = require('webpack-dev-middleware')
…
// 监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
const serverConfig = require('./webpack.server.config')
const serverCompiler = webpack(serverConfig)
const serverDevMiddleware = devMiddleware(serverCompiler, {
logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
})
serverCompiler.hooks.done.tap('server', () => {
serverBundle = JSON.parse(
serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
)
update()
})
这里要webpack帮助,devMiddleware把数据存入内存,加快存取速度,logLevel是设定日志,silent表示关闭日志,serverCompiler.hooks.done.tap设定监视的回调函数,第一个参数是函数名。serverDevMiddleware.fileSystem.readFileSync与fs.readFileSync类似,只是它读取内存,resolve是对path.resolve封装的函数:
const resolve = file => path.resolve(__dirname, file)
因为读取出来是字符串,所以要解析。
然后是客户端打包文件监视,基本上差不多:
// 监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
const clientConfig = require('./webpack.client.config')
const clientCompiler = webpack(clientConfig)
const clientDevMiddleware = devMiddleware(clientCompiler, {
publicPath: clientConfig.output.publicPath,
logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
})
clientCompiler.hooks.done.tap('client', () => {
clientManifest = JSON.parse(
clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
)
update()
})
区别是要加上publicPath,因为客户端还要加载dist的文件,由于浏览器要访问内存数据,所以要另外加上:
server.use(clientDevMiddleware)
最后把热更新加上,项目中有任何变动,浏览器(客户端)自动更新。先安装 webpack-hot-middleware。之后在客户端配置部分添加代码:
// 监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
const clientConfig = require('./webpack.client.config')
clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
clientConfig.entry.app = [
'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
clientConfig.entry.app
]
clientConfig.output.filename = '[name].js' // 热更新模式下确保一致的 hash
const clientCompiler = webpack(clientConfig)
const clientDevMiddleware = devMiddleware(clientCompiler, {
publicPath: clientConfig.output.publicPath,
logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
})
clientCompiler.hooks.done.tap('client', () => {
clientManifest = JSON.parse(
clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
)
update()
})
server.use(hotMiddleware(clientCompiler, {
log: false // 关闭它本身的日志输出
}))
// 重要!!!将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问
server.use(clientDevMiddleware)
先添加插件** HotModuleReplacementPlugin **,入口处的值用数组把 ** webpack-hot-middleware/client ** 添加,后面字符串是调参用的,reload是webpack卡住时,自动刷新页面,quiet把浏览器控制台的日志关闭。热更新下output不用加上hash,最后server把hotMiddleware加上。
setup-dev-server完成了,添加到server.js:
const express = require('express')
const fs = require('fs')
const { createBundleRenderer } = require('vue-server-renderer')
const setupDevServer = require('./build/setup-dev-server')
const server = express()
server.use('/dist', express.static('./dist'))
const isProd = process.env.NODE_ENV === 'production'
let renderer
let onReady
if (isProd) {
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const template = fs.readFileSync('./index.template.html', 'utf-8')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createBundleRenderer(serverBundle, {
template,
clientManifest
})
} else {
// 开发模式 -> 监视打包构建 -> 重新生成 Renderer 渲染器
onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
renderer = createBundleRenderer(serverBundle, {
template,
clientManifest
})
})
}
const render = async (req, res) => {
try {
const html = await renderer.renderToString({
title: '拉勾教育',
meta: `
<meta name="description" content="拉勾教育">
`,
url: req.url
})
res.setHeader('Content-Type', 'text/html; charset=utf8')
res.end(html)
} catch (err) {
res.status(500).end('Internal Server Error.')
}
}
// 服务端路由设置为 *,意味着所有的路由都会进入这里
server.get('*', isProd
? render
: async (req, res) => {
// 等待有了 Renderer 渲染器以后,调用 render 进行渲染
await onReady
render(req, res)
}
)
server.listen(3000, () => {
console.log('server running at port 3000.')
})
onReady是表示已经构建好开发模式的设置,之后异步函数用到。先看路由设置,开发模式用异步函数,await onReady 先看已经构建好没有,好的话返回fulfiled状态下的Promise,执行渲染。
Router
之后比较简单。在router/index.js中,与创建vue实例一样,用createrouter封装:
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '@/pages/Home'
Vue.use(VueRouter)
export const createRouter = () => {
const router = new VueRouter({
mode: 'history', // 兼容前后端
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/about',
name: 'about',
component: () => import('@/pages/About')
},
{
path: '/posts',
name: 'post-list',
component: () => import('@/pages/Posts')
},
{
path: '*',
name: 'error404',
component: () => import('@/pages/404')
}
]
})
return router
}
在app.js导入router:
import { createRouter } from './router/'
...
export function createApp () {
const router = createRouter()
...
return { app, router, store }
}
只是需要在服务端入口添加代码。
const { app, router } = createApp()
// 设置服务器端 router 的位置
router.push(context.url)
// 等到 router 将可能的异步组件和钩子函数解析完
await new Promise(router.onReady.bind(router))
router.push(context.url) 是因为客户端不一定从首页开始浏覧应用,可以是其他页面,所以服务端需要定位。
await new Promise(router.onReady.bind(router)) 是由于router中可能有异步组件和钩子函数,所以需要等待它解析完。
Vuex
Vuex的配置也是类似的,这里只说重点。在服户端与客户端分别添加以下代码:
服务端:
context.rendered = () => {
// Renderer 会把 context.state 数据对象内联到页面模板中
// 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
// 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
context.state = store.state
}
客户端:
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
现在SSR基本就配建完成了