你真的掌握了 JavaScript 变量和类型嘛?(上)

这是深入理解 JavaScript 语法合集的第二篇,其余文章见文末。

前言

前面介绍的文章中,我们讲到设计语言模块的第一步就是设计基本语法,而基本语法的开始就是数据类型和变量。

数据类型是指语言内置的基本变量类型,而变量是作为数据的一种表现(存储)形式存在。现在我们就从底层原理看看 JavaScript 种的变量和类型吧。

JavaScript 的数据类型

(1)数据类型

JavaScript 一共有 8 种数据类型(ES10),这 8 种类型数据又分为两种:原始类型(primitive values)和对象类型(reference values)。
原始类型都有:

  1. Number
  2. String
  3. Null
  4. Undefined
  5. Boolean
  6. Symbol(ES6)
  7. BigInt(ES10)

如果你记不住的话,可以这样记 SUNS NB(笑哭)。。。。

  1. Number 类型:包含所有可能的Number值的集合,也包括一些特殊的值 +Infinity-InfinityNot-a-Number(NaN)
  2. String 类型,包含所有可能的有限文本值序列
  3. Null 类型:只包含一个 null
  4. Undefined 类型:只包含一个 undefined
  5. Boolean 类型:只有两个值,分别为 truefalse
  6. Symbol 类型:包含所有表示唯一的非字符串对象属性键。
  7. BigInt类型:

大多数的时候,原始值是语言实现的最底层的表示。

注:很多地方也讲基本类型和引用类型,即原始类型就是基本类型,对象类型就是引用类型。

对象类型:

  1. Object 类型:从逻辑上来讲,对象是属性的集合,并且每个属性要么是数据属性,要么是访问器属性。从用法上来说,常用的 ArrayFunction 都属于特殊的对象。

(2)这些数据是如何存储的?

刚刚讲到,ECMAScript 规范将类型分为了 原始数据类型对象类型。那它们有什么区别呢?它们分别又是怎么存储的?

区别 ①:原始数据类型值不可变,而对象类型值可变

All primitives are immutable, i.e., they cannot be altered. It is important not to confuse a primitive itself with a variable assigned a primitive value. The variable may be reassigned a new value, but the existing value can not be changed in the ways that objects, arrays, and functions can be altered.

大意是说:所有的原始值都是不可更改的。我们不能将原始值本身和已经被定义的原始值变量搞混。我们可以使用重新赋值的方式改变变量的值,但是对于刚刚已经存在的原始值,我们是没有办法通过更改对象、数组和函数的方式对其进行更改的。

《你不知道的JavaScript》中卷第二章第二小节也有提到:

JavaScript 中字符串是不可变的,而数组是可变的。字符串不可变是指字符串的成员函数不会改变其原始值,而是创建并返回一个新的字符串。而数组的成员函数都是在其原始值上进行操作。

注:文中的字符串可以抽象成 primitive values,即所有的原始值:Number, String, Undefined, Null, Symbol, Boolean, BigInt.

还有疑惑?好的,我们就以最常用的字符串操作为例来举个栗子:

1
2
3
4
5
6
7
8
var a = 'foo';
var b = ["f", "o", "o"];

var c = a.concat("bar"); // foobar
var d = b.concat(["b", "a", "r"]); // ["f", "o", "o", "b", "a", "r"]

console.log(c === a); // false
console.log(d === b); // false

最后输出的两个值都为 false。这说明,不论是字符串还是字符数组,它们在进行 concat 时并不会对原来的值做更改,取而代之的是生成一个副本然后赋值给相应的变量。你可能会说,concat 这个操作只是要读取被操作的值,又不对它进行写的操作,肯定不会改变原来的值啊,这不是很正常嘛。那好,我们来看另一段代码:

1
2
3
4
5
6
7
8
var a = 'foo';
a.toUpperCase();

var b = ['b', 'a', 'r'];
b.push('!');

console.log(a); // foo
console.log(b); //  ["b", "a", "r", "!"]

你会发现,不论你对 a 做什么操作,a 的原始值都只为 foo。而当对 b 进行写操作时,它的值就会发生改变。当然你可能会这样操作:

1
2
3
4
var a = 'foo';
a += 'bar';

console.log(a); // foobar

啊,这不是嘛,a 的值改变了!哈哈,其实并没有。请记住,在 JavaScript 中,原始类型的值一旦被设置之后就没有办法再改变了。那么上面测试的值又怎么解释呢?

区别②:存储的形式不同

在 JavaScript 中,内存空间分为栈(stack)、堆(heap)、池(一般也会归类到栈中)。其中栈存放变量,堆存放复杂对象,池存放常量也就是我们说的常量池。

栈结构,相信大家都知道。它的特点是后进先出,只能访问栈顶元素。如果我们想要拿到栈底元素,我们就必须要先将栈顶元素一一弹出,直至栈中只剩一个元素。

关于堆,它是一种经过排序的树形数据结构,每个节点都有一个值。我们经常说的堆数据结构就是指二叉堆。它的特点是,父节点的键值总是大于或等于(小于或等于)任何一个子节点的键值。也就是我们常说的最大堆(最小堆)。对于堆的这个特性,常用来实现优先队列。堆的存取是随意的,因为我们只需要知道存取数据的‘地址’就可以拿到我们需要的数据。

