this详解,默认绑定,隐式绑定,显示绑定,硬绑定,new绑定,软绑定,箭头函数

45 阅读18分钟

 this

JS不具有动态作用域,只有词法作用域,但this和动态作用域很像,也是在运行时确定,this关注函数如何调用。

在执行时,生成上下文this,普通this指向调用者箭头函数的this指向上级(词法作用域)

在词法作用域中声明var self=this,或者用bind改变this指向,也可以有箭头函数的效果。

1.this的两种误解

误解1.this指向函数自身

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

            // 记录foo被调用的次数
            this.count++;//this指向foo的调用者,这里是全局,所以为0
        }

        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 -- 什么?!
//代替方法1(但回避this的使用原理)
        function foo(num) {
            console.log("foo: " + num);

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

        var data = {
            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(data.count); // 4
//用词法作用域,而不是真正理解this的含义和工作原理 
//在函数中,创建data对象,包含count属性data.count 代替this.count

还有一种传统的但是现在已经被弃用和批判的用法,是使用arguments.callee来引用当前正在运行的函数对象。这是唯一一种可以从匿名函数对象内部引用自身的方法。然而,更好的方式是避免使用匿名函数,至少在需要自引用时使用具名函数(表达式)。arguments.callee已经被弃用,不应该再使用它

//代替this,方法2(但回避this的使用原理)
        function foo(num) {
            console.log("foo: " + num);

            // 记录foo被调用的次数
            foo.count++;//之前写的是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); // 4
//用函数foo标识符,foo.count++,虽然解决问题了,
//但依然回避this的使用原理
//和前面区别就是foo.count和词法作用域data.count,还有this.count
//代替this,方法3,这次没有回避this
        function foo(num) {
            console.log("foo: " + num);

            // 记录foo被调用的次数
            // 注意,在当前的调用方式下(参见下方代码), this确实指向foo
            this.count++;
        }

        foo.count = 0;

        var i;

        for (i=0; i<10; i++) {
            if (i > 5) {
              // 使用call(..)可以确保this指向函数对象foo本身
              foo.call(foo, i);
            }
        }
        // foo: 6
        // foo: 7
        // foo: 8
        // foo: 9

        // foo被调用了多少次?
        console.log(foo.count); // 4
//foo.call(foo,i)强制this指向foo函数对象

误解2.this指向函数自身作用域

this在任何情况下都不指向函数的词法作用域

正解:指向调用者

2. this的4种绑定规则

2.1. 默认绑定(普通函数)

function bar(){}
function foo(){
  bar();
}

foo();

转存失败,建议直接上传图片文件

独立函数调用,在非严格模式下,通过调用栈获取this

在全局调用foo时,栈顶是foo,第二个栈内容是全局window,此时foo中this指向调用者,这里是全局

在foo中调用bar函数,栈顶bar,第二个栈内容是foo,此时bar中this指向调用者foo

总之:看调用位置,全局中调用函数foo时,foo的this指向调用者(全局),bar在foo函数中调用,bar的this指向调用者foo

在严格模式下,不能获取默认绑定this

2.2. 隐式绑定(对象名.方法名())

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

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

        obj.foo(); // 2

在对象中引用函数,把函数作为对象的值,在执行obj.foo()时,函数foo的this指向它的调用者(上级)obj

但是无论是直接在obj中定义还是先定义再添加为引用属性,这个函数严格来说都不属于obj对象。

声明函数的时候,函数开辟独立作用域,只有在执行时,才赋值给obj对象,所以严格说,函数不属于obj,只有在执行时才短暂属于obj

隐式丢失

        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()其实是一个不带任何修饰的函数调用,因此应用了默认绑定。

解决隐式丢失,可以用硬绑定或软绑定,在后面介绍

2.3. 显式绑定

通过call()和apply() ,因为你可以直接指定this的绑定对象,因此我们称之为显式绑定。

硬绑定是显示绑定的变种,直接指定this的绑定对象,并且后续不可修改,一般用bind,也可用call等封装函数

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

        var obj = {
            a:2
        };

        foo.call(obj); // 2

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

装箱和拆箱

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

foo.call(基础数据类型)被转换为对象形式,如new String()、new Boolean()、new Number(),被称为“装箱”。
复杂数据类型被转换成基础数据类型,称为“拆箱”

硬绑定bind(显式绑定的一个变种):解决隐式绑定丢失,用了硬绑定后,不能通过显示(call/apply)和隐式绑定修改this,灵活性下降,可以用软绑定,见绑定例外

        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。
//这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。

如call、apply、bind

调用API上下文:如forEach(参数1:回调函数,参数2:改变回调函数this指向)

硬绑定的典型应用场景

//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
//2.创建一个可以重复使用的辅助函数:bind
        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

polyfill腻子代码

polyfill就是我们常说的刮墙用的腻子,polyfill代码主要用于旧浏览器的兼容,比如说在旧的浏览器中并没有内置bind函数,因此可以使用polyfill代码在旧浏览器中实现新的功能

2.4. new绑定

绑定到新创建的对象

构造函数

所有函数都可以用new来调用,这种函数调用被称为构造函数调用

这里有一个重要但是非常细微的区别:实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。

1.创建(或者说构造)一个全新的对象。

2.这个新对象会被执行[[Prototype]]连接。

3.这个新对象会绑定到函数调用的this。

4.如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。

new和call/apply无法一起使用

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

        // 之所以使用null是因为在本例中我们并不关心硬绑定的this是什么
        // 反正使用new时this会被修改
        var bar = foo.bind (null, "p1");
        //非严格模式下,传空this指向全局window。严格模式下,依然指向null
        var baz = new bar("p2");

        baz.val; // p1p2

为什么要在new中使用硬绑定函数

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

柯里化/部分应用

柯里化是一种将多元函数转换成一系列一元函数的编程技术。它允许程序员逐步传递参数,而不是一次性传递所有参数。以下是具体分析:

  1. 定义与原理

  • 基本概念:柯里化是把接受多个参数的函数变换成接受一个单一参数的新函数的技术

  • 函数转换过程:在柯里化过程中,原函数的第一个参数被保留,其余参数则由返回的新函数继续接收,直到所有参数都被使用并返回最终结果。

  1. 历史与发展

  • 命名由来:该技术以逻辑学家 Haskell Curry 的名字命名,尽管其最初的发明者是 Moses Schönfinkel 和 Gottlob Frege。

  • 理论发展:在理论计算机科学中,柯里化提供了一种研究多参数函数的方法,尤其在如lambda演算这样的简单理论模型中。

  1. 实际应用案例

  • 延迟计算与参数复用:柯里化允许函数在接收到最后一个参数时才进行计算,这有助于延迟计算和参数复用。

  • 简化代码与提高可维护性:通过柯里化可以编写更加简洁、可维护的代码,尤其是在处理具有复杂参数结构的函数时。

  1. 编程语言中的实现

  • 语言特性:某些编程语言如 ML 和 Haskell 几乎总是使用柯里化的函数来实现多个参数,这源于它们对lambda演算的继承。

  • JavaScript中的应用:虽然 JavaScript 不是纯粹的函数式编程语言,但利用闭包特性,可以实现柯里化,优化代码封装和管理。

  1. 与其他编程技术的关系

  • 部分应用与闭包:柯里化与部分应用有关但不相同,闭包编程技术可以用于执行部分应用和实现柯里化

  • 高阶函数与函数式编程:柯里化常与高阶函数结合使用,在函数式编程中扮演重要角色,有助于减少中间变量,使代码更加简洁高效

  1. 优势与局限性

  • 代码优化:柯里化可以帮助开发者写出更通用、可复用的代码,提高开发效率和程序性能
  • 理解难度:柯里化的概念和实践可能需要一定的学习和适应,对于初学者来说可能存在一定的学习曲线。

进一步考虑柯里化在不同编程场景下的应用可能性,可以看到其在提高代码的模块化、测试性和可读性方面的潜力。例如,在大型软件项目中,通过柯里化可以将复杂的操作分解为更小、更易管理的函数,每个函数负责单一的功能点。这不仅有助于代码的组织和维护,也使得单元测试更加针对性强和简单。

综上所述,柯里化不仅是一种强大的编程技术,它还反映了函数式编程的核心思想——构建小型、独立的函数来共同完成复杂的任务。通过理解和应用柯里化,开发者能够编写出更加灵活、模块化且易于管理的代码,从而在软件开发实践中获得显著的优势。

MDN:
bind() 的另一个最简单的用法是使一个函数拥有预设的初始参数。只要将这些参数(如果有的话)作为 bind() 的参数写在 this 后面。当绑定函数被调用时,这些参数会被插入到目标函数的参数列表的开始位置,传递给绑定函数的参数会跟在它们后面。

2.5. 4种绑定优先级

new绑定>硬绑定(显示绑定的变种)>显示绑定>隐式绑定>默认绑定

2.6. 绑定的默认this指向

默认绑定:默认绑定普通函数,this指向全局window,严格模式绑定到undefined

隐式绑定:对象方法,对象名.方法名() this指向该对象

显示绑定:call、apply(显式绑定)或者硬绑定调用,this绑定的是指定的对象。

new绑定:构造函数,new 函数名() this指向new创建实例对象

2.7. 绑定例外

2.7.1. 忽略this

传入null或undefined,被忽略(显示绑定传入null,undefined,实际执行是默认绑定)

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

什么情况下你会传入null(需要一个参数,这时传入null,作为占位符)会导致难以追踪的bug

实际应用:1.使用apply展开数组(ES6中可用扩展运算...展开数组),2.bind 柯里化(ES6中依然需要用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

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

更安全的this

使用this显示绑定时,如果想忽略this,需要一个占位符,如果传入null是不安全的,所以使用var ø = 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

Object.create(null)创建一个空对象,不包含原型链,所有属性都是自己定义。
也可用于创建纯净对象,如用于数据存储的对象;用于字典,存储键值对

2.7.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 //相当于直接调用foo,所以是默认绑定

赋值表达式p.foo = o.foo的返回值是目标函数的引用,因此调用位置是foo()而不是p.foo()或者o.foo()。根据我们之前说过的,这里会应用默认绑定。

对于默认绑定来说,决定this绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。如果函数体处于严格模式,this会被绑定到undefined,否则this会被绑定到全局对象

2.7.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);
              //arguments 对象转换为真正的数组,
              //并获取除第一个参数(即 obj)以外的所有参数,存储在 curried 数组中
              
              //将除第一个参数 obj 以外的其他参数存储在 curried 数组中,
              //这些参数可以被看作是 “预设” 的参数。
              //当调用 softBind 方法时,这些参数可能是提前准备好的,
              //需要被存储起来以供后续使用。
              

[].slice.call() 是 JavaScript 中借用数组方法处理类数组对象的经典技巧,核心作用是:把 “类数组对象”(如 arguments 、DOM 集合)转换成真正的数组,本质是 “借用” 数组原型上的 slice() 方法,让非数组对象也能使用切片功能。

  1. []:创建一个临时空数组(仅用于获取数组原型上的 slice() 方法,用完即销毁,不占额外内存);
  2. slice:数组原型的 slice() 方法(核心功能是切片 / 转数组);
  3. call():函数的原生方法,作用是改变函数执行时的 ****this ****指向,并传入参数执行函数。

简单说: [].slice.call(obj) 等价于 Array.prototype.slice.call(obj) — 都是让 slice() 方法的 this 指向 obj,从而把 obj 当成数组来处理。

              var bound = function() {
                  return fn.apply(
                    //硬绑定,但是改变this不是全局和undefined
                    //如果this指向undefined或全局对象,改变this指向参数obj,否则this不变
                      (! this || this === (window || global)) ?
                    //在浏览器中是 window,在 Node.js 中是 global
                          obj : this,
                      curried.concat.apply(curried, arguments)
                    //合并softBind预设的参数和bound新传入的参数
                  );
              };
              bound.prototype = Object.create(fn.prototype);
              return bound;
            };
        }

关于上面软绑定函数中,参数分解合并(参数预处理)的详细解释分析和使用

假设我们有一个函数 func,使用 softBind 方法对其进行软绑定:

function func(a, b, c) {
    console.log(this.name, a, b, c);
}
var obj = { name: "John" };
var softBoundFunc = func.softBind(obj, 1, 2);//返回的是bound函数
  • 这里 softBoundFuncfunc 函数经过 softBind 处理后的函数,其中 12 是通过 curried = [].slice.call(arguments, 1); 存储在 curried 数组中的参数。
  • 当我们调用 softBoundFunc 时,可能会传入新的参数:
softBoundFunc(3);
  • bound 函数内部,curried.concat.apply(curried, arguments) 会将 curried 中的 [1, 2] 和新传入的 [3] 合并为 [1, 2, 3],然后将这个合并后的数组作为参数传递给原函数 func 进行调用。
        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 <---- 看!! !隐式绑定,this指向它的调用者

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

        setTimeout(obj2.foo, 10);
        // name: obj   <---- 应用了软绑定
        //此时this正常是全局或者undefined,之前进行了软绑定

//可以看到,软绑定版本的foo()可以手动将this绑定到obj2或者obj3上,
//但如果应用默认绑定,则会将this绑定到obj。

3. 箭头函数

箭头函数不会自己创建this,只会从自己作用域链的上一层延用(即箭头函数的this,指向上级词法作用域)箭头函数的绑定无法被修改。(new也不行!)

        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也不行!)

