【译】理解 JavaScript 中函数调用和 this

原文 Understanding JavaScript Function Invocation and "this"
github 的地址 欢迎 star!

前言

过去几年,我经常听到很多人对 JavaScript 函数调用的谈论,尤其是对其中 this 指向是困惑的。

在我看来,通过深入了解函数调用的核心概念,这些困惑都是可以消除的,其他形式的调用都是其核心的语法糖。事实上,ECMAScript 规范就是这样认为的。这篇博客在很多地方其实就是简单的规范而已,不过基本概念都是相通的。

The Core Primitive(核心原始地调用)

首先,来看一下核心原始地调用:函数 call 的方法[1]。call 的方法相对直接明了。来看一下过程:

  1. call 括号后面的集合就是参数 list(从第一个参数到最后一个):arguments
  2. 第一个参数就是 thisValue
  3. 函数调用的时候,将函数的 this 指向这个 thisValue,除了 thisValue 以外的 arguments 当做函数的参数

例如:

function hello(thing) {
  console.log(this + " says hello " + thing);
}

hello.call("Yehuda", "world") //=> Yehuda says hello world
复制代码

如你所见,调用 hello 方法是把 this 指向 "Yehuda",同时给它传递了一个简单 "world" 的参数。这就是核心原始的函数调用的形式。你可以认为其它所有的函数调用其原理都是通过 call 的形式来实现的(其它形式都是 call 的语法糖,语法糖是指用一个更方便语法和一个更基本的核心原生术语描述它)

[1] 在 ECMAScript 5规范中,call 方法用另外一种更加底层的原生的方法描述。但是它真是一个非常轻的包装, 所以我在这里简化了一点。想了解更多信息请看文章末尾。

简单的函数调用

明显地,每次都用 call 方法调用函数有点烦人。所有 JavaScript 允许我们通过这种模式的语法 hello("world") 调用函数。当我们这样调用时,它内部语法还是用 call 的形式

function hello(thing) {
  console.log("Hello " + thing);
}

// this:
hello("world")

// desugars to:  上面的内部实现
hello.call(window, "world");
复制代码

当然上面的形式在 ECMAScript 5的严格模式下是不同的[2]

// this:
hello("world")

// desugars to:
hello.call(undefined, "world");
复制代码

通用公式: fn(...args) <==等价于==> fn.call(window [ES5-strict: undefined], ...args)

注意,对于立即执行的匿名函数也是如此:

