你不知道的JavaScript(this和对象原型)

前言

本书第二部分“this 和对象原型”非常不错,它很好地衔接了本书第一部分“作用域和闭 包”,进一步介绍了 JavaScript 语言中非常重要的两个部分,this 关键字和原型。这两个部 分对于你未来的学习来说非常重要,它们是使用 JavaScript 进行编程的基础。只有掌握了 如何创建、关联和扩展对象,你才能用 JavaScript 创建类似谷歌地图这样大型的复杂应用。

目录

第1章 关于this

第2章 this全面解析

第3章 对象

第4章 混合对象“类”

第5章 原型

第6章 行为委托

第1章 关于this

如果不使用 this,那就需要给函数显式传入一个上下文对象。然而,this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用。随着你的使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱,使用 this 则不会这样。当我们介绍对象和原型时,你就会明白函数可以自动引用合适的上下文对象 有多重要。

关于 this 的错误认识

扫描二维码关注公众号,回复: 13169711 查看本文章

1.把 this 理解成指向函数自身 2.this 指向函数的作用域。这个问题有点复杂,因为在某种情况下它是正确的,但是在其他情况下它却是错误的。

之前我们说过 this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调 用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包 含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的 其中一个属性,会在函数执行的过程中用到。

学习 this 的第一步是明白 this 既不指向函数自身也不指向函数的词法作用域,你也许被 这样的解释误导过,但其实它们都是错误的。this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。

第2章 this全面解析

通常来说,寻找调用位置就是寻找“函数被调用的位置”,但是做起来并没有这么简单, 因为某些编程模式可能会隐藏真正的调用位置。

最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的 调用位置就在当前正在执行的函数的前一个调用中。

function baz() {
    // 当前调用栈是:baz
    // 因此,当前调用位置是全局作用域
    console.log( "baz" );
    bar(); // <-- bar 的调用位置 
}
function bar() {
    // 当前调用栈是 baz -> bar
    // 因此,当前调用位置在 baz 中
    console.log( "bar" );
    foo(); // <-- foo 的调用位置 
}
function foo() {
  // 当前调用栈是 baz -> bar -> foo // 因此,当前调用位置在 bar 中
  console.log( "foo" );
}

baz(); // <-- baz 的调用位置
复制代码

你可以把调用栈想象成一个函数调用链,就像我们在前面代码段的注释中所 写的一样。但是这种方法非常麻烦并且容易出错。另一个查看调用栈的方法 是使用浏览器的调试工具。绝大多数现代桌面浏览器都内置了开发者工具, 其中包含 JavaScript 调试器。就本例来说,你可以在工具中给 foo() 函数的 第一行代码设置一个断点,或者直接在第一行代码之前插入一条 debugger; 语句。运行代码时,调试器会在那个位置暂停,同时会展示当前位置的函数 调用列表,这就是你的调用栈。因此,如果你想要分析 this 的绑定,使用开 发者工具得到调用栈,然后找到栈中第二个元素,这就是真正的调用位置。

1.默认绑定 首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。

function foo() { 
//"use strict";
 console.log( this.a );//2
}
var a = 2;
foo(); 
复制代码

那么我们怎么知道这里应用了默认绑定呢?可以通过分析调用位置来看看函数是如何调用的。在代码中,函数是直接使用不带任何修饰的函数引用进行调用的,因此只能使用 默认绑定,无法应用其他规则。

如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此 this 会绑定 到 undefined。只有 foo() 运行在非 strict mode 下时,默认绑定才能绑定到全局对象;严格模式下与 foo() 的调用位置无关。

2.隐式绑定 另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,不过这种说法可能会造成一些误导。

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。

对象属性引用链中只有最顶层或者说最后一层会影响调用位置。举例来说:

function foo() { 
    console.log( this.a );
}
var obj2 = { 
    a: 42,
    foo: foo 
};

var obj1 = { 
    a: 2,
    obj2: obj2 
};
obj1.obj2.foo(); // 42 指向最后调用的obj2
复制代码

隐式丢失 一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上,取决于是否是严格模式。

