说起来是游记,但以流水账为主,谨记念第一次长途骑行和观光游

去海南的主意不是我出的,最初我和C是打算去些更远的地方骑行。那还是2016年的下半年的事情,C在学苑超市偶遇我,突然说起要不要去台湾骑行。对于长期只在北京市里转来转去的我来说,实在太有诱惑力,于是我俩一拍即合。只是那会儿我既没实习更没工作,长途骑行也只能是计划。

这一计划就是一年多,转眼就是17年的12月,时机成熟,我叫上凯哥。我们最终定下了5天的行程,从海口到三亚,中间在文昌、博鳌、兴隆和海棠湾停靠(这也是经典的环岛东线)。整个5天除了第1天,每天都有很贴近海边的路,风景还是不错的。路上遇到了蛮多骑友,他们来自不同的地方,有的甚至是从甘肃、辽宁这种地方骑过来的。除了晒伤和胳膊肘的轻伤外,这次难得的旅行还留下了下面这些经历。

下图是我在首都机场候机的间隙拍的,窗外那架海航的波音747就是我们要乘坐的那架。

AhhMDA.jpg

我们到海口时已经是晚上了,第一晚住在租车的517驿站,环境比事先设想的要差劲一些,所幸也只住这一晚。租车的人并不是很多,听老板说寒假的时候是旺季,可能是都来避寒了吧。我们的行程是5天,刚好满足一个5日套餐,学生证还可以8.8折,可以说相当划算了。

Ahh1Et.jpg

Day1: 海口 ——> 龙楼

第一天是整个行程里相对比较无趣的,并看不到海。路书里甚至有一条没法骑行的小路。最后从小路绕上213省道时,我们三个都累坏了。若没有凯哥的麦丽素,后果恐怕难以设想。

AhhnjH.jpg

在大致坡镇休息时,C瞥见了一家叫“琼海炒冰清补凉”的店,作为曾经在广东上了4年学的C,竟然也没断清楚句。拜这好奇所赐,我尝到了一道极好的甜点。

这里的清补凉以椰奶为底,炒冰为料,辅以红豆、绿豆、薏米、椰果、葡萄干、莲子、仙草冻等。入口冰爽,糖水的味道清而不腻,实在美味。要是夏天品尝,应该更是大大提升幸福感。(右边的菜汤请忽略)

AhhQHI.jpg

另外,由于我们三个的速度有所差距,为了避免走失的情况。在大致坡时,我们威逼利诱凯哥下载了行者,加入了我们“海南养生骑行群”,从而能共享队友的位置。这在后面几天派上了大用场。

海南的人们大多比较慵懒,穿着拖鞋。很少见到穿着其他鞋的。海南的摩托和电动车非常多,十字路口过街的车流相当壮观。

Day2: 龙楼 ——> 博鳌

在龙楼歇了一夜后,我们向博鳌出发。沿着213省道骑行十几公里后左拐,东郊椰林中成片椰树的画面就出现在面前。在后面的几天骑行里,我们逐渐发现椰树似乎是这边主要的木本植物。

AhhKud.jpg

现在似乎是淡季,这边的野海滩几乎没有人。灰暗的天空营造出一种凄凉的既视感。

Ahh84f.jpg

离开东郊椰林后,我们坐船过江来到文昌市内,码头边停着许多渔船,散发着浓重的腥味。现在可能是休渔期,船上没看到什么人。

AhbZX8.jpg

我们在文昌市吃过午饭,从海边延乡道绕回S213省道上,半路下起了小雨。不过不影响兴致,反倒驱散了骑车的燥热。等再次骑上县道时,离博鳌已经不远了。

Ahh3UP.jpg

这边的鸡、牛、猪的养殖方式很有特点。在路的两边,很容易发现它们没人看管,自由踱步地觅食。有的甚至直接走上公路。

AhhY8S.jpg

C的估计真的非常准,我和他准时在下午5点半到达酒店(凯哥要慢一点)。我们晚上去了一家叫“海的故事”的酒吧,它建在海边,夜晚的海浪咆哮着涌上海岸,海风也比文昌那里凶猛些。享受着海风拂面的同时,我和C进行了一番恋爱观的探讨,然而都是纸上谈兵。

AhhJC8.jpg

Day3: 博鳌 ——> 兴隆

海南的东北人很多,新建的楼盘也很多,当然同样没人住的楼盘也挺多。

Ahhauj.jpg

博鳌附近的省道修得极好,很适合骑车。不知道是不是因为亚洲论坛的缘由。

AhhNvQ.jpg

在到达万宁市稍作休整后,我们转向海边的旅游公路进发,“旅游公路”这名字名副其实,海边的风景很不错。

Ahhtgg.jpg

路况同样很棒。

AhhdDs.jpg

在桥上还能看到某小河的入海口。

Ahhwbn.jpg

AhhBEq.jpg

晚上5点多时,我们很幸运地赶上了夕阳,旅游公路上的远眺美不胜收。

AhhDU0.jpg

Ahhr5V.jpg

AhhyCT.jpg

由于拍照耽误了些时间,我们到达兴隆时已经比较晚了。吃了顿正宗的海(dong)南(bei)饺子,便结束了一天的行程。并没有泡到兴隆温泉。

Day4: 万宁 ——> 三亚海棠湾

海南的丘陵比较多,一路上很多上上下下的起伏。不过,真正意义上的爬坡出现在万宁和陵水的分界岭,是一个海拔100多米的爬坡。在下坡的路上,还看见了一群5、60岁大爷组成的车队,着实是老当益壮。

Ahhc2F.jpg

这一天的午餐在陵水进行,早听人说海南的粉很出名,四大粉中就有陵水酸粉的一个位置。这份是加了沙虫的,味道很赞。

Ahh68U.jpg

接下来又是S213上的狂奔,海棠湾位于三亚市区东北边,离蜈支洲岛比较近,有着相当不错的海景。我们入住的民宿正对着海(当然旁边的所有民宿都是这样)。

Ahhgv4.jpg

天台上的风景也是不错的。

AhbQts.jpg

灯台旁的防波堤上,海浪有耐心地拍打。

AhbMkj.jpg

Day5: 三亚海棠湾 ——> 三亚

我们出发去三亚时已经是12月31号的事情了,这段路很近,只有30多公里。还车的时候,还没到正午。

在三亚跨年是C设计好的环节,似乎在一个陌生的地方,哦不,严格意义上来说是在一个陌生的热闹的地方跨年,会很有feel。尽管对此我持保留意见,但我倒是也觉得这会是有趣的体验。

1年说过就过去了。12月31号的这个青旅里,十几个来自世界各地的朋友玩着抽牌喝酒的游戏,和我们一样,等待2018的来临。唯一的不同是他们似乎真的很期待这一刻的到来。临到跨年的前几分钟,更是热情地拉着我们一同去海边看烟花。“打冬海”,她们有点激动地说。

AhhWr9.jpg

沿着海边的街直走,我才发现,三亚的跨年比想象中热闹。街头遍是从遥远的俄罗斯赶来的毛子兄弟。海边的跨年party上,他们更是在倒计时中载歌载舞。这恐怕是我这辈子见过最密集的毛子群体了。

海水拍打的沙滩上,邀请我们的外国友人相互祝着“Happy new year”。恍惚间,一瓶劲酒被塞进手里。“Take a dip,let’s celebrate!”,一个褐发碧眼的年轻姑娘朗声笑着。盛情难却,我们一人一口,干完了最后的酒。

远处有人放起烟花,仿佛春节似的。

用一个这样的跨年作为环岛东线的结尾,挺好的。

原作:You-Dont-Know-JS
本文的99.9%的内容都来自《You dont know JS》的电子中文版

传送门:《You don’t know JS》 上(入门&作用域&对象)

类型和文法

内建类型

  • 7种类型
  • 值才有类型,变量没有
  • undefined ≠ is not defined(undeclared)。undefined表示定义却没有赋值的变量类型。然而typeof一个未声明的变量也会返回undefined,这是typeof的安全机制,它给了我们更多空间检查变量是否可用。

由于JS里String的只读性,所有String的相关方法都是返回一个新字符串。

