你真的懂 this 吗?6000 字硬核解析 this 原理(你没有见过的船新版本~)

783 阅读17分钟

写在前面

本篇文章是在阅读 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 中定义的类型有两种,如下:

  1. ECMAScript Language Types(语言类型)。这就是我们在代码中书写的类型,一共有八种—— Undefined、Null、Boolean、String、Symbol、Number、Bigint、Object。
  2. 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 deletetypeof, the assignment operators, the super 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 的结构

image.png

(插播一下,[[Base]] 这种奇怪的形式你可能不太熟悉,像这样使用两个中括号包裹字符串的写法在标准中被定义为 internal slot (内部插槽),它是内部属性,没有向外暴露,所以我们在代码中无法通过获取。这么讲可能有点懵,但是我说 [[prototype]] 你肯定就熟悉了。对,就是辣个原型链,如下所示)

image.png

我们举两个简单的例子对 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 是不是属性引用,步骤如下:

  1. If V.[[Base]] is unresolvable, return false.
  2. If V.[[Base]] is an Environment Record, return false; otherwise return true.

GetThisValue(V)

这个抽象操作用于获取 this ,步骤如下,

  1. Assert: IsPropertyReference(V) is true.
  2. If IsSuperReference() is true, return .[[ThisValue]]; otherwise return . [[Base]] .

GetValue(V)

GetValue(V) 这个抽象操作描述了从 Reference Record 中取值的过程,其结果是 ECMAScript language value,也就是我们上述提到的语言类型。具体的步骤如下:

image.png

是不是有点懵?我们一步步解析。步骤 1~2 我们不用管,从第 3 步看起。

第 3 步: 当 Reference Record 的 [[Base]] 属性为 unresolvable 时,抛出一个 ReferenceError 异常。这个 ReferenceError 是不是看着有些眼熟?当我们试图使用一个未定义的变量时,就会报出这样的错误,如下所示。

let a = b;
// Uncaught **ReferenceError**: b is not defined

第 4 步: 首先判断这个引用是不是属性引用,

  1. 如果是(true),则调用 .[[Get]] 方法拿到对应的值(Returen the value of the property)。[[Get]] 方法在标准中的定义如下,

image.png

  1. 如果不是(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 描述了函数调用的具体过程,

image.png

上述 Function Calls 的步骤 1~5 是为了获得表达式的结果(引用)以及对应的值。其中出现了一个名词 MemberExpression 。它是规范中的定义 [Left-Hand-Side Expressions](<https://tc39.es/ecma262/#sec-left-hand-side-expressions>) 的语法,这些表达式执行返回了一个 Reference Record,如下所示:

image.png

简单举个例子,

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) 。规范定义如下,

image.png

这里我们只看前两个步骤,其他步骤不用关心,

  1. 如果 ref 是一个引用,那么

    a. 如果 ref 是一个属性引用,那么获取 this 值,通常情况下就是 ref.[[Base]]

    b. 否则,this 值为调用 WithBaseObject() 的结果,正常情况下返回 undefined

  2. 否则,返回 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 ] ) 方法传递了三个参数,分别对应调用的函数 functhis 和参数列表 argList记住这里的 Call 方法,我们之后还会提到很多次!

Call 抽象操作返回结果步骤如下,

  1. Return ? F.[Call].

这里 F.[[Call]] 调用的是 Function Object (函数对象)的内部方法,定义第 5 步如下,

  1. Perform OrdinaryCallBindThis(F, calleeContext, thisArgument).

这里又调用了抽象操作 OrdinaryCallBindThis,我们继续,

image.png

注意步骤 6,当 thisMode 不是 strict(严格模式),并且 thisArgument 值为 undefined 或者 null 时,会将 thisValue 自动设置为 globalEnv.[[GlobalThisValue]] ,在 Chrome 中,这个值就是我们所说的 window 。至此我们解答了默认绑定的 this 值为什么是 window 而不是 undefined 的问题。

  1. Return localEnv.BindThisValue(thisValue).

对于函数,这步就是初始化内部插槽 [[ThisValue]] 的值。

image.png

