从一道常见的面试题开始:

1
2
3
4
5
var a = {n: 1};
var b = a;
a.x = a = a.y = {n: 2};
console.log(a.x);
console.log(b.y);

显然,关键点在于最后一个语句的执行。这个语句的执行主要涉及了 属性获取表达式赋值表达式,先去规范里看对于这两种语法及其执行的规定。

1. 赋值表达式

规范中规定了三种形式的赋值表达式:

1
2
3
4
AssignmentExpression : 
ConditionalExpression
LeftHandSideExpression = AssignmentExpression
LeftHandSideExpression AssignmentOperator AssignmentExpression

a.x = a = a.y = {n: 2}; 是其中的第二种形式 (第三种形式中的AssignmentOperator在规范中是复合赋值符号,即 += 等等)。 有的同学说,js中=是从右向左执行的。对于语句的执行,规范中写道:

The source text of an ECMAScript program is first converted into a sequence of input elements, which are tokens, line terminators, comments, or white space. The source text is scanned from left to right, repeatedly taking the longest possible sequence of characters as the next input element.

也就是说,源代码被转换为一系列的输入单元(输入单元的类型包括token,行结束符,注释和空白符); 然后从左到右进行解析,重复以最长子序列作为下一个输入单元。除此之外,规范规定了每种类型语句的执行流程,却并没有地方提到 = 要从右向左执行。造成这种广泛的误解的,可能是类似 MDN 在语句优先级的地方提到了 =Associativity是从右到左,但其实这个Associativity并不是执行流程。

规范中规定了表达式 AssignmentExpression : LeftHandSideExpression = AssignmentExpression 的执行流程(11.13.1节中),我们把这个流程命名为 parseAssignment, 后面会以 parseAssignment(n)来指代执行这里的第n步:

1
2
3
4
5
6
7
8
9
10
11
The production AssignmentExpression : LeftHandSideExpression = AssignmentExpression is evaluated as follows:
1. Let lref be the result of evaluating LeftHandSideExpression.
2. Let rref be the result of evaluating AssignmentExpression.
3. Let rval be GetValue(rref).
4. Throw a SyntaxError exception if the following conditions are all true:
Type(lref) is Reference is true
IsStrictReference(lref) is true
Type(GetBase(lref)) is Environment Record
GetReferencedName(lref) is either "eval" or "arguments"
5. Call PutValue(lref, rval).
6. Return rval.

显然,第一步是 evaluating LeftHandSideExpression ,将结果赋给变量 lref 。然后是 evaluating AssignmentExpression, 将结果付给 rref。那么在表达式 a.x = a = a.y = {n: 2} 中 哪一部分是 LeftHandSideExpression, 哪一部分是 rref 有没有疑问呢?会不会
a.x = a 或者 a.x = a = a.yLeftHandSideExpression 呢?

再来看 LeftHandSideExpression 的语法:

1
2
3
LeftHandSideExpression : 
NewExpression
CallExpression

只有这两种形式,它们具体的语法定义我们就不翻了,不然可能会翻出10多层(事实上,规范中正是通过这种嵌套的表达式语法定义,规定了其优先级)。总之没有赋值表达式,并没有涉及到 = 语法。

且规范中规定了语句解析顺序是从左到右(Chapter 7),所以 a.x = a = a.y = {n: 2}; 中的 LeftHandSideExpression 就是 a.x

再仔细思考 AssignmentExpression : LeftHandSideExpression = AssignmentExpression, 把最后的 AssignmentExpression置换为左边的 AssignmentExpression,就得到了我们使用的这个表达式 : AssignmentExpression : LeftHandSideExpression = (LeftHandSideExpression = AssignmentExpression)。从这里我们也能看出,对于a.x = a = a.y = {n: 2};的执行来说,是先把 a.x 当作 LeftHandSideExpression,把a = a.y = {n: 2}当作 AssignmentExpression;执行到 evaluating AssignmentExpression时,再把 a 当作 LeftHandSideExpressiona.y = {n: 2}作为 AssignmentExpression。直到最后以 a.y 作为 LeftHandSideExpression, 以 {n: 2}作为AssignmentExpression(AssignmentExpression的第一种形式ConditionalExpression是允许为 对象字面量 的)。

按照这样的执行步骤,第一步就是把 执行 a.x 的结果赋给 lrefa.x是一个 属性读取表达式,我们再来看它的执行流程。

【Note】
规范中并没有对优先级进行规定,只是通过设置语句的解析规则,形成了事实上的优先级。
读者可以试试这段代码的结果:
    var a = "a"
    console.log(a) // 'a'
    true ? a : a = 'c'
    console.log(a) // 'a'
    false ? a : a = "c"
    console.log(a) // 'c'
