Skip to content

Commit 29511ce

Browse files
committed
new file: _posts/2017-10-28-client-rendering.md
new file: _posts/2017-10-30-cli-testing.md
1 parent 1b107b7 commit 29511ce

File tree

3 files changed

+212
-3
lines changed

3 files changed

+212
-3
lines changed

_posts/2017-10-28-client-rendering.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
title: 客户端渲染有哪些坑?
3+
tags: MVVM 异步渲染 路由 兼容性 pushState popstate
4+
---
5+
6+
从别的角度出发,客户端渲染有很多其他名字比如前端渲染、前端异步、前端 MVC。
7+
今天的 Web 稍有交互的站点都会做一套前端渲染,从早期的 Backbone,AngularJS 1.0,
8+
到现在的流行的 Vue,React。基于这些技术做 MVVM 的同时甚至可以完成服务器端渲染。
9+
但浏览器的客户端渲染(也就是前端 MVC)仍然存在不少限制,这些限制都是前端渲染绕不过的问题。
10+
11+
<!--more-->
12+
13+
# 概述
14+
15+
一个支持客户端渲染的技术架构应当包括这些内容:
16+
17+
* 首屏渲染。服务器端渲染首屏,或者客户端根据当前 URL 渲染对应的页面。
18+
* 前端路由。点击链接时改变页面的 URL,返回/前进时渲染对应页面。
19+
* 异步渲染。在不发生整页重新载入的情况下更新页面内容。
20+
21+
下文中,我们用 [同步渲染][sync] 指代浏览器直接载入 HTML 及其中的资源;用 [异步渲染][async] 指通过 DOM API 去动态插入元素和资源。
22+
23+
# 共享同一个后端服务
24+
25+
**结论**:异步打开的所有 URL 都必须在同一服务器,或者这些服务器都知道所有 URL 对应的页面信息。
26+
27+
**场景**:打开页面A -> 跳转到页面 B -> 刷新 -> 返回到页面 A。考虑如何渲染页面 A?
28+
29+
对于传统的同步渲染,构成 Web 站点的页面之间不需共享任何信息,一个链接就足够了。
30+
但对于一部打开的页面,没有对方页面的信息就意味着无法异步渲染。
31+
32+
在同构渲染流行之前,通常的实现是让服务器对所有页面都返回同样的内容:框架页面。
33+
前端路由会根据页面 URL (最初还是使用 hash 部分)从这个框架页面渲染出真正的页面。
34+
因此那个框架页面知道所有可能的页面如何渲染。
35+
36+
显然上述实现搜索引擎不友好,首屏时间延迟。同构渲染技术使得服务器可以直接渲染用户请求的那个页面,而不需要先在客户端启动 MVC 框架。但即使是同构渲染,仍然要求一个服务器知道所有可能会异步打开的页面。
37+
38+
> 这一限制从某个角度上理解是反 Web 的。Web 是开放互联的同时服务器之间完全独立,正是这一点让 Web 变得 Scalable。客户端渲染让一组页面对应同一个后端服务,它们组成一个前端 App。
39+
40+
# 脚本要符合异步风格
41+
42+
**结论**:所有页面的脚本都必须无副作用、不依赖 `<script>` 顺序。
43+
44+
**场景**:两个页面间异步切换时,对应脚本能够多次执行和卸载。
45+
46+
同步渲染中,所有全局变量、定时器、事件监听器会在一个 Reload 后完全消失,而异步页面则不然。
47+
如果一个脚本依赖于(读写)全局变量,那么多次载入后它的行为可能会发生异常。
48+
比如一个交互统计脚本多次载入后可能会重复计数,因为它每次载入都产生一个事件监听器。
49+
50+
如果一个异步页面有多个 `<script>`,在 [异步渲染时脚本的执行顺序][dynamic-script] 是不保证的。
51+
这一点与 [浏览器同步渲染][static-render] 完全不同。
52+
因此可能需要类似 [RequireJS][req] 之类的模块加载器。
53+
54+
为了解决上述问题,多数前端 MVVM 框架都不建议直接在 HTML 中插入 `<script>` 来编写业务代码。
55+
与此相反,会提供类似 [模块][ng-module][组件][comp] 之类的概念来托管脚本的执行。
56+
57+
# PushState API 不完善
58+
59+
**结论**:浏览器的路由相关 API 能力较弱且存在兼容性问题。
60+
61+
**场景**:在用户点击链接时,需要操作 URL;在用户点击浏览器返回/前进时,需要渲染页面。
62+
63+
HTML5 中定义了 pushState API,包括 [pushState 方法][pushState][replaceState 方法][replaceState][popstate 事件][popstate]
64+
我们不谈这些 API 的设计,只看它们的奇怪行为:
65+
66+
* [同步渲染的页面资源][static-render] 加载会延迟 `popstate` 事件。这使得页面未加载完时可以点出但无法返回。
67+
* `pushState` 调用不会触发 `popstate` 事件。通常需要一个路由工具来包装这些不一致。
68+
* [PopStateEvent.state][popstate-event] 总是等于 `history.state`。无法获取被 pop 出的 state。
69+
* `popstate` 事件处理函数中无法区分是前进还是后退。考虑刷新页面的场景不能只存储为变量,只能存储在 [`sessionStorage`][local-store] 中,但这无疑会增加路由的延迟。
70+
* 有些浏览器不支持 `history.state`,但支持 `pushState``popstate`
71+
72+
[static-render]: /2016/11/26/static-dom-render-blocking.html
73+
[sync]: /2016/11/26/static-dom-render-blocking.html
74+
[async]: /2016/11/26/dynamic-dom-render-blocking.html
75+
[dynamic-script]: /2017/01/16/dynamic-script-insertion.html
76+
[req]: http://requirejs.org/
77+
[ng-module]: https://angular.io/api/core/NgModule
78+
[comp]: https://reactjs.org/docs/react-component.html
79+
[static-render]: /2016/11/26/static-dom-render-blocking.html
80+
[pushState]: https://developer.mozilla.org/zh-CN/docs/Web/API/History/pushState
81+
[popstate]: https://developer.mozilla.org/zh-CN/docs/Web/Events/popstate
82+
[replaceState]: https://developer.mozilla.org/zh-CN/docs/Web/API/History/replaceState
83+
[popstate-event]: https://developer.mozilla.org/zh-CN/docs/Web/API/PopStateEvent
84+
[local-store]: /2015/08/16/localstorage-sessionstorage-cookie.html

