Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
YIngChenIt opened this issue Jul 1, 2020 · 0 comments
Open

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

YIngChenIt opened this issue Jul 1, 2020 · 0 comments

Comments

@YIngChenIt
Copy link
Owner

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

源码

点击查看源码

SSR的基本原理

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

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

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是最新的

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant