Skip to content
快速预览

变量声明和变量赋值

✍️ w 🕒 2023-06-28 12:51:11(a year ago) 🔗 A.前端知识整理

js 变量声明整体可以分为六种

  1. let x-- 声明变量 x。不可在赋值之前读。
  2. const x -- 声明常量 x。不可在赋值之前读,不可写。
  3. var x-- 声明变量 x。在赋值之前可读取到 undefined 值。
  4. function x -- 声明变量 x。该变量指向一个函数。
  5. class x -- 声明变量 x。该变量指向一个类(该类的作用域内部是处理严格模式的)。
  6. import -- 导入标识符并作为常量(可以有多种声明标识符的模式和方法)

上面的六种大体可以归类为三种情况 var,const,let

  • 属于 var 归类: varfunction 声明变量,这两种声明方式都会发生变量提升,即在声明之前使用变量会得到 undefined 值,而不会报错。它们的作用域是函数作用域。
  • 属于 const 归类: constimport 声明变量, 这两种声明方式都是常量,不能被重新赋值。在声明前使用会报错。它们的作用域是块级作用域。正是如此也可以更好的解释 import 声明为什么要放到最上面, import 声明的变量是常量,不能被重新赋值,且在声明前就使用会报错。和 const 声明的变量一样,import 声明的变量也是块级作用域。
  • 属于 letclass 声明变量,这两种声明方式都不能在声明前使用,否则会报错。它们的作用域是块级作用域。特别地,class 声明的内部是处于严格模式中的。

要注意一个强调的变量声明,声明和语句是编程中两个不同的概念

声明是用来告诉编译器某个变量的类型和名称,以便在编译时分配内存空间。声明发生在编译期,编译器会为所声明的变量在相应的变量表中增加一个名字。

语句是用来执行某些操作的代码,例如赋值、条件判断、循环等。声明和语句的区别在于它们发生的时间点不同。语句是在运行期执行的程序代码,需要CPU来执行。如果声明不带初始化,编译器可以完全处理,不会产生运行时执行的代码,因为编译器已经知道了变量的类型和名称,可以在编译时分配内存空间。

JavaScript 将可以通过 静态 语法分析发现那些声明的标识符;在变量声明阶段,JavaScript引擎会为变量分配内存空间,并将变量绑定到该内存空间。

对于使用var关键字声明的变量,它们的初始值会被设置为undefined;对于使用let和const关键字声明的变量,它们在声明阶段不会被赋值。

因此标识符对应的变量 / 常量(let var 声明的就是变量可以重新赋值,const 属于常量) 一定 会在用户代码执行前就已经被创建在作用域中(也就是声明阶段)。

js 变量声明赋值过程

举个例子 var myArray= [] 赋值过程经历步骤

  1. 变量声明:在编译阶段(声明),JavaScript 引擎会为变量 myArray 进行声明。此时,变量尚未赋值,其值为 undefined

  2. 变量赋值:在执行阶段(语句),JavaScript 引擎会创建一个空数组对象 []。这个对象会被存储在堆内存中,同时分配一个内存地址。

  3. 将内存地址赋值给变量:将存储空数组对象的内存地址赋值给变量 myArray。这样,myArray 就指向了空数组对象。

  4. 动态调整:由于 JavaScript 是一种动态语言,数组的大小和内容可以在运行时动态调整。当向数组中添加或删除元素时,内存中的空间会相应地进行调整。

图 1

对比来看静态语言声明赋值过程

在静态语言(如 C++、Java 等)中,变量的类型和初始值在编译阶段就已经确定。这与动态语言(如 JavaScript)有所不同,动态语言在运行时才会为变量分配内存和赋值。int[] myArray = [] 这个语句在静态语言(如 Java)中为例

  1. 变量声明与类型:int[] myArray 声明了一个整型数组变量 myArray。这意味着 myArray 只能存储整数类型的元素。

  2. 变量赋值:int[] myArray = [] 语句在 编译阶段(声明时候) 为 myArray 分配内存并初始化为空数组。这意味着 myArray 的长度为 0,没有任何元素。

  3. 内存管理:在静态语言中,数组的大小通常是固定的。如果需要改变数组的大小,需要创建一个新的数组并将原数组的元素复制到新数组中。在 Java 中,可以使用 System.arraycopy() 方法实现这一功能。

  4. 类型安全:由于静态语言在编译阶段就确定了变量的类型,因此具有更强的类型安全。在本例中,myArray 只能存储整数类型的元素,如果尝试向其中添加其他类型的元素,编译器会报错。