(function() {})()
// 等价于
(function() {}).call(window [ES5-strict: undefined)
复制代码

[2]实际上,我撒了点谎。 ECMAScript 5 规范说了应该全部都是 undefined 传递的,但在非严格模式需要把 this 指向全局对象。这样做是为了避免在严格模式下调用了非严格模式的三方库导致异常的情况

对象方法进行调用

接下来常见的方法,调用一个对象的方法如 person.hello()。在这种情况下,调用语法糖如下:

var person = {
  name: "Brendan Eich",
  hello: function(thing) {
    console.log(this + " says hello " + thing);
  }
}

// this:
person.hello("world")

// desugars to this: 内部实现,this指向那个对象
person.hello.call(person, "world");
复制代码

注意:在这种情况下 hello 方法和对象的是如何绑定的并不重要。还记得我们之前定义了一个独立的 hello 函数, 让我们看看把它动态的绑定在对象上会发生什么:

function hello(thing) {
  console.log(this + " says hello " + thing);
}

person = { name: "Brendan Eich" }
person.hello = hello;

person.hello("world") // still desugars to person.hello.call(person, "world")

hello("world") // "[object DOMWindow]world"
复制代码

注意:函数中的 this 不是固定的(非箭头函数)。它总是在调用过程中由谁调用它来确定的。

使用Function.prototype.bind

当然,有时候还是需要 this 的值是固定的,通常使用一个简单的闭包就可以固定住 this 的指向:

var person = {
  name: "Brendan Eich",
  hello: function(thing) {
    console.log(this.name + " says hello " + thing);
  }
}

var boundHello = function(thing) { return person.hello.call(person, thing); }

boundHello("world");
复制代码

即使使用boundHello.call(window, "world")这样原始的调用形式,我们也无法达到改变 this 的预期。

我们可以做一些改动使它更通用:

var bind = function(func, thisValue) {
  return function() {
    return func.apply(thisValue, arguments);
  }
}

var boundHello = bind(person.hello, person);
boundHello("world") // "Brendan Eich says hello world"
复制代码

为了理解 this,你只需要知道这两个点。首先 arguments 是一个类数组的对象,它代表了传递给函数的所有参数。第二,apply 的方法准确地说像 call 的底层实现,只是把那个类数组对象用一个接一个参数代替。

我们 bind 的方法简单地返回了一个新的函数。当它被调用的时候,新的函数又调用原始函数,并把 this 指向原始值,它也可以传递参数。

因为这个方法是常用的,故 ES5 给所有 Function 对象定义了一个新的方法 bind,实现了如下调用:

var boundHello = person.hello.bind(person);
boundHello("world") // "Brendan Eich says hello world"  this都是指向person的
复制代码

这是非常实用的,当你把一个原始函数作为一个回调的时候:

var person = {
  name: "Alex Russell",
  hello: function() { console.log(this.name + " says hello world"); }
}

$("#some-div").click(person.hello.bind(person));

// when the div is clicked, "Alex Russell says hello world" is printed
复制代码

当然这种方式是笨重的, TC39(正在编写下一代 ECMAScript 的组织)创造一种更加优雅和 向后兼容的解决方法(箭头函数)。

在jQuery中

因为 jQuery 使用了很多的匿名函数,在内部它使用 call 将 this 指向了预期有用的值。例如,在 jQuery 的所有事件操作中(假设你没有进行特殊的操作),DOM 调用回调函数,this 总是指向那个 DOM 元素。

这是非常有用的,因为在匿名函数中默认的 this 是没有什么特殊作用的。但这使得那些刚刚学习 JavaScript 的人将难以理解 this。

如果你明白将函数调用改写为 func.call(thisValue, ...args) 这样简单的方法(去除语法糖),将不会在确定JavaScript中this值的过程中迷失。

PS(附言): 我撒谎了

在很多地方,我稍微简化了规范里面一些确切的点。最明显的地方就是我将 func.call 的方法当作一个原生函数调用方法。事实上,规范里面明确了 func.call[obj.]func() 都是通过一个叫 [[Call]] 的原始方法实现的。

当然,看看 fun.call(thisArg, arg1, arg2, ...) 的定义:

  1. 如果不能当做函数调用,则抛出类型错误。
  2. 参数列表为空
  3. 如果传了不止一个参数,则按从左到右的 arg1, arg2 的顺序把这些值传递给函数作为参数列表(除了 this )
  4. 使用调用者提供的this值和参数调用该函数的返回值。this 指向 thisArg,arguments 指向后面的参数组成的 list

如上所述,这个定义本质就是一个简单的 JavaScript 绑定原始 [[Call]] 的操作。

你可以回顾一下这个函数调用的定义,首先几步是建立 thisValueargList,把 this 指向 thisValue, 把 arguments 指向了 argList,最后一步,调用内部函数,返回结果。这和那个原始的最初的调用是基本相同的。

我撒谎称 call 是最原始的函数调用方式,但它的内部调用实现基本和规范里面说的原始调用形式是相同的。

另外还有一些额外的 this 指向的例子没有说明,像 with。

额外的总结

看到了《JavaScript 设计模式与开发实践的总结

除去不常用的 with 和 eval 的情况,具体到实际应用中,this 的指向大致可以分为以下4种:

  1. 作为对象的方法调用:指向函数直接所在的那个对象
  2. 作为普通函数调用(this 就是改写为 call 的第一个参数)
  3. 构造器调用( new Fn() ):指向新生成的那个对象实例(当然书中说了,构造函数里面要避免显式的返回一个对象,不然 this 指向的是返回的那个对象!)
  4. Function.prototype.call 或 Function.prototype.apply 调用

此外,还有箭头函数中,this 就是箭头函数相邻外面的那个this(也是一个参数)

当然你可以看一下 call 的实现,你就可以理解一下函数核心原始的底层调用是什么样子:

Function.protoType.call2 = function(context){
    var context = context | window;// 可能没有传参数
    context.fn = this;
    var args = [];
    for(var i = 1; i < arguments.length; i++) {
        args.push("arguments[" + i +"]"); // 不这么做的话 字符串的引号会被自动去掉 变成了变量 导致报错
    }
    args = args.join(",");
    
    var result = eval("context.fn(" + args +")");//相当于执行了context.fn(arguments[1], arguments[2]);
    
    delete context.fn;
    return result;
}
复制代码

另外强烈推荐大家可以看一下如何编写高质量的函数 -- 敲山震虎篇 ---详细介绍了函数中底层知识: 总结如下:

  1. 创建函数,开辟堆内存,以字符串存入函数体,将函数名(变量)的值变为函数体堆内存中地址。
  2. 执行函数,将存储的字符串函数体复制一份到新开辟的栈内存中,使其变为真正的 JS 代码

反正我就记住了,this 是一个参数,是一个参数!

如果有错误或者不严谨的地方,请务必给予指正,十分感谢!

参考

  1. www.ruanyifeng.com/blog/2018/0…
  2. Understanding JavaScript Function Invocation and "this"
  3. 【周刊-1】三年大厂面试官-面试题精选及答案--call 实现
  4. 如何编写高质量的函数 -- 敲山震虎篇
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值