你可能以为自己已经非常熟悉 JavaScript 的赋值操作了,但这一行代码:
var a = { n: 1 };
var b = a;
a.x = a = { n: 2 };
却让无数开发者陷入沉思:到底是先给 a.x 赋值,还是先给 a 重新赋值?为什么 console.log(b.x) 是一个对象,而 a.x 却是 undefined?这不是语法问题,而是 JavaScript 底层执行机制的奇妙。
下面就简单讲述一下。
JavaScript 赋值操作基础
运算符优先级与结合性
赋值运算符(=、+=、-=) 在 JavaScript 中是 右结合(right-to-left)的。这意味着表达式 a = b = c 会先计算右侧的 b = c,再把结果赋给 a。
运算符 | 优先级 | 结合性 |
---|---|---|
= | 3 | 右到左 |
+, - | 13 | 左到右 |
. | 20 | 左到右 |
左值(L-value)与右值(R-value)
- 左值:表达式的“赋值目标”,例如变量、对象属性、数组元素。
- 右值:表达式的“计算结果”,例如字面量、函数返回值、其他表达式。
赋值时,JS 引擎要分别评估左值(确定要写到哪里)和评估右值(计算要写什么),然后才执行写入。
连续赋值语义
为什么 a = b = c 能连续写?
因为在 JS 中,赋值表达式本身会返回被赋的值,所以可以写成链式:
// 步骤拆解:
b = c // 返回 c
a = (b = c) // 再把 c 赋给 a
赋值表达式的返回值
- 表达式 x = y 的求值结果就是“被赋的值 y”。
- 因此 a = b = 5 相当于 a = 5; b = 5;,且整个表达式的值为 5。
a = b = 5;
console.log(a, b, (a = b = 5)); // 5 5 5
ECMAScript 规范视角
如何评估赋值表达式
根据 ECMAScript® Language Specification(ECMA-262):
- 评估 AssignmentExpression:先识别运算符右侧的“AssignmentExpression”
- 评估左值:调用 GetValue 获取右侧的值
- 分辨目标:调用 PutValue 将值写入左值目标
- 返回值:整个表达式的值为所写入的值
步骤拆解
Function EvaluateAssignmentExpression(expr):
If expr is “LeftHandSide = RightHandSide” then:
// 1. 先评估右侧,得到一个值 R
R = Evaluate(RightHandSide)
// 2. 再评估左侧,得到一个引用 L
L = ResolveReference(LeftHandSide)
// 3. 将 R 写入 L
PutValue(L, R)
// 4. 返回 R 作为整个表达式的值
return R
常见示例
1、简单链式赋值
let x, y, z;
x = y = z = 100;
console.log(x, y, z); // 100 100 100
执行顺序:
- z = 100 → 返回 100
- y = (z = 100) → 返回 100
- x = (y = (z = 100)) → 返回 100
2、对象属性与赋值顺序
let obj = { a: 1 };
obj.b = obj.a = 2;
console.log(obj); // { a: 2, b: 2 }
执行顺序:
- 先计算 obj.a = 2,写入 a,返回 2。
- 再执行 obj.b = <返回值 2>。
重点示例分析
var a = { n: 1 };
var b = a;
a.x = a = { n: 2 };
console.log(a.x);
console.log(b.x);
详解解析:
var a = { n: 1 }; // [Obj1]
var b = a; // b ➔ [Obj1]
a.x = a = { n: 2 };
- 解析左侧目标(Capture LHS)
- 引擎先看到整个赋值表达式 a.x = …,此时 a 仍指向最初的对象 [Obj1]。
- “目标位置”锁定为:对象 [Obj1] 的属性 x。
- 执行右侧赋值(Evaluate RHS)
- 新建一个对象字面量 { n:2 },记为 [Obj2]。
- 把 [Obj2] 赋给变量 a,此刻 a 从指向 [Obj1] 变为指向 [Obj2]。
- 赋值表达式 a = [Obj2] 的返回值是这个新对象 [Obj2]。
- 写入属性(Perform original “.x = …”)
- 虽然此时 a 已经指向 [Obj2],但左侧“目标”早已捕获,仍是 [Obj1].x 这个位置。
- 因此,将右侧返回的 [Obj2] 写入到原始对象 [Obj1] 的 x 属性上:Obj1.x = Obj2。
- 查看最终结果
- a 指向 [Obj2],所以 a.x 查的是 [Obj2].x ——该对象上没有 x 属性,结果 undefined。
- b 依旧指向最初的 [Obj1],它的 x 属性被写入了 [Obj2],所以 console.log(b.x) 输出 { n: 2 }。
关键点:
- 左侧目标先捕获:赋值表达式开始时就确定属性写入位置,不受后续变量指向变化影响。
- 左侧 a.x 在赋值之前就要解析“引用”,此时 a 仍指向旧对象;
- 右侧 a = tmp 改变了 a 引用,但不会影响已经捕获的“属性写入位置”。
- 赋值表达式返回值:a = {n:2} 返回新的对象引用,用于后续“属性赋值”。
- 顺序原则:先捕获 LHS,再执行 RHS 并返回值,最后将返回值写入先前捕获的 LHS。
这样,一眼就能看清变量指向与属性写入的“先后关系”,避免踩坑。