function foo() { 
    console.log( this,this.a );//
}
var obj = { 
    a: 2,
    foo: foo 
};
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar(); // bar调用,其 this 指向 windows
复制代码

虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此此时的 bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。

function foo() { 
    console.log( this,this.a );
}
function doFoo(fn) {
    console.log( this)
    // fn 其实引用的是 foo 
    fn(); // <-- 调用位置!
}
var obj = { 
    a: 2,
    foo: foo 
};
var a = "oops, global"; // a 是全局对象的属性 
doFoo( obj.foo ); // "oops, global"
复制代码

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样。

如果把函数传入语言内置的函数而不是传入你自己声明的函数,结果是一样的,没有区别。

3.显示绑定

JavaScript 提供的绝大多数函数以及你自 己创建的所有函数都可以使用 call(..) 和 apply(..) 方法。

这两个方法是如何工作的呢?它们的第一个参数是一个对象,它们会把这个对象绑定到 this,接着在调用函数时指定这个 this。因为你可以直接指定 this 的绑定对象,因此我 们称之为显式绑定。

如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对 象,这个原始值会被转换成它的对象形式(也就是new String(..)、new Boolean(..)或者 new Number(..))。这通常被称为“装箱”。

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

var obj = { 
    a:2
};

var bar = function() { 
    foo.call( obj );
};

bar(); // 2
setTimeout( bar, 100 ); // 2 
// 硬绑定的 bar 不可能再修改它的 this 
bar.call( window ); // 2
复制代码

通过call 强制把 foo 的 this 绑定到了 obj。无论之后如何调用函数,它总会手动在 obj 上调用 foo。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。

由于硬绑定是一种非常常用的模式,所以在 ES5 中提供了内置的方法 Function.prototype.bind,它的用法如下:

function foo(something) { 
    console.log( this.a, something ); 
    return this.a + something;
}

var obj = { 
    a:2
};
var bar = foo.bind( obj );
var b = bar( 3 ); // 2 3 
console.log( b ); // 5
复制代码

bind(..) 会返回一个硬编码的新函数,它会把参数设置为 this 的上下文并调用原始函数。 2. API调用的“上下文”
第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一 个可选的参数,通常被称为“上下文”(context),其作用和 bind(..) 一样,确保你的回调 函数使用指定的 this。

举例来说:

function foo(el) {
    console.log( el, this.id );
}

var obj = {
    id: "awesome"
};

// 调用 foo(..) 时把 this 绑定到 obj 
[1, 2, 3].forEach( foo, obj );
// 1 awesome 2 awesome 3 awesome
复制代码

这些函数实际上就是通过 call(..) 或者 apply(..) 实现了显式绑定,这样你可以少些一些代码。

4.new绑定

使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。

  1. 创建(或者说构造)一个全新的对象。\
  2. 这个新对象会被执行[[原型]]连接。\
  3. 这个新对象会绑定到函数调用的this。\
  4. 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。

new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。

优先级

就优先级而言,new 绑定高于显式绑定高于隐式绑定高于默认绑定。

判断this

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:

  1. 函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象。
     var bar = new foo()
复制代码
  1. 函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this绑定的是 指定的对象。
     var bar = foo.call(obj2)
复制代码
  1. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上 下文对象。
     var bar = obj1.foo()
复制代码
  1. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到 全局对象。
     var bar = foo()
复制代码

就是这样。对于正常的函数调用来说,理解了这些知识你就可以明白 this 的绑定原理了。不过......凡事总有例外。

绑定例外

1.被忽略的this 如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际应用的是默认绑定规则

什么情况下你会传入 null 呢?
一种非常常见的做法是使用 apply(..) 来“展开”一个数组,并当作参数传入一个函数。

类似地,bind(..) 可以对参数进行柯里化(预先设置一些参数),这种方法有时非常有用:

function foo(a,b) {
    console.log( "a:" + a + ", b:" + b );
}
// 把数组“展开”成参数
foo.apply( null, [2, 3] ); // a:2, b:3
// 我们的 DMZ 空对象

