第二部分 this

1.1 关于 this

当模式越来越复杂时,显示传递上下文对象会让代码变得越来越混乱,使用this则不会这样。可自动引用合适的上下文对象非常重要。

1.2 误解

1.2.1 指向自身

this并不像我们所想的那样指向函数本身

function foo(num){
    console.log("foo: " + num);

    // 记录 foo 被调用的次数
    this.count++;
}

foo.count = 0;

var i;

for(i=0; i<10; i++){
    if( i > 5){
        foo(i);
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9

// foo 被调用了多少次?
console.log(foo.count); // 0

执行foo.count=0时,的确向函数对象foo添加了一个属性count。但是函数内部代码this.count中的this并不指向那个函数对象,所以虽然属性名相同,根对象却并不相同。

这段代码在无意中创建了一个全局变量count,它的值为NaN。

如果要从函数对象内部引用它自身,那只用this是不够的。一般来说你需要通过一个指向函数对象的词法标识符来引用它。

function foo() {
    foo.count = 4;  // foo 指向它自身
}
setTimeout( function(){
    // 匿名函数无法指向自身
}, 10);

所以,另一种解决方法是使用foo 标识符替代this来引用函数对象:

function foo(num){
    console.log("foo: " + num);

    // 记录 foo 被调用的次数
    foo.count++;
}

foo.count = 0;

var i;

for(i=0; i<10; i++){
    if( i > 5){
        foo(i);
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9

// foo 被调用了多少次?
console.log(foo.count); // 4

然而,这种方法回避了this的问题,另一种方法是强制this指向foo函数对象:

function foo(num){
    console.log("foo: " + num);

    // 记录 foo 被调用的次数
    this.count++;
}

foo.count = 0;

var i;

for(i=0; i<10; i++){
    if( i > 5){
        foo.call(foo, i);
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9

// foo 被调用了多少次?
console.log(foo.count); // 4

这次我们接受了this,没有回避它。

1.2.2 它的作用域

作用域“对象”无法通过JS代码访问,它存在于JS引擎内部。this在任何情况下都不指向函数的词法作用域

function foo() {
    var a = 2;
    this.bar();
}
function bar() {
    console.log(this.a);
}
foo();  // ReferenceError: a is not defined

首先,这段代码试图通过this.bar()来引用bar()函数。这是绝对不可能成功的(后面解释)。调用bar()最自然的方法是省略前面的this,直接使用词法标识符。

此外,还试图使用 this 联通 foo()bar() 的词法作用域,从而让 bar() 可访问 foo() 作用域里的变量a。这是不可能实现的,你不能使用 this 来引用一个词法作用域内部的东西。

1.3 this到底是什么

this是在运行时绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
当一个函数被调用时,会创建一个活动记录(上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。

2 this 全面解析

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

首先是最常用的函数调用类型:独立函数调用。可看做是默认规则。

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

foo(); //2

全局作用域中的变量a,就是全局对象的一个同名属性。它们本质上就是同一个东西。

当调用foo()时,this.a被解析成了全局变量a。因为函数调用时应用了this的默认绑定,因此this指向位居对象。

如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此this会绑定到undefined

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

foo(); // TypeError: this is undefined

2.2.2 隐式绑定

另一个需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含:

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

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

obj.foo();  //2

调用位置会使用obj上下文来引用函数,因此你可说函数被调用时obj对象“拥有”或者“包含”它。因此 this.aobj.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"

虽然barobj.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"; 

doFoo(obj.foo);  // "oops, global";

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

回调函数丢失this是非常常见的。

2.2.3 显示绑定

call()apply() 方法

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),因此强制把foothis绑定到了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.2.4 new绑定

先澄清一个误解。在传统面向类的语言中,“构造函数”是类中的一些特殊方法,使用new初始化类时会调用类中的构造函数。
JS也有一个new操作符,使用方法看起来一样。然而,JS中的new的机制实际上和面向类的语言完全不同。

我们来重新定义下JS 中的“构造函数”。在JS中,构造函数只是一些使用new操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被new操作符调用的普通函数而已。
实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。

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

  1. 创建(或构造)一个全新的对象。
  2. 这个新对象会被执行[[原型]]连接。
  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绑定

#2.3 优先级

默认绑定是优先级中最低的。
显式绑定高于隐式绑定
new绑定高于隐式绑定

new绑定 和 显式绑定呢?

new 和 call/apply 无法一起使用,因此无法通过 new foo.call(obj1); 来直接测试。但可用硬绑定来测试它俩的优先级。

先回忆下硬绑定是如何工作的。Function.prototype.bind(..)会创建一个新的包装函数,这个函数会忽略它当前的this绑定(无论绑定的对象是什么),并把它们提供的对象绑定到this上。

这样看起来硬绑定似乎比new绑定的优先级更高,无法使用new来控制this绑定。

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修改了硬绑定(到obj1的)调用bar(..)中的this。因为使用了new绑定,我们得到了一个名字为baz的新对象,并且baz.a 的值是 3

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

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

// 使用 null 是因为本例中不关心硬绑定的this是什么
// 反正使用new时也会被修改
var bar = foo.bind(null, "p1");

var baz = new bar("p2");

baz.val; // p1p2

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

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

2.4 绑定例外

2.4.1 被忽略的this

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

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

那么什么情况下你会传入null呢?
一种非常常见的做法是使用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

在ES6中,可用...操作符来展开数组。foo(...[1,2])和foo(1,2)是一样的。

然而,总是使用null来忽略this绑定可能产生副作用 。如确实使用了this会被默认绑定到全局对象。会导致难以分析和追踪的bug.

更安全的this
我们可创建一个DMZ(demilitarized zone,非军事区)对象,它就是一个空的非委托的对象。
如果在忽略this绑定时总是传入一个DMZ对象,那就什么都不用担心了,因为任何对this的使用都会被限制在这个空对象中。

这个空对象可用 ø 命名,在mac可用 option + o 来打出这个符号。

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

function foo(a, b) {
    console.log("a: " + a + ", b:" + b);
}
var ø = Object.create(null);

foo.apply(ø, [2, 3]);  // a:2, b:3

var bar = foo.bind(ø, 2);
bar( 3 ); //a:2, b:3

2.4.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()。

2.4.3 软绑定

硬绑定会大大降低函数的灵活性,使用硬绑定后就不可在使用隐式或显式绑定来修改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;
    }
}

首先检查调用时的this,如果this绑定到全局对象或者undefined,那就把指定的默认对象obj绑定到this,否则不会修改this.

看看softBind是否实现了软绑定功能:

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    <----- look!

fooOBJ.call( obj3 );   // name: obj3  <---- look!

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

2.5 this词法

箭头函数不使用this的四种标准规则,而是根据外层作用域来决定this。常用于回调函数中。

function foo() {
    setTimeout(() => {
        // 这里的this 在此法上继承自foo()
        console.log( this.a );
    })
}
var obj = {
    a: 2
}
foo.call(obj);  //2

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

  1. 只使用词法作用域并完全抛弃错误this风格代码;
  2. 完全采用 this 风格,在必要时使用bind(..),尽量避免使用 self = this 和箭头函数。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,496评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,407评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,632评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,180评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,198评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,165评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,052评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,910评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,324评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,542评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,711评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,424评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,017评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,668评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,823评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,722评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,611评论 2 353

推荐阅读更多精彩内容