ZEROMAKE | keep codeing and thinking!
2017-02-18 | vue

vue-ssr

前言

自从前端技术栈换到 mvvm 之类的以后网站的源码查看就只会有一些 js 了,对于用户是没什么问题但是却对 seo 有很大的问题。

因为百度之类的爬虫不会执行 js 来渲染所以无法得到内容。大部分主流的mvvm框架都有了 ssr(Server Side Rendering) 意为服务端渲染。
不是手游的 ssr,好像暴露了什么

一.ssr 的技术选择

vue 官方的服务端渲染包

使用官方 vue-server-renderer 包装后的脚手架工具,极大的简化了 ssr 的搭建;

但是不仅仅保含 ssr 包括了现有的大部分的 vue 栈,快速简化了 vue 栈项目的搭建;

如果不是有特殊需求,直接使用 nuxt ,以便节省时间特别是不会英文的童鞋 nuxt 官方文档还带支持中文。

二.初始化项目

1
yarn init
2
# node项目初始化
3
yarn add axios compression cross-env es6-promise \
4
express lru-cache serialize-javascript vue vue-router \
5
vuex vuex-router-sync
6
# 安装运行时包根据顺序解释
7
# 1.axios - http 请求工具
8
# 2.compression - express 的 gzip 中间组件
9
# 3.cross-env - 跨平台环境变量设置工具
10
# 4.es6-promise - ie9 的 promise 支持
11
# 5.express - node web 框架
12
# 6.lru-cache - js 的 lru 缓存
13
# 7.serialize-javascript - js 对象序列化为 js 代码
14
# 8.vue - 这个不用说了吧
15
# 9.vue-router - vue 的前端路由通过 ssr 后有后端路由效果
16
# 10.vuex - vue 的状态管理工具 ssr 中前后端同步
17
# 11.vuex-router-sync - 路由同步工具
18
19
yarn add autoprefixer buble buble-loader css-loader \
20
url-loader html-webpack-plugin rimraf stylus \
21
stylus-loader sw-precache-webpack-plugin vue-loader \
22
vue-template-compiler webpack webpack-dev-middleware \
23
webpack-hot-middleware [email protected] --dev
24
# 开发&&打包时包
25
# 1.autiprefixer - css 前缀自动生成
26
# 2.buble - babel 的类似工具以后更换看看会不会有什么影响
27
# 3.buble-loader - 同上
28
# 4.css-loader - css 加载器
29
# 5.url-loader - file-loader 的包装,图片可以以base64导入
30
# 6.html-webpack-plugin - html 的资源加载生成
31
# 7.rimraf - 跨平台的删除工具
32
# 8.stylus - stylus 加载器类似 sass 但是个人不喜欢用 sass 所以用 stylus
33
# 9.stylus-loader - 同上
34
# 10.sw-precache-webpack - service-worker 支持
35
# 11.vue-loader - vue 文件加载器
36
# 12.vue-template-compiler - template to render
37
# 13.webpack - 模块打包工具
38
# 14.webpack-dev-middleware - 监听文件改动
39
# 15.webpack-hot-middleware - 热更新
40
# 16.extract-text-webpack-plugin - 独立生成css

三.项目结构

1
│ package.json
2
│ server.js # server-render
3
│ yarn.lock
4
5
├─build
6
│ setup-dev-server.js # dev 的热生成
7
│ vue-loader.config.js # 独立出 vue-loader 配置以便根据环境改变
8
│ webpack.base.config.js # 基础 webpack 配置
9
│ webpack.client.config.js # 打包 client 的配置
10
│ webpack.server.config.js # 打包 server 的配置
11
12
├─public # 一些静态资源
13
└─src
14
│ app.js # 整合 router,filters,vuex 的入口文件
15
│ App.vue # 顶级 vue 组件
16
│ client-entry.js # client 的入口文件
17
│ index.template.html # html 模板
18
│ server-entry.js # server 的入口文件
19
20
├─components
21
│ Item.vue # 抽取出List中的每个项
22
│ ItemList.vue # List 的 vue 组件
23
│ Spinner.vue # 加载提示
24
25
├─filters
26
│ index.js # filters
27
28
├─router
29
│ index.js # router config
30
31
├─store
32
│ api.js # 数据请求方式
33
│ create-api-client.js # client 数据请求对象的设置
34
│ create-api-server.js # server 数据请求对象的设置
35
│ index.js # vuex 的各种配置
36
37
└─views
38
CreateListView.js # 包装 component 支持 preFetch

