ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
## 2.2 全面解析this ### 2.2.1 调用位置 调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。 最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的调用位置就在当前正在执行的函数的前一个调用中。 ~~~ 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 的调用位置 ~~~ ### 2.2.2 绑定规则 **1. 默认绑定** 最常用的函数调用类型:**独立函数调用。** ~~~ function foo() { console.log( this.a ); } var a = 2; foo(); // 2 ~~~ foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。函数调用时应用了this 的默认绑定,因此this 指向全局对象。 如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此this 会绑定到undefined: ~~~ function foo() { "use strict"; console.log( this.a ); } var a = 2; foo(); // TypeError: this is undefined ~~~ ,虽然this 的绑定规则完全取决于调用位置,但是只有foo() 运行在非strict mode 下时,默认绑定才能绑定到全局对象;严格模式下与foo()的调用位置无关: ~~~ function foo() { console.log( this.a ); } var a = 2; (function(){ "use strict"; foo(); // 2 })(); ~~~ **2. 隐式绑定** 隐式绑定规则是**调用位置是否有上下文对象。** ~~~ function foo() { console.log( this.a ); } var obj = { a: 2, foo: foo }; obj.foo(); // 2 ~~~ 当函数引用有上下文对象时,隐式绑定规则会把函数调用中的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" ~~~ 参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样。 如果把函数传入语言内置的函数而不是传入你自己声明的函数,结果是一样的,没有区别。 **3. 显示绑定** JavaScript可以使用函数的call(..) 和apply(..) 方法在某个对象上强制调用函数。 它们的第一个参数是一个对象,它们会把这个对象绑定到this,接着在调用函数时指定这个this。因为可以直接指定this 的绑定对象,所有称之为显式绑定。 ~~~ function foo() { console.log( this.a ); } var obj = { a:2 }; foo.call( obj ); // 2 ~~~ (1)硬绑定 ~~~ 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 ~~~ * 创建一个 i 可以重复使用的辅助函数: ~~~ 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 的上下文并调用原始函数。 (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 ~~~ **4. new绑定** 在JavaScript 中,构造函数只是一些使用new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被new 操作符调用的普通函数而已。 举例来说,思考一下Number(..) 作为构造函数时的行为,ES5.1 中这样描述它: ~~~ 15.7.2 Number 构造函数 当Number 在new 表达式中被调用时,它是一个构造函数:它会初始化新创建的对象。 ~~~ 所以,包括内置对象函数(比如Number(..))在内的所有函数都可以用new 来调用,这种函数调用被称为**构造函数调用**。这里有一个重要但是非常细微的区别:**实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”**。 使用new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。 * 创建(或者说构造)一个全新的对象。 * 这个新对象会被执行[[ 原型]] 连接。 * 这个新对象会绑定到函数调用的this。 * 如果函数没有返回其他对象,那么new 表达式中的函数调用会自动返回这个新对象。 ~~~ function foo(a) { this.a = a; } var bar = new foo(2); console.log( bar.a ); // 2 ~~~ 使用new 来调用foo(..) 时,我们会构造一个新对象并把它绑定到foo(..) 调用中的this上。new 是最后一种可以影响函数调用时this 绑定行为的方法,称之为new 绑定。 ### 2.2.3 优先级 **隐式绑定和显式绑定**哪个优先级更高?我们来测试一下: ~~~ function foo() { console.log(this.a); } var obj1 = { a: 2, foo: foo }; var obj2 = { a: 3, foo: foo }; obj1.foo(); // 2 obj2.foo(); // 3 obj1.foo.call(obj2); // 3 obj2.foo.call(obj1); // 2 ~~~ 显式绑定优先级更高,也就是说在判断时应当先考虑是否可以应用显式绑定。 现在我们需要搞清楚**new 绑定和隐式绑定**的优先级谁高谁低: ~~~ function foo(something) { this.a = something; } var obj1 = { foo: foo }; var obj2 = {}; obj1.foo( 2 ); console.log( obj1.a ); // 2 obj1.foo.call( obj2, 3 ); console.log( obj2.a ); // 3 var bar = new obj1.foo( 4 ); console.log( obj1.a ); // 2 console.log( bar.a ); // 4 ~~~ 可以看到new 绑定比隐式绑定优先级高。 ~~~ function foo(something) { this.a = something; } var obj1 = {}; var bar = foo.bind( obj1 ); bar( 2 ); console.log( obj1.a ); // 2 var baz = new bar(3); console.log( obj1.a ); // 2 console.log( baz.a ); // 3 ~~~ bar 被硬绑定到obj1 上,但是new bar(3) 并没有像我们预计的那样把obj1.a修改为3。相反,new 修改了硬绑定(到obj1 的)调用bar(..) 中的this。因为使用了new 绑定,我们得到了一个名字为baz 的新对象,并且baz.a 的值是3。 * **为什么要在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** 现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断: * 函数是否在new 中调用(new 绑定)?如果是的话this 绑定的是新创建的对象。 ~~~ var bar = new foo() ~~~ * 函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。 ~~~ var bar = foo.call(obj2) ~~~ * 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。 ~~~ var bar = obj1.foo() ~~~ * 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。 ~~~ var bar = foo() ~~~ ### 2.2.4 绑定例外 **1. 被忽略的this** 如果你把null 或者undefined 作为this 的绑定对象传入call、apply 或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则: ~~~ function foo() { console.log( this.a ); } var a = 2; foo.call( null ); // 2 ~~~ 一种非常常见的做法是使用apply(..) 来“展开”一个数组,并当作参数传入一个函数。类似地,bind(..) 可以对参数进行柯里化(预先设置一些参数),这种方法有时非常有用: ~~~ 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 ~~~ 这两种方法都需要传入一个参数当作this 的绑定对象。如果函数并不关心this 的话,你仍然需要传入一个占位值,这时null 可能是一个不错的选择,就像代码所示的那样。 ~~~ 在ES6 中,可以用... 操作符代替apply(..) 来“展开”数组,foo(...[1,2]) 和foo(1,2) 是一样的, 这样可以避免不必要的this 绑定。可惜,在ES6 中没有柯里化的相关语法,因此还是需要使用bind(..)。 ~~~ 总是使用null 来忽略this 绑定可能产生一些副作用。如果某个函数确实使用了this(比如第三方库中的一个函数),那默认绑定规则会把this 绑定到全局对象(在浏览器中这个对象是window),这将导致不可预计的后果(比如修改全局对象)。 #### 更安全的this 一种“更安全”的做法是传入一个特殊的对象,把this 绑定到这个对象不会对你的程序产生任何副作用。我们可以创建一个“DMZ”(demilitarizedzone,非军事区)对象——它就是一个空的非委托的对象。 无论你叫它什么,在JavaScript 中创建一个空对象最简单的方法都是Object.create(null)。Object.create(null) 和{} 很像, 但是并不会创建Object.prototype 这个委托,所以它比{}“更空”: ~~~ function foo(a,b) { console.log( "a:" + a + ", b:" + b ); } // 我们的DMZ 空对象 var ø = Object.create( null ); // 把数组展开成参数 foo.apply( ø, [2, 3] ); // a:2, b:3 // 使用bind(..) 进行柯里化 var bar = foo.bind( ø, 2 ); bar( 3 ); // a:2, b:3 ~~~ 使用变量名ø 不仅让函数变得更加“安全”,而且可以提高代码的可读性,因为ø 表示“我希望this 是空”,这比null 的含义更清楚。(不过你可以用任何喜欢的名字来命名DMZ 对象) **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(),应用默认绑定。 注意:对于默认绑定来说,决定this 绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。如果函数体处于严格模式,this 会被绑定到undefined,否则 this 会被绑定到全局对象。 **3. 软绑定** 硬绑定这种方式可以把this 强制绑定到指定的对象(除了使用new时),防止函数调用应用默认绑定规则。问题在于,硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改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; }; } ~~~ 除了软绑定之外,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 <---- 应用了软绑定 ~~~ 可以看到,软绑定版本的foo() 可以手动将this 绑定到obj2 或者obj3 上,但如果应用默认绑定,则会将this 绑定到obj。 ### 2.2.5 this词法 ES6 中介绍了一种无法使用this规则的特殊函数类型:箭头函数。 箭头函数并不是使用function 关键字定义的,而是使用被称为“胖箭头”的操作符`=>` 定义的。箭头函数不使用this 的四种标准规则,而是会继承外层函数调用的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 机制。实际上,在ES6 之前我们就已经在使用一种几乎和箭头函数完全一样的模式。 ~~~ function foo() { var self = this; // lexical capture of this setTimeout( function(){ console.log( self.a ); }, 100 ); } var obj = { a: 2 }; foo.call( obj ); // 2 ~~~ 虽然self = this 和箭头函数看起来都可以取代bind(..),但是从本质上来说,它们想替代的是this 机制。