// var ø = Object.create( null ); // 把数组展开成参数,更安全的做法
// foo.apply( ø, [2, 3] ); // a:2, b:3


// 使用 bind(..) 进行柯里化\
var bar = foo.bind( null, 2 ); 
bar( 3 ); // a:2, b:3
复制代码

2.间接引用

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

var a = 2;
var o = { a: 3, foo: foo }; 
var p = { a: 4 };
o.foo(); // 3
(p.foo = o.foo)(); // 2
复制代码

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo() 而不是 p.foo() 或者 o.foo()。根据我们之前说过的,这里会应用默认绑定。

3.软绑定

if (!Function.prototype.softBind) {
    Function.prototype.softBind = function(obj) {

        var fn = this;
        // 捕获所有 curried 参数
        var curried = [].slice.call( arguments, 1 ); 
        var bound = function() {
            return fn.apply(
                (!this || this === (window || global)) ?
                obj : this
                curried.concat.apply( curried, arguments )
            ); 
        };
        bound.prototype = Object.create( fn.prototype );

        return bound; 
    };
}
复制代码

this词法

箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决 定 this。虽然 self = this 和箭头函数看起来都可以取代 bind(..),但是从本质上来说,它们想替 代的是 this 机制。

如果你经常编写 this 风格的代码,但是绝大部分时候都会使用 self = this 或者箭头函数 来否定 this 机制,那你或许应当:

  1. 只使用词法作用域并完全抛弃错误this风格的代码;\
  2. 完全采用 this 风格,在必要时使用 bind(..),尽量避免使用 self = this 和箭头函数。

当然,包含这两种代码风格的程序可以正常运行,但是在同一个函数或者同一个程序中混合使用这两种风格通常会使代码更难维护,并且可能也会更难编写。

第3章 对象

语法

对象可以通过两种形式定义:声明(文字)形式和构造形式。 对象的文字语法大概是这样:

var myObj = { 
    key: value
    // ... 
};
复制代码

构造形式大概是这样:

var myObj = new Object(); 
myObj.key = value;
复制代码

构造形式和文字形式生成的对象是一样的。唯一的区别是,在文字声明中你可以添加多个 键 / 值对,但是在构造形式中你必须逐个添加属性。

类型

对象是 JavaScript 的基础。 在 JavaScript 中一共有六种主要类型(术语是“语言类型”):

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

注意,简单基本类型(string、boolean、number、null 和 undefined)本身并不是对象。 null 有时会被当作一种对象类型,但是这其实只是语言本身的一个 bug,即对 null 执行 typeof null 时会返回字符串 "object"。实际上,null 本身是基本类型。

内置对象

• String • Number • Boolean
• Object • Function • Array
• Date • RegExp • Error

但是在 JavaScript 中,它们实际上只是一些内置函数。这些内置函数可以当作构造函数 (由 new 产生的函数调用——参见第 2 章)来使用,从而可以构造一个对应子类型的新对象。

内容

对象的内容是由一些存储在特定命名位置的(任意类型的)值组成的,我们称之为属性。如果要访问 myObject 中 a 位置上的值,我们需要使用 . 操作符或者 [] 操作符。.a 语法通 常被称为“属性访问”,["a"] 语法通常被称为“键访问”。实际上它们访问的是同一个位 置,并且会返回相同的值 2,所以这两个术语是可以互换的。

在 ES5 之前,JavaScript 语言本身并没有提供可以直接检测属性特性的方法,比如判断属性是否是只读。但是从 ES5 开始,所有的属性都具备了属性描述符。

在 ES5 中可以使用 getter 和 setter 部分改写默认操作,但是只能应用在单个属性上,无法 应用在整个对象上。getter 是一个隐藏函数,会在获取属性值时调用。setter 也是一个隐藏 函数,会在设置属性值时调用。

for..in 循环可以用来遍历对象的可枚举属性列表(包括 [[Prototype]] 链)。ES5 中增加了一些数组的辅助迭代器,包括 forEach(..)、every(..) 和 some(..)。每种辅 助迭代器都可以接受一个回调函数并把它应用到数组的每个元素上,唯一的区别就是它们 对于回调函数返回值的处理方式不同。forEach(..) 会遍历数组中的所有值并忽略回调函数的返回值。every(..) 会一直运行直到回调函数返回 false(或者“假”值),some(..) 会一直运行直到回调函数返回 true(或者 “真”值)。

