js基石系列(二)——数据类型你真的知其然嘛?

737 阅读9分钟

js基石系列大纲

js基石系列.png

基础数据类型

js目前一共有8种数据类型

基础数据类型:number string null boolean undefined symbol bigint

引用数据类型:object

注意:
function 和 array 不是数据类型 广义上,他们属于对象。
NaN 也不是数据类型  
typeof NaN   // number 

1. undefined

undefined 类型表示未定义,它的类型只有一个值,就是 undefined。任何变量在赋值前是 undefined 类型、值为 undefined,可以用全局变量 undefined 来表达这个值。可以通过以下方式来得到 undefined:

(1)声明了一个变量,但没有赋值

var foo; //undefined

(2)引用未定义的对象属性

var obj = {}
obj.b // undefined

(3)函数定义了形参,但没有传递实参

function fn(a) {
    console.log(a); //undefined
}
fn();

(4)执行 void 表达式;

void 0 // undefined

推荐通过 void 表达式来得到 undefined 值,因为这种方式既简便又不需要引用额外的变量和属性;同时它作为表达式还可以配合三目运算符使用,代表不执行任何操作。

如下面的代码就表示满足条件 x 大于 0 且小于 5 的时候执行函数 fn,否则不进行任何操作:

x > 0 && x < 5 ? fn() : void 0;

那如何判断一个变量的值是否为 undefined 呢?可以通过 typeof 关键字获取变量 x 的类型,然后与 'undefined' 字符串做真值比较

if(typeof x === 'undefined') {
  ...
}

2. null

null 数据类型和 undefined 类似,只有一个值 null,表示变量被置为空对象,而非一个变量最原始的状态。null 是 JavaScript 保留关键字,而 undefined 只是一个常量。也就是说可以声明名称为 undefined 的变量,但将 null 作为变量使用时则会报错。

对于null,还有一个比较关键的问题,来看代码:

typeof null == 'object' // true

实际上,null 有自己的类型 null,而不属于Object类型,typeof 之所以会判定为 Object 类型,是因为当初的设计,历史原因造成的。

3. boolean

boolean 数据类型只有两个值:true 和 false,分别代表真和假。很多时候我们需要将各种表达式和变量转换成 boolean 数据类型来当作判断条件。

下面是将星期数转换成中文的函数,比如输入数字 1,函数就会返回“星期一”,输入数字 2 会返回“星期二”,以此类推,如果未输入数字则返回 undefined:

function getWeek(week) {
  const dict = ['日', '一', '二', '三', '四', '五', '六'];
  if(week) return `星期${dict[week]}`;
}

这里在 if 语句中会进行类型转换,将 week 变量转换成 boolean 数据类型,而 0、空字符串、null、undefined 在转换时都会返回 false。所以在输入 0 并不会返回“星期日”,而会返回 undefined。这是我们需要注意的问题。

4. string

string 用于表示字符串,string的最大长度是 2的53次方 - 1,这个所谓的最大长度并不是指字符数,而是字符串的 UTF16 编码长度。 字符串的 charAt、charCodeAt、length 等方法针对的都是 UTF16 编码。所以,字符串的最大长度,实际上是受字符串的编码长度影响的。

JavaScript 中的字符串是永远无法变更的,一旦构造出来,就无法用任何方式改变其内容,所以字符串具有值类型的特征。

5. number

Number 类型表示数字。JavaScript 中的 Number 类型基本符合 IEEE 754-2008 规定的双精度浮点数规则,但是 JavaScript 为了表达几个额外的语言场景(比如为了不让除以 0 出错,而引入了无穷大的概念),规定了几个例外情况:

  • NaN,占用了 9007199254740990,这原本是符合 IEEE 规则的数字,通常在计算失败时会得到该值。要判断一个变量是否为 NaN,则可以通过 Number.isNaN 函数进行判断。

  • Infinity,无穷大,在某些场景下比较有用,比如通过数值来表示权重或者优先级,Infinity 可以表示最高优先级或最大权重。

  • -Infinity,无穷小。