使用二进制浮点数的最出名(臭名昭著)的副作用是(记住,这是对 所有 使用 IEEE 754 的语言都成立的 —— 不是许多人认为/假装 仅 在 JavaScript 中存在的问题):0.1 + 0.2 === 0.3 // false。不过可以用Number.EPSILON做最小误差得到足够精确的近似结果。ES6下已经可以用Number.isInteger()Number.isSafeInteger()检查数字是不是整数/安全整数。

特殊值

undefinednull是JS里比较特殊的两类值,它们既是类型又是唯一的值。更加不幸的是,在非strict模式下,undefined还可以作为标识符,像下面这样:

1
undefined = 2;

另外,在特别需要undefined时,void操作符会变得很有用。

Infinity / Infinity == undefined。

针对一些特殊的等价情况(NaN和-0),ES6使用Object.is()判断其相等性。

值与引用

在JS中没有指针,只有引用,同时页没有语法上的提示可以控制值和引用的赋值/传递。取而代之的是,值的类型用来唯一控制值是通过值拷贝,还是引用拷贝来赋予(复合值)。引用指向的是值本身而不是变量,不能使用一个引用来改变另一个引用所指向的值。

底层的基本标量值是不可变的(String和Boolean也一样)。比如一个Number对象持有一个基本标量值2,那么这个Number对象就永远不能再持有另一个值;你只能用一个不同的值创建一个全新的Number对象:

1
2
3
4
5
6
7
8
9
10
function foo(x) {
x = x + 1;
x; // 3
}

var a = 2;
var b = new Number( a ); // 或等价的 `Object(a)`

foo( b );
console.log( b ); // 2, 不是 3

在其中x = x + 1这一步,包装值内的x被取出+1后,赋值给x,将其从一个引用变成一个基本标量值3。

类型转换

对于最简单的值,JSON字符串化行为基本上和toString()转换是相同的,在对String字符串化时,结果也会包含"",如JSON.stringify("11") // ""11""。另外,对于JSON不安全值(即不能移植到消费JSON的语言中),有下面的处理:

  • 忽略undefinedfunctionsymbol
  • Array中遇到这种类型的值,会被替换为null(避免修改位置信息)
  • Object的属性中遇到时,属性会被简单的忽略掉
  • 带有循环引用时,JSON.stringify()会报错

另外,对于有toJSON()方法的对象,JSON字符串化会优先使用该方法。JSON.stringify()的第二个参数可以指定Array或Function说明可以编辑的对象属性。第三个参数是填充符,填充在各级开头,用来友好展示结果,最多取入参的前10个字符。

在对象上使用toNumbertoString方法,首先会找到其原始类型(toPrimitives()),即使用其valueOf()toString()方法(也会在[[prototype]]上寻找)。

-> Number

可以用Date.now()代替+new Date()获取更好的语义。

~除了可以用来检查-1这个特殊的值,还可以通过~~对小数取整,因为执行位操作时会先将数字转为Int32类型。

parseInt以及parseFloat+Number()强制类型转换存在区别。它们的作用是,从字符串中解析出一个number出来。两者是不能相互替换的。后者是不能容忍非数字字符的。另外,**请在字符串上使用parseIntparseFloat**,这也是它们的设计目的。对非字符串类型使用它们可能得到意外的结果:

1
parseInt( 1/0, 19 ); // 18,惊不惊喜,意不意外

原因是,parseInt会把第一个参数toString(这不能责怪它,因为它本来就是设计对String使用的)。类似的例子还能举出很多:

1
2
3
4
5
6
7
parseInt( 0.000008 );       // 0   ("0" from "0.000008")
parseInt( 0.0000008 ); // 8 ("8" from "8e-7")
parseInt( false, 16 ); // 250 ("fa" from "false")
parseInt( parseInt, 16 ); // 15 ("f" from "function..")

parseInt( "0x10" ); // 16
parseInt( "103", 2 ); // 2

另外,parseInt会通过前缀试图猜测数字进制,默认是10进制。以0x开头表示16进制,以0b开头表示2进制,以0o开头表示8进制。

-> Boolean

使用!!强制转换类型。

&&||在JS中的逻辑和C++以及Java中的不大一样,它并不一定返回boolean类型的值,而是根据比较的两个数判断返回哪一个。其中&&可以用来进行短路操作。

另外,对于Symbol来说,只能通过String()的形式转为String类型,却不能转为Boolean类型。

等价

等价分为=====

StringNumber进行比较时,会对String使用强制类型转换(类似+Number());

在和Boolean比较时,会首先把Boolean类型转为Number类型,再进行比较。这会产生下面这样比较迷惑的情况:

1
2
"42" == true  // false
"42" == false // false

Object和非Object比较时,会先对Object进行toPrimtives,即先使用valueOf()看能否转成基本类型,再使用toString()

下面有一些疯狂的例子,但却可以由上面的规则解释:

1
2
3
4
5
6
7
8
"0" == false    // true
false == [] // true
0 == [] // true
[] == ![]; // true
2 == [2]; // true
"" == [] // true
"" == [null]; // true
0 == "\n" // true

通过上面的坑可以看到,等号的两边总有[]""false0。建议在这些情况使用===

下面是由Alex Dorey(@dorey on GitHub)制作的一个方便的表格,将各种比较进行了可视化:

大小关系比较

首先对值进行toPrimitives转换,如果有一个不是String,则使用Number类型比较。见下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// toNumber
var a = [ 42 ];
var b = [ "43" ];

a < b; // true

// toString
var a = { b: 42 };
var b = { b: 43 };

a < b; // false
a == b; // false
a > b; // false

a <= b; // true
a >= b; // true

在下面的例子里,a和b在比较时,都会转成”[object Object]”。而等价比较上会比较引用是否相同。因此都返回false,而JS中的<=>=操作会去对应计算><的结果再取反,从而会得到两个true

语法

语句和表达式

JS中的表达式都有一个隐式的返回值,但是它只会打印在控制台中,并不会真实返回。块语句的返回值是块中最后一个语句的返回值。ES7中可能会引入do语句显式地使用这个返回值。

JS中的++和C风格类似,表示自增,有前后之分。但是++a++这种用法是不合法的。

赋值表达式的返回是赋予的值,这在链式赋值时很好用:

1
2
var a, b, c;
a = b = c = 42;

这里,c = 42被求值得出42(带有将42赋值给c的副作用),然后b = 42被求值得出42(带有将42赋值给b的副作用),而最后a = 42被求值(带有将42赋值给a的副作用)。

另一种用法是直接将之放在&&||的前后,检查赋值语句的真值。

上下文

{}包裹的内容作为表达式结果可以直接赋值给变量,但是直接声明时会被当做代码块,但是可能仍然是合法的,如:

1
2
3
{
foo: bar()
}

因为,JS中允许使用语句标签,便于breakcontinue跳转(JS中没有goto)。而JSON中带有""的键则不会被这么解释,因为语句标签不允许出现引号。

我们现在来解决下面的问题:

1
2
[] + {}; // "[object Object]"
{} + []; // 0

为什么交换顺序会对+的规则有影响?在第一个式子里一切都是正常的,[]转换成""{}转换成[object Object];在第二个式子里,{}被理解成一个空代码块儿,[]被强制转换为0。

操作符优先级

  • &&优先于||这里有完整的表格。
  • &&||有短接的特点,即当第一个表达式为true或false时直接返回结果。
  • 赋值表达式和三元表达式? : 一样是从右向左结合的

ASI(自动分号)

尽量避免ASI,只在确认没有歧义的地方依赖ASI。

错误

  • JS有早期错误一说,即运行前编译期间的错误
  • let会造成块域内的TDZ(Temporal Dead Zone,时间死区),typeof在此时会报错,而不会返回undefined。TDZ是指变量还没到能使用它的时候,还需要初始化。下面还有一个例子:
    1
    2
    3
    4
    5
    var b = 3;

    function foo( a = 42, b = a + b + 5 ) {
    // ..
    }
  • ES6提供了剩余参数来代替原有的arguments对象,这更加安全。

finally子句

try catch在和finally一起使用时,finally的语句一定会被执行,而且一定会在try语句执行完后立即执行,即使try中有return或者throwcontinue等控制语句。可以在finally中修改try中的结果,但是最后不要这么做,因为会影响程序可读性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 function foo() {
try {
throw 42;
}
finally {
console.log( "Hello" );
}

console.log( "never runs" );
}

console.log( foo() );
// Hello
// Uncaught Exception: 42

宿主环境

