写在前面
本篇文章是在阅读 mqyqingfeng 大佬这篇《JavaScript深入之从ECMAScript规范解读this》文章后结合自己一些感悟的总结。文中详细从规范的角度讲解了 this 的指向,但是规范到现在已经发生了变化,所以重新梳理了一下相关内容。文章后半部分还对 .call, .apply, .bind 方法和 new 操作符进行了分析。
本篇文章不适合刚入门前端的同学阅读,如果你对隐式绑定、显示绑定和 new 绑定这些概念还不太了解,建议先去了解这些概念,《你不知道的JavaScript》(人称小黄书)这本书就不错。
文章涉及到的 ECMAScript 标准中的定义和抽象步骤比较多,如果一次没有看懂,非常正常,可以先收藏这篇文章(不要脸~),常读常新。本篇文章近 6000 字,耐心看下去,你一定会有所收获。(PS:如果我在文章中有错误的描述,还请及时指出来!)
另外,大佬写的文章非常通俗易懂,如果看不懂我写的,那一定是我太菜了,非常推荐阅读大佬的文章。
(文中涉及到很多链到 ECMAScript 规范的文本链接,如果打不开,可以尝试科学上网)
从 delete foo.bar 开始
我们先暂时忘记主题,来考虑一下 delete
操作符。以一段非常简单的代码为例,
var foo = {
bar() {}
}
delete foo.bar
console.log(foo.bar) // undefined
我们知道,delete 操作符用来删除对象中的属性,但是仔细看这行代码 delete foo.bar
。
按照我们通常的认知,表达式 foo.bar 执行的结果不应该是一个函数吗?delete 怎么能够删除一个函数呢?
delete 到底怎样删除一个属性呢?这就需要引出我们今天要讨论的关键词 —— Reference Record
了,请往下看。
ECMAScript 中的类型
ECMAScript 中定义的类型有两种,如下:
- ECMAScript Language Types(语言类型)。这就是我们在代码中书写的类型,一共有八种—— Undefined、Null、Boolean、String、Symbol、Number、Bigint、Object。
- ECMAScript Specification Types(规范类型)。规范类型只存在于规范中,是为了更好地描述语言的底层行为逻辑而存在的,不存在于实际的 js 代码中。规范类型包括 Reference、List、Completion、Property Descriptor、Environment Record、Abstract Closure、Data Block。我们今天要讲的就是其中一个类型——Reference。
Reference Record Specification Type
The Reference Record type is used to explain the behaviour of such operators as
delete
,typeof
, the assignment operators, thesuper
keyword and other language features. For example, the left-hand operand of an assignment is expected to produce a Reference Record.
Reference Record 直译过来叫做引用记录,用于解释例如 delete 和 typeof 操作符,赋值操作和 super 关键字等等。
注意,这里所说的引用和我们通常所说的引用类型(如 Object,Array 等等)是完全不同的两个概念,不能够混为一谈。
Reference Record 的结构
下面这张图片截取自 ECMA 规范文档中,描述了 Reference Record 的结构:
(插播一下,[[Base]] 这种奇怪的形式你可能不太熟悉,像这样使用两个中括号包裹字符串的写法在标准中被定义为 internal slot
(内部插槽),它是内部属性,没有向外暴露,所以我们在代码中无法通过获取。这么讲可能有点懵,但是我说 [[prototype]]
你肯定就熟悉了。对,就是辣个原型链,如下所示)
我们举两个简单的例子对 Reference Record 的结构进行说明,
var foo = {
bar () {
console.log('bar')
}
}
// 对应的 Reference Record
{
[[Base]]: foo,
[[ReferencedName]]: bar,
[[Strict]]: false,
[[ThisValue]]: empty
}
var foo = 1
// 对应的 Reference Record
{
[[Base]]: Environment Record // 具体我们之后说明,
[[ReferencedName]]: foo,
[[Strict]]: false,
[[ThisValue]]: empty
}
Reference Record 还定义了一系列的 abstract operations(抽象操作),这里我们摘取其中一部分进行说明:
IsPropertyReference(V)
这个抽象方法用于判断一个 Reference Record 是不是属性引用,步骤如下:
- If V.[[Base]] is unresolvable, return false.
- If V.[[Base]] is an Environment Record, return false; otherwise return true.
GetThisValue(V)
这个抽象操作用于获取 this
,步骤如下,
- Assert: IsPropertyReference(V) is true.
- If IsSuperReference() is true, return .[[ThisValue]]; otherwise return . [[Base]] .
GetValue(V)
GetValue(V) 这个抽象操作描述了从 Reference Record 中取值的过程,其结果是 ECMAScript language value,也就是我们上述提到的语言类型。具体的步骤如下:
是不是有点懵?我们一步步解析。步骤 1~2 我们不用管,从第 3 步看起。
第 3 步: 当 Reference Record 的 [[Base]] 属性为 unresolvable
时,抛出一个 ReferenceError 异常。这个 ReferenceError 是不是看着有些眼熟?当我们试图使用一个未定义的变量时,就会报出这样的错误,如下所示。
let a = b;
// Uncaught **ReferenceError**: b is not defined
第 4 步: 首先判断这个引用是不是属性引用,
- 如果是(true),则调用
.[[Get]]
方法拿到对应的值(Returen the value of the property)。[[Get]] 方法在标准中的定义如下,
- 如果不是(false),那么 [[Base]] 是一个 Environment Record。Environment Record 直译叫做环境记录。简单理解,我们在代码中定义的变量都保存在环境记录中,也就是我们通常所说的作用域。通过
GetBindingValue
方法我们就可以拿到作用域中定义的变量的值。
说这么多,GetValue 这个方法要怎么通俗的理解呢?来看一段最简单的代码,
let a = 1
let b = 2
a = b
a = b
这个过程到底发生了什么?我们可能听过 JavaScript 中的两种查找类型, LHS lookup 和 RHS lookup。这两种查找变量的方式的区别在于,LHS 返回的是一个 Reference Record,而 RHS 返回的是一个值,即 GetValue(Reference Record),上述的代码可以理解为,
a = GetValue(b)
(lref) (rval)
那么,如果左侧不是一个引用呢?那么引擎会报一个 SyntaxError 的错误。
2 = 1
//Uncaught SyntaxError: Invalid left-hand side in assignment
//SyntaxError 表明这是在词法解析阶段的报错,表明左侧是非法的。
看到这里你可能会想,这篇文章不是讲 this 的吗?说了这么多甚至都没提到一个 this。别急,我们接下来就要讲到了。
函数调用
调用过程
众所周知,this 是在函数调用时被绑定的,那么在函数调用时发生了什么呢?this 是如何被调用的呢?ECMAScript 规范的 13.3.6 Function Calls 描述了函数调用的具体过程,
上述 Function Calls 的步骤 1~5 是为了获得表达式的结果(引用)以及对应的值。其中出现了一个名词 MemberExpression
。它是规范中的定义 [Left-Hand-Side Expressions](<https://tc39.es/ecma262/#sec-left-hand-side-expressions>)
的语法,这些表达式执行返回了一个 Reference Record,如下所示:
简单举个例子,
function func() { return this }
var foo = {
bar() { return this },
baz() { return function () { return this }}
}
1. func()
2. foo.bar()
3. foo['bar']()
4. foo.baz()()
例子 1~4 ()
左侧都是 MemberExpression
,返回了 Reference Record。之后在 EvaluateCall 步骤中判断得到 this 的引用。
我们看最后一条,9. Return ? EvaluateCall(func, ref, arguments, tailCall) 。规范定义如下,
这里我们只看前两个步骤,其他步骤不用关心,
-
如果 ref 是一个引用,那么
a. 如果 ref 是一个属性引用,那么获取
this
值,通常情况下就是 ref.[[Base]] 。b. 否则,this 值为调用 WithBaseObject() 的结果,正常情况下返回 undefined。
-
否则,返回 undefined。
到这一步,当前函数要动态绑定的 this 是什么已经确定了,就是上图中的 thisValue
。
具体分析
了解了函数调用左侧是一个引用后,我们通过实际的例子来模拟获取 this 值的步骤。
var a = 0;
let foo = {
a: 1,
bar: function () {
console.log(this.a)
}
};
var baz;
baz = foo.bar;
foo.bar(); // 1
baz(); // 0
(otherFunc = foo.bar)(); // 0
foo.bar()
foo.bar
返回的 Reference Record 的结构如下:
V = {
[[Base]]: foo,
[[ReferencedName]]: bar,
[[Strict]]: false,
[[ThisValue]]: empty
}
很明显,IsPropertyReference(V) 的结果是 true
(如果忘了 IsPropertyReference 是什么,可以翻到文章前半部分查看)。紧接着,this 的值被赋为 GetThisValue(V) 的结果 V.[[Base]],也就是 foo
对象。
baz()
还记得我们上边说的赋值表达式吗?这里 baz = foo.bar
可以理解为 baz = GetValue(foo.bar)
。所以变量 baz
被赋值为一个 ECMAScript language type,是一个函数。为什么要说这个呢?就是这一步操作,导致我们丢失了对对象 bar 的引用!
所以在调用 baz()
时, baz 返回的 Reference Record 的结构如下:
V = {
[[Base]]: Environment Record,
[[ReferencedName]]: baz,
[[Strict]]: false,
[[ThisValue]]: empty
}
与上一个例子相比,这里的 [[Base]] 变变成了一个 Environment Record。
那么,IsPropertyReference(V) 的结果就变成了 false
。接着 this 的值就被赋为 V.[[Base]].WithBaseObject() 的结果——undefined。
(otherFunc = foo.bar)()
我们知道,在 JavaScript 中每个表达式都有返回值,这里 otherFunc = foo.bar
可以等价于 otherFunc = GetValue(foo.bar)
。所以,这个表达式的返回值为 GetValue(foo.bar)
——一个函数表达式(ECMAScript language value)。
显然,它不是一个 Reference Record,所以 EvaluateCall 的步骤 1. If ref is a Reference Recored
返回的结果是 false
。直接执行步骤 2. Else, Let thisValue be **undefined**
。
虽然和上个例子相同,this 绑定的值都是 undefined
,但是他们绑定的过程却大相迳庭。
到这里,我们通常所说的默认绑定和隐式绑定也就明了了。如果是类似 obj.func()
的属性调用,会走到 1.a. 步骤,this 的值为 ref.[[Base]] -> obj
,也就是我们通常所说的隐式绑定,否则为默认绑定。
PS:大佬的文章内还提到了其他一些情况,有兴趣可以去阅读,我这里就不赘述了。
为什么是 window 而不是 undefined
上面我们说到,默认绑定 this 的值为 undefined。但是为什么我们通常在浏览器打印看到的是 window 对象呢?请继续往下看。
this 被存放在哪里?
通过上述我们一系列的分析,已经知道了在函数调用过程中 this 是怎么被绑定的,但是 this 被存放在了哪里呢?
上边提到的 EvaluateCall 抽象操作第 7 步如下,
7. Let result be Call(func, thisValue, argList).
Call ( F, V [ , argumentsList ] )
方法传递了三个参数,分别对应调用的函数 func
、 this
和参数列表 argList
。记住这里的 Call 方法,我们之后还会提到很多次!
Call 抽象操作返回结果步骤如下,
- Return ? F.[Call].
这里 F.[[Call]]
调用的是 Function Object (函数对象)的内部方法,定义第 5 步如下,
- Perform OrdinaryCallBindThis(F, calleeContext, thisArgument).
这里又调用了抽象操作 OrdinaryCallBindThis,我们继续,
注意步骤 6,当 thisMode
不是 strict(严格模式),并且 thisArgument 值为 undefined 或者 null 时,会将 thisValue
自动设置为 globalEnv.[[GlobalThisValue]]
,在 Chrome 中,这个值就是我们所说的 window
。至此我们解答了默认绑定的 this 值为什么是 window 而不是 undefined 的问题。
- Return localEnv.BindThisValue(thisValue).
对于函数,这步就是初始化内部插槽 [[ThisValue]]
的值。
ThisValue 内部插槽定义如下,
所以,综上所述,就是通过一系列抽象步骤将 this 值赋值给函数作用域内部的插槽 [[ThisValue]],到这里,我们的函数已经动态绑定了 this 值。当我们调用 this 时,就通过对应的 GetThisBinding()
方法获取。
.bind, .call, .apply 和 new 操作符呢?
我们上述已经提到了默认绑定和隐式绑定的具体步骤,那么显式绑定和 new 绑定在规范中是怎么定义的呢?
Function.prototype.call(thisArg, ...args)
Function.prototype.call() 方法在规范中的定义如下,
- Let func be the this value.
首先第一步,将 func 的值设为 this。我们举一个简单的例子,
var newObj = {
a: 1
}
function foo () {
return this
}
foo.call(newObj)
注意这里 foo.call
,我们知道,在 JavaScript 中函数是一种特殊的对象,所以又回到了我们熟悉的 Reference Record。foo.call 返回了一个引用记录(设为 V),V.[[Base]] 就是 foo 函数对象。所以步骤 1. 就是拿到调用的函数 foo。
- Return ? Call(func, thisArg, args)
可以看到,这里调用了 Call 方法,是不是有些眼熟?我们刚才在 **this 被存放在哪里?**中已经提到了 Call 及之后的步骤,可以看到在 ECMAScript 标准内部,直接调用一个函数 foo()
与通过 .call() 方法调用 foo.call(newObj)
在这里被统一了起来,之后的步骤就与普通函数执行的步骤一样了。
eg:
我们常说 .call() 和 .apply() 方法的区别在于 .call() 方法可以接受一连串的参数,而 .apply() 只能接受一个数组。从规范中的定义我们可以发现不过是 .call() 方法通过 ...args
将剩余参数收起了起来,没有什么神秘的。
Function.prototype.apply(thisArg, argArray)
.apply() 方法与 .call() 方法大同小异,Function.prototype.apply() 方法在规范中定义如下,
与 .call 方法不同的地方在于 步骤 3. 和 4.。
步骤 3 其实就是判断当传入的 argArray
参数是 undefined 和 null 时忽略该参数,直接调用。
步骤 4 是将传入的 argArray
对象转换成一个真正的数组(List),有兴趣可以点击查看规范。
Function.prototype.bind(thisArgs, ...args)
该方法在规范中的定义如下,
重点步骤在 第 3 步和第 10 步,
3. Let F be ? BoundFunctionCreate(Target, thisArg, args).
BoundFunctionCreate 方法步骤如下,
这一步创建出来了一个特殊的对象。从步骤可以知道,这个对象包含了 [[Call]]
(所以能被调用)、 [[Prototype]]
、 [[BoundTargetFunction]]
、 [[BoundThis]]
(这里绑定了 this) 和 [[BoundArguments]]
等参数。
10. Perform SetFunctionName(F, targetName, "bound").
这一步是将原函数的名称同步给新创建的函数。这步完成之后,新的函数也就创建完毕了。
等等,那它的调用过程是啥样的呢?
我们刚才提到,.bind() 创建的其实是一个特殊的函数对象,在规范中叫做 **Bound Function Exotic Objects 。**它在调用时,使用了自己内部的 [[Call]] 方法,步骤如下,
很简单,其实就是调用 Call 方法并将存储的 targetFunction、This 和 Arguments 传递了进去,Call 方法你可就熟悉了吧(如果不记得,可以向上翻翻文章)。所以,.bind() 方法只是创建了一个对象来托管 this 和 arguments 罢了。我们举一个简单的例子来说明,
function foo(name) { console.log(this, name) }
let copyFoo = foo.bind({}, 'GraceWalk')
// 这里得到的 copyFoo 可以理解为以下对象
{
[[BoundTargetFunction]]: foo,
[[BoundThis]]: {},
[[BOundArguments]]: ['GraceWalk'],
[[Call]]: 自己的 Call 方法,
[[Prototype]]: foo.[[GetPrototypeOf]](),通常是 Function.prototype,
[[Extensible]]:true //(默认值)
}
// 调用,等价于 Call(foo, {}, ['GraceWalk'])
copyFoo() // 输出:{}, GraceWalk
好了,我们已经把 .bind() 方法分析完了,接下来只剩 new 操作符了。
new 操作符
我们来分析 new 操作符 在规范中执行的步骤,
6. Return ? Construct(constructor, argList).
这里的传入的参数 constructor 就是构造器,即一个函数,argList 是我们传递的参数。这里调用了 Construct
方法,我们继续。
3. Return ? F.[[Construct]] (argumentsList, newTarget).
这里调用的其实是构造器函数内部的 [[Construct]],有该插槽代表一个函数对象可以被当作构造器,否则不能使用 new 方法。同理,一个对象存在 [[Call]] 插槽才代表它是一个函数,否则这个对象不能被调用。(PS:typeof 操作符就是通过 [[Call]] 插槽来判断一个 Object 是不是 Function 的。)
[[Construct]] 方法较为复杂,我们这里就不贴图片了,挑重点分析,
3. a. Let thisArgument be ? OrdinaryCreateFromConstructor(newTarget, "%Object.prototype%").
这一步创建一个新的对象作为 thisArgument
,并将其原型赋值为 Object.prototype
。
4. Let calleeContext be PrepareForOrdinaryCall(F, newTarget).
- Assert: calleeContext is now the running execution context.
这两步创建一个新的执行上下文,并作为当前执行上下文,通俗一点将就是在函数调用栈栈顶推入了一个栈顶元素。
6.a. Perform OrdinaryCallBindThis(F, calleeContext, thisArgument).
这一步将新的执行上下文中作用域的 [[ThisValue]]
设置为 thisArgument
。
8. Let result be OrdinaryCallEvaluateBody(F, argumentsList).
这一步执行构造器函数并返回结果。记住这个 result,我们稍后就会讲到。
- Remove calleeContext from the execution context stack and restore callerContext as the running execution context.
当前执行上下文出栈。
- If result.[[Type]] is return, then a. If Type(result.[[Value]]) is Object, return NormalCompletion(result.[[Value]]).
返回创建的新对象。至此,new 的过程结束。
The Completion Record Specification Type
等等,上边步骤 10 中的 result.[[Type]]
和 result.[[Value]]
是什么东西呢?它是 ECMAScript Specification Types 中的一种,叫做 The Completion Record Specification Type,规范中定义如下,
The Completion type is a Record used to explain the runtime propagation of values and control flow such as the behaviour of statements (
break
,continue
,return
andthrow
) that perform nonlocal transfers of control.
它解释了 JS 执行过程中值的传播和非本地控制转移的语句的行为(原谅我不说人话,看英文意会~)。它有多重要呢?我们在使用 break、continue、return 和 throw 这些语句时,都会生成一个中间的记录状态。这个记录状态的结构如下,
我们结合 new 操作的步骤来举例解释,在上边步骤 8 我们说到返回了一个 result,这个 result 结构如下,
result = {
[[Type]]: 'return',
[[Value]]: 创建的新对象,是我们之前步骤传入的 thisArgument,
[[Target]]: empty
}
这样,我们在看步骤 10 就容易理解了,就是将 result.[[Value]] ,即通过 new 操作创建出来的新的对象返回。
注意,和 Reference Record 一样,Completion Record 也是只存在于规范中的,我们在实际写代码的过程中完全不会涉及到这些东西,但是了解这些东西能够让你更加深入地知道 JavaScript 是怎样运行的。
完结
从文章开头到现在,我们分别从规范的角度讲解了默认绑定、隐式绑定、显示绑定和 new 绑定,如果面试官以后再问到你对 this 的理解,你就可以装逼(雾~)了。当然,如果你没有依次看懂也没有关系,如果感兴趣,可以结合 ECMAScript 规范对照阅读,相信你能够对 JavaScript 有更深入的了解。
还记得文章开头的 delete 吗?
(这里重新复制一下文章开头的例子)
var foo = {
bar() {}
}
delete foo.bar
console.log(foo.bar) // undefined
通过文章上述内容,我们知道,其实表达式在得到一个具体的值(ECMAScript language value)之前还有一个中间状态,叫做 Reference Record。所以这里的 foo.bar
并不是一个函数,而是一个 Reference Record 结构,如下,
V = {
[[Base]]: foo,
[[ReferencedName]]: bar,
[[Strict]]: false,
[[ThisValue]]: empty
}
delete 执行步骤如下,
重点看步骤5.d.
- If IsPropertyReference(ref) is true, then ... d. Let deleteStatus be ? baseObj.[Delete].
有了之前的铺垫,我们理解 delete 操作就比较简单了。这里就是调用对象内部方法 [[Delete]]
实现的。
具体的删除步骤如下:
从步骤中可以看出,delete 操作有两个特殊之处:
- 如果要删除的属性不存在,直接返回
true
。 - 如果属性不可配置,直接返回
false
。所以我们设置属性时,可以通过设置 [[Configurable]] 值为false
来阻止属性被意外删除。
补充: 如果我们直接执行 delete 1
呢?
- If ref is not a Reference Record, return true
可以看到,当表达式的结果不是一个 Reference Record 时,会直接返回 true。
未完待续...
关于 this 我们终于讲完了,以 this 为主题,我们提及到了很多 ECMAScript 中的概念,这些概念不仅在分析 this 的时候可以用到,它们贯穿了 JavaScript 代码执行的整个过程。只要你细心,在代码中处处可以看到这些隐含的抽象定义。
这篇文章中途提到了 Environment Record,涉及到作用域相关的知识,如果有机会我们之后也会详细展开(给自己挖坑)。如果有兴趣,可以尝试阅读这篇文章。
如果对阅读 ECMAScript 规范感兴趣,可以参考《怎样阅读 ECMAScript 规范?》。
看到这里不来点个赞嘛~
最后,感谢你能够阅读到这里~