箭头函数也无法通过call apply bind修改this,因为箭头函数没有this

箭头函数是匿名函数,不能使用new去构建构造函数,没有原型属性prototype

箭头函数不绑定arguments,可以使用rest剩余参数

箭头函数不能当做Generator函数,不能使用yield关键字(Generator函数后面再写文章介绍)

箭头函数最常用于回调函数中,例如事件处理器或者定时器

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

        var obj = {
            a:2
        };

        foo.call(obj); // 2

箭头函数可以像bind(..)一样确保函数的this被绑定到指定对象,它用更常见的词法作用域取代了传统的this机制。

在ES6之前我们就已经在使用一种几乎和箭头函数完全一样的模式。

在词法作用域中声明var self =this,或者用bind改变this指向,也可以有箭头函数的效果

        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机制。

在写this相关代码时,选择一种风格书写更利于维护。1.完全箭头函数,利用词法作用域。2.完全this,必要时用bind硬绑定,不使用self=this

参考

  • 《你不知道的JavaScript》

最后

这是JavaScript系列第6篇,下一篇更新《深入理解call,apply,bind相同点及区别》。

小伙伴如果喜欢我的分享,可以动动您发财的手关注下我,我会持续更新的!!!
您对我的关注、点赞和收藏,是对我最大的支持!欢迎关注、评论、讨论和指正!