由于浏览器的遗留行为,使用id属性创建DOM元素会创建同名的全局变量。

1
<div id="foo"></div>
1
2
3
4
5
if (typeof foo == "undefined") {
foo = 42; // 永远不会运行
}

console.log( foo ); // HTML元素
  • 永远不要修改内建类型。
  • JS的函数和变量声明提升只在同一<script>标签内

保留字

Let this long package float, Goto private class if short. While protected with debugger case, Continue volatile interface. Instanceof super synchronized throw, Extends final export throws.

Try import double enum?

False, boolean, abstract function, Implements typeof transient break! Void static, default do, Switch int native new. Else, delete null public var In return for const, true, char …Finally catch byte.

来自StackOverflow用户“art4theSould”创造性的一首小诗

另外,在ES6+中,可以使用保留字作为对象字面量中的属性名或键。

异步与性能

JS引擎对时间没有天生的感觉,只是一个任意JS代码段的按需执行环境。是周围的宿主环境在不停地安排“事件”(JS代码的执行)。举例来说,当你的JS程序发起一个从服务器取得数据的Ajax请求时,你在一个函数(通常称为回调)中建立好“应答”代码,然后JS引擎就会告诉宿主环境,“嘿,我就要暂时停止执行了,但不管你什么时候完成了这个网络请求,而且你还得到一些数据的话,请回来调这个函数。”

然后浏览器就会为网络的应答设置一个监听器,当它有东西要交给你的时候,它会通过将回调函数插入事件轮询来安排它的执行。

关于事件轮询队列,之前也有过一些介绍。

异步概览

异步≠并行。异步本质上还是串行的。工作依然有先后之分,没有线程、线程池的概念。从而,在JS中的函数都是原子的,即不会与别的函数的代码相互穿插(除非使用Generator)。

并发

并发是当两个或多个“进程”(或任务)在同一时间段内同时执行,而不管构成它们的每个操作是不是同时进行的。在JS中,单线程事件轮询是并发的一种表达。

不互动

当程序中运行多个“进程”(或任务),如果它们之间没有逻辑联系,那么不互动是完全可以接受的。

1
2
3
4
5
6
7
8
9
10
11
12
13
var res = {};

function foo(results) {
res.foo = results;
}

function bar(results) {
res.bar = results;
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

互动

相反,如果它们之间有依赖关系,或者前后次序而产生互动时,let it alone就会出事。

1
2
3
4
5
6
7
8
9
var res = [];

function response(data) {
res.push( data );
}

// ajax(..) 是某个包中任意的Ajax函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

协调

跳过使用全局变量等丑陋的协作手段,有一种方法,将长时间处理的任务打断成多个小段的请求外加setTimeout,以便将任务穿插完成。

Jobs

ES6在事件轮询队列之上引入了一层新概念,称为“工作队列(Job queue)”。它和轮询队列的关系类似于Macrotask和Microtask。

回调

顺序的大脑

回调不符合正常思维逻辑顺序 & 回调地狱。

信任问题

(本人并不完全赞同)回调遭受着控制反转的蹂躏,它们隐含地将控制权交给第三方(通常第三方工具不受你控制!)来调用你程序的延续。

Promise

Promise的thencatch

可靠的Promise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var p3 = new Promise( function(resolve,reject){
resolve( "B" );
} );

var p1 = new Promise( function(resolve,reject){
resolve( p3 );
} );

var p2 = new Promise( function(resolve,reject){
resolve( "A" );
} );

p1.then( function(v){
console.log( v );
} );

p2.then( function(v){
console.log( v );
} );

// A B <-- 不是你可能期望的 B A

这是因为p1由p3解析的结果所解析,这个过程是异步地。

作者认为Promise在很大程度上,解决了下面的问题:

  • 调的太早/太晚(本人并不赞同)
  • 根本不调回调(勉强成立),Promise通知状态改变是由编程者自己代码控制的,用resolvereject(用户只能借助外部环境API发起异步操作,resolve一样要么放在传统的回调,要么转交给第三方完成)。
  • 调太少或太多次(成立),一个Promise一旦resolve或者reject,状态就不再发生变化
  • 没能传入任何参数/环境(勉强成立),原因与第二条相同
  • 吞掉所有错误和异常(勉强成立),Promise中在catch字句里捕获异常。

Promise.resolve(p)会把thenable的入参p转换为合法的Promise。这里猜测下这个resolve(p)的实现(个人猜想):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Promise.resolve = thenable => {
// if `thenable` is a promise, just return it
// ...

// if `thenable` is plain, just resolve
if (typeof thenable.then != 'function') {
return new Promise(resolve => {
resolve(thenable);
});
}

return new Promise((resolve, reject) => {
thenable.then(resolve, reject);
});
}

// 一个thenable的例子
const p = {
then(cb, err) {
Math.random < 0.5 ? cb(42) : err("oops! Something bad happens.");
}
}

链式调用

看看RxJS的Introduction。就可以很轻松地理解Promise的流程和链式过程了。

Promise模式

Promise.all()Promise.race()。除了这两个官方钦定的方法外,其他的Promise库还实现了像是any()none()first()last()这样的方法,看看RxJS的operators会有更多选择。

Promise的限制

  • 顺序的错误处理
  • 只能传单一的值(其实就是状态改变不可逆)
  • 单次解析(同上),文章也在惰性的上方提到了观察者模式的RxJS,的确在设计时间概念的领域,RxJS要厉害多了
  • 惰性(生产生产Promise函数的工厂函数)
  • 不可反悔(即不能中途撤销)
  • 性能

Generator

使用同步风格书写异步代码的基础在Generator。关于这部分的更详细介绍见本人之前参考阮一峰大神写的博文

打破运行至完成

generator(生成器)是一个可以和别的代码穿插执行的非原子的特殊函数。使用new构造generator得到的只是一个迭代器,迭代器在执行到yield时会让出执行权。真正执行这个迭代器需要用调用或者执行器的方式。

yield和next是generator可以和外部甚至是其他generator双向通信。但是generator只是声明了自己将要以什么样的形式去执行。还需要一个下面这样的帮助函数去推动它执行:

1
2
3
4
5
6
7
8
9
function step(gen) {
var it = gen();
var last;

return function() {
// 不论`yield`出什么,只管在下一次时直接把它塞回去!
last = it.next( last ).value;
};
}

生成器

  • 可以把generator像状态机一样使用。
  • for of需要迭代器的实现
  • 可以在generator上使用for of
  • 使用return而非next可以终止生成器执行

在异步流程中使用generator

generator的yield暂停特性不仅意味着我们可以从异步的函数调用那里得到看起来同步的return值。

带有promise的generator

在ES6的世界中最棒的就是将generator(看似同步的异步代码)与Promise(可靠性和可组合性)组合起来。

co与koa。

ES7中的await和async

像下面这样,没有run函数,没有生成器函数的*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function foo(x,y) {
return request(
"http://some.url.1/?x=" + x + "&y=" + y
);
}

async function main() {
try {
var text = await foo( 11, 31 );
console.log( text );
}
catch (err) {
console.error( err );
}
}

main();

yield委托

使用yield * foo可以把其他的生成器函数整合进当前生成器中。除了生成器外,还可以委托一个非generator的iterator。错误可以委托,promise可以委托,委托还可以递归。

结合yield可以很方便地协调多个generator

thunk

同步的thunk即包装了所有预设形参的函数执行的函数。异步thunk指需要指定callback的包装所有其他预设形参异步函数的函数。像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 同步thunk
function foo(x,y) {
return x + y;
}

function fooThunk() {
return foo( 3, 4 );
}

// 异步thunk
function foo(x,y,cb) {
setTimeout( function(){
cb( x + y );
}, 1000 );
}

function fooThunk(cb) {
foo( 3, 4, cb );
}

一旦来说会有一个工具thunkify帮你完成制造函数thunk的工作(放心,总会有人这么做的)。它的用法是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var fooThunkory = thunkify( foo );

var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );

// 稍后

fooThunk1( function(sum) {
console.log( sum ); // 7
} );

fooThunk2( function(sum) {
console.log( sum ); // 11
} );

包装函数生产一个thunkory,之后指定除cb以外的其他参数得到thunk函数。

thunk和Promise本质上其实是等价的。只不过是回调所在的为之不一样罢了。所以使用Promise.wrap包装得到的promise还是thunkify包装得到的thunk函数其实都可以yield出来。因为,它们都能通过指定回调来让generator进一步推动下去。