静态语言在编译阶段就确定了变量的类型和初始值,这使得程序在执行过程中具有更高的确定性和性能。然而,这也意味着静态语言的灵活性相对较低,因为在运行时不能轻易地改变变量的类型和内存空间。

var x = y = 100 发生什么

结合上面,要知道 js 两个阶段 声明语句运行var/let/const声明语句运行的赋值阶段不同

js
var a = {} // 这是 var 声明

a.b = 10 // 这是语句运行的赋值

var/let/const声明语句中的等号左边,绝不可能是一个表达式。声明阶段一定是发生在编译期

因此,根本上来说,在 var 声明 语法中,变量名位置上就是写不成a.x的

js
var a.x = ...   // <- 这里将导致语法出错

在看这个案例 var x声明阶段也就是编译期间就已经存在了

但 此时 y 变量并没有被声明定义 (在当前作用域(scope)中没有被声明使用var、let或const关键字定义的变量),将赋值给未声明变量(let/const/var)的值在执行赋值时将其隐式地创建为全局变量 这种变量也叫做隐式全局变

声明变量在任何代码执行前创建,而非声明变量只有在执行赋值操作的时候才会被创建。通过下面代码可以证明 var x 是在声明阶段 ,而 y 是在运行阶段

js
// 非声明 变量隐式全局变量如果没有上来赋值就报错
console.log(a);                // 抛出ReferenceError。
console.log('still going...'); // 永不执行。

// var 这中声明变量就不会有这个问题
var a;
console.log(a);                // 打印"undefined"或""(不同浏览器实现不同)。
console.log('still going...'); // 打印"still going..."。
  • 也通过 getOwnPropertyDescriptor 来验证 configurable 如果是true 怎说明是一个隐式全局变量 为false 则是一个声明
js
var x = y = 100
console.log(Object.getOwnPropertyDescriptor(window,'y'))
console.log(Object.getOwnPropertyDescriptor(window,'x'))
console.log(x,y)
打印结果
{value: 100, writable: true, enumerable: true, configurable: true}
{value: 100, writable: true, enumerable: true, configurable: false}
100 100

因此整个过程可以拆解为,因此并不推荐这种连续赋值

js
var x; // 变量提升声明 x
y = 100 // 隐式全局变量在特定情况下会出现泄漏
x = y // 变量赋值
console.log(x,y)
打印结果
100 100

这种隐士变量的好处代码中不需要显式地声明一个变量了,变量可以随用随声明,也不用像后来的let语句一样,还要考虑在声明语句之前能不能访问的问题了。相对的也会出现一个问题 变量泄漏,在少量的代码中也相当易用。但是,如果代码规模扩大,变成百千万行代码,可能因为忘记本地的声明而读写了全局变量,不方便调试

还是要特别强调要注意 ,声明和赋值的区别他们完全是在两个阶段,var/let/const声明语句中的等号左边,绝不可能是一个表达式 , var/let/const 关键字只负责声明变量名,而不是进行赋值操作,他的过程属于 初始器(Initializer),初始器是用来在声明变量时给变量赋予初始值的。在初始器中,"=" 号不是一个运算符,而是一个语法分隔符,用于将变量名与其初始值关联起来。所以,虽然初始器看起来像是一个赋值操作,但实际上它并不是一个严格意义上的赋值操作。

Initializer: = AssignmentExpression