注意,JavaScript 中有 +0-0 的概念,在加法类运算中它们没有区别,但是除法时需要特别注意。可以使用 1/x 是 Infinity 还是 -Infinity来区分 +0 和 -0。

根据双精度浮点数的定义,Number 类型中有效的整数范围是 -0x1fffffffffffff0x1fffffffffffff,所以 Number 无法精确表示此范围外的整数。根据浮点数的定义,非整数的 Number 类型无法用 == 或者 === 来比较,这也就是在 JavaScript 中为什么 0.1+0.2 !== 0.3

出现这种情况的原因在于计算的时候,JavaScript 引擎会先将十进制数转换为二进制,然后进行加法运算,再将所得结果转换为十进制。在进制转换过程中如果小数位是无限的,就会出现误差。

6. Symbol

ES5 的对象属性名都是字符串,这容易造成属性名的冲突。比如,你使用了一个他人提供的对象,但又想为这个对象添加新的方法(mixin 模式),新方法的名字就有可能与现有方法产生冲突。如果有一种机制,保证每个属性的名字都是独一无二的就好了,这样就从根本上防止属性名的冲突。这就是 ES6 引入Symbol的原因。

基于以上特性,Symbol 属性类型比较适合用于两类场景中:常量值和对象属性

(1)消灭魔术字符串

getValue 函数会根据传入字符串参数 key 执行对应代码逻辑:

function getValue(key) {
  switch(key){
    case 'A':
      ...
    case 'B':
      ...
  }
}
getValue('B');

这段代码对调用者而言非常不友好,因为代码中使用了魔术字符串(Magic string,指的是在代码之中多次出现、与代码形成强耦合的某一个具体的字符串或者数值),导致调用 getValue 函数时需要查看函数代码才能找到参数 key 的可选值。所以可以将参数 key 的值以常量的方式声明:

const KEY = {
  alibaba: 'A',
  baidu: 'B',
}
function getValue(key) {
  switch(key){
    case KEY.alibaba:
      ...
    case KEY.baidu:
      ...
  }
}
getValue(KEY.baidu);

但这样也并非完美,假设现在要在 KEY 常量中加入一个 key,根据对应的规则,很有可能会出现值重复的情况:

const KEY = {
  alibaba: 'A',
  baidu: 'B',
  tencent: 'B'
}

这就会出现问题:

getValue(KEY.baidu) // 等同于 getValue(KEY.tencent)

所以在这种场景下更适合使用 Symbol,不需要关心值本身,只关心值的唯一性:

const KEY = {
  alibaba: Symbol(),
  baidu: Symbol(),
  tencent: Symbol()
}

(2)避免对象属性覆盖

函数 fn 需要对传入的对象参数添加一个临时属性 user,但可能该对象参数中已经有这个属性了,如果直接赋值就会覆盖之前的值。此时就可以使用 Symbol 来避免这个问题。创建一个 Symbol 数据类型的变量,然后将该变量作为对象参数的属性进行赋值和读取,这样就能避免覆盖的情况:

function fn(o) { // {user: {id: xx, name: yy}}
  const s = Symbol()
  o[s] = 'zzz'
}

7. BigInt

BigInt 可以表示任意大的整数。其语法如下:

BigInt(value);

其中 value 是创建对象的数值。可以是字符串或者整数。

在 JavaScript 中,Number 基本类型可以精确表示的最大整数是2的53次方。因此早期会有这样的问题:

let max = Number.MAX_SAFE_INTEGER;    // 最大安全整数

let max1 = max + 1
let max2 = max + 2

max1 === max2   // true

有了BigInt之后,这个问题就不复存在了:

let max = BigInt(Number.MAX_SAFE_INTEGER);

let max1 = max + 1n
let max2 = max + 2n

max1 === max2   // false

注意,BigInt 和 Number 不是严格相等的,但是宽松相等:

10n === 10 // false 
10n == 10  // true 

Number 和 BigInt 可以进行比较:

1n < 2;    // true 
2n > 1;    // true 
2 > 2;     // false 
2n > 2;    // false 
2n >= 2;   // true

8. Object

