你不知道的JS-上(七)

11 阅读10分钟

this 和对象原型

this 全面解析

调用位置

在上一章中,我们明白 this 是在函数被调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)。

在理解 this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的位置(而不是声明位置)。

我们可以通过分析调用栈来查找函数的调用位置。

绑定规则

我们来看看在函数的执行过程中调用位置如何决定 this 的绑定对象。

默认绑定

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

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

我们应该注意到,声明在全局作用域中的变量(如 var a = 2)就是全局对象的一个同名属性。它们本质上就是同一个东西。

在代码中,foo() 在全局直接使用不带任何修饰的函数应用进行调用,因此只能使用默认绑定,没有应用其他规则,使 this 指向全局对象。

在严格模式时,不能将全局对象用于默认绑定,因为 this 会绑定到 undefined。

但这里有个非常重要的细节,虽然 this 的绑定规则完全取决于调用位置,但是只有 foo()运行在非严格模式下时,默认绑定才能绑定到全局对象;在严格模式下调用 foo()则不影响默认绑定:


function foo() {
  console.log(this.a);
}
​
var a = 2(function () {
  "use strict";
​
  foo(); // 2
})();

通常来说,我们不应该在代码中混合使用“严格模式”和“非严格模式”。

隐式绑定

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


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

首先要注意 foo()的声明方式,及其之后是如何被当作引用属性添加到 obj 中的。但无论是直接在 obj 中定义还是先定义再添加为引用属性,这个函数严格来说都不属于 obj 对象。

然而,调用位置会使用 obj 上下文来引用函数,因此你可以说函数被调用时 obj 对象“拥有”或者“包含”它。

当 foo()被调用时,它的落脚点指向 obj 对象,使得隐式绑定规则将 this 绑定到这个上下文对象,即 foo()的 this 被绑定到 obj,因此 this.a 和 obj.a 是一样的。

对象属性引用链中只有最顶层或者说最后一成会影响调用位置。如下代码所示:


function foo() {
  console.log(this.a);
}
​
var obj2 = {
  a: 42,
  foo: foo,
};
​
var obj1 = {
  a: 2,
  obj2: obj2,
};
​
obj1.obj2.foo(); //42
隐式丢失

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


function foo() {
  console.log(this.a);
}
​
var obj = {
  a: 2,
  foo: foo,
};
​
var bar = obj.foo; // 函数别名!
​
var a = "oops, global"; // a是全局对象属性
​
bar(); // "oops, global"

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

再看传入回调函数的例子:


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"

传递参数就是一种隐式赋值。

显示绑定

JS 通过的绝大多数函数以及我们创建的所有函数都可以使用 call(..)和 apply(..)方法,来实现在某个对象上强制调用函数。


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

通过 foo.call(..),我们可以在调用 foo 时强制把它的 this 绑定到 obj 上。

如果传入了一个原始值(String、Boolean 或者 Number 类型)来当作 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

我们在 bar 函数内部手动调用 foo.call(obj),将 foo 的 this 绑定到了 obj。无论之后如何调用函数 bar,它总会在 obj 上调用 foo。这种就是一种显示的强制绑定,被称为硬绑定。

硬绑定的典型应用场景就是创建一盒包裹函数,负责接受参数并返回值:


function foo(something) {
  console.log(this.a, something);
  return this.a + something;
}
​
var obj = {
  a: 2,
};
​
var bar = function () {
  return foo.apply(obj, arguments);
};
​
var b = bar(3); // 2 3
console.log(b); // 5

另一种使用方法是创建一个可以重复使用的辅助函数:


function foo(something) {
  console.log(this.a, something);
  return this.a + something;
}
​
// 简单的辅助绑定函数function bind(fn, obj) {
  return function () {
    return fn.apply(obj, arguments);
  };
}
​
var obj = {
  a: 2,
};
​
var bar = bind(foo, obj);
​
var b = bar(3); // 2 3
console.log(b); // 5

由于硬绑定是一种非常常用的模式,所以 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 的上下文并调用原始函数。

  • 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(..)实现了显示绑定。

new 绑定