四.部分代码分析

我将根据依赖关系来一个个说明,npm 包不说明

1. server.js(就说点重点代码)

require: [‘build/setup-dev-server.js’]

1
// [L16-L48]
2
let indexHTML
3
let renderer
4
if (isProd) {
5
// 生产环境直接读取build生成的文件
6
renderer = createRenderer(fs.readFileSync(resolve('./dist/server-bundle.js'), 'utf-8'))
7
indexHTML = parseIndex(fs.readFileSync(resolve('./dist/index.html'), 'utf-8'))
8
} else {
9
// 开发环境下使用dev-server来通过回调把生成在内存中的文件赋值
10
require('./build/setup-dev-server')(app, {
11
bundleUpdated: bundle => {
12
renderer = createRenderer(bundle)
13
},
14
indexUpdated: index => {
15
indexHTML = parseIndex(index)
16
}
17
})
18
}
19
// 不论生产还是开发环境把server-bundle.js设置到vue-server-renderer获得Renderer装换器对象
20
function createRenderer (bundle) {
21
return require('vue-server-renderer').createBundleRenderer(bundle, {
22
cache: require('lru-cache')({
23
max: 1000,
24
maxAge: 1000 * 60 * 15
25
})
26
})
27
}
28
// 通过目标预设的字符串分割出头部和尾部
29
function parseIndex (template) {
30
const contentMarker = '<!-- APP -->'
31
const i = template.indexOf(contentMarker)
32
return {
33
head: template.slice(0, i),
34
tail: template.slice(i + contentMarker.length)
35
}
36
}// [L60-L78]
37
// 模拟api
38
app.use('/api/topstories.json', serve('./public/api/topstories.json'))
39
app.use('/api/newstories.json', serve('./public/api/newstories.json'))
40
// 模拟了/api/item/xx.json的接口
41
app.get('/api/item/:id.json', (req, res, next) => {
42
const id = req.params.id
43
const time = parseInt(Math.random()*(1487396700-1400000000+1)+1400000000)
44
const item = {
45
by: "zero" + id,
46
descendants: 0,
47
id: id,
48
score: id - 13664000,
49
time: time,
50
title: `测试Item:${id} - ${time}`,
51
type: 'story',
52
url: `/api/item/${id}.json`
53
}res.json(item)
54
})
55
// [L81-L116]
56
app.get('*', (req, res) => {
57
// 防止异步的renderer对象还未生成
58
if (!renderer) {
59
return res.end('waiting for compilation.. refresh in a moment.')
60
}
61
// set header
62
res.setHeader("Context-Type", "text/html")
63
res.setHeader("Server", serverInfo)
64
// 记录时间
65
const s = Date.now()
66
const context = { url: req.url }
67
// 为renderToStream方法传入url,renderToStream会根据url寻找vue-router
68
const renderStream = renderer.renderToStream(context)
69
// 注册data之前事件把index.html的头部写入res
70
renderStream.once('data', () => {
71
res.write(indexHTML.head)
72
})
73
// 注册data中事件直接把ssr的html写出
74
renderStream.on('data', chunk => {
75
res.write(chunk)
76
})
77
// 注册end事件把已经挂载到context的vuex的State,
78
// 通过`serialize-javascript`序列化成js字面量。
79
// 其中挂载到window.__INSTAL_STATE__
80
// 并且返回index.html尾部并结束这个请求
81
// 最后输出这次ssr的时间
82
renderStream.on('end', () => {
83
if (context.initialState) {
84
res.write(
85
`<script>window.__INSTAL_STATE__=${
86
serialize(context.initialState)
87
}</script>`
88
)
89
}
90
res.end(indexHTML.tail)
91
console.log(`whole request: ${Date.now() - s}ms`)
92
})
93
// 错误事件
94
renderStream.on('error', err => {
95
if (err && err.code === '404') {
96
res.status(404).end('404 | Page Not Found')
97
return
98
}
99
res.status(500).end('Internal Error 500')
100
console.error(`error during render : ${req.url}`)
101
console.error(err)
102
})
103
})

2. build/setup-dev-server.js

require: [‘build/webpack.client.config.js’,’build/webpack.server.config.js’]

