Book Reader:你不知道的JavaScript(Part Two):this和对象原型

书接上回,在了解作用域和闭包的内容后,我们继续学习JS两个重要的部分:this关键字和对象原型。我们首先看个例子。如下代码,我们想通过this获得obj对象的成员变量id。

var obj = {
  id: 'awesome',
  cool: function coolFn(){
      console.log(this.id);
  }
}
var id = 'not awesome';
obj.cool(); // awesome
setTimeout(obj.cool, 100); // not aweome

从结果我们可以得出,第二个cool()函数丢失了同this之间的绑定。this关键词是JS中最复杂的机制之一,其被自动定义在所有函数的作用域中。this提供一种优雅的方式来隐式“传递”一个对象引用,而这个对象是函数调用时的上下文对象。这是为什么使用this的原因。

this

那么this到底是什么呢?this既不是指向自身,也不是它的作用域(作用域“对象”存在引擎内部)。在排除错误的字面理解后,我们来看看this到底是什么样的运行机制。this实在运行时进行绑定,他的上下文取决于函数调用时的各种条件。当函数被调用时,会创建一个执行上下文。this就在这个上下文对象里头的一个属性,它指向什么完全取决于函数在哪里被调用。而寻找调用位置则要分析调用栈(到达当前执行位置所调用的所有函数),调用位置在当前执行函数的前一个调用中。比如在下列代码中:

function baz(){
  //当前调用栈是:baz
  bar(); // bar的调用位置
}
function bar(){
  //当前调用栈是:baz -> bar
  foo(); // foo的调用位置
}
function foo(){
  //当前调用栈是:baz -> bar -> foo
}
baz(); // baz的调用位置

找到调用位置后,还需判断this绑定对象的规则。总共有4种规则:默认绑定、、、。

  • 默认绑定:当不带任何修饰直接调用函数引用时就使用默认绑定。比如下列代码,函数调用时应用了this的默认绑定,因此this指向全局对象。值得注意的是,在严格模式下,全局对象将无法使用默认绑定,依此this会绑定到undefined。

    function foo(){
      console.log(this.a);
    }
    var a = 2;
    foo(); // 2
    
  • 隐式绑定:当调用位置有上下文对象时进行隐式绑定,而隐式绑定则会把函数调用值得this绑定到这个上下文对象。比如下列代码中的foo()被调用时,它的落脚点指向obj,this就绑定到这个对象上。值得注意的是,对象属性引用链只有最顶层或者说最后一层会受到影响。

    function foo(){
      console.log(this.a);
    }
    var obj = {
      a: 2,
      foo: foo
    };
    obj.foo(); // 2
    

    正如开头代码示例遇到的问题一样,隐式绑定会丢失绑定对象,从而将this绑定到全局对象或者undefined上。函数调用时直接将coolFn传入到回调函数中进行隐式赋值,这种隐式赋值的行为应用了默认绑定从而导致了对象丢失。

    //例子重现
    var obj = {
      id: 'awesome',
      cool: function coolFn(){
        console.log(this.id);
      }
    }
    function bar(){
      console.log(this.id);
    }
    var id = 'not awesome';
    var baz = obj.bar;
    //隐式绑定
    obj.cool(); // awesome
    //默认绑定
    baz(); // not awesome
    //在回调函数中的隐式绑定,实际上是默认绑定
    //在回调函数中做了一个隐式的赋值操作:setTimeout(func, delay){var func= coolFn };
    setTimeout(obj.cool, 100); // not awesome
    
  • 显式绑定:当使用call()、apply()强制在某个对象上调用函数时,就会进行显式绑定。这两个方法的第一个参数是一个对象,它们会把这个对象绑定在this上。比如下面的例子中,通过call(),可在调用foo时强制将this绑定到obj上。

    function foo(){
      console.log(this.a);
    }
    var obj = {
      a: 2
    };
    foo.call(obj); // 2
    

    可惜,显式绑定还是可能出现丢失绑定的情况。比如下面的例子中第一个bar调用依旧默认绑定到全局对象上。但是第二次foo调用怎么能绑定到正确的对象上呢?这里需要引入“硬绑定”的概念。

    function bar(func){
      func();
    };
    function foo(func){
      func.call(obj); //硬绑定
    };
    var obj = {
      id: 'awesome',
      cool: function coolFn(){
          console.log(this.id);
      }
    };
    var id= 'not awesome';
    bar.call(obj, obj.cool); // not awesome
    bar.call(obj, obj.cool.call(obj)); // awesome 先运行obj.cool.call(obj),并报TypeError: func is not a function错误
    foo.call(obj, obj.cool); // awesome
    

    硬绑定通过手动调用func.call(),并强制把foo的this绑定到obj。如果将硬绑定修改成 func.call(this); 也能与外围上下文配合完成显式绑定obj。而ES5内置Function对象的方法bind()来进行硬绑定。bind()会返回一个函数,它hi将参数设置为this的上下文并调用原始函数。还有许多第三方库、宿主内置函数通过call()或者apply()实现了显式绑定。

  • new绑定:在JS中,构造函数只是一些使用new操作符时被调用的普通函数,它们并没有属于某个类。使用new来调用函数时,会执行如下4步:创建对象 -> 执行原型连接 -> 绑定到函数调用的this -> 返回值。new调用函数,会构造一个新对象并绑定到this上。

    function foo(a){
      this.a = a;
    }
    var bar = new foo(2);
    console.log(bar.a); // 2
    

