聊一聊一道经典的面试题:a.x=a={n:2}

769 阅读8分钟

本文的是建立在v8引擎的基础之上,所使用的分析工具是d8。v8是谷歌的js引擎,chrome和大部分浏览器都是以v8为js虚拟机。而d8可以理解v8的调试工具,可以利用d8查看js代码在执行过程各种中间数据,比如生成的ast,字节码,二进制代码等。

js的虚拟机v8,在执行这段代码之前,还是有不少的事情要做的。准备全局执行上下文,包括内置函数全局变量;准备全局作用域,包括全局变量,在执行过程中的所有数据都要放在内存当中; 初始化内存当中的堆栈;初始化消息系统 准备好基础环境之后,v8才会接收我们这段代码。

题中完整的代码是下面这样的,如果没有了解过这道题的朋友,可以思考一下,a.x的结果是什么。

var a = {n:1}
a.x = a = {n:2}
console.log( a.x )

先说答案:undefined

在开始分析之前,先简单介绍下v8解释器的架构:字节码,寄存器,栈,堆:

  1. 字节码是由Ignition解释器生成的,包含了AST和作用域信息。
  2. 寄存器用来存放中间数据,主要有通用寄存器r0、r1等,用来指向下条要执行的字节码的PC寄存器,有指向栈顶的栈顶寄存器,累加器是一个特殊的寄存器,用来存放中间结果。
  3. 栈和堆是用来提供指令集,区别是提供的指令集不同。

接收到代码之后,v8会先使用parser解析器解析生成AST,然后使用ignition生成字节码。

以下是v8为上面那段代码生成的字节码:

0000033A0824FAD6 @    0 : 12 00             LdaConstant [0]
0000033A0824FAD8 @    2 : 26 fa             Star r1
0000033A0824FADA @    4 : 27 fe f9          Mov <closure>, r2
0000033A0824FADD @    7 : 61 37 01 fa 02    CallRuntime [DeclareGlobals], r1-r2
0000033A0824FAE2 @   12 : 7d 01 00 29       CreateObjectLiteral [1], [0], #41
0000033A0824FAE6 @   16 : 15 02 01          StaGlobal [2], [1]
0000033A0824FAE9 @   19 : 13 02 03          LdaGlobal [2], [3]
0000033A0824FAEC @   22 : 26 fa             Star r1
0000033A0824FAEE @   24 : 7d 03 05 29       CreateObjectLiteral [3], [5], #41
0000033A0824FAF2 @   28 : 15 02 01          StaGlobal [2], [1]
0000033A0824FAF5 @   31 : 26 f9             Star r2
0000033A0824FAF7 @   33 : 2d fa 04 06       StaNamedProperty r1, [4], [6]
0000033A0824FAFB @   37 : 27 f9 fb          Mov r2, r0
0000033A0824FAFE @   40 : 25 fb             Ldar r0
0000033A0824FB00 @   42 : aa                Return

接下来,就开始这篇文章吧。

一、先从var a = { n : 1 }开始

首先要是var a声明一个标识符为a的变量,基本概念就不讲了。预解析会将这段代码处理成一个AST树,这个树大概是下面这个样子的。

image.png

然后var a = { n : 1 }这一串代码当中,var a只是一个在语法分析阶段作为标识符来理解的字面文本,n2本质上也是一个表达式。var从来不进行求值计算。

000002110824FA82 @    0 : 12 00             LdaConstant [0]
// 取出常量池中[0]的内容写入累加器中
// 累加器:a
000002110824FA84 @    2 : 26 fa             Star r1  
// 把累加器中的内容保存在寄存器r1当中
// 累加器:undefined,r1寄存器:a
000002110824FA86 @    4 : 27 fe f9          Mov <closure>, r2  
// 把寄存器的内容放到r2当中
// 累加器:undefined,r1寄存器:undefined,r2寄存器:a
000002110824FA89 @    7 : 61 37 01 fa 02    CallRuntime [DeclareGlobals], r1-r2
// 用r1-r2中的内容作为参数调用DeclareGlobals,将a声明为全局变量。
// 此时全局变量栈为[ a : undefined ]。

其次是{ n : 1 }

000002110824FA8E @   12 : 7e                CreateObjectLiteral [1], [0], #41
// 创建一个字面量对象,对象被分配在堆中,用数组下标代表对象的引用的地址,把这个地址写入累加器中。
// 累加器:{n:1},r1寄存器:undefined,r2寄存器:a

再进行=的赋值操作

000000550824FAE6 @   16 : 15 02 01          StaGlobal [2], [1]
// 将累加器的内容存到全局变量a里。
// 此时全局变量栈中[2]为 (a : { n : 1 })

