# JS类型

# 前言

ECMAScript是JavaScript的标准,JavaScript是ECMAScript标准的实现

  • 首先,ECMAScript标准定义了一系列语法和类型
  • 接着,(浏览器大厂的)JS引擎开发者实现了这些标准
  • 最后,作为开发者的我们才可以按照标准来使用这些类型,进而完成日常开发工作

那么问题来了,JS定义了哪些类型?各个类型都有什么特点呢? 另外,变量是如何存储的?如何知道一个变量的类型?如何进行类型转换呢?

本文将努力回答上述的问题,主要分为以下几个部分:

  • 一、类型分类
  • 二、数据存储
  • 三、类型判定
  • 四、类型转换
  • 五、关于类型,有哪些你不知道的细节?

# 一、类型分类

JavaScript 的类型分为 原始类型 和 引用类型

# 原始类型

  • Null:只包含一个值 null
  • Undefined:只包含一个值 undefined
  • Boolean:包含两个值 true 和 false
  • Number:整数或浮点数,还有一些特殊值(-Infinity、+Infinity、NaN)
  • String:一串表示文本值的字符序列
  • Symbol:一种实例是唯一且不可改变的数据类型
  • BigInt:在es10中加入

# 对象类型

  • Object:单独分一类。Object、 Array、Function 等都属于特殊的对象

# 二、数据存储

原始数据类型存放在栈空间,引用类型存放在堆空间。

# 栈空间 vs 堆空间

效果:原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。

原因:

  1. 通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。

  2. JavaScript引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了,所有的数据都存放在栈空间里面,会影响上下文切换的效率,进而影响程序的执行效率。

# 值类型 vs 引用类型

  • 什么是值传递
let name = 'AaronKong';
function changeValue(name){
  name = 'Aaron';
}
changeValue(name);
console.log(name);
1
2
3
4
5
6

执行上面的代码,如果最终打印出来的name是'AaronKong',没有改变,说明函数参数传递的是变量的值,即值传递。如果最终打印的是'Aaron',函数内部的操作可以改变传入的变量,那么说明函数参数传递的是引用,即引用传递

  • 什么是引用传递
let obj = {name:'AaronKong'};
function changeValue(obj){
  obj.name = 'Aaron';
}
changeValue(obj);
console.log(obj.name); // Aaron
1
2
3
4
5
6

首先明确一点,ECMAScript中所有的函数的参数都是按值传递的。

同样的,当函数参数是引用类型时,我们同样将参数复制了一个副本到局部变量,只不过复制的这个副本是指向堆内存中的地址而已,我们在函数内部对对象的属性进行操作,实际上和外部变量指向堆内存中的值相同,但是这并不代表着引用传递,下面我们再看一个例子:

let obj = {};
function changeValue(obj){
  obj.name = 'AaronKong';
  obj = {name:'Aaron'};
}
changeValue(obj);
console.log(obj.name); // AaronKong
1
2
3
4
5
6
7

可见,函数参数传递的并不是变量的引用,而是变量拷贝的副本,当变量是原始类型时,这个副本就是值本身,当变量是引用类型时,这个副本是指向堆内存的地址。所以,再次记住:

ECMAScript中所有的函数的参数都是按值传递的

# 三、类型判定

类型判定的几种方式:

  • typeof 判断基本类型和函数对象很方便,但无法区分 null 和 object(包括数组)。
  • instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上,只能检测对象的类型。
  • Object.prototype.toString 可以准确返回当前对象的 Class 属性,因为JS中没有任何方法可以更改私有的 Class 属性
  • constructor 通过对象的该属性可判断其类型(有被修改的风险)

typeof示例:

typeof 1 // number
typeof '123' // string
typeof true // boolean
typeof undefined // undefined
typeof null // object
typeof {a:1} // object
typeof function(){} // function
1
2
3
4
5
6
7

小疑问:typoef null 输出为什么是 object

  • 这是因为在早期JS实现中,一个JS的值由类型标识(tag)和值(value)两部分组成,object的tag是0,而null在tag和value均为0,因此被认为是object类型,是JS设计的一个bug。
  • 后来有人提交更正给ECMAScript标准委员会,但被拒绝了,原因是担心影响现有的系统。于是这个bug一直保留到现在。 why-is-typeof-null-object (opens new window)

instanceof示例:

var obj = {a:1}
obj instanceof Object // true
1
2

小疑问:如何实现一个 instanceof

Object.prototype.toString 示例:

var number = 1;          // [object Number]
var string = '123';      // [object String]
var boolean = true;      // [object Boolean]
var und = undefined;     // [object Undefined]
var nul = null;          // [object Null]
var obj = {a: 1}         // [object Object]
var array = [1, 2, 3];   // [object Array]
var date = new Date();   // [object Date]
var error = new Error(); // [object Error]
var reg = /a/g;          // [object RegExp]
var func = function a(){}; // [object Function]
Math    //[object Math]
JSON  //[object JSON]

Object.prototype.toString.call(number)
// ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 判断是否为数组

if (!Array.isArray) { // 兼容
    Array.isArray = (arg) => {
        return Object.prototype.toString.call(arg) === '[object Array]';
    }
}
1
2
3
4
5

