# Vue + koa 服务端渲染采坑日记
# 源码
# SSR的基本原理
我们先来看一个ssr的流程图
根据流程图我们大致理一下思路:
1、根据vue项目的入口文件(app.js/main.js)和对应的xxx.entry.js
通过webpack打包出服务端和客户端的js文件
2、在node服务层将打包出来的server.bundle.js
通过``vue-server-renderer`等包生成HTML返回给客户端
3、客户端将打包出来的客户端jsclient.bundle.js
水合到HTML中,激活事件、路由等
# 项目初始化
首先我们初始化项目和安装对应的包
npm init -y
cnpm i koa koa-router koa-static
cnpm i vue vue-router vuex vue-server-renderer
cnpm i webpack webpack-cli webpack-dev-server babel-loader @babel/core @babel/preset-env vue-loader vue-template-compiler html-webpack-plugin webpack-merge -D
cnpm i vue-style-loader css-loader
这里需要注意的是有2个特别的地方,首先是vue-server-renderer
,它是我们vue-ssr的核心包。然后就是vue-style-loader
,我们知道在服务端是没有DOM元素的,所以style-loader
是不可以使用的,取而代之的是vue-style-loader
,他和style-loader
具有一样的功能
然后我们准备一个vue项目的基本模板
── plubic
├── index.html
├── index-ssr.html
── src
├── components
├── Bar.vue
├── Foo.vue
├── App.vue
├── main.js(入口文件)
── webpack.config.js
接下来补充一下代码
// Bar.vue
<template>
<div @click="click">bar</div>
</template>
<script>
export default {
methods: {
click() {
alert(1)
}
},
}
</script>
<style scoped>
div {
background: red;
}
</style>
Bar组件添加点击事件和样式
// Foo.vue
<template>
<div>foo</div>
</template>
// APP.vue
<template>
<div>
<Bar/>
<Foo/>
</div>
</template>
<script>
import Bar from './components/Bar'
import Foo from './components/Foo'
export default {
components: {
Bar,
Foo
}
}
</script>
// main.js
import Vue from 'vue'
import App from './App.vue'
const vm = new Vue({
el: '#app',
render: h => h(App)
})
// pulbic/index- ssr.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
# 渲染第一个Vue组件
# 打包客户端文件和服务端文件
项目准备好之后,我们还需要自己写一个webpack
的打包配置
// webpack.config.js
const path = require('path')
const VueLoader = require('vue-loader/lib/plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const resolve = (dir) => {
return path.resolve(__dirname, dir)
}
module.exports = {
entry: resolve('./src/main.js'),
output: {
filename: 'bundle.js',
path: resolve('./dist')
},
resolve: {
extensions: ['.js', '.vue']
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
},
exclude: /node_modules/
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader']
},
{
test: /\.vue$/,
use: 'vue-loader'
}
]
},
plugins: [
new VueLoader(),
new HtmlWebpackPlugin({
filename: 'index.html',
template: resolve('./plubic/index.html')
})
]
}
很常规的vue-webpack配置,接下来我们打包一下
npx webpack-dev-server
访问对应的端口,我们的基础vue项目就跑通了。接下来我们试一下按照流程图来将这个项目改造成ssr项目.
首先我们需要对入口文件main.js
进行修改
// main.js
import Vue from 'vue'
import App from './App'
// 入口文件 提供vue实例 为了保证每次导出的实例不一样 应该为函数
export default () => {
const app = new Vue({
render: h => h(App)
})
return { app }
}
导出一个函数的意义是不管是服务端还是客户端执行这份代码,都是生成一个新的app实例
接下来我们写下server-entry.js
和 client-entry.js
// client-entry.js
// 客户端
import createApp from './main'
const { app } = createApp() // 获得客户端实例
app.$mount('#app') // 挂载到#app上
// server-entry.js
// 服务端
import createApp from './main'
// 服务端需要调用当前这个文件产生一个vue的实例
export default (context) => {
const { app } = createApp()
return app
}
有了客户端和服务端对应的打包入口文件,我们需要通过webpack用对应的入口打出不同的包,所以我们将webpack
配置拆为webpack.base.js
、webpack.client.js
、webpack.server.js
, 分别对应webpack
基础共有配置, 客户端私有webpack
配置和服务端私有webpack
配置
// webpack.base.js
// webpack基础配置文件
const path = require('path')
const VueLoader = require('vue-loader/lib/plugin')
const resolve = (dir) => {
return path.resolve(__dirname, dir)
}
module.exports = {
output: {
filename: '[name]bundle.js',
path: resolve('./dist')
},
resolve: {
extensions: ['.js', '.vue']
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
},
exclude: /node_modules/
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader']
},
{
test: /\.vue$/,
use: 'vue-loader'
}
]
},
plugins: [
new VueLoader()
]
}
// webpack.server.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const resolve = (dir) => {
return path.resolve(__dirname, dir)
}
module.exports = merge(base, {
target: 'node', // 打包之后要给node使用
entry: {
server: resolve('./src/server-entry.js')
},
output: {
libraryTarget: 'commonjs2' // 最终这个文件的导出的结果,放到module.exports上
},
plugins: [
new HtmlWebpackPlugin({
filename: 'index.ssr.html',
template: resolve('./public/index-ssr.html'),
excludeChunks: ['server'] //打包出来的html不引入server打包的js 因为要引入客户端打包出来的js
}),
]
})
webpack.server.js
的配置中,根据入口server-entry.js
打包出来的文件需要在node环境下运行,并且不可以在生成的index-ssr.html
上挂载serverbundle.js
// webpack.client.js
const merge = require('webpack-merge')
const base = require('./webpack.base')
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const resolve = (dir) => {
return path.resolve(__dirname, dir)
}
module.exports = merge(base, {
entry: {
client: resolve('./src/client-entry.js')
},
plugins: [
new HtmlWebpackPlugin({
filename: 'index.html',
template: resolve('./public/index.html')
}),
]
})
webpack.client.js
的配置就比较简单了,根据入口client-entry.js
打包出clientbundle.js
并挂载到index.html
中
最后我们配置几条脚本可以分别执行我们的打包就好
// package.json
"scripts": {
"client:dev": "webpack-dev-server --config ./webpack.client.js --mode development",
"client:build": "webpack --config ./webpack.client.js --mode production",
"server:build": "webpack --config ./webpack.server.js --mode production"
},
然后我们就可以通过npm run client:build
和 npm run server:build
打包出对应的客户端文件和服务端文件了
# 配置koa渲染打包后的文件
有了打包之后的文件,我们就可以写一个koa服务器来解析渲染我们的文件了
// server.js
const Koa = require('koa')
const fs = require('fs')
const path = require('path')
const Router = require('koa-router')
const static = require('koa-static')
const VueServerRender = require('vue-server-renderer') // 这个包可以渲染vue实例
const ServerBundle = fs.readFileSync('./dist/serverbundle.js', 'utf8')
const template = fs.readFileSync('./dist/index.ssr.html', 'utf8')
// createBundleRenderer 渲染打包后的结果
const render = VueServerRender.createBundleRenderer(ServerBundle, {
template,
}) // 创建一个渲染器
const app = new Koa()
const router = new Router()
router.get('/', async ctx => {
ctx.body = await render.renderToString()
})
app.use(router.routes())
app.use(static(path.resolve(__dirname, 'dist')))
app.listen(3000, () => {
console.log('server start')
})
服务端主要做的事情就是使用VueServerRender
将我们打包出来的serverbundle
和index.ssr.html
生成一个html,然后返回给客户端,并且这里通过koa-static
设置dist目录为静态资源目录
然后我们运行node ./server.js
访问http://localhost:3000/
发现已经可以渲染出来我们的组件了
# 处理css
但是我们发现,Bar组件的css失效了,这个问题我们需要通过Promise 的方式来解决
// server.js
router.get('/', async ctx => {
ctx.body = await new Promise((resolve, reject) => {
render.renderToString((err, data) => {
if (err) reject(err)
resolve(data)
})
})
})
通过Promise和回调的方式我们就可以让css生效了
# 处理点击事件
但是我们还是发现,Bar组件的点击事件不管用了,这是因为我们没有把客户端打包出来的clientbundle.js
挂载到HTML上
首先我们需要在App.vue上加上id,这一步叫做客户端激活
// App.vue
<template>
<div id="app">
<Bar/>
<Foo/>
</div>
</template>
然后我们还是需要核心包vue-server-renderer
将我们的客户端和服务端联系在一起
// webpack.client.js
const ClientRenderPlugin = require('vue-server-renderer/client-plugin')
module.exports = merge(base, {
plugins: [
new ClientRenderPlugin()
]
})
// webpack.server.js
const ServerRenderPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(base, {
plugins: [
new ServerRenderPlugin()
]
})
这样之后我们打包就会产生两个文件vue-ssr-client-manifest.json
和 vue-ssr-server-bundle.json
分别代表着客户端映射和服务端映射
然后我们在koa服务器稍微处理一下,就可以实现客户端代码和服务端代码的映射连接了
// server.js
const template = fs.readFileSync('./dist/index.ssr.html', 'utf8')
const ServerBundle = require('./dist/vue-ssr-server-bundle')
const clientManifest = require('./dist/vue-ssr-client-manifest') // 渲染的时候可以找到客户端的js文件自动引入到html中
// createBundleRenderer 渲染打包后的结果
const render = VueServerRender.createBundleRenderer(ServerBundle, {
template,
clientManifest
}) // 创建一个渲染器
到目前为止我们就实现了服务端渲染vue组件,支持css和事件了
# 集成路由
# 实现客户端路由
首先我们按照普通vue项目来配置路由
// route.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Bar from './components/Bar.vue'
Vue.use(VueRouter)
export default () => { // 写成函数的写法 每次创建新的路由
const router = new VueRouter({
mode: 'history',
routes: [
{
path: '/',
component: Bar
},
{
path: '/foo',
component: () => import('./components/Foo.vue')
}
]
})
return router
}
和以往不一样的是,这里需要导出一个函数,确保客户端和服务端拿到的是不一样的路由
然后我们修改一下App.vue
// App.js
<template>
<div id="app">
<router-link to="/">bar</router-link>
<router-link to="/foo">foo</router-link>
<router-view></router-view>
</div>
</template>
接下来就是ssr配置流程了,我们修改一下入口文件main.js
// main.js
import Vue from 'vue'
import App from './App'
import createRouter from './route'
// 入口文件 提供vue实例 为了保证每次导出的实例不一样 应该为函数
export default () => {
const router = createRouter()
const app = new Vue({
router,
render: h => h(App)
})
return { app, router }
}
除了将实例导出以外,我们在导出创建的路由
最后我们打包执行,发现点击路由是可以进行切换的了,但是这仅仅只是客户端的路由切换,当我们访问3000
端口的时候是没有出现页面的,也就是我们的服务器路由没有生效
# 实现服务端路由
那我们接下来实现一下服务端路由吧
// server.js
router.get('/', async ctx => {
ctx.body = await new Promise((resolve, reject) => {
render.renderToString({ url: '/' }, (err, data) => {
if (err) reject(err)
resolve(data)
})
})
})
我们首先需要在renderToString
添加第一个参数,这个参数会传递给server.entry.js
中的形参context中
// server-entry.js
export default (context) => {
const { app, router } = createApp()
router.push(context.url)
return app
}
然后我们通过router.push(context.url)
进行服务端路由跳转,我们打包重新运行下,我们可以发现在访问3000
端口的时候是有内容出来了
但是这个仅仅是作用于根路径/
,那我们按照这个逻辑写一个中间件,来实现匹配其他路径下的服务端路由跳转
// server.js
app.use(async ctx => {
ctx.body = await new Promise((resolve, reject) => {
// 如果服务器没有此路径 会渲染当前的app.vue
render.renderToString({ url: ctx.url }, (err, data) => {
if (err) reject(err)
resolve(data)
})
})
})
这样的话就可以实现任何已有路由下的服务端路由跳转了
TIP
并且我们知道,vue-ssr中通过点击切换路由其实是切换的客户端路由,而服务器路由切换是在url地址栏中输入回车的时候进行的路由切换
# 处理异步组件
在项目中我们很多组件都需要异步的获取数据,那我们也修改一下代码支持异步组件
// 服务端
import createApp from './main'
// 服务端需要调用当前这个文件产生一个vue的实例
export default (context) => {
// 涉及到异步组件 所以写成promise
return new Promise((resolve, reject) => {
const { app, router } = createApp() // 获得服务端实例,每次产生一个新的实例
router.push(context.url) // 服务端进行路由跳转
router.onReady(() => {
// 获取当前路由匹配到的组件
const matchs = router.getMatchedComponents()
if (!matchs.length) { // 如果没有匹配组件
reject({ code: 404 })
}
resolve(app)
}, reject)
})
}
通过 `router.onReady`监听组件是否加载完成,然后然后app
# 404页面
404页面就比较简单了,我们在server-entry.js
中,如何当前路由没有匹配到自建的话会reject一个404,那我们可以在服务端捕获这个错误,然后返回状态码404就好了
// server.js
app.use(async ctx => {
try{
ctx.body = await new Promise((resolve, reject) => {
// 如果服务器没有此路径 会渲染当前的app.vue
render.renderToString({ url: ctx.url }, (err, data) => {
if (err) reject(err)
resolve(data)
})
})
} catch(e) { // 路由没有匹配到组件 返回404
ctx.body = '404'
}
})
# 集成vuex
现在我们来搭一下普通vue项目的vuex流程
// store.js
import Vuex from 'vuex'
import Vue from 'vue'
Vue.use(Vuex)
export default () => {
const store = new Vuex.Store({
state: {
name: ''
},
mutations: {
setName(state, data) {
state.name = data
}
},
actions: {
changeName({ commit }) {
return new Promise((resolve, reject) => {
setTimeout(() => {// 模拟异步请求
commit('setName', 'chenying')
resolve()
}, 1000)
})
}
}
})
return store
}
如果用过nuxt的同学肯定会知道在nuxt中有一个钩子叫asyncData
,我们可以在这个钩子发起一些请求,而且这些请求是在服务端发出的
// Bar.vue
asyncData(store) { // 这个方法只有在服务器端执行 并且只有页面组件才有
return store.dispatch('changeName')
}
那我们来看下如何实现asyncData
吧,在server-entry.js
中我们知道可以通过const matchs = router.getMatchedComponents()
获取到匹配当前路由的所有组件,也就是我们可以拿到所有组件的asyncData
方法让他执行就完事了
// server-entry.js
export default (context) => {
// 涉及到异步组件 所以写成promise
return new Promise((resolve, reject) => {
const { app, router, store } = createApp() // 获得服务端实例,每次产生一个新的实例
router.push(context.url) // 服务端进行路由跳转
router.onReady(() => {
// 获取当前路由匹配到的组件
const matchs = router.getMatchedComponents()
if (!matchs.length) { // 如果没有匹配组件
reject({ code: 404 })
}
Promise.all(matchs.map(component => {
if (component.asyncData) { // 如果组件有asyncData 执行
return component.asyncData(store)
}
})).then(() => { // 每个组件的asyncData都执行玩才渲染
context.state = store.state // 把vuex中的状态挂载到上下文中(会将状态挂到window上)
resolve(app)
})
}, reject)
})
}
通过 Promise.all 我们就可以让所有匹配到的组件中的asyncData执行,然后修改服务器的store了
但是这里只是修改了服务端的store,我们应该将服务端的最新store同步到客户端的store中
// store.js
export default () => {
....
// 如果浏览器执行的时候,需要将服务器设置的最新状态替换掉客户端的状态
if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__) // 替换store
}
return store
}
这一点和react的服务端渲染很像,通过window将服务端的store和客户端的store同步
但是一般项目中为了更好的效果,我们通常会asyncData
+ mounted
同时发起请求,确保客户端渲染的时候stroe是最新的