JavaScript this 解析

关于 this

this 关键字是 JavaScript 中最复杂的机制之一。它是一个很特别的关键字,被自动定义在所有函数的作用域中。但是即使是非常有经验的 JavaScript 开发者也很难说清它到底指向什么。

任何足够先进的技术都和魔法无异。 —— Arthur C.Clarke

为什么要用 this

【示例】:

function identify() {
    return this.name.toUpperCase();
}

function speak() {
    var greeting = "Hello, I'm " + identify.call(this);
}

var me = {
    name: "Spirit"
};

var you = {
    name: "Reader"
};

identify.call(me);  // SPIRIT
identify.call(you); // READER

speak.call(me);  // Hello, I'm SPIRIT
speak.call(you); // Hello, I'm READER

【作用】:在不同的上下文对象(me 和 you)中重复使用函数 identify() 和 speak(),不用对每个对象编写不同版本的函数。

如果不使用 this,就需要给 identify() 和 speak() 显式地传入一个上下文对象。

function identify(context) {
    return context.name.toUpperCase();
}

function speak(context) {
    var greeting = "Hello, I'm " + identify(context);
    console.log(greeting);
}

identify(me); // SPIRIT
speak(you);   // Hello, I'm READER

【优点】:随着代码量的增加,使用模式将会越来越复杂。显式传递上下文对象会让代码变得越来越混乱,使用 this 则可以更优雅地隐式传递一个对象引用。因此可以将 API 设计得更加简洁并且易于复用。

误解

太拘泥于 this 的字面意思会产生一些误解。有两种常见的对于 this 的解释,但是它们都是错误的。

指向自身

人们很容易把 this 理解成指向函数自身。

【示例】:

function foo() {
    console.log(this);
}
foo(); // Window 对象,并非 foo

如果要从函数对象内部引用它自身,只使用 this 是不够的。一般来说需要通过一个指向函数对象的变量来引用它。

function foo() {
    foo.count = 4; // foo 指向它自身
}

setTimeout(function() {
    // 匿名函数无法指向自身
}, 10);
  • 具名函数,在函数内部可以使用 foo 引用自身。
  • 匿名函数没有名称标识符,因此无法在函数内部引用自身。

【其他】:使用 arguments.callee 来引用当前正在运行的函数对象,这是唯一一种可以从匿名函数对象内部引用自身的方法。然而,更好的方式是避免使用匿名函数,至少在需要自引用时使用具名函数。arguments.callee 已经被弃用,不应该再使用它。

它的作用域

第二种常见的误解是,this 指向函数的作用域。这个问题有点复杂,因为在某种情况下它是正确的,但是在其他情况下它却是错误的。

【注意】:this 在任何情况下都不指向函数的词法作用域。

this 到底是什么

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

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

this 全面解析

在理解 this 的绑定过程之前,首先要理解调用位置。只有仔细分析调用位置才能回答这个问题:这个 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 调试器。

【问】:this 指向调用位置所在的调用对象?比如说上述示例中,foo 的 this 指向 bar。

绑定规则

默认绑定

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

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

var a = 2;

foo(); // 2

【解释】:在代码中,foo() 直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。如果使用严格模式,则不能将全局对象用于默认绑定,因此 this 会绑定到 undefined。

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

var a = 2;

foo(); // TypeError: this is undefined

【注意】:虽然 this 的绑定规则完全取决于调用位置,但是只有函数运行在非 strict model 下时,默认绑定才能绑定到全局对象;在严格模式下调用 foo() 会影响默认绑定。

隐式绑定

在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。

【举例】:

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

var obj = {
    a: 2,
    foo: foo
};

obj.foo(); // 2

【解释】:

  • foo() 声明在全局作用域中,无论是直接在 obj 对象中定义(例子所示)还是先定义再添加为引用属性(obj.foo = foo;),这个函数严格来说都不属于 obj 对象。
  • 然而,调用位置会使用 obj 上下文来引用函数,因此可以说函数被调用时 obj 对象“拥有”或者“包含”它。

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

【反思】:其实默认绑定规则也是如此,直接使用不带任何修饰的函数引用进行调用时,例如 foo(); 实际上相当于调用全局对象的函数,例如 window.foo();

【示例】:

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"

【总结】:回调函数丢失 this 绑定是非常常见的。

【解决方法】:固定 this。

显式绑定

如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,需要使用函数的 call() 和 apply() 方法。

【注意】:严格来说,JavaScript 的宿主环境有时会提供一些非常特殊的函数,它们并没有这两个方法。但是这样的函数非常罕见,JavaScript 提供的绝大多数函数以及开发者自己创建的所有函数都可以使用 call() 和 apply() 方法。

【显式绑定】:apply() 和 call() 方法的第一个参数是一个对象,是给 this 准备的,接着在调用函数时将其绑定到 this。因此开发者能够直接指定 this 的绑定对象,将其称之为显式绑定。

【示例】:

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

var obj = {
    a: 2
};

foo.call(obj); // 2

【解释】:通过 foo.call() 方法,开发者可以在调用 foo 时强制把它的 this 绑定到 obj 上。

