JavaScript深入之作用域链

前言

《JavaScript 深入之执行上下文栈》中讲到,当 JavaScript 代码执行一段可执行代码 (executable code) 时,会创建对应的执行上下文(execution context)。

对于每个执行上下文,都有三个重要属性:

  • 变量对象 (Variable object,VO)
  • 作用域链 (Scope chain)
  • this

今天重点讲讲作用域链。

作用域链

《JavaScript 深入之变量对象》中讲到,当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级 (词法层面上的父级) 执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

下面,让我们以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的。

函数创建

《JavaScript 深入之词法作用域和动态作用域》中讲到,函数的作用域在函数定义的时候就决定了。

这是因为函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链!

举个例子:

1
2
3
4
5
6
 
function foo() {
function bar() {
...
}
}

函数创建时,各自的[[scope]]为:

1
2
3
4
5
6
7
8
foo.[[scope]] = [
globalContext.VO
];

bar.[[scope]] = [
fooContext.AO,
globalContext.VO
];

函数激活

当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用链的前端。

这时候执行上下文的作用域链,我们命名为 Scope:

1
Scope = [AO].concat([[Scope]]);

至此,作用域链创建完毕。

捋一捋

以下面的例子为例,结合着之前讲的变量对象和执行上下文栈,我们来总结一下函数执行上下文中作用域链和变量对象的创建过程:

1
2
3
4
5
6
var scope = "global scope";
function checkscope(){
var scope2 = 'local scope';
return scope2;
}
checkscope();

执行过程如下:

1.checkscope 函数被创建,保存作用域链到 内部属性[[scope]]

1
2
3
checkscope.[[scope]] = [
globalContext.VO
];
  1. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
1
2
3
4
ECStack = [
checkscopeContext,
globalContext
];

3.checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链

1
2
3
checkscopeContext = {
Scope: checkscope.[[scope]],
}
  1. 第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明
1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: checkscope.[[scope]],
}
  1. 第三步:将活动对象压入 checkscope 作用域链顶端
1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: [AO, [[Scope]]]
}
  1. 准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值
1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: 'local scope'
},
Scope: [AO, [[Scope]]]
}
  1. 查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
1
2
3
ECStack = [
globalContext
];

JavaScript深入之作用域链

前言

《JavaScript 深入之执行上下文栈》中讲到,当 JavaScript 代码执行一段可执行代码 (executable code) 时,会创建对应的执行上下文(execution context)。

对于每个执行上下文,都有三个重要属性:

  • 变量对象 (Variable object,VO)
  • 作用域链 (Scope chain)
  • this

今天重点讲讲作用域链。

作用域链

《JavaScript 深入之变量对象》中讲到,当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级 (词法层面上的父级) 执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

下面,让我们以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的。

函数创建

《JavaScript 深入之词法作用域和动态作用域》中讲到,函数的作用域在函数定义的时候就决定了。

这是因为函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链!

举个例子:

1
2
3
4
5
6
 
function foo() {
function bar() {
...
}
}

函数创建时,各自的[[scope]]为:

1
2
3
4
5
6
7
8
foo.[[scope]] = [
globalContext.VO
];

bar.[[scope]] = [
fooContext.AO,
globalContext.VO
];

函数激活

当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用链的前端。

这时候执行上下文的作用域链,我们命名为 Scope:

1
Scope = [AO].concat([[Scope]]);

至此,作用域链创建完毕。

捋一捋

以下面的例子为例,结合着之前讲的变量对象和执行上下文栈,我们来总结一下函数执行上下文中作用域链和变量对象的创建过程:

1
2
3
4
5
6
var scope = "global scope";
function checkscope(){
var scope2 = 'local scope';
return scope2;
}
checkscope();

执行过程如下:

1.checkscope 函数被创建,保存作用域链到 内部属性[[scope]]

1
2
3
checkscope.[[scope]] = [
globalContext.VO
];
  1. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
1
2
3
4
ECStack = [
checkscopeContext,
globalContext
];

3.checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链

1
2
3
checkscopeContext = {
Scope: checkscope.[[scope]],
}
  1. 第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明
1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: checkscope.[[scope]],
}
  1. 第三步:将活动对象压入 checkscope 作用域链顶端
1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: [AO, [[Scope]]]
}
  1. 准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值
1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: 'local scope'
},
Scope: [AO, [[Scope]]]
}
  1. 查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
1
2
3
ECStack = [
globalContext
];

JavaScript深入之创建对象的多种方式以及优缺点

写在前面

这篇文章讲解创建对象的各种方式,以及优缺点。

但是注意:

这篇文章更像是笔记,因为《JavaScript 高级程序设计》写得真是太好了!

1. 工厂模式

1
2
3
4
5
6
7
8
9
10
11
function createPerson(name) {
var o = new Object();
o.name = name;
o.getName = function () {
console.log(this.name);
};

return o;
}

var person1 = createPerson('kevin');

缺点:对象无法识别,因为所有的实例都指向一个原型

2. 构造函数模式

1
2
3
4
5
6
7
8
function Person(name) {
this.name = name;
this.getName = function () {
console.log(this.name);
};
}

var person1 = new Person('kevin');

优点:实例可以识别为一个特定的类型

缺点:每次创建实例时,每个方法都要被创建一次

2.1 构造函数模式优化

1
2
3
4
5
6
7
8
9
10
function Person(name) {
this.name = name;
this.getName = getName;
}

function getName() {
console.log(this.name);
}

var person1 = new Person('kevin');

优点:解决了每个方法都要被重新创建的问题

缺点:这叫啥封装……

3. 原型模式

1
2
3
4
5
6
7
8
9
10
function Person(name) {

}

Person.prototype.name = 'keivn';
Person.prototype.getName = function () {
console.log(this.name);
};

var person1 = new Person();

优点:方法不会重新创建

缺点:1. 所有的属性和方法都共享 2. 不能初始化参数