every(..) 和 some(..) 中特殊的返回值和普通 for 循环中的 break 语句类似,它们会提前 终止遍历。

使用 for..in 遍历对象是无法直接获取属性值的,因为它实际上遍历的是对象中的所有可 枚举属性,你需要手动获取属性值。

那么如何直接遍历值而不是数组下标(或者对象属性)呢?幸好,ES6 增加了一种用来遍 历数组的 for..of 循环语法(如果对象本身定义了迭代器的话也可以遍历对象):

var myArray = [ 1, 2, 3 ];
for (var v of myArray) { 
    console.log( v );
}
// 1 // 2 // 3
复制代码

for..of 循环首先会向被访问对象请求一个迭代器对象,然后通过调用迭代器对象的 next() 方法来遍历所有返回值。

数组有内置的 @@iterator,因此 for..of 可以直接应用在数组上。我们使用内置的 @@ iterator 来手动遍历数组,看看它是怎么工作的:

var myArray = [ 1, 2, 3 ];
var it = myArray[Symbol.iterator]();

it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { done:true }
复制代码

第4章 混合对象“类”

面向类的设计模式:实例化(instantiation)、继承(inheritance)和 (相对)多态(polymorphism)。

JavaScript 只有一些近似类的语法元素 (比如 new 和 instanceof),不过在后来的 ES6 中新增了一些元素,比如 class 关键字(参见附 录 A )。这是不是意味着 JavaScript 中实际上有类呢?简单来说:不是。

虽然有近似类的语法,但是 JavaScript 的机制似乎一直在阻止你使用类设计模式。在 近似类的表象之下,JavaScript 的机制其实和类完全不同。语法糖和(广泛使用的) JavaScript“类”库试图掩盖这个现实,但是你迟早会面对它:其他语言中的类和 JavaScript 中的“类”并不一样。

类实例是由一个特殊的类方法构造的,这个方法名通常和类名相同,被称为构造函数。这个方法的任务就是初始化实例需要的所有信息(状态)。

类构造函数属于类,而且通常和类同名。此外,构造函数大多需要用 new 来调,这样语言引擎才知道你想要构造一个新的类实例。

定义好一个子类之后,相对于父类来说它就是一个独立并且完全不同的类。子类会 包含父类行为的原始副本,但是也可以重写所有继承的行为甚至定义新行为。非常重要的一点是,我们讨论的父类和子类并不是实例。

有些面向类的语言允许你继承多个“父类”。多重继承意味着所有父类的定义都会被复制到子类中。相比之下,JavaScript 要简单得多:它本身并不提供“多重继承”功能。许多人认为这是 件好事,因为使用多重继承的代价太高。然而这无法阻挡开发者们的热情,他们会尝试各 种各样的办法来实现多重继承,我们马上就会看到。

在继承或者实例化时,JavaScript 的对象机制并不会自动执行复制行为。简单来说, JavaScript 中只有对象,并不存在可以被实例化的“类”。一个对象并不会被复制到其他对 象,它们会被关联起来(参见第 5 章)。

由于在其他语言中类表现出来的都是复制行为,因此 JavaScript 开发者也想出了一个方法来 模拟类的复制行为,这个方法就是混入。接下来我们会看到两种类型的混入:显式和隐式。

由于 JavaScript 不会自动实现 父类 到 子类 的复制行为,所以我们需要手动实现复制功能。这个功能在许多库和框架中被称为 extend(..),但是为了方便理解我们称之为 mixin(..)。

有一点需要注意,我们处理的已经不再是类了,因为在 JavaScript 中不存在 类,所谓的 “父类” 和 “子类” 都是对象,供我们分别进行复制和粘贴。