话说回来,那么栈内存和堆内存具有什么特点呢?

栈内存:

  • 存储值的内存空间大小固定
  • 通过按值来访问,运行效率高。
  • 空间较小
  • 由系统自动分配存储空间

堆内存:

  • 存储的值大小不固定,可以在申请空间时自己确定大小
  • 空间较大,运行效率低
  • 按引用访问
  • 通过代码进行分配空间

在 JavaScript 中,所有的原始数据类型在变量定义时, 就为其分配好了内存空间。

还记得我们刚刚的那个问题嘛?现在我们从内存的角度来解释一下原因。在代码中,我们执行了 a += 'bar'。实际上是在栈中又开辟了一块内存空间用于存储 foobar,然后让变量 a 指向这块空间。而原来 a 的值 foo 并没有发生改变。所以,这没有违背原始类型的值不能发生改变这一特点。

栈结构

而对于引用类型,它的值被存储到堆内存中。只需要在栈中存储一个固定长度的地址,然后让这个地址指向堆中的值我们就可以通过这个地址快速的从二叉堆中找到值。

堆结构

区别③:作用形式不同

赋值,传参

不知道你有没有注意到一个现象,当我们对一个原始数据类型的变量进行赋值给另一个变量时,它得到的值是前者值得副本。而当我们对一个对象类型进行赋值时,被赋值得对象得到的却是前者的引用(实质上是一个指向堆内存的地址)。

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = "foo";
var b = ['b', 'a', 'r'];

var c = a;
var d = b;

a = "bar";
b.push('!');

console.log(a); // bar
console.log(c); // foo
console.log(b); // ["b", "a", "r", "!"]
console.log(d); // ["b", "a", "r", "!"]

从代码中,我们可以看到:我们把 a 的值赋值给 c 后再对 a 进行更改,对 c 并没有影响,这说明它们两个值在内存中是互不影响的。但是当我们把 b 赋值给了 d 后,再对 b 执行了 push 操作后,bd 的值都发生了变化。这是由于对象类型赋值时复制的是地址。这样 d 就也拥有了指向存储在堆内存中值的指针。当我们对 b 指向的堆内存中值进行更新时,d 通过指针也同样访问到该值。

其实,传参和赋值一样,都是将被赋值的对象值进行copy,然后给需要的变量(函数)。比如:

1
2
3
4
5
6
7
8
9
10
11
12
var a = 'foo';
var b = a; // 将 a 的值在栈内存中复制一份,然后让 b 指向这个副本

function fun(para) {
return para.map(i => i+=1);
}

var c = [1, 2, 3, 4];

var result = fun(c);

console.log(result); // [2, 3, 4, 5];

这段代码你可能闭着眼睛都能写好几堆了。但是,你知道这里的参数其实是值传递嘛?当我们把 c 作为参数传入函数中,参数 para 就会拿到 c 的值,然后进行操作。那么 c 的值是什么?是数组的地址啊!所以 para 也指向了这个数组。这就是为什么但我们在函数内部对形参进行操作时会影响到外部的数组。

我们需要记得一句话,在JavaScript中,所有的函数的参数都是按值传递的。

比较

不知道你发现没有,当我们在对两个变量进行比较时,不同类型的变量的表现是不相同的。

对于原始数据类型,它们比较的是它们存储在栈内存中的值,如果相等,则返回 true。而对于原始数据类型,它们比较的是存储在栈内存中的值(地址),如果值不相同,不论指向的堆内存中的值和属性是否相同,它们都返回 false

1
2
3
4
5
6
7
var a = "foo";
var b = ['b', 'a', 'r'];
var c = "foo";
var d = ['b', 'a', 'r'];

console.log(a === c); // true
console.log(b === d); // false

总结

其实它们本质是一样的。复制时(函数传参时也是复制)都是复制该变量在栈内存中存储的值。不管是原始数据类型还是对象类型,都是如此。如果是原始数据类型,那么就会重新开辟一个空间复制值的副本,然后让新的变量指向它。如果是对象类型,同样是复制该变量栈内存中的值,只不过这里面存储的是地址,所以当我们重新开辟一个空间复制值得副本时,它得到的也是一个地址,都指向堆内存的值。

对于比较,我们只需要记住,原始数据类型比较的是值,而对于对象类型,比较的是引用。


这是深入理解 JavaScript 语法合集的其中一篇,其它合集:
1. 深入理解 JavaScript —— 从设计一门语言讲起
3. 你真的掌握了 JavaScript 变量和类型嘛?(下)
4. JavaScript 的原型和原型链
5. JavaScript 中的对象

参考文章:
1. 你真的掌握变量和类型了吗-2019-05-28 by ConardLi
2. ECMAScript 中的 Number Type 与 IEEE 754-2008-2019-05-02 by JunYu
3.《你不知道的 JavaScript 》中卷 by Kyle Simpson
4. MDN Web docs——Primitive-2020-1-13 by MDN contributors

有问题?发送 issues 给我~

0%