3.1 原型模式优化

1
2
3
4
5
6
7
8
9
10
11
12
function Person(name) {

}

Person.prototype = {
name: 'kevin',
getName: function () {
console.log(this.name);
}
};

var person1 = new Person();

优点:封装性好了一点

缺点:重写了原型,丢失了 constructor 属性

3.2 原型模式优化

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person(name) {

}

Person.prototype = {
constructor: Person,
name: 'kevin',
getName: function () {
console.log(this.name);
}
};

var person1 = new Person();

优点:实例可以通过 constructor 属性找到所属构造函数

缺点:原型模式该有的缺点还是有

4. 组合模式

构造函数模式与原型模式双剑合璧。

1
2
3
4
5
6
7
8
9
10
11
12
function Person(name) {
this.name = name;
}

Person.prototype = {
constructor: Person,
getName: function () {
console.log(this.name);
}
};

var person1 = new Person();

优点:该共享的共享,该私有的私有,使用最广泛的方式

缺点:有的人就是希望全部都写在一起,即更好的封装性

4.1 动态原型模式

1
2
3
4
5
6
7
8
9
10
function Person(name) {
this.name = name;
if (typeof this.getName != "function") {
Person.prototype.getName = function () {
console.log(this.name);
}
}
}

var person1 = new Person();

注意:使用动态原型模式时,不能用对象字面量重写原型

解释下为什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Person(name) {
this.name = name;
if (typeof this.getName != "function") {
Person.prototype = {
constructor: Person,
getName: function () {
console.log(this.name);
}
}
}
}

var person1 = new Person('kevin');
var person2 = new Person('daisy');

// 报错 并没有该方法
person1.getName();

// 注释掉上面的代码,这句是可以执行的。
person2.getName();

为了解释这个问题,假设开始执行var person1 = new Person('kevin')

如果对 new 和 apply 的底层执行过程不是很熟悉,可以阅读底部相关链接中的文章。

我们回顾下 new 的实现步骤:

  1. 首先新建一个对象
  2. 然后将对象的原型指向 Person.prototype
  3. 然后 Person.apply(obj)
  4. 返回这个对象

注意这个时候,回顾下 apply 的实现步骤,会执行 obj.Person 方法,这个时候就会执行 if 语句里的内容,注意构造函数的 prototype 属性指向了实例的原型,使用字面量方式直接覆盖 Person.prototype,并不会更改实例的原型的值,person1 依然是指向了以前的原型,而不是 Person.prototype。而之前的原型是没有 getName 方法的,所以就报错了!

如果你就是想用字面量方式写代码,可以尝试下这种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Person(name) {
this.name = name;
if (typeof this.getName != "function") {
Person.prototype = {
constructor: Person,
getName: function () {
console.log(this.name);
}
}

return new Person(name);
}
}

var person1 = new Person('kevin');
var person2 = new Person('daisy');

person1.getName(); // kevin
person2.getName(); // daisy

5.1 寄生构造函数模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Person(name) {

var o = new Object();
o.name = name;
o.getName = function () {
console.log(this.name);
};

return o;

}

var person1 = new Person('kevin');
console.log(person1 instanceof Person) // false
console.log(person1 instanceof Object) // true

寄生构造函数模式,我个人认为应该这样读:

寄生 - 构造函数 - 模式,也就是说寄生在构造函数的一种方法。

也就是说打着构造函数的幌子挂羊头卖狗肉,你看创建的实例使用 instanceof 都无法指向构造函数!

这样方法可以在特殊情况下使用。比如我们想创建一个具有额外方法的特殊数组,但是又不想直接修改 Array 构造函数,我们可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function SpecialArray() {
var values = new Array();

for (var i = 0, len = arguments.length; i < len; i++) {
values.push(arguments[i]);
}

values.toPipedString = function () {
return this.join("|");
};
return values;
}

var colors = new SpecialArray('red', 'blue', 'green');
var colors2 = SpecialArray('red2', 'blue2', 'green2');


console.log(colors);
console.log(colors.toPipedString()); // red|blue|green

console.log(colors2);
console.log(colors2.toPipedString()); // red2|blue2|green2

你会发现,其实所谓的寄生构造函数模式就是比工厂模式在创建对象的时候,多使用了一个 new,实际上两者的结果是一样的。

但是作者可能是希望能像使用普通 Array 一样使用 SpecialArray,虽然把 SpecialArray 当成函数也一样能用,但是这并不是作者的本意,也变得不优雅。

在可以使用其他模式的情况下,不要使用这种模式。

但是值得一提的是,上面例子中的循环:

1
2
3
for (var i = 0, len = arguments.length; i < len; i++) {
values.push(arguments[i]);
}

可以替换成:

1
values.push.apply(values, arguments);

5.2 稳妥构造函数模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function person(name){
var o = new Object();
o.sayName = function(){
console.log(name);
};
return o;
}

var person1 = person('kevin');

person1.sayName(); // kevin

person1.name = "daisy";

person1.sayName(); // kevin

console.log(person1.name); // daisy

所谓稳妥对象,指的是没有公共属性,而且其方法也不引用 this 的对象。

与寄生构造函数模式有两点不同:

  1. 新创建的实例方法不引用 this
  2. 不使用 new 操作符调用构造函数

稳妥对象最适合在一些安全的环境中。

稳妥构造函数模式也跟工厂模式一样,无法识别对象所属类型。

JavaScript深入之参数按值传递

定义

在《JavaScript 高级程序设计》第三版 4.1.3,讲到传递参数:

ECMAScript 中所有函数的参数都是按值传递的。

什么是按值传递呢?

也就是说,把函数外部的值复制给函数内部的参数,就和把值从一个变量复制到另一个变量一样。

按值传递

举个简单的例子:

1
2
3
4
5
6
7
var value = 1;
function foo(v) {
v = 2;
console.log(v); //2
}
foo(value);
console.log(value) // 1

很好理解,当传递 value 到函数 foo 中,相当于拷贝了一份 value,假设拷贝的这份叫 _value,函数中修改的都是 _value 的值,而不会影响原来的 value 值。

引用传递?

拷贝虽然很好理解,但是当值是一个复杂的数据结构的时候,拷贝就会产生性能上的问题。

所以还有另一种传递方式叫做按引用传递。

所谓按引用传递,就是传递对象的引用,函数内部对参数的任何改变都会影响该对象的值,因为两者引用的是同一个对象。

举个例子:

1
2
3
4
5
6
7
8
9
var obj = {
value: 1
};
function foo(o) {
o.value = 2;
console.log(o.value); //2
}
foo(obj);
console.log(obj.value) // 2

哎,不对啊,连我们的红宝书都说了 ECMAScript 中所有函数的参数都是按值传递的,这怎么能按 “引用传递” 成功呢?

而这究竟是不是引用传递呢?

第三种传递方式

不急,让我们再看个例子:

1
2
3
4
5
6
7
8
9
var obj = {
value: 1
};
function foo(o) {
o = 2;
console.log(o); //2
}
foo(obj);
console.log(obj.value) // 1

如果 JavaScript 采用的是引用传递,外层的值也会被修改呐,这怎么又没被改呢?所以真的不是引用传递吗?

这就要讲到其实还有第三种传递方式,叫按共享传递。

而共享传递是指,在传递对象的时候,传递对象的引用的副本。

注意: 按引用传递是传递对象的引用,而按共享传递是传递对象的引用的副本!

所以修改 o.value,可以通过引用找到原值,但是直接修改 o,并不会修改原值。所以第二个和第三个例子其实都是按共享传递。

最后,你可以这样理解:

参数如果是基本类型是按值传递,如果是引用类型按共享传递。

但是因为拷贝副本也是一种值的拷贝,所以在高程中也直接认为是按值传递了。

所以,高程,谁叫你是红宝书嘞!

JavaScript深入之变量对象

前言

在上篇《JavaScript 深入之执行上下文栈》中讲到,当 JavaScript 代码执行一段可执行代码 (executable code) 时,会创建对应的执行上下文(execution context)。

对于每个执行上下文,都有三个重要属性:

  • 变量对象 (Variable object,VO)
  • 作用域链 (Scope chain)
  • this

今天重点讲讲创建变量对象的过程。

变量对象

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。

因为不同执行上下文下的变量对象稍有不同,所以我们来聊聊全局上下文下的变量对象和函数上下文下的变量对象。

全局上下文

我们先了解一个概念,叫全局对象。在 W3School 中也有介绍:

全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。

在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。

例如,当 JavaScript 代码引用 parseInt() 函数时,它引用的是全局对象的 parseInt 属性。全局对象是作用域链的头,还意味着在顶层 JavaScript 代码中声明的所有变量都将成为全局对象的属性。

如果看的不是很懂的话,容我再来介绍下全局对象:

  1. 可以通过 this 引用,在客户端 JavaScript 中,全局对象就是 Window 对象。
  1. 全局对象是由 Object 构造函数实例化的一个对象。
1
console.log(this instanceof Object);
  1. 预定义了一堆,嗯,一大堆函数和属性。
1
2
3
// 都能生效
console.log(Math.random());
console.log(this.Math.random());
  1. 作为全局变量的宿主。
1
2
var a = 1;
console.log(this.a);
  1. 客户端 JavaScript 中,全局对象有 window 属性指向自身。
1
2
3
4
5
var a = 1;
console.log(window.a);

this.window.b = 2;
console.log(this.b);

花了一个大篇幅介绍全局对象,其实就想说:

全局上下文中的变量对象就是全局对象呐!

函数上下文

在函数上下文中,我们用活动对象 (activation object, AO) 来表示变量对象。

活动对象和变量对象其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫 activation object 呐,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。

活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性值是 Arguments 对象。

执行过程

执行上下文的代码会分成两个阶段进行处理:分析和执行,我们也可以叫做:

  1. 进入执行上下文
  2. 代码执行

进入执行上下文

当进入执行上下文时,这时候还没有执行代码,

变量对象会包括:

  1. 函数的所有形参 (如果是函数上下文)
    • 由名称和对应值组成的一个变量对象的属性被创建
    • 没有实参,属性值设为 undefined
  2. 函数声明
    • 由名称和对应值(函数对象 (function-object))组成一个变量对象的属性被创建
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  3. 变量声明
    • 由名称和对应值(undefined)组成一个变量对象的属性被创建;
    • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

举个例子:

1
2
3
4
5
6
7
8
9
10
function foo(a) {
var b = 2;
function c() {}
var d = function() {};

b = 3;

}

foo(1);

在进入执行上下文后,这时候的 AO 是:

1
2
3
4
5
6
7
8
9
10
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
c: reference to function c(){},
d: undefined
}

代码执行

在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值

还是上面的例子,当代码执行完后,这时候的 AO 是:

1
2
3
4
5
6
7
8
9
10
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 3,
c: reference to function c(){},
d: reference to FunctionExpression "d"
}

到这里变量对象的创建过程就介绍完了,让我们简洁的总结我们上述所说:

  1. 全局上下文的变量对象初始化是全局对象
  2. 函数上下文的变量对象初始化只包括 Arguments 对象
  3. 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
  4. 在代码执行阶段,会再次修改变量对象的属性值

思考题

最后让我们看几个例子:

  1. 第一题
1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
console.log(a);
a = 1;
}

foo(); // ???

function bar() {
a = 1;
console.log(a);
}
bar(); // ???

第一段会报错:Uncaught ReferenceError: a is not defined

第二段会打印:1

这是因为函数中的 “a” 并没有通过 var 关键字声明,所有不会被存放在 AO 中。

第一段执行 console 的时候, AO 的值是:

1
2
3
4
5
AO = {
arguments: {
length: 0
}
}

没有 a 的值,然后就会到全局去找,全局也没有,所以会报错。

当第二段执行 console 的时候,全局对象已经被赋予了 a 属性,这时候就可以从全局找到 a 的值,所以会打印 1。

  1. 第二题
1
2
3
4
5
6
7
console.log(foo);

function foo(){
console.log("foo");
}

var foo = 1;

会打印函数,而不是 undefined 。

这是因为在进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。

JavaScript深入之头疼的类型转换(下)

前言

举个例子:

在 JavaScript 中,这是完全可以运行的,不过你有没有好奇,为什么 1 和 ‘1’ 分属不同的数据类型,为什么就可以进行运算呢?

这其实是因为 JavaScript 自动的将数据类型进行了转换,我们通常称为隐式类型转换。但是我们都知道,+运算符既可以用于数字加法,也能用于字符串拼接,那在这个例子中,是将数字 1 转成字符串 '1',进行拼接运算?还是将字符串 '1' 转成数字 1,进行加法运算呢?

先卖个关子,虽然估计你也知道答案。今天,我们就常见的隐式类型转化的场景进行介绍。

一元操作符 +

当 + 运算符作为一元操作符的时候,查看 ES5 规范 1.4.6,会调用 ToNumber 处理该值,相当于 Number('1'),最终结果返回数字 1

那么下面的这些结果呢?

1
2
3
4
console.log(+[]);
console.log(+['1']);
console.log(+['1', '2', '3']);
console.log(+{});

既然是调用 ToNumber 方法,回想《JavaScript 深入之头疼的类型转换 (上)》中的内容,当输入的值是对象的时候,先调用 ToPrimitive(input, Number) 方法,执行的步骤是:

  1. 如果 obj 为基本类型,直接返回
  2. 否则,调用 valueOf 方法,如果返回一个原始值,则 JavaScript 将其返回。
  3. 否则,调用 toString 方法,如果返回一个原始值,则JavaScript 将其返回。
  4. 否则,JavaScript 抛出一个类型错误异常。

+[] 为例,[] 调用 valueOf 方法,返回一个空数组,因为不是原始值,调用 toString 方法,返回 ""

得到返回值后,然后再调用 ToNumber 方法,"" 对应的返回值是 0,所以最终返回 0

剩下的例子以此类推。结果是:

1
2
3
console.log(+['1']); // 1
console.log(+['1', '2', '3']); // NaN
console.log(+{}); // NaN

二元操作符 +

规范

现在 + 运算符又变成了二元操作符,毕竟它也是加减乘除中的加号

1 + '1' 我们知道答案是 ‘11’,那 null + 1[] + [][] + {}{} + {} 呢?

如果要了解这些运算的结果,不可避免的我们要从规范下手。

规范地址:http://es5.github.io/#x11.6.1

不过这次就不直接大段大段的引用规范了,直接给大家讲简化后的内容。

到底当执行 + 运算的时候,会执行怎样的步骤呢?让我们根据规范11.6.1 来捋一捋:

当计算 value1 + value2 时:

  1. lprim = ToPrimitive(value1)
  2. rprim = ToPrimitive(value2)
  3. 如果 lprim 是字符串或者 rprim 是字符串,那么返回 ToString(lprim) 和 ToString(rprim) 的拼接结果
  4. 返回 ToNumber(lprim) 和 ToNumber(rprim) 的运算结果

规范的内容就这样结束了。没有什么新的内容,ToStringToNumberToPrimitive都是在《JavaScript 深入之头疼的类型转换 (上)》中讲到过的内容,所以我们直接进分析阶段:

让我们来举几个例子:

1.Null 与数字

按照规范的步骤进行分析:

  1. lprim = ToPrimitive(null) 因为 null 是基本类型,直接返回,所以 lprim = null
  2. rprim = ToPrimitive(1) 因为 1 是基本类型,直接返回,所以 rprim = null
  3. lprim 和 rprim 都不是字符串
  4. 返回 ToNumber(null) 和 ToNumber(1) 的运算结果

接下来:

ToNumber(null) 的结果为 0,(回想上篇 Number(null)),ToNumber(1) 的结果为 1

所以,null + 1 相当于 0 + 1,最终的结果为数字 1

这个还算简单,看些稍微复杂的:

2. 数组与数组

依然按照规范:

  1. lprim = ToPrimitive([]),[]是数组,相当于 ToPrimitive([], Number),先调用 valueOf 方法,返回对象本身,因为不是原始值,调用 toString 方法,返回空字符串 “”
  2. rprim 类似。
  3. lprim 和 rprim 都是字符串,执行拼接操作

所以,[] + []相当于 ""+"",最终的结果是空字符串""

看个更复杂的:

3. 数组与对象

1
2
3
// 两者结果一致
console.log([] + {});
console.log({} + []);

按照规范:

  1. lprim = ToPrimitive([]),lprim = “”
  2. rprim = ToPrimitive({}),相当于调用 ToPrimitive({}, Number),先调用 valueOf 方法,返回对象本身,因为不是原始值,调用 toString 方法,返回 “[object Object]”
  3. lprim 和 rprim 都是字符串,执行拼接操作

所以,[] + {} 相当于 ""+"[object Object]",最终的结果是 “[object Object]”。

下面的例子,可以按照示例类推出结果:

1
2
3
console.log(1 + true);
console.log({} + {});
console.log(new Date(2017, 04, 21) + 1) // 这个知道是数字还是字符串类型就行

结果是:

1
2
3
console.log(1 + true); // 2
console.log({} + {}); // "[object Object][object Object]"
console.log(new Date(2017, 04, 21) + 1) // "Sun May 21 2017 00:00:00 GMT+0800 (CST)1"

注意

以上的运算都是在 console.log 中进行,如果你直接在 Chrome 或者 Firebug 开发工具中的命令行直接输入,你也许会惊讶的看到一些结果的不同,比如:

我们刚才才说过 {} + [] 的结果是 "[object Object]" 呐,这怎么变成了 0 了?

不急,我们尝试着加一个括号:

结果又变成了正确的值,这是为什么呢?

其实,在不加括号的时候,{} 被当成了一个独立的空代码块,所以 {} + [] 变成了 +[],结果就变成了 0

同样的问题还出现在 {} + {} 上,而且火狐和谷歌的结果还不一样:

1
2
3
> {} + {}
// 火狐: NaN
// 谷歌: "[object Object][object Object]"

如果 {} 被当成一个独立的代码块,那么这句话相当于 +{},相当于 Number({}),结果自然是 NaN,可是 Chrome 却在这里返回了正确的值。

那为什么这里就返回了正确的值呢?我也不知道,欢迎解答~

== 相等

规范

"==" 用于比较两个值是否相等,当要比较的两个值类型不一样的时候,就会发生类型的转换。

关于使用 “==” 进行比较的时候,具体步骤可以查看规范 11.9.5

当执行 x == y 时:

  1. 如果 x 与 y 是同一类型:
    1. x 是 Undefined,返回 true
    2. x 是 Null,返回 true
    3. x 是数字:
      1. x 是 NaN,返回 false
      2. y 是 NaN,返回 false
      3. x 与 y 相等,返回 true
      4. x 是 + 0,y 是 - 0,返回 true
      5. x 是 - 0,y 是 + 0,返回 true
      6. 返回 false
    4. x 是字符串,完全相等返回 true, 否则返回 false
    5. x 是布尔值,x 和 y 都是 true 或者 false,返回 true,否则返回 false
    6. x 和 y 指向同一个对象,返回 true,否则返回 false
  2. x 是 null 并且 y 是 undefined,返回 true
  3. x 是 undefined 并且 y 是 null,返回 true
  4. x 是数字,y 是字符串,判断 x == ToNumber(y)
  5. x 是字符串,y 是数字,判断 ToNumber(x) == y
  6. x 是布尔值,判断 ToNumber(x) == y
  7. y 是布尔值,判断 x ==ToNumber(y)
  8. x 不是字符串或者数字,y 是对象,判断 x == ToPrimitive(y)
  9. x 是对象,y 不是字符串或者数字,判断 ToPrimitive(x) == y
  10. 返回 false

觉得看规范判断太复杂?我们来分几种情况来看:

1. null 和 undefined

1
console.log(null == undefined);

看规范第 2、3 步:

  1. x 是 null 并且 y 是 undefined,返回 true
  1. x 是 undefined 并且 y 是 null,返回 true

所以例子的结果自然为 true

这时候,我们可以回想在《JavaScript 专题之类型判断 (上)》中见过的一段 demo,就是编写判断对象的类型 type 函数时,如果输入值是 undefined,就返回字符串 undefined,如果是 null,就返回字符串 null

如果是你,你会怎么写呢?

下面是 jQuery 的写法:

1
2
3
4
5
6
function type(obj) {
if (obj == null) {
return obj + '';
}
...
}

2. 字符串与数字

结果肯定是 true,问题在于是字符串转化成了数字和数字比较还是数字转换成了字符串和字符串比较呢?

看规范第 4、5 步:

4.x 是数字,y 是字符串,判断 x == ToNumber(y)

5.x 是字符串,y 是数字,判断 ToNumber(x) == y

结果很明显,都是转换成数字后再进行比较

3. 布尔值和其他类型

当要判断的一方出现 false 的时候,往往最容易出错,比如上面这个例子,凭直觉应该是 true,毕竟 Boolean('2') 的结果可是 true,但这道题的结果却是 false。

归根到底,还是要看规范,规范第 6、7 步:

6.x 是布尔值,判断 ToNumber(x) == y

7.y 是布尔值,判断 x ==ToNumber(y)

当一方出现布尔值的时候,就会对这一方的值进行 ToNumber 处理,也就是说 true 会被转化成 1,

true == '2' 就相当于 1 == '2' 就相当于 1 == 2,结果自然是 false

所以当一方是布尔值的时候,会对布尔值进行转换,因为这种特性,所以尽量少使用 xx == truexx == false 的写法。

比如:

1
2
3
4
5
6
7
// 不建议
if (a == true) {}

// 建议
if (a) {}
// 更好
if (!!a) {}

4. 对象与非对象

1
console.log( 42 == ['42'])

看规范第 8、9 步:

  1. x 不是字符串或者数字,y 是对象,判断 x == ToPrimitive(y)
  1. x 是对象,y 不是字符串或者数字,判断 ToPrimitive(x) == y

以这个例子为例,会使用 ToPrimitive 处理 ['42'],调用valueOf,返回对象本身,再调用 toString,返回 '42',所以

42 == ['42'] 相当于 42 == '42' 相当于42 == 42,结果为 true

到此为止,我们已经看完了第 2、3、4、5、6、7、8、9 步,其他的一概返回 false。

其他

再多举几个例子进行分析:

1
console.log(false == undefined)

false == undefined 相当于 0 == undefined 不符合上面的情形,执行最后一步 返回 false

false == [] 相当于 0 == [] 相当于 0 == '' 相当于 0 == 0,结果返回 true

首先会执行 ![] 操作,转换成 false,相当于 [] == false 相当于 [] == 0 相当于 '' == 0 相当于 0 == 0,结果返回 true

最后再举一些会让人踩坑的例子:

1
2
3
4
5
6
7
8
9
10
11
12
console.log(false == "0")
console.log(false == 0)
console.log(false == "")

console.log("" == 0)
console.log("" == [])

console.log([] == 0)

console.log("" == [null])
console.log(0 == "\n")
console.log([] == 0)

以上均返回 true

其他

除了这两种情形之外,其实还有很多情形会发生隐式类型转换,比如if? :&&等情况,但相对来说,比较简单,就不再讲解。

JavaScript深入之执行上下文