# 四、类型转换

因为 JS 是弱类型语言,所以类型转换发生非常频繁,大部分我们熟悉的运算都会先进行类型转换。

# 隐式类型转换的场景

  • if 语句和逻辑语句
  • 各种数学运算,如+ - > <
  • 臭名昭著的 == (试图跨类型比较)

# 转换规则

比较容易混淆的几类规则:

  • StringToNumber —— Number函数
  • NumberToString —— 基本符合直觉
  • 装箱转换(把基本类型转换为对应的对象) —— 内置对象(Number、String、Boolean等)
  • 拆箱转换(对象类型到基本类型的转换) —— valueOftoString 配置使用,前后顺序由调用方式决定

StringToNumber 示例:

Number('123') // 123
1

NumberToString 示例:

String(123) // '123'
1

装箱拆箱示例:

var s = 'Hello World';
console.log(s + 'Aaron'); // 先装箱,再拆箱
1
2

# 五、关于类型,有哪些你不知道的细节?

# 1、null和undefined的区别

在原始类型中,有两个类型Null和Undefined,他们都有且仅有一个值,null和undefined,并且他们都代表无和空,我一般这样区分它们:

null

  • 表示被赋值过的对象,刻意把一个对象赋值为null,故意表示其为空,不应有值
  • 所以对象的某个属性值为null是正常的,null转换为数值时值为0

undefined

  • 表示“缺少值”,即此处应有一个值,但还没有定义
  • 如果一个对象的某个属性值为 undefined,这是不正常的,如 obj.name=undefined ,我们不应该这样写,应该直接 delete obj.name
  • undefined 转为数值时为 NaN (非数字值的特殊值)
  • JavaScript是一门动态类型语言,成员除了表示存在的空值外,还有可能根本就不存在(因为存不存在只在运行期才知道),这就是undefined的意义所在。对于JAVA这种强类型语言,如果有"undefined"这种情况,就会直接编译失败,所以它不需要一个这样的类型。

# 2、为什么有的编程规范要求用 void 0 代替 undefined?

JavaScript中的 undefined 是一个变量,不是关键字,这是JavaScript公认的设计失误之一。为了避免无意中被篡改,建议用 void 0 来获取 undefined

ES5后 undefined 已经被改为了 read-only 了,但是在局部作用域上,使用 var 定义时,依然可以被修改:

const a = () => {
    var undefined = 'abc';
    console.log(undefined); // abc
    console.log(typeof undefined) // string
}
a();
1
2
3
4
5
6

而在全局作用域上使用 var/let/const 均无法修改 undefined 的值:

var undefined = 'abc';
console.log(undefined); // undefined
console.log(typeof undefined) // undefined

const undefined = 'abc'; // Uncaught SyntaxError: Identifier 'undefined' has already been declared
console.log(undefined);
console.log(typeof undefined)

let undefined = 'abc'; // Uncaught SyntaxError: Identifier 'undefined' has already been declared
console.log(undefined);
console.log(typeof undefined)
函数作用域上使用 let/const 会提示常量已经被定义:

a = () => {
    const undefined = 'abc'; // Uncaught TypeError: Assignment to constant variable
    console.log(undefined);
    console.log(typeof undefined)
}
a()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 3、JavaScript字符串有最大长度吗?

一点编码历史:

  • 最初ASCII编码诞生用来表示英文字母和一些符号,但是其他国家的语言不支持
  • 后来中国大陆搞了GB2312,欧洲日本韩国也都有自己的编码,并不统一
  • 再后来Unicode统一码点,包含了全世界几乎所有国家的文字,但实现方式没有规定
  • UTF-8,USC、UTF-16,UTF-32都是Unicode编码的具体实现,其中UTF8在互联网普及、UTF16> > 被JavaScript采用作为字符串的编码方式
  • 1995年诞生的JavaScript字符串采用 1990年发布的 UCS-2编码方式,后来UCS-2被1996年发布的 UTF-16 编码取代
  • JS字符串的长度受到下标限制,理论最大长度是2^53-1(即js中可表达的最大安全整数)

# 4、为什么 0.1 + 0.2 !== 0.3?

JavaScript采用 IEEE754 双精度浮点数来表示数字,而0.1和0.2在转换为二进制时是除不尽的,因此会产生误差,相加后的二进制值转换为十进制时又产生了一次误差,共3次误差,因此是不相等的。

PS:64位 = 1符号位+11指数位+52有效位

进一步,可了解:

  • 精度丢失的原因
  • JavaScript可以存储的最大数字、最大安全数字
  • JavaScript处理大数字的方法、避免精度丢失的方法

参考:一网打尽JavaScript的Number类型 (opens new window)

# 5、ES6 新加入的 Symbol 是什么?

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

使用示例:

var sym = Symbol('foo');
typeof sym; // 'symbol'
Symbol('foo') === Symbol('foo'); // false

var obj = {[sym]: 1};
obj[sym]; // 1
1
2
3
4
5
6

由来:

  • ES6引入Symbol,是为了解决对象属性名冲突

