要进行深度克隆,首先就需要知道进行克隆的这个变量是什么类型的值,知道了是什么类型的,我们才能分门别类的去根本不同的类型进行克隆。所以我们先介绍如何进行准确的类型判断。
类型判断
首先我们需要知道JavaScript都有哪些数据类型。
基本数据类型:Undefined、Null、Boolean、Number、 String 和 Symbol。
引用数据类型:Object。
定义一些变量方便后面使用。
const str = ''
const _undefined = undefined
const _null = null
const num = 0
const bool = true
const sym = Symbol()
const obj = {}
const arr = []
const func = () => {}
const reg = /a/
const date = new Date()
const dom = document.getElementsByTagName('body')[0]
- 拓展 -- bigInt
BigInt 是一种内置对象,它提供了一种方法来表示大于 253 - 1 的整数。这原本是 Javascript中可以用 Number 表示的最大数字。BigInt 可以表示任意大的整数。可以用在一个整数字面量后面加 n 的方式定义一个 BigInt ,如:10n,或者调用函数BigInt()。
这是一个新的基本数据类型。如下
const bigNum = 10n
const oldNum = 10
console.log(typeof bigNum) // 'bigint'
console.log(bigNum === oldNum) // false
console.log(bigNum == oldNum) // true
console.log(bigNum + bigNum) // 20n
console.log(bigNum + oldNum) // Uncaught TypeError: Cannot mix BigInt and other types, use explicit conversions
详细可以看以下两篇内容:MDN - BigInt,JS最新基本数据类型:BigInt。
然后我们看看有哪些方法可以去判断一个变量的类型呢?
typeof
typeof 操作符返回一个字符串,表示未经计算的操作数的类型。
我们看看上面定义的变量的表现。
typeof str // "string"
typeof _undefined // "undefined"
typeof _null // "object"
typeof num // "number"
typeof bool // "boolean"
typeof sym // "symbol"
typeof obj // "object"
typeof arr // "object"
typeof func // "function"
typeof reg // "object"
typeof date // "object"
typeof dom // "object"
可以看出来有些变量的表现让人比较不满意,所以在涉及到引用变量的时候,使用typeof
时还是要注意的。
typeof的迷惑行为
typeof new Number(1) === 'object'
什么?new Number(1)
不是个数字吗?
这是因为使用new
操作符创建的变量都是这个构造函数的实例,被加在了原型链上,尽管他仍然等于 1。
let newNum = new Number(1)
console.log(newNum) // Number {1}
newNum === 1 // false
newNum + 1 // 2
因为这个迷惑行为,所以非必要时,不要使用new
操作符去创建一个基本类型的变量,这可能会导致不必要的麻烦。
typeof null === 'object'
什么?null
不是属于基本数据类型null
吗?
这个大家应该都清楚,从 JavaScript 诞生到现在一直都是这样。
在 JavaScript 最初的实现中,JavaScript 中的值是由一个表示类型的标签和实际数据值表示的。对象的类型标签是 0。由于
null
代表的是空指针(大多数平台下值为0x00
),因此,null
的类型标签是 0,typeof null
也因此返回"object"
。
typeof 9 + 'str'
什么?9 + 'str'
不是属于字符串的拼接,属于string
类型吗?
这是因为运算符的优先级,typeof
的优先级要高于+
,所以会先得到typeof 9
的值为'number'
,然后计算'number' + 'str'
,得到最终结果为'numberstr'
。
如果是这样写:typeof (9 + 'str')
。得到的结果就是'string'
。
- 拓展 -- 运算符优先级
下面列出了常用的运算符的优先级。
typeof newStr === 'undefined'
这个需要分情况讨论,看下面一段代码。
console.log(typeof varStr === 'undefined')
console.log(typeof letStr === 'undefined')
let letStr
执行这段代码的结果会是什么呢?
首先有个前置知识。
在 ECMAScript 2015 之前,typeof 总能保证对任何所给的操作数返回一个字符串。即便是没有声明的标识符,typeof 也能返回 'undefined'。使用 typeof 永远不会抛出错误。
所以第一行的答案必是true
。那第二行呢?
在看正确答案之前我们先复习一下var let const
。
var
关联词用来声明一个可变的本地变量,在全局范围内都有效,也就是说,当你用var
声明了一个变量的时候,他就会在全局声明时创建一个属性。
let
关联词用来声明一个可变的本地变量,在其作用域或子块内都有效,也就是说,当你用let
声明了一个变量的时候,在此之前或者作用域之外都是不能使用这个变量的。
const
关联词用来声明一个不可变的本地变量,在其作用域或子块内都有效,也就是说,当你用const
声明了一个变量的时候,他和let
声明的变量有着同样的作用域,但无法更改。
我们通过一个简单的for循环理解一下var
和let
。
for (var i = 0; i < 10; i ++) {
setTimeout(() => console.log(i), 300) // 10 个 10
}
console.log(i) // 10
for (let j = 0; j < 10; j ++) {
setTimeout(() => console.log(j), 300) // 0 ~ 9
}
console.log(j) // Uncaught ReferenceError: j is not defined
对于第一个for循环,var
定义的i
被提升到了代码开头,在全局范围内都有效,所以全局只有一个变量i
,循环的每一层都是去改变这个i
的值;而循环内部的setTimeout
都被加到了执行队列的尾端,执行其内部的方法时就会去寻找全局的i
,这个时候的i就已经时被改变成最后的值10
。同理在for循环外部的打印也是去找的全局变量i
。
对于第二个for循环。let
定义的j只在其当轮的作用域下有效,所以每次循环其实都是一个新的变量j
;而循环内部的setTimeout
同样都被加到了执行队列的尾端,但是每个setTimeout
在执行的时候都会去找其对应的作用域下的值,也就是会输出正确的0 - 9
。在for循环外部的打印,因为其不在定义j
的作用域范围内,所以会报错。
看到这里,最开始的那一段代码的结果就显而易见了。
console.log(typeof varStr === 'undefined') // true
console.log(typeof letStr === 'undefined') // Uncaught ReferenceError: letStr is not defined
let letStr
在加入了块级作用域的 let 和 const 之后,在其被声明之前对块中的 let 和 const 变量使用 typeof 会抛出一个 ReferenceError。块作用域变量在块的头部处于“暂存死区”,直至其被初始化,在这期间,访问变量将会引发错误。
typeof document.all === 'undefined'
什么?document.all
不是当前页面的标签的集合,属于object
类型吗?
这是一个例外,在MDN中是这样说的。
尽管规范允许为非标准的外来对象自定义类型标签,但它要求这些类型标签与已有的不同。document.all 的类型标签为 'undefined' 的例子在 Web 领域中被归类为对原 ECMA JavaScript 标准的“故意侵犯”。
总结
typeof
可以用来对基本数据类型变量(通过new
定义的除外)做类型校验,也可以用来区分是否是引用型变量;但是在用的时候需要注意上面所说的一些特殊情况。
instanceof
instanceof
运算符用于检测构造函数的prototype
属性是否出现在某个实例对象的原型链上。
我们看看上面定义的变量的表现。
str instanceof String // false
_undefined instanceof // ❌
_null instanceof Object // false
num instanceof Number // false
bool instanceof Boolean // false
sym instanceof Symbol // false
obj instanceof Object // true
arr instanceof Array // true
func instanceof Function // true
reg instanceof RegExp // true
date instanceof Date // true
dom instanceof HTMLBodyElement // true
然后我们看看对于由构造函数创建的基本类型的变量。
const conNum = new Number(10)
const conStr = new String('abc')
const conBoo = new Boolean(true)
conNum instanceof Number // true
conStr instanceof String // true
conBoo instanceof Boolean // true
以及当右边为Object
时。
conStr instanceof Object // true
_null instanceof Object // false
conNum instanceof Object // true
conBoo instanceof Object // true
obj instanceof Object // true
arr instanceof Object // true
func instanceof Object // true
reg instanceof Object // true
date instanceof Object // true
dom instanceof Object // true
这是因为instanceof
会在你的原型链上去找关联关系,第一层就是每个变量所对应的构造函数,而Object
就是在原型链上能找到的终点了。
至于null
,虽然typeof null === 'object'
,而且Object.prototype.__proto__ === null
;但是实际上,null并不存在原型链,他就是一个简简单单的null,没有任何属性。
下面这种神图,相信大家都有看过,instanceof
就是在这个链上一层层去找的。
多全局对象
在浏览器中,我们的脚本可能需要在多个窗口之间进行交互。多个窗口意味着多个全局环境,不同的全局环境拥有不同的全局对象,从而拥有不同的内置类型构造函数。这可能会引发一些问题。比如,表达式
[] instanceof window.frames[0].Array
会返回false
,因为Array.prototype !== window.frames[0].Array.prototype
,并且数组从前者继承。
总结
instanceof
可以用来判断引用数据类型变量的具体类型以及由new
定义的基本数据类型变量的类型。
constructor
constructor
是一种用于创建和初始化class
创建的对象的特殊方法。
constructor
返回的是当前变量的构造器,和instanceof
一样都是在原型链上操作。先看下之前定义的变量的表现。
console.log(str.constructor) // String
console.log(_undefined.constructor) // Uncaught TypeError: Cannot read property 'constructor' of undefined
console.log(_null.constructor) // Uncaught TypeError: Cannot read property 'constructor' of null
console.log(num.constructor) // Number
console.log(bool.constructor) // Boolean
console.log(sym.constructor) // Symbol
console.log(obj.constructor) // Object
console.log(arr.constructor) // Array
console.log(func.constructor) // Function
console.log(reg.constructor) // RegExp
console.log(date.constructor) // Date
console.log(dom.constructor) // HTMLBodyElement
可以看出来除了null
和undefined
都可以使用constructor
属性得到其的构造函数,也就是准确的类型。
- 注意:数值不能直接使用
constructor
console.log(1.constructor) // Uncaught SyntaxError: Invalid or unexpected token
console.log((1).constructor) // Number
总结
constructor
可以判断除了null
和undefined
外的所有变量的类型。
toString
toString()
方法返回一个表示该对象的字符串。
toString()
是Object
原型链上的方法,如果直接调用返回的值是其转换成字符串的值,我们可以通过call
方法来调用。
const _toString = str => Object.prototype.toString.call(str)
console.log(_toString(str)) // [object String]
console.log(_toString(_undefined)) // [object Undefined]
console.log(_toString(_null)) // [object Null]
console.log(_toString(num)) // [object Number]
console.log(_toString(bool)) // [object Boolean]
console.log(_toString(sym)) // [object Symbol]
console.log(_toString(obj)) // [object Object]
console.log(_toString(arr)) // [object Array]
console.log(_toString(func)) // [object Function]
console.log(_toString(reg)) // [object RegExp]
console.log(_toString(date)) // [object Date]
console.log(_toString(dom)) // [object HTMLBodyElement]
那么,我们为什么要通过call
来调用呢?
首先一点是,Object
的toString
方法会返回一个[object ${calss}]
的形式的字符串,在ecma中是这样说的:“Return the String value that is the result of concatenating the three Strings "[object ", class, and "]".”;翻译过来就是“返回字符串值,该值是将三个字符串“[object”,class和“]”连接在一起的结果”。所以我们可以使用这个方法去判断变量类型。
其次是,大部分的类型都重写了Object
的toString
方法,也就是每个类型的变量去直接调用结果的表现都是不一样的,所以我们需要通过call
去调用Object
的toString
方法。
总结
toString
可以用来准确的判断一个变量的类型。
其他
Array.isArray(arg)
对于数组,Array
给我们专门提供了Array.isArray(arg)
来判断arg
是否是数组。
其实现方法很简单,就是封装了toString
,如下
Array.isArray = function(arg) {
return Object.prototype.toString.call(arg) === '[object Array]';
};
当然其内部的实现肯定不会这么简单,至少也要加上类型判断。
isNaN(arg) / Number.isNaN(arg)
isNaN
是提供给我们的一个全局方法,Number.isNaN
则是Number
的一个原型方法。二者均是用来判断NaN
的,后者比前者更加稳妥。如下
console.log(isNaN(NaN)) // true
console.log(Number.isNaN(NaN)) // true
console.log(isNaN('NaN')) // true
console.log(Number.isNaN('NaN')) // false
console.log(isNaN(undefined)) // true
console.log(Number.isNaN(undefined)) // false
console.log(isNaN('23')) // false
console.log(Number.isNaN('23')) // false
console.log(isNaN('abc')) // true
console.log(Number.isNaN('abc')) // false
可以发现,当参数为非数字类型时,isNaN
会先尝试将其转换为数值,然后去判断转换后的值是否为NaN
,所以会有很多的迷惑行为。
而对于Number.isNaN
,其并不会去尝试转换参数,而是直接去判断参数是否为NaN
,哪怕是字符串的'NaN'
都不行,所以推荐使用Number.isNaN
。
深度克隆
学习了几种类型判断的方法,下面我们正式开始深度克隆。
我们为什么需要深度克隆?
首先看下面这段代码。
const str = '123'
let newStr = str
newStr += '4'
const arr = [1, 2, 3, 4]
const newArr = arr
newArr.push(5)
console.log(newArr)
console.log(arr)
console.log(newStr)
console.log(str)
在这段代码中,newArr是arr的复制,newStr是str的复制;但是结果是arr跟着newArr改变了。
出现这种情况的原因是,在JavaScript中,普通数据类型都存在栈(栈内存)中,占用空间大小固定;引用数据类型都存在堆(堆内存)中,通过指针建立联系。而引用类型的值的复制,只是给新变量B添加了一个指针b,指向了被复制变量A的指针a所指向的内存,也就是说,B虽然是经过A复制得来,但是他们指向的始终还是同一个值,所以会一个变,另一个跟着变。
为了避免这种指向同一地址,联动更改的问题,就需要用到深度克隆,使得复制出来的变量是一个全新的变量,不会对以前的变量产生其他影响。
实现过程
先搭个架子。
const deepClone = arg => {
if (!arg) return arg
return arg
}
基本数据类型变量克隆
然后我们由上一节得知,深度遍历主要就是针对的引用型变量,所以第一步就是先区分出基本变量和引用型变量。区分是否引用型变量可以使用typeof
。
这一步有几点需要注意一下, 一个是function
,typeof fun === 'function'
,而对于function
来说,直接复制是没有问题的,所以不需要考虑这个。
还有一个是document.all
,typeof document.all === 'undefined'
,document.all
并不被推荐使用,且其中的关键信息都是只读的,所以在这里不考虑其的复制。
还有一个是通过new
定义的基本类型变量,这些变量会通过第一步的判断,所以我们留在下一步讨论。
// 区分基本类型变量和引用类型变量
// 对于基本类型变量可以直接复制
if (typeof arg !== 'object) {
return arg
}
然后第二步,我们需要分类型考虑各类型的复制。不过在此之前,需要先分辨出各类型。
在这里,用的是toString
,当然,其他的也可以。
const type = Object.prototype.toString.call(arg)
switch(type) {
case '[object RegExp]': break;
case '[object Date]': break;
case '[object Array]': break;
case '[object Object]': break;
default: return arg;
}
通过switch去处理不同类型的克隆,而default分支就用来处理通过了第一步判断的基本类型的变量,可以直接返回。
RegExp
克隆
我们需要先了解几个RegExp
的内置属性。
属性名 | 含义 |
---|---|
source | source 属性返回一个值为当前正则表达式对象的模式文本的字符串,该字符串不会包含正则字面量两边的斜杠以及任何的标志字符。 |
global | global 属性表明正则表达式是否使用了 "g" 标志。global 是一个正则表达式实例的只读属性。 |
ignoreCase | ignoreCase 属性表明正则表达式是否使用了 "i" 标志。ignoreCase 是正则表达式实例的只读属性。 |
multiline | multiline 属性表明正则表达式是否使用了 "m" 标志。multiline 是正则表达式实例的一个只读属性。 |
我们需要获取源正则对象的字符串和其标志,然后生成一个新的正则。如下
switch(type) {
case '[object RegExp]':
let flag = ''
if (arg.global) flag.push('g')
if (arg.ignoreCase) flag.push('i')
if (arg.multiline) flag.push('m')
return new RegExp(arg.source, flag)
}
而在支持es6的环境中,我们可以不用这么麻烦,直接生成即可。
switch(type) {
case '[object RegExp]':
return new RegExp(arg)
}
这是因为从es6开始,当第一个参数为正则表达式而第二个标志参数存在时,new RegExp(/ab+c/, 'i') 不再抛出 TypeError ("从另一个RegExp构造一个RegExp时无法提供标志")的异常,取而代之,将使用这些参数创建一个新的正则表达式。
Date
克隆
对于时间,我们可以直接获取其时间戳,然后新生成一个即可。
switch(type) {
case '[object Date]':
return new Date(obj.getTime())
}
Array
克隆
对于数组,可以通过遍历去处理数组内部每一项的值,而这些值又可能是任何属性,所以这里需要用递归去处理这些值。
这里要注意,不能使用解构,因为解构并不会改变数组内部值的组成,所以对于内部值,仍然是浅克隆。
switch(type) {
case '[object Array]':
const result = []
for (let i = 0; i < arg.length; i ++) {
result[i] = deepClone(arg[i])
}
return result
}
Object
克隆
对于对象,和数组类似,都要使用递归去处理其内部值,而不同点在于,对象还需要处理其原型链、只读属性或者是修改过属性的值等等。
首先是处理原型链,可以用Object.getPrototypeOf()
和Object.create()
,前者作用是返回指定对象的原型;后者作用是创建一个新对象,使用现有的对象来提供新创建的对象的proto。
const obj = {}
let proto = Object.getPrototypeOf(obj)
let newObj = Object.create(proto)
然后是只读属性或者是修改过属性的值,可以用Object.getOwnPropertyDescriptor()
和Object.defineProperty()
,前者作用是返回指定对象上一个自有属性对应的属性描述符;后者作用是直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
const obj = {
get foo() { return 2; }
}
const newObj = {}
let rule = Object.getOwnPropertyDescriptor(obj, 'foo')
Object.defineProperty(newObj, 'foo', rule)
然后合在一起处理就是
switch(type) {
case '[object Object]':
let proto = Object.getPrototypeOf(arg)
const result = Object.create(proto)
for (let item in arg) {
let rule = Object.getOwnPropertyDescriptor(arg, item)
rule.value = rule.value && deepClone(rule.value)
Object.defineProperty(result, item, rule)
}
return result
}
Dom
克隆
Dom可能是几个节点的集合,也可能是单一的节点。因为节点的集合属于HTMLCollection
接口,而这个接口是会自动更新的,所以不考虑这个。而对于单一节点,根据其节点名称的不同,toString
返回的结果也不相同,所以我们使用节点的一个属性nodeTpy
来判断其是否是dom节点。
这里还要用到一个内置方法Node.cloneNode()
,其返回调用该方法的节点的一个副本;接收一个参数deep
,参数如果为true,则该节点的所有后代节点也都会被克隆,如果为false,则只克隆该节点本身。
if (arg.nodeType && 'cloneNode' in arg) {
return arg.cloneNode(true)
}
总结
目前是做了基本的深度克隆,但是还有很多缺陷,比如没有考虑过循环引用以及层数过多时递归会爆栈,后面会继续做优化。
参考
JavaScript高级程序设计
MDN
JS最新基本数据类型:BigInt
ECMAScript (ECMA-262)
JavaScript中如何实现深度克隆