在 "var x = 100" 这个语句中,"var x" 是创建变量名的部分,而 "= 100" 是给变量 x 赋初始值的部分。x 只是一个表达名字的、静态语法分析期作为标识符来理解的字面文本,而不是一个表达式。

  • 用 "var"、"let" 或 "const" 关键字创建变量时,这些关键字的作用是告诉 JavaScript 我们要创建一个变量,而不是给变量赋值。
  • 当我们创建变量的同时给它赋一个初始值,这个过程叫做 "初始器"。在这个过程中,"=" 号的作用是把初始值和变量关联起来,而不是进行数学运算。

因此ECMAScript 6 之后的模板赋值的左侧也不是表达式,只要是声明语句中,那么就 不是

js
const [a,b] = [2,3] // 左侧不是表单式

也可以说 并没有连续声明,var x 是声明 y 是赋值,关于赋值可以参考下面

赋值

看完了变量声明在来看赋值操作js 的赋值操作也并不是单纯的 变量名 = 值

整个赋值的过程在规范中来看是 LeftHandSideExpression = AssignmentExpression,也就是将右操作数(的值)赋给左操作数(的引用),操作数既可以是值也可以是字面量。因此 JavaScript 中,一个赋值表达式的左边和右边其实都是表达式! 可以 参考 规范ECMAScript Language Specification sec-assignment-operators

赋值是语句执行时候过程,因此左侧可以作为表达式

LeftHandSideExpression 和 AssignmentExpression

具体来看规范中对 LeftHandSideExpression 和 AssignmentExpression 的解释

  1. LeftHandSideExpression(左表达式) ,在规范中对 LeftHandSideExpression 可以是以下三种类型之一

    • NewExpression :包含 MemberExpression[?Yield, ?Await] 的定义比较多可以参考 但是举一个例子 a.b 这种对象属性形式 和 new NewExpression[?Yield, ?Await] 使用 new 关键字创建一个对象实例。例如,new MyClass()

    • CallExpression:这是一个用于调用函数的表达式。?Yield和?Await表示在这个表达式中是否允许使用yield和await关键字。

    • OptionalExpression :这是一个可选链表达式,用于访问对象属性或调用方法,如果对象或属性不存在,则返回undefined。?Yield和?Await表示在这个表达式中是否允许使用yield和await关键字

  2. AssignmentExpression[In, Yield, Await]: 赋值表达式是将一个值分配给一个左手边表达式的表达式 在规范里面定义

AssignmentOperator:one of *= /= %= += -= <<= >>= >>>= &= ^= |= **=

对上述规则的解释:

  1. ConditionalExpression[?In, ?Yield, ?Await]:条件表达式,通常用于三元操作符(condition ? expression1 : expression2),根据条件选择执行哪个表达式。
js
// 三元表达案例
let age = 18;
let isAdult = (age >= 18) ? '成年' : '未成年';
  1. [+Yield] YieldExpression[?In, ?Await]:Yield表达式,用于生成器函数中暂停和恢复执行。
js
function* generator() {
  yield 1;
  yield 2;
  yield 3;
}

const iter = generator();
console.log(iter.next()); // 输出:{ value: 1, done: false }
  1. ArrowFunction[?In, ?Yield, ?Await]:箭头函数,一种简洁的函数表达式,使用=>符号定义。
js
const sum = (a, b) => a + b;
console.log(sum(3, 4)); // 输出:7
  1. AsyncArrowFunction[?In, ?Yield, ?Await]:异步箭头函数,一种支持异步操作的箭头函数。
js
const fetchData = async () => {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();
  console.log(data);
};
fetchData();
  1. LeftHandSideExpression[?Yield, ?Await] = AssignmentExpression[?In, ?Yield, ?Await]:基本赋值操作,使用=符号将右侧表达式的值赋给左侧变量。
js
let a;
a = 10;
console.log(a); // 输出:10
  1. LeftHandSideExpression[?Yield, ?Await] AssignmentOperator AssignmentExpression[?In, ?Yield, ?Await]:复合赋值操作,如*= /= %= += -= <<= >>= >>>= &= ^= |= **=等,将左侧变量与右侧表达式的值进行操作后重新赋值给左侧变量。