【注意】:

  1. 如果传入的是一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String()、new Boolean 或者 new Number())。这通常被称为“装箱”。
  2. 显式绑定仍然无法解决隐式丢失问题。
function foo() {
    console.log(this.a);
}

var obj = {
    a: 2,
    foo: foo
};

foo.call(obj); // 输出 2

setTimeout(obj.foo, 1000); // 输出 undefined
1. 硬绑定

显式绑定的一个变种。

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

var obj = {
    a: 2,
    foo: foo
};

function bar() {
    foo.call(obj);
    // 或
    obj.foo();
};

setTimeout(bar, 1000); // 输出 2

【解释】:创建函数 bar(),并在其内部手动调用 foo.call(obj),因此强制把 foo 的 this 绑定给 obj。无论之后如何调用函数 bar,它总会手动在 obj 上调用 foo。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。

【应用场景】:

  1. 创建包裹函数,负责接收参数并返回值。
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
  1. 创建一个可以重复使用的辅助函数。
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。

/**
@param {T} thisArg
@param {...*} [arg]
@return {function(this:T)}
@template T
*/
Function.prototype.bind = function(thisArg,arg) {};

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 语言和宿主环境中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文”,其作用和 bind() 一样,确保你的回调函数使用指定的 this。

【示例】:

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

var obj = {
    id: "Hello World!"
};

// 调用 foo() 时把 this 绑定到 obj
[1, 2, 3].forEach(foo, obj);
// 输出 1 Hello World!
// 输出 2 Hello World!
// 输出 3 Hello World!

【实质】:这些函数实际上就是通过 call() 或者 apply() 实现了显式绑定。

new 绑定

在讲解之前需要先澄清一个非常常见的关于 JavaScript 中函数和对象的误解。

【误解】:JavaScript 中有一个 new 操作符,使用方法看起来和面向类的语言一样,绝大多数开发者都认为 JavaScript 中 new 的机制也和那些语言一样。然而,JavaScript 中 new 的机制实际上和面向类的语言完全不同。

【解释】:在 JavaScript 中,构造函数只是一些使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。

【举例】:ES5.1 对 Number() 的描述。

15.7.2 Number 构造函数
当 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 上。

优先级

处理调用位置可同时应用多条规则时的绑定规则。

毫无疑问,默认绑定的优先级是最低的。

【隐式绑定和显式绑定】:

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

var obj = {
    a: 2,
    foo: foo
};

var obj2 = {
    a: 3,
    foo: foo
};

obj.foo();  // 2
obj2.foo(); // 3

obj.foo.call(obj2); // 3
obj2.foo.call(obj); // 2

【结论】:显式绑定的优先级高于隐式绑定。

【new 绑定和隐式绑定】:

function foo(something) {
    this.a = something;
}

var obj = {
    foo: foo
};

var obj2 = {};

obj.foo(2);
console.log(obj.a); // 2

obj.foo.call(obj2, 3);
console.log(obj2.a); // 3

var bar = new obj.foo(4);
console.log(obj.a); // 2
console.log(bar.a); // 4

【结论】:new 绑定比隐式绑定优先级高。

【new 绑定和显式绑定】:
new 和 call、apply 无法一起使用,因此无法通过 new foo.call(obj) 来直接进行测试。

function foo(something) {
    this.a = something;
}

var obj = {};

var bar = foo.bind(obj);
bar(2);
console.log(obj.a); // 2

var baz = new bar(3);
console.log(obj.a); // 2
console.log(baz.a); // 3

【解释】:new 修改了硬绑定(到 obj 的)调用 bar() 中的 this。new 关键字会判断硬绑定函数是否被 new 调用,如果是的话就会使用新创建的 this 替换硬绑定的 this。

【问】:为什么要在 new 中使用硬绑定函数呢?直接使用普通函数不是更简单吗?

【答】:之所以要在 new 中使用硬绑定函数,主要目的是预先设置函数的一些参数,这样在使用 new 进行初始化时就可以只传入其余的参数。例如,bind() 的功能之一就是把除了第一个参数(用于绑定 this)之外的其他参数都传给下层函数(这种技术称为“部分应用”,是“柯里化”的一种)。

function foo(p1, p2) {
    this.val = p1 + p2;
}

// 之所以使用 null 是因为在本例中我们并不关心硬绑定的 this 是什么,因为使用 new 时 this 会被修改
var bar = foo.bind(null, "p1");

var baz = new bar("p2");

baz.val; // p1p2

判断 this

根据优先级来判断函数在某个调用位置应用的是哪条规则。

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

绑定例外

在某些场景下 this 的绑定行为会出乎意料。应当应用其他绑定规则时,实际上应用的可能是默认绑定规则。

被忽略的 this

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

【示例】:

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

var a = 2;

foo.call(null); // 2

【问】:在什么情况下会传入 null?

【答】:一种非常常见的做法是使用 apply() 来“展开”一个数组,并当作参数传入一个函数。或使用 bind() 对参数进行柯里化(预先设置一些参数)。因为这两种方法都需要传入一个参数当作 this 的绑定对象。如果函数并不关心 this 的话,仍然需要传入一个占位值,这时 null 可能是一个不错的选择。