当然了无论是在可组合性还是错误处理上,Promise都有更胜一筹。所以,thunk通常作为替代性的前ES6方案。

前ES6的Generator

当然了Generator也是可以通过其他方式实现的。

性能

Web Worker

近HTML5时代被加入web平台的特性,称为“Web Worker”。这是一个浏览器(也就是宿主环境)特性,而且几乎和JS语言本身没有任何关系。这里简单说了下它和Service Worker的区别。

asm.js

asm.js”是可以被高度优化的JavaScript语言子集的标志。通过小心地回避那些特定的很难优化的(垃圾回收,强制转换,等等)机制和模式,asm.js风格的代码可以被JS引擎识别,而且用主动地底层优化进行特殊的处理。

基准分析(BenchMark)和调优

  • Benchmark.js用统计学的方式避免时间戳测量语句性能时的不准确
  • jsPerf.com基于Benchmark.js的代码性能测试平台

编写好的测试

  • 注意上下文的影响
  • “过早的优化是万恶之源”
  • 尾部调用优化

ES6与未来

ES?现在与未来

  • polyfill与转译

语法

尽管ES6算是JS最新的官方特性,下面说的大部分特性已经被很经常地使用了。

  • 块作用域(之前的部分已经提到过了)
  • 扩散、剩余,...操作符,用在函数入参,数组和对象中
  • 函数默认参数值(是不是很神奇),默认参数值可以是合理的表达式甚至是函数调用
  • 解构赋值,也可以有默认参数值
  • 对象字面量拓展,简约声明/简约方法/getter,setter/计算型属性名/__proto__/super
  • 模板字面量
  • 箭头函数,词法this
  • for of和iterator
  • 正则表达式拓展
    • Unicode标识
    • 粘性标志
  • 数字,八进制
  • Unicode
    • 合理的string长度,String.prototype.normalize()
    • charCodeAt => codePointAt
    • fromCharCode => fromCodePoint
    • Unicode标识符名称
  • Symbol,新的基本类型,它是一个新的包装器对象,可以认为每个EVT_LOGIN持有一个不能被其他任何值所(有意或无意地)重复的值。
    • Symbol.for()先查询是否有一个同名的Symbol,如果有就返回,没有就创建一个

组织

迭代器

迭代器Iterator接口有一个必选接口next(),和两个可选接口return()throw(),它的result被规定为包括属性valuedone,下面是一个数组的迭代:

1
2
3
4
5
6
7
8
9
var arr = [1,2,3];

var it = arr[Symbol.iterator]();

it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { value: 3, done: false }

it.next(); // { value: undefined, done: true }

但通常使用for of就足够了。我们可以依照这个接口,定义一个自己的迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var Fib = {
[Symbol.iterator]() {
var n1 = 1, n2 = 1;

return {
// 使迭代器成为一个可迭代对象
[Symbol.iterator]() { return this; },

next() {
var current = n2;
n2 = n1;
n1 = n1 + current;
return { value: current, done: false };
},

return(v) {
console.log(
"Fibonacci sequence abandoned."
);
return { value: v, done: true };
}
};
}
};

Generator

这个上一章已经提到了。它可以用来:

  • 生产一系列值,即状态机
  • 串行执行的任务队列,化异步同步

模块

importexport

  • ES6引入了元属性的概念,用new.target表示。在任意的构造器中,new.target总是指向new实际直接调用的构造器。

集合

ArrayBuffer

它表示一组比特位,但是这些比特的实际意义是由结构化数组控制的,由它表示这些比特上的“视图”究竟是8位有符号整数还是字符串。

1
2
3
4
var buf = new ArrayBuffer( 32 );
buf.byteLength; // 32字节
var arr = new Uint16Array( buf );
arr.length; // 16

一个单独的缓冲可以连接多个视图

1
2
3
4
5
6
7
8
var buf = new ArrayBuffer( 2 );

var view8 = new Uint8Array( buf );
var view16 = new Uint16Array( buf );

view16[0] = 3085;
view8[0]; // 13
view8[1]; // 12

在ES6中可以使用下面的类型化数组构造器:

  • Int8Array(8位有符号整数),Uint8Array(8位无符号整数)
  • Uint8ClampedArray(8位无符号整数,每个值都被卡在0 - 255范围内)
  • Int16Array(16位有符号整数),Uint16Array(16位无符号整数)
  • Int32Array(32位有符号整数),Uint32Array(32位无符号整数)
  • Float32Array(32位浮点数,IEEE-754)
  • Float64Array(64位浮点数,IEEE-754)

Maps

摆脱对象只能使用字符串做键值的限制。有getsetdeletehasclear等方法。类似地还有WeakMap,不过它只能使用对象做键。

Sets

一个集合。类似Map,不过set换成了add,且没有get。Set和Map都有自己的迭代器。也可以通过keysvaluesentries来访问里面的内容。

新增API & 元编程

略,参考原文

ES6以后

  • asnyc function
  • Object.observe
  • 指数运算符**
  • Array#includes替代~Array.indexOf(value)
  • SIMD(多个数据),用于多个元素的并行数学操作,参考下面
    1
    2
    3
    4
    5
    var v1 = SIMD.float32x4( 3.14159, 21.0, 32.3, 55.55 );
    var v2 = SIMD.float32x4( 2.1, 3.2, 4.3, 5.4 );

    SIMD.float32x4.mul( v1, v2 );
    // [ 6.597339, 67.2, 138.89, 299.97 ]
  • WASM(Web Assembly)

-END-

原作:You-Dont-Know-JS
本文的99.9%的内容都来自《You dont know JS》的电子中文版

传送门:《You don’t know JS》 下(类型文法&异步&ES6与未来)

入门与进阶

值和类型

JavaScript只有带类型的值,没有带类型的变量。大家都知道JS的基本类型共6类:

  • undefined
  • null
  • boolean
  • number
  • string
  • object

但是在ES6之后,需要新增一类symbol。另外,对null使用typeof将得到“object”的结果。

JavaScript中对“falsy”的定义包括:

  • “”
  • 0, -0, NaN
  • null, undefined
  • false

除此之外的值都是truthy。

关于JavaScript中的=====,作者的看法是在必要的时候==会很好地改善程序。然而==判断规则比较复杂,可以总结出一些情况便于我们选择是否使用==

  • 如果一个比较的两个值之一可能是truefalse,避免==而使用===。
  • 如果一个比较的两个值之一可能是0/""/[],避免==而使用===。
  • 其他情况下,放心使用==。不仅安全,在许多情况下它可以简化你的代码并改善可读性。

变量

一个变量标识符必须以a-z,A-Z,$,或_开头。它可以包含任意这些字符外加数字0-9,但不能使用保留字。

变量作用域提升(var定义)和嵌套就不多说了。

Strict模式

让代码更严谨,同样可以选择用在全局或是函数中。

函数作为值

IIFE(立即执行函数)和闭包是JS中值得玩味的特性。除了使用()包裹,还可以用void打头开始一个IIFE。

闭包经常用来包装模块。

this指代和prototype

新的特性

填充(polyfill)和转译(transpile)

作用域与闭包

作用域

作用域与LHS,RHS。在非Strict模式下,如果到全局作用域还找不到变量,会在作用域内创建一个新的同名变量。在Strict模式下,不允许这种行为(意外地创建全局变量),此时会抛出ReferenceError,即找不到变量。如果找到了值,但是并不能对它做一些事情,就会抛出TypeError。

词法作用域

JavaScript使用词法作用域,即变量和作用域在编写代码阶段已经确定。JS引擎也可以在这个阶段针对作用域和变量对代码进行优化,但是eval()with()会在代码中动态改变作用域,从而使得引擎无法进行优化,使代码运行得更慢。在strict模式下,eval()的不安全用法(修改作用域)以及with()都是不允许使用的。

词法作用域是编写时的,而动态作用域(和this)是运行时的。词法作用域关心的是函数在何处被声明,但是动态作用域关心的是函数从何处被调用

this在JS中始终是运行时的,即根据运行时的调用情况有不同的值。在箭头函数中则是词法this的,即声明时决定。

块作用域

封装、匿名函数、IIFE。

for循环、if、while、switch等流程控制语句的{},都是假的块作用域,其中的内容都依附于外部的函数作用域。with(不建议使用),try catch,let,const可以形成新的块作用域。