JavaScript(在 ES6 之前;参见附录 A)并没有相对多态的机制。在 JavaScript 中(由于屏蔽)使用显式伪多态会在所有需要使用(伪)多态引用的地 方创建一个函数关联,这会极大地增加维护成本。此外,由于显式伪多态可以模拟多重继 承,所以它会进一步增加代码的复杂度和维护难度。

使用伪多态通常会导致代码变得更加复杂、难以阅读并且难以维护,因此应当尽量避免使 用显式伪多态,因为这样做往往得不偿失。

显式混入模式的一种变体被称为“寄生继承”,它既是显式的又是隐式的,主要推广者是 Douglas Crockford。

隐式混入和之前提到的显式伪多态很像,因此也具备同样的问题。

第5章 原型

JavaScript 中的对象有一个特殊的 [[Prototype]] 内置属性,其实就是对于其他对象的引用。几乎所有的对象在创建时 [[Prototype]] 属性都会被赋予一个非空的值。

如果对象在当前自身属性中也找不到要查找的属性并且 [[Prototype]] 链不为空的话,就会沿着原型链继续查找下去。这个过程会持续到找到匹配的属性名或者查找完整条 [[Prototype]] 链。如果是后者的话, [[Get]] 操作的返回值是 undefined。这个过程所形成的的链路称之为原型链。

所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype。

如果属性名 foo 既出现在 myObject 中也出现在 myObject 的 [[Prototype]] 链上层,那 么就会发生屏蔽。myObject 中包含的 foo 属性会屏蔽原型链上层的所有 foo 属性,因为myObject.foo 总是会选择原型链中最底层的 foo 属性。

屏蔽比我们想象中更加复杂。

  1. 如果在[[Prototype]]链上层存在名为foo的普通数据访问属性(参见第3章)并且没 有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo 的新 属性,它是屏蔽属性。
  2. 如果在[[Prototype]]链上层存在foo,但是它被标记为只读(writable:false),那么 无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会 抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
  3. 如果在[[Prototype]]链上层存在foo并且它是一个setter(参见第3章),那就一定会 调用这个 setter。foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义 foo 这 个 setter。

大多数开发者都认为如果向 [[Prototype]] 链上层已经存在的属性([[Put]])赋值,就一 定会触发屏蔽,但是如你所见,三种情况中只有一种(第一种)是这样的。

有些情况下会隐式产生屏蔽,一定要当心。思考下面的代码:

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
复制代码

尽管 myObject.a++ 看起来应该(通过委托)查找并增加 anotherObject.a 属性,但是别忘 了 ++ 操作相当于 myObject.a = myObject.a + 1。因此 ++ 操作首先会通过 [[Prototype]] 查找属性 a 并从 anotherObject.a 获取当前属性值 2,然后给这个值加 1,接着用 [[Put]] 将值 3 赋给 myObject 中新建的屏蔽属性 a,天呐!

修改委托属性时一定要小心。如果想让 anotherObject.a 的值增加,唯一的办法是 anotherObject.a++。

JavaScript 中只有对象。JavaScript 和面向类的语言不同,它并没有类来作为对象的抽象模式或者说蓝图。

在 JavaScript 中,我们并不会将一个对象(“类”)复制到另一个对象(“实例”),只是将它们 关联起来。这个机制通常被称为原型继承。它常常被视为动态语言版本 的类继承。这个名称主要是为了对应面向类的世界中“继承”的意义,但是违背(写作违 背,读作推翻)了动态脚本中对应的语义。

“继承”这个词会让人产生非常强的心理预期(参见第 4 章)。仅仅在前面加上“原型”并 不能区分出 JavaScript 中和类继承几乎完全相反的行为,因此在过去 20 年中造成了极大的 误解。

因此我认为这个容易混淆的组合术语“原型继承”(以及使用其他面向类的术语比如 “类”、“构造函数”、“实例”、“多态”,等等)严重影响了大家对于 JavaScript 机制真实原理的理解。

继承意味着复制操作,JavaScript(默认)并不会复制对象属性。相反,JavaScript 会在两 个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。 委托(参见第 6 章)这个术语可以更加准确地描述 JavaScript 中对象的关联机制。