《JavaScript 深入之执行上下文栈》中讲到,当 JavaScript 代码执行一段可执行代码 (executable code) 时,会创建对应的执行上下文(execution context)。

对于每个执行上下文,都有三个重要属性:

  • 变量对象 (Variable object,VO)
  • 作用域链 (Scope chain)
  • this

然后分别在《JavaScript 深入之变量对象》《JavaScript 深入之作用域链》《JavaScript 深入之从 ECMAScript 规范解读 this》中讲解了这三个属性。

阅读本文前,如果对以上的概念不是很清楚,希望先阅读这些文章。

因为,这一篇,我们会结合着所有内容,讲讲执行上下文的具体处理过程。

思考题

《JavaScript 深入之词法作用域和动态作用域》中,提出这样一道思考题:

1
2
3
4
5
6
7
8
9
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();
1
2
3
4
5
6
7
8
9
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();

两段代码都会打印’local scope’。虽然两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?

紧接着就在下一篇《JavaScript 深入之执行上下文栈》中,讲到了两者的区别在于执行上下文栈的变化不一样,然而,如果是这样笼统的回答,依然显得不够详细,本篇就会详细的解析执行上下文栈和执行上下文的具体变化过程。

具体执行分析

我们分析第一段代码:

1
2
3
4
5
6
7
8
9
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();

执行过程如下:

  1. 执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈
1
2
3
ECStack = [
globalContext
];
  1. 全局上下文初始化
1
2
3
4
5
globalContext = {
VO: [global],
Scope: [globalContext.VO],
this: globalContext.VO
}
  1. 初始化的同时,checkscope 函数被创建,保存作用域链到函数的内部属性[[scope]]
1
2
3
checkscope.[[scope]] = [
globalContext.VO
];
  1. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
1
2
3
4
ECStack = [
checkscopeContext,
globalContext
];

4.checkscope 函数执行上下文初始化:

  1. 复制函数 [[scope]] 属性创建作用域链,
  2. 用 arguments 创建活动对象,
  3. 初始化活动对象,即加入形参、函数声明、变量声明,
  4. 将活动对象压入 checkscope 作用域链顶端。

同时 f 函数被创建,保存作用域链到 f 函数的内部属性[[scope]]

1
2
3
4
5
6
7
8
9
10
11
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope: undefined,
f: reference to function f(){}
},
Scope: [AO, globalContext.VO],
this: undefined
}
  1. 执行 f 函数,创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈
1
2
3
4
5
ECStack = [
fContext,
checkscopeContext,
globalContext
];

6.f 函数执行上下文初始化, 以下跟第 4 步相同:

  1. 复制函数 [[scope]] 属性创建作用域链
  2. 用 arguments 创建活动对象
  3. 初始化活动对象,即加入形参、函数声明、变量声明
  4. 将活动对象压入 f 作用域链顶端
1
2
3
4
5
6
7
8
9
fContext = {
AO: {
arguments: {
length: 0
}
},
Scope: [AO, checkscopeContext.AO, globalContext.VO],
this: undefined
}

7.f 函数执行,沿着作用域链查找 scope 值,返回 scope 值

8.f 函数执行完毕,f 函数上下文从执行上下文栈中弹出

1
2
3
4
ECStack = [
checkscopeContext,
globalContext
];

9.checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出

1
2
3
ECStack = [
globalContext
];

第二段代码就留给大家去尝试模拟它的执行过程。

1
2
3
4
5
6
7
8
9
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();

不过,在下一篇《JavaScript 深入之闭包》中也会提及这段代码的执行过程。

JavaScript深入之执行上下文栈

顺序执行?

如果要问到 JavaScript 代码执行顺序的话,想必写过 JavaScript 的开发者都会有个直观的印象,那就是顺序执行,毕竟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var foo = function () {

console.log('foo1');

}

foo(); // foo1

var foo = function () {

console.log('foo2');

}

foo(); // foo2

然而去看这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo() {

console.log('foo1');

}

foo(); // foo2

function foo() {

console.log('foo2');

}

foo(); // foo2

打印的结果却是两个 foo2

刷过面试题的都知道这是因为 JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行。当执行一段代码的时候,会进行一个 “准备工作”,比如第一个例子中的变量提升,和第二个例子中的函数提升。

但是本文真正想让大家思考的是:这个 “一段一段” 中的 “段” 究竟是怎么划分的呢?

到底 JavaScript 引擎遇到一段怎样的代码时才会做 “准备工作” 呢?

可执行代码

这就要说到 JavaScript 的可执行代码 (executable code) 的类型有哪些了?

其实很简单,就三种,全局代码、函数代码、eval 代码。

举个例子,当执行到一个函数的时候,就会进行准备工作,这里的 “准备工作”,让我们用个更专业一点的说法,就叫做 “执行上下文 (execution context)”。

执行上下文栈

接下来问题来了,我们写的函数多了去了,如何管理创建的那么多执行上下文呢?

所以 JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文

为了模拟执行上下文栈的行为,让我们定义执行上下文栈是一个数组:

试想当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前, ECStack 最底部永远有个 globalContext:

1
2
3
ECStack = [
globalContext
];

现在 JavaScript 遇到下面的这段代码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
function fun3() {
console.log('fun3')
}

function fun2() {
fun3();
}

function fun1() {
fun2();
}

fun1();

当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。知道了这样的工作原理,让我们来看看如何处理上面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 伪代码

// fun1()
ECStack.push(<fun1> functionContext);

// fun1中竟然调用了fun2,还要创建fun2的执行上下文
ECStack.push(<fun2> functionContext);

// 擦,fun2还调用了fun3!
ECStack.push(<fun3> functionContext);

// fun3执行完毕
ECStack.pop();

// fun2执行完毕
ECStack.pop();

// fun1执行完毕
ECStack.pop();

// javascript接着执行下面的代码,但是ECStack底层永远有个globalContext

