CategoryResourceRepost/极客时间专栏/JavaScript核心原理解析/从零开始:JavaScript语言是如何构建起来的/03 | a.x = a = {n:2}:一道被无数人无数次地解释过的经典面试题.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

350 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="03 | a.x = a = {n:2}:一道被无数人无数次地解释过的经典面试题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/53/83/53fc95378939b6b1bef341be705b2983.mp3"></audio>
你好,我是周爱民。
在前端的历史中有很多人都曾经因为同一道面试题而彻夜不眠。这道题出现在9年之前它的提出者“蔡mc蔡美纯”曾是JQuery的提交者之一如今已经隐去多年不复现身于前端。然而这道经典面试题仍然多年挂于各大论坛被众多后来者一遍又一遍地分析。
在2010年10月[Snandy](https://github.com/snandy)于iteye/cnblogs上发起对这个话题的讨论之后淘宝的玉伯lifesinger也随即成为这个问题早期的讨论者之一并写了一篇“**a.x = a = { }, 深入理解赋值表达式**”来专门讨论它。再后来,随着它在各种面试题集中频繁出现,这个问题也就顺利登上了知乎,成为一桩很有历史的悬案。
蔡mc最初提出这个问题时用的标题是“**赋值运算符:"=", 写了10年javascript未必全了解的"="**”,原本的示例代码如下:
```
var c = {};
c.a = c = [];
alert(c.a); //c.a是什么
```
蔡mc是在阅读JQuery代码的过程中发现了这一使用模式
```
elemData = {}
...
elemData.events = elemData = function(){};
elemData.events = {};
```
并质疑,为什么`elemData.events`需要连续两次赋值。而Snandy在转述的时候换了一个更经典、更有迷惑性的示例
```
var a = {n:1};
a.x = a = {n:2};
alert(a.x); // --&gt; undefined
```
Okay这就是今天的主题。
接下来,我就为你解释一下,为什么在第二行代码之后`a.x`成了undefined值。
## 与声明语句的不同之处
你可能会想三行代码中出问题的为什么不是第1行代码
在上一讲的讨论中,声明语句也可以是一个连等式,例如:
```
var x = y = 100;
```
在这个示例中“var”关键字所声明的事实上有且仅有“x”一个变量名。
在可能的情况下变量“y”会因为赋值操作而导致JavaScript引擎“**意外**”创建一个全局变量。所以声明语句“var/let/const”的一个关键点在于语句的关键字var/let/const只是用来“声明”变量名x的去除掉“var x”之后剩下的部分并不是一个严格意义上的“赋值运算”而是被称为“初始器Initializer”的语法组件它的词法描述为
>
**Initializer**: **=** **AssignmentExpression**
在这个描述中,“=”号并不是运算符,而是一个语法分隔符号。所以,之前我在讲述这个部分的时候,总是强调它“被实现为一个赋值操作”,而不是直接说“它是一个赋值操作”,原因就在这里。
如果说在语法“var x = 100”中“= 100”是向x绑定值那么“var x”就是单纯的标识符声明。这意味着非常重要的一点——**“x”只是一个表达名字的、静态语法分析期作为标识符来理解的字面文本而不是一个表达式**。
而当我们从相同的代码中去除掉“var”关键字之后
```
x = y = 100;
```
其中的“x”却是一个表达式了它被严格地称为“赋值表达式的左手端lhs操作数”。
所以关键的区别在于赋值表达式左侧的操作数可以是另一个表达式——这在专栏的第一讲里就讲过了而“var声明”语句中的等号左边绝不可能是一个表达式
也许你会质疑难道ECMAScript 6之后的模板赋值的左侧也不是表达式确实答案是如果它用在声明语句中那么就“不是”。
对于声明语句来说紧随于“var/let/const”之后的一定是变量名标识符且无论是一个或多个都是在JavaScript语法分析阶段必须能够识别的。
如果这里是赋值模板那么“var/let/const”语句也事实上只会解析那些用来声明的变量名并在运行期使用“初始器Initializer”来为这些名字绑定值。这样“变量声明语句”的语义才是确定的不至于与赋值行为混淆在一起。
因此根本上来说在“var声明”语法中变量名位置上就是写不成`a.x`的。例如:
```
var a.x = ... // &lt;- 这里将导致语法出错
```
所以在最初蔡mc提出这个问题时以及其后Sanady和玉伯的转述中都不约而同地在代码中绕过了第一行的声明而将问题指向了第二行的连续赋值运算。
```
var a = {n:1}; // 第一行
a.x = a = {n:2}; // 第二行
...
```
## 来自《JavaScript权威指南》的解释
有人曾经引述《JavaScript权威指南》中的一段文字4.7.7 运算顺序),来解释第二行的执行过程:
>
JavaScript总是严格按照从左至右的顺序来计算表达式。
并且还举了一个例子:
>
例如,在表达式`w = x + y * z`中将首先计算子表达式w然后计算x、y和z然后y的值和z的值相乘再加上x的值最后将其赋值给表达式w所指代的变量或属性。
《JavaScript权威指南》的解释是没有问题的。首先在这个赋值表达式的右侧`x + y*z`中,`x`与`y*z`是求和运算的两个操作数任何运算的操作数都是严格从左至右计算的因此x先被处理然后才会尝试对`y`和`z`求乘积。这里所谓的“x先被处理”是JavaScript中的一个特异现象
>
一切都是表达式,一切都是运算。
这一现象在语言中是函数式的特性,类似“一切被操作的对象都是函数求值的结果,一切操作都是函数”。
这对于以过程式的,或编译型语言为基础的学习者来说是很难理解的,因为在这些传统的模式或语言范型中,所谓“标识符/变量”就是一个计算对象,它可能直接表达为某个内存地址、指针,或者是一个编译器处理的东西。对于程序员来说,将这个变量直接理解为“操作对象”就可以了,没有别的、附加的知识概念。例如:
```
a = 100
b * c
```
这两个例子中a、b、c都是确定的操作数我们只需要
- 将第一行理解为“a有了值100”
- 将第二行理解为“b与c的乘积”
就可以了,至于引擎怎么处理这三个变量,我们是不管的。
然而在JavaScript中上面一共是有六个操作的。以第二行为例包括
- 将`b`理解为单值表达式,求值并得到`GetValue(evalute('b'))`
- 将`c`理解为单值表达式,求值并得到`GetValue(evalute('c'))`
- 将上述两个值理解为求积表达式’*'的两个操作数,计算
>
`evalute('*', GetValue(evalute('b')), GetValue(evalute('c')))`
所以,关键在于`b`和`c`在表达式计算过程中都并不简单的是“一个变量”而是“一个单值表达式的计算结果”。这意味着在面对JavaScript这样的语言时你需要关注“变量作为表达式是什么以及这样的表达式如何求值以得到变量”。
那么,现在再比较一下今天这一讲和上一讲的示例:
```
var x = y = 100;
a.x = a = {n:2}
```
在这两个例子中,
- x是一个标识符不是表达式而y和100都是表达式且`y = 100`是一个赋值表达式。
- a.x是一个表达式而`a = {n:2}`也是表达式,并且后者的每一个操作数(本质上)也都是表达式。
这就是“语句与表达式”的不同。正如上一讲的所强调的:“**var x”从来都不进行计算求值所以也就不能写成“var a.x …**”。
>
所以严格地说在上一讲的例子中并不存在连续赋值运算因为“var x = …”是**值绑定操作**而不是“将…赋值给x”。在代码`var x = y = 100;`中实际只存在一个赋值运算那就是“y = 100”。
## 两个连续赋值的表达式
所以,今天标题中的这行代码,是真正的、两个连续赋值的表达式:
```
a.x = a = {n:2}
```
并且,按照之前的理解,`a.x`总是最先被计算求值的(从左至右)。
回顾第一讲的内容你也应该记得所谓“a.x”也是一个表达式其结果是一个“引用”。这个表达式“a.x”本身也要再计算它的左操作数也就是“a”。完整地讲“a.x”这个表达式的语义是
- 计算单值表达式`a`,得到`a`的引用;
- 将右侧的名字`x`理解为一个标识符,并作为“.”运算的右操作数;
- 计算“a.x”表达式的结果Result
表达式“a.x”的计算结果是一个引用因此通过这个引用保存了一些计算过程中的信息——例如它保存了“a”这个对象以备后续操作中“可能会”作为`this`来使用。所以现在在整行代码的前三个表达式计算过程中“a”是作为一个**引用**被暂存下来了的。
那么这个“a”现在是什么呢
```
var a = {n:1};
a.x = ...
```
从代码中可见保存在“a.x”这个引用中的“a”是当前的“{n:1}”这个对象。好的,接下来再继续往下执行:
```
var a = {n:1};
a.x = // &lt;- `a` is {n:1}
a = // &lt;- `a` is {n:1}
...
```
这里的“a = …”中的`a`仍然是当前环境中的变量,与上一次暂存的值是相同的。这里仍然没有问题。
但接下来,发生了赋值:
```
...
a.x = // &lt;- `a` is {n:1}
a = // &lt;- `a` is {n:1}
{n:2}; // 赋值,覆盖当前的左操作数(变量`a`
```
于是,左操作数`a`作为一个引用被覆盖了,这个引用仍然是当前上下文中的那个变量`a`。因此,这里真实地发生了一次`a = {n:2}`。
那么现在表达式最开始被保留在“一个结果Result”中的引用`a`会更新吗?
不会的。这是因为那是一个“**运算结果**Result这个结果有且仅有引擎知道它现在是一个引擎才理解的“**引用**(规范对象)”,对于它的可能操作只有:
- 取值或置值GetValue/PutValue以及
- 作为一个引用向别的地方传递等。
当然如同第一讲里强调的它也可以被typeof和delete等操作引用的运算来操作。但无论如何在JavaScript用户代码层面能做的主要还是**取值**和**置值**。
现在,在整个语句行的最左侧“**空悬**”了一个已经求值过的“a.x”。当它作为赋值表达式的左操作数时它是一个被赋值的引用这里是指将`a.x`的整体作为一个引用规范对象。而它作为结果Result所保留的“a”是在被第一次赋值操作覆盖之前的、那个“原始的变量`a`”。也就是说如果你试图访问它的“a.n”那应该是值“1”。
这个被赋值的引用“a.x”其实是一个未创建的属性赋值操作将使得那个“原始的变量`a`”具有一个新属性,于是它变成了下面这样:
```
// a.x中的“原始的变量`a`”
{
x: {n: 2}, // &lt;- 第一次赋值“a = {n:2}”的结果
n: 1
}
```
这就是第二次赋值操作的结果。
## 复现现场
上面发生了两次赋值第一次赋值发生于“a = {n: 2}”,它覆盖了“原始的变量`a`”第二次赋值发生于被“a.x”引用暂存的“原始的变量`a`”。
我可以给出一段简单的代码,来复现这个现场,以便你看清这个结果。例如:
```
// 声明“原始的变量a”
var a = {n:1};
// 使它的属性表冻结(不能再添加属性)
Object.freeze(a);
try {
// 本节的示例代码
a.x = a = {n:2};
}
catch (x) {
// 异常发生说明第二次赋值“a.x = ...”中操作的`a`正是原始的变量a
console.log('第二次赋值导致异常.');
}
// 第一次赋值是成功的
console.log(a.n); //
```
第二次赋值操作中,将尝试向“原始的变量`a`”添加一个属性“a.x“且如果它没有冻结的话属性“a.x”会指向第一次赋值的结果。
## 回到标题中的示例
那标题中的这行代码的最终结果是什么呢?答案是:
- 有一个新的`a`产生,它覆盖了原始的变量`a`,它的值是`{n:2}`
- 最左侧的“a.x”的计算结果中的“原始的变量`a`”在引用传递的过程中丢失了且“a.x”被同时丢弃。
所以第二次赋值操作“a.x = …”实际是无意义的。因为它所操作的对象,也就是“原始的变量`a`”被废弃了。但是,如果有其它的东西,如变量、属性或者闭包等,持有了这个“原始的变量`a`”,那么上面的代码的影响仍然是可见的。
>
事实上由于JavaScript中支持属性读写器因此向“a.x”置值的行为总是可能存在“某种执行效果”而与“a”对象是否被覆盖或丢弃无关。
例如:
```
var a = {n:1}, ref = a;
a.x = a = {n:2};
console.log(a.x); // --&gt; undefined
console.log(ref.x); // {n:2}
```
这也解释了最初“蔡mc”的疑问连续两次赋值`elemData.events`有什么用?
如果`a`(或`elemData`)总是被重写的旧的变量,那么如下代码:
```
a.x = a = {n:2}
```
意味着给**旧的变量**添加一个**指向新变量的属性**。因此,一个链表是可以像下面这样来创建的:
```
var i = 10, root = {index: &quot;NONE&quot;}, node = root;
// 创建链表
while (i &gt; 0) {
node.next = node = new Object;
node.index = i--; // 这里可以开始给新node添加成员
}
// 测试
node = root;
while (node = node.next) {
console.log(node.index);
}
```
最后,我做这道面试题做一点点细节上的补充:
- 这道面试题与运算符优先级无关;
- 这里的运算过程与“栈”操作无关;
- 这里的“引用”与传统语言中的“指针”没有可比性;
- 这里没有变量泄漏;
- 这行代码与上一讲的例子有本质的不同;
- 上一讲的例子“var x = y = 100”严格说来并不是连续赋值。
## 知识回顾
前三讲中我通过对几行特殊代码的分析希望能帮助你理解“引用规范类型”在JavaScript引擎内部的基本运作原理包括
- 引用在语言中出现的历史;
- 引用与值的创建与使用以及它的销毁delete
- 表达式(求值)和引用之间的关系;
- 引用如何在表达式连续运算中传递计算过程的信息;
- 仔细观察每一个表达式(及其操作数)计算的顺序;
- 所有声明,以及声明语句的共性。
## 复习题
下面有几道复习题,希望你尝试解答一下:
1. 试解析`with ({x:100}) delete x;` 将发生什么。
1. 试说明`(eval)()`与`(0, eval)()`的不同。
1. 设“a.x === 0”试说明“(a.x) = 1”为什么可行。
1. 为什么`with (obj={}) x = 100;` 不会给obj添加一个属性x
希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。