在这4种绑定规则中,new绑定优先级高于显式绑定,当硬绑定函数被new调用时,会使用新创建的this替换硬绑定的this。显式绑定又比隐式绑定优先级高,而默认绑定是优先级最低的。

不过凡事都有例外,当显式绑定到null、undefined时会被忽略,实际应用的是默认绑定。当然小心不要绑定到全局对象而污染全局对象。还有创建一个函数的“间接引用”,比如下例中, (p.foo = o.foo)(); 其实返回的是foo()的引用。

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 默认绑定

而软绑定则是给默认绑定指定一个默认对象,同时保留隐式绑定和显式绑定的修改this的能力。而ES6中的箭头函数的绑定是无法修改的。这和 self = this; 的效果基本是一致的。

至此,this词法的内容就告一段落了。但是对象到底是什么呢?接下来就介绍对象内容。

对象

对象是JS中的主要类型之一。JS的其他主要类型为简单基本类型(string、number、boolean、null、undefined),简单基本类型本身并不是对象。函数、数组都是对象子类型,还有其他对象子类型,被称为内置对象。

如果访问对象的属性是一个函数,一般用方法来称呼。但是从技术角度上说,一个函数不会“属于”一个方法。虽然如上述有的函数具有指向调用位置的this引用。但是这种用法其实是在运行时工具调用位置动态绑定的。最保险的说法可能是,“函数”和“方法”在JS中可以互换的。

接下来思考一下,对象的复制。复制分为浅复制和深复制。对于浅复制来说,复制出的新对象中的数值会直接被复制过来,但是引用变量还是引用原来的对象而不是赋值过来。对于深复制,变量引用的对象也会被复制过来。这里可以两项到JSON安全,在以前做项目时将JSON串反序列化时就会出现循环引用的错误。

在ES6中定义了Object.assign()方法来实现浅复制。他回遍历一个或者多个源对象的所有可枚举的自有键并复制到目标对象。

从ES5开始对象属性都具备属性描述符。其有如下描述符:

  • value: 属性数值。
  • writable: 描述可写性。当为true时,可以修改属性的值。
  • enumerable: 描述可枚举性。当为true时,可以出现在对象的属性枚举中,比如for...in循环。可以通过.propertyIsEnumerable()和Object.keys()来检查是否可以枚举。
  • configurable: 描述可配置性。当为true时,可以通过Object.defineProperty()来修改属性描述符。当为false时,除了不能配置外,还会禁止删除这个属性。
  • getter:会在获取属性值时调用。它的返回值会被当作属性访问的返回值。
  • setter:会在设置属性值时调用。

在创建对象属性时,可以使用Object.defineProperty()来添加或者修改一个属性以及描述符。当writable:false、configurable:false时,就是一个常数属性。而使用Object.preventExtensions()能禁止添加新属性并保留已有属性。Object.seal()在Object.preventExtensions()基础上还将所有属性configurable设置为false。Object.freeze()则在Object.seal()基础上讲writable设置为false,禁止对于对象本身及其任意直接属性的修改。这三个函数是层层递进的。

而关于对象属性的存在性,可以通过in和.hasOwnProperty()。in 会在对象及其原型链上检查属性。而.hasOwnProperty()值会检查属性是否存在对象中。

接下来介绍的面向类的设计模式,这些概念实际上无法直接对应到JS的对象机制。

类/继承描述了一种在软件中对真实世界的建模方法。面向对象强调的是数据和操作数据的行为本质上是相互关联的。