1
// [L24-L37]
2
// 在client-webpack转换完成时获取devMiddleware的fileSystem。
3
// 读取生成的index.html并通过传入的opt的回调设置到server.js里
4
clientCompiler.plugin("done", () => {
5
const fs = devMiddleware.fileSystem;
6
const filePath = path.join(clientConfig.output.path, "index.html");
7
fs.stat(filePath, (err, stats) => {
8
if (stats && stats.isFile()) {
9
fs.readFile(filePath, "utf-8", (err, data) => {
10
opts.indexUpdated(data);
11
});
12
} else {
13
console.error(err);
14
}
15
});
16
});
17
// [L41-L52]
18
// 通过memory-fs创建一个内存文件系统对象
19
const mfs = new MFS();
20
const outputPath = path.join(
21
serverConfig.output.path,
22
serverConfig.output.filename
23
);
24
// 把server-webpack生成的文件重定向到内存中
25
serverCompiler.outputFileSystem = mfs;
26
// 设置文件重新编译监听并通过opt的回调设置到server.js
27
serverCompiler.watch({}, (err, stats) => {
28
if (err) throw err;
29
stats = stats.toJson();
30
stats.errors.forEach(err => console.error(err));
31
stats.warnings.forEach(err => console.warn(err));
32
mfs.readFile(outputPath, "utf-8", (err, data) => {
33
opts.bundleUpdated(data);
34
});
35
});

3. build/webpack.base.config.js

require: [‘build/vue-loader.config.js’]

因为其它 webpack 配置都依赖这个所以就先说这个

其中 build/vue-loader.config.js 并没有什么对 ssr 有关的内容就不说明了

然后这个配置文件就是很普通的 webpack2 配置文件满地都是就不说了代码

entry 默认是 client, 对 vue-loader 做了 css 插件引入配置对 ssr 没什么用

4. build/webpack.client.config.js

require: [‘build/vue-loader.config.js’, ‘build/webpack.base.config.js’]
webpack_require: [‘src/cilent-entry.js’]

在 resolve 的 alias 设置好 api 为 client 的 js 导入

设置好环境变量

添加 HtmlPlugin 来生成 index.html

剩下的也和 ssr 无关

5. bulid/webpack.client.config.js

require: [‘build/webpack.base.config.js’]
webpack_require: [‘src/server-entry.js’]

1
module.exports = Object.assign({}, base, {
2
// 生成后的运行环境在node
3
target: "node",
4
// 关闭map
5
devtool: false,
6
// 替换到server-entry.js
7
entry: "./src/server-entry.js",
8
// 设置输出文件名与模块导出为commonjs2
9
output: Object.assign({}, base.output, {
10
filename: "server-bundle.js",
11
libraryTarget: "commonjs2"
12
}),
13
// api设置到server的api上
14
resolve: {
15
alias: Object.assign({}, base.resolve.alias, {
16
"create-api": "./create-api-server.js"
17
})
18
},
19
// 不打包运行时依赖,后面这个文件在node中运行
20
externals: Object.keys(require("../package.json").dependencies),
21
// 设置环境变量
22
plugins: [
23
new webpack.DefinePlugin({
24
"process.env.NODE_ENV": JSON.stringify(
25
process.env.NODE_ENV || "development"
26
),
27
"process.env.VUE_ENV": '"server"'
28
})
29
]
30
});

6. src/client-entry.js

webpack_require: [‘src/app.js’]

1
import "es6-promise/auto";
2
import { app, store } from "./app";
3
// 第一次进入页面时获取ssr的state替换上
4
if (window.__INSTAL_STATE__) {
5
store.replaceState(window.__INSTAL_STATE__);
6
}
7
// 把app直接与ssr的html同步
8
app.$mount("#app");
9
// 生产环境优化使用sw缓存
10
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
11
navigator.serviceWorker.register("/service-worker.js");
12
}

7. src/server-entry.js

webpack-require: [‘src/app.js’]

