vue-hackernews-2.0 源码解读(适合入门)(一)

HackerNews 是基于 HN 的官方 firebase API 、Vue 2.0 、vue-router 和 vuex 来构建的,使用服务器端渲染。

vue-hackernews 项目,是尤大神在 Vue-SSR 官方文档所展示的范例 DEMO.涉及知识点及技术栈非常全面,对于初学者来说,直接阅读该项目,极具挑战。那么我就来一句一句的解析,看看到底都是啥.

本文借鉴了 osan 的文章(https://wangfuda.github.io/2017/05/14/vue-hackernews-2.0-code-explain/),加上本人自己的理解,从菜鸡的角度来看,添加一些理解.

牢骚

去了一家公司面试,问了半天技术,答基础不错(因为我没有工作经验).回复说我司非常依赖 SSR,对 SEO 要求很高,搜索引擎排名必须第一,要求我必须尽快做出一份 demo 出来满足他们的最低要求.那么问题来了,我对 SSR 基本只是一知半解,怎么用完全属于白板啊.当时周五,那么只剩周六日两天了,也就是说,两天从不知 SSR 为何物到搞出一个 demo.压力好大.回去路上就开始上网查文档.我一看,这是啥,这又是啥.完全不知所云啊.

无奈看看有没有入门教程吧,一搜,好少啊,看看掘金,挺多的,挨个看一遍,为什么都在 copy 官方文档啊,把各个组件,方法讲讲都是干嘛的也行啊,根本没讲,copy 完官方文档,直接甩一个 demo.我:what?

这真是应验了官方指南的话,

本指南将会非常深入,并且假设你已经熟悉 Vue.js 本身,并且具有 Node.js 和 webpack 的相当不错的应用经验。

我这完全不熟悉 node 和 webpack 的人完全就是抓瞎啊.

好吧,经过不断努(bao)力(gan),终于搞出了一个 demo,人家说技术不过关,期待下次合作.wtf?

也在意料之中了,有老手为什么还要菜鸡呢?

不过这技术不能扔了,我得记下来,以警示后人…

结构概览

项目结构图上显示,有两个入口文件,entry-server.js 和 entry-client.js, 分别是服务端渲染和客户端渲染的实现入口,webpack 将两个入口文件分别打包成给服务端用的 server bundle 和给客户端用的 client bundle.

服务端:当 Node Server 收到来自 Browser 的请求后,会创建一个 Vue 渲染器 BundleRenderer,这个 bundleRenderer 会读取上面生成的 server bundle 文件(即 entry-server.js),并且执行它,而 server bundle 实现了数据预取并返回已填充数据的 Vue 实例,接下来 Vue 渲染器内部就会将 Vue 实例渲染进 html 模板,最后把这个完整的 html 发送到浏览器。

客户端:Browser 收到 HTML 后,客户端加载了 client bundle(即 entry-client.js) ,通过 app.$mount(‘#app’)挂载 Vue 实例到服务端渲染的 DOM 上,并会和服务端渲染的 HTML 进行 Hydration(合并).

大体操作

目录概览

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
│  manifest.json				# progressive web apps配置文件
│ package.json # 项目配置文件
│ server.js # 服务端渲染

├─public # 静态资源
│ logo-120.png
│ logo-144.png
│ logo-152.png
│ logo-192.png
│ logo-384.png
│ logo-48.png

└─src
│ app.js # 整合 router,filters,vuex 的入口文件
App.vue # 根 vue 组件
│ entry-client.js # client 的入口文件
│ entry-server.js # server 的入口文件
│ index.template.html # html 模板

├─api
│ create-api-client.js # Client数据源配置
│ create-api-server.js # server数据源配置
│ index.js # 数据请求API

├─components
Comment.vue # 评论组件
Item.vue #
ProgressBar.vue # 进度条组件
Spinner.vue # 加载提示组件

├─router
│ index.js # router配置

├─store # Vue store模块
│ actions.js # 根级别的 action
│ getters.js # 属性接口
│ index.js # 我们组装模块并导出 store 的地方
│ mutations.js # 根级别的 mutation

├─util
│ filters.js # 过滤器
│ title.js # 工具类

└─views
CreateListView.js # 动态生成列表界面的工厂方法
ItemList.vue # List界面组件
ItemView.vue # 单List项组件
UserView.vue # 用户界面组件

开发环境的服务端渲染流程

1
2
# serve in dev mode, with hot reload at localhost:8080
$npm run dev

看看发生了什么

上述执行 dev 属性对应的脚本:node server 即 node server.js,即执行 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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
//server.js
const fs = require("fs");
const path = require("path");
const LRU = require("lru-cache");
const express = require("express");
const favicon = require("serve-favicon");
const compression = require("compression");
const microcache = require("route-cache");
const resolve = (file) => path.resolve(__dirname, file);
const { createBundleRenderer } = require("vue-server-renderer");

//设置生产环境
const isProd = process.env.NODE_ENV === "production";
//使用微缓存
const useMicroCache = process.env.MICRO_CACHE !== "false";
//服务端信息
const serverInfo =
`express/${require("express/package.json").version} ` +
`vue-server-renderer/${require("vue-server-renderer/package.json").version}`;

//调用expess
const app = express();

//创建渲染器
function createRenderer(bundle, options) {
// 调用vue-server-renderer的createBundleRenderer方法创建渲染器,
//并设置HTML模板,以后后续将服务端预取的数据填充至模板中
return createBundleRenderer(
bundle,
Object.assign(options, {
// for component caching
//设置一个缓存
cache: LRU({
max: 1000,
maxAge: 1000 * 60 * 15,
}),
// this is only needed when vue-server-renderer is npm-linked
basedir: resolve("./dist"),
// recommended for performance
runInNewContext: false,
})
);
}

let renderer;
let readyPromise;
//模板路径
const templatePath = resolve("./src/index.template.html");
if (isProd) {
//生产环境:
//webpack结合vue-ssr-webpack-plugin插件生成的server bundle
//服务端渲染的HTML模板
const template = fs.readFileSync(templatePath, "utf-8");
//生产环境的时候这里已经打包好了这个json文件可以直接调用
const bundle = require("./dist/vue-ssr-server-bundle.json");

//client manifests是可选项,允许渲染器自动预加载,渲染添加<script>标签

// The client manifests are optional, but it allows the renderer
// to automatically infer preload/prefetch links and directly add <script>
// tags for any async chunks used during render, avoiding waterfall requests.
const clientManifest = require("./dist/vue-ssr-client-manifest.json");
//vue-server-renderer创建bundle渲染器并绑定server bundle
renderer = createRenderer(bundle, {
template,
clientManifest,
});
} else {
// 开发环境下,使用dev-server来通过回调把生成在内存中的bundle文件传回
// 通过dev server的webpack-dev-middleware和webpack-hot-middleware实现客户端代码的热更新
//以及通过webpack的watch功能实现服务端代码的热更新

// In development: setup the dev server with watch and hot-reload,
// and create a new renderer on bundle / index template update.
readyPromise = require("./build/setup-dev-server")(
app,
templatePath,
(bundle, options) => {
// 基于热更新,回调生成最新的bundle渲染器
renderer = createRenderer(bundle, options);
}
);
}

//静态缓存时间
const serve = (path, cache) =>
express.static(resolve(path), {
maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0,
});
//依次装载一系列Express中间件,用来处理静态资源,数据压缩等
app.use(compression({ threshold: 0 }));
app.use(favicon("./public/logo-48.png"));
app.use("/dist", serve("./dist", true));
app.use("/public", serve("./public", true));
app.use("/manifest.json", serve("./manifest.json", true));
app.use("/service-worker.js", serve("./dist/service-worker.js"));

// since this app has no user-specific content, every page is micro-cacheable.
// if your app involves user-specific content, you need to implement custom
// logic to determine whether a request is cacheable based on its url and
// headers.
// 1-second microcache.
// https://www.nginx.com/blog/benefits-of-microcaching-nginx/

//设置微缓存
app.use(microcache.cacheSeconds(1, (req) => useMicroCache && req.originalUrl));

function render(req, res) {
const s = Date.now();

res.setHeader("Content-Type", "text/html");
res.setHeader("Server", serverInfo);

const handleError = (err) => {
if (err.url) {
res.redirect(err.url);
} else if (err.code === 404) {
res.status(404).send("404 | Page Not Found");
} else {
// Render Error Page or Redirect
res.status(500).send("500 | Internal Server Error");
console.error(`error during render : ${req.url}`);
console.error(err.stack);
}
};
// 设置请求的url
const context = {
title: "Vue HN 2.0", // default title
url: req.url,
};

// 为渲染器绑定的server bundle(即entry-server.js)设置入参context
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err);
}
res.send(html);
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`);
}
});
}
//启动一个服务并监听从 8080 端口进入的所有连接请求。
app.get(
"*",
isProd
? render
: (req, res) => {
readyPromise.then(() => render(req, res));
}
);

const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`server started at localhost:${port}`);
});

server.js 最终监听 8080 端口等待处理客户端请求,此时在浏览器访问 localhost:8080

请求经由 express 路由接收后,执行处理逻辑:readyPromise.then(() => render(req, res))

沿着 Promise 的调用链处理:

开发环境下 1.调用 setup-dev-server.js 模块,根据上图中 webpack config 文件实现入口文件打包,热替换功能实现。

最终通过回调把生成在内存中的 server bundle 传回。 2.创建渲染器,绑定 server bundle,设置渲染模板,缓存等 3.依次装载一系列 Express 中间件,用来处理静态资源,数据压缩等 4.最后将渲染好的 HTML 写入 http 响应体,传回浏览器。

setup-dev-server

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
104
105
106
107
// setup-dev-server.js
const fs = require("fs");
const path = require("path");
const MFS = require("memory-fs");
const webpack = require("webpack");
const chokidar = require("chokidar");
const clientConfig = require("./webpack.client.config");
const serverConfig = require("./webpack.server.config");

//读取文件
const readFile = (fs, file) => {
try {
//fs读取客户端输出路径
return fs.readFileSync(path.join(clientConfig.output.path, file), "utf-8");
} catch (e) {}
};

//输出模块,创建开发服务
module.exports = function setupDevServer(app, templatePath, cb) {
let bundle;
let template;
let clientManifest;

let ready;
const readyPromise = new Promise((r) => {
ready = r;
});
//设定更新
const update = () => {
if (bundle && clientManifest) {
ready();
cb(bundle, {
template,
clientManifest,
});
}
};

//读取本地模板并观测

// read template from disk and watch
template = fs.readFileSync(templatePath, "utf-8");
chokidar.watch(templatePath).on("change", () => {
template = fs.readFileSync(templatePath, "utf-8");
console.log("index.html template updated.");
update();
});

//客户端热加载服务
// modify client config to work with hot middleware
clientConfig.entry.app = [
"webpack-hot-middleware/client",
clientConfig.entry.app,
];
clientConfig.output.filename = "[name].js";
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
);

//开发中间件
// dev middleware
const clientCompiler = webpack(clientConfig);
const devMiddleware = require("webpack-dev-middleware")(clientCompiler, {
publicPath: clientConfig.output.publicPath,
noInfo: true,
});
app.use(devMiddleware);
// 在client webpack结合vue-ssr-webpack-plugin完成编译后,获取devMiddleware的fileSystem
// 读取内存中的bundle 并通过传入的回调更新server.js中的bundle
clientCompiler.plugin("done", (stats) => {
stats = stats.toJson();
stats.errors.forEach((err) => console.error(err));
stats.warnings.forEach((err) => console.warn(err));
if (stats.errors.length) return;
clientManifest = JSON.parse(
readFile(devMiddleware.fileSystem, "vue-ssr-client-manifest.json")
);
update();
});

//中间件热加载
// hot middleware
app.use(
require("webpack-hot-middleware")(clientCompiler, { heartbeat: 5000 })
);

//观测更新服务渲染
// watch and update server renderer
const serverCompiler = webpack(serverConfig);
// 获取基于memory-fs创建的内存文件系统对象
const mfs = new MFS();
serverCompiler.outputFileSystem = mfs;
// 设置文件重新编译监听并通过传入的回调更新server.js中的bundle
serverCompiler.watch({}, (err, stats) => {
if (err) throw err;
stats = stats.toJson();
if (stats.errors.length) return;

//读取bundle文件
// read bundle generated by vue-ssr-webpack-plugin
bundle = JSON.parse(readFile(mfs, "vue-ssr-server-bundle.json"));
update();
});

return readyPromise;
};
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
// build/webpack.base.config.js
const path = require("path");
const webpack = require("webpack");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const FriendlyErrorsPlugin = require("friendly-errors-webpack-plugin");
const { VueLoaderPlugin } = require("vue-loader");
//生产环境
const isProd = process.env.NODE_ENV === "production";

module.exports = {
//开发环境搞这些
// 开发环境下,开启代码调试map,方便调试断点时代码寻址,推荐模式选择:cheap-module-source-map
devtool: isProd ? false : "#cheap-module-source-map",
// 打包输出配置
output: {
path: path.resolve(__dirname, "../dist"),
publicPath: "/dist/",
filename: "[name].[chunkhash].js",
},
resolve: {
alias: {
public: path.resolve(__dirname, "../public"),
},
},
//解析模块,各种加载器
module: {
noParse: /es6-promise\.js$/, // avoid webpack shimming process
rules: [
{
test: /\.vue$/,
loader: "vue-loader",
options: {
compilerOptions: {
preserveWhitespace: false,
},
},
},
{
test: /\.js$/,
loader: "babel-loader",
exclude: /node_modules/,
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: "url-loader",
options: {
limit: 10000,
name: "[name].[ext]?[hash]",
},
},
{
test: /\.styl(us)?$/,
use: isProd
? ExtractTextPlugin.extract({
use: [
{
loader: "css-loader",
options: { minimize: true },
},
"stylus-loader",
],
fallback: "vue-style-loader",
})
: ["vue-style-loader", "css-loader", "stylus-loader"],
},
],
},
performance: {
hints: false,
},
//生产环境搞这些
plugins: isProd
? [
new VueLoaderPlugin(),
// 压缩js的插件
new webpack.optimize.UglifyJsPlugin({
compress: { warnings: false },
}),
new webpack.optimize.ModuleConcatenationPlugin(),
// 从bundle中提取出特定的text到一个文件中,可以把css从js中独立抽离出来
new ExtractTextPlugin({
filename: "common.[chunkhash].css",
}),
]
: [new VueLoaderPlugin(), new FriendlyErrorsPlugin()],
};

创建渲染器

就是 server.js 里这一步

1
2
3
4
5
6
7
8
9
function createRenderer (bundle, options) {
// https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer
console.log(`createRenderer`)
return createBundleRenderer(bundle, Object.assign(options, {
template,

···
}))
}

创建渲染器时重点两件事:

1.绑定渲染用的 server bundle 至渲染器,这个 bundle 是在 setup-dev-server.js 中将服务端入口文件 entry-server.js 打包生成的。

当渲染器调用 renderer.renderToString 开始渲染时,会执行该入口文件的默认方法。

2.传入了一个 html 模板 index.template.html,这个模板稍后在服务端渲染时就会动态填充预取数据到模板中。

顺着 readyPromise.then 的调用链,接下来调用 render 方法

1
2
3
4
5
6
function render (req, res) {
···
renderer.renderToString(context, (err, html) => {
res.end(html)
})
}

renderer.renderToString 方法内部会先调用入口模块 entry-server.js 的默认方法.

下节再叙