在ES6到ES5的转译时,具有块作用域的代码,会采用try catch来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ES6
{
let a = 1;
console.log(a);
}


// ES5
try {
throw undefined;
} catch(a) {
a = 1;
console.log(a);
}

提升

  • 在代码被执行前,所有的声明,变量和函数,都会首先被处理。处理的只有“声明”,而没有“赋值”。
  • 函数提升优先于变量的提升
  • 后续的提升会覆盖此前的同名提升

闭包

闭包就是函数能够记住并访问它的词法作用域,即使当这个函数在它的词法作用域之外执行时。

循环加闭包会出现面试中的经典问题:

1
2
3
4
5
for (var i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}

上面的代码为啥不好用?

从之前关于作用域的讨论来看,每次setTimeout只是完成了函数声明,并丢进队列里而已。当定时器函数在其词法作用域外执行时,因为闭包的特点会保留有父级的作用域。而这5个函数都定义在同一个父级函数作用域内,对变量i的引用自然是同一个了。

1
2
3
4
5
6
7
for (var i=1; i<=5; i++) {
(function(j){
setTimeout( function timer(){
console.log( j );
}, j*1000 );
})( i );
}

有IIFE的加持,父级作用域现在变成了每个IIFE而非for循环所在的作用域。即每个变量i来自不同的独立作用域,自然就可以得到理想的效果了。

1
2
3
4
5
6

for (let i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}

不就是想要个块作用域嘛,使用let关键字后变量将不是只为循环声明一次,而是为每次迭代声明一次。每次都能得到一个新的块作用域,自然得到和IIFE一样的效果。

this与对象

this是什么

也许JS已经入门的前端程序员们早就对this在不同环境下的不同值烂熟在心。但可能没有想过这种情况的本质:上一部分提到的JS中的this是运行时的,和作用域完全不一样。

对比一下按照传统OOP理解下的JS代码,从不同的角度看,能进一步得到对this的认识:

1
2
3
4
5
6
7
8
9
10
function foo() {
var a = 2;
this.bar();
}

function bar() {
console.log( this.a );
}

foo(); //undefined
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function foo(num) {
console.log( "foo: " + num );

// 追踪 `foo` 被调用了多少次
this.count++;
}

foo.count = 0;

var i;

for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9

// `foo` 被调用了多少次?
console.log( foo.count ); // 0 -- 这他妈怎么回事……?

虽然看上去很愚蠢,但是从词法作用域的角度去理解,是不是能更清楚看到JS中this的特殊之处。

this豁然开朗

根据上面的描述,this是根据调用点确定含义的。下面的4个规则,在准备JS面试的时候肯定都见过:

  • 默认绑定,独立函数调用。可以认为这种this规则是在没有其他规则适用时的默认规则。此时this指向全局对象,在strict mode下,this指向undefined。
  • 隐含绑定,调用点有一个环境对象,即作为函数方法,但是下面的情况下会回退到默认绑定,因为调用点实际位于独立函数内
    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
    function foo() {
    console.log( this.a );
    }
    var obj = {
    a: 2,
    foo: foo
    };

    var bar = obj.foo; // 函数引用!

    var a = "oops, global"; // `a`也是一个全局对象的属性

    bar(); // "oops, global"

    function foo() {
    console.log( this.a );
    }

    ========

    function doFoo(fn) {
    // `fn` 只不过 `foo` 的另一个引用

    fn(); // <-- 调用点!
    }

    var obj = {
    a: 2,
    foo: foo
    };

    var a = "oops, global"; // `a`也是一个全局对象的属性

    doFoo( obj.foo ); // "oops, global"
  • 显式绑定,callapply可以显式attach context到函数上,使用bind可以避免前面那种this丢失的情况。
  • new绑定,函数作为构造函数调用时,this指向即将返回的新对象。

从优先级上看,new > 硬绑定 > 隐含绑定 > 默认绑定。其中“new > 硬绑定”有趣的一点是,使用bind在第一个后的参数实际上会作为函数的默认入参(类似于函数柯里化),如下:

1
2
3
4
5
6
7
8
9
10
11
function foo(p1,p2) {
this.val = p1 + p2;
}

// 在这里使用 `null` 是因为在这种场景下我们不关心 `this` 的硬绑定
// 而且反正它将会被 `new` 调用覆盖掉!
var bar = foo.bind( null, "p1" );

var baz = new bar( "p2" );

baz.val; // p1p2

除了上面的规则,还有一些特例:

传递nullundefinedcallapplybind时,那么这些值会被忽略掉,取而代之的是默认绑定规则将适用于这个调用。单纯使用apply数组化输入参数(现在已经可以用[...foo]了)和bind柯里化函数时常用到。

不过,这么做还是有风险,建议用Object.create(null)创建的对象替代null,既能表示无意义的值,又能避免默认绑定的行为。

作者给出了软绑定的工具方法,提高了硬绑定的灵活性,又避免了默认绑定的问题。逻辑是在绑定时检查this是否是全局对象,如果是才使用输入的this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (!Function.prototype.softBind) {
Function.prototype.softBind = function(obj) {
var fn = this,
curried = [].slice.call( arguments, 1 ),
bound = function bound() {
return fn.apply(
(!this ||
(typeof window !== "undefined" &&
this === window) ||
(typeof global !== "undefined" &&
this === global)
) ? obj : this,
curried.concat.apply( curried, arguments )
);
};
bound.prototype = Object.create( fn.prototype );
return bound;
};
}

另外,前面提到的箭头函数具有词法this,等同在调用前声明self = this,再把self传入的效果。

对象

内建对象中,只有Date()是必须要使用new创建的。

对象的属性有两种访问方法:.操作符或[ ]操作符。不同的是.操作符后只能使用标识符兼容的属性名,[...]操作符后可以使用任何合理的UTF-8的字符串。另外,对象的属性名总是字符串,如果使用了其他类型值,会进行强制转换:

1
2
3
4
5
6
7
8
9
var myObject = { };

myObject[true] = "foo";
myObject[3] = "bar";
myObject[myObject] = "baz";

myObject["true"]; // "foo"
myObject["3"]; // "bar"
myObject["[object Object]"]; // "baz"

计算型属性名

ES6中新增了计算型属性名,允许使用表达式作为一个键名称,表达式用[ ]括起来。像下面这样:

1
2
3
4
5
6
7
8
9
var prefix = "foo";

var myObject = {
[prefix + "bar"]: "hello",
[prefix + "baz"]: "world"
};

myObject["foobar"]; // hello
myObject["foobaz"]; // world

深、浅复制与对象属性描述符(description),writableconfigurablegetOwnPropertyDescriptor()defineProperty()。在JS中,delete仅用于直接从目标对象移除该对象的(可以被移除的)属性,与释放内存并无直接关系。

Immutability

注意:所有这些方法创建的都是浅不可变性。也就是,它们仅影响对象和它的直属属性的性质。如果对象拥有对其他对象(数组、对象、函数等)的引用,那个对象的内容不会受影响,任然保持可变

属性描述符里的writableconfiguratable限制了对属性和属性值的修改。preventExtensions()方法可以防止对象被添加新属性。

  • seal() = configuratable: false + preventExtensions()
  • freeze() = seal() + writable: false

getter与setter

除了使用defineProperty外,可以直接用字面量的形式,通过get prop1()set prop1(val)的形式设置getter和setter。

for infor of

混合(淆)“类”的对象

有些语言(比如Java)不给你选择,所以这根本没什么 选择性 —— 一切都是类。其他语言如C/C++或PHP同时给你过程式和面向类的语法,在使用哪种风格合适或混合风格上,留给开发者更多选择。

类意味着拷贝。

当一个传统的类被实例化时,就发生了类的行为向实例中拷贝。当类被继承时,也发生父类的行为向子类的拷贝。多态也是拷贝行为的结果。

但是:

  • 第一,JavaScript并不会自动地 (像类那样)在对象间创建拷贝;
  • 第二,显式mixin只能复制函数或对象的引用,而不是自身。

正如我们在第四章讲解的,在 JavaScript 中,对于对象来说没有抽象模式/蓝图,即没有面向类的语言中那样的称为类的东西。JavaScript 只有 对象。