JS虽然有近似类的语法,但这个模式是可选的。JS中类的实现中,本身不提供“多重继承”。而对于继承来说,类的继承其实就是复制。而在JS中,通过混入来模拟类的复制行为。混入有显式和隐式。

  • 显式混入:手动实现复制功能(这或许是一个循环)。从技术角度来说,函数实际上没有被复制,复制的是函数函数引用。相反,成员属性就是直接被复制。当两个对象都有同名函数,显式的重写并不会将子类的方法覆盖,这样就相当于实现了“子类”对“父类”的属性重写。我们可以通过显式地调用父类.方法.call(this),this绑定到子类的作用域上,这就是显式伪多态。这种伪多态会在需要使用多态引用的地方创建一个函数关联,这极大的提高了维护成本。并且,这种实现方法也可以模拟多重继承(即一个子类可以从多个父类获得成员属性和方法),这又进一步增加了复杂度和维护难度。

    其实显式混入并不能完全模拟面向类的语言中的复制,JS的函数无法真正地赋值,所以你只能复制对共享对象的应用。显示混入的功能也没有看起来那么强大。虽然它可以将一个对象的属性赋值到另一个对象,但是无法解决函数引用的问题。

  • 隐式混入:隐式混入和显示伪多态有点像。通过在构造函数中调用或者方法中调用中使用父类的方法父类.方法.call(this),我们实际上“借用”了函数父类.方法()并在子类的上下文上调用了它。因此把父类的方法“混入”到了子类中。

可看到类就意味着赋值。传统的类被实例化时,它的行为会被复制到实例中。类被继承时,其行为也被复制到子类中。多态看起来似乎是子类引用到父类中,但是本质上还是复制的结果。

JS并不会自动创建对象的副本。混入模式可以用来模拟类的复制行为,但是这通常会产生丑陋和脆弱的语法和难以维护的代码。此外,混入也服务完全模拟复制行为(深度复制)。总之,在JS中模拟类是得不偿失的。那么我们使用什么模式来设计程序呢?

原型

JS'对象中有一个特殊的内置属性——[[Prototype]],其指向构造函数的原型对象。等等,我在说什么?不急,我们从最简单的开始。我们先理清构造函数、原型对象等等概念。比如下面这个例子中的Person函数就是一个构造函数,当使用new来实例化时,ruier会获得构造函数的作用域。而ruier对象有一个constructor属性来指向Person()。构造函数的prototype(原型对象)指向一个对象,这个原型对象可以让所有对象实例共享它所包含的属性和方法。而经过构造函数实例化的对象(实例)内部包含一个指针属性,指向构造函数的原型对象

function Person(){
}
var ruier = new Person();
console.log(ruier.constructor === Person);

是不是很绕,我们再看下面一个例子。构造函数Person的prototype属性指向包含其属性和方法的对象——原型对象。Person实例化的两个对象为ruier和gougou,其[[prototype]](有些实现为__proto__)属性指向其构造函数的原型对象Person.prototype。

function Person(){
}
Person.prototype.sex = 'man';
Person.prototype.age = 30;
Person.prototype.sayName = function(){
};
var ruier = new Person();
var gougou = new Person();

那我们来看看各个对象之间的关系。其构造函数的prototype和实例的[[Prototype]]都指向了原型对象。而原型的构造函数(constructor)则指向了Person。

fig1. prototype relation

给一个对象设置属性并不仅仅添加或者修改一个属性。比如, 'ruier.tel = 911; '在执行时会先检查ruier对象是否包含tel属性,如若有属性名,则修改其属性值。如果tel不存在ruier对象中,[[Prototype]]链就会被遍历,类似[[Get]]操作。如果原型链上也找不到tel属性,tel则被直接添加到ruier上。

然而,如果ruier存在于原型链上层,赋值语句会进入到不同的处理过程。1).如果原型链上的tel属性为普通数据属性并且没被标记为只读(writable: false),那么就会直接在ruier对象上添加tel属性。2).如果原型链上的tel属性被标记为只读(writable: false),那么将无法修改属性值。3).如果tel不是数据属性而是一个setter,那么一定会调用这个方法。tel则不会进行任何修改。4).如果tel同时出现在ruier和原型链上层,那么ruier中的tel属性则会屏蔽上层属性。

书中给出了一个隐式屏蔽的例子。

var fatherObj = {
  a: 2
};
var sonObj = Object.create(fatherObj);
fatherObj.a; // 2
sonObj.a; // 2
fatherObj.hasOwnProperty('a'); // true
sonObj.hasOwnProperty('a'); // false .hasOwnProperty不检查原型链
sonObj.a++; //sonObj.a = sonObj.a + 1 隐式屏蔽
fatherObj.a; // 2
sonObj.a; // 3
sonObj.hasOwnProperty('a'); // true 在sonObj赋予属性a

