this

this绑定规则

默认绑定之函数绑定

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

function foo(){
    console.log(this.a);
}
var a= 2;
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"

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

如果把函数传入语言内置的函数而不是你自己声明的函数,会发生什么呢?结果是一样的,没有区别:

function foo(){
    console.log(this.a);
}
var obj = {
    a:2,
    foo:foo
}
var a = "oops,global";//a是全局对象的属性
setTimeout(obj.foo,100);//"oops,global"

就像我们看到的那样,回调函数丢失this绑定是非常常见的。除此之外,还有一种情况this的行为会出乎我们意料:调用回调函数的函数可能会修改this。在一些流行的JS库中事件处理器常会把回调函数的this强制绑定在触发事件的DOM元素上。这在一些情况下可能很有用,但是有时它可能让你感到非常郁闷。遗憾的是,这些工具通常无法选择是否启用这个行为。

无论哪种情况,this的改变都是意想不到的,实际上你无法控制回调 函数的执行方式,因此就没有办法控制调用位置以得到期望的绑定。之后我们会介绍如何通过固定this来修复这个问题。

显式绑定

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

JS中的“所有”函数都有一些有用的特性,可以用来解决这个问题。具体点说,可以使用函数的call()和apply()方法。

它们的第一个参数是一个对象,是给this准备的,接着在调用函数时将其绑定到this.因为你可以直接指定this的绑定对象,因此我们称之为显式绑定。

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

var obj = {
    a:2;
}
foo.call(obj);//2

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

如果你传入一个原始值(字符串类型,布尔类型或者数字类型)来当作this的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(...))。这通常被称为装箱。

可惜,显式绑定仍然无法解决我们之前提出的丢失绑定问题

硬绑定

显式绑定的一个变种可以解决这个问题。

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绑定到了obj。无论之后如何调用函数bar,它总会手动在obj上调用foo。这种绑定是一种显式的强制绑定。因此我们称之为硬绑定。

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

function foo(something){
    console.log(this.a,something);
    return this.a +something;
}
var obj =  {
    a:2
}

var bar = funtion(){
    return foo.apply(obj,arguments);
}

var b = bar(3);//2,3
console.log(b);//5

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

function foo(someting){
    console.log(this.a,something);
    return this.a +something;
}
//简单的辅助绑定函数
function bind(fn,obj){
    return function(){
        return function(){
            return fn.apply(obj,arguments);
        }
    }
}

var bar = funtion(){
    return foo.apply(obj,arguments);
}

var b = bar(3);//2,3
console.log(b);//5

由于硬绑定是一种非常常用的模式,所以es5提供了内置的方法Function.prototype.bind,它的用法如下:

