一道让我开始怀疑自己的 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 } }

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

相关推荐
weixin_382395232 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__3 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.3 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~4 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华5 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子7 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅7 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni8 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学9 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端