Object 是 JavaScript 中最复杂的类型,它表示对象。在 JavaScript 中,对象的定义是属性的集合。简单地说,Object 类型数据就是键值对的集合,键是一个字符串(或者 Symbol) ,值可以是任意类型的值; 复杂地说,Object 又包括很多子类型,比如 Date、Array、Set、RegExp。

其实,JavaScript的几个基本数据类型在对象类型中都有一个对应的类:

  • Number;

  • String;

  • Boolean;

  • Symbol。

对于 Number 类,1 与 new Number(1) 是完全不同的值,一个是 Number 类型, 一个是对象类型。Number、String 和 Boolean 构造器是两用的:当跟 new 搭配时,它们产生对象;当直接调用时,它们表示强制类型转换。Symbol 函数比较特殊,直接用 new 调用它会抛出错误,但它仍然是 Symbol 对象的构造器。

基础数据类型不可改变

学习基础数据类型,最核心的关键点,是要理解到,基础数据类型的值,是不可改变的。

你一听,好像有点不太对呀。我们在使用过程中,经常会遇到修改值的场景。例如数据的累加,字符串的变动等等。这不是可以改变嘛?

image.png

以数据累加为例,我们写一个小案例来验证一下。

let a = 1;

let b = a;

b++;

console.log(a); // 仍然为1

console.log(b); // 变成了2

分析一下上面的代码。我们声明一个变量 a, 并且声明一个变量 b,将 a 赋值给 b。这个时候,a 与 b 是等价的。然后我们试图去修改 b 的值,进行累加操作。最后发现一个奇怪的结果,a 的值没有变,可是 b 的值改变了。也就意味着,a b 的等价,并不表示他们是同一个值。我们用图例来表达这个过程。

左侧表示变量,右侧表示具体的值。第三步 b 为1的结果被覆盖了,因此用虚线表示。

我们发现,变量名对应的值可能被改变,但是基础数据类型的值本身是没有改变的。对于变量 b 来说,是一个新的值跟他建立了对应关系。

因此,我们说,基础数据类型,是按值访问的。

当两个基础类型的变量进行比较时,本质上,也是他们的值在进行比较。

const a = 1;

const b = 1;

a == b  // --> 1 == 1

下面字符串的案例也能说明问题

var str = 'hello world'

str[0] = 'K' // 试图修改字符串的第一个字符



console.log(str) // 打印结果:hello world

为什么“改变”基本类型不起作用(因为 str[0] = k 不是赋值给 str 本身的 Foo 属性,而是赋值给了一个临时包装器对象)。请继续往下看:

除了不可变性,基础数据类型还有一个需要我们关注的重点,那就是基础数据类型也能访问方法。

这就很奇怪,如果是一个对象,能够访问方法我们能够理解,基础数据类型也能访问方法其实细想一下就很奇怪。

var str = 'hello world'

str.charAt(0)

原因是因为当我们在访问字符串时,实际上依然是在访问一个对象。在 JavaScript 中,针对每一种基础数据类型(null 和 undefined 除外),都有提供对应的包装对象,例如对于字符串而言,就有一个名为 String 的包装对象,当我们使用字符串变量访问方法时,实际上经历了如下三步代码

// 首先使用包装对象创建对象

var _str = new String('hello world')



// 然后使用包装对象的实例去访问方法

_str.charAt(0)



// 最后销毁该对象

_str = null

包装对象会在访问方法时临时生效,并在访问结束之后清除包装对象生成的实例。

堆栈空间

js的数据主要存储在栈空间和堆空间中,下面来看看栈空间和堆空间的概念。

栈是一种数据结构。它表达的是对于数据的一种存取方式。这是一种理论基础。

要理解栈数据结构的存取方式,我们可以通过类比乒乓球盒子来分析。如下图所示。

image.png

往乒乓球盒子中依次放入乒乓球,当我们想要取出来使用时,处于盒子中最顶层的乒乓球5,它一定是最后被放进去并且最先被取出来的。而我们想要使用最底层的乒乓球1,就必须要将上面的所有乒乓球取出来之后才能取出。但乒乓球1是最先放入盒子的。

