原作: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的问题。

《人类简史》是几年前的畅销书,早有耳闻。它不是对智人几万年来的历史事件像讲故事似的一个个娓娓道来,反而从生物学、社会学、宗教、经济、人文等各个方面提出了相当有深度的观点和见解,包括认知革命、农业革命、帝国与金钱、科技革命等方面的理解让我大开眼界。虽然了解了人类在这些方面的种种真相,对日常生活似乎也不会有什么改善,这并不妨碍它成为一本好书。总而言之,《人类简史》值得一读。

认知革命

智人不过是地球生物发展长河中的一种物种而已,与它类似的还有尼安德特人、丹尼索瓦人、梭罗人等其他人种。然而随着智人向地球的各个大陆蔓延时,这些人种却渐渐消失在历史长河中。在这个过程中,智人似乎发生了认知革命,在智人之前,许多动物都有自己的语言,作者认为不同的是,首先智人的语言能够传达更多的信息,从而执行复杂的计划;其次只有智人可以谈论并不真正存在的事物,同时相信一些不大可能的事情。“虚构”这件事的重点在于人类可以拥有想象,同时可以一起想象,编制出共同的虚构故事。这种“想象的现实”也是人类后面建立秩序的基础。

早期的智人(农业革命前)主要靠狩猎和采集生存,营群体游牧生存。不论从工作时间、生存本领还是幸福程度甚至都比现代人高。他们的营养充分、能够免受饥饿或营养不良的影响、由于不依赖单一作物,受自然灾害影响较小,也很少受传染病影响。在度过艰难的童年时光后,多半能活到不错的岁数。在这个阶段,智人的信仰为泛神论,即一切皆有灵,几乎任何一个地点,任何一个动物,任何一个植物都有自己的声明和情感。

随着智人向各个大陆迁移、在从大陆向太平洋岛屿迁移,这股毁天灭地的人类洪水带来了大批的动植物灭绝,尤其是大型陆地动物。

农业革命

作者认为,农业革命是史上最大的谎言,看上去是人类驯服的小麦和水稻,实际上却是小麦和水稻驯服了人类。农业革命包括农业作物的驯服(植物),包括小麦、水稻、玉米、马铃薯、豆类等,也包括动物的驯服,如牛、狗、羊、鸡等。主要发生在中东、中国、中美洲,原因很简单:大多数的动植物无法驯化,这些好驯化的动植物只生长在特定的地方

人类以小麦等为主食完全是巧合,人们发现在作物丰盛的情况下,终于不用再四处迁移,从而转而通过种植这些农业作物为生,永久聚落开始形成,生活方式的变化使得人类需要考虑自然灾害等的影响,需要预留多余的粮食,同时还要从早到晚照料这些作物免受其他物种侵袭。这似乎是一个陷阱,智人的身体构造其实并不是为了成天弯腰除草和挑水准备的,身体劳累的同时,传染病和暴力行为也威胁到人类的生存。人类以为自己再辛苦点,生活就会更好,等到群落规模达到上百上千人的时候才发现已经无法回头到以狩猎和采集为生的生活,因为此时只有这些农作物能养活这么多人。可受苦的不只是人类,农业革命中的动物也在饱受着痛苦,只因人类需要它们来过上更好的生活。

随着农民生产出来的食物越来越多,加上运输技术的成熟,住在一起的人越来越多,渐渐形成村落和城镇,这个速度快到人类根本没有进化出让这么多人协同工作的本能。幸运的是,智人是个可以讲“谎话”和相信“谎话”的物种。于是一系列“虚构的故事”帮助人类建立稳定的秩序。同时为了保证秩序不会崩塌,还需要暴力的维持。除了暴力还需要一些真正的信徒。为了做到这一点,

  • 第一,对外的说法绝对要坚持它们千真万确、绝非虚构,并永远强调下去
  • 第二,在教育上也要彻底贯彻同一套原则,要在一切事物中融入这套由想象建构出的秩序

最后,想象构建的秩序深深与真实的世界结合,想象构建的秩序塑造了我们的欲望,想象出的秩序存在于人与人的思想连接。作为人类,我们已经不可能摆脱想象建构出的秩序,因为为了让所有人都摆脱,我们需要想象出更强大的东西,从而只是换了种想象而已。即使最后真正摆脱了这种秩序,所谓犬儒主义者,将不可能建立起稳定的帝国。