实际上,在所有语言中,JavaScript 几乎是独一无二的,也许是唯一的可以被称为“面向对象”的语言,因为可以根本没有类而直接创建对象的语言很少,而 JavaScript 就是其中之一。

在 JavaScript 中,类不能(因为根本不存在)描述对象可以做什么。对象直接定义它自己的行为。这里 仅有 对象。

总之,JavaScript中面向对象的部分和大多数OOP语言不完全一样。这使得在JS中模拟类编程将既累又会埋下很多坑。

原型

使用[[get]]查询属性时,也会在[[prototype]]链上寻找,因此修改对象属性的时候,应该注意属性遮蔽(即在[[prototype]]中找到)的情况。它会增加代码的复杂度和可读性,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var anotherObject = {
a: 2
};

var myObject = Object.create( anotherObject );

anotherObject.a; // 2
myObject.a; // 2

anotherObject.hasOwnProperty( "a" ); // true
myObject.hasOwnProperty( "a" ); // false

myObject.a++; // 噢,隐式遮蔽!

anotherObject.a; // 2
myObject.a; // 3

myObject.hasOwnProperty( "a" ); // true

for in循环中,同样注意用hasOwnProperty()排除[[prototype]]上的属性。

所有用constructor构建的对象都有所指向的prototype,而在prototype中的.constructor又会指回constructor。但是这个关系是可以被覆盖的。

原型继承

作者认为,JS中的对象是通过链接组织起来的。说是原型继承,实际上就是在两个原型间建立了[[prototype]]的关系。这个关系的建立方法很多,各有优劣。最简单的还是用ES5提供的Object.create()方法,对__proto__constructor等视而不见。它的polyfill像下面这样:

1
2
3
4
5
6
7
if (!Object.create) {
Object.create = function(o) {
function F(){}
F.prototype = o;
return new F();
};
}

另外,Object.create()第一个后面的参数可以用来声明对象属性描述符,不过用得不多。

总结

虽然这些JavaScript机制看起来和传统面向类语言的“初始化类”和“类继承”类似,而在JavaScript中的关键区别是,没有拷贝发生。取而代之的是对象最终通过[[Prototype]]链链接在一起。

相反,“委托”是一个更确切的术语,因为这些关系不是拷贝而是委托链接。

从这个角度去看new Foo()过程中发生的事,除了返回一个新的对象外,Foo()还会将这个对象和Foo.prototype链接起来(通过指定[[prototype]]),Foo.prototype和别的对象并没有本质区别。

行为委托

在上面一章提到,[[prototype]]是存在于对象内部的引用另一个对象的内部连接。当一个属性/方法引用在一个对象上发生,而这样的属性/方法又不存在时,这个链接就会被使用。在这种情况下,[[Prototype]]链接告诉引擎去那个被链接的对象上寻找该属性/方法。接下来,如果那个对象也不能满足查询,就沿着它的[[Prototype]]查询,如此继续。这种对象间的一系列链接构成了所谓的“原形链”。

其重要的实质全部在于被连接到其他对象的对象

下面是一段OLOO(链接到其他对象的对象)风格的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var Task = {
setID: function(ID) { this.id = ID; },
outputID: function() { console.log( this.id ); }
};

// 使 `XYZ` 委托到 `Task`
var XYZ = Object.create( Task );

XYZ.prepareTask = function(ID,Label) {
this.setID( ID );
this.label = Label;
};

XYZ.outputTaskDetails = function() {
this.outputID();
console.log( this.label );
};
// ...

它的特点在于:

  • 状态保留在委托者上
  • 避免[[prototype]]链上的重复命名
  • 行为委托用在内部实现,避免暴露在API的设计上

思维的转变

放弃传统OO思路在JS中的蹩脚实现(像下面这样),抓住[[prototype]]链接对象以及“原型链”的特殊性,可以让思路更加自然且符合JS的特点(像下面的下面那样)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Foo(who) {
this.me = who;
}
Foo.prototype.identify = function() {
return "I am " + this.me;
};

function Bar(who) {
Foo.call( this, who );
}
Bar.prototype = Object.create( Foo.prototype );

Bar.prototype.speak = function() {
alert( "Hello, " + this.identify() + "." );
};

var b1 = new Bar( "b1" );
var b2 = new Bar( "b2" );

b1.speak();
b2.speak();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var Foo = {
init: function(who) {
this.me = who;
},
identify: function() {
return "I am " + this.me;
}
};

var Bar = Object.create( Foo );

Bar.speak = function() {
alert( "Hello, " + this.identify() + "." );
};

var b1 = Object.create( Bar );
b1.init( "b1" );
var b2 = Object.create( Bar );
b2.init( "b2" );

b1.speak();
b2.speak();

在这种委托的思路下,不存在严格的父子关系,甚至不存在继承和类的说法。全程通过Object.create()建立起对象和对象的联系,连new也是不建议使用的。

但是这种思路也有个明显的问题,“子类”没法定义“父类”的同名方法,因为整个程序建立在[[prototype]]联系的基础上,重复命名将会隔断连接。

作者并不推荐匿名函数的使用,认为:1,追踪调试栈困难;2,难以自引用;3,代码变得不好理解。这点上我是保留意见的。

类型自省

类型自省即instanceof,而这个操作符是依赖于[[prototype]]中的constructor属性的,这个属性除了不可枚举外,相较其他属性并没有特别之处。重写或者[[prototype]]的改变就可以改变它。因此,instanceof在很多情况下可能并不会那么可靠。

使用鸭子类型的类型自省就更加不可靠了。

在作者提出的OLOO范式中,采取isPrototypeOf()Object.getPrototypeOf()进行类型自省。

新的class关键字

为了便于“类”思维编程者,class可以说是一大福音。

  • 不再有.prototype的困扰
  • extends一键式继承
  • super对多态的支持
  • 语法上使用更加贴近OOP语言

但实际上,这里的class只是语法糖,它还是没实现从类(“蓝图”)到实例(“建筑”)以及从父类到子类的复制,还建立在[[prototype]]的基础上。原文给出了很多例子说明这点。

在传统面向类的语言中,你从不会在晚些时候调整类的定义,所以类设计模式不提供这样的能力。但是JS的一个最强大的部分就是它是动态的,而且任何对象的定义都是(除非你将它设定为不可变)不固定的可变的东西。

换句话说,class 好像在告诉你:“动态太坏了,所以这可能不是一个好主意。这里有看似静态语法,把你的东西静态编码。”

关于 JavaScript 的评论是多么悲伤啊:动态太难了,让我们假装成(但实际上不是!)静态吧。

最近看看要不要在网上学习下性能监测和告警的解决方案,加在项目里。已经调研了一下才发现,项目里已经用上Raven.js了。实际上,各大公司也都有自己的实现方式,除了sentry的Raven.js外,还有腾讯的badjs,淘宝的JSTracker,阿里巴巴的FdSafe,支付宝的saijs等。早在几年前,就已经有许多解决方案了。

异常监测和信息采集的需要实现的主要功能点包括:

  • 前端SDK实现包括错误拦截和监控,错误信息包装、信息上报、API设计等
  • 提供一个可视化的管理后台
  • 可以正确定位错误位置
  • 可以对上报的日志进行筛选、查询、聚类等操作
  • 可以用邮件、短信或集成在其他平台中通知开发者

从一个前端初学者的角度,下面更多聊一下前端SDK的细节。

前端SDK实现

前端实现上的技术重点有三:错误捕获和封装AJAX上报JSON字符串化参数

在raven-js的vendor目录下,引用json-stringify-safeTracekit。前者为了避免JSON.stringify中出现的循环引用的情况,下面主要介绍后者。

Tracekit

常见的方案就是拦截window.onerror方法,在做完自己的工作后,调用原来的window.onerror。自己的工作里包括对错误信息的同一美化和包装。raven.js在这里是借助Tracekit.js完成的。

Tracekit主要分为两部分,Tracekit.report()Tracekit.computeStackTraceWrapper()。前者主要用来绑定和解绑错误监听函数、拦截错误;后者主要用来格式化错误信息。

Tracekit.report()

