1. 为什么要封装空值判断工具函数
1.1 空值判断的日常痛点
在 JavaScript 项目里,空值判断大概是写起来最不起眼、但出错率最高的一类代码。我见过不少同事在业务代码里直接写 if (!value),然后被 0、空字符串、false 这些“合法值”坑得怀疑人生。这类一次性判断散落在各处,看似省事,实际上后期维护全是雷。
先看一个典型的翻车现场。假设你要判断用户是否填了手机号:
javascript复制function isPhoneValid(phone) {
return !!phone;
}
这段代码在用户填 0 开头或纯数字 0 时,直接返回 false,虽然手机号理论上不会出现这种值,但如果你处理的是订单金额、库存数量这类数值型字段,if (!value) 就会把 0、NaN 一并拦截掉。真实项目里我见过一次因为 0 被当成空值,导致价格展示异常的线上事故——排查了半天,最后发现就是判断逻辑把合法的 0 过滤了。
再比如 NaN。它是个奇特的存在,NaN === NaN 返回 false,Number.isNaN(NaN) 才是可靠判断。如果业务里有“数字为空”的校验需求,只判断 !value 远远不够。空值判断工具函数存在的意义,就是把这类语义明确、边界清晰的判断逻辑统一收口,让业务代码专注于业务本身,而不是反复和类型系统搏斗。
1.2 工具函数的设计思路
封装空值判断函数时,我会遵循几个原则:
- 语义单一:每个函数只做一件事,函数名必须能直观表达判断意图。
- 覆盖核心边界类型:
null、undefined、空字符串、空数组、空对象、NaN是最常见的“空值”形态。 - 配合 TypeScript 类型收窄:如果项目用了 TS,工具函数要返回类型谓词,让后续代码享受类型提示。
- 不偷懒地做深比较:业务里经常需要“深层空”判断,比如对象里所有属性都是空,或者数组里全是空对象。
设计上我倾向于分两层:底层是精确到单个值的原子判断,上层是组合判断的工具函数。底层负责“是什么”,上层负责“是否为空”。这样既保持了单测的覆盖粒度,又方便业务直接调用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心工具函数实现与解析
2.1 基础原子判断:null、undefined、nil
先补一个基础概念。JavaScript 里 null 和 undefined 是两个不同的值:
undefined表示“声明了但没赋值”,或者“访问不存在的属性时返回的值”。null表示“有意的空值”,通常由开发者主动赋值。
日常开发里很多时候不区分它们,所以 isNil 会非常实用。直接看实现:
javascript复制/**
* 判断是否为 null
*/
function isNull(value) {
return value === null;
}
/**
* 判断是否为 undefined
*/
function isUndefined(value) {
return value === undefined;
}
/**
* 判断是否为 null 或 undefined
*/
function isNil(value) {
return isNull(value) || isUndefined(value);
}
有朋友会问:为什么不直接用 == null?因为 value == null 会把 undefined 和 null 一起匹配,这在逻辑上没错,但代码规范里很多团队的 ESLint 配置会禁止 ==,强制 ===。而且 value == null 这种写法对刚入行的同事不太友好,读起来不够直观。我自己的习惯是写成 isNil,把意图写在函数名上,读代码的人一眼就懂。
2.2 基础原子判断:空字符串与空白字符串
判断空字符串,最常见的写法是 str === ''。但如果用户输入的是 " "(一串空格),这个判断就会漏掉。所以我会把“空字符串”和“空白字符串”分开处理。
javascript复制/**
* 判断是否为空字符串(长度为 0)
*/
function isEmptyString(value) {
return typeof value === 'string' && value.length === 0;
}
/**
* 判断是否为空白字符串(包含纯空格、tab、换行等)
*/
function isBlankString(value) {
return typeof value === 'string' && value.trim().length === 0;
}
trim() 会移除字符串首尾的空白字符,包括空格、制表符 \t、换行 \n、回车 \r 等。表单校验场景里,isBlankString 比 isEmptyString 更实用——用户输入几个空格,视觉上像没填,isEmptyString 判断不出来,但 isBlankString 能精确拦截。
我遇到过一种情况:文本框的内容是 "0",被人用 isEmptyString 判断后当成空值,导致提交被拦截。这就是典型的“空字符串判断”和“空值判断”混用导致的困惑。区分好边界很重要。
2.3 对象、数组、数字的“空”判断
接下来是对象和数组。typeof [] === 'object' 是 JavaScript 著名的历史包袱,所以数组判断必须用 Array.isArray()。空对象和空数组的判断如下:
javascript复制/**
* 判断是否为空对象(无自身可枚举属性)
*/
function isEmptyObject(value) {
if (value === null || typeof value !== 'object') {
return false;
}
if (Array.isArray(value)) {
return false;
}
return Object.keys(value).length === 0;
}
/**
* 判断是否为空数组
*/
function isEmptyArray(value) {
return Array.isArray(value) && value.length === 0;
}
Object.keys() 只返回对象自身的可枚举属性,不包括继承来的属性,所以 isEmptyObject 对原型链上扩展的方法无感。比如 Object.create({ inherited: true }) 这个对象,虽然原型上有属性,但自身是空的,Object.keys() 返回 [],判断结果就是空对象——这个行为在业务里通常是我们想要的。
这里有个细节值得展开:Date 对象、RegExp 对象、Map、Set 都满足 typeof value === 'object',但它们不是“普通对象”。我上面的实现把 Array 排除了,但还没排除 Date、RegExp 等内置类型。如果调用 isEmptyObject(new Date()),Object.keys() 返回 [],结果会被误判为空对象。所以更严谨的写法要考虑内置类型:
javascript复制/**
* 判断是否为“普通”对象(prototype 为 Object 或 null)
*/
function isPlainObject(value) {
if (Object.prototype.toString.call(value) !== '[object Object]') {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
}
function isEmptyObject(value) {
if (!isPlainObject(value)) {
return false;
}
return Object.keys(value).length === 0;
}
用 Object.prototype.toString.call(value) 是判断内置类型的经典方法,比 instanceof 更可靠,跨 iframe 场景也不会误判。
2.4 数字与 NaN 的判断
NaN 的坑值得单独拎出来说。typeof NaN === 'number',但它表示“无法表示的数字结果”。比如 0 / 0、parseInt('abc') 都会得到 NaN。如果业务里允许用户输入空值但不允许输入非法数字,判断逻辑就得区分开:
javascript复制function isNilOrNaN(value) {
return isNil(value) || (typeof value === 'number' && Number.isNaN(value));
}
这段代码的含义是:要么是 null/undefined,要么是“非法的数字结果”,都算作“空”。注意我用了 Number.isNaN 而不是全局的 isNaN,因为 Number.isNaN 不会强制把非数字类型的参数转成数字再判断。举个例子:
javascript复制Number.isNaN('abc'); // false
isNaN('abc'); // true
isNaN('abc') 会先把字符串 'abc' 转成数字,转不了就得到 NaN,所以返回 true。这不是我们想要的。Number.isNaN 更严格,只在类型是 number 且值为 NaN 时返回 true,符合直觉。
2.5 组合判断:万能 isEmpty
原子判断写完之后,业务侧最常用的一个函数是 isEmpty。它的职责是“判断传入的值是否为空”,但“空”的定义要按类型区分,不能用 !value 一刀切。
javascript复制/**
* 通用空值判断
* 空:null、undefined、空数组、空对象、空字符串、NaN
*/
function isEmpty(value) {
if (isNil(value)) {
return true;
}
if (typeof value === 'string') {
return value.length === 0;
}
if (Array.isArray(value)) {
return value.length === 0;
}
if (typeof value === 'number') {
return Number.isNaN(value);
}
if (isPlainObject(value)) {
return Object.keys(value).length === 0;
}
return false;
}
这套实现的关键是“按类型分派”:字符串只看长度,数组只看 length,数字只看是否为 NaN,对象只看自身可枚举键数量。这样既不会误伤合法的 0 和 false,又能覆盖常见的空值场景。
javascript复制isEmpty(null); // true
isEmpty(undefined); // true
isEmpty(''); // true
isEmpty(' '); // false(注意:不是空字符串)
isEmpty([]); // true
isEmpty({}); // true
isEmpty(NaN); // true
isEmpty(0); // false
isEmpty(false); // false
isEmpty('0'); // false
从结果可以看出,isEmpty(0) 是 false,这对业务来说更合理——你没法说数字 0 是“空值”,它是合法数值。
3. 实操过程:封装成独立的工具模块
3.1 在 TypeScript 中使用类型谓词
如果项目是 TypeScript,工具函数可以加上类型谓词,让类型系统在判断之后自动收窄类型。比如:
typescript复制/**
* 判断是否为 null 或 undefined,并以类型谓词返回
*/
function isNil(value: unknown): value is null | undefined {
return value === null || value === undefined;
}
类型谓词的语法是 value is T,它告诉 TypeScript:当这个函数返回 true 时,参数的类型就是 T。这样在 if (isNil(user)) 之后,user 的类型会被自动收窄为 null | undefined,后续代码里访问 user.name 会被编译器拦截,避免空指针错误。
类似的,也可以写 isNonNullable:
typescript复制function isNonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
这个函数在 Array.prototype.filter 里特别实用。项目里经常有这种代码:
typescript复制const list: (string | null)[] = ['a', null, 'b'];
// 错误写法:filter 之后类型仍然是 (string | null)[]
const filtered = list.filter((item) => item !== null);
// 正确写法:类型收窄为 string[]
const filtered = list.filter(isNonNullable);
前者虽然运行时结果正确,但 TypeScript 推断出的类型还是 (string | null)[],后面要用这些数据时还得再判空。用 isNonNullable 一次搞定,类型干净利落。
3.2 完整工具模块的代码组织
实际项目里我不会零散地到处定义这些函数,而是集中放在一个 utils/empty.ts(或者 is.ts)模块里,统一导出。下面是一个经过实战检验的版本:
typescript复制/**
* 空值判断工具函数集合
*/
// 判断是否为 undefined
export function isUndefined(value: unknown): value is undefined {
return value === undefined;
}
// 判断是否为 null
export function isNull(value: unknown): value is null {
return value === null;
}
// 判断是否为 null 或 undefined
export function isNil(value: unknown): value is null | undefined {
return value === null || value === undefined;
}
// 判断是否为非空(非 null 且非 undefined)
export function isNonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}
// 判断是否为字符串
export function isString(value: unknown): value is string {
return typeof value === 'string';
}
// 判断是否为数字(包括 NaN,但不包括 Infinity 的讨论场景)
export function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
// 判断是否为有限数字
export function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
// 判断是否为普通对象
export function isPlainObject(value: unknown): value is Record<string, any> {
if (Object.prototype.toString.call(value) !== '[object Object]') {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
}
// 判断是否为空字符串
export function isEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length === 0;
}
// 判断是否为空白字符串
export function isBlankString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length === 0;
}
// 判断是否为空数组
export function isEmptyArray(value: unknown): value is any[] {
return Array.isArray(value) && value.length === 0;
}
// 判断是否为空对象
export function isEmptyObject(value: unknown): value is Record<string, any> {
return isPlainObject(value) && Object.keys(value).length === 0;
}
// 通用空值判断
export function isEmpty(value: unknown): boolean {
if (isNil(value)) {
return true;
}
if (typeof value === 'string') {
return value.length === 0;
}
if (Array.isArray(value)) {
return value.length === 0;
}
if (typeof value === 'number') {
return Number.isNaN(value);
}
if (isPlainObject(value)) {
return Object.keys(value).length === 0;
}
return false;
}
模块化的好处是全局只需写一份,其他地方 import { isEmpty } from '@/utils/empty' 直接引用即可。配合 Tree Shaking,打包时未使用的函数会被自动清除。
3.3 配合 Optional Chaining 使用
ES2020 之后,可选链 ?. 和空值合并 ?? 让空值处理的体验大幅提升。工具函数可以和它们配合使用,形成一套完整的空值防御体系。
typescript复制// 用户信息可能为空,安全取值
const city = user?.address?.city ?? '未知城市';
// 判断用户是否提供了地址信息
const hasAddress = !isNil(user?.address) && !isEmptyObject(user!.address);
?. 的作用是“如果左侧值为 null 或 undefined,就返回 undefined,不再继续访问后续属性”。?? 的作用是“如果左侧值为 null 或 undefined,就取右侧值”。这俩和 isNil 的语义完全一致,都是处理 null/undefined,不会误伤 0 或空字符串。这和很多人的直觉不一样——0 || '默认值' 会得到 '默认值',但 0 ?? '默认值' 会得到 0。所以空值合并运算符比 || 更适合“只有空值才走默认”的场景。
4. 常见问题与排查技巧实录
4.1 为什么 typeof null 返回 "object"
这是 JavaScript 从诞生起就存在的历史问题。第一版实现里,JavaScript 用 type tag 的低三位表示类型,null 的 type tag 是 0,和对象的 type tag 相同,所以 typeof null 返回了 "object"。修复它不是做不到,而是修复后会导致海量线上代码崩溃,所以官方一直保留了这个问题。
这意味着判断 null 时不能依赖 typeof。我的工具函数里 isNull 用的是严格等于 value === null,这是唯一可靠的方式。
4.2 空值判断该不该处理继承属性
Object.keys(obj) 只返回自身属性,for...in 会包含继承属性。判断对象是否为空时,绝大多数业务场景关心的是“自身有没有可枚举属性”,所以 Object.keys().length === 0 是正确选择。
但如果你的项目里有人通过原型链注入过属性(比如给 Object.prototype 扩展过方法),for...in 会把它们也枚举出来,导致空对象被误判为非空。ESLint 的 guard-for-in 规则会限制 for...in 的使用,本质上就是在避免这类意外。所以工具函数里我统一用 Object.keys,不碰 for...in。
4.3 数组的空值判断别用 length 之外的方式
有些同学会写 value === [],这是判断题主常犯的错误。JavaScript 里数组是引用类型,每次的字面量 [] 都会创建一个新对象,严格比较时只比较引用地址:
javascript复制[] === [] // false
所以判断空数组必须用 .length === 0,想通过比较引用来判断是行不通的。另外,Array.isArray 是判断数组的唯一可靠手段,instanceof Array 在 iframe 或跨域 window 对象时会失效,因为不同 window 的 Array 构造函数不是同一个。
4.4 深度空值判断的场景补充
有些业务需要“深度为空”的判断。比如一个对象 { a: [], b: '' },虽然 Object.keys().length !== 0,但所有属性值都是空的。对于这类需求,可以写一个递归版本:
typescript复制/**
* 深度判断是否为空(递归检查对象/数组的每个值)
*/
function isEmptyDeep(value: unknown, seen = new WeakSet<object>()): boolean {
if (isNil(value) || typeof value !== 'object') {
return isEmpty(value);
}
if (seen.has(value)) {
return true; // 循环引用,视为空
}
seen.add(value);
if (Array.isArray(value)) {
return value.every((item) => isEmptyDeep(item, seen));
}
if (isPlainObject(value)) {
return Object.keys(value).every((key) => isEmptyDeep(value[key], seen));
}
return false;
}
这里用了 WeakSet 来处理循环引用。如果对象里有属性互相引用,不加处理的递归会无限循环下去,而 WeakSet 记录访问过的对象,再次遇到时直接返回,避免死循环。
需要注意,深度判断的性能开销要比普通判断大,因为它会遍历整个对象树。如果只是判断“有没有属性”,用 isEmptyObject 就够了,没必要上递归版。选择哪个函数,取决于业务语义。
4.5 实际项目中踩过的坑
- 表单校验里用
!value判断必填项:用户在数字输入框填0会被认为没填。我后来统一用isNil或isEmpty代替。 - 把
NaN当普通数字:用户输入1e3这种科学计数法字符串,parseFloat解析后是1000,没问题。但输入1e3abc,parseFloat返回1000,Number('1e3abc')返回NaN。不同解析函数行为不同,处理前要想清楚用什么。 - 对象属性值为
0被误判为空对象:isEmpty({ price: 0 })返回false,这是对的。关键要看你们团队的“空”定义是什么——是把合法0当成有值,还是当成没填。沟通成本比写代码本身更大。
空值判断这个领域,看着简单,真正把边界情况想清楚并不容易。我见过太多项目里因为空值判断不严谨导致线上 bug 的案例——比如列表页数据渲染失败、表单提交后后台收到脏数据、金额展示出现 undefined,等等。工具函数的价值不是代码量多少,而是把这类分散在各处的逻辑统一收口,让每个人在写业务时不用重新发明轮子。
我个人在项目中实践下来的体会是:先确定团队语义里“空”的定义,再落成工具函数;工具函数要小而专,配合 TypeScript 类型谓词效果最佳;最后在测试用例里把边界值(0、空字符串、false、NaN、空数组、空对象)全覆盖一遍。比如我自己的单测会有这样一批断言:
typescript复制expect(isEmpty(0)).toBe(false);
expect(isEmpty(false)).toBe(false);
expect(isEmpty('0')).toBe(false);
expect(isEmpty('')).toBe(true);
expect(isEmpty(NaN)).toBe(true);
expect(isEmpty([])).toBe(true);
expect(isEmpty({})).toBe(true);
这些断言排除了最常见的误判场景,后续新增需求时如果影响了空值语义,测试会立即变红提醒你。
要说后续还能怎么扩展,最自然的想法是把这个工具模块接到表单校验库上,让 isEmpty 成为自定义校验规则的基础,比如必填校验里“必须通过 isEmpty 才算空”。再进一步,可以结合 zod 这类 schema 校验库定义更丰富的规则。不过那是另一个话题了,先把空值判断这层地基打牢,上层业务才能盖得稳。
