前言
前一篇文章,我大致描述了vue ssr的过程,并且构建出了vue ssr的生产环境。上篇文档主要还是讲述了vue ssr的整个思想,如果足够熟悉,会明显发现vue ssr的整个过程很零散,与vue3提供SSR函数有很大的不同,vue3对ssr做了补充。
下面还是讲述vue2的开发环境搭建,而不是vue3;虽然,微软已经宣布IE已经寿尽寝终,但是,我发现还有很多坑逼在用IE,就互联网行业而言,也还有人是用IE,或许在非专业人士眼中,所有的浏览器并没有什么差别。只要还有5%的目标用户在用IE,IE就不会消亡;其实5%在流量大头企业,已经是很恐怖的收入了,嗯,严谨点可以降到2%-1%。
这篇文章是对vue ssr的深入,对vue ssr概念还是不太熟,建议查看上一篇文章,有空的话,建议手撸一遍,两遍更好:VUE SSR基础功能搭建,以下,我们开始吧!
(文章尾部会附上代码链接)
概述
开发环境简单点来讲,就是修改一次代码,就按照上一篇文章那样build一下,但是,这个过程应该让程序自动完成。就这个自动过程,我们来分析一下:
- vueCLI提供了开发环境,即,如果我们在vueCli的基础上添加
vue-server-renderer/client-plugin,vueCLI会生成我们需要的vue-ssr-client-manifest.json文件,但是,我们还缺少vue-ssr-server-bundle.json; - 如果我们开启两个vscode的控制台,执行两次
serve命令,提供不同的参数,则会产生两个HMR,按道理应该是会生成我们需要的vue-ssr-server-bundle.json和vue-ssr-client-manifest.json文件,我们还是缺少renderToString的过程。 - 由此,上一篇文章单纯的vueCLI满足不了我们的需求了,所以,我们得自己搭建一个vueCLI开发环境,依然要用vueCLI,因为,完全自己弄,真的,真的,真的很恶心(感谢开源社区,感谢尤大)。
开发环境需要的东西
根据webpack的node api,需要两个东西来满足我们的需求:开发环境服务和HMR(庆幸的是loader帮我们实现了HMR,我们只需要简单使用)。下面提出两个webpack中间件和一个渲染服务,满足我们需求:
webpack-hot-middleware:提供了HMR相关东西的定义,各种loader实现的接口webpack-dev-middleware:提供开发环境服务- 自建node渲染服务,执行两次
webpack([webpack配置])方法,获取到对应的bunld和manifest,通过renderToString结合html模板,生成最终的html(这个过程不清楚的,还是得看上一篇文章),将上面两个middlewareuse进该服务,端口问题也没有了。
注意,上一篇文章,渲染服务用的是koa,但是,这两个中间件是express的,所以,需要调整
webpack config的获取
因为不能直接使用npm run serve了,所以,webpack相关的,我们都要弄,上面说还是要用vueCLI,是因为除了服务,还是其他配套的东西,完全不需要自己再弄。(信我,真的恶心)
- vueCLI提供了,webpack配置审查功能,意思是,我们可以直接获取webpack的配置,不用自己瞎搞:
import cliConfig from '@vue/cli-service/webpack.config.js'; - 新建
webpack-client.js和webpack-server.js自定义两个如果需要的配置,通过webpack-merge库合并就是我们的目标webpack配置。
webpack-hot-middleware的使用
注意事项:
- webpack配置entry需要添加:
'webpack-hot-middleware/client',否者HMR不生效; - webpack添加插件:
webpack.HotModuleReplacementPlugin和webpack.NoEmitOnErrorsPlugin
以上就是整体思路和注意事项,下面直接上代码帮助理解
整体流程
sequenceDiagram
用户 ->> 服务器: 访问页面
服务器 ->> node: 触发node服务
node ->> node: node:app.listen监听端口服务
node ->> node: 添加webpack-dev-middleware服务中间件(提供开发服务)
node ->> node: 添加webpack-hot-middleware服务中间件(提供热更新服务)
node ->> node: 通过require('@vue/cli-service/webpack.config')获取webpack配置
node -->> 服务器: 项目文件更改触发以上两个中间件,根据webpack配置打包代码
服务器 ->> 服务器: 生成vue-ssr-server-bundle.json和vue-ssr-client-manifest.json
服务器 ->> 服务器: 根据前面生成的文件,通过vue-server-renderer/renderToString方法生成目标代码
服务器 ->> 服务器: 将生成的html插入<!--vue-ssr-outlet-->,渲染页面
服务器 --) 用户: 将页面返回
详细代码
基础代码 (与上一篇文章一致,不做赘述)
- main.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
Vue.config.productionTip = false
export function createApp () {
const router = createRouter()
const store = createStore()
const app = new Vue({
router,
store,
render: h => h(App)
})
return { app, router, store }
}
- router
import Vue from 'vue'
import VueRouter from 'vue-router'
import HomeView from '../views/HomeView.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
}
]
export function createRouter () {
return new VueRouter({
mode: 'history',
routes
})
}
- store
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export function createStore () {
return new Vuex.Store({
state: {
},
getters: {
},
mutations: {
},
actions: {
},
modules: {
}
})
}
- entry-client.js
import { createApp } from './main.js'
const { app, router, store } = createApp()
if (window.__INITIAL_STATE__) {
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))
})
if (!activated.length) {
return next()
}
Promise.all(activated.map(c => {
if (c.asyncData) {
return c.asyncData({ store, route: to })
}
return Promise.resolve()
})).then(next).catch(next)
})
app.$mount('#app')
})
if (module.hot) {
module.hot.acesst()
}
- entry-serve.js
import { createApp } from './main.js'
export default content => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(content.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return resolve(app)
}
Promise.all(matchedComponents.map(component => {
if (component.asyncData) {
return component.asyncData({ store, route: router.currentRoute })
}
return Promise.resolve()
}))
.then((arr) => {
content.state = store.state
resolve(app)
})
.catch(reject)
resolve(app)
}, reject)
})
}
- package.json
"scripts": {
"serve": "vue-cli-service serve",
"build": "npm run build:client && npm run build:server && node ./server/copy_server.js",
"build:client": "vue-cli-service build",
"build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --no-clean",
"dev": "cross-env WEBPACK_TARGET=node NODE_ENV=development node ./server/dev.js",
"test:unit": "vue-cli-service test:unit",
"lint": "vue-cli-service lint"
},
package.json入口分析
npm run dev
执行了命令:cross-env WEBPACK_TARGET=node NODE_ENV=development node ./server/dev.js,这里设置了两个环境变量,并且执行了node代码,作用是根据WEBPACK_TARGET环境变量配置相对应的webpack配置,node代码根据webpack配置,通过webpack中间件:webpack-dev-middleware服务中间件、webpack-hot-middleware服务中间件,提供对应的服务
node服务文件夹描述
其他文件不一一描述,就是cli生成的文件以及上面的基础文件
关键代码
- server/dev.js文件,该文件是webpack服务的所有代码集合
- server/webpack.client.config.js文件
- server/webpack.server.config.js文件
webpack相关的配置
相关的配置内容都比较简单,复杂的配置都在
@vue/cli-service/webpack.config里面,我们这里做个简单的继承,就能让项目跑起来,自己配置的话,比较恶心
server/webapck.client.config.js
const { merge } = require('webpack-merge')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const localIpAddr = require('ip').address()
const webpackConfig = require('@vue/cli-service/webpack.config')
const config = merge(webpackConfig, {
entry: './src/entry-client.js',
devtool: 'eval-source-map',
target: 'web',
node: false,
devServer: {
static: '/dist',
hot: true
},
output: {
libraryTarget: undefined,
publicPath: `http://${localIpAddr}:8080/`
},
externals: undefined,
optimization: {
splitChunks: undefined
},
plugins: [new VueSSRClientPlugin()]
})
module.exports = config
server/webpack.server.config.js
const { merge } = require('webpack-merge')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const nodeExternals = require('webpack-node-externals')
const webpackConfig = require('@vue/cli-service/webpack.config')
const config = merge(webpackConfig, {
entry: './src/entry-server.js',
devtool: 'eval-source-map',
target: 'node',
node: undefined,
output: {
libraryTarget: 'commonjs2'
},
externals: nodeExternals({ allowlist: [/\.css$/] }),
optimization: {
splitChunks: undefined
},
plugins: [new VueSSRServerPlugin()]
})
module.exports = config
server/dev.js代码如下:
const express = require('express')
const path = require('path')
const fs = require('fs')
const webpack = require('webpack')
const { createFsFromVolume, Volume } = require('memfs')
const app = express()
const resolve = file => path.resolve(__dirname, file)
const readFileByMemfs = (fs, file) => {
try {
return fs.readFileSync(path.join(wpClientConfig.output.path, file), 'utf-8')
} catch (err) {
console.error('func rreadFileByMemfs error:', err)
}
}
let clientManifest
let bundle
let renderer
let ready
/** 添加promise,等待webpack构建完成 */
const readyPromise = new Promise(r => { ready = r })
/** 引入vue的服务端渲染函数 */
const { createBundleRenderer } = require('vue-server-renderer')
/** 引入模板文件,用于插入服务端渲染函数,渲染完成的html字符串 */
const templatePath = resolve('../public/index.template.html')
const template = fs.readFileSync(templatePath, 'utf-8')
/**
* 每次热更新:webpack-hot-middleware, webpack服务:webpack-dev-middleware更新都会触发该函数,
* 用于确定manifest.json和bundle.json是否已经处理好,处理好之后再转交给vue-server-renderer生产html
*/
const update = () => {
if (bundle && clientManifest) { ready() }
renderer = createBundleRenderer(bundle, {
runInNewContext: false,
template,
clientManifest
})
}
/** 引入client的webpack配置,并更改相关的webpack配置 */
const wpClientConfig = require('./webpack.client.config')
/**
* 如果需要更改,提供热更新服务,意思是在执行webpack.entry之前执行:webpack-hot-middleware/client代码,
* 注意:这里并不是热更新,热更新中间件需要app.use才是使用,这里仅仅是热更新需要的代码
* */
wpClientConfig.entry = ['webpack-hot-middleware/client', wpClientConfig.entry]
wpClientConfig.output.filename = '[name].js'
/** 官方要求的相关插件 */
wpClientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
)
/** 上面已经准备好client的webpack配置,以下开始执行 */
const wpClienetCompiler = webpack(wpClientConfig)
const wpDevServer = require('webpack-dev-middleware')(wpClienetCompiler, {
publicPath: wpClientConfig.output.publicPath,
stats: 'errors-warnings'
})
app.use(wpDevServer)
/** webpack Compiler完成后的hooks */
wpClienetCompiler.hooks.done.tap('vue-server-renderer/client-plugin', stats => {
const statsJson = stats.toJson() /** 转换格式 */
/** 异常处理 */
statsJson.errors.forEach(err => console.error(err))
statsJson.warnings.forEach(err => console.warn(err))
if (statsJson.errors.length) return
/** 获取生成后的manifest.json文件 */
clientManifest = JSON.parse(readFileByMemfs(wpClienetCompiler.outputFileSystem, 'vue-ssr-client-manifest.json'))
update()
})
/** use热更新代码以及相关的配置,具体可查看官方配置 */
app.use(require('webpack-hot-middleware')(wpClienetCompiler, {
heartbeat: 5000,
log: false,
path: '/__webpack_hmr',
timeout: 2000,
overlay: false,
reload: true
}))
/** 引入server相关的webpack配置,并执行webpack配置 */
const wpServerConfig = require('./webpack.server.config')
const wpServerCompiler = webpack(wpServerConfig)
/** 将编译的内容存放在内存,提升速度 */
const serverFs = createFsFromVolume(new Volume())
wpServerCompiler.outputFileSystem = serverFs
/** watch代码更改 */
wpServerCompiler.watch({}, (err, stats) => {
if (err || stats.hasErrors()) {
console.error(err || 'webpack watch error!')
return
}
const statsJson = stats.toJson() /** 格式转换 */
/** 异常处理 */
statsJson.errors.forEach(err => console.error(err))
statsJson.warnings.forEach(err => console.warn(err))
if (statsJson.errors.length) return
/** 读取生产后的bundle.json文件,这时两个关键文件已经生成 */
bundle = JSON.parse(readFileByMemfs(serverFs, 'vue-ssr-server-bundle.json'))
update()
})
/** 通过上面webpack生成的manifest.json、bundle.json 通过renderToString生成html代码 */
/** 以下node静态资源服务代码 */
function renderToString (content) {
return new Promise((resolve, reject) => {
renderer.renderToString(content, (err, html) => {
err ? reject(err) : resolve(html)
})
})
}
// const serve = (path) => express.static(resolve(path), { maxAge: 0 })
// app.use('/dist', serve('./dist'))
// app.use('/public', serve('./public'))
// app.use('/manifest.json', serve('./manifest.json'))
function render (req, res) {
const s = Date.now()
res.setHeader('Content-Type', 'text/html')
const handleError = err => {
if (err.url) {
res.redirect(err.url)
} else if (err.code === 404) {
res.status(404).send('404 | url找不到')
} else {
// Render Error Page or Redirect
res.status(500).send('500 | Internal Server Error')
console.error(`ender 错误: ${req.url}`)
console.error(err.stack)
}
}
const context = {
title: 'vue ssr',
description: 'vue2 ssr服务端渲染',
keyword: 'vue2,ssr',
url: req.url
}
renderToString(context, (err, html) => {
if (err) {
return handleError(err)
}
res.send(html)
console.log(`whole request: ${Date.now() - s}ms`)
})
}
app.get('*', (req, res) => {
readyPromise.then(() => render(req, res))
})
const port = 8080
app.listen(port, () => {
console.log(`server is running in localhost:${port}`)
})
以上全部核心代码已经展示,关键逻辑已添加注释
server/server.js文件
该文件就是打包后的服务端执行代码,需要将里面引入的包再打包才能正常运行
const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const send = require('koa-send')
const compress = require('koa-compress')
const app = new Koa()
const resolve = file => path.resolve(__dirname, file)
// 第 2 步:获得一个createBundleRenderer
const template = fs.readFileSync(resolve('./index.template.html'), 'utf-8')
const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require(resolve('./vue-ssr-server-bundle.json'))
const clientManifest = require(resolve('./vue-ssr-client-manifest.json'))
const renderer = createBundleRenderer(bundle, {
runInNewContext: false,
template,
clientManifest
})
function renderToString (content) {
return new Promise((resolve, reject) => {
renderer.renderToString(content, (err, html) => {
err ? reject(err) : resolve(html)
})
})
}
// 注入变量
app.use(async (ctx, next) => {
ctx.ssrContext = {
url: ctx.path + ctx.search,
title: 'vue ssr',
description: 'vue2 ssr服务端渲染',
keyword: 'vue2,ssr'
}
await next()
})
// 第 3 步:添加一个中间件来处理所有请求
app.use(async (ctx, next) => {
const url = ctx.path
if (/[.](js|css|jpg|jpeg|png|gif|map|ico|cur|json|html|txt|svg|font|woff|ttf)$/.test(url)) {
await send(ctx, url, { root: path.resolve(__dirname, './') })
return
}
ctx.res.setHeader('Content-Type', 'text/html')
try {
const html = await renderToString(ctx.ssrContext)
ctx.body = html
} catch (res) {
console.log(
`服务器catch异常:${
res instanceof Error ? res.stack : JSON.stringify(res)
}`
)
ctx.response.redirect(`/error/${res.code ? res.code : 500}`)
}
next()
})
app.use(compress({ threshold: 2048 }))
const port = 8080
app.listen(port, function () {
console.log(`server started at localhost:${port}`)
})