_posts/2017-10-30-cli-testing.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
title: CLI 测试:文件与标准输出
3+
tags: BDD Mocha Node.js 测试 Ramdisk
4+
---
5+
6+
[利用 Mocha 进行 BDD 风格测试](/2016/06/23/mocha-chai-bdd.html) 中介绍了
7+
Node.js 下如何做单元测试,要确保软件的质量我们还需要 e2e 测试,
8+
确保整个系统在真实场景下能够正常工作。
9+
10+
> End-to-end testing involves ensuring that the integrated components of an application function as expected. The entire application is tested in a real-world scenario such as communicating with the database, network, hardware and other applications. -- [techopedia][techopedia]
11+
12+
本文 Node.js 的命令行程序(CLI)为例,介绍如何测试一个可执行程序的输入输出以及文件修改。
13+
样例代码可参考 [APM 的 e2e 测试代码][apm]
14+
15+
<!--more-->
16+
17+
# 输入输出
18+
19+
测试一个 cli 程序的输入输出很容易,几乎不需要借助任何工具。
20+
使用内置的 `child_process` 模块就足够了:
21+
22+
```javascript
23+
import {exec} from 'child_process'
24+
25+
it('should print author', function (done) {
26+
exec('./bin/cli --author',
27+
(err, stdout, stderr) => err ? done(err) : expect(stdout).to.equal('http://harttle.com')
28+
)
29+
})
30+
```
31+
32+
如果希望注入环境变量,修改要执行的命令即可:
33+
34+
```javascript
35+
exec('PORT=8080 ./bin/cli --start')
36+
```
37+
38+
为了测试代码更加可读,可以引入 `chai-as-promise`。上述测试项可以重写为:
39+
40+
```javascript
41+
import Promise from 'bluebird'
42+
43+
it('should print author', function () {
44+
var p = Promise.fromCallback(cb => exec('./bin/cli --author', cb))
45+
return expect(p).to.eventually.equal('http://harttle.com')
46+
})
47+
```
48+
49+
> 更多细节请参考:[Mocha 下测试异步代码](/2016/07/12/async-test-with-chai-as-promised.html) 一文。
50+
51+
# 文件系统
52+
53+
在单元测试中,我们可以 [修改内置的 `fs` 模块][mock-fs] 达到测试的目的。
54+
其实文件并没有真正被读写,只是测试了调用读写的逻辑是否正确。
55+
但 e2e 测试中命令行程序在不同的进程启动,无法修改其中的 `fs` 模块。
56+
当然你可以 `require` 进来去执行,但那就不是 e2e 测试了。
57+
58+
在此 [Harttle](http://harttle.com) 介绍两种方式来初始化用于测试的工作区。
59+
60+
## 临时目录
61+
62+
如果文件读写较少,可以在测试开始前初始化一个临时目录,测试完成时删除。
63+
使用 [fs-extra][fs-extra] 会让这个工作变得非常简单:
64+
65+
```javascript
66+
import fs from 'fs-extra'
67+
import os from 'os'
68+
69+
beforeEach(() => fs.emptyDir(os.tmpdir() + '/test'))
70+
```
71+
72+
## Ramdisk
73+
74+
也可以把一部分内存挂载到文件系统,有大量文件读写时比较快,同时可以保护 SSD(是这样吗?)。
75+
Node 下有一个不错的 [node-ramdisk][ramdisk] 工具来创建内存虚拟磁盘。
76+
77+
```javascript
78+
const ramdisk = require('node-ramdisk')
79+
var mountpoint
80+
var disk
81+
82+
before(done => {
83+
disk = ramdisk(this.dirname)
84+
disk.create(10, function (err, mount) { // 创建 10MB 的虚拟磁盘
85+
mountpoint = mount
86+
})
87+
})
88+
after(done => disk.delete(mountpoint, cb))
89+
```
90+
91+
创建 ramdisk 系统调用比较耗时,建议在 `before()` 时完成,`beforeEach()` 时清空目录。
92+
此外,因为设备根目录存在权限较高的隐藏文件,建议在 `mountpoint` 下创建一个子目录来进行测试。
93+
94+
# 网络
95+
96+
在单元测试中可以使用 [Sinon fakeXHR][fakeXHR][nock][nock] 来 Mock 掉 http 模块,
97+
但 e2e 测试中则需要启动真实的服务器。
98+
99+
```javascript
100+
var server
101+
102+
before(done => {
103+
server = http.createServer(requestHandler)
104+
server.listen(8080, done))
105+
})
106+
107+
after(done => server.close(done))
108+
109+
function requestHandler (req, res) {
110+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
111+
res.end('ok')
112+
}
113+
```
114+
115+
由于不同机器的环境可能不同,端口应该在置默认值的同时读取环境变量。
116+
如果只需要静态文件服务,可以直接引入 [http-server][http-server] 模块。
117+
118+
[mock-fs]: /2016/08/01/javascript-mock-fs.html
119+
[techopedia]: https://www.techopedia.com/definition/7035/end-to-end-test
120+
[apm]: https://github.com/apmjs/apmjs/tree/master/test/e2e
121+
[fs-extra]: https://github.com/jprichardson/node-fs-extra
122+
[ramdisk]: https://www.npmjs.com/package/node-ramdisk
123+
[fakeXHR]: http://sinonjs.org/releases/v4.0.2/fake-xhr-and-server/
124+
[nock]: https://github.com/node-nock/nock
125+
[http-server]: https://github.com/indexzero/http-server

0 commit comments

Comments
 (0)