若按照优先级规定,条件表达式的优先级高于赋值表达式;
那么语句应该按照 先执行条件表达式,后执行赋值表达式的顺序执行,第二个输出就应该是'c'了。
但事实上是'a'。
这是因为按照规范的表达式解析规则,=的左边总是被解析为 LeftHandSideExpression,
而条件表达式并不在它的语法形式之中。
所以按照最大可解析长度的原则,上式被解析为了true ? a : (a = 'c'),
所以只有在最后 a 才会被改写为'c'。

2. 属性获取表达式

规范中在 LeftHandSideExpression 相关 Property Accessors(11.2.1节) 中规定了其执行流程,我们把这个流程命名为 parseMember, 后面会以 parseMember(n)来指代执行这里的第n步:

1
2
3
4
5
6
7
8
9
10
11
The production MemberExpression : MemberExpression [ Expression ] is evaluated as follows:
1. Let baseReference be the result of evaluating MemberExpression.
2. Let baseValue be GetValue(baseReference).
3. Let propertyNameReference be the result of evaluating Expression.
4. Let propertyNameValue be GetValue(propertyNameReference).
5. Call CheckObjectCoercible(baseValue).
6. Let propertyNameString be ToString(propertyNameValue).
7. If the syntactic production that is being evaluated is contained in strict mode code, let strict be true, else let
strict be false.
8. Return a value of type Reference whose base value is baseValue and whose referenced name is
propertyNameString, and whose strict mode flag is strict.

我们看到这个流程大概是,从 MemberExpression (即这里的 a) 得到baseValue, 从 Expression (即这里的字符串 x )得到 propertyNameString,然后返回以它们组成的 Reference
我们先去了解下 Reference

3. Reference

规范的第8章 Types 中, 将类型分为两大类: 一是语言类型,也就是提供给开发者的Undefined, Null, Boolean, String, Number, and Object;另一类是 规范类型,它们不会提供给开发者,也不一定对应到一个es实现中的数据结构,只是用来描述规范中的算法和刚才提到的语言类型,可以理解为是用来描述算法和数据结构的抽象。Reference 就是规范类型的一种。

规范的8.7节中这样写到:

A Reference is a resolved name binding. A Reference consists of three components, the base value, the referenced name and the Boolean valued strict reference flag. The base value is either undefined, an Object, a Boolean, a String, a Number, or an environment record (10.2.1). A base value of undefined indicates that the reference could not be resolved to a binding. The referenced name is a String.

意即,Reference 是一个 已解析的命名绑定。所谓命名绑定,就是说它用来用一个命名找到对应的某个内部数值/数据;所谓已解析,就是说这个 命名 到 数据 的绑定关系是确定的。好比我们在面对函数中的某个变量,想要知道它的确切值是多少,就是想确定它的命名绑定。
简而言之,Reference 就是一个表示引用类型或者环境对象的抽象。一个 Reference 由三个部分组成: basereference namestrict flag

base可以看作是就是引用的实体或作宿主,好比 a.x 就是一个引用,它的 base value就是 areference name 则是字符串 x。而在如下函数func中:

1
2
3
function func() {
var a = 'a';
}

a 也是一个引用,它的 basefunc 函数对应的执行环境的环境记录(Enviroment Record); reference name则是字符串 'a'

前述表达式的执行流程中还用到了 ReferenceGetValue 方法。我们看它的执行过程:

1
2
3
4
5
6
7
8
9
10
GetValue (V)
1. If Type(V) is not Reference, return V.
2. Let base be the result of calling GetBase(V). // 获取 Reference 的 base component
3. If IsUnresolvableReference(V), throw a ReferenceError exception.
4. If IsPropertyReference(V), then
a. If HasPrimitiveBase(V) is false, then let get be the [[Get]] internal method of base, otherwise let get be the special [[Get]] internal method defined below.
b. Return the result of calling the get internal method using base as its this value, and passing GetReferencedName(V) for the argument.
5. Else, base must be an environment record.
a. Return the result of calling the GetBindingValue (see 10.2.1) concrete method of base passing
GetReferencedName(V) and IsStrictReference(V) as arguments.

即,如果参数 V 不是一个 Reference 类型,那么直接返回;否则在 base上取出对应 reference name的值并返回。

4. 题目分析