function Foo() { 
    // ...
 }
 Foo.prototype.constructor === Foo; // true

 var a = new Foo();
 a.constructor === Foo; // true
复制代码

按照 JavaScript 世界的惯例,“类”名首字母要大写,所以名字写作 Foo 而 非 foo 似乎也提示它是一个“类”。显而易见,是吧 ?!
这个惯例影响力非常大,以至于如果你用 new 来调用小写方法或者不用 new 调用首字母大写的函数,许多 JavaScript 开发者都会责怪你。这很令人吃惊, 我们竟然会如此努力地维护 JavaScript 中(假)“面向类”的权力,尽管对于 JavaScript 引擎来说首字母大写没有任何意义。

上一段代码很容易让人认为 Foo 是一个构造函数,因为我们使用 new 来调用它并且看到它 “构造”了一个对象。

实际上,Foo 和你程序中的其他函数没有任何区别。函数本身并不是构造函数,然而,当 你在普通的函数调用前面加上 new 关键字之后,就会把这个函数调用变成一个“构造函数 调用”。实际上,new 会劫持所有普通函数并用构造对象的形式来调用它。

Foo只是一个普通的函数,但是使用 new 调用时,它就会构造一个对象并赋值 给 a,这看起来像是 new 的一个副作用(无论如何都会构造一个对象)。这个调用是一个构 造函数调用,但是 Foo 本身并不是一个构造函数。

换句话说,在 JavaScript 中对于“构造函数”最准确的解释是,所有带 new 的函数调用。 函数不是构造函数,但是当且仅当使用 new 时,函数调用会变成“构造函数调用”。

要创建一个合适的关联对象,我们必须使用 Object.create(..) 而不是使用具有副 作用的Foo(..)。这样做唯一的缺点就是需要创建一个新对象然后把旧对象抛弃掉,不能 直接修改已有的默认对象。

如果能有一个标准并且可靠的方法来修改对象的 [[Prototype]] 关联就好了。在 ES6 之前, 我们只能通过设置 .proto 属性来实现,但是这个方法并不是标准并且无法兼容所有浏 览器。ES6 添加了辅助函数 Object.setPrototypeOf(..),可以用标准并且可靠的方法来修 改关联。

我们来对比一下两种把 Bar.prototype 关联到 Foo.prototype 的方法: 
// ES6 之前需要抛弃默认的 Bar.prototype
Bar.ptototype = Object.create( Foo.prototype );

// ES6 开始可以直接修改现有的 Bar.prototype 
Object.setPrototypeOf( Bar.prototype, Foo.prototype );
复制代码

如果忽略掉 Object.create(..) 方法带来的轻微性能损失(抛弃的对象需要进行垃圾回 收),它实际上比 ES6 及其之后的方法更短而且可读性更高。不过无论如何,这是两种完 全不同的语法。

[[Prototype]] 机制就是存在于对象中的一个内部链接,它会引用其他对象。