记忆过载

随着人口规模越来越大,“虚构的故事”越来越多,秩序也越来越复杂。下面几个原因催生出新的表达方式:

  1. 人类并不像蜜蜂,将秩序深植在基因之中,随着人死亡,秩序并不会遗传下去
  2. 大脑容量有限,储存不了这么多信息
  3. 人类大脑的演化并不是为了储存抽象信息设计的,只习惯于存储动植物、地形等具体信息

于是,数字和文字逐渐形成,让“虚构的故事”能够更久地传承下去,文字也从记录这些秩序逐渐发展到完备的可以表达日常的生活。在管理机构里,专门有人记忆这些秩序,便于随时取出使用。文字对人类的重要影响是,逐渐改变了人类思维和看待这个世界的方式,从过去的自由连接、整体思考到分割思考、官僚主义。而数字符号更是如今程序语言的基石。

历史从无正义,这些“想象的故事”并不公平,总把人分成一些其实并不存在的分类,上层人享有各种权力和特权,下等人有的只有歧视和压迫,阶级由此产生。有趣的是,大多数人都会认为只有自己社会的阶级才是自然的,而其他社会的阶级分法实在虚伪。不幸的是,复杂的人类社会似乎确实需要这种由想象建构出来的阶级制度和歧视,而这种歧视往往会造成恶性循环(被歧视->没有发展机会->缺乏教育、贫穷->被歧视),成为底层人士被歧视的所谓客观道理。

关于性别歧视和偏见,许多人认知的所谓“自然”和“不自然”并不是生物学的概念,而是人为(如基督教神学)想象出的规则,指的是“符合创造自然的神的旨意”。各种规定男人就该如何、女人就该怎样的法律、规范、权利和义务,反映的多半只是人类的想象,而不是生物天生的现实。

人类的融合统一

农业革命后,人类社会规模变得更大,维系社会秩序的想象故事也更为精致。人类从出生到死亡被各种虚构的故事和规则围绕,人们往往遵照着这种人造而非天生的直觉,这种直觉就叫做“文化”。原本整个世界是可以大致划分成多个相互隔离并具有一定规模的世界的。随着亚非世界的发展,不断吞噬了其他世界。如今,几乎所有人类都认同同一套地缘政治体系,即地球被划分出不同的国家;使用同一套经济制度,采用同一套法律制度和科学体系。有三种秩序促成了现在的大统一:金钱、帝国、宗教。

农业革命后,随着城镇和王国的出现,人类的分工越来越细,从前一个部落内就可以完成的需求,如今可能需要找完全的陌生人。以物易物的交易有诸多的问题,某些社会采用集中的以物易物系统,但是大多数社会通过发明“钱”的概念解决。这里的“钱”完全是一个概念,存在于人们共同想象中的概念。金钱不仅能够交换物品,还能用来积累财富,相比农作物等,金钱要好储存多了,同时也更便于携带。金钱在转换、存储、运输上的优势,造就了现如今如此复杂的商业系统。可以说,“金钱是人类有史以来最成功的互信系统”。

帝国是一种政治秩序,往往统治许多民族,有着灵活的疆域。它对人类最大的益处在,四处征服、掠夺财富后,不只是拿来养活军队,同时也赞助了艺术、科学、司法等的发展。全球的帝国有个共同点就是,在征服它国时,都带着“征服你们是为你们好”,“统治全世界,为全人类带来福祉”的动机,与排外相反,帝国展现更多的是包容。在帝国的统治下,帝国中的“它们”也渐渐认同了“我们”的概念,和帝国融为一体。在公元前200年左右,大多数人已经活在各个帝国之下。现如今,似乎要形成一个全球性的帝国。

宗教的特点有二:1. 认为世界上有一种超人类的秩序,2. 宗教会以此发展出具有约束力的规范和价值观。只有具有普世特征推广特质的才算得上是宗教。偶像崇拜发展大致是从泛神论到多神论再到一神论。多神论认为世界由一群神威浩荡的神灵统治,而主宰世界的最高权利不带有私心和偏见,在需要时,和特定的神进行交易就可以得偿所愿。随着时间推移,许多多神论者开始对自己信仰的某位神灵越来越虔诚,也慢慢偏离多神论概念,形成一神论。一神论者通常认为自己信奉的神就是唯一的神,自然会批评其他的神不可信,这点不同于多神论。

