一周一章前端书·第7周:《你不知道的JavaScript(上)》S02E02

第2章:this全面解析

2.1 调用位置

  • 在理解this的绑定之前,首先要理解“函数调用的位置”,即函数在代码中被调用的位置。只有仔细分析了调用位置才能解释,函数中的this到底引用的是什么?
  • 寻找“函数被调用的位置”,其实并没有想象中的简单,因为JS是很灵活的语言,经常将函数也作为参数进行传递,可能会隐藏真正的调用位置。
  • 所以我们需要分析函数的调用栈。所谓调用栈,就是为了到达当前执行位置,所调用过的所有函数,所以我们可以把调用栈想象成一个函数调用链,举例说明:
function baz(){
    //当前位置是baz
    console.log("my name is baz");
    //baz中调用bar
    bar();
}

function bar(){
    //当前位置是bar
    console.log("my name is bar");
    //bar中调用foo
    foo();
}

function foo(){
    //当前位置是foo
    console.log("my name is foo");
}

//window下调用baz
baz();

/* 
 * 所以foo的调用栈(链)就是:
 * window -> baz -> bar -> foo
 * /

2.2 绑定规则

  • 通过函数的调用位置,并应用JavaScript中四条决定this绑定的规则,就能分析this的引用值了。

2.2.1 默认绑定

  • 首先是最常用的函数调用类型:独立函数调用。所谓独立函数调用,就是没有应用其他规则的默认调用规则。举例:
var a = 2;
function foo(){
    console.log(this.a);   
}
foo();  //输出 2
  • 当调用foo()函数时,this.a指向了全局变量a,因为在默认绑定下,this指向全局对象。
  • 那如何辨别这里应用的是默认绑定呢?这时候,就需要运用我们前面讲的“分析函数调用位置”了,在这段代码中,foo()函数是直接调用的,不带任何修饰,也不被任何函数包含,所以可以确定是默认绑定。

注意:如果 函数内使用严格模式(strict mode) ,是不能将全局对象用于默认绑定的,最终this会绑定到undefined上。举例说明:

var a = 2;
function foo(){ 
  "use strict";
  console.log(this.a);
}
foo();    //输出 TypeError : this is undefined

在严格模式下调用函数 ,则不影响默认绑定。举例说明:

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

由于我们可能会使用众多第三方库,所以代码中可能会混合使用strict模式和非strict模式,因此一定要注意这类的兼容性问题。

2.2.2 隐式绑定

  • 第二条规则,就是通过函数调用位置,函数是否属于某个对象的属性。
var obj = {
    a : 2,
    foo : foo
}

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

obj.foo();  // 输出 2
  • 你看foo()方法的声明方式,它是被当做引用属性添加到了obj对象中,这种情况下,obj对象拥有/包含了foo()方法。
  • 当函数有包含自己的对象时,隐式绑定规则会把this绑定到这个对象。
  • 因此,调用foo()时,this被绑定到了obj对象,在函数中this.aobj.a的引用是一样的。
  • 值得注意的是,如果是多层嵌套对象下的函数,就只在最后一层中起作用。举例:
function foo(){
    console.log(this.a);
}

var obj1 = {
    a : 2,
    obj2 : {
        a : 42,
        foo : foo
    }
}

obj1.obj2.foo();    //输出 42
  • 值得注意的是,如果将obj1.obj2.foo函数的引用赋值给另一个变量,然后以默认绑定的方式调用函数,不管是自定义的函数,还是JS的内置函数,则还是会应用默认绑定规则:
var a = 'oops,global';
var bar = obj1.obj2.foo;

function runFoo(){
    obj1.obj2.foo();
}

// 都是输出 'oops,global'
bar();  
runfoo();   
setTimeout(obj1.obj2.foo,100);

2.2.3 显示绑定

  • 如果不想在对象内部包含函数引用,想在某个对象上强制调用函数,该怎么做呢?
  • JavaScript中的函数都有一些特性,可以用来解决这个问题。比如函数的call()applay()方法
  • 这两个方法,传入的第一个参数是一个对象,就是留给this准备的,调用时会将其绑定到this。因为可以直接指定this的绑定对象,因此我们称之为显示绑定。
function foo(){
    console.log(this.a);
}
var obj = {
    a : 2
};
foo.call(obj);  // 输出 2
  • 但如果传入的参数是原始值(字符串、布尔或者数值类型)当做this的绑定对象的话,这个原始值会被转换成它的对象形式。也就是new String()new Boolean()或者new Number(),这个过程通常叫做装箱
  • 1. 硬绑定
function foo(){
    console.log(this.a);
}

var obj = {
    a : 2
};

var bar = function(){
    foo.call(obj);
};

bar();  // 2
setTimeout(bar,100);

//硬绑定不能再修改它的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
  • 另一种方式就是创建一个可以重复使用的辅助函数:
function foo(something){
    cosnole.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调用的“上下文”
  • 许多函数都提供了一个可选的参数,其作用和bind()函数一样,确保你的回调函数使用指定的this。举例:
function foo(el){
    console.log(el,this.id);
}
var obj = {
    id : 'awesome'
}
[1,2,3].forEach(foo,obj);
  • 通过call()apply()实现显示绑定,可以少写代码。

2.2.4 new绑定

  • 在讲解最后一条this的绑定规则之前,首先要澄清一个常见的关于JavaScript中函数和对象的误解。
  • 在传统的面向类的语言中,“构造函数”是类中的一些特殊方法,使用new初始化类时会调用类的构造函数,something = new MyClass()。然而,JavaScript中的new的机制实际上和面向类的语言完全不同。
  • 我们重新定义一些JavaScript中的“构造函数”:在JavaScript中,构造函数只是使用new操作符时被调用的函数,它们并不属于某一个对象,也不会实例化一个类。
  • 所有函数都可以用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绑定。

2.3 优先级

  • 上文通过大篇幅讲了函数调用中,this绑定的四条规则:默认绑定、隐式绑定、显示绑定和new绑定。但如果调用应用了多条规则就必须给这些规则设定优先级了。
  • 毫无疑问,默认绑定的优先级是最低的,暂不考虑它。
  • 隐式绑定和显示绑定哪一个优先级更高?我们来测试一下:
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);    // 3
  • 可以看到,显式绑定优先级更高。
  • 接下来,我们要测试,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);    //2
  • 可以看到new绑定比隐式绑定的优先级更高,obj1.a的值一直没改变。
  • 那new绑定和显示绑定,谁的优先级更高呢?(由于new和call/apply无法一起使用,所以通过硬绑定来测试)
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 baz(3)并没有把obj1.a修改为3.
  • 话说回来,之所以在new中使用硬绑定函数,主要目的是想预先设置一些参数,这样在使用new进行初始化时就可以传入其他参数了。举例:
function foo(p1,p2){
    this.val = p1 + p2;
}

var bar = foo.bind(null,"p1");
var baz = new bar("p2");
baz.val;    //p1p2
  • 根据优先级就能判断函数调用时应用的是哪条规则了,判断的步骤:
    1. 函数是否进行了new调用,如果是的话,this绑定的是新创建的对象;
    2. 函数是否通过callapply或者硬绑定调用,如果有的话,this绑定的是指定的对象;
    3. 函数是否在某个对象中调用,如果是的话,this绑定的是该对象;
    4. 如果都不是的话,使用默认绑定,绑定到全局对象。(严格模式下,绑定到undefined)

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

//先预先传入参数a
var bar = foo.bind(null,2);
//调用时再传入参数b
bar(3); // a:2,b:3
  • 但这种方式可能会导致许多难以分析和追踪的bug,我们可以用更安全的方式。
  • 更安全的做法就是不传入null,而是传入一个空的对象,把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

//先预先传入参数a
var bar = foo.bind(o,2);
//调用时再传入参数b
bar(3); // a:2,b:3
  • 我们通过Object.create(null)来创建对象,它和直接以字面量{}创建对象很相似,但前者不会创建Object.prototype的委托,所以它比{}更空。

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
(function(){
    p.foo = o.foo
})();   // 2

2.4.3 软绑定

  • 硬绑定可以把this强制绑定到指定的对象,以防止函数调用应用默认绑定规则。问题在于,硬绑定会大大降低函数的灵活性,硬绑定之后,就无法使用隐式绑定或者显示绑定来修改this。
if(!Function.prototype.softbind){
    Function.prototype.softbind = function(obj){
        var fn = this;
        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 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

2.5 this词法

  • 前面解说的四条规则几乎也包含所有函数,但ES6中介绍了一种无法使用这些规则的特殊函数类型:箭头函数
  • 箭头函数使用操作符=>来定义,箭头函数不适用this的四种标准规则,而是根据外层作用域来决定this。
function foo(){
    return (a) => {
        console.log(this.a);
    }
}

var obj1 = {
    a : 2
}

var obj2 = {
    a : 3
}

var bar = foo.call(obj1);
bar.call(obj2); // 2 , 不是 3 !
  • 箭头函数最常用于回调函数中,例如事件处理器或者定时器:
function foo(){
    setTimeout(() => {
        console.log(this.a);
    },100);
}
var obj = {
    a : 2
};
foo.call(obj); // 2

2.6 小结

  • 如果要判断一个运用中函数的this绑定,需要找到函数的调用位置,然后按顺序应用四条规则来判断this的绑定对象:
    1. 由new调用?绑定到新创建的对象;
    2. 由call或者apply/bind调用?绑定到指定的对象;
    3. 由对象调用?绑定到那个对象;
    4. 默认:严格模式下绑定到undefined,否则绑定到全局对象;
  • 有些调用无意中使用默认绑定规则。如果想“更安全” 地忽略this绑定,可以使用一个空的临时对象,比如o = Object.create(null),以保护全局对象。
  • ES6中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定this。

你可能感兴趣的:(一周一章前端书·第7周:《你不知道的JavaScript(上)》S02E02)