《You don't know JS》 上(入门&作用域&对象)

原作: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 的评论是多么悲伤啊:动态太难了,让我们假装成(但实际上不是!)静态吧。