解答思考题

好啦,现在我们已经了解了执行上下文栈是如何处理执行上下文的,所以让我们看看上篇文章《JavaScript 深入之词法作用域和动态作用域》最后的问题:

1
2
3
4
5
6
7
8
9
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();
1
2
3
4
5
6
7
8
9
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();

两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?

答案就是执行上下文栈的变化不一样。

让我们模拟第一段代码:

1
2
3
4
ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();

让我们模拟第二段代码:

1
2
3
4
ECStack.push(<checkscope> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();

是不是有些不同呢?

当然了,这样概括的回答执行上下文栈的变化不同,是不是依然有一种意犹未尽的感觉呢,为了更详细讲解两个函数执行上的区别,我们需要探究一下执行上下文到底包含了哪些内容,所以欢迎阅读下一篇《JavaScript 深入之变量对象》。

JavaScript深入之类数组对象与arguments

类数组对象

所谓的类数组对象:

拥有一个 length 属性和若干索引属性的对象

举个例子:

1
2
3
4
5
6
7
8
var array = ['name', 'age', 'sex'];

var arrayLike = {
0: 'name',
1: 'age',
2: 'sex',
length: 3
}

即便如此,为什么叫做类数组对象呢?

那让我们从读写、获取长度、遍历三个方面看看这两个对象。

读写

1
2
3
4
5
console.log(array[0]); // name
console.log(arrayLike[0]); // name

array[0] = 'new name';
arrayLike[0] = 'new name';

长度

1
2
console.log(array.length); // 3
console.log(arrayLike.length); // 3

遍历

1
2
3
4
5
6
for(var i = 0, len = array.length; i < len; i++) {
……
}
for(var i = 0, len = arrayLike.length; i < len; i++) {
……
}

是不是很像?

那类数组对象可以使用数组的方法吗?比如:

然而上述代码会报错: arrayLike.push is not a function

所以终归还是类数组呐……

调用数组方法

如果类数组就是任性的想用数组的方法怎么办呢?

既然无法直接调用,我们可以用 Function.call 间接调用:

1
2
3
4
5
6
7
8
9
10
11
var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 }

Array.prototype.join.call(arrayLike, '&'); // name&age&sex

Array.prototype.slice.call(arrayLike, 0); // ["name", "age", "sex"]
// slice可以做到类数组转数组

Array.prototype.map.call(arrayLike, function(item){
return item.toUpperCase();
});
// ["NAME", "AGE", "SEX"]

类数组转数组

在上面的例子中已经提到了一种类数组转数组的方法,再补充三个:

1
2
3
4
5
6
7
8
9
var arrayLike = {0: 'name', 1: 'age', 2: 'sex', length: 3 }
// 1. slice
Array.prototype.slice.call(arrayLike); // ["name", "age", "sex"]
// 2. splice
Array.prototype.splice.call(arrayLike, 0); // ["name", "age", "sex"]
// 3. ES6 Array.from
Array.from(arrayLike); // ["name", "age", "sex"]
// 4. apply
Array.prototype.concat.apply([], arrayLike)

那么为什么会讲到类数组对象呢?以及类数组有什么应用吗?

要说到类数组对象,Arguments 对象就是一个类数组对象。在客户端 JavaScript 中,一些 DOM 方法 (document.getElementsByTagName() 等)也返回类数组对象。

Arguments 对象

接下来重点讲讲 Arguments 对象。

Arguments 对象只定义在函数体中,包括了函数的参数和其他属性。在函数体中,arguments 指代该函数的 Arguments 对象。

举个例子:

1
2
3
4
5
function foo(name, age, sex) {
console.log(arguments);
}

foo('name', 'age', 'sex')

打印结果如下:

我们可以看到除了类数组的索引属性和 length 属性之外,还有一个 callee 属性,接下来我们一个一个介绍。

length 属性

Arguments 对象的 length 属性,表示实参的长度,举个例子:

1
2
3
4
5
6
7
8
9
10
function foo(b, c, d){
console.log("实参的长度为:" + arguments.length)
}

console.log("形参的长度为:" + foo.length)

foo(1)

// 形参的长度为:3
// 实参的长度为:1

callee 属性

Arguments 对象的 callee 属性,通过它可以调用函数自身。

讲个闭包经典面试题使用 callee 的解决方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var data = [];

for (var i = 0; i < 3; i++) {
(data[i] = function () {
console.log(arguments.callee.i)
}).i = i;
}

data[0]();
data[1]();
data[2]();

// 0
// 1
// 2

接下来讲讲 arguments 对象的几个注意要点:

arguments 和对应参数的绑定

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
function foo(name, age, sex, hobbit) {

console.log(name, arguments[0]); // name name

// 改变形参
name = 'new name';

console.log(name, arguments[0]); // new name new name

// 改变arguments
arguments[1] = 'new age';

console.log(age, arguments[1]); // new age new age

// 测试未传入的是否会绑定
console.log(sex); // undefined

sex = 'new sex';

console.log(sex, arguments[2]); // new sex undefined

arguments[3] = 'new hobbit';

console.log(hobbit, arguments[3]); // undefined new hobbit

}

foo('name', 'age')

传入的参数,实参和 arguments 的值会共享,当没有传入时,实参与 arguments 值不会共享

除此之外,以上是在非严格模式下,如果是在严格模式下,实参和 arguments 是不会共享的。

传递参数

将参数从一个函数传递到另一个函数

1
2
3
4
5
6
7
8
9
// 使用 apply 将 foo 的参数传递给 bar
function foo() {
bar.apply(this, arguments);
}
function bar(a, b, c) {
console.log(a, b, c);
}

foo(1, 2, 3)

强大的 ES6

使用 ES6 的 … 运算符,我们可以轻松转成数组。

1
2
3
4
5
function func(...arguments) {
console.log(arguments); // [1, 2, 3]
}

func(1, 2, 3);

应用