ThisValue 内部插槽定义如下,

image.png

所以,综上所述,就是通过一系列抽象步骤将 this 值赋值给函数作用域内部的插槽 [[ThisValue]],到这里,我们的函数已经动态绑定了 this 值。当我们调用 this 时,就通过对应的 GetThisBinding() 方法获取。

.bind, .call, .apply 和 new 操作符呢?

我们上述已经提到了默认绑定和隐式绑定的具体步骤,那么显式绑定和 new 绑定在规范中是怎么定义的呢?

Function.prototype.call(thisArg, ...args)

Function.prototype.call() 方法在规范中的定义如下,

image.png

  1. 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。

  1. 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() 方法在规范中定义如下,

image.png

与 .call 方法不同的地方在于 步骤 3. 和 4.。

步骤 3 其实就是判断当传入的 argArray 参数是 undefined 和 null 时忽略该参数,直接调用。

步骤 4 是将传入的 argArray 对象转换成一个真正的数组(List),有兴趣可以点击查看规范

Function.prototype.bind(thisArgs, ...args)

该方法在规范中的定义如下,

image.png

重点步骤在 第 3 步和第 10 步,

3. Let F be ? BoundFunctionCreate(Target, thisArg, args).

BoundFunctionCreate 方法步骤如下,

image.png

这一步创建出来了一个特殊的对象。从步骤可以知道,这个对象包含了 [[Call]] (所以能被调用)、 [[Prototype]][[BoundTargetFunction]][[BoundThis]] (这里绑定了 this) 和 [[BoundArguments]] 等参数。

10. Perform SetFunctionName(F, targetName, "bound").

这一步是将原函数的名称同步给新创建的函数。这步完成之后,新的函数也就创建完毕了。

等等,那它的调用过程是啥样的呢?

我们刚才提到,.bind() 创建的其实是一个特殊的函数对象,在规范中叫做 **Bound Function Exotic Objects 。**它在调用时,使用了自己内部的 [[Call]] 方法,步骤如下,

image.png

很简单,其实就是调用 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).

  1. 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,我们稍后就会讲到。

  1. Remove calleeContext from the execution context stack and restore callerContext as the running execution context.

当前执行上下文出栈。

  1. 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 (breakcontinuereturn and throw) that perform nonlocal transfers of control.

它解释了 JS 执行过程中值的传播和非本地控制转移的语句的行为(原谅我不说人话,看英文意会~)。它有多重要呢?我们在使用 break、continue、return 和 throw 这些语句时,都会生成一个中间的记录状态。这个记录状态的结构如下,

image.png

我们结合 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 执行步骤如下,

image.png

重点看步骤5.d.

  1. If IsPropertyReference(ref) is true, then ... d. Let deleteStatus be ? baseObj.[Delete].

有了之前的铺垫,我们理解 delete 操作就比较简单了。这里就是调用对象内部方法 [[Delete]] 实现的。

具体的删除步骤如下:

image.png

从步骤中可以看出,delete 操作有两个特殊之处:

  1. 如果要删除的属性不存在,直接返回 true
  2. 如果属性不可配置,直接返回 false 。所以我们设置属性时,可以通过设置 [[Configurable]] 值为 false 来阻止属性被意外删除。

补充: 如果我们直接执行 delete 1 呢?

  1. If ref is not a Reference Record, return true

可以看到,当表达式的结果不是一个 Reference Record 时,会直接返回 true。

未完待续...

关于 this 我们终于讲完了,以 this 为主题,我们提及到了很多 ECMAScript 中的概念,这些概念不仅在分析 this 的时候可以用到,它们贯穿了 JavaScript 代码执行的整个过程。只要你细心,在代码中处处可以看到这些隐含的抽象定义。

这篇文章中途提到了 Environment Record,涉及到作用域相关的知识,如果有机会我们之后也会详细展开(给自己挖坑)。如果有兴趣,可以尝试阅读这篇文章

如果对阅读 ECMAScript 规范感兴趣,可以参考《怎样阅读 ECMAScript 规范?》

看到这里不来点个赞嘛~

最后,感谢你能够阅读到这里~