一道阿里面试题引起的思考:var foo = {n: 1}; var bar = foo; foo.x = foo = {n: 2};

1,017 阅读5分钟

前言

最近准备归纳总结一下前端的知识,突然看到了这样一道题:

var foo = {n: 1}; 
var bar = foo; 
foo.x = foo = {n: 2}; 
console.log(foo.x); 
console.log(bar);

乍一看,脑子确实一下转不过来,仔细一看也似懂非懂的。那么接下来我们就一起来解析一下这道题目。

赋值运算符的运算顺序

运算符的优先级决定了表达式中运算执行的先后顺序。

我们这里只分析与本题相关的运算符:赋值。想了解更多的同学可以查看MDN

赋值运算顺序:从右到左

我们先来看赋值运算符

'use strict'
const bar = new Proxy({ obj: { n: 1 } }, {
  get: (_obj, prop) => {
    console.log('get-----bar');
    return _obj[prop];
  },
});

foo = bar.obj;

首先我们申明了一个使用Proxy代理的bar对象,然后将bar对象的obj属性赋值给了一个未声明foo变量。

1639044798238.jpg

运行后,可以看到,首先打印了Proxyget方法的日志,然后报错提示了foo变量没有定义。

这里验证了赋值运算符的运算顺序的确是从右到左

那么使用连续赋值也是一样吗?我们再来看一个例子:

'use strict'
const bar = new Proxy({ obj: { n: 1 } }, {
  get: (_obj, prop) => {
    console.log('get-----bar');
    return _obj[prop];
  },
});

foo = test = bar.obj;

我们以上面的例子为基础,在最后赋值的时候使用了连续赋值,将bar.obj赋值给了两个未声明的变量:testfoo。运行一下,如图:

1639045584691.jpg

可以看到,首先还是打印了Proxyget方法的日志,然后报错提示了test变量没有定义。

从这个例子中我们可以看出,在连续赋值操作中,运算的顺序还是从右到左。可以看做这样:

A = (B = C)

解析题目

再来看题目

'use strict'

var foo = {n: 1}; // A
var bar = foo; 
foo.x = foo = {n: 2}; // B
console.log(foo.x); 
console.log(bar);

我们先来分析一下题目:

  1. 创建了一个对象A并赋值给了foo变量
  2. 将foo变量赋值给了bar
  3. 然后创建了新的对象B进行连续赋值

首先我们知道foo变量持有的是一个引用类型的内存地址,将foo赋值给了barbar也持有了同一个内存地址。

在最后进行了连续赋值操作,通过上面我们知道,连续赋值的操作运算顺序是从右到左的,可以转化为这样:

foo.x = (foo = {n: 2});

根据ECMScript规范:

LeftHandSideExpression = AssignmentExpression

  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. Perform ? PutValue(lrefrval).

  5. Return rval.

我们根据规范分析一下:

  1. 首先会将左手边的表达式,赋值给lref,像这样:lref = foo.x
  2. 然后将右手边的赋值表达,赋值给rref(此时并没有开始计算),像这样:rref = (foo = {n: 2})
  3. 然后通过GetValue(rref)获取到右手边赋值表达式的结果赋值给rval,像这样:rval = {n: 2} = (foo = {n: 2})
  4. 然后使用PutValue方法,将rval的结果赋值给lref,像这样:
lref = rval

也就是:

foo.x = {n: 2}; // 此时foo和bar(指向的同一个对象A)都等于:{n: 1, x: {n: 2}}
  1. 最后返回rval,第一个赋值表达式结束。
  2. 开始第二个赋值表达式的运算,也就是小括号中的:
foo = {n: 2} // 此时,foo变量重新赋值对象B,与对象A切断了联系

最终连续赋值的结果为:

bar = {n: 1, x: {n: 2}};
foo = {n: 2};

console.log(foo.x); // undefined
console.log(bar); // {n: 1, x: {n: 2}}

可以使用运算符优先级解答?

在查看一些资料的时候,也看到其中一种解法是根据运算符优先级来的。

运算符顺序:

运算符优先级
.20
=3

运算符优先级越大优先级越高。想了解更多的同学可以查看MDN

由于访问成员.的优先级大于赋值的优先级,所以赋值时这样的:

foo.x = foo = {n: 2}

转为:

foo.x = {n: 2};
foo = {n: 2};

这样的解法,最后结果确实是对的,但是我持怀疑的态度,进行了实验。

实验1:

'use strict'

m.n = g = 1;

对两个未声明的变量进行了连续赋值,如果是使用优先级赋值的话,那么肯定会先调用m.n,由于m并没有声明,所以肯定会提示m没有定义的错误。结果如下:

1639105174089.jpg

这里证明了,的确会先调用优先级更高的运算符。

实验2:

let a = new Proxy({n: 1}, {
  set: (obj, prop, value) => {
    console.log('set-----a');
    obj[prop] = value;
    return true;
  },
});

const b = a;

a.x = a = { n: 2 };

console.log('a.x', a.x);
console.log('b', b);

这个例子中,我们使用Proxy对a变量指向的对象进行了包装,当调用a变量的属性赋值时,则会打印一段日志。

假设赋值顺序为这样:

a = {};
b = a;
a = b = c

顺序为:

先 b = c
后 a = (b = C)

a和c都重新被复制为新的对象c,与原对象的联系被切断,那么Proxy中的日志便不会打印。

那么我们来看下结果:

1639107000493.jpg

最终日志被打印了,说明赋值的顺序为:

先对a.x赋值: a.x = { n: 2 };
再对a赋值: a = { n: 2 };

结果证明,使用运算符优先级也是可以正确解答本题。

总结

本题可以使用两种方法解答:

  1. 从ECMscript规范:
    1. 首先会将左手边的表达式,赋值给lref,像这样:lref = foo.x
    2. 然后将右手边的赋值表达,赋值给rref(此时并没有开始计算),像这样:rref = (foo = {n: 2})
    3. 然后通过GetValue(rref)获取到右手边赋值表达式的结果赋值给rval,像这样:rval = {n: 2} = (foo = {n: 2})
    4. 然后使用PutValue方法,将rval的结果赋值给lref
    5. 最后返回rval,第一个赋值表达式结束。
    6. 然后又从第一步开始,开启第二个赋值表达式的运算。
  2. 使用运算符优先级:
运算符优先级
.20
=3

成员访问.的优先级比赋值优先级的优先级更高,会先对成员访问的表达式进行赋值。