【注意】最后更新于 April 20, 2021,文中内容可能已过时,请谨慎使用。
前言
自从前端技术栈换到 mvvm
之类的以后网站的源码查看就只会有一些 js 了,对于用户是没什么问题但是却对 seo
有很大的问题。
因为百度之类的爬虫不会执行 js 来渲染所以无法得到内容。大部分主流的mvvm
框架都有了 ssr(Server Side Rendering)
意为服务端渲染。
不是手游的 ssr,好像暴露了什么
一.ssr 的技术选择
vue 官方的服务端渲染包
使用官方 vue-server-renderer
包装后的脚手架工具,极大的简化了 ssr
的搭建;
但是不仅仅保含 ssr
包括了现有的大部分的 vue
栈,快速简化了 vue
栈项目的搭建;
如果不是有特殊需求,直接使用 nuxt
,以便节省时间特别是不会英文的童鞋 nuxt
官方文档还带支持中文。
二.初始化项目
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
| yarn init
# node项目初始化
yarn add axios compression cross-env es6-promise \
express lru-cache serialize-javascript vue vue-router \
vuex vuex-router-sync
# 安装运行时包根据顺序解释
# 1.axios - http 请求工具
# 2.compression - express 的 gzip 中间组件
# 3.cross-env - 跨平台环境变量设置工具
# 4.es6-promise - ie9 的 promise 支持
# 5.express - node web 框架
# 6.lru-cache - js 的 lru 缓存
# 7.serialize-javascript - js 对象序列化为 js 代码
# 8.vue - 这个不用说了吧
# 9.vue-router - vue 的前端路由通过 ssr 后有后端路由效果
# 10.vuex - vue 的状态管理工具 ssr 中前后端同步
# 11.vuex-router-sync - 路由同步工具
yarn add autoprefixer buble buble-loader css-loader \
url-loader html-webpack-plugin rimraf stylus \
stylus-loader sw-precache-webpack-plugin vue-loader \
vue-template-compiler webpack webpack-dev-middleware \
webpack-hot-middleware extract-text-webpack-plugin@2.0.0-rc3 --dev
# 开发&&打包时包
# 1.autiprefixer - css 前缀自动生成
# 2.buble - babel 的类似工具以后更换看看会不会有什么影响
# 3.buble-loader - 同上
# 4.css-loader - css 加载器
# 5.url-loader - file-loader 的包装,图片可以以base64导入
# 6.html-webpack-plugin - html 的资源加载生成
# 7.rimraf - 跨平台的删除工具
# 8.stylus - stylus 加载器类似 sass 但是个人不喜欢用 sass 所以用 stylus
# 9.stylus-loader - 同上
# 10.sw-precache-webpack - service-worker 支持
# 11.vue-loader - vue 文件加载器
# 12.vue-template-compiler - template to render
# 13.webpack - 模块打包工具
# 14.webpack-dev-middleware - 监听文件改动
# 15.webpack-hot-middleware - 热更新
# 16.extract-text-webpack-plugin - 独立生成css
|
三.项目结构
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
| │ package.json
│ server.js # server-render
│ yarn.lock
│
├─build
│ setup-dev-server.js # dev 的热生成
│ vue-loader.config.js # 独立出 vue-loader 配置以便根据环境改变
│ webpack.base.config.js # 基础 webpack 配置
│ webpack.client.config.js # 打包 client 的配置
│ webpack.server.config.js # 打包 server 的配置
│
├─public # 一些静态资源
└─src
│ app.js # 整合 router,filters,vuex 的入口文件
│ App.vue # 顶级 vue 组件
│ client-entry.js # client 的入口文件
│ index.template.html # html 模板
│ server-entry.js # server 的入口文件
│
├─components
│ Item.vue # 抽取出List中的每个项
│ ItemList.vue # List 的 vue 组件
│ Spinner.vue # 加载提示
│
├─filters
│ index.js # filters
│
├─router
│ index.js # router config
│
├─store
│ api.js # 数据请求方式
│ create-api-client.js # client 数据请求对象的设置
│ create-api-server.js # server 数据请求对象的设置
│ index.js # vuex 的各种配置
│
└─views
CreateListView.js # 包装 component 支持 preFetch
|
四.部分代码分析
我将根据依赖关系来一个个说明,npm 包不说明
1. server.js(就说点重点代码)
require: [‘build/setup-dev-server.js’]
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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
| // [L16-L48]
let indexHTML
let renderer
if (isProd) {
// 生产环境直接读取build生成的文件
renderer = createRenderer(fs.readFileSync(resolve('./dist/server-bundle.js'), 'utf-8'))
indexHTML = parseIndex(fs.readFileSync(resolve('./dist/index.html'), 'utf-8'))
} else {
// 开发环境下使用dev-server来通过回调把生成在内存中的文件赋值
require('./build/setup-dev-server')(app, {
bundleUpdated: bundle => {
renderer = createRenderer(bundle)
},
indexUpdated: index => {
indexHTML = parseIndex(index)
}
})
}
// 不论生产还是开发环境把server-bundle.js设置到vue-server-renderer获得Renderer装换器对象
function createRenderer (bundle) {
return require('vue-server-renderer').createBundleRenderer(bundle, {
cache: require('lru-cache')({
max: 1000,
maxAge: 1000 * 60 * 15
})
})
}
// 通过目标预设的字符串分割出头部和尾部
function parseIndex (template) {
const contentMarker = '<!-- APP -->'
const i = template.indexOf(contentMarker)
return {
head: template.slice(0, i),
tail: template.slice(i + contentMarker.length)
}
}// [L60-L78]
// 模拟api
app.use('/api/topstories.json', serve('./public/api/topstories.json'))
app.use('/api/newstories.json', serve('./public/api/newstories.json'))
// 模拟了/api/item/xx.json的接口
app.get('/api/item/:id.json', (req, res, next) => {
const id = req.params.id
const time = parseInt(Math.random()*(1487396700-1400000000+1)+1400000000)
const item = {
by: "zero" + id,
descendants: 0,
id: id,
score: id - 13664000,
time: time,
title: `测试Item:${id} - ${time}`,
type: 'story',
url: `/api/item/${id}.json`
}res.json(item)
})
// [L81-L116]
app.get('*', (req, res) => {
// 防止异步的renderer对象还未生成
if (!renderer) {
return res.end('waiting for compilation.. refresh in a moment.')
}
// set header
res.setHeader("Context-Type", "text/html")
res.setHeader("Server", serverInfo)
// 记录时间
const s = Date.now()
const context = { url: req.url }
// 为renderToStream方法传入url,renderToStream会根据url寻找vue-router
const renderStream = renderer.renderToStream(context)
// 注册data之前事件把index.html的头部写入res
renderStream.once('data', () => {
res.write(indexHTML.head)
})
// 注册data中事件直接把ssr的html写出
renderStream.on('data', chunk => {
res.write(chunk)
})
// 注册end事件把已经挂载到context的vuex的State,
// 通过`serialize-javascript`序列化成js字面量。
// 其中挂载到window.__INSTAL_STATE__
// 并且返回index.html尾部并结束这个请求
// 最后输出这次ssr的时间
renderStream.on('end', () => {
if (context.initialState) {
res.write(
`<script>window.__INSTAL_STATE__=${
serialize(context.initialState)
}</script>`
)
}
res.end(indexHTML.tail)
console.log(`whole request: ${Date.now() - s}ms`)
})
// 错误事件
renderStream.on('error', err => {
if (err && err.code === '404') {
res.status(404).end('404 | Page Not Found')
return
}
res.status(500).end('Internal Error 500')
console.error(`error during render : ${req.url}`)
console.error(err)
})
})
|
2. build/setup-dev-server.js
require: [‘build/webpack.client.config.js’,‘build/webpack.server.config.js’]
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
| // [L24-L37]
// 在client-webpack转换完成时获取devMiddleware的fileSystem。
// 读取生成的index.html并通过传入的opt的回调设置到server.js里
clientCompiler.plugin("done", () => {
const fs = devMiddleware.fileSystem;
const filePath = path.join(clientConfig.output.path, "index.html");
fs.stat(filePath, (err, stats) => {
if (stats && stats.isFile()) {
fs.readFile(filePath, "utf-8", (err, data) => {
opts.indexUpdated(data);
});
} else {
console.error(err);
}
});
});
// [L41-L52]
// 通过memory-fs创建一个内存文件系统对象
const mfs = new MFS();
const outputPath = path.join(
serverConfig.output.path,
serverConfig.output.filename
);
// 把server-webpack生成的文件重定向到内存中
serverCompiler.outputFileSystem = mfs;
// 设置文件重新编译监听并通过opt的回调设置到server.js
serverCompiler.watch({}, (err, stats) => {
if (err) throw err;
stats = stats.toJson();
stats.errors.forEach(err => console.error(err));
stats.warnings.forEach(err => console.warn(err));
mfs.readFile(outputPath, "utf-8", (err, data) => {
opts.bundleUpdated(data);
});
});
|
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
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
| module.exports = Object.assign({}, base, {
// 生成后的运行环境在node
target: "node",
// 关闭map
devtool: false,
// 替换到server-entry.js
entry: "./src/server-entry.js",
// 设置输出文件名与模块导出为commonjs2
output: Object.assign({}, base.output, {
filename: "server-bundle.js",
libraryTarget: "commonjs2"
}),
// api设置到server的api上
resolve: {
alias: Object.assign({}, base.resolve.alias, {
"create-api": "./create-api-server.js"
})
},
// 不打包运行时依赖,后面这个文件在node中运行
externals: Object.keys(require("../package.json").dependencies),
// 设置环境变量
plugins: [
new webpack.DefinePlugin({
"process.env.NODE_ENV": JSON.stringify(
process.env.NODE_ENV || "development"
),
"process.env.VUE_ENV": '"server"'
})
]
});
|
6. src/client-entry.js
webpack_require: [‘src/app.js’]
1
2
3
4
5
6
7
8
9
10
11
12
| import "es6-promise/auto";
import { app, store } from "./app";
// 第一次进入页面时获取ssr的state替换上
if (window.__INSTAL_STATE__) {
store.replaceState(window.__INSTAL_STATE__);
}
// 把app直接与ssr的html同步
app.$mount("#app");
// 生产环境优化使用sw缓存
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js");
}
|
7. src/server-entry.js
webpack-require: [‘src/app.js’]
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
| import { app, router, store } from "./app";
const isDev = process.env.NODE_ENV !== "prodution";
// server.js的renderToStream方法会调用这里
export default context => {
const s = isDev && Date.now();
// 使用前端路由切换到请求的url
router.push(context.url);
// 并获取该路由的所有Component
const matchedComponents = router.getMatchedComponents();
// 没有Component说明没有路由匹配
if (!matchedComponents.length) {
return Promise.reject({ code: "404" });
}
// 使用Promise.all把所有的Component有异步preFetch方法执行
return Promise.all(
matchedComponents.map(component => {
if (component.preFetch) {
return component.preFetch(store);
}
})
).then(() => {
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`);
// 把vuex的state设置到传入的context.initialState上
context.initialState = store.state;
// 返回已经设置好state, router的app
return app;
});
};
|
8. src/app.js
webpack-require: [‘src/App.vue’, ‘src/store/index.js’, ‘src/router/index.js’]
1
2
3
4
5
6
7
8
| const app = new Vue({
router,
store,
// 把App.vue的所有对象属性设置到新的根vue上
...App
});
// 导出app,router,store给ssr使用
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
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
| // [L15-L29]
function fetch(child) {
const cache = api.cachedItems;
// 优化可不做
if (cache && cache.has(child)) {
return Promise.resolve(cache.get(child));
} else {
// 获取api数据并设置最后更新时间
return new Promise((resolve, reject) => {
Axios.get(api.url + child + ".json")
.then(res => {
const val = res.data;
if (val) val.__lastUpdate = Date.now();
cache && cache.set(child, val);
resolve(val);
})
.catch(reject);
});
}
}
// [L51-L75]
export function watchList(type, cb) {
let first = true;
let isOn = true;
let timeoutId = null;
const handler = res => {
cb(res.data);
};
// 创建一个无限的定时循环来请求数据
function watchTimeout() {
if (first) {
first = false;
} else {
Axios.get(`${api.url}${type}stories.json`).then(handler);
}
if (isOn) {
timeoutId = setTimeout(watchTimeout, 1000 * 60 * 15);
}
}
watchTimeout();
// 返回一个结束无限定时循环的函数
return () => {
isOn = false;
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}
|
11. src/views/CreateListView.js
webpack-require: [‘src/components/ItemList.vue’]
src/router/index.js 没有什么有和 ssr 有关的直接来到这里
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // 导出的方法通过参数来重新包装component,
// preFetch则是保证ssr时component的data里数据已经完成异步获取。
// 如果没有preFetch而是通过vue的生命周期来异步设置则data不会有ssr效果
export function createListView (type) {
return {
name: `${type}-stories-view`,
preFetch (store) {
return store.dispatch('FETCH_LIST_DATA', { type })
},
render (h) {
return h(ItemList, { props: { type } })
}
}
}
|
12. src/components/ItemList.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // [L60-L30]
// 在判断root已经挂载说明是路由跳转重新调用loadItems
beforeMount () {
if (this.$root._isMounted) {
this.loadItems(this.page)
}
// [L80-L94]
// 触发vuex设置的动作来请求数据
loadItems (to=this.page, from=-1) {
this.loading = true
this.$store.dispatch('FETCH_LIST_DATA', {
type: this.type
}).then(() => {
if (this.page < 0 || this.page > this.maxPage) {
this.$router.replace(`/${this.type}/1`)
return
}
this.transition = from === -1 ? null : to > from ? 'slide-left' : 'slide-right'
this.displayedPage = to
this.displayedItems = this.$store.getters.activeItems
this.loading = false
})
},
|
五.文字流程说明
- 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 来获取异步数据
六.源码
源码这里
文章作者
上次更新
2021-04-20 23:17:11 +08:00
(0eef502)
许可协议
CC BY-NC-ND 4.0