上篇文章分析了在 Lodash 中如何可靠地检测对象类型(纯对象)。在 JS 中,引用类型不仅仅有纯对象,常见的还有数组、函数、类数组对象等等。除了引用类型,还有一个大类叫做基本数据类型,包括数值、字符串、布尔值、null、undefnied、Symbol。
所以,在这一篇文章中,就要来分析在 Lodash 中如何判断其他常见的数据类型的?以及在原生 JS 中,这些数据类型又存在哪些“坑”?
Part 1. 数值、字符串、布尔值、Symbol
先来看这三个最基本的类型。
OK,它们三种类型可以被 typeof 操作符准确地检测。但是,在分析 Lodash 源码时,我发现 Lodash 可不是只利用 typeof 进行地检测。
function isNumber(value) {
return typeof value == 'number' ||
(isObjectLike(value) && baseGetTag(value) == '[object Number]')
}
先看 isNumber() 函数。可以看到,Lodash 用到了 isObjectLike() 和 baseGetTag() 函数,这两个函数在上一篇文章中已经分析过了。
function isString(value) {
const type = typeof value
return type == 'string' || (type == 'object' && value != null && !Array.isArray(value) && getTag(value) == '[object String]')
}
function isBoolean(value) {
return value === true || value === false ||
(isObjectLike(value) && baseGetTag(value) == '[object Boolean]')
}
再看看,我们发现 Lodash 在判断数值、字符串、布尔值都用到了 isObjectLike() 和 baseGetTag() 函数。Why?
这得从 JS 语言本身来考虑。
在 JS 中,可以通过字面量的形式来直接创建数值、字符串、布尔值:
var num = 1;
var str = 'a';
var flag = true;
这种形式创建的数据均可以直接被 typeof 操作符检测,不会存在歧义:
typeof num // => number
typeof str // => string
typeof flag // => boolean
但是,JS 允许我们通过内置函数 Number()、String()、Boolean() 来创建相应的数据,只不过这时用 typeof 检测不到,只能返回 ‘object’:
typeof new Number(1) // => object
typeof new String('a') // => object
typeof new Boolean(true) // => object
这从开发者的感知上会产生歧义,这是原生 JS 存在的问题。问题的原因在于 new 操作符本身的原理是创建一个对象并返回(这里不展开,会在其他专题中总结),所以导致了 typeof 检测为 ‘object’。
在开发中最佳实践是要求我们避免这样创建基本数据类型。但是,Lodash 采用了主动出击的方式,在源码中认为以 new 形式创建的基本数据类型依旧应该是相应的数据类型。这就解释了这一行代码存在的意义:
isObjectLike(value) && baseGetTag(value) == '[object Number]'
对于 Symbol,Lodash 也采用了同样的处理方式:
function isSymbol(value) {
const type = typeof value
return type == 'symbol' || (type == 'object' && value != null && getTag(value) == '[object Symbol]')
}
Part 2. null 与 undefined
判断这两货是非常容易的,可以直接利用 === 关系。
function isNull(value) {
return value === null
}
function isUndefined(value) {
return value === undefined
}
提个醒:void 0 是个标准的 undefined 值。
在 JS 中,我们知道 null 与 undefined 是相等的,即:
null == undefined // => true
为什么呢?它们存在转换?
答案是不。**在 ECMA-262 中规定 null 与 undefined 的相等性测试要返回 true。**诶,没错,这是规定!
如果你不信,再看看 == 的转换规则:
1、布尔值转为数值;
2、字符串与数值相比,将字符串转为数值;
3、对象,调用 valueOf() 得到结果后按照前面的规则转换;
4、null 与 undefined 是相等的;
5、null 与 undefined 在比较相等性时不可以转换为任何值。
再看看例子:
null == 0 // => false
undefined == false // => false
Lodash 除了提供 isNull() 和 isUndefined() 函数,还提供了一个 isNil() 函数:
function isNil(value) {
return value == null
}
Part 3. 增强类 API
Lodash 除了判断基本数据类型,还增加了对 JS 中许多特定的类型做出判断的 API。
1、isArrayLike()
Lodash 对于 ArrayLike 类数组的定义是:
A value is considered array-like if it’s not a function and has a
value.length
that’s an integer greater than or equal to0
and less than or equal toNumber.MAX_SAFE_INTEGER
.
首先,类数组不是一个函数。最重要的是,它具有 length 属性,其值是一个大于等于 0 并且小于等于 MAX_SAFE_INTEGER 最大安全正整数。
function isArrayLike(value) {
return value != null && typeof value != 'function' && isLength(value.length)
}
这里涉及 isLength() API,来看一下:
function isLength(value) {
return typeof value == 'number' &&
value > -1 &&
value % 1 == 0 && value <= MAX_SAFE_INTEGER
}
提个醒:MAX_SAFE_INTEGER 是多少?
在 ES6 中引入两个常量值:Number.MAX_SAFE_INTEGER 和 Number.MIN_SAFE_INTEGER 表示 JS 能精确表示的安全正整数范围。
Number.MAX_SAFE_INTEGER === Math.pow(2, 53) - 1
// => true
Number.MIN_SAFE_INTEGER === 1 - Math.pow(2, 53)
// => true
再来看看哪些常见的满足 ArrayLike 定义的数据类型,以下都将返回 true:
// arguments 对象
_.isArrayLike(function(){return arguments}());
// 数组
_.isArrayLike([1, 2, 3])
// NodeList
_.isArrayLike(document.body.children)
// 字符串
_.isArrayLike('abc')
Lodash 中还有一个 isArrayObjectLike() API,它其实就是 isObjectLike() 和 isArrayLike() 的并集:
function isArrayLikeObject(value) {
return isObjectLike(value) && isArrayLike(value)
}
这样来看,字符串就不满足 isArrayLikeObject()。
_.isArrayLikeObject('abc')
// => false
2、isEmpty()
在 Lodash 中是这样定义 Empty 的:
Checks if
value
is an empty object, collection, map, or set.Objects are considered empty if they have no own enumerable string keyed properties.
Array-like values such as
arguments
objects, arrays, buffers, strings, or jQuery-like collections are considered empty if they have alength
of0
. Similarly, maps and sets are considered empty if they have asize
of0
.
对于对象来说,Lodash 认为没有可枚举的字符串键值就属于 Empty;对于数组或者类数组来说,length 属性为 0 就属于 Empty;以及 size 属性为 0 的 Set 和 Map。
function isEmpty(value) {
if (value == null) {
return true
}
if (isArrayLike(value) &&
(Array.isArray(value) || typeof value == 'string' || typeof value.splice == 'function' ||
isBuffer(value) || isTypedArray(value) || isArguments(value))) {
return !value.length
}
const tag = getTag(value)
if (tag == '[object Map]' || tag == '[object Set]') {
return !value.size
}
if (isPrototype(value)) {
return !Object.keys(value).length
}
for (const key in value) {
if (hasOwnProperty.call(value, key)) {
return false
}
}
return true
}
在 isEmpty() 的源码中,重点来说一下 isPrototype()。这是一个内部函数。
const objectProto = Object.prototype
function isPrototype(value) {
const Ctor = value && value.constructor
const proto = (typeof Ctor == 'function' && Ctor.prototype) || objectProto
return value === proto
}
这个函数用于判断一个值是否是原型对象。它的实现逻辑还是很容易理解的,就是判断 value 等不等于 value.constructor.prototype,如果等于,那么这个 value 就是原型对象。(关于原型的知识,这里不做阐述)
关键是它在 isEmpty() 中的作用是什么?即下面这段代码在 isEmpty() 中的作用是什么?
if (isPrototype(value)) {
return !Object.keys(value).length
}
这个问题我现在不是很能理解,为什么要把原型对象单独拿出来?而不是合在下面的 in 操作符中去处理,并且我知道在这里的 Object.keys() 确实只迭代对象自身的可枚举属性。
我们再来分析一下,判断可枚举性是如何在 isEmpty() 中实现的?
它就是利用了 in 操作符和 Object.hasOwnProperty() 这两个 JS 原生的语法进行的判断。in 操作符会迭代一个对象中所有可枚举的属性,包括从原型链上继承而来的属性,所以需要通过设置一层 Object.hasOwnProperty() 来判断某个属性是否是自身的。
3、isNative()
这个 API 用来判断是否是 JS 的一个原生函数。这里举个例子:
_.isNative(Array.prototype.push)
// => true
来看一下实现:
const reRegExpChar = /[\\^$.*+?()[\]{}|]/g
const reIsNative = RegExp(`^${
Function.prototype.toString.call(Object.prototype.hasOwnProperty)
.replace(reRegExpChar, '\\$&')
.replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g, '$1.*?')
}$`)
function isNative(value) {
return isObject(value) && reIsNative.test(value)
}
(⊙o⊙)… 正则表达式,这一块触及到我的知识盲区了。来一点一点的分析。
首先,Function.prototype.toString() 是继承自 Object.prototype.toString(),并且重写,作用是返回一个代表函数的字符串。
Function.prototype.toString.call(Object.prototype.hasOwnProperty)
// => "function hasOwnProperty() { [native code] }"
接着:
Function.prototype.toString.call(Object.prototype.hasOwnProperty).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
// => "function hasOwnProperty\(\) \{ \[native code\] \}"
这里可以理解为将 () {} []
前添加 \
,目的是转义这些符号。
再来:
Function.prototype.toString.call(Object.prototype.hasOwnProperty)
.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
.replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g, '$1.*?')
// => "function.*?\(\) \{ \[native code\] \}"
最终通过 RegExp() 将 "function.*?\(\) \{ \[native code\] \}"
生成一个正则表达式,就可以去判断一个函数是否是原生 JS 的内部函数了。其实分析到这里整个思路已经清晰了,对于正则表达式的细节,不是这篇文章探讨的。
4、isElement()
这个 API 是用来判断 DOM 元素的。
function isElement(value) {
return isObjectLike(value) && value.nodeType === 1 && !isPlainObject(value)
}
关于 isObjectLike() 和 isPlainObject() 在上一篇文章中分析过了,也就是说判断一个值是 DOM 元素,它不能是一个纯对象,但是是一个类对象,并且具有 nodeType 值为 1 的属性。
5、其他
除上面的一些 API,还有一些高级类型判断 API,比如:isEqualWith(), isMatch(), isMatchWith()。这三个 API 将放在后续其他专题中去分析。
关于 isTypedArray() 也会在我学习 TypedArray 之后再来分析。
另外,以下类型可以直接通过 Object.prototype.toString.call() 来判断:Set, Map, WeakSet, WeakMap, RegExp, Function, Array, Error, Date, ArrayBuffer, arguments。这些类型就不再一一分析。
Part 4. 小结
今天分析了 Lodash 中如何判断基本数据类型,以及 Lodash 其他的增强型的类型判断 API。
纵观 Lodash 整套判断数据类型的 API,可以说现在 ES6 官方推荐的 Object.prototype.toString() 方法已经可以满足绝大多数开发者的需求,这个原生 API 也是 Lodash 判断数据类型的核心思想。
在分析的过程中,我的偏向主要还是回到 JS 里去寻求根本原因。比如上一篇文章分析 isPlainObject() 时,往原型与原型链、继承的方向去思考;又比如这一篇文章中对形如 new Number() 这样的语言本身缺陷的思考。
最后,我还有一点体会就是:有时,你可能真的不需要第三方工具库。随着 ES6+ 的普及与实现,原生 API 可能在某种程度上已经满足你的需求。
关于 Lodash 判断数据类型的文章就告一段落,后续会对这两篇文章不断地修改,因为还有一些谜题没有解决(比如上一篇文章中提出的 typeof 是如何判断函数的?这篇文章中的 isPrototype() 在 isEmpty() 中起到的什么作用?)。
如果你已经有思路了,或者说有其他见解。欢迎 Email 给我:
文章首发在微信公众号 cameraee,另外文章同步在我的 优快云 上,优快云 传送门:
https://blog.youkuaiyun.com/swpu_leo
转载请注明
Next
分析 Lodash 源码的下一站,Array。
关注
文章首发在微信公众号 cameraee