一道让我开始怀疑自己的 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.child。AssignmentExpression 最终产生括号中的内容 obj = { num2: 222 }。记住这一点后,我们就来看赋值运算具体是如何执行的。

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

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

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

于是,我们又需要最终产生的是 obj 的 MemberExpression 的求值结果。这里直接给出这个结果,是一个可以表示为 { [[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 就是 o2。GetValue(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 } }

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

相关推荐
用户1880687493524 分钟前
戴着 Rokid 接成语:用 AIUI 做一局有提示、有复盘的“接着说”
前端
Sakura_j_h_14 分钟前
Three.js 单文件离线机械臂工作台:爆炸图、透明外壳与剖面
前端·机械臂·webgl·three.js·1024程序员节·esbuild
计算机魔术师2 小时前
管谷歌的不是Pichai一个人,是一整层AI智能体
前端
米米商聊2 小时前
图标能点,按钮就可用了吗?键盘、名称与收藏状态
前端·javascript
Younglina2 小时前
三个晚上,我和 WorkBuddy 做了个生图网站
前端·后端·ai编程
xy34533 小时前
Axure 9.0 内联框架加载方式
前端·ui·html·axure·原型·产品设计
技术小甜甜3 小时前
【Cursor】Skill 被标黄色警告,怎么改成合格的 SKILL.md
开发语言·javascript·ai编程·cursor·1024程序员节·ai agent·skills
陈随易4 小时前
副业搞钱点子之收入查看器
前端·后端
Profile排查笔记4 小时前
浏览器指纹逆向怎么分析:从网络请求追踪到 JavaScript 采集链
前端·人工智能·后端·自动化
hudie7654 小时前
LikeShop 二开最常改的 5 个文件(附改前改后对比)
前端·uni-app·php·个人开发