report()里,整体的设计和基本的观察者设计模式一样,内部成员handlers保存所有的事件消费者,与事件处理函数相关的有四个:

  • subscribe(),绑定一个监听错误的函数,并在绑定第一个函数时替换原有的window.onerror
  • unsubscribe(),解绑一个监听错误的函数,需要提供函数的引用
  • unsubscribeAll(),解绑所有监听错误的函数,还原原有的window.onerror
  • notifyHandlers(),触发错误时,将处理过的错误分发给各handlers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function notifyHandlers(stack, isWindowError) {
var exception = null;
if (isWindowError && !TraceKit.collectWindowErrors) {
return;
}
for (var i in handlers) {
if (handlers.hasOwnProperty(i)) {
try {
handlers[i].apply(null, [stack].concat(_slice.call(arguments, 2)));
} catch (inner) {
exception = inner;
}
}
}

if (exception) {
throw exception;
}
}

另外,函数installGlobalHandler()uninstallGlobalHandler()就是上文中用来拦截window.onerror的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function installGlobalHandler() {
if (_onErrorHandlerInstalled) {
return;
}
_oldOnerrorHandler = _window.onerror;
_window.onerror = traceKitWindowOnError;
_onErrorHandlerInstalled = true;
}
function uninstallGlobalHandler() {
if (!_onErrorHandlerInstalled) {
return;
}
_window.onerror = _oldOnerrorHandler;
_onErrorHandlerInstalled = false;
_oldOnerrorHandler = undefined;
}

report()中最主要的函数是traceKitWindowOnError()。它的工作流程如下:

  1. 查看lastException是否有正在处理的error,如果有则说明是当前错误引起的,使用computeStackTrace.augmentStackTraceWithInitialElement()追加到当前的错误栈前。调用processLastException(),将lastException的信息交给handler处理,并将lastException置空。
  2. 如果lastException为空,且Error为错误对象,使用computeStackTrace()格式化错误信息,再交给错误消费者。
  3. 如果lastException为空,且Error不是错误对象(如字符串),则自行包装错误信息,交给消费者
  4. 使用原来的window.onerror()处理事件
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
function traceKitWindowOnError(message, url, lineNo, colNo, ex) {
var stack = null;

if (lastExceptionStack) {
TraceKit.computeStackTrace.augmentStackTraceWithInitialElement(
lastExceptionStack,
url,
lineNo,
message
);
processLastException();
} else if (ex && utils.isError(ex)) {
// non-string `ex` arg; attempt to extract stack trace
stack = TraceKit.computeStackTrace(ex);
notifyHandlers(stack, true);
} else {
// 自行封装
// ...
notifyHandlers(stack, true);
}

if (_oldOnerrorHandler) {
return _oldOnerrorHandler.apply(this, arguments);
}
return false;
}

Tracekit.computeStackTraceWrapper()

这一部分主要由下面几个函数组成:

  • computeStackTraceFromStackProp(),处理Chrome和Gecko浏览器下的错误信息格式化
  • computeStackTraceByWalkingCallerChain(),处理IE和Safari浏览器下的错误信息格式化
  • augmentStackTraceWithInitialElement(),在当前错误栈底新增新的错误信息,用于computeStackTraceByWalkingCallerChain()和第一部分的processLastException()
  • computeStackTrace(),格式化错误栈信息

其中computeStackTraceFromStackProp()通过换行符得到stack信息,并通过正则格式化所需要的错误信息,computeStackTraceByWalkingCallerChain()是利用arguments.caller得到错误栈信息并格式化。

computeStackTrace()代码如下:

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
function computeStackTrace(ex, depth) {
var stack = null;
depth = depth == null ? 0 : +depth;

try {
stack = computeStackTraceFromStackProp(ex);
if (stack) {
return stack;
}
} catch (e) {
if (TraceKit.debug) {
throw e;
}
}

try {
stack = computeStackTraceByWalkingCallerChain(ex, depth + 1);
if (stack) {
return stack;
}
} catch (e) {
if (TraceKit.debug) {
throw e;
}
}
return {
name: ex.name,
message: ex.message,
url: getLocationHref()
};
}

除了Tracekit所做的工作外,raven本身也对console的log/warning/assert/error方法,setTimeoutsetInterval,requestAnimationFrame()以及各种事件handler进行了拦截。

这里有个坑,跨域的问题无法拦截错误,解决办法就是对跨域的script标签加入crossorigin属性,并在后台配置Access-Control-Allow-Origin=*

Raven

实际上,Tracekit本身已经完成对错误捕获和封装。Raven为了便于在管理后台展示和管理,进一步提出了DSN、context等设计。raven-js的源码主要在src/raven.js中。剩下两部分也是在其中实现的。下面分部分介绍一些:

DSN

DSN(Data Source Name)是Sentry对一个项目的定义。它由协议、端口、用户、密码、后台Sentry服务器地址、项目名组成。通过Raven.config()设置。在config()中通过正则匹配用户输入的DSN字符串,得到后台地址。

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
config: function(dsn, options) {
var self = this;

// ...
if (!dsn) return self;

var globalOptions = self._globalOptions;

// 设置全局参数
if (options) {
each(options, function(key, value) {
// tags and extra are special and need to be put into context
if (key === 'tags' || key === 'extra' || key === 'user') {
self._globalContext[key] = value;
} else {
globalOptions[key] = value;
}
});
}

self.setDSN(dsn);

// 屏蔽跨域的无效错误
globalOptions.ignoreErrors.push(/^Script error\.?$/);
globalOptions.ignoreErrors.push(/^Javascript error: Script error\.? on line 0$/);

// ...

// return for chaining
return self;
},

安装和卸载

install()uninstall()函数中完成。

install()中完成了下面的工作:

  • 借助Tracekit监听了全局的错误事件
  • 监听try catch和一些浏览器事件过程(如console,click,fetch等)中的信息
  • 安装插件
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
install: function() {
var self = this;
if (self.isSetup() && !self._isRavenInstalled) {
// 订阅所有错误事件
TraceKit.report.subscribe(function() {
self._handleOnErrorStackInfo.apply(self, arguments);
});

// 下方的函数会修改原回调函数
// 需要修改函数的toString方法
self._patchFunctionToString();

// 封装定时器和事件回调函数以提供更好的错误监控
if (self._globalOptions.instrument && self._globalOptions.instrument.tryCatch) {
self._instrumentTryCatch();
}

// 一些浏览器原生方法的封装,以捕获事件
// 设置里可关闭
if (self._globalOptions.autoBreadcrumbs) self._instrumentBreadcrumbs();

// 安装所有插件
self._drainPlugins();

// 更新状态
self._isRavenInstalled = true;
}

Error.stackTraceLimit = self._globalOptions.stackTraceLimit;
return this;
}

uninstall中还原了对浏览器原方法的修改,并卸载了Tracekit的report。

封装函数

相关函数:context()wrap()。完成的主要工作是对浏览器原生方法的拦截,使得能更好地捕获其中的错误,在对象内部使用。

capture相关

用来捕获事件,有三种用法。

  • captureException(),最典型的用法,借助Tracekit捕获页面的异常,之后进一步封装成frame后交给_send()发送
  • captureMessage(),最常用的用法,类似埋点,将信息封装成frame后交给_send()发送
  • captureBreadcrumb,类似captureMessage(),不过储存信息在this._breadcrumbs,并不交给_send()
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
captureException: function(ex, options) {
// ex不是错误时的处理
// ...

// Get actual Error from ErrorEvent
if (isErrorEvent(ex)) ex = ex.error;

// Store the raw exception object for potential debugging and introspection
this._lastCapturedException = ex;

// TraceKit.report will re-raise any exception passed to it,
// which means you have to wrap it in try/catch. Instead, we
// can wrap it here and only re-raise if TraceKit.report
// raises an exception different from the one we asked to
// report on.
try {
var stack = TraceKit.computeStackTrace(ex);
this._handleStackInfo(stack, options);
} catch (ex1) {
if (ex !== ex1) {
throw ex1;
}
}
return this;
}

值得注意的是captureMessage中可以设置rate,使一些消息不上报。白名单、正则过滤也是在这里完成的。captureException则是在_processException中完成的。

设置context

context包括三部分:

  • tags,用于从不同维度标识错误或信息,使用setTagsContext()全局配置
  • users,用于标识错误来源,使用setUsersContext()配置
  • extra,用来携带额外的信息,这部分信息不会被索引,使用setExtraContext()配置

它们都放在Raven._globalContext中。涉及的函数还有clearContext()getContext()

同时environmentrelease也放在Raven._globalContext中,可以通过setEnvironmentsetRelease设置