2.然后是a.x = a = { n : 2 }

前面的var a = { n : 1 }应该是不会任何的疑问。

关键的在于下面的a.x = a = { n : 2 }

var x = y = 1来举个例子,这是一个典型的赋值语句,在语法是将表达式1的值绑定给y,在将y的值绑定给x,而非x的值等于y的值等于1,只有y = 1才是一个表达式。y只是一个意外,在js当中,如果向一个不存在的变量进行赋值,那么js会在全局变量中创建这个变量。

倘若将语句修改为const x = y = 1,之后 x = 2浏览器会报Assignment to constant variable错误,但是y = 2却不会有任何问题。这个语句,可以理解解为y = 1; const x = y

a.x = a = { n : 2 }中,是两个连续赋值的表达式,也一样可以理解为a = { n : 2 }; a.x = a

这时候可能会有人有疑问,那为什么a.x的结果是undefined而不是a

在js当中,任何运算的操作都是严格从左到右地进行计算,而a.x是优先被处理的,这是一个运算,表示a.x所代表的是{ n : 1 }对象的内存地址,a.x的计算结果已经固定为一个引用,假设这里a.x先获取到的a对象的内存地址是100000f90。而后面a = { n : 2 }则是在堆空间中创建了一个新的对象,这个对象的内存地址为100000f98

那么a.x = 所操作的是100000f90地址的对象,而a = 是将100000f98对象赋值到a上,并不能影响到a.x的值,因为a.x已经是一个计算结果,而非变量。也就是说,a.x操作的是堆内存中的{ n : 1 }而非{ n : 2 }

在这之后,将100000f98这个内存地址绑定到的100000f90所指向的对象的x属性上去。

var a = { n : 1 }
var ref = a
a.x = a = { n :2 }
// a的值为{ n : 2 },ref的值为{ n : 1, x : { n : 2 } } 
a.n = 3
// a的值为{ n : 2 },ref的值为{ n : 1, x : { n : 3 } } 

理解了之后,就可以看看v8生成了什么样的字节码,是如何处理的。

读取{ n : 1 }并存到r1寄存器

000000550824FAE9 @   19 : 13 02 03          LdaGlobal [2], [3]
// 读取全局变量[2]的内容({ n : 1 }的地址)并储存到累加器中
000000550824FAEC @   22 : 26 fa             Star r1
// 将累加器中的内容存到r1寄存器当中
// 累加器:{ n : 1 },r1寄存器:{ n : 1 }。

创建{ n : 2 },修改全局变量栈中a的地址为该对象,然后将对象存到r2寄存器。

000000550824FAEE @   24 : 7d 03 05 29       CreateObjectLiteral [3], [5], #41
// 在堆中创建字面量对象{ n : 2 },并将地址存到累加器中。
// 累加器:{ n : 2 },r1寄存器:{ n : 1 }。
000000550824FAF2 @   28 : 15 02 01          StaGlobal [2], [1]
// 将累加器的内容({ n : 2})存到全局变量栈[2](a)的位置
// 累加器:{ n : 2 },r1寄存器:{ n : 1 }。
000000550824FAF5 @   31 : 26 f9             Star r2
// 将累加器({ n : 2})的内容存到r2寄存器
// 累加器:undefined,r1寄存器:{ n : 1 },r2寄存器:{ n : 2 }。

将r2寄存器中的内容绑定到{ n : 1 }x属性。从此处也可以知道,a.x操作是r1寄存器,也就是{ n :1 }

000000550824FAF7 @   33 : 2d fa 04 06       StaNamedProperty r1, [4], [6]
// 将常量池中[4]的值赋给 r1
// 累加器:undefined,r1寄存器:{ n : 1, x : { n : 2 }},r2寄存器:{ n : 2 }。

最后返回值。

000000550824FAFB @   37 : 27 f9 fb          Mov r2, r0
// 将r2寄存器的内容移动到r0寄存器
// r0寄存器:{ n : 2 }
000000550824FAFE @   40 : 25 fb             Ldar r0
// 将寄存器r0的内容转移到累加器
// 累加器:{ n : 2 }
000000550824FB00 @   42 : aa                Return
// 返回累加器中的值({ n : 2 })

至此,这道题的分析就已经完成。

最后,前文提到过语句和表达式的概念,闲着可以,思考一下delete 0这个表达式的结果。

ECMAScript规定任何表达式计算的结果,要么是值要么是引用,delete 0这个操作,之所以会返回true,是因为0本身就是一个字面值表达式的计算结果,并不代表现实意义上的0,也不可能可以将0从系统中删除,所以,这行代码实际上什么也没有发生。

关于字节码的解释,我也不是很会,只是看了些文章之后尝试解读,如有错误欢迎指出。