有了这些基础,我们可以来分析面试题中的表达式了。步骤如下:

  1. 执行parseAssignment(1), 即执行 a.x 表达式,将得到的 Reference 类型值赋给 lrefa.x 是一个 Property Accessor,我们来按照规范解析它的执行:

    1.1 parseMember(1). MemberExpression 是 a。这是表达式 PrimaryExpression 的 Identifier 类型,它会返回一个 Reference 类型的值: base 是全局环境变量(global enviroment record),reference name是'a',strict flag是false。 
    1.2 parseMember(2). 对全局环境变量调用 GetBindingValue('a')方法,在变量对象中找到对应的值,即 a 所引用的 对象字面量 {n: 1}。
    1.3 parseMember(3). Let propertyNameReference  = 'x'
    1.4 parseMember(4). Let propertyNameValue = 'x'
    1.5 parseMember(5). 检查是否可以1.2中的返回值是否可以转为 Object, {n: 1}本就是对象类型,返回true
    1.6 parseMember(6). 获取property name string,即'x'
    1.7 parseMember(7). 设置 strict flag 为false
    1.8 parseMember(8). 返回一个 Reference 类型的值,base 是 {n: 1}, reference name是'x', strict flag 是 false。
    

    这里第一步执行完得到的 lref 就是1.8中返回的值。

  2. parseAssignment(2). 执行 a = a.y = {n: 2},将返回值赋给 rref。它的执行如下:

    2.1 执行 a。它返回一个 Reference 类型的值,base 是 全局环境变量,refrence name是'a', strict flag是false。我们姑且称这一步的lref为 lref2.1。
    
    2.2 执行 a.y = {n: 2}。它也是一个赋值表达式,执行如下:
        2.2.1 执行 a.y 。这里又涉及到了对 a 的解析,前面的操作并没有改变 a 的引用,所以到现在为止,a 仍然会被解析为全局环境变量上的一个命名绑定。所以对 a.y 的解析所返回的 Reference 中,base 组件是就是lref中的base。 我们姑且称这一步的lref为 lref2.2.1,它的组成: base 是 {n: 1},refrence name是'y', strict flag是false。(注意 lref2.2.1 的 base 与 lref 的 base, 是同一个对象。因为 a 都会解析为 全局环境变量 上对应属性'a'的对象。)
        2.2.2 parseAssignment(2). 这里右边是一个 对象初始化表达式,返回一个对象类型的值 {n: 2}。
        2.2.3 parseAssignment(3). 对上一步中的返回值执行 GetValue(rref),结果仍然是 {n: 2}, 赋给 rval2.2.3。
        2.2.4 parseAssignment(4). 判断是否抛异常,这里不会。
        2.2.5 parseAssignment(5). 调用 PutValue(lref2.2.1, rval2.2.3),结果是lref2.2.1 的base增加了一个属性,此时变为了 {n: 1, y: {n: 2}} // 这里的 base 与 lref 中的 base 仍然是同一个对象
        2.2.6 parseAssignment(6). 返回 rval2.2.3。
    
    所以这一步返回 rval2.2.3。
    
    2.3 parseAssignment(3). 对2.2返回的值进行 GetValue(rref), 仍然是 rval2.2.3
    2.4 parseAssignment(4). 判断是否要抛异常,这里不会。
    2.5 parseAssignment(5). 调用 PutValue(lref2.1, rval2.2.3),lref2.1 的base是 全局环境变量,这里修改了其中变量 a 的引用,指向新的对象 rval2.2.3
    2.6 parseAssignment(6). 返回 rval2.2.3。
    

    这一步的返回仍然是对象 rval2.2.3。

  3. parseAssignment(3). 将 rval 设为上一步的返回即 rval2.2.3。
  4. parseAssignment(4). 判断是否要抛异常,这里不会。
  5. parseAssignment(5). 调用 PutValue(lref, rval),lref 的base 增加了一个属性,此时变为了 {n: 1, y: {n: 2}, x: {n: 2}}
  6. Return rval.

所以执行完后,变量 a 所引用的对象是 {n: 2}。 而它之前指向的对象,也即这时变量b指向的对象(b 的指向未改变过),变为了 {n: 1, y: {n: 2}, x: {n: 2}}。可以用 JSON.stringify 验证下b。而且这时候 b.xb.y 和 a 指向同一个对象。

其实这里的关键点就是,赋值表达式要先对左边的表达进行引用确定,再进行赋值。

PS: 文中对于符号优先级的阐述,完全出于自己对规范的理解,欢迎指正

PPS: 经社区同学指出,贴出文章所参照的规范地址EcmaScript 5.1 Edition。文章参照的是旧版本,es6之后对于这个问题的分析是一致的,差异主要是:

  • 表达式增加了更多语法。比如es6中赋值表达式增加了对箭头函数和Yeild语法的支持。
  • 对应的章节不同。比如es6中表达式放到了第12章,对Reference的阐述放在了6.2.3(The Reference Specification Type ),关于输入源码解析的机制放在了第11章(ECMAScript Language: Lexical Grammar)中。