打开任何一个前端项目,只要代码量到一定程度,里面藏着一堆手写类型判断基本是逃不掉的。最常见的就是 typeof xxx === 'object' 就当成普通对象处理,或者 instanceof Array 判定数组。这类代码在本地跑得好好的,一上生产环境,某些接口返回了 null,或者从另一个 iframe 里拿了个数组过来,整个判断链就崩了。所以这些年我越来越倾向于在项目里维护一套独立的工具函数,专门做类型检测与断言。这套东西早期就是几个 isXxx 函数,后来慢慢补齐了断言、空值语义、TypeScript 类型守卫,才变成一个能稳定复用、也能拿出来讲的完整模块。
这篇就想把这套封装的完整思路讲透:为什么不能用原始方法直接判断、Object.prototype.toString 为什么是公认的标尺、断言函数和普通判断函数到底差在哪、TypeScript 下怎么写才能让类型检查也跟上,以及封装过程中我实际踩过的坑。适合正在整理自己工具库的同学,也适合被某个“看着是对象其实不是对象”的问题坑过、想一次性解决的人。
1. typeof 与 instanceof 的边界,以及为什么需要自己做一层
1.1 typeof 的四个“经典误判”
先说 typeof。它速度很快、语法简单,所以很多人第一步就选它。问题在于它返回的结果粒度太粗,业务代码里根本不够用。
javascript复制typeof 42; // 'number'
typeof 'hello'; // 'string'
typeof function(){}; // 'function'
typeof undefined; // 'undefined'
typeof { foo: 1 }; // 'object'
typeof null; // 'object' ← 历史遗留 bug,不是刻意设计
typeof []; // 'object' ← 完全没区分数组
typeof new Date(); // 'object' ← 日期也是 object
typeof /regex/; // 'object' ← 正则也是 object
typeof new Map(); // 'object'
这四个“经典误判”里,最坑的是 typeof null === 'object'。这不是什么设计意图,纯粹是早期实现里 null 的内部标记是 0,正好和对象标记撞了,后来为了兼容就一直没改。其次就是数组、正则、日期这些内置对象,typeof 统一给一个 object,等于告诉你有值,但不告诉是什么。
还有一个容易忽略的:typeof NaN === 'number'。严格说这不是误判,NaN 确实是 number 类型。但业务里判断一个数字的时候,通常不希望拿到 NaN 还继续算下去。所以单靠 typeof 判断数字,等于半个判断,后续还得再查 Number.isNaN。
1.2 instanceof 的两个硬伤
instanceof 是另一条常见路线。它的原理是沿原型链查找,看构造函数的 prototype 是否出现在目标对象的原型链上。
javascript复制[] instanceof Array; // true
({}) instanceof Object; // true
new Date() instanceof Date; // true
但它有两个很硬的缺陷,在实际项目里会直接导致线上 bug。
第一个是跨全局执行环境失效。浏览器里存在多个 window,比如一个 <iframe>。iframe 里的数组是 iframe 自己的 Array 构造出来的,它的原型链上挂着的是 iframe 里的 Array.prototype,和主窗口的 Array.prototype 不是同一个对象。这时候:
javascript复制const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const iframeArray = iframe.contentWindow.Array.of(1, 2, 3);
iframeArray instanceof Array; // false ← 明明是个数组
Array.isArray(iframeArray); // true
也就是说,只要你项目里接入了第三方 iframe、webview、electron 渲染进程之类场景,instanceof 就可能误判。
第二个是原型链可以被人为改动。Array.prototype.constructor 可以被覆盖,或者你在某些兼容性代码里重写过原型链,instanceof 的结果就和预期不一致。虽然大多数项目不会真的去篡改内置原型,但只要有人这么干过,instanceof 就不是一个可靠的约定。
1.3 Object.prototype.toString 才是真正可依赖的“标尺”
所有纠结到最后,业内基本都收敛到同一个方案:
javascript复制Object.prototype.toString.call(value);
这个调用返回一个形如 '[object TypeName]' 的字符串。它不依赖原型链,也不依赖全局变量,而是读取对象内部的 [[Class]](或现代引擎里的 Symbol.toStringTag)信息,所以跨 iframe、跨 window 基本都能给出稳定结果。
javascript复制Object.prototype.toString.call(42); // '[object Number]'
Object.prototype.toString.call('a'); // '[object String]'
Object.prototype.toString.call(null); // '[object Null]'
Object.prototype.toString.call(undefined); // '[object Undefined]'
Object.prototype.toString.call([]); // '[object Array]'
Object.prototype.toString.call({}); // '[object Object]'
Object.prototype.toString.call(/re/); // '[object RegExp]'
Object.prototype.toString.call(new Map()); // '[object Map]'
Object.prototype.toString.call(new Date()); // '[object Date]'
这就等于给了我们一把统一的尺子。只要把 [object ] 去掉、再转成小写,就得到一个稳定的类型名,用它来做后续所有 isXxx 判断的地基。这也是我最终决定自己封装一整套的核心原因——原生方法不是不能用,而是组合起来问题太多,与其每个文件里各写各的,不如集中收口到一套工具函数里,把边界问题一次解决干净。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用 Object.prototype.toString 搭一套基础检测函数族
2.1 先封装一个底层 toType 方法
所有 isXxx 判断的最底层,我会先做一个 toType。它不直接对外暴露太多复杂逻辑,主要就是把 Object.prototype.toString 的返回结果处理成一个统一的小写字符串。
typescript复制// src/utils/type.ts
const toString = Object.prototype.toString;
/**
* 返回值的标准化类型名,如 'number'、'array'、'null'、'map'
*/
export function toType(value: unknown): string {
return toString.call(value).slice(8, -1).toLowerCase();
}
slice(8, -1) 的意思是去掉前 8 个字符 '[object ',去掉最后一个 `'
