从 Lodash `has` 迁移到 Remeda `hasProp`:深层路径与单键检查的差异全解析

原创2026-10-12 11:42:29256 阅读
文章标签:开发工具

从 Lodash has 迁移到 Remeda hasProp:深层路径与单键检查的差异全解析

本指南聚焦于 Remeda 官方迁移文档(packages/docs/src/content/mapping/lodash/has.md)所记录的 Lodash has → Remeda hasProp 映射关系,深入对比两者在路径支持、属性判定语义上的根本差异,并结合 hasProp.ts 源码、类型测试与运行时测试,说明迁移时的改写策略与替代方案。读完本文,你将能够准确判断何时直接替换 hasProp、何时改用 hasSubObject 或 prop 组合来等价还原 Lodash 的深层路径能力。

迁移背景:Remeda 如何组织“从 Lodash 迁移”对照文档

Remeda 文档站维护了一套跨库迁移映射集合(mapping collection)。每个映射文件以目标函数名命名,通过 frontmatter 声明所属分类与对应的 Remeda 函数。该集合的加载与校验逻辑定义在 packages/docs/src/content/mapping/content.config.ts:

  • category:功能分类(如 Object),用于在迁移页中按分类聚合展示;
  • remeda:指向 Remeda 官方函数文档的引用,例如 has.md 中声明 remeda: hasProp。

页面渲染时,mapping-function-card.astro 会同时展示“被迁移库的函数名 + 对应 Remeda 函数名”的跳转链接与正文说明;entries.ts 负责把散落的 md 文件按分类分组、排序,供 migrate/[library].astro 生成各库的迁移总览页。lodash/has.md 正是 Lodash 迁移对照表中的一条映射记录,其正文用最精炼的篇幅点出了迁移时最关键的语义差异。

核心差异:Lodash has 支持深层路径,hasProp 只查单键

原映射文档的核心结论只有一条,但它是迁移踩坑的高发区:

Lodash's has accepts deep paths ("a.b.c" or ["a", "b", "c"]). Remeda's hasProp only checks a single key.

对照代码:

// Lodash
has(obj, "a");

// Remeda
hasProp(obj, "a");

也就是说,Lodash 的 has(obj, "a.b.c") 会沿着 a → b → c 逐层判定是否存在,而 Remeda 的 hasProp 只做“第一层”的单键检查。如果从 Lodash 迁移时直接把 has(obj, "a.b.c") 机械地改成 hasProp(obj, "a.b.c"),将得到完全错误的结果——hasProp 检查的是对象上是否存在字面键 "a.b.c",而非路径 a.b.c。

同样的差异也出现在 Just 的迁移对照中(见 packages/docs/src/content/mapping/just/has.md):Just 的 has 同样接受深层路径(如 ["a", "b"]、"a.b")。因此这条“深层路径 vs 单键”的提醒不是 Lodash 独有,而是函数式工具库生态中的普遍设计取向。

hasProp 的实现原理:基于 Object.hasOwn 的自身属性判定

为了正确迁移,先要理解 hasProp 到底“检查什么”。其核心实现位于 packages/remeda/src/hasProp.ts#L85-L94:

export function hasProp(...args: readonly unknown[]): unknown {
  return purry(hasPropImplementation, args);
}

function hasPropImplementation<T extends object, Key extends KeysOfUnion<T>>(
  data: T,
  key: Key,
): data is HasProp<T, Key> {
  return Object.hasOwn(data, key);
}

两个关键事实:

  1. 判定标准是 Object.hasOwn:它只检查自身属性(own properties),完全跳过原型链上继承来的属性。这与 Lodash 的 has(同样基于自身属性,但支持路径)以及 hasIn(使用 in 操作符、包含继承属性)都不同。原文档对应的 hasIn 迁移记录(packages/docs/src/content/mapping/lodash/hasIn.md)专门用两个例子强调这一点:
// Lodash hasIn —— 继承属性也会命中
hasIn({}, "toString"); //=> true

// Remeda hasProp —— 只认自身属性
hasProp({}, "toString"); //=> false

Ramda 的 hasIn 对照(packages/docs/src/content/mapping/ramda/hasIn.md)给出了同样语义的例子,说明 Remeda 在“属性归属”上采取了更严格的立场。

  1. 依赖 purry 机制:hasProp 经由 purry.ts 同时支持数据优先(data-first)与数据最后(data-last)两种调用形式——hasProp(obj, "a") 与 pipe(obj, hasProp("a")) 等价。

运行时测试 hasProp.test.ts 完整覆盖了上述语义边界:

  • 自身属性存在返回 true(hasProp({ a: 1 }, "a"));
  • 值为 undefined 的属性依然返回 true(hasProp({ a: undefined }, "a")),注意这与 in 判定的“可访问”语义不同,hasProp 判定的是“键是否存在”而非“值是否可读”;
  • 可选属性缺失返回 false(hasProp({} as { a?: number }, "a"));
  • 原型链属性返回 false:toString 以及通过 Object.create({ inherited: 1 }) 挂到自定义原型上的属性都不命中;
  • null 原型对象(Object.create(null))上的自身属性正常命中;
  • 支持 Symbol 键与数字键;
  • 数组场景:存在的索引返回 true,越界索引返回 false,自身属性 length 返回 true,而继承的 push 等原型方法返回 false。

类型层面:hasProp 是类型守卫,会收紧属性的可选性

