你真的了解JavaScript中的赋值语句吗?

223 阅读5分钟

提出问题

在任何一门编程语言中,赋值语句非常常见。JavaScript 中的赋值语句是这样子的:

a = 1

等号左边的代码称为左值,一般是变量,等号右边的代码称为右值,一般为表达式。

第一个例子

然而,JavaScript 的赋值语句并没有像我们直觉上认为的那么简单,比如下面这个例子:

{
	let a = b = 1
}
console.log(a)
console.log(b)

运行结果是什么?显然,let 声明的变量的作用域只在语句块中,在作用域外打印 a 会导致引用错误。

那如果注释掉 console.log(a) 呢?结果是 1!这是由于连续的赋值语句导致的偶然性全局变量问题。

或许你会得出答案:

代码:

let a = b = 1

等同于:

b = 1
let a = b

这样,就能很好地解释为什么打印 a 会报错,而 b 却可以正常打印。

第二个例子

那么是不是所有例子都是这样的?(从右往左赋值)

比如:

foo.a = foo.b = 1

是否等效于

foo.b = 1
foo.a = foo.b

从结果上看,好像是的。

第三个例子

那么请你观察第三个例子:

let foo = { age:20 }
foo.age = foo = { name:'foo' }
console.log(foo.age) // undefined

如果按照我们上面的变换方法,代码应该等同于:

let foo = { age: 20 }
foo = { name: 'foo' }
foo.age = foo
console.log(foo.age)
// 运行结果为 <ref *1> { name: 'foo', age: [Circular *1] }

然而,令人出乎意料的是,第三个例子的打印结果为 undefined

如果你不信,可以在浏览器打开开发者工具,把代码粘到控制台中运行:

image.png 如果假设赋值是从左往右开始的,比如下面这段代码:

let foo = { age: 20 }
foo.age = foo
foo = { name: 'foo' }
console.log(foo.age)

结果才是undefined!

循环引用的表示方法

在 console. log 中是如何表示循环引用的?很简单,在旁边增加注解即可。变量 *1 代表对象 { name: 'foo', ref: null } 的地址。在对象中使用 [Circular 变量] 来指代某一个对象。

于是,对象:

const foo = {
	name:'foo'
	ref:null
}
foo.ref = foo;
console.log(foo);

得到:

<ref *1> { name: 'foo', ref: [Circular *1] }

除了 *1,其它的循环引用都会尽可能展示,除非它们形成的图不存在同构关系,则被表示为 [Object]。没有 *2

求证

赋值顺序

到底赋值是从左到右还是从右到左呢?

第一、二个例子给出的答案是从右到左,而第三个例子给出的答案是从左到右。

而根据我们的常识和直觉,也应该是从右到左的,因为只有把右值算出来了,左边才能赋值:

a = b = 0;

从右往左:b=0,然后 a=b,那么 b 的值可知,a 的值也可知。 从左往右:a=b,b 的值是多少?这个时候我们是不知道的。

借助 proxy 验证:

const order = {
  a: 'a',
  b: 'b',
  c: 'c',
}

const p = new Proxy(order, {
  set(target, property, newValue) {
    console.log(property)
    target[property] = newValue
  }
})

p.a = p.b = p.c = 'new value'

输出结果是:

c
b
a

说明赋值是从右往左的!

两个阶段

那么第三个例子是怎么解释?它也是从右到左的!请仔细观察和下面的代码:

  1. 从右到左赋值,但是输出 { name: 'foo' }
let foo = { age: 20 }
foo.age = foo = { name: 'foo' }
console.log(foo)
  1. 从右到左拆分,输出 <ref *1> { name: 'foo', age: [Circular *1] }
let foo = { age: 20 }
foo = { name: 'foo' }
foo.age = foo
console.log(foo)
  1. 从左到右拆分,输出 { name: 'foo' }
let foo = { age: 20 }
foo.age = foo
foo = { name: 'foo' }
console.log(foo.age)

经过对比,它好像既是从右往左赋值,又是从左往右赋值,于是,我们可以得出结论:我们在 JavaScript 中发现了量子态!(误)

事实上,确实存在一种“叠加”的状态,但是这是因为两个阶段叠加在一起了!别急,我们先回忆一下 webpack 的 loader 机制。

webpack 的 loader 机制

webpack 会分两个阶段执行 loader ,分别是从左往右的 pitch (预检)阶段和从右往左的 normal (执行)阶段。

如果 pitch 有返回值,则执行已经 pitch 过的模块的 normal 阶段!

JavaScript 的赋值过程也是类似的。一开始会从左往右检查并保存地址,直到遇到既不是左值又同时是右值的代码,第一阶段结束。接着,第二阶段开始,从右往左对变量进行赋值。为了方便称呼,我们借用 webpack 的叫法,称第一个阶段为预检阶段,第二个阶段为执行阶段。

让我们在回到第三个例子,现在它已经难不倒我们了!

let foo = { age: 20 }
foo.age = foo = { name: 'foo' }
console.log(foo)

执行过程:

  1. 读取 foo. age 的地址。
  2. 读取 foo 的地址。
  3. 预检阶段结束,进入执行阶段。
  4. 将 foo 赋值为 {name:'foo'}
  5. 给 foo. age 赋值,但此时 foo. age 并不是从新的 foo 上读取的,而是使用了旧的 foo 的地址。(相对于内存来说的)

我们可以通过以下代码验证:

let foo = { tag: 'oldfoo', age: 20 }
let goo = foo
foo.age = foo = { name: 'newfoo' }
console.log(goo)
console.log(foo)

输出:

{ tag: 'oldfoo', age: { name: 'newfoo' } }
{ name: 'newfoo' }

可以发现,在旧的foo(oldfoo)中,foo.age确实是被赋值了,而新的foo是没有被赋值的。

不难得出结论,在赋值语句中,左值的地址并不是在赋值阶段读取的,而是在检查阶段读取的。

总结

  1. 赋值是分两个阶段进行的。
  2. 赋值语句是从左往右检查的,并且在检查时给左值绑定了地址。
  3. 赋值语句是从右往左执行的,并使用了检查时给定的地址。