arguments 的应用其实很多,在下个系列,也就是 JavaScript 专题系列中,我们会在 jQuery 的 extend 实现、函数柯里化、递归等场景看见 arguments 的身影。这篇文章就不具体展开了。

如果要总结这些场景的话,暂时能想到的包括:

  1. 参数不定长
  2. 函数柯里化
  3. 递归调用
  4. 函数重载

欢迎留言回复。

JavaScript深入之继承的多种方式和优缺点

写在前面

本文讲解 JavaScript 各种继承方式和优缺点。

但是注意:

这篇文章更像是笔记,哎,再让我感叹一句:《JavaScript 高级程序设计》写得真是太好了!

1. 原型链继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Parent () {
this.name = 'kevin';
}

Parent.prototype.getName = function () {
console.log(this.name);
}

function Child () {

}

Child.prototype = new Parent();

var child1 = new Child();

console.log(child1.getName()) // kevin

问题:

  1. 引用类型的属性被所有实例共享,举个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Parent () {
this.names = ['kevin', 'daisy'];
}

function Child () {

}

Child.prototype = new Parent();

var child1 = new Child();

child1.names.push('yayu');

console.log(child1.names); // ["kevin", "daisy", "yayu"]

var child2 = new Child();

console.log(child2.names); // ["kevin", "daisy", "yayu"]
  1. 在创建 Child 的实例时,不能向 Parent 传参

2. 借用构造函数 (经典继承)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Parent () {
this.names = ['kevin', 'daisy'];
}

function Child () {
Parent.call(this);
}

var child1 = new Child();

child1.names.push('yayu');

console.log(child1.names); // ["kevin", "daisy", "yayu"]

var child2 = new Child();

console.log(child2.names); // ["kevin", "daisy"]

优点:

  1. 避免了引用类型的属性被所有实例共享
  1. 可以在 Child 中向 Parent 传参

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Parent (name) {
this.name = name;
}

function Child (name) {
Parent.call(this, name);
}

var child1 = new Child('kevin');

console.log(child1.name); // kevin

var child2 = new Child('daisy');

console.log(child2.name); // daisy

缺点:

方法都在构造函数中定义,每次创建实例都会创建一遍方法。

3. 组合继承

原型链继承和经典继承双剑合璧。

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
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function () {
console.log(this.name)
}

function Child (name, age) {

Parent.call(this, name);

this.age = age;

}

Child.prototype = new Parent();
Child.prototype.constructor = Child;

var child1 = new Child('kevin', '18');

child1.colors.push('black');

console.log(child1.name); // kevin
console.log(child1.age); // 18
console.log(child1.colors); // ["red", "blue", "green", "black"]

var child2 = new Child('daisy', '20');

console.log(child2.name); // daisy
console.log(child2.age); // 20
console.log(child2.colors); // ["red", "blue", "green"]

优点:融合原型链继承和构造函数的优点,是 JavaScript 中最常用的继承模式。

4. 原型式继承

1
2
3
4
5
function createObj(o) {
function F(){}
F.prototype = o;
return new F();
}

就是 ES5 Object.create 的模拟实现,将传入的对象作为创建的对象的原型。

缺点:

包含引用类型的属性值始终都会共享相应的值,这点跟原型链继承一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
var person = {
name: 'kevin',
friends: ['daisy', 'kelly']
}

var person1 = createObj(person);
var person2 = createObj(person);

person1.name = 'person1';
console.log(person2.name); // kevin

person1.firends.push('taylor');
console.log(person2.friends); // ["daisy", "kelly", "taylor"]

注意:修改person1.name的值,person2.name的值并未发生改变,并不是因为person1person2有独立的 name 值,而是因为person1.name = 'person1',给person1添加了 name 值,并非修改了原型上的 name 值。

5. 寄生式继承

创建一个仅用于封装继承过程的函数,该函数在内部以某种形式来做增强对象,最后返回对象。

1
2
3
4
5
6
7
function createObj (o) {
var clone = Object.create(o);
clone.sayName = function () {
console.log('hi');
}
return clone;
}

缺点:跟借用构造函数模式一样,每次创建对象都会创建一遍方法。

6. 寄生组合式继承

为了方便大家阅读,在这里重复一下组合继承的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function () {
console.log(this.name)
}

function Child (name, age) {
Parent.call(this, name);
this.age = age;
}

Child.prototype = new Parent();

var child1 = new Child('kevin', '18');

console.log(child1)

组合继承最大的缺点是会调用两次父构造函数。

一次是设置子类型实例的原型的时候:

1
Child.prototype = new Parent();

一次在创建子类型实例的时候:

1
var child1 = new Child('kevin', '18');

回想下 new 的模拟实现,其实在这句中,我们会执行:

在这里,我们又会调用了一次 Parent 构造函数。

所以,在这个例子中,如果我们打印 child1 对象,我们会发现 Child.prototype 和 child1 都有一个属性为colors,属性值为['red', 'blue', 'green']

那么我们该如何精益求精,避免这一次重复调用呢?

如果我们不使用 Child.prototype = new Parent() ,而是间接的让 Child.prototype 访问到 Parent.prototype 呢?

看看如何实现:

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
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function () {
console.log(this.name)
}

function Child (name, age) {
Parent.call(this, name);
this.age = age;
}

// 关键的三步
var F = function () {};

F.prototype = Parent.prototype;

Child.prototype = new F();


var child1 = new Child('kevin', '18');

console.log(child1);

最后我们封装一下这个继承方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function object(o) {
function F() {}
F.prototype = o;
return new F();
}

function prototype(child, parent) {
var prototype = object(parent.prototype);
prototype.constructor = child;
child.prototype = prototype;
}

// 当我们使用的时候:
prototype(Child, Parent);

引用《JavaScript 高级程序设计》中对寄生组合式继承的夸赞就是:

这种方式的高效率体现它只调用了一次 Parent 构造函数,并且因此避免了在 Parent.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。