【第765期】你不懂JS:this豁然开朗!
前言
昨天第一篇分享就被@烛火星光发现是跟《你不知道的javascript》上册(一看就是看过书的),没看过书的童鞋也没关系,可以跟着早读君每天看一篇。今天继续由前端早读课专栏作者@HetfieldJoe带来的翻译分享。
正文从这开始~
这是 你不懂JS:this与对象原型 第一章:this豁然开朗!
在【第764期】你不懂JS:this是什么?中,我们摒弃了种种对this的误解,并且学习了this是一个完全根据调用点(函数是如何被调用的)而为每次函数调用建立的绑定。
调用点(Call-site)
为了理解this绑定,我们不得不理解调用点:函数在代码中被调用的位置(不是被声明的位置)。我们必须考察调用点来回答这个问题:这个this指向什么?
一般来说寻找调用点就是:“找到一个函数是在哪里被调用的”,但不总是那么简单,比如某些特定的编码模式会使 真正的 调用点变得不那么明确。
考虑 调用栈(call-stack) (使我们到达当前执行位置而被调用的所有方法的堆栈)是十分重要的。我们关心的调用点就位于当前执行中的函数 之前 的调用。
我们来展示一下调用栈和调用点:
在分析代码来寻找(从调用栈中)真正的调用点时要小心,因为它是影响this绑定的唯一因素。
注意: 你可以通过按顺序观察函数的调用链在你的大脑中建立调用栈的视图,就像我们在上面代码段中的注释那样。但是这很痛苦而且易错。另一种观察调用栈的方式是使用你的浏览器的调试工具。大多数现代的桌面浏览器都内建开发者工具,其中就包含JS调试器。在上面的代码段中,你可以在调试工具中为foo()函数的第一行设置一个断点,或者简单的在这第一行上插入一个debugger语句。当你运行这个网页时,调试工具将会停止在这个位置,并且向你展示一个到达这一行之前所有被调用过的函数的列表,这就是你的调用栈。所以,如果你想调查this绑定,可以使用开发者工具取得调用栈,之后从上向下找到第二个记录,那就是你真正的调用点。
仅仅是规则
现在我们将注意力转移到调用点 如何 决定在函数执行期间this指向哪里。
你必须考察call-site并判定4种规则中的哪一个适用。我们将首先独立的解释一下这4种规则中的每一种,之后我们来展示一下如果有多种规则可以适用调用点时,它们的优先顺序。
默认绑定(Default Binding)
我们要考察的第一种规则来源于函数调用的最常见的情况:独立函数调用。可以认为这种this规则是在没有其他规则适用时的默认规则。
考虑这个代码段:
第一点要注意的,如果你还没有察觉到,是在全局作用域中的声明变量,也就是var a = 2,是全局对象的同名属性的同义词。它们不是互相拷贝对方,它们 就是 彼此。正如一个硬币的两面。
第二,我们看到当foo()被调用时,this.a解析为我们的全局变量a。为什么?因为在这种情况下,对此方法调用的this实施了 默认绑定,所以使this指向了全局对象。
我们怎么知道这里适用 默认绑定 ?我们考察调用点来看看foo()是如何被调用的。在我们的代码段中,foo()是被一个直白的,毫无修饰的函数引用调用的。没有其他的我们将要展示的规则适用于这里,所以 默认绑定 在这里适用。
如果strict mode在这里生效,那么对于 默认绑定 来说全局对象是不合法的,所以this将被设置为undefined。
一个微妙但是重要的细节是:即便所有的this绑定规则都是完全基于调用点,如果foo()的 内容 没有在strint mode下执行,对于 默认绑定 来说全局对象是 唯一 合法的;foo()的call-site的strict mode状态与此无关。
注意: 在你的代码中故意混用strict mode和非strict mode通常是让人皱眉头的。你的程序整体可能应当不是 Strict 就是 非Strict。然而,有时你可能会引用与你的 Strict 模式不同的第三方包,所以对这些微妙的兼容性细节要多加小心。
隐含绑定(Implicit Binding)
另一种要考虑的规则是:调用点是否有一个环境对象(context object),也称为拥有者(owning)或容器(containing)对象,虽然这些名词可能有些误导人。
考虑这段代码:
首先,注意foo()被声明然后作为引用属性添加到obj上的方式。无论foo()是否一开始就在obj上被声明,还是后来作为引用添加(如上面代码所示),都是这个 函数 被obj所“拥有”或“包含”。
然而,调用点 使用 obj环境来 引用 函数,所以你 可以说 obj对象在函数被调用的时间点上“拥有”或“包含”这个 函数引用。
不论你怎样称呼这个模式,在foo()被调用的位置上,它被冠以一个指向obj的对象引用。当一个方法引用存在一个环境对象时,隐式绑定 规则会说:是这个对象应当被用于这个函数调用的this绑定。
因为obj是foo()调用的this,所以this.a就是obj.a的同义词。
只有对象属性引用链的最后一层是影响调用点的。比如:
隐含地丢失(Implicitly Lost)
this绑定最常让人沮丧的事情之一,就是当一个 隐含绑定 丢失了它的绑定,这通常意味着它会退回到 默认绑定, 根据strict mode的状态,结果不是全局对象就是undefined。
考虑这段代码:
尽管bar似乎是obj.foo的引用,但实际上它只是另一个foo自己的引用而已。另外,起作用的调用点是bar(),一个直白,毫无修饰的调用,因此 默认绑定 适用于这里。
这种情况发生的更加微妙,更常见,更意外的方式,是当我们考虑传递一个回调函数时:
参数传递仅仅是一种隐含的赋值,而且因为我们在传递一个函数,它是一个隐含的引用赋值,所以最终结果和我们前一个代码段一样。
那么如果接收你所传递回调的函数不是你的,而是语言内建的呢?没有区别,同样的结果。
把这个粗糙的,理论上的setTimeout()假想实现当做JavaScript环境内建的实现的话:
正如我们刚刚看到的,我们的回调函数丢掉他们的this绑定是十分常见的事情。但是另一种this使我们吃惊的方式是,接收我们的回调的函数故意改变调用的this。那些很受欢迎的事件处理JavaScript包就十分喜欢强制你的回调的this指向触发事件的DOM元素。虽然有时这很有用,但其他时候这简直能气死人。不幸的是,这些工具很少给你选择。
不管哪一种意外改变this的方式,你都不能真正地控制你的回调函数引用将如何被执行,所以你(还)没有办法控制调用点给你一个故意的绑定。我们很快就会看到一个方法,通过 固定 this来解决这个问题。
明确绑定(Explicit Binding)
用我们刚看到的 隐含绑定,我们不得不改变目标对象使它自身包含一个对函数的引用,而后使用这个函数引用属性来间接地(隐含地)将this绑定到这个对象上。
但是,如果你想强制一个函数调用使用某个特定对象作为this绑定,而不在这个对象上放置一个函数引用属性呢?
JavaScript语言中的“所有”函数都有一些工具(通过他们的[[Prototype]]——待会儿详述)可以用于这个任务。特别是,函数拥有call(..)和apply(..)方法。从技术上讲,JavaScript宿主环境有时会提供一些很特别的函数,它们没有这些功能,但这很少见。绝大多数被提供的函数,当然还有你将创建的所有的函数,都可以访问call(..)和apply(..)。
这些工具如何工作?它们接收的第一个参数都是一个用于this的对象,之后使用这个指定的this来调用函数。因为你已经直接指明你想让this是什么,所以我们称这种方式为 明确绑定(explicit binding)。
考虑这段代码:
通过foo.call(..)使用 明确绑定 来调用foo,允许我们强制函数的this指向obj。
如果你传递一个简单原始类型值(string,boolean,或 number类型)作为this绑定,那么这个原始类型值会被包装在它的对象类型中(分别是new String(..),new Boolean(..),或new Number(..))。这通常称为“boxing(封箱)”。
注意: 就this绑定的角度讲,call(..)和apply(..)是完全一样的。它们确实在处理其他参数上的方式不同,但那不是我们当前关心的。
不幸的是,单独依靠 明确绑定 仍然不能为我们先前提到的问题提供解决方案,也就是函数“丢失”自己原本的this绑定,或者被第三方框架覆盖,等等问题。
硬绑定(Hard Binding)
但是有一个 明确绑定 的变种确实可以实现这个技巧。考虑这段代码:
我们来看看这个变种是如何工作的。我们创建了一个函数bar(),在它的内部手动调用foo.call(obj),由此强制this绑定到obj并调用foo。无论你过后怎样调用函数bar,它总是手动使用obj调用foo。这种绑定即明确又坚定,所以我们称之为 硬绑定(hard binding)
用 硬绑定 将一个函数包装起来的最典型的方法,是为所有传入的参数和传出的返回值创建一个通道:
另一种表达这种模式的方法是创建一个可复用的帮助函数:
由于 硬绑定 是一个如此常用的模式,它已作为ES5的内建工具提供:Function.prototype.bind,像这样使用:
bind(..)返回一个硬编码的新函数,它使用你指定的this环境来调用原本的函数。
注意: 在ES6中,bind(..)生成的硬绑定函数有一个名为.name的属性,它源自于原始的 目标函数(target function)。举例来说:bar = foo.bind(..)应该会有一个bar.name属性,它的值为"bound foo",这个值应当会显示在调用栈轨迹的函数调用名称中。
API调用的“环境”
确实,许多包中的函数,和许多在JavaScript语言以及宿主环境中的内建函数,都提供一个可选参数,通常称为“环境(context)”,这种设计作为一种替代方案来确保你的回调函数使用特定的this而不必非得使用bind(..)。
举例来说:
从内部来说,这种类型的函数几乎可以确定是通过call(..)或apply(..)使用了 明确绑定 来节省你的麻烦。
new绑定(new Binding)
第四种也是最后一种this绑定规则,需要我们重新思考关于JavaScript中对函数和对象的常见误解。
在传统的面向类语言中,“构造器”是附着在类上的一种特殊方法,当使用new操作符来初始化一个类时,这个类的构造器就会被调用。通常看起来像这样:
JavaScript拥有new操作符,而且它使用的代码模式看起来基本和我们在面向类语言中看到的一样;大多数开发者猜测JavaScript机制是某种相似的东西。但是,实际上JavaScript的机制和new在JS中的用法所暗示的面向类的功能 没有任何联系。
首先,让我们重新定义JavaScript的“构造器”是什么。在JS中,构造器 仅仅是一个函数,它们偶然地被前置的new操作符调用。它们不依附于类,它们也不初始化一个类。它们甚至不是一种特殊的函数类型。它们本质上只是一般的函数,在被使用new来调用时改变了行为。
比如,Number(..)函数作为一个构造器来说,引用ES5.1的语言规范:
15.7.2 The Number 构造器
当Number作为new表达式的一部分被调用时,它是一个构造器:它初始化这个新创建的对象。
所以,任何关联在对象上的函数,包括像Number(..)(见第三章)这样的内建对象函数都可以在前面加上new来被调用,这使函数调用成为一个 构造器调用(constructor call)。这是一个重要且微妙的区别:实际上不存在“构造器函数”这样的东西,而只有函数的构造器调用。
当在函数前面被加入new调用时,也就是构造器调用时,下面这些事情会自动完成:
一个全新的对象会凭空创建(就是被构建)
这个新构建的对象会被接入原形链([[Prototype]]-linked)
这个新构建的对象被设置为函数调用的this绑定
除非函数返回一个它自己的其他 对象,这个被new调用的函数将 自动 返回这个新构建的对象。
步骤1,3和4是我们当下要讨论的。我们现在跳过第2步,在第五章回来讨论。
考虑这段代码:
通过在前面使用new来调用foo(..),我们构建了一个新的对象并这个新对象作为foo(..)调用的this。 new是函数调用可以绑定this的最后一种方式,我们称之为 new绑定(new binding)。
一切皆有顺序
如此,我们已经揭示了函数调用中的4种this绑定规则。你需要做的 一切 就是找到调用点然后考察哪一种规则适用于它。但是,如果调用点上有多种规则都适用呢?这些规则必须有一个优先顺序,我们下面就来展示这些规则以什么样的优先顺序实施。
很显然,默认绑定 在4种规则中拥有最低的优先权。所以我们先把它放在一边。
隐含绑定 和 明确绑定 哪一个更优先呢?我们来测试一下:
所以, 明确绑定 的优先权要高于 隐含绑定,这意味着你应当在考察 隐含绑定 之前 首先 考察 明确绑定 是否适用。
现在,我们只需要搞清楚 new绑定 的优先级位于何处。
好了,new绑定 的优先级要高于 隐含绑定。那么你觉得 new绑定 的优先级较之于 明确绑定 是高还是低呢?
注意: new和call/apply不能同时使用,所以new foo.call(obj1)是不允许的,也就是不能直接对比测试 new绑定 和 明确绑定。但是我们依然可以使用 硬绑定 来测试这两个规则的优先级。
在我们进入代码中探索之前,回想一下 硬绑定 物理上是如何工作的,也就是Function.prototype.bind(..)创建了一个新的包装函数,这个函数被硬编码为忽略它自己的this绑定(不管它是什么),转而手动使用我们提供的。
因此,这似乎看起来很明显,硬绑定(明确绑定的一种)的优先级要比 new绑定 高,而且不能被new覆盖。
我们检验一下:
哇!bar是硬绑定到obj1的,但是new bar(3)没有想我们期待的那样将obj1.a变为3。反而,硬绑定(到obj1)的bar(..)调用 可以 被new所覆盖。因为new被实施,我们得到一个名为baz的新创建的对象,而且我们确实看到baz.a的值为3。
如果你回头看看我们的“山寨”绑定帮助函数,这很令人吃惊:
如果你推导这段帮助代码如何工作,会发现对于new操作符调用来说没有办法去像我们观察到的那样,将绑定到obj的硬绑定覆盖。
但是ES5的内建Function.prototype.bind(..)更加精妙,实际上十分精妙。这里是MDN网页上为bind(..)提供的polyfill(低版本兼容填补工具):
注意: 在ES5中,就将与new一起使用的硬绑定函数(参照下面来看为什么这有用)而言,上面的bind(..)polyfill与内建的bind(..)是不同的。因为polyfill不能像内建工具那样,没有.prototype就能创建函数,这里使用了一些微妙而间接的方法来近似模拟相同的行为。如果你打算将硬绑定函数和new一起使用而且依赖于polyfill,应当多加小心。
允许new进行覆盖的部分是这里:
我们不会实际深入解释这个花招儿是如何工作的(这很复杂而且超出了我们当前的讨论范围),但实质上这个工具判断硬绑定函数是否是用new被调用的(结果是用一个它新构建的对象作为this),如果是,它就用那个新构建的this而非先前为this指定的 硬绑定。
为什么new可以覆盖 硬绑定 这件事很有用?
这种行为的主要原因是,创建一个实质上忽略this的 硬绑定 而预先设置一部分或所有的参数的函数(这个函数可以与new一起使用来构建对象)。bind(..)的一个能力是,任何在第一个this绑定参数之后被传入的参数,默认地作为当前函数的标准参数(技术上这称为“局部应用(partial application)”,是一种“柯里化(currying)”)。
比如:
判定 this
现在,我们可以按照优先顺序来总结一下从函数调用的调用点来判定this的规则了。按照这个顺序来问问题,然后在第一个规则适用的地方停下。
函数是和new一起被调用的吗(new绑定)?如果是,this就是新构建的对象。
var bar = new foo()
函数是用call或apply被调用(明确绑定),甚至是隐藏在bind 硬绑定 之中吗?如果是,this就是明确指定的对象。
var bar = foo.call( obj2 )
函数是用环境对象(也称为拥有者或容器对象)被调用的吗(隐含绑定)?如果是,this就是那个环境对象。
var bar = obj1.foo()
否则,使用默认的this(默认绑定)。如果在strict mode下,就是undefined,否则是global对象。
var bar = foo()
以上,就是理解对于普通的函数调用来说的this绑定规则所需的全部。是的···几乎是全部。
绑定的特例
正如通常的那样,对于这些“规则”有一些 例外。
在某些场景下this绑定会让人很吃惊,比如在你试图实施一种绑定,然而最终得到的是 默认绑定 规则的绑定行为(见前面的内容)。
被忽略的this
如果你传递null或undefined作为call,apply或bind的this绑定参数,那么这些值会被忽略掉,取而代之的是 默认绑定 规则将适用于这个调用。
为什么你会向this绑定故意传递像null这样的值?
使用apply(..)来将一个数组散开,从而作为函数调用的参数,是一个很常见的做法。相似地,bind(..)可以curry参数(预设值),也是很有帮助的。
这两种工具都要求第一个参数是this绑定。如果想让使用的函数不关心this,你就需要一个占位值,而且正如这个代码段中展示的,null看起来是一个合理的选择。
注意: 虽然我们在这本书中没有涵盖,但是ES6中有一个扩散操作符:...。它让你无需使用apply(..)而在语法上将一个数组“散开”作为参数,比如foo(...[1,2])表示foo(1,2)——如果this绑定没有必要,可以在语法上回避它。不幸的是,柯里化在ES6中没有语法上的替代品,所以bind(..)调用的this参数依然需要注意。
可是,在你不关心this绑定而一直使用null的时候,有些潜在的“危险”。如果你这样处理一些函数调用(比如,不归你管控的第三方包),而且那些函数确实使用了this引用,那么 默认绑定 规则意味着它可能会不经意间引用(或者改变,更糟糕!)global对象(在浏览器中是window)。
很显然,这样的陷阱会导致多种 非常难 诊断和追踪的Bug。
更安全的this
也许某些“更安全”的实践是:为了this而传递一个特别建立好的对象,这个对象保证不会对你的程序产生副作用。从网络学(或军事)上借用一个词,我们可以建立一个“DMZ”(非军事区)对象——只不过是一个完全为空,没有委托(见第五,六章)的对象。
如果我们为了忽略自己认为不用关心的this绑定,而总是传递一个DMZ对象,我们就可以确定任何对this的隐藏或意外的使用将会被限制在这个空对象中,也就是说这个对象将global对象和副作用隔离开来。
因为这个对象是完全为空的,我个人喜欢给他一个变量名为ø(空集合的数学符号的小写)。在许多键盘上(比如Mac的美式键盘),这个符号可以很容易地用⌥ o (option o)打出来。有些系统还允许你为某个特殊符号设置快捷键。如果你不喜欢ø符号,或者你的键盘没那么好打,你当然可以叫它任意你希望的名字。
无论你叫它什么,创建 完全为空的对象 的最简单方法就是Object.create(null)(见第五章)。Object.create(null)和{}很相似,但是没有Object.prototype的委托,所以它比{}“空得更彻底”。
不仅在功能上更“安全”,ø还会在代码风格上产生些好处,它在语义上可能会比null更清晰的表达“我想让this为空”。当然,你可以随自己喜欢来称呼你的DMZ对象。
间接
另外一个要注意的是,你可以(故意或非故意地!)创建对函数的“间接引用(indirect reference)”,在那样的情况下,当那个函数引用被调用时,默认绑定 规则也会适用。
一个最常见的 间接引用 产生方式是通过赋值:
赋值表达式p.foo = o.foo的 结果值 是一个刚好指向底层函数对象的引用。如此,起作用的调用点就是foo(),而非你期待的p.foo()或o.foo()。根据上面的结果,默认绑定 规则适用。
提醒: 无论你如何得到适用 默认绑定 的函数调用,被调用函数的 内容 的strict mode状态——而非函数的调用点——决定了this引用的值:不是global对象(在非strict mode下),就是undefined(在strict mode下)。
软化绑定(Softening Binding)
我们之前看到 硬绑定 是一种通过强制函数绑定到特定的this上,来防止函数调用在不经意间退回到 默认绑定 的策略(除非你用new去覆盖它!)。问题是,硬绑定 极大地降低了函数的灵活性,阻止我们手动使用 隐式绑定 或后续的 明确绑定 尝试来覆盖this。
如果有这样的办法就好了:为 默认绑定 提供不同的默认值(不是global或undefined),同时保持函数可以通过 隐式绑定 或 明确绑定 技术来手动绑定this。
我们可以构建一个所谓的 软绑定 工具来模拟我们期望的行为。
这里提供的softBind(..)工具的工作方式和ES5内建的bind(..)工具很相似,除了我们的 软绑定 行为。他用一种逻辑将指定的函数包装起来,这个逻辑在函数调用时检查this,如果它是global或undefined,就使用预先指定的 默认值 (obj),否则保持this不变。它也提供了可选的柯里化行为(见先前的bind(..)讨论)。
我们来看看它的用法:
软绑定版本的foo()函数可以如展示的那样被手动this绑定到obj2或obj3,如果 默认绑定 适用时会退到obj。
词法this
我们刚刚涵盖了一般函数遵守的4种规则。但是ES6引入了一种不适用于这些规则特殊的函数:箭头函数(arrow-function)。
箭头函数不是通过function声明的,而是通过所谓的“大箭头”操作符:=>。与使用4种标准的this规则不同的是,箭头函数从封闭它的(function或global)作用域采用this绑定。
我们来展示一下箭头函数的词法作用域:
在foo()中创建的箭头函数在词法上捕获foo()调用时的this,不管它是什么。因为foo()被this绑定到obj1,bar(被返回的箭头函数的一个引用)也将会被this绑定到obj1。一个箭头函数的词法绑定是不能被覆盖的(就连new也不行!)。
最常见的用法是用于回调,比如事件处理器或计时器:
虽然箭头函数提供除了使用bind(..)外,另外一种在函数上来确保this的方式,这看起来很吸引人,但重要的是要注意它们本质是用被广泛理解的词法作用域来禁止了传统的this机制。在ES6之前,我们为此已经有了相当常用的模式,这些模式几乎和ES6的箭头函数的精神没有区别:
虽然对不想用bind(..)的人来说self = this和箭头函数都是看起来不错的“解决方案”,但它们实质上逃避了this而非理解和接受它。
如果你发现你在写this风格的代码,但是大多数或全部时候,你都用词法上的self = this或箭头函数“技巧”抵御this机制,那么也许你应该:
仅使用词法作用域并忘掉虚伪的this风格代码。
完全接受this风格机制,包括在必要的时候使用bind(..),并尝试避开self = this和箭头函数的“词法this”技巧。
一个程序可以有效地同时利用两种风格的代码(词法和this),但是在同一个函数内部,特别是对同种类型的查找,混合这两种机制通常是自找很难维护的代码,而且可能是聪明过了头。
复习
为执行中的函数判定this绑定需要找到这个函数的直接调用点。找到之后,4种规则将会以 这个 优先顺序施用于调用点:
被new调用?使用新构建的对象。
被call或apply(或 bind)调用?使用指定的对象。
被持有调用的环境对象调用?使用那个环境对象。
默认:strict mode下是undefined,否则就是全局对象。
小心偶然或不经意的 默认绑定 规则调用。如果你想“安全”地忽略this绑定,一个像ø = Object.create(null)这样的“DMZ”对象是一个很好的占位值,来保护global对象不受意外的副作用影响。
与这4种绑定规则不同,ES6的箭头方法使用词法作用域来决定this绑定,这意味着它们采用封闭他们的函数调用作为this绑定(无论它是什么)。它们实质上是ES6之前的self = this代码的语法替代品。