hasProp 不只返回布尔值,它是一个类型谓词(type predicate)。在 hasProp.test-d.ts 中可以完整看到其类型窄化行为:

  • 去除可选性:{ a?: string; b: number } 经 hasProp(data, "a") 窄化后,真分支变为 { a: string; b: number };
  • 联合类型分发:{ a: number } | { b: string } 检查 "a" 后,真分支只剩 { a: number },假分支只剩 { b: string }——不含该键的联合成员会被剔除;
  • 索引签名收窄:Record<string, number> 检查字面量键 "foo" 后,真分支被补全为 { [x: string]: number; foo: number };
  • Symbol 键、数字键、readonly 修饰符都能被保留并窄化;
  • 字面量键有界时触发窄化,无界键(如 string、模板字符串类型)回退为纯 boolean 重载,避免把假分支塌缩成 never;数组输入同样走纯 boolean 重载(数组的 keyof 包含 push、pop 等运行时并不存在的自身属性,若走窄化重载会把假分支错误地塌缩为 never)。

类型实现依赖 HasProp<T, Key> 工具类型(packages/remeda/src/hasProp.ts#L13-L23),它会在 Key extends keyof T 时产出 Simplify<T & Required<Pick<T, Key>>>,即“把该键改为必选”。类型测试还记录了一个已知局限:对于 Map、Set 等内置类,keyof 包含原型方法(get、has),类型层面会把方法误当自身属性导致假分支窄化为 never,运行时 Object.hasOwn 却返回 false——这是 TypeScript 无法区分自身与继承属性所导致的既有限制(测试中以 known limitations 注明)。因此对 Map/Set/Date 等对象做键存在性检查时,建议谨慎依赖类型窄化结果。

迁移实战:如何还原 Lodash 的深层路径检查

既然 hasProp 只查单键,从 Lodash 迁移深层路径需求时该如何改写?以下是基于仓库源码的三种可行方案。

方案一:hasSubObject 按“子对象包含”语义检查

当深层检查的诉求是“目标对象是否包含某个子对象(所有键与值逐一相等)”时,Remeda 提供了专门的守卫函数 hasSubObject.ts。其语义是:对 subObject 的每个键值对,目标对象必须有该键且值经 isDeepEqual 相等:

hasSubObject({ a: 1, b: 2, c: 3 }, { a: 1, c: 3 }); //=> true
hasSubObject({ a: 1, b: 2, c: 3 }, { b: 4 });       //=> false
hasSubObject({ a: 1, b: 2, c: 3 }, {});             //=> true

实现上(packages/remeda/src/hasSubObject.ts#L109-L124)遍历 Object.entries(subObject),先 Object.hasOwn 判键、再 isDeepEqual 判值。注意两个边界:

  • 空子对象恒为 true;
  • subObject 中键存在但值为 undefined 时,与目标对象缺失该键判为 false(测试 hasSubObject.test.ts 中 { a?: undefined } 与 { a: undefined } 不匹配),因为 hasOwn 要求键真实存在。

它同样支持 data-last 形式:pipe(data, hasSubObject({ a: 1 }))。

方案二:prop 读取深层值 + 手动判空

若需要的是“沿路径取值后再判断”,Remeda 的 prop.ts 支持变长路径读取:prop(data, "a", "b", "c") 可逐层取值,且路径在类型层面会被校验、享受 IDE 自动补全。prop 对路径上任意缺失节点返回 undefined(实现中每层都检查 output === undefined || output === null)。可以组合出深层检查:

// Lodash: has(obj, "a.b.c")
// Remeda: 路径逐层读取 + 显式判空
prop(obj, "a", "b", "c") !== undefined;

需要留意 prop 与 hasProp 语义差异:值本身为 undefined 时 prop 返回 undefined 而 hasProp 仍返回 true,迁移时需按业务语义取舍。此外从源码结构看,prop 内部并未提供“安全取值后区分键存在与否”的封装,深层存在性判断本质上是“路径上每一段都存在”,因此对于中间段可能缺失的深层路径,上述 !== undefined 的写法是直接的等价实现。

方案三:逐层组合 hasProp(适用于中间层确定性已知)

如果深层路径的中间层在类型上已知必然存在、只需确认最末层键,可以直接多次调用 hasProp:

// 仅当 a、b 必然存在时,检查最末层 c
hasProp(obj, "a") && hasProp(obj.a, "b") && hasProp(obj.a.b, "c");

该写法保留了 hasProp 的 Object.hasOwn 严格语义,但中间层若缺失会抛错,仅适用于受控数据场景。

方案对比速查

迁移需求 Lodash 原写法 Remeda 改写 语义差异
单层键存在性 has(obj, "a") hasProp(obj, "a") 等价(同为自身属性判定)
深层路径存在性 has(obj, "a.b.c") prop(obj, "a", "b", "c") !== undefined 或逐层 hasProp 组合 需显式处理中间层缺失与 undefined 值
子对象整体包含 has + 手动比对 hasSubObject(obj, { a: 1 }) 深度相等语义,空子对象恒真
含继承属性的存在性 hasIn(obj, "a") 无直接等价 Remeda 刻意只认自身属性(Object.hasOwn)

小结

lodash/has.md 这份映射文档揭示的迁移要点可以概括为三句话:hasProp 是“单键 + 自身属性 + 类型守卫”的组合;Lodash 的深层路径能力需要改用 prop、hasSubObject 或逐层组合来等价还原;而 hasIn 涉及的继承属性语义在 Remeda 中被有意识地舍弃。迁移时先确认业务究竟需要“键存在”、“路径存在”还是“子对象包含”,再选择对应 API,即可避免最常见的坑。更完整的 Lodash → Remeda 迁移对照,可在 packages/docs/src/content/mapping/lodash 目录下按函数名逐一查阅,而 hasProp、hasSubObject、prop 三者的运行时与类型行为,均可在 packages/remeda/src 对应的 .test.ts 与 .test-d.ts 文件中找到可运行的验证用例。

登录后查看全文
remeda