在传统的面向对象语言中,“构造函数”是类中的一些特殊方法,使用 new 初始化类时会调用类中的构造函数。通常是这样的形式:


something = new MyClass(..)

JS 也有一个 new 操作符,使用方法看起来也和那些面向类的语言一样,但 JS 中 new 的机制实际上和面向类的语言完全不同。

在 JS 中,构造函数只是一些使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。它们只是被 new 操作符调用的普通函数而已。

包括内置对象函数(比如 Number(..))在内的所有函数都可以用 new 来调用,这种函数调用被称为构造函数调用。这里有个重要但非常细微的区别:实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。

使用 new 调用函数,会自动执行以下操作。

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

思考以下代码:


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

使用 new 来调用 foo(..)时,我们会创造一个新对象并把它绑定到 foo(..)调用中的 this 上。new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称为 new 绑定。

优先级

  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

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


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

这种做法可以使用 apply(..)来“展开”一个数组,并当作参数传入一个函数。也可以使用 bind(..)对参数进行柯里化(预先设置一些参数),这种方法优势非常有用

然而,总是使用 null 来忽略 this 绑定可能会产生一些副作用。如果某个函数确实使用了 this,那默认绑定规则会把 this 绑定到全局对象,这将导致不可预计的后果(比如修改全局对象)。

更安全的 this

我们可以创建一个“DMZ”对象——它就是一个空的非委托对象,在我们忽略 this 绑定时传入这个 DMZ 对象,将 this 绑定到该空对象中,就不会对全局对象产生任何影响


function foo(a, b) {
  console.log("a:" + a + ", b:" + b);
}
​
//我们的DMZ对象
var empty = Object.create(null);
​
//把数组展开成参数
foo.apply(empty, [1, 2]); // a:1, b:2//使用bind(..)进行柯里化
var bar = foo.bind(empty, 2);
bar(3); // a:2, b:3
间接引用

我们有可能(有意或无意地)创建一个函数地“简介引用”,在这种情况下,调用这个函数会应用默认规则绑定。


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)(); // 2p.foo = o.foo;
​
p.foo(); // 4

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo()而不是 p.foo()或 o.foo()。因此会应用默认绑定。

软绑定

硬绑定可以把 this 强制绑定到指定的对象,但硬绑定会大大降低函数的灵活性,使用硬绑定后会就无法通过隐式绑定或显示绑定来修改 this。

我们可以通过给默认绑定指定一个全局对象和 undefined 以外的值,实现和硬绑定相同的效果,同时保留修改 this 的能力。该方法被称为软绑定。


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;
  };
}

这段代码是在 Function 的原型上添加一个名为 softBind 的方法(如果该方法不存在的话)。softBind 的作用类似于原生的 bind 方法,但是有一个关键区别:当使用 softBind 绑定的函数被调用时,它会检查调用时的 this 值,如果 this 是全局对象(window 或 global)或者 undefined(在严格模式下可能是 null),那么就会使用传入的 obj 作为 this 值;否则,使用调用时的 this 值。


function foo() {
  console.log("name:" + this.name);
}
​
var obj = { name: "obj" },
  obj2 = { name: "obj2" },
  obj3 = { name: "obj3" };
​
var FooOBJ = foo.sofrBind(obj);
​
FooOBJ(); // name:objobj2.foo = foo.sofrBind(obj);
obj.foo(); // name:obj2
​
fooOBJ.call(obj3); // name:obj3
​
setTimeout(obj2.foo, 10); //name:obj 软绑定
this 词法

前面介绍的四条规则能够包含所有正常的函数。但在 ES6 的箭头函数“=>”不使用 this 的四种标准,而是根据外层作用域来决定 this。


function foo() {
  // 返回一个箭头函数
  return (a) => {
    //this 继承自foo()
    console.log(this.a);
  };
}
​
var obj1 = {
  a: 2,
};
​
var obj2 = {
  a: 3,
};
​
var bar = foo.call(obj1);
bar.call(obj2); // 2, 不是3!

foo()内部创建的箭头函数会捕获调用时 foo()的 this。由于 foo()的 thi 绑定到 obj1,bar 的 this 也会绑定到 obj1,箭头函数的绑定无法被修改。(new 也不行!)