一道让我开始怀疑自己的 JavaScript 面试题

开门见山,标题中提到的面试题:

js 复制代码
let obj = { num1: 111 };

obj.child = obj = { num2: 222 };

console.log(obj.child); // 输出?

你会给出什么答案呢?

不知道你是不是也是这样,反正作看这道题时,心里想的是这也太简单了。只一眼,就得出了答案,{ num2: 222 }。然后,当看到给出的正确答案时,我震惊了。我本以为自己的 JS 基础还是可以的,没想到会栽在这样一道看上去平平无奇的题上。而且,即使在知道答案之后,我还是想不明白这是为什么。后来经过查找资料,我终于想明白了。于是,我将解答这个问题的过程记录在本文。如果你只想知道问题的答案,请直接跳到最后一段。

很显然,这道题考察的重点在于赋值表达式。首先,我们知道赋值运算符 = 是右结合(Right-associative),因此 obj.child = obj = { num2: 222 } 等价于 obj.child = (obj = { num2: 222 })。接下来,我们需要理清楚赋值操作到底是怎样被执行的。而关于 JavaScript 的最权威的信息来源就是 ECMAScript 规范。因此我们在 ECMAScript 规范中找到赋值表达式的运行时语义(Runtime Semantic)章节:13.15.2 Runtime Semantics: Evaluation。题中的表达式符合 AssignmentExpression : LeftHandSideExpression = AssignmentExpression 这条产生规则。其中, LeftHandSideExpression 最终产生 obj.childAssignmentExpression 最终产生括号中的内容 obj = { num2: 222 }。记住这一点后,我们就来看赋值运算具体是如何执行的。

首先,obj.child 既不是对象字面量也不是数组字面量,而且 AssignmentExpression 也不是一个匿名函数定义,因此上述步骤可以大致简化为:

  1. LeftHandSideExpression 的求值(Evaluation)结果记为 lref
  2. AssignmentExpression 的求值结果记为 rref
  3. GetValue(rref) 的返回值结果记为 rval
  4. 执行 PutValue(lref, rval)
  5. 返回 rval

所以我们需要首先得到最终产生 obj.childLeftHandSideExpression 的求值结果。经过在规范中的搜寻,我们可以得知 LeftHandSideExpression 可以产生 MemberExpression。规范中又存在规则 MemberExpression : MemberExpression . IdentifierName。而通过这个规则得到的结果和我们最终需要的 obj.child 已经非常形似了。继续重复这个过程,我们可以确认 obj.child 是可以由 MemberExpression 产生的(过程省略),而且就是用到了上面这条规则。于是,查看这条规则对应的运行时语义:

于是,我们又需要最终产生的是 objMemberExpression 的求值结果。这里直接给出这个结果,是一个可以表示为 { [[Base]]: env, [[ReferenceName]]: obj, [[Strict]]: false, EMPTY } 的 Reference Record。这里的 env 指的是当前执行上下文(running execution context)的 LexicalEnvironment,是一个 Environment Record。这个求值结果被赋给 baseReference,执行 GetValue(baseReference),得到 obj 指向的对象(此时的值可以表示为 { num1: 111 }),我们记为 o1。最终,这个表达式返回一个可以表示为 { [[Base]]: o1, [[ReferenceName]]: 'child', [[Strict]]: false, [[ThisValue]]: EMPTY } 的 Reference Record。

回到 LeftHandSideExpression 那条产生规则对应的运行时语义。我们已经得到 lref, 接下来需要对最终产生 obj = { num2: 222 }AssgimentExpression 进行求值,并赋给 rref。于是又要执行一次 AssignmentExpression : LeftHandSideExpression = AssignmentExpression 这条规则对应的语义。只不过这次的 LeftHandSideExpression 将产生 obj,而 AssignmentExpression 将产生 { num2: 222 }。由于篇幅原因,这里直接给出这一次的 lref,可以表示为 { [[Base]]: env, [[ReferenceName]]: obj, [[Strict]]: false, [[ThisValue]]: EMPTY } 以及 rval,也就是根据字面量 { num2: 222 } 生成的对象,同时记为 o2。然后就到了真正的赋值那一步 PutValue(lref, rval), 其中包含执行等价于 env.SetMutableBinding(obj, o2, false)这一操作的步骤。于是,obj 就指向了 o2。最后,rval,也就是 o2,被返回。

回到上一层的赋值表达式的执行步骤中,我们得到 rref 就是 o2GetValue(rref),即 GetValue(o2),直接返回 o2, 同时又被赋给 rval。于是执行 PutValue(lref, rval)。注意,此时的 lref{ [[Base]]: o1, [[ReferenceName]]: 'child', [[Strict]]: false, [[ThisValue]]: EMPTY }。其中包含执行等价于 o1.[[Set]]('child', o2, o1) 这一操作的步骤。于是,o1 变成了 { num1: 111, child: o2 }

上文省略了 GetValue / PutValue 等抽象操作(abstract operation)以及 [[Set]] 等对象的内部方法(internal method)的具体步骤,因为其中大部分是与本文无关的。但是他们其实还挺有趣的,可以解释很多 JavaScript 的特性,其中也涉及到了原型链。以后有机会可能会另外写一篇文章详细展开聊聊。

至此,我们终于可以给出这个面试题的正确答案了。由于此时的 obj 指向的是 o2,也就是 { num2: 222 },因此 console.log(obj.child) 输出 undefined。而由于已经没有了指向 o1 的引用,我们拿不到 o1 的值。假如,修改题目为:

js 复制代码
let obj = { num1: 111 }, ref = obj;

obj.child = obj = { num2: 222 };

console.log(obj.child); // undefined
console.log(ref); // { num1: 111, child: { num2: 222 } }

这样,输出的结果证实了我们的结论是正确的。

相关推荐
etsuyou几秒前
Koa学习
服务器·前端·学习
Easonmax16 分钟前
【CSS3】css开篇基础(1)
前端·css
大鱼前端34 分钟前
未来前端发展方向:深度探索与技术前瞻
前端
昨天;明天。今天。39 分钟前
案例-博客页面简单实现
前端·javascript·css
天上掉下来个程小白41 分钟前
请求响应-08.响应-案例
java·服务器·前端·springboot
萧鼎1 小时前
JavaScript可视化
javascript
周太密1 小时前
使用 Vue 3 和 Element Plus 构建动态酒店日历组件
前端
安冬的码畜日常1 小时前
【玩转 JS 函数式编程_008】3.1.2 JavaScript 函数式编程筑基之:箭头函数——一种更流行的写法
开发语言·javascript·ecmascript·es6·this·箭头函数
时清云2 小时前
【算法】合并两个有序链表
前端·算法·面试
小爱丨同学2 小时前
宏队列和微队列
前端·javascript