二元论宗教为了解决“恶的难题”,即为什么世界会有苦难,人为什么会犯错,应运而生。对二元论者来说,掌握世界的不是一个无所不能的神,还有不受控制的恶。佛教认为人遇到事情会产生欲念,欲念会造成不满,释迦牟尼认为重点要看清事情本质,而不是它带来的感受,在专注于实际感受而非欲求和幻想时,原来的欲求就换来了圆满和寂静,称为涅槃。

后世的崇拜对象由神变成了人或是智人的群体,从而产生了人文主义,而根据人文主义者对人性定义的不同,又有自由人文主义、社会人文主义等。

科学革命

现代科学和以往的知识体系最大的不同在:

  • 愿意承认自己的无知
  • 以观察和数学为中心
  • 通过科学获得新能力(即技术)

但是,若是假设我们并非无所不知,现有知识也非全对,那这也适用于那些让数百万人得以有效合作的虚构故事,社会秩序岂不是要崩溃。因此,要维持社会政治秩序稳定,只能依靠不科学的方法,别无选择。毕竟科学还是要依靠宗教、意识形态才能获得经费,并让研究正当化。

在精确科学的趋势下,连生物学、经济学、社会学、心理学等学科也得依靠上数学工具,从而发展出数学的新分支——统计学。知识就是力量,意在考验科学的一大标准就是能否应用于实践。不能免俗的是,科学活动也像其他文化活动一样,收到经济、政治和宗教利益的影响,科学研究一定得和某些宗教或意识形态联手,才有蓬勃发展的可能。

近现代,在欧洲,帝国带着征服的心态不断扩张。同时,不得不提的说资本主义的发展来自对未来的信任,信用->贷款->发展->回报->信用的良性循环,现代经济建立在信任的基础上,使得饼越来越大,有钱也不是一件可耻的事。然而上面循环的一个基础就是:生产的利润必须再投资于提高产量。这也是资本主义的原则,因此即使你是某个辛苦的工人,把收入的一部分拿出投资股票,你也算是个资本主义者。资本主义认为,经济发展就是至善。欧洲的商业型帝国就建立在资本主义的基础上,毕竟靠投资提高收入要好于缴税。

哥伦布探索新大陆的举动,催生了帝国资本主义的奇妙循环:信贷->新发现->殖民地->利润->信任->信贷。然而探险也是有风险的,股份有限公司随着产生,一次探险失败所有股东分担,避免血本无归。荷兰、英国等国家在此基础上,渐渐采用出售股份的方式,让部分债权人也能享有部分获利,这种股票的转手和买卖大行其道,从而催生了证券交易所,专门进行股票交易。以至于后面的战争也可能成为商品的一种。

伴随科技革命的是工业的发展壮大。蒸汽机的出现前,只能靠人体来进行能量间的转化,学会驾驭和转化这些能量的同时,人类可以使用的能源也大大提升。工业催生了冷漠和传送带,一切都以产量为标的。与此同时,产生了全新的问题:谁来买这些产品?

为了避免这种灾难,一种伦理观便被发明出来:消费主义。有史以来,人们生活窘迫,多以勤俭为口号。然而消费主义的没得就是消费更多的产品和服务,鼓励所有人善待自己,宠爱自己,即使因此走上绝路,也在所不惜。在传统农业社会,饥荒的阴影挥之不去,而今天,肥胖却成为一大健康问题。资本主义和消费主义的伦理可以分为两面:

  • 有钱人的指导原则是——“投资”
  • 其他人的指导原则是——“购买”

现代生活

随着工业的出现和发展,时刻表和生产线的概念随之出现,人们关心上下班时间,列车也开始设计列车时刻表,时间变得越来越精细。1847年,英国的火车业者齐聚一堂,同意以格林尼治天文台的时间为准,协调火车时刻表。最终在1880年,英国成为是一个统一全国时间的国家。而现代的一切都得按时完成,时间无所不在。

