从 Lodash `has` 迁移到 Remeda `hasProp`:深层路径与单键检查的差异全解析
从 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
hasaccepts deep paths ("a.b.c"or["a", "b", "c"]). Remeda'shasProponly 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);
}
两个关键事实:
- 判定标准是
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 在“属性归属”上采取了更严格的立场。
- 依赖
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 文件中找到可运行的验证用例。