1
import { app, router, store } from "./app";
2
const isDev = process.env.NODE_ENV !== "prodution";
3
// server.js的renderToStream方法会调用这里
4
export default context => {
5
const s = isDev && Date.now();
6
// 使用前端路由切换到请求的url
7
router.push(context.url);
8
// 并获取该路由的所有Component
9
const matchedComponents = router.getMatchedComponents();
10
// 没有Component说明没有路由匹配
11
if (!matchedComponents.length) {
12
return Promise.reject({ code: "404" });
13
}
14
// 使用Promise.all把所有的Component有异步preFetch方法执行
15
return Promise.all(
16
matchedComponents.map(component => {
17
if (component.preFetch) {
18
return component.preFetch(store);
19
}
20
})
21
).then(() => {
22
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`);
23
// 把vuex的state设置到传入的context.initialState上
24
context.initialState = store.state;
25
// 返回已经设置好state, router的app
26
return app;
27
});
28
};

8. src/app.js

webpack-require: [‘src/App.vue’, ‘src/store/index.js’, ‘src/router/index.js’]

1
const app = new Vue({
2
router,
3
store,
4
// 把App.vue的所有对象属性设置到新的根vue上
5
...App
6
});
7
// 导出app,router,store给ssr使用
8
export { app, router, store };

9. src/store/index.js

webpack-require: [‘src/store/api.js’]
没有什么有 ssr 有关的东西,只是 api 请求都在 api.js

10. src/store/api.js

webpack-require: [‘src/store/create-api-(client|server).js’]

1
// [L15-L29]
2
function fetch(child) {
3
const cache = api.cachedItems;
4
// 优化可不做
5
if (cache && cache.has(child)) {
6
return Promise.resolve(cache.get(child));
7
} else {
8
// 获取api数据并设置最后更新时间
9
return new Promise((resolve, reject) => {
10
Axios.get(api.url + child + ".json")
11
.then(res => {
12
const val = res.data;
13
if (val) val.__lastUpdate = Date.now();
14
cache && cache.set(child, val);
15
resolve(val);
16
})
17
.catch(reject);
18
});
19
}
20
}
21
// [L51-L75]
22
export function watchList(type, cb) {
23
let first = true;
24
let isOn = true;
25
let timeoutId = null;
26
const handler = res => {
27
cb(res.data);
28
};
29
// 创建一个无限的定时循环来请求数据
30
function watchTimeout() {
31
if (first) {
32
first = false;
33
} else {
34
Axios.get(`${api.url}${type}stories.json`).then(handler);
35
}
36
if (isOn) {
37
timeoutId = setTimeout(watchTimeout, 1000 * 60 * 15);
38
}
39
}
40
watchTimeout();
41
// 返回一个结束无限定时循环的函数
42
return () => {
43
isOn = false;
44
if (timeoutId) {
45
clearTimeout(timeoutId);
46
}
47
};
48
}

11. src/views/CreateListView.js

webpack-require: [‘src/components/ItemList.vue’]
src/router/index.js 没有什么有和 ssr 有关的直接来到这里

1
// 导出的方法通过参数来重新包装component,
2
// preFetch则是保证ssr时component的data里数据已经完成异步获取。
3
// 如果没有preFetch而是通过vue的生命周期来异步设置则data不会有ssr效果
4
export function createListView (type) {
5
return {
6
name: `${type}-stories-view`,
7
preFetch (store) {
8
return store.dispatch('FETCH_LIST_DATA', { type })
9
},
10
render (h) {
11
return h(ItemList, { props: { type } })
12
}
13
}
14
}

12. src/components/ItemList.vue

1
// [L60-L30]
2
// 在判断root已经挂载说明是路由跳转重新调用loadItems
3
beforeMount () {
4
if (this.$root._isMounted) {
5
this.loadItems(this.page)
6
}
7
// [L80-L94]
8
// 触发vuex设置的动作来请求数据
9
loadItems (to=this.page, from=-1) {
10
this.loading = true
11
this.$store.dispatch('FETCH_LIST_DATA', {
12
type: this.type
13
}).then(() => {
14
if (this.page < 0 || this.page > this.maxPage) {
15
this.$router.replace(`/${this.type}/1`)
16
return
17
}
18
this.transition = from === -1 ? null : to > from ? 'slide-left' : 'slide-right'
19
this.displayedPage = to
20
this.displayedItems = this.$store.getters.activeItems
21
this.loading = false
22
})
23
},

五.文字流程说明

  • node server.js : 设置路由所有请求通过 ssr 生成器
  • server.js -> setup-dev-server.js : dev 时生成 index.html 和 server-bundle.js
  • setup-dev-server.js -> (server|client)-entry.js : 通过 webpack 对入口文件生成
  • client-entry.js : 挂载 ssr 的 vuex 的 state
  • server-entry.js : 通过 url 来 preFetch 设置 vuex 的 state
  • component: 需要有 preFetch 来获取异步数据

六.源码

源码这里