js
let b = 5;
b += 3;
console.log(b); // 输出:8
  1. LeftHandSideExpression[?Yield, ?Await] &&= AssignmentExpression[?In, ?Yield, ?Await]:逻辑与赋值操作,仅当左侧变量为真时,将右侧表达式的值赋给左侧变量。
js
let c = true;
c &&= false;
console.log(c); // 输出:false
  1. LeftHandSideExpression[?Yield, ?Await] ||= AssignmentExpression[?In, ?Yield, ?Await]:逻辑或赋值操作,仅当左侧变量为假时,将右侧表达式的值赋给左侧变量。
js
let d = 0;
d ||= 10;
console.log(d); // 输出:10
  1. LeftHandSideExpression[?Yield, ?Await] ??= AssignmentExpression[?In, ?Yield, ?Await]:空值合并赋值操作,仅当左侧变量为nullundefined时,将右侧表达式的值赋给左侧变量。
js
let e = null;
e ??= 'default';
console.log(e); // 输出:'default'

对LeftHandSideExpression = AssignmentExpression 详细说明

LeftHandSideExpression[?Yield, ?Await] = AssignmentExpression[?In, ?Yield, ?Await] AssignmentExpression 是一个递归操作 注意 上面说到的 AssignmentExpression 表达的含义有九种,因此 所分配解答最开始 解释的 js 并不是简单的 值赋值操作

这里对 ?In 做个说明这个参数表示表达式是否在in操作符的上下文中。in操作符用于检查对象是否具有某个属性。例如:

js
let obj = { key: "value" };
let result = "key" in obj; // result为true,因为obj具有"key"属性

补充一个知识点

在上面的左侧表达是里,知道左侧其实可以是一个 new 对象 但实际 new Object = 1; 赋值报错( Invalid left-hand side in assignment)参考1参考2

  • CallExpression :包含函数调用。例如,myFunction() 或者 myFunction.a 更多定义。
  • OptionalExpression :使用可选链操作符(?.)检查对象属性或方法的存在。例如,obj?.prop。

最主要的还是要看规范中的介绍图 1

赋值表达式:LeftHandSideExpression = AssignmentExpression

  1. 如果左边的表达式(LeftHandSideExpression)既不是一个对象字面量(ObjectLiteral)也不是一个数组字面量(ArrayLiteral),如果赋值操作符左边的表达式既不是对象字面量也不是数组字面量,那么它可能是一个变量名、函数调用、成员访问等其他类型的表达式那么:

    a. 计算左边的表达式并将结果存储在lref中(首先计算赋值操作符左边的表达式的值,并将这个值存储在一个叫做lref的地方)。

     b. 如果赋值表达式(AssignmentExpression)是一个匿名函数定义,并且左边的表达式是一个标识符引用,即赋值操作符右边的表达式是一个匿名函数,并且左边的表达式是一个**变量名**。在这种情况下,JavaScript会使用左边的变量名作为匿名函数的名字那么: 
      i. 使用lref.[[ReferencedName]]作为参数,对赋值表达式进行命名评估,并将结果存储在rval中。
     c. 否则: 
       i. 计算赋值表达式并将结果存储在rref中。
       ii. 获取rref的值并将结果存储在rval中。 
     d. 将rval的值赋给lref。 
     e. 返回rval的值。
    
  2. 让assignmentPattern成为由左边的表达式所覆盖的赋值模式。

  3. 计算赋值表达式并将结果存储在rref中。

  4. 获取rref的值并将结果存储在rval中。

  5. 使用rval作为参数,对assignmentPattern进行解构赋值评估。

  6. 返回rval的值。

ObjectLiteral (对象字面量)也不是 ArrayLiteral(数组字面量),这里和 我们的情况还是不一样 new Object = 1;new Object 并不是字面量,他所拦截的错误案例在

js
// 这是一个不正确的示例,因为我们试图将值赋给一个对象字面量
{ key: "value1" } = { key: "value2" };

