JS类型判断与深度克隆

要进行深度克隆,首先就需要知道进行克隆的这个变量是什么类型的值,知道了是什么类型的,我们才能分门别类的去根本不同的类型进行克隆。所以我们先介绍如何进行准确的类型判断。

类型判断

首先我们需要知道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 - BigIntJS最新基本数据类型: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循环理解一下varlet

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

总结

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

可以看出来除了nullundefined都可以使用constructor属性得到其的构造函数,也就是准确的类型。

  • 注意:数值不能直接使用constructor
console.log(1.constructor) // Uncaught SyntaxError: Invalid or unexpected token
console.log((1).constructor) // Number

总结

constructor可以判断除了nullundefined外的所有变量的类型。

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来调用呢?

首先一点是,ObjecttoString方法会返回一个[object ${calss}]的形式的字符串,在ecma中是这样说的:“Return the String value that is the result of concatenating the three Strings “[object “, class, and “]”.”;翻译过来就是“返回字符串值,该值是将三个字符串“[object”,class和“]”连接在一起的结果”。所以我们可以使用这个方法去判断变量类型。

其次是,大部分的类型都重写了ObjecttoString方法,也就是每个类型的变量去直接调用结果的表现都是不一样的,所以我们需要通过call去调用ObjecttoString方法。

总结

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

这一步有几点需要注意一下, 一个是functiontypeof fun === 'function',而对于function来说,直接复制是没有问题的,所以不需要考虑这个。
还有一个是document.alltypeof 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中如何实现深度克隆

文章作者: JaCo Wu
文章链接: https://jacokwu.cn/blog/2020/07/30/JS类型判断与深度克隆/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 JaCo Wu的博客