BreadCrumb

这部分功能是在_instrumentTryCatch_instrumentBreadcrumbs方法里实现的。它们通过重写原方法,捕获其中的错误和事件。在卸载时,通过restoreBuiltin还原。

发送

  • send()方法中,会使用封装好的数据附加上_globalOptions中的数据,附带浏览器的状态信息(_getHttpdata()中实现)之后交由_sendProcessedPayload()
  • _sendProcessedPayload()中,会裁剪过长的信息(message, stack, url, referer等)添加请求头,设置发送目标,传入成功和失败回调调用发送函数_makeRequest()
  • _makeRequest()中,为了跨域发送,会优先尝试fetch,然后尝试带有withCredentials字段的XMLHttpRequest,最后采用XDomainRequest对象发送。
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
_makeRequest: function(opts) {
// Auth is intentionally sent as part of query string (NOT as custom HTTP header) to avoid preflight CORS requests
var url = opts.url + '?' + urlencode(opts.auth);

if (supportsFetch()) {
return _window
.fetch(url, {
method: 'POST',
body: stringify(opts.data)
})
.then(function(response) {
if (response.ok) {
opts.onSuccess && opts.onSuccess();
} else {
// ..
}
})
['catch'](function() {
opts.onError &&
opts.onError(new Error('Sentry error code: network unavailable'));
});
}

var request = _window.XMLHttpRequest && new _window.XMLHttpRequest();
if (!request) return;

// if browser doesn't support CORS (e.g. IE7), we are out of luck
var hasCORS = 'withCredentials' in request || typeof XDomainRequest !== 'undefined';

if (!hasCORS) return;

if ('withCredentials' in request) {
request.onreadystatechange = function() {
if (request.readyState !== 4) {
return;
} else if (request.status === 200) {
opts.onSuccess && opts.onSuccess();
} else if (opts.onError) {
var err = new Error('Sentry error code: ' + request.status);
err.request = request;
opts.onError(err);
}
};
} else {
request = new XDomainRequest();
// xdomainrequest cannot go http -> https (or vice versa),
// so always use protocol relative
url = url.replace(/^https?:/, '');

// onreadystatechange not supported by XDomainRequest
if (opts.onSuccess) {
request.onload = opts.onSuccess;
}
if (opts.onError) {
request.onerror = function() {
var err = new Error('Sentry error code: XDomainRequest');
err.request = request;
opts.onError(err);
};
}
}

request.open('POST', url);
request.send(stringify(opts.data));
}

至此,错误捕获和封装AJAX上报JSON字符串化参数都已完成。

可视化后台

在自己设计异常监控系统时,需要和后台商量好接口的设定。用Express + React/Vue等方案快速搭建。

参考

构建工具升级

因为要拆分代码,便于管理,需要使用importexport,因此必须要引入webpack这样的打包工具到gulp中,使用webpack-stream,具体使用方法和其他的gulp插件类似,在pipe在这样插入就行了.pipe(webpack()),配置方式和webpack一样。(webpack中引入babel-loader的过程就不赘述了)引入webpack后,开发流程和一起类似,gulp启动测试服务器,使用webpack通过entry.js打包代码,CSS和JSON相关流程不变。release时,增加了minify的流程,让js流程后的代码再压缩一遍。

另外,引入babel后,可以用ES6语法改写gulpfile.js。最后的gulpfile.babel.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
import gulp from 'gulp';
import rename from 'gulp-rename';
import uglify from 'gulp-uglify';
import cleanCSS from 'gulp-clean-css';
import jsonminify from 'gulp-jsonminify';
import webserver from 'gulp-webserver';
import webpack from 'webpack-stream';

gulp.task('js', function () {
return gulp.src('src/index.js')
.pipe(webpack({
module: {
rules: [
{ test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" }
]
}
}))
.pipe(rename("index.js"))
.pipe(gulp.dest('dist'));
});

gulp.task('minify', function () {
return gulp.src('dist/index.js')
.pipe(uglify())
.pipe(rename("index.min.js"))
.pipe(gulp.dest("dist"));
})

gulp.task('css', function () {
return gulp.src(['src/index.css'])
.pipe(cleanCSS({compatibility: 'ie8'}))
.pipe(gulp.dest('dist'));
});

gulp.task('json', function () {
return gulp.src('src/meta*.json')
.pipe(jsonminify())
.pipe(gulp.dest('dist'))
});

gulp.task('webserver', function() {
gulp.src('./')
.pipe(webserver({
livereload: true,
directoryListing: true,
open: true
}));
});

gulp.task('watch', function() {
gulp.watch(['src/*.js', 'src/**/*.js', 'src/**/*.vue'], ['js']);
gulp.watch('src/*.css', ['css']);
gulp.watch('src/*.json', ['json']);
})

gulp.task('assets', ['json', 'css', 'js']);
gulp.task('default', ['assets', 'webserver', 'watch']);
gulp.task("release", ['assets', 'minify']);

使用单文件组件

引入webpack后,开始高高兴兴地分模块拆分代码,却发现分组件使用Vue时,不是单纯地定义组件配置信息,然后传给入口组件就行。必须要引入全家桶,vue-loader等工具,文件不得不用.vue这样的形式组织(现在开始觉得React组件的组织比Vue舒服了)。本来使用单文件的形式就是想尽量精简,可随着功能逐渐健全,看来重构也是避免不了的啊。在vue-loader的介绍里,居然还要通过vue-cli来大一统,但是我一是想维持项目尽量轻量精简,使用gulp的工具链;二是项目已经写了很久了,全部迁移过去成本有些大。于是,通过vue-cli新建样本项目,对着package.jsonwebpack.config.js一抄了事。

加上种种.vue文件的相关配置,gulp.babel.js最后长下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ...
gulp.task('js', function () {
return gulp.src('src/index.js')
.pipe(webpack({
module: {
rules: [
{ test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" },
{ test: /\.vue$/, loader: 'vue-loader'},
{ test: /\.(png|jpg|gif|svg)$/, loader: 'file-loader', options: { name: '[name].[ext]?[hash]' } }
]
},
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
},
extensions: ['*', '.js', '.vue', '.json']
}
}))
.pipe(rename("index.js"))
.pipe(gulp.dest('dist'));
});
// ...

下面需要开始拆分代码了。根据React/Vue这样框架通常的设计经验,需要下面一些组成:

  • actions 存储状态管理的动作
  • components 存储相互解耦的”dumb”组件,最好和业务无关
  • constants 存储全局常量
  • containers 存储组织components的业务容器组件
  • entry 存储入口文件
  • helper 存储工具函数
  • reducers 存储状态管理的reducers
  • settings 存储全局配置,通常用来初始化store
  • store 存储全局状态
  • templates 存储引入js的html文件

我的项目比较简单,一没有状态管理(后面复杂了之后可能会引入😂),二只有三个组件,只要上面的components, constants, containers, helper的就够用了。最后src下的文件目录大概像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
├── App.vue
├── components
│   └── column
│   └── Column.vue
├── constants
│   └── index.js
├── containers
│   ├── info
│   │   └── Info.vue
│   └── wall
│   └── Wall.vue
├── helper
│   └── utils.js
├── index.css
└── index.js

在拆分时遇到了一些数据需要从最外层透传到子组件的情况,如res, tag_list, tag_keys。不过他们是只读的,而且数目很少,所以并不需要状态管理,只用通过props传下去就行了。

新功能

重构完之后,终于可以写新功能了。新功能主要是增加两个伪路由,方便页面的分享(这个需求我之前遇到过几次了)。页面是spa类型的,所以前端路由可以采用hash或history H5 API来实现。同时也有许多在这个基础上了前端路由库,提供一站式解决方案。我的需求目前其实不需要完整的路由方案:

  • 图片详情页可以分享
  • 搜索结果可以分享

因此,设计上使用hash的方案,对于图片详情页,用!开头,后接图片序号。对于搜索结果页,则没有开头的!,仅使用/隔开每一个搜索关键字。对hash的读写上,没有什么困难的地方:

  • App.vue根据hash注入对应的数据,更改默认视图,对于图片详情页,更改展示组件
  • :切换组件时,记录当前数据到location.hash,方便直接复制链接分享

后面的计划

现在网站还是有点单调了。只能自娱自乐,没有互动。后面应该会考虑在每张图片接入Disqus的问题。

0%