特性:

  • Symbol函数前不能使用new命令,因为Symbol是一个原始类型的值,不是对象。
  • Symbol函数可以接收一个字符串作为参数,表示对Symbol实例的描述
  • Symbol值不能与其他类型的值进行运算,会报错

用途:

  • 用途1:消除魔法字符串(代码中写死的值)
  • 用途2:可以用来表示一个独一无二的变量防止命名冲突
  • 用途3:利用 symbol 不会被常规的方法(除了 Object.getOwnPropertySymbols 外)遍历到,所以可以用来模拟私有变量
// 基本使用
var sym = Symbol('foo');
typeof sym; // 'symbol'
Symbol('foo') === Symbol('foo'); // false

var obj = {[sym]: 1};
obj[sym]; // 1

// Symbol.for()
let s1 = Symbol.for('foo');
let s2 = Symbol.for('foo');

s1 === s2 // true

// Symbol.keyFor()
let s1 = Symbol.for("foo");
Symbol.keyFor(s1) // "foo"

let s2 = Symbol("foo");
Symbol.keyFor(s2) // undefined
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

参考:ES6 系列之模拟实现 Symbol 类型 (opens new window)

# 6、BigInt有什么用?

  • 目的是解决Number无法精确表示非常大的整数
0n === 0
// ↪ false

0n == 0
// ↪ true
1
2
3
4
5

BigInt (opens new window)

# 7、为什么给对象添加的方法能用在基本类型上?

如 1.toString()

先装箱为 String 内置类型,然后拆箱出 string 字符串

# 8、浮点数精度问题

  • 它存在于任何使用浮点数来表示数字的编程语言中,比如java/c中的使用的float和double

  • 问题一:0.1+0.2!=0.3

    • 1、当JavaScript执行比较时,实际比较的是比特位;
    • 2、而JavaScript使用IEEE754浮点数标准表示数字,0.1和0.2因为尾数无限循环而触发3次舍入操作,单独存储0.3这个数字时,仅触发1次舍入,因此会不相等。
  • 问题二:9007199254740992 == 9007199254740993 // true

    • 1、Math.MAX_SAFE_INTEGER:2^53-1,所有尾数都是1
    • IEEE754标准下,无法精确表示的非常大的整数将自动四舍五入。确切地说,JS 中的Number类型只能安全地表示-9007199254740991 (-(2^53-1)) 和9007199254740991(2^53-1)之间的整数,任何超出此范围的整数值都可能失去精度。
    • 3、MAX_SAFE_INTEGERMAX_VALUE 之间的数字却并不能被正确地表示
  • 问题三:指数偏移量为什么是1023(2^11劈一半+同时首位两个数值有特殊用途-2=>(2^11-2)/2) IEEE 754浮点数标准中64位浮点数为什么指数偏移量是1023? (opens new window)

  • 问题四:为什么最大安全数是 2^53-1 参考 (opens new window)

  • 解决方案

    • 1.将数字转成整数
    function add(num1, num2) {
      const num1Digits = (num1.toString().split('.')[1] || '').length;
      const num2Digits = (num2.toString().split('.')[1] || '').length;
      const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
      return (num1 * baseNum + num2 * baseNum) / baseNum;
      }
    
    1
    2
    3
    4
    5
    6
    • 2.三方库(Math.js、Big.js)
    • 3.ES6 Number.EPSILON
      Number.EPSILON=(function(){   //解决兼容性问题
        return Number.EPSILON?Number.EPSILON:Math.pow(2,-52);
      })();
      //上面是一个自调用函数,当JS文件刚加载到内存中,就会去判断并返回一个结果,相比if(!Number.EPSILON){
      //   Number.EPSILON=Math.pow(2,-52);
      //}这种代码更节约性能,也更美观。
      function numbersequal(a,b){ 
        return Math.abs(a-b)<Number.EPSILON;
      }
      //接下来再判断   
      var a=0.1+0.2, b=0.3;
      console.log(numbersequal(a,b)); //这里就为true了
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

原码/反码/补码 (opens new window) 原码/反码/补码计算器 (opens new window)

# 总结

本文重要描述了一下几个部分:

  • 类型分类:类型分为 原始数据类型(undefined null number string boolean symbol bigInt) 和 引用类型(object)
  • 类型存储:原始数据类型存放在栈空间,引用类型存放在堆空间。 本质上,ECMAScript中都是值传递的。
  • 类型判定:
    • typeof 判断基本类型和函数对象很方便,但无法区分 null 和 object(包括数组)。
    • instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上,只能检测对象的类型。
    • Object.prototype.toString 默认返回当前对象的 [[Class]]
    • constructor 通过对象的该属性可判断其类型(有被修改的风险)
  • 类型的转换:常见的隐式类型转换的场景、以及使用内置对象进行的装箱拆箱的过程
  • 类型细节:比如为什么 0.1 + 0.2 !== 0.3 、为什么推荐用 void 0 代替 undefined

感谢阅读,希望看完本文你能有所收获,也欢迎点赞留言,一起交流进步~

思考题:类型到底是什么?在执行JS的过程中它扮演着怎样的角色?

上次更新: 3/1/2022, 10:32:19 AM