# Vue + koa 服务端渲染采坑日记

# 源码

点击查看源码

# SSR的基本原理

我们先来看一个ssr的流程图 An image

根据流程图我们大致理一下思路:

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.jsclient-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.jswebpack.client.jswebpack.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:buildnpm 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将我们打包出来的serverbundleindex.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.jsonvue-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是最新的