JS数据类型
概念
JavaScript 的数据类型有下图所示的 8 种:
前 7 种类型为基础类型,最后 1 种(Object)为引用类型,而引用数据类型(Object)又分为图上这几种常见的类型:Array
- 数组对象、RegExp
- 正则对象、Date
- 日期对象、Math
- 数学函数、Function
- 函数对象。
各种 JavaScript 的数据类型最后都会在初始化之后放在不同的内存中,因此上面的数据类型大致可以分成两类来进行存储:
- 基础类型存储在
栈内存
,被引用或拷贝时,会创建一个完全相等的变量; - 引用类型存储在
堆内存
,存储的是地址,多个引用指向同一个地址,这里会涉及一个“共享”
的概念。
类型检测
判断方法1:typeof
1 | typeof 1 // 'number' |
typeof
对于基本类型,除了 null
都可以显示正确的类型,虽然null
是基本类型,但是会显示object
,这是一个存在很久的Bug,因为在 JS 的最初版本中,使用的是 32位系统
,为了性能考虑使用低位存储了变量的类型信息,000
开头代表是对象,然而 null
表示为全零,所以将它错误的判断为 object
。虽然现在的内部类型判断代码已经改变了,但是对于这个 Bug 却是一直流传下来。
判断方法2:instanceof
我们new
一个对象,那么这个新对象就是它原型链继承上面的对象了,通过instanceof
我们能判断这个对象是否是之前那个构造函数生成的对象,这样就基本可以判断出这个新对象的数据类型。下面通过代码来了解一下。
1 | let Car = function() {} |
那么如何实现一个instanceof
的底层实现呢,代码如下:
1 | function myInstanceof(left, right) { |
前面两种判断数据类型的方法它们之间有什么差异呢?我总结了下面两点:
instanceof 可以准确地判断复杂引用数据类型,但是不能正确判断基础数据类型;
而 typeof 也存在弊端,它虽然可以判断基础数据类型(null 除外),但是引用数据类型中,除了 function 类型以外,其他的也无法判断。
判断方法3:Object.prototype.toString
toString()
是Object
的原型方法,调用该方法,可以统一返回格式为“[object Xxx]”
的字符串,其中 Xxx
就是对象的类型。对于 Object
对象,直接调用 toString()
就能返回 [object Object]
;而对于其他对象,则需要通过 call
来调用,才能返回正确的类型信息。
1 | Object.prototype.toString({}) // "[object Object]" |
下面来实现一个全局通用的数据类型判断方法,代码如下:
1 | function getType(obj){ |
深浅拷贝
浅拷贝
创建一个新的对象,来接收你要重新复制或引用的对象值。如果对象属性是基本的数据类型,复制的就是基本类型的值给新对象;但如果属性是引用数据类型,复制的就是内存中的地址,如果其中一个对象改变了这个内存中的地址,肯定会影响到另一个对象。
1 | let a = { |
上述例子看出,给一个变量赋值一个对象,那么两者的值会是同一个引用,其中一方改变,另一方也会相应改变,如果不想要改变,可以用浅拷贝的方式,下面就是浅拷贝的几种方法:
方法1:object.assign
object.assign
是 ES6 中 object 的一个方法,该方法可以用于 JS 对象的合并等多个用途,该方法有两个参数:
参数1:拷贝的目标对象
,
参数2:拷贝的来源对象(也可以是多个来源)
。
1 | let a = { |
使用 object.assign
方法有几点需要注意:
- 它不会拷贝对象的继承属性;
- 它不会拷贝对象的不可枚举的属性;
- 可以拷贝 Symbol 类型的属性。
方法2:...
扩展运算符方式
1 | let a = { |
扩展运算符
和object.assign
有同样的缺陷,也就是实现的浅拷贝的功能差不多,但是如果属性都是基本类型
的值,使用扩展运算符进行浅拷贝会更加方便。
方法3:concat
拷贝数组
语法:
1 | arrayObject.concat(arrayX,arrayX,......,arrayX) |
concat() 方法用于连接两个或多个数组,该方法不会改变现有的数组,而仅仅会返回被连接数组的一个副本。注意:concat只能用于数组的浅拷贝,使用场景比较局限。代码如下所示。
1 | let arr = [1, 2, 3]; |
方法4:slice
拷贝数组
语法:
1 | arrayObject.slice(begin, end); |
slice()方法可从已有的数组中返回选定的元素,该方法并不会修改数组,而是返回一个新的数组,包含从 start 到 end (不包括该元素)的 arrayObject 中的元素,下面是实现浅拷贝的代码示例:
1 | let arr = [1, 2, {val: 4}]; |
从上面的代码中可以看出,浅拷贝只能拷贝一层对象。如果存在对象的嵌套,那么浅拷贝将无能为力,为了解决这个问题,这个时候就可以采用深拷贝,它能解决多层对象嵌套问题,彻底实现拷贝。
手工实现浅拷贝
实现思路:
- 对基础类型做一个最基本的一个拷贝;
- 对引用类型开辟一个新的存储,并且拷贝一层对象属性。
1 | const shallowClone = (target) => { |
hasOwnProperty()
方法用来检测一个属性是否是对象的自有属性,而不是从原型链继承的。如果该属性是自有属性,那么返回true
,否则返回false
。换句话说,hasOwnProperty()
方法不会检测对象的原型链,只会检测当前对象本身,只有当前对象本身存在该属性时才返回true
。
1 | let obj = { |
深拷贝
将一个对象从内存中完整地拷贝出来一份给目标对象,并从堆内存中开辟一个全新的空间存放新对象,且新对象的修改并不会改变原对象,二者实现真正的分离。
方法1:JSON.stringfy
JSON.stringify()是目前开发过程中最简单的深拷贝方法,其实就是把一个对象序列化成为JSON的字符串,并将对象里面的内容转换成字符串,最后再用JSON.parse()
的方法将JSON字符串
生成一个新的对象。示例代码如下所示:
1 | let obj1 = { a:1, b:[1,2,3] } |
该方法也是有局限性的:
- 拷贝的对象的值中如果有
函数
、undefined
、symbol
这几种类型,经过JSON.stringify序列化
之后的字符串中这个键值对会消失
; - 拷贝 Date 引用类型会变成字符串;
- 无法拷贝不可枚举的属性;
- 无法拷贝对象的原型链;
- 拷贝 RegExp 引用类型会变成空对象;
- 对象中含有 NaN、Infinity 以及 -Infinity,JSON 序列化的结果会变成 null;
- 无法拷贝对象的循环引用
1 | // 针对上面说法示例: |
以上代码的打印结果:
使用 JSON.stringify 方法实现深拷贝对象,虽然到目前为止还有很多无法实现的功能,但是这种方法足以满足日常的开发需求,并且是最简单和快捷的。而对于其他的也要实现深拷贝的,比较麻烦的属性对应的数据类型,JSON.stringify 暂时还是无法满足的,那么就需要下面的几种方法了。
方法2:基础版(手写递归实现)
下面是一个实现 deepClone 函数封装的例子,通过 for in 遍历传入参数的属性值,如果值是引用类型则再次递归调用该函数,如果是基础数据类型就直接复制,代码如下所示。
1 | let obj1 = { |
虽然利用递归能实现一个深拷贝,但是同上面的 JSON.stringfy 一样,还是有一些问题没有完全解决,例如:
- 这个深拷贝函数并不能复制不可枚举的属性以及 Symbol 类型;
- 这种方法只是针对普通的引用类型的值做递归复制,而对于 Array、Date、RegExp、Error、Function 这样的引用类型并不能正确地拷贝;
- 对象的属性里面成环,即循环引用没有解决。
这种基础版本的写法也比较简单,可以应对大部分的应用情况。
方法3:改进版(改进后递归实现)
针对上面几个待解决问题,我先通过四点相关的理论告诉你分别应该怎么做。
- 针对能够遍历对象的不可枚举属性以及 Symbol 类型,我们可以使用 Reflect.ownKeys 方法;
- 当参数为 Date、RegExp 类型,则直接生成一个新的实例返回;
- 利用 Object 的 getOwnPropertyDescriptors 方法可以获得对象的所有属性,以及对应的特性,顺便结合 Object 的 create 方法创建一个新对象,并继承传入原对象的原型链;
- 利用 WeakMap 类型作为 Hash 表,因为 WeakMap 是弱引用类型,可以有效防止内存泄漏(你可以关注一下 Map 和 weakMap 的关键区别,这里要用 weakMap),作为检测循环引用很有帮助,如果存在循环,则引用直接返回 WeakMap 存储的值。
那么针对上面这几个问题,我们来看下改进后的递归实现的深拷贝代码应该是什么样子的,如下所示。
1 | const isComplexDataType = obj => (typeof obj === 'object' || typeof obj === 'function') && (obj !== null) |
我们看一下结果,cloneObj 在 obj 的基础上进行了一次深拷贝,cloneObj 里的 arr 数组进行了修改,并未影响到 obj.arr 的变化,如下图所示。
从这张截图的结果可以看出,改进版的 deepClone 函数已经对基础版的那几个问题进行了改进,也验证了我上面提到的那四点理论。
JS常见的6种继承方式
在学习继承之前,先了解原型链,便于更好的理解
原型链继承
1 | // 原型链继承 |
原型链继承方式也是有缺点的,如下代码:
1 | var s1 = new Child(); |
因为两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化
构造函数继承(借助 call)
1 | function Person(){ |
从上面的结果就可以看到构造函数实现继承的优缺点,它使父类的引用属性不会被共享,优化了第一种继承方式的弊端;但是随之而来的缺点也比较明显——只能
继承父类的实例属性和方法,不能
继承原型属性或者方法
组合继承(前两种组合)
这种方式结合了前两种继承方式的优缺点,结合起来的继承
1 | function Person(){ |
从组合继承
打印的结果可以看出,上面原型链继承
和构造函数继承
的问题都得以解决,但是有个新的问题是,Person
执行了两次,第一次
改变Child
的prototype
的时候,第二次
是通过call
调用Person
的时候,Person
多构造一次就多进行一次性能开销,这是一个缺点。
原型式继承
通过ES5 里面的 Object.create
方法,这个方法接收两个参数:参数1
:用作新对象原型的对象、参数2
:为新对象定义额外属性的对象(可选参数)
1 | let parent4 = { |
后两个输出结果是一样的,可以联想到上面的浅拷贝的知识点,关于引用数据类型“共享”
的问题,其实Object.create
方法是可以为一些对象实现浅拷贝的,关于这种继承方式的缺点也很明显,多个实例的引用类型属性指向相同的内存,存在篡改的可能。
寄生式继承
上面使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法,这样的继承方式就叫作寄生式继承
1 | let parent5 = { |
通过上面这段代码可以看到 p 是通过寄生式继承生成的实例,它不仅仅有 getName 的方法,而且可以看到它最后也拥有了 getFriends 的方法,从最后的输出结果中可以看到,p 通过clone的方法,增加了 getFriends 的方法,从而使 p 这个普通对象在继承过程中又增加了一个方法,这样的继承方式就是寄生式继承。我在上面第三种组合继承方式中提到了一些弊端,即两次调用父类的构造函数造成浪费,下面要介绍的寄生组合继承就可以解决这个问题。
寄生组合式继承
结合上面寄生式继承
中提及的继承方式,解决普通对象的继承问题的Object.create
方法,我们在前面这几种继承方式的优缺点基础上进行改造,得出了寄生组合式的继承方式,这也是所有继承方式里面相对最优的继承方式
。
1 | function clone (parent, child) { |
通过这段代码可以看出来,这种寄生组合式继承方式,基本可以解决前几种继承方式的缺点,较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销,可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题,可以输出预期的结果。
整体看下来,这六种继承方式中,寄生组合式继承是这六种里面最优的继承方式。另外,ES6 还提供了继承的关键字 extends,我们再看下 extends 的底层实现继承的逻辑。
ES6的extends继承
利用 ES6 里的 extends 的语法糖,使用关键词很容易直接实现 JavaScript 的继承,但是如果想深入了解 extends 语法糖是怎么实现的,就得深入研究 extends 的底层逻辑,我们先看下用利用 extends 如何直接实现继承。
1 | class Person { |
因为浏览器的兼容性问题,如果遇到不支持 ES6 的浏览器,那么就得利用 babel 这个编译工具,将 ES6 的代码编译成 ES5,让一些不支持新语法的浏览器也能运行。
那么最后 extends 编译成了什么样子呢?我们看一下转译之后的代码片段
1 | function _possibleConstructorReturn (self, call) { |
从上面编译完成的源码中可以看到,它采用的也是寄生组合继承方式,因此也证明了这种方式是较优的解决继承的方式。
实现new、apply、call、bind的底层逻辑
new原理介绍
new 关键词的主要作用就是执行一个构造函数、返回一个实例对象,在 new 的过程中,根据构造函数的情况,来确定是否可以接受参数的传递
1 | function Person(){ |
大致分为以下几个步骤:
- 创建一个新对象;
- 将构造函数的作用域赋给新对象(this 指向新对象);
- 执行构造函数中的代码(为这个新对象添加属性);
- 返回新对象。
下面对构造函数稍微改动一下,直接return看下会发生什么变化
1 | function Person(){ |
当构造函数最后 return 出来的是一个和 this 无关的对象时,new 命令会直接返回这个新对象,而不是通过 new 执行步骤生成的 this 对象。
1 | function Person(){ |
当构造函数中 return 的不是一个对象时,那么它还是会根据 new 关键词的执行逻辑,生成一个新的对象(绑定了最新 this),最后返回出来。
总结:new 关键词执行之后总是会返回一个对象,要么是实例对象,要么是 return 语句指定的对象。
apply & call & bind 原理介绍
call、apply 和 bind 是挂在 Function 对象上的三个方法,调用这三个方法的必须是一个函数。
1 | // 基本语法: |
其中 func 是要调用的函数,thisArg 一般为 this 所指向的对象,后面的 param1、2 为函数 func 的多个参数,如果 func 不需要参数,则后面的 param1、2 可以不写。
这三个方法共有的、比较明显的作用就是,都可以改变函数 func 的 this 指向。call 和 apply 的区别在于,传参的写法不同:apply 的第 2 个参数为数组; call 则是从第 2 个至第 N 个都是给 func 的传参;而 bind 和这两个(call、apply)又不同,bind 虽然改变了 func 的 this 指向,但不是马上执行,而这两个(call、apply)是在改变了函数的 this 指向之后立马执行。
下面例子分析:
1 | let a = { |
A 对象有个 getName 的方法,B 对象也需要临时使用同样的方法,那么这时候我们是单独为 B 对象扩展一个方法,还是借用一下 A 对象的方法呢?当然是可以借用 A 对象的 getName 方法,既达到了目的,又节省重复定义,节约内存空间。
new的实现
执行 new 的过程
- 让实例可以访问到私有属性
- 让实例可以访问构造函数原型(constructor.prototype)所在原型链上的属性
- 构造函数返回的最后结果是引用数据类型
1
2
3
4
5
6
7
8
9
10
11
12function _new(ctor, ...args) {
if(typeof ctor !== 'function') {
throw 'ctor must be a function';
}
let obj = new Object();
obj.__proto__ = Object.create(ctor.prototype);
let res = ctor.apply(obj, [...args]);
let isObject = typeof res === 'object' && typeof res !== null;
let isFunction = typeof res === 'function';
return isObect || isFunction ? res : obj;
};call的实现
1
2
3
4
5
6
7Function.prototype.call = function (context, ...args) {
var context = context || window;
context.fn = this;
var result = eval('context.fn(...args)');
delete context.fn
return result;
}apply的实现
实现 call 和 apply 的关键就在 eval 这行代码。其中显示了用 context 这个临时变量来指定上下文,然后还是通过执行 eval 来执行 context.fn 这个函数,最后返回 result。1
2
3
4
5
6
7Function.prototype.call = function (context, ...args) {
var context = context || window;
context.fn = this;
var result = eval('context.fn(...args)');
delete context.fn
return result;
}bind的实现
bind 的实现思路基本和 apply 一样,但是在最后实现返回结果这里,bind 和 apply 有着比较大的差异,bind 不需要直接执行,因此不再需要用 eval ,而是需要通过返回一个函数的方式将结果返回,之后再通过执行这个结果,得到想要的执行效果。从上面的代码中可以看到,实现 bind 的核心在于返回的时候需要返回一个函数,故这里的 fbound 需要返回,但是在返回的过程中原型链对象上的属性不能丢失。因此这里需要用Object.create 方法,将 this.prototype 上面的属性挂到 fbound 的原型上面,最后再返回 fbound。这样调用 bind 方法接收到函数的对象,再通过执行接收的函数,即可得到想要的结果。1
2
3
4
5
6
7
8
9
10
11
12
13Function.prototype.bind = function (context, ...args) {
if (typeof this !== "function") {
throw new Error("this must be a function");
}
var self = this;
var fbound = function () {
self.apply(this instanceof self ? this : context, args.concat(Array.prototype.slice.call(arguments)));
}
if(this.prototype) {
fbound.prototype = Object.create(this.prototype);
}
return fbound;
}
闭包
什么是闭包?
红宝书闭包的定义:闭包是指有权访问另外一个函数作用域中的变量的函数。
MDN:一个函数和对其周围状态的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。
闭包基本概念
通俗地讲解一下:闭包其实就是一个可以访问其他函数内部变量的函数。即一个定义在函数内部的函数,或者直接说闭包是个内嵌函数也可以。(函数 A 返回了一个函数 B,并且函数 B 中使用了函数 A 的变量,函数 B 就被称为闭包。)
因为通常情况下,函数内部变量是无法在外部访问的(即全局变量和局部变量的区别),因此使用闭包的作用,就具备实现了能在外部访问某个函数内部变量的功能,让这些内部变量的值始终可以保存在内存中。下面我们通过代码先来看一个简单的例子。
1 | function fun1() { |
闭包的表现形式
- 返回一个函数
- 在定时器、事件监听、Ajax 请求、Web Workers 或者任何异步中,只要使用了回调函数,实际上就是在使用闭包。请看下面这段代码,这些都是平常开发中用到的形式。
1
2
3
4
5
6
7
8// 定时器
setTimeout(function handler(){
console.log('1');
},1000);
// 事件监听
$('#app').click(function(){
console.log('Event Listener');
}); - 作为函数参数传递的形式
1
2
3
4
5
6
7
8
9
10
11
12
13var a = 1;
function foo(){
var a = 2;
function baz(){
console.log(a);
}
bar(baz);
}
function bar(fn){
// 这就是闭包
fn();
}
foo(); // 输出2,而不是1 - IIFE(立即执行函数),创建了闭包,保存了全局作用域(window)和当前函数的作用域,因此可以输出全局的变量
1
2
3
4var a = 2;
(function IIFE(){
console.log(a); // 输出2
})();
循环中使用闭包解决 var 定义函数的问题
1 | for(var i = 1; i <= 5; i ++){ |
从控制台执行的结果可以看出来,结果输出的是 5 个 6,那么为什么都是 6 ?
1.setTimeout 为宏任务,由于 JS 中单线程 eventLoop 机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后 setTimeout 中的回调才依次执行。
2.因为 setTimeout 函数也是一种闭包,往上找它的父级作用域链就是 window,变量 i 为 window 上的全局变量,开始执行 setTimeout 之前变量 i 已经就是 6 了,因此最后输出的连续就都是 6。
如何实现输出1,2,3,4,5?
- 利用 IIFE(立即执行函数)当每次 for 循环时,把此时的变量 i 传递到定时器中,然后执行,改造之后的代码如下。
1
2
3
4
5
6
7for(var i = 1;i <= 5;i++){
(function(j){
setTimeout(function timer(){
console.log(j)
}, 0)
})(i)
} - 使用 ES6 中的 letES6 中新增的 let 定义变量的方式,使得 ES6 之后 JS 发生革命性的变化,让 JS 有了块级作用域,代码的作用域以块级为单位进行执行
1
2
3
4
5for(let i = 1; i <= 5; i++){
setTimeout(function() {
console.log(i);
},0)
} - 定时器传入第三个参数setTimeout 作为经常使用的定时器,它是存在第三个参数的,日常工作中我们经常使用的一般是前两个,一个是回调函数,另外一个是时间,而第三个参数用得比较少。那么结合第三个参数,调整完之后的代码如下。
1
2
3
4
5for(var i=1;i<=5;i++){
setTimeout(function(j) {
console.log(j)
}, 0, i)
}从中可以看到,第三个参数的传递,可以改变 setTimeout 的执行逻辑,从而实现我们想要的结果,这也是一种解决循环输出问题的途径。1
2
3
4
5for(var i=1;i<=5;i++){
setTimeout(function(j) {
console.log(j)
}, 0, i)
}
JSON.stringify
—————— 该篇文章继续学习中,未完待续 ——————