通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就 会继续在 [[Prototype]] 关联的对象上进行查找。同理,如果在后者中也没有找到需要的 引用就会继续查找它的 [[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。

第6章 行为委托

[[Prototype]] 机制就是指对象中的一个内部链接引用 另一个对象。

如果在第一个对象上没有找到需要的属性或者方法引用,引擎就会继续在 [[Prototype]] 关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的 [[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。

JavaScript 中这个机制的本质就是对象之间的关联关系。

在 JavaScript 中,[[Prototype]] 机制会把对象关联到其他对象。无论你多么努力地说服自 己,JavaScript 中就是没有类似“类”的抽象机制。这有点像逆流而上:你确实可以这么 做,但是如果你选择对抗事实,那要达到目的就显然会更加困难。

对象关联风格的代码还有一些不同之处。
复制代码
  1. 在上面的代码中,id 和 label 数据成员都是直接存储在 XYZ 上(而不是 Task)。通常 来说,在 [[Prototype]] 委托中最好把状态保存在委托者(XYZ、ABC)而不是委托目标

(Task)上。

  1. 在类设计模式中,我们故意让父类(Task)和子类(XYZ)中都有outputTask方法,这 样就可以利用重写(多态)的优势。在委托行为中则恰好相反:我们会尽量避免在 [[Prototype]] 链的不同级别中使用相同的命名,否则就需要使用笨拙并且脆弱的语法 来消除引用歧义(参见第 4 章)。

这个设计模式要求尽量少使用容易被重写的通用方法名,提倡使用更有描述性的方法 名,尤其是要写清相应对象行为的类型。这样做实际上可以创建出更容易理解和维护的 代码,因为方法名(不仅在定义的位置,而是贯穿整个代码)更加清晰(自文档)。

  1. this.setID(ID);XYZ中的方法首先会寻找XYZ自身是否有setID(..),但是XYZ中并没 有这个方法名,因此会通过 [[Prototype]] 委托关联到 Task 继续寻找,这时就可以找到 setID(..) 方法。此外,由于调用位置触发了 this 的隐式绑定规则(参见第 2 章),因 此虽然 setID(..) 方法在 Task 中,运行时 this 仍然会绑定到 XYZ,这正是我们想要的。 在之后的代码中我们还会看到 this.outputID(),原理相同。

换句话说,我们和 XYZ 进行交互时可以使用 Task 中的通用方法,因为 XYZ 委托了 Task。 委托行为意味着某些对象(XYZ)在找不到属性或者方法引用时会把这个请求委托给另一

个对象(Task)。 这是一种极其强大的设计模式,和父类、子类、继承、多态等概念完全不同。在你的脑海中

对象并不是按照父类到子类的关系垂直组织的,而是通过任意方向的委托关联并排组织的。

匿名函数表达式的三大主要缺点,下面我们会简单介绍一下这三个缺点,然后和简洁方法定义进行对比。 匿名函数没有 name 标识符,这会导致:

  1. 调试栈更难追踪;
  2. 自我引用(递归、事件(解除)绑定,等等)更难;
  3. 代码(稍微)更难理解。

使用 ES6 的简洁方法可以让对象关联风格更加人性化(并且仍然比典型的原型风格代码更 加简洁和优秀)。简洁方法有一个非常小但是非常重要的缺点。简洁方法没有第 1 和第 3 个缺点。

在软件架构中你可以选择是否使用类和继承设计模式。大多数开发者理所当然地认为类是 唯一(合适)的代码组织方式,但是本章中我们看到了另一种更少见但是更强大的设计模式:行为委托。

行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript 的 [[Prototype]] 机制本质上就是行为委托机制。也就是说,我们可以选择在 JavaScript 中努 力实现类机制(参见第 4 和第 5 章),也可以拥抱更自然的 [[Prototype]] 委托机制。

当你只用对象来设计代码时,不仅可以让语法更加简洁,而且可以让代码结构更加清晰。 对象关联(对象之前互相关联)是一种编码风格,它倡导的是直接创建和关联对象,不把

它们抽象成类。对象关联可以用基于 [[Prototype]] 的行为委托非常自然地实现。


作用域链是基于调用栈的,而不是代码中的作用域嵌套。 需要明确的是,事实上 JavaScript 并不具有动态作用域。它只有词法作用域,简单明了。 但是 this 机制某种程度上很像动态作用域。主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定 的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

最后简单地看一下 try/catch 带来的性能问题,并尝试回答“为什么不直接使用 IIFE 来创

建作用域”这个问题。

首先,try/catch 的性能的确很糟糕,但技术层面上没有合理的理由来说明 try/catch 必 须这么慢,或者会一直慢下去。自从 TC39 支持在 ES6 的转换器中使用 try/catch 后, Traceur 团队已经要求 Chrome 对 try/catch 的性能进行改进,他们显然有很充分的动机来 做这件事情。

其次,IIFE 和 try/catch 并不是完全等价的,因为如果将一段代码中的任意一部分拿出来 用函数进行包裹,会改变这段代码的含义,其中的 this、return、break 和 contine 都会 发生变化。IIFE 并不是一个普适的解决方案,它只适合在某些情况下进行手动操作。

猜你喜欢

转载自juejin.im/post/7030759116473630750
今日推荐