Vue项目SSR改造实战

2018-06-07

为什么要做SSR

为了更好的seo抓取,当然,还有另一个原因是SSR概念现在在前端非常火。下面将详细介绍本博客项目SSR全过程。

SSR改造实战

总的来说SSR改造还是相当容易的。推荐在动手之前,先了解Vue SSR指南官方文档和官方Vue SSR Demo,这会让我们事半功倍。

1. 构建改造


上图是Vue官方的SSR原理介绍图片。从这张图片,我们可以知道:我们需要通过Webpack打包生成两份bundle文件:
Client Bundle,给浏览器用。和纯Vue前端项目Bundle类似
Server Bundle,供服务端SSR使用,一个json文件
不管你项目先前是什么样子,是否是使用vue-cli生成的。都会有这个构建改造过程。在构建改造这里会用到 vue-server-renderer 库,这里要注意的是 vue-server-renderer 版本要与Vue版本一样。下图是我的构建文件目录:

util.js 提供一些公共方法
webpack.base.js是公共的配置
webpack.client.js 是生成Client Bundle的配置。核心配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const config = merge(baseConfig, {
target: 'web',
entry: './src/entry.client.js',
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"client"'
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'vender',
minChunks: 2
}),
// extract webpack runtime & manifest to avoid vendor chunk hash changing
// on every build.
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
}),
new VueSSRClientPlugin()
]
})

webpack.server.js 是生成Server Bundle的配置,核心配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const config = merge(baseConfig, {
target: 'node',
devtool: '#source-map',
entry: './src/entry.server.js',
output: {
libraryTarget: 'commonjs2',
filename: 'server-bundle.js'
},
externals: nodeExternals({
// do not externalize CSS files in case we need to import it from a dep
whitelist: /\.css$/
}),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()
]
})

2. 代码改造

2.1 必须使用VueRouter, Vuex。ajax库建议使用axios
可能你的项目没有使用VueRouter或Vuex。但遗憾的是,Vue-SSR必须基于 Vue + VueRouter + Vuex。Vuex官方没有提,但其实文档和Demo都是基于Vuex。我的博客以前也没有用Vuex,但经过一翻折腾后,还是乖乖加上了Vuex。另外,因为代码要能同时在浏览器和Node.js环境中运行,所以ajax库建议使用axios这样的跨平台库。

2.2 两个打包入口(entry),重构app, store, router, 为每个对象增加工厂方法createXXX

每个用户通过浏览器访问Vue页面时,都是一个全新的上下文,但在服务端,应用启动后就一直运行着,处理每个用户请求的都是在同一个应用上下文中。为了不串数据,需要为每次SSR请求,创建全新的app, store, router。


上图是我的项目文件目录。

app.js, 通用的启动Vue应用代码
App.vue,Vue应用根组件
entry.client.js,浏览器环境入口
entry.server.js,服务器环境入口
index.html,html模板
再看一下具体实现的核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// app.js
import Vue from 'vue'
import App from './App.vue' // 根组件
import {createRouter} from './routers/index'
import {createStore} from './vuex/store'
import {sync} from 'vuex-router-sync' // 把当VueRouter状态同步到Vuex中

// createApp工厂方法
export function createApp (ssrContext) {
let router = createRouter() // 创建全新router实例
let store = createStore() // 创建全新store实例

// 同步路由状态到store中
sync(store, router)

// 创建Vue应用
const app = new Vue({
router,
store,
ssrContext,
render: h => h(App)
})
return {app, router, store}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// entry.client.js 
import Vue from 'vue'
import { createApp } from './app'
const { app, router, store } = createApp()

// 如果有__INITIAL_STATE__变量,则将store的状态用它替换
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))
})
// 组件数据通过执行asyncData方法获取
const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
if (!asyncDataHooks.length) {
return next()
}
// 要注意asyncData方法要返回promise,asyncData调用的vuex action也必须返回promise
Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
.then(() => {
next()
})
.catch(next)
})

// 将Vue实例挂载到dom中,完成浏览器端应用启动
app.$mount('#app')
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// entry.server.js
import { createApp } from './app'

export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp(context)

// 设置路由
router.push(context.url)

router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}