function foo(a, b) {
    console.log("a:" + a + ", b:" + b);
}

// 把数组“展开”成参数
foo.apply(null, [2, 3]); // a: 2, b: 3

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

【其他】:在 ES6 中,可以用...操作符代替 apply() 来“展开”数组,foo(...[1, 2]) 和 foo(1, 2) 是一样的,这样可以避免不必要的 this 绑定。可惜,在 ES6 中没有柯里化的相关语法,因此还是需要使用 bind()。

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

【示例】:

function create(config, obj) {
    // ... 很长一段代码
    this.id = "...";
    // 很长一段代码
}

// 使用 apply() 方法“展开”函数,会导致 this 绑定到全局对象上。
create.apply(null, [config, obj]);
更安全的 this

【做法】:传入一个特殊的对象,一个空的非委托对象。然后把 this 绑定到这个对象上,这样就不会对你的程序产生任何副作用。

function foo(a, b) {
    console.log("a:" + a + ", b:" + b);
}

var O = Object.create(null);

// 把数组展开成参数
foo.apply(O, [2, 3]); // a:2, b: 3

// 使用 bind() 进行柯里化
var bar = foo.bind(O, 2);
bar(3); // a: 2, b: 3

【注意】:创建一个空对象最简单的方法是 Object.create(null)。Object.create(null) 和 {} 很像,但是不会创建 Object.prototype 这个委托,所以它比 {} “更空”。

【好处】:任何对于 this 的使用都会被限制在这个空对象中,不会对全局对象产生任何影响。

间接引用

有意或者无意识地创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。

【示例】:间接引用最容易在赋值时发生。

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)的引用,因此调用位置是 全局对象,故而会应用默认绑定。

【注意】:对于默认绑定来说,决定 this 绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。

软绑定

使用软绑定之后仍然可以使用隐式绑定或者显式绑定来修改 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;
    };
}

【解释】:除了软绑定特有的功能之外,softBind() 的其他原理和 ES5 内置的 bind() 类似。会对指定的函数进行封装,首先检查调用时的 this,如果 this 绑定到全局对象或者 undefined,那就把指定的默认对象 obj 绑定到 this,否则不会修改 this。此外,这段代码还支持可选的柯里化。

【示例】:

function foo() {
    console.log("name: " + this.name);
}

var obj ={ name: "obj" },
    obj2 = { name: "obj2" },
    obj3 = { name: "obj3" };
    
var fooOBJ = foo.softBind(obj);

fooOBJ(); // name: obj

obj2.foo = foo.softBind(obj);
obj2.foo(); // name: obj2

fooOBJ.call(obj3); // name: obj3

setTimeout(obj2.foo, 10); // name: obj 应用了软绑定

this 词法

ES6 新增一种无法使用这些绑定规则的特殊函数类型:箭头函数。

【箭头函数】:使用 => 操作符,而非 function 关键字定义函数。同时,箭头函数不使用 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() 的 this 绑定到 obj1,bar(引用箭头函数)的 this 也会绑定到 obj1,箭头函数的绑定无法被修改(new 也不行!)。

【常用情形】:回调函数,例如事件处理器或者定时器。

function foo() {
    setTimeout(() => {
        // 这里的 this 在词法上继承自 foo()
        console.log(this.a);
    }, 100);
}

var obj = {
    a: 2
};

foo.call(obj); // 2

【解释】:箭头函数可以像 bind() 一样确保函数的 this 被绑定到指定对象。此外,其重要性体现在它用更常见的词法作用域取代了传统的 this 机制。

// 之前的模式
function foo() {
    var self = this;
    setTimeout(function() {
        console.log(self.a);
    }, 100);
}

var obj = {
    a: 2
};

foo.call(obj); // 2

【解释】:虽然 self = this 和箭头函数都可以取代 bind(),但是从本质上来说,他们想替代的是 this 机制。

【注意】:如果你经常编写 this 风格的代码,但是绝大部分时候都会使用 self = this 或者箭头函数来否定 this 机制,建议你(虽然这两种代码风格的程序都可以正常运行,但是在同一个函数或者同一个程序中混合使用这两种风格会使代码更难维护,并且也可能更难编写):

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

小结:

  1. this 既不指向函数自身,也不指向函数的词法作用域。
  2. this 是在函数调用时发生的绑定,指向什么完全取决于函数在哪里被调用。
  3. 如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面四条规则来判断 this 的绑定对象。
    1. 由 new 调用?绑定到新创建的对象。
    2. 由 call 或者 apply(或者 bind)调用?绑定到指定的对象。
    3. 由上下文对象调用?绑定到那个上下文对象。
    4. 默认:在严格模式下绑定到 undefined,否则绑定到全局对象。

一定要注意,有些调用可能在无意中使用了默认绑定规则。如果想更安全地忽略 this 绑定,可以使用自定义的空对象来保护全局对象。

ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定 this,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论 this 绑定到什么)。这其实和 ES6 之前代码中的 self = this 机制一样。

你可能感兴趣的:(JavaScript this 解析)