function foo(someting){
    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调用的上下文

第三方库的许多函数,以及JS语言宿主环境中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文”,其作用和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绑定

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

Number(...)作为构造函数的行为,ES5.1中这样描述它:

当Number在new 表达式中被调用时,她是一个构造函数:它会初始化新创建的对象。

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

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

  • 创建(或者说构造)一个全新的对象。
  • 这个新对象会被执行[[Prototype]]连接。
  • 这个新对象会绑定到函数调用的this。
  • 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象
function foo(a){
    this.a = a;
}
var bar = new foo(2);
console.log(bar.2);//2


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

this的优先级

隐式绑定和显式绑定谁的优先级高?

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绑定比隐式绑定优先级高。但是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 bar(3)并没有像我们预计的那样把obj1.a修改为3。相反,new修改了硬绑定(到obj的)调用bar(..)中的this。因为使用了new绑定,我们得到了一个名字为baz的新对象,并且baz.a 的值是3。

再来看看我们之前介绍的“裸”辅助函数bind:

function bind(fn,obj){
   return function(){
       fn.apply(obj,arguments);
   }
}

看起来在辅助函数中new操作符的调用无法修改this绑定,但是在刚才的代码中new确实修改了this绑定。

实际上,ES5中内置的Function.prototype.bind(..)更加复杂。下面是MDN提供的一种bind实现:

if(!Function.prototype.bind){
    Function.prototype.bind = function(oThis){
        if(typeof this !== "function"){
            //与ES5最接近的
            //内部IsCallable函数
            throw new TypeError(
                "Function.prototype.bind - what is trying" + "to be bound is not callable"
             )
        }
        
        var aArgs = Array.prototype.slice.call(arguments,1),
            fToBind = this,
            fNop = function(){},
            fBound = function(){
                return fToBind.apply(
                    (this instanceof fNop && oThis ? this : oThis),
                    aArgs.concat(Array.prototype.slice.call(arguments));
                )
            }
        fNop.prototype = this.prototype;
        fToBind.prototype = new fNop();
        return fBound;
    }
}

这种bind(...)是一种polyfill代码(polyfill就是我们常说的刮墙用的腻子,polyfill代码主要用于旧浏览器的兼容,比如说在旧的浏览器中并没有内置bind函数,因此可以使用polyfill代码在就浏览器中实现新的功能),对于new使用的硬绑定来说,这段polyfill代码和ES5内置的bind(...)函数并不完全相同。由于polyfill并不是内置函数,所以无法创建一个不包含.prototype的函数,因此会具有一些副作用。如果你在new中使用硬绑定并且依赖polyfill代码的话,一定要非常小心。

下面是new修改this的相关代码:

this instanceof fNop && oThis ? this : oThis
// ...以及
fNop.prototype = this.prototype;
fToBind.prototype = new fNop();

这段代码会判断硬绑定函数是否被new调用,如果是的话就会使用新创建的this替换硬绑定的this。

之所以在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()
    
    
  2. 函数是否指定call,apply(显示绑定)或者硬绑定调用?如果是的话,this绑定的是指定的对象。

    var bar = foo.call(obj2)
    
    
  3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this绑定的是那个上下文对象

    var bar = obj1.foo()
    
    
  4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到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(..)可以对参数进行柯里化(预先设置一些参数),这种方法有时非常有用:

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),这将导致不可预计的后果(比如修改全局对象)。

显而易见,这种方式可能会导致许多难以分析和追踪的bug。

更安全的this

一种更安全的做法是传入一个特殊的对象,把this绑定到这个对象不会对你的程序产生任何副作用。就像网络(以及军队)一样,我们可以创建一个“DMZ”(非军事区)对象—它就是一个空的非委托的对象。

如果我们在忽略this绑定时总是传入一个DMZ对象,那就什么都不用担心了,因为任何对于this的使用都会被限制在这个空对象中,不会对全局对象产生任何影响。

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

function foo(a,b){
    console.log("a:"+a+",b:"+b)
}
//我们的DMZ空对象
foo.apply($,[2,3]);//a:2,b:3
//使用bind(..)进行柯里化
var bar = foo.bind($,2);
bar(3);//a:2,b:3


使用变量名$不仅让函数变得更加安全,而且可以提高代码的可读性,因为$表示“我希望this是空”,这比null的含义更清楚。不过再说一遍,你可以用任何喜欢的名字来命名DMZ对象。

间接引用

另一个需要注意的是,你有可能创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。

间接引用最容易在赋值时发生:

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 会被绑定到全局对象。

软绑定

硬绑定这种方式可以把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。此外,这段代码还支持可选的柯里化。

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

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

var obj = {name :"obj"}
var obj2 = {name :"obj2"}
var obj3 = {name :"obj3"}

var fooOBJ = foo.softBind(obj);
fooOBJ();//name :obj

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

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

可以看到,软绑定的foo()可以手动将this绑定到obj2或者obj3上,但如果应用默认绑定,则会将this绑定到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(obj);
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;
    setTimeout(()=>{
        console.log(self.a);
    },100);
}
var obj = {a;2}
foo.call(obj);//2

虽然self= this 和箭头函数看起来都可以取代bind(..),但是从本质上来说,它们都想替代的是this机制。

但是绝大部分时候都会使用 self = this 或者箭头函数来否定this机制,你或许应当:

  • 只使用词法作用域并完全抛弃错误this风格的代码
  • 完全采用this风格,在必要时使用bind(..),尽量避免使用self = this 和箭头函数。
posted @ 2019-09-18 09:28  windalm  阅读(217)  评论(0编辑  收藏  举报