乒乓球的存取方式与栈数据结构如出一辙。这种存取方式的特点我们总结为先进后出,后进先出(LIFO,Last In,First Out)。 如上图右侧所示,处于栈顶的数据 true,最后进栈,最先出栈。处于栈底的数据 1,最先进栈,最后出栈。

下面来看一段代码:

function fn() {
  var a = "hello";
  var b = a;
  var c = { name: "CUGGZ"};
  var d = c;
}
fn()

当这段代码执行时,需要先进行编译,并创建执行上下文,最后在按照顺序执行代码。当执行到第三行时,调用栈执行状态如下:

此时变量 a 和 b 的值都被保存在执行上下文中, 而执行上下文又被压入到了栈中。

接下来继续执行后面的代码。当执行到第四行代码时,JavaScript 引擎判断变量 c 的值是一个引用类型,这时JavaScript 引擎会将该对象分配到堆空间里,分配后该对象会有一个在堆中的地址,然后再将该数据的地址写进 c 的变量值,最终分配好内存的执行上下文如下:

可以看到,对象类型存储在堆空间中,在栈空间中只保留了对象的引用地址,当 JavaScript 访问该数据时,会通过栈中的引用地址来访问。

所以,基本数据类型的值直接保存在栈中,引用类型的值会存放在堆中

那为什么要区分堆空间和栈空间呢?将数据都存在栈空间中不行吗?

答案肯定是不可以的。JavaScript 引擎需要使用栈来维护程序执行期间上下文的状态,如果将所有数据都放在栈空间中,就会影响到上下文切换的效率,进而影响到整个程序的执行效率。

所以,通常情况下,栈空间不会设置的很大,主要用来存放一些基本类型的小数据。由于引用类型的数据占用空间都比较大,所以这类数据会被存放到堆中,堆空间比较大,能存放很多较大的数据

最后,我们再看看上面实例代码中第五行,也就是将变量 c 赋值给变量 d 是怎么执行的。在 JavaScript 中,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。 所以d = c 的操作就是把 c 的引用地址赋值给 d,如下图所示:

可以看到,变量 c 和 d 都指向了同一个堆中的对象,当我们修改c的值时,d也会发生变化。

总结

基础数据类型:

存储在栈(stack)中,占据空间小、大小固定,属于被频繁使用数据,所以放入栈中存储

引用数据类型:

同时存储在栈(stack)和堆(heap)中,占据空间大、大小不固定。 栈中存储了指针,该指针指向堆中该实体的起始地址。

思考

基础数据类型为何是不可变数据

这跟垃圾回收机制的工作逻辑有关。

首先我们需要明确一点,JavaScript 中的任何内存,不能凭空回收,而只能由垃圾回收器回收。

其次需要明确第二点,回收器会周期性的遍历内存空间,找到内存垃圾然后进行回收利用。也就意味着,垃圾产生的那一刻,并不会立刻被回收,而是要等到垃圾回收器遍历到此处时,才会被回收。

这个过程就如同扫地机器人。扫地机器人在屋内转圈圈,当你向地上扔一个垃圾时,如果没有扔在扫地机器人的面前,它就不会再那一瞬间将垃圾吸收走,而是要等到它走到垃圾处时,垃圾才会被收走。

此时我们来分析一个简单的值改变的代码

var a = 10

a = 20

我们首先声明变量 a = 10 ,并且将变量修改为 20。当我给变量 a 赋值为 10 的时候,此时内存空间中,会分配一个空间,专门存放 10 这个数字。

当我们将一个新的数字 20 赋值给 a 时,此时 10 这个数字成为了垃圾,这里许多人就会误解,那我能不能就在 10 这个内存空间的位置,直接填充 20 ?不能!因为垃圾回收器不会闪现过来处理这个逻辑。

因此,10 所占的内存空间不能立即释放,需要等待垃圾回收器,而此时只能给 20 分配一个新的内存空间,10 因为失去了引用,成为了内存垃圾,等待被回收。

所以,我们可以推断出,基础数据类型,在内存中是不可以被改变的。