工业革命后个人的力量逐渐摆脱家庭和社群,个人的命运不再完全由家庭长辈或是家族左右,人类的生活和思考方式也不再预设自己属于社群。家庭和社群力量减弱的同时,国家和市场力量变强,从而进一步使得个人不避依赖社群生存。如今,社群更多是发挥一些重要的情感功能。现代兴起了两大想象社群——“民族”和“消费大众”,前者是国家的想象社群,而后者是市场的想象社群。后者是指消费者们可能彼此并不认识,却因为相同的消费习惯和兴趣成为“同一伙人”(如粉丝)。

过去两世纪,社会秩序变动不休。狄更斯曾在法国大革命时说过:“这是最好的年代,也是最坏的年代”。这句话同样可以用于现在。

快乐并不存在于任何财富、健康和社群之类的客观条件,而是客观条件和主管期望之间是否相符。所谓的快乐更可能只是让个人对意义的错觉和现行的集体错觉达成同步而已。

下面的内容主要来自作者André Staltz的egghead.io

![cycle-flow](http://ow5o14n5d.bkt.clouddn.com/blogcycle-flow.svg)

设计

cycle.js设计上有三个特点:

  • 万物都是Stream(collections + 时间
  • Logic和Effect分离(借助maindrivers
  • app是纯数据流(data flow)

第一部分正如RxJS中介绍的一样,可以用Observable建模。

第二部分中,logic是数学相关的东西,是抽象的。effects则是影响实际世界的效应(如DOM、HTTP等),是实际的。将两者更好地分离开来是Cycle.js的设计初衷。而logic纯函数的特点优势也是很明显的(无副作用)。

上面的两部分在Cycle.js中,借助xstream, RxJS等Reactive Programming的库,以main和driver函数来实现。其中main实现逻辑、生产数据,driver订阅消费数据,交由cycle.js制造Effect。这里有一个浅显易懂的例子。

除了简单的DOM Effect外,还有HTTP请求等。交给不同的drivers完成就行了。这一点上和Elm设计很像。

对比流行的React、Angular、Vue,组件化的设计模式当然也涉及到。文档的Components部分讲得很清楚:

Any Cycle.js app can be reused as a component in a larger Cycle.js app.

即任何一个Cycle.js的应用都可以直接重用成更大应用的一个组件,无需额外的操作。原因很简单,任何一个logic都是在main函数中完成的,这也是开发者唯一需要做的事。而这个函数接收的sources以及返回给Cycle.js的sinks都是一个包含DOM、HTTP等stream的对象,且键值对都一样,可以看下面这张图更好理解:

![cycle.js component](https://cycle.js.org/img/dataflow-component.svg)

原理

完全可以在main中生产多种数据,作为对象返回,交给不同driver的得到不一样的effects。将main中数据交给driver的过程抽象在run函数中,完成数据生产和订阅的过程。这个过程并不复杂。下面是一个简陋的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
function run(mainFn, drivers) {
const sinks = mainFn();
Object.keys(drivers).forEach(key => {
if (sinks[key]) {
drivers[key](sinks[key]);
}
});
}

run(main, {
DOM: domDriver,
other: otherDriver
})

上面的实现手法只实现了单向的从逻辑到Effects的映射过程,要完成相反的从实际世界到逻辑的过程,需要读取外界的事件。要这么做,不仅main函数需要可以接受入参sources,生产Effects的drivers也要能够返回main需要的sources。类似地,sources的类型可以是DOM或者其他的合法input。同时,连接两者的run函数会遇到循环的问题,driver的入参和出参正好是main的出参和入参,这也是Cycle一词的来源。解决办法是先fake一个的初始数据流,得到Effects后,再用Effects初始化main,最后用main替换fake的数据流即可。

继续改造run函数,考虑到driver有多种类型,需要事先为所有driver都使用fakeSinks。在构造好drivers后,使用drivers的返回,构造main。最后用imitate替代掉fakeSinks即可。这就是Cycle.js核心部分run的设计思路,实际上,run部分的代码也只有一百余行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function run(mainFn, drivers) {
const fakeSinks = {};
Object.keys(drivers).forEach(key => {
fakeSinks[key] = xs.create();
})

const sources = {};
Object.keys(drivers).forEach(key => {
sources[key] = drivers[key](fakeSinks[key]);
});

const sinks = mainFn(sources);

Object.keys(sinks).forEach(key => {
fakeSinks[key].imitate(sinks[key];)
});
}

除了run,drivers也是Cycle.js设计的重要部分。它需要能够根据main逻辑的描述灵活地生成对应的Effects。如DOM,在main逻辑中声明所需的DOM结构,对应地,在domDriver中,根据结构生成实际的DOM元素(不论是使用createElement还是vDOM)。

不过仅仅在main逻辑中描述DOM结构是不够的,逻辑上还应该包括如何响应Effects的输入。类似地,这部分应该从driver中的hardcode抽离出来,由main声明,driver实现。类似下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function main(sources) {
const events$ = sources.DOM.selectEvents('span', 'click');
return {
DOM: events$.startWith(null).map(
xs.periodic(1000)
// ...
).flatten()
.map(i => ({
tagName: 'H1',
children: [
{
tagName: 'SPAN',
children:[`Seconds elapsed: ${i}`]
}
]
}))
// ...
}
}

上面的DOM结构可以进一步抽象函数,便于代码书写。另外,Cycle.js中使用makeDOMDriver的方式是为了显示声明DOM容器名,避免hardcode在driver中。

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
function h(tagName, children) {
return {
tagName,
children
}
}

function h1(children) {
return {
tagName: 'H1',
children
}
}

function span(children) {
return {
tagName: 'SPAN',
children
}
}

// ...
.map(
h1([
span([`Seconds elapsed: ${i}`])
])
)

使用

习惯了上面的思考方式后,可以考虑如何使用Cycle.js的问题了。通常情况下,一个空白的Cycle.js的脚手架像下面这样(使用UMD方案时):

1
2
3
4
5
6
7
8
9
10
11
12
const { makeDOMDriver } = CycleDOM;

function main(sources) {
// TODO
}

const drivers = {
DOM: makeDOMDriver('#app'),
// ...
}

Cycle.run(main, drivers);

结合HTML内容的声明和用户输入事件的读取,可以得到下面的结果:

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
const { div, label, input, hr, h1, makeDOMDriver } = CycleDOM;

function main(sources) {

// ''--------------->
// div-------------->

const input$ = sources.DOM.select('.name').events('change');
const name$ = input$.map(ev => ev.target.value).startWidth('');

return {
DOM: name$.map(name =>
div([
label(['Name: ']),
input('.name', {attrs: {type: 'text'}}),
hr(),
h1(`Hello ${name}!`)
])
)
};
}

const drivers = {
DOM: makeDOMDriver('#app'),
// ...
}

Cycle.run(main, drivers);

其中要格外注意的是,name$需要有startWith才能有流的起始数据,从而初始化真实DOM。

结合灵活的流操作符,如merge, fold等,可以实现更加复杂点的应用,如官网给出的计数器

除了DOMDriver,HTTPDriver也是很常用的一种Driver,可以借助它实现HTTP的request和response的响应。如官网给的样例

MVI

![MVI模型](http://ow5o14n5d.bkt.clouddn.com/cycle-mvi.svg)

MVI(Model-View-Intent)是Cycle.js提出的编程范式,可以将main的内容拆分成三部分,

  • 第一部分Intent将Effects传递过来的事件转换成Model逻辑可以接受的类型,
  • 第二部分Model实现具体逻辑,即state的改动
  • 最终由View部分将逻辑转换成DOMDriver可以接受的数据流传递到最后的vdom。

使用这种方式拆分后的代码实际上类似view(model(intent(sources.DOM)))。如文档中介绍的那样。

关于组件拆分和isolate,可以参看文档的Components部分。另外,在Cycle.js的状态管理工具cycle-onionify中也用到了isolate,使用isolate可以保证组件间的stream、状态相互独立。

xstream

万物皆Stream的概念是需要额外的库支持的。因此没有接触过RxJS的建议先学习一下这种思路。

Cycle.js允许使用RxJS等多种Reactive Programming库构造响应式的流结构,不过推荐针对Cycle.js定制的xstream。xstream学习成本简单,API仅有26个,此外文件体积小,速度适中。熟悉RxJS后,学习xstream就更简单了。

xstream的API分为FactoriesOperators。前者通过Producer或合并等方式生产新的Stream,后者是Stream的相关方法。

学习xstream了解4个概念就足够了(如果你已经熟悉RxJS的思想后),Stream, Listener, Producer, MemoryStream

  • Stream,类似EventEmitter和RxJS中的Subject,一个Stream可以注册多个Listener,Stream上有event出现时,所有Listener都会收到通知。除此之外,Stream可以通过operators生产新的Stream,如fold, map等。可以使用shamefullySend*手动触发event,但是应避免使用这种方式
  • Listener,和RxJS中的Observer类似,是有next, error, complete三种状态的对象,用来处理stream的三种event。通过addListenerremoveListener和Stream建立联系。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var listener = {
    next: (value) => {
    console.log('The Stream gave me a value: ', value);
    },
    error: (err) => {
    console.error('The Stream gave me an error: ', err);
    },
    complete: () => {
    console.log('The Stream told me it is done.');
    },
    }
  • Producer,生产Stream所需的event。xstream使用create(producer)等方法生产Stream。一个Producer只能绑定一个Stream。Producer本身拥有startstop便于在没有Listener监听时停止工作。
    1
    2
    3
    4
    5
    6
    7
    8
    9
     var producer = {
    start: function (listener) {
    this.id = setInterval(() => listener.next('yo'), 1000)
    },
    stop: function () {
    clearInterval(this.id)
    },
    id: 0,
    }
  • MemoryStream,和Stream不同的是会记录最后一次的event信息。类似RxJS里的BehaviorSubject。

生产Stream的函数有下面这些:

  • create(producer)createWithMemory(producer)
  • never() 生产不产生event的Stream
  • empty() 生产立即结束的Stream
  • error(error) 生产立即错误的Stream
  • from(input) 通过数组、Promise、Observable等生产Stream
  • of(a1,a2,a3) 生产根据输入产生的一系列event
  • fromArray(array), fromPromise(promise), fromObservable(observable)
  • periodic(period) 周期性产生递增的数
  • merge(s1, s2) 合并两个流
  • combine(s1, s2) 合并两个流中的值

Stream的相关方法有:

  • addListener(listener)removeListener(listener)
  • subscribe(listener)注册listener返回remove的函数
  • map(project)mapTo(projectedValue) 映射event中的值
  • filter(passes) 过滤
  • take(amount) 限制Stream的event数目
  • drop(amount) 忽略前amount次的event数目
  • last() 只释放最后一次event
  • startWith(initialValue) 以给定值开始
  • endWhen(other) 使用其他Stream决定是否完成当前Stream
  • fold(accumulate, seed) 以给定值开始累加
  • replaceError(replace) 取代一个流中的所有error
  • flatten() 将streams的Stream压缩为一个Stream,输出流中的数据只来自于当前Stream
  • compose(operator)
  • remember() 缓存最后一个值
  • debug(labelOrSpy) 不修改流,便于debug
  • imitate(target) 使用给定流替换原有Stream
  • shamefullySendNext/Error/Complete

另外一些实用的Stream相关方法,在extra部分中引入,包括如下

  • buffer(separator) 缓存部分内容一同输出,输出时机由输入的Stream决定
  • concat(s1, s2, ..., sn) 将Stream按照参数顺序从前到后连接起来
  • debounce(period)throttle(period) 防抖和节流
  • delay(period) 时延
  • dropRepeats(isEquals) 丢掉邻接的重复数据
  • dropUntil(other) 根据其他Stream决定该Stream的开始时机
  • flattenConcurrently() 类似flatten(),不过流中的数据根据时间merge,flattenSequentially()类似,将Stream先后连接
  • fromDiagram(diagram, options) 通过图表创建Stream
  • fromEvent(element, eventName, useCapture) 通过DOM事件创建Stream
  • pairwise() 和上一个值成对组成event的值
  • sampleCombine(streams) source流和其他发生时间最近的流event相组合
  • split(separator) 类似buffer,将stream拆分为释放streams的Stream
  • tween(config) 根据配置创建缓动函数,用于制作动画

参考

0%