报错信息 Uncaught SyntaxError: Unexpected token '=' 也和 我们的报错信息 Invalid left-hand side in assignment 在苹果电脑执行Left side of assignment is not a reference. 也不同实际报错位置发生在 PutValue(lref,rval)。在PutValue操作的情况下,如果V不是引用记录,容易导致对不存在的属性或值进行赋值或访问。因此,在这个步骤中,会抛出一个ReferenceError异常,表示这是一个错误的引用,程序无法正常执行。简单地说,这是一种防止程序员犯错的保护措施,确保代码的正确性。

a.x = a ={n:2}

js
var a = {}
a.x = a ={n:2}

console.log(a.n) // 2
console.log(a.x) // undefined

简化赋值规范再来看这个问题

  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(lref, rval).

  5. Return rval.

这个代码是从左到右解析的,但赋值过程是从右到左,下面打印的结果 Evaluating the left16 side / Evaluating the left6 side /Evaluating the right2 side 虽然做了运算正常来说算除法 但实际并不是 是先从左到右解析 然后在根据规范计算先除法在加法

js
function echo(name, num) {
    console.log("Evaluating the " + name+num + " side");
    return num;
}
// 注意这里的除法运算符 (/)
console.log(echo("left", 16)+echo("left", 6) / echo("right", 2));

打印结果

因此按照规范来看 LeftHandSideExpression = AssignmentExpression 注意之前说过 AssignmentExpression 是一个递归表达,因此a.x = a ={n:2} 应该拆解为 a.x = (a = {n:2}) 也就是 LeftHandSideExpression =( LeftHandSideExpression = AssignmentExpression)

按照规范来说第一步计算左边的表达式并将结果存储在lref中(首先计算赋值操作符左边的表达式的值,并将这个值存储在一个叫做lref的地方),此时被保存起来的 a.x 指向的是 还有进行操作的 我们这里叫 最初a 值是空对象,再来看 后面a = {n:2} 这里的 a 虽然也是 最初a 值是空对象,但因为被赋值的原因此时 a 已经变成了 新a {n:2},现在就可以拆解为 a.x = {n:2} 注意此时 a.x a是最初a,也就是 PutValue({}, {n:2}) 在最初的 a上赋值了 对新的没有影响

所以,当你执行 console.log(a.n) 时,输出的是 2,因为这是新对象的n属性的值。而当你执行 console.log(a.x) 时,输出的是 undefined,因为在新对象上并没有x属性,x属性是在原来的对象上,但是原来的对象已经被新的对象替代了,所以无法访问到。

  • 图从左到右看,绿色代表 最初a 红色表示 新a图 1

通过步骤还原

js
var a = {}
a.x = a ={n:2}

// 此时 a.x  中的a 指向 var a ={} 并且存在了一个空间中
// a = {n:2} 的 a 原本也是 var a = {} 但将 此时 a 进行了重新赋值 var a 变成了 a ={n:2}
// 注意此时 a.x 虽然指向是 var a = {} 并且 新的 var a ={n:2}但是 在左边表达式时候 原 var a ={} 已经保存了
// a.x => {} => {} = {n:2},现在 在原始的 a 上 a.x = {n:2} 但新 a 上还是 {n:2} 没有 x 属性  

// 下面打印的是谁 实际是新 a
console.log(a.n) // 2
console.log(a.x) // undefined
  • 证明上面说的,我们用ref 保存我们说的最初 a,执行代码后
js
// ref 保存的是上一次的a
var a = {n:1}, ref = a;
a.x = a = {n:2};
console.log(a.x); // --> undefined
console.log(ref.x); // {n:2}
  • 另一种证明方法,第二次赋值操作中,将尝试向“原始的变量a”添加一个属性“a.x“,且如果它没有冻结的话,属性 a.x 会指向第一次赋值的结果
js
// 声明“原始的变量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); //

换个角度通过运算符优先级角度来看

其中 赋值=优先级别是 2 ,成员访问.是18,运算符优先级越大优先级越高。想了解更多的同学可以查看 MDN。

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

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

转为:

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

参考

浏览器工作原理与实践_李兵

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

a.x = a = {n:2}:一道被无数人无数次地解释过的经典面试题

var x = y = 100:声明语句与语法改变了JavaScript语言核心性质

Released under the MIT License.