在这个例子中,sonObj.a++的作用相当于sonObj.a = sonObj.a + 1。首先通过原型链找到a的属性值,计算之后将其赋给sonObj。

JS没有类来作为对象的抽象模式或者说蓝图。但还是有一种模仿类的奇怪行为。其通过借用公共的prototype来模拟类的行为。在面向类的语言中,类可以被复制多次。

但像我们前面所说的,JS并没有类似的复制机制。它的[[Prototype]]也是关联同一个对象。new Foo()会生成要给新的对象,其中一步就是给新的对象一个内部的[[Prototype]]链接,关联到Foo.prototype。这两个对象通过[[Prototype]]相互关联,而没有从“所谓的类”中复制任何行为到一个对象中。这个机制叫做原型继承,其实委托这个术语估计精确。对象通过委托[[Prototype]]访问Foo的属性。

我们回到有构造函数的代码。实际上,构造函数和其他函数没有任何区别,只是带有new的函数调用。其副作用是构造并返回一个对象。

function Person(){
}
var ruier = new Person();

其实ruier对象的constructor只是通过默认的[[Prototype]]委托指向Person,这和“构造”丝毫无关。当你用字面量创建对象时不显式的定义.prototype的constructor属性,对象的constructor就指向内置的Object。这就是通过委托访问到原型链的顶端。

调用Object.create(proto, [ propertiesObject ])会凭空创建一个对象,并把对象内部的[[Prototype]]关联到你指定的对象。这种方法会抛弃默认的原型Prototype。而ES6支持使用Object.setPrototypeOf(obj, proto)来修改原有的原型prototype。

而通过prototype.isPrototypeOf(object)方法来检查实例(JS对象)的继承祖先(委托关联),其会检查实例的[[Prototype]]链中是否出现过构造函数.prototype。当然,也可以直接检查对象b是否在a的原型链中。

ES5中,通过Object.getPrototypeOf(object)获得对象的[[Prototype]]链。

这三个方法完成原型链的CRU操作。

! 在有的实现中,[[Prototype]]以__proto__的形式出现,但是__proto__是非标准的,请尽量不要在生产环境中使用它,而是用Object.getPrototypeOf(object)。

JS中,通过原型链,从下至上地查找属性或者方法引用。换句话说,JS的这个机制的本质就是对象之间的关联关系。这是一种不同于类的设计模式,类设计模式鼓励你在继承的时候使用方法重写和多态。现在我们使用委托行为的方式来思考问题。比如下面这个例子。

Task = {
  setID: function(ID){ this.id = ID; };
  outputID: function(){ console.log(this.id); }
} // 全局 Task
//让XYZ委托Task
XYZ = Object.create(Task);
XYZ.prepareTask = function(ID, label){
  this.setID(ID);
  this.label = label;
};
XYZ.outputTaskDetails = function(){
  this.outputID();
  console.log(this.label);
}

在这段代码中,XYZ通过Object.create()创建,它的[[Prototype]]委托了Task对象。这种风格作者称为“对象关联”。id和label数据成员变量都是直接存储在XYZ对象上。并且在原型链中的不同级别中的功能方法使用不同的命名。在调用this.setID()时,首先会在XYZ中寻找方法,如果找不到再通过[[Prototype]]来进行寻找。这时候由于隐式绑定规则,this还是在XYZ上。这就是委托行为很重要的一点。

然后我们比较书中的类和委托的例子。首先是面向对象风格。

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();    

其对象关系如下图,可以看到Bar.prototype = Object.create(Foo.prototype); 将Bar的原型对象指向Foo的原型对象,即 'Bar.prototype.__proto__ = = = Foo.prototype' 。这样Bar对象的实例调用其Foo的方法。那么 b1.__proto__.__proto__ === Foo.prototype 的值为true。

而使用对象关联风格的代码如下:

Foo = {
  init: function(who){
    this.me = who;
  },
  identify: function(){
    return 'I am ' + this.me;
  }
}
Bar = Object.create(Foo);
Bar.speak = function(){
  alert('Hello, ' + this.identify() + '.');
};
var b1 = Object.create(Bar);
b1.init('b1');
var b2 = Object.create(Bar);
b1.speak();
b2.speak();

这种代码只关注对象之间的关联关系。

行为委托认为对象之间是兄弟关系,相互委托,而不是父子关系。

这就是JS中的面向对象行为委托的模式。