// 执行asyncData方法,预拉取数据
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store: store,
route: router.currentRoute
})
}
})).then(() => {
// 将store的快照挂到ssr上下文上
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// createStore
import Vue from 'vue'
import Vuex from 'vuex'
// ...

Vue.use(Vuex)
// createStore工厂方法
export function createStore () {
return new Vuex.Store({
// rootstate
state: {
appName: 'appName',
title: 'home'
},

modules: {
// ...
},

strict: process.env.NODE_ENV !== 'production' // 线上环境关闭store检查
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// createRouter
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)

// createRouter工厂方法
export function createRouter () {
return new Router({
mode: 'history', // 注意这里要使用history模式,因为hash不会发送到服务端
fallback: false,
routes: [
{
path: '/index',
name: 'index',
component: () => System.import('./index/index.vue') // 代码分片
},
{
path: '/detail/:aid',
name: 'detail',
component: () => System.import('./detail/detail.vue')
},
// ...
{
path: '/',
redirect: '/index'
}
]
})
}

3. 重构组件获取数据方式

关于状态管理,要严格遵守Redux思想。建议把应用所有状态都存于store中,组件使用时再mapState下来,状态更改严格使用action的方式。另一个要提一点的是,action要返回promise。这样我们就可以使用asyncData方法获取组件数据了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const actions = {
getArticleList ({state, commit}, curPageNum) {
commit(FETCH_ARTICLE_LIST, curPageNum)

// action 要返回promise
return apis.getArticleList({
data: {
size: state.pagi.itemsPerPage,
page: curPageNum
}
}).then((res) => {
// ...
})
}
}

// 组件asyncData实现
export default {
asyncData ({ store }) {
return store.dispatch('getArticleList', 1)
}
}

4. SSR服务器实现

在完成构建和代码改造后,如果一切顺利。我们能得到下面的打包文件:

这时,我们可以开始实现SSR服务端代码了。下面是我博客SSR实现(基于Koa)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// server.js
const Koa = require('koa')
const path = require('path')
const logger = require('./logger')
const server = new Koa()
const { createBundleRenderer } = require('vue-server-renderer')
const templateHtml = require('fs').readFileSync(path.resolve(__dirname, './index.template.html'), 'utf-8')

let distPath = './dist'

const renderer = createBundleRenderer(require(`${distPath}/vue-ssr-server-bundle.json`), {
runInNewContext: false,
template: templateHtml,
clientManifest: require(`${distPath}/vue-ssr-client-manifest.json`)
})

server.use(function * (next) {
let ctx = this
const context = { url: ctx.req.url, pageTitle: 'default-title' }

// cgi请求,前端资源请求不能转到这里来。这里可以通过nginx做
if (/\.\w+$/.test(context.url)) {
return yield next
}

// 注意这里也必须返回promise
return new Promise((resolve, reject) => {
renderer.renderToString(context, function (err, html) {
if (err) {
logger.error(`[error][ssr-error]: ` + err.stack)
return reject(err)
}
ctx.status = 200
ctx.type = 'text/html; '
ctx.body = html
resolve(html)
})
})
})

// 错误处理
server.on('error', function (err) {
logger.error('[error][server-error]: ' + err.stack)
})

let port = 80

server.listen(port, () => {
logger.info(`[info]: server is deploy on port: ${port}`)
})

5. 服务器部署

服务器部署,跟你的项目架构有关。比如我的博客项目在服务端有2个后端服务,一个数据库服务,nginx用于请求转发:

6. 遇到的问题及解决办法

1
2
3
4
5
6
7
8
9
10
11
加载不到组件的JS文件
[vue-router] Failed to resolve async component default: Error: Cannot find module 'js\main1.js'
[vue-router] uncaught error during route navigation:
解决办法:
去掉webpack配置中的output.chunkFilename: getFileName(‘js/main[name]-$hash.js’)

if you are using CommonsChunkPlugin, make sure to use it only in the client config because the server bundle requires a single entry chunk.
所以对webpack.server.js不要对配置CommonsChunkPlugin,也不要设置output.chunkFilename

代码高亮codeMirror使用到navigator对象,只能在浏览器环境运行
把执行逻辑放到mounted回调中。实现不行,就封装一个异步组件,把组件的初始化放到mounted中:

mounted () {
let paragraph = require(‘./paragraph.vue’)
Vue.component(‘paragraph’, paragraph)
new Vue().$mount(‘#paragraph’)
},

1
2
3
4
5
串数据
dispatch的action没有返回promise,保证返回promise即可

路由跳转
路由跳转使用router方法或标签,这两种方式能自适应浏览器端和服务端,不要使用a标签

注:本文转自u3xyz,仅供本人学习存档。