深入解析 @pnpm/object.property-path:pnpm 内部的对象属性路径解析与读写基础库
2026/9/20 15:38:28 网站建设 项目流程
  • 包管理器
  • 开发工具
  • CLI

【免费下载链接】pnpm

Fast, disk space efficient package manager

项目地址:https://gitcode.com/gh_mirrors/pn/pnpm
点击查看免费下载

导读

本文围绕 pnpm 11 仓库中的@pnpm/object.property-path基础库展开,系统讲解它如何解析并读写"含点号(.)与下标([...])"的对象属性路径,覆盖路径语法、解析器实现、get/set/delete 三类读写操作、原型污染防护以及完整错误体系。读完本文,你将掌握该库的全部公开 API 与底层实现原理,并理解它如何支撑pnpm pkgpnpm config等真实命令的嵌套字段操作。

库的定位:用字符串路径操作嵌套对象

@pnpm/object.property-path是一个"Basic library to manipulate object property path which includes dots and subscriptions"(操作包含点号与下标的对象属性路径的基础库),位于仓库的 pnpm11/object/property-path 目录。它解决的是一个非常典型的工程问题:pnpm 的配置、package.json 清单、catalog 等都是深层嵌套结构(例如scripts.buildpackageExtensions["@babel/parser"].peerDependencies),命令行场景下需要一种字符串形式的路径语法来定位任意深度的字段,并完成读取、写入与删除。

这里的 "dots" 指foo.bar.baz这样的点号分隔写法,"subscriptions"(下标)指foo[0]foo["bar"]这样的方括号访问写法。库把字符串路径解析为string | number的段序列,再基于该序列完成对象操作。

从包元数据(package.json)可以看到:该包当前版本为1100.1.6,采用 ESM("type": "module"),要求node >= 22.13,唯一的外部依赖是@pnpm/error(用于定义标准化的 Pnpm 错误),源码入口统一从 src/index.ts 导出。

安装与包信息

安装方式(与 README 一致):

pnpm add @pnpm/object.property-path

其他包信息:

  • 名称:@pnpm/object.property-path
  • 许可证:MIT
  • 包入口:lib/index.js,类型声明lib/index.d.ts
  • 运行环境:Node.js >= 22.13,ESM 模块

路径语法:从字符串到段序列

支持的写法

解析核心是parsePropertyPath,定义在 src/parse.ts 中。它是一个生成器函数,签名如下:

export function * parsePropertyPath (propertyPath: string): Generator<string | number, void, void>

即把字符串路径逐一产出为string(标识符/字符串字面量)或number(数字字面量)类型的段。函数文档注释给出了完整语法示例,结合 test/parse.test.ts 中的断言,可以得到如下等价关系:

路径字符串解析结果
''[](空路径)
foo['foo']
.foo['foo'](允许前导点)
["foo"]/['foo']['foo']
[ "foo" ]['foo'](方括号内允许空白)
foo.bar[0]['foo', 'bar', 0]
foo["bar"][0]['foo', 'bar', 0]
foo.bar["0"]['foo', 'bar', '0'](引号内是字符串,0是数字)
a .b .c .d['a', 'b', 'c', 'd'](点号两侧允许空白)

注意foo.bar["0"]foo.bar[0]的区别:带引号产出字符串段'0',不带引号产出数字段0。这个区分会直接影响读写语义——数字段会被当作数组下标处理。

连字符键名

包名、脚本名等经常包含连字符(如some-package-namebuild-prod),因此标识符解析(src/token/Identifier.ts)在首字符为字母或下划线(/[a-z_]/i)后,允许后续字符为[\w-],即字母、数字、下划线与连字符。于是:

parsePropertyPath('dependencies.some-package-name') // ['dependencies', 'some-package-name'] parsePropertyPath('scripts.build-prod') // ['scripts', 'build-prod']

这也呼应了源码注释中的设计意图:"Hyphens are in because package names are full of them"(npm 包名充满连字符)。

字符串字面量转义

方括号内的字符串字面量解析在 src/token/StringLiteral.ts 中,支持单引号与双引号两种引用方式,转义规则有限且严格,仅支持:\\\'\"\b\n\r\t。其他转义序列(如\u)会抛出UnsupportedEscapeSequenceError;字符串未闭合则抛出IncompleteStringLiteralError

数字字面量的限制

数字字面量解析在 src/token/NumericLiteral.ts 中。按源码注释,出于严格性考虑,当前不支持0x1A2E1e20123n这类十六进制、科学计数法或 BigInt 后缀写法,遇到字母后缀会抛出UnsupportedNumericSuffix

词法分析器与非法路径

路径解析分两层:先由 src/token/tokenize.ts 把字符串切成 token(点号、开闭方括号、标识符、数字字面量、字符串字面量、空白),再由 src/parse.ts 依据状态机约束 token 顺序。tokenize对无法识别的字符产出unexpectedtoken,最终由解析器抛出错误。测试中明确列出的非法路径包括:

'foo.bar.0' // 点号后跟数字字面量 → UnexpectedLiteralError 'foo.bar.baz.' // 尾部点号 → UnexpectedEndOfInputError 'foo.bar[0' // 方括号未闭合 → UnexpectedEndOfInputError 'foo.bar?.baz' // 不支持的 ? 字符 → UnexpectedTokenError 'foo.bar[baz]' // 方括号内出现标识符 → UnexpectedIdentifierError 'foo.bar..baz' // 连续点号 → UnexpectedTokenError 'dependencies.-foo' // 点号后跟连字符 → UnexpectedTokenError

值得一提的特性是生成器的"流式"语义:解析是惰性的,调用方可以逐段消费路径,遇到非法 token 时错误在消费到该位置才抛出(partial parse测试验证了这一点)。

核心 API:读取、写入、删除

库公开的读写函数都提供两种形态:接受已解析段序列Iterable<string | number>)的版本,以及直接接受路径字符串的版本(内部调用parsePropertyPath)。

读取:getObjectValueByPropertyPath

定义在 src/get.ts:

export function getObjectValueByPropertyPath (object: unknown, propertyPath: Iterable<string | number>): unknown export const getObjectValueByPropertyPathString = (object: unknown, propertyPath: string): unknown => ...

读取规则(test/get.test.ts 全部验证):

  • 遇到非对象、null、自身不存在的键(用Object.hasOwn判断)、或用非数字访问数组时,返回undefined,绝不抛异常;
  • 空路径返回对象本身:getObjectValueByPropertyPathString(obj, '')等价于obj
  • 不泄漏 JavaScript 内建属性:由于使用Object.hasOwn而非原型链查找,constructorlengthvalueOfprototype等原型属性一律返回undefinedpackages.length也不会意外读到数组长度;
  • 字符串上无法用下标取字符:getObjectValueByPropertyPathString('foo', '[0]')返回undefined

写入:setObjectValueByPropertyPath

定义在 src/set.ts:

export function setObjectValueByPropertyPath (object: ObjectOrArray, propertyPath: Iterable<string | number>, value: unknown): void

写入规则(test/set.test.ts 全部验证):

  • 自动创建中间容器setObjectValueByPropertyPathString({}, 'scripts.build', 'tsc')会生成{ scripts: { build: 'tsc' } }
  • 根据下一段类型决定数组还是对象:下一段是数字则建数组,是字符串则建对象。例如contributors[0].name生成{ contributors: [{ name: 'Alice' }] }
  • 形状不匹配时替换容器:如果中间节点已存在但形状不符(标量需要变容器、数组需要变对象、对象需要变数组),会替换为全新的容器,保证写入结果能通过JSON.stringify无损往返。例如{ scripts: 'echo hi' }上写scripts.test会把scripts整体替换为对象;
  • 空路径抛错EmptyPropertyPathError(错误码EMPTY_PROPERTY_PATH);
  • 写入用Object.defineProperty而非括号赋值,确保即使异常键漏过校验,也只会创建自有属性,不会触发原型 setter——这是防原型污染的最后一道保险(详见下文安全机制)。

删除:deleteObjectValueByPropertyPath

定义在 src/delete.ts:

export function deleteObjectValueByPropertyPath (object: ObjectOrArray, propertyPath: Iterable<string | number>): void

删除规则:

  • 路径不存在时静默无操作(no-op),不抛错;
  • 中间某段不是对象、为null、没有该自有键,或非数字键访问数组时,同样直接返回;
  • 数组元素用splice删除,不会留下null空洞;isArrayIndex还会严格校验数组下标(非负整数且为安全整数,字符串形式如"0"也接受);
  • 空路径直接返回(与 set 的抛错行为不同);
  • 同样会先做不安全键校验(见下文)。

不安全键防护:rejectUnsafeKeys

定义在 src/unsafeKeys.ts,setdelete在操作前都会调用它。它维护一个黑名单集合:

const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])

路径中只要出现上述任意键,就抛出UnsafePropertyPathKeyError(错误码UNSAFE_PROPERTY_PATH_KEY),从源头阻断通过__proto__.polluted这类路径进行的原型污染攻击。测试验证了三个键全部被拒绝,且对象上不会出现polluted属性。

错误体系一览

所有错误均继承自@pnpm/errorPnpmError(因此会带ERR_PNPM_前缀),便于 pnpm 统一的错误报告与排查。汇总如下:

错误类错误码触发场景
UnexpectedTokenErrorUNEXPECTED_TOKEN_IN_PROPERTY_PATH语法错误字符(?-等)或非法 token 顺序
UnexpectedIdentifierErrorUNEXPECTED_IDENTIFIER_IN_PROPERTY_PATH方括号内出现标识符(如foo.bar[baz]
UnexpectedLiteralErrorUNEXPECTED_LITERAL_IN_PROPERTY_PATH点号后跟数字/字符串字面量(如foo.bar.0
UnexpectedEndOfInputErrorUNEXPECTED_END_OF_PROPERTY_PATH尾部点号、未闭合方括号
EmptyPropertyPathErrorEMPTY_PROPERTY_PATH用空路径执行 set
UnsafePropertyPathKeyErrorUNSAFE_PROPERTY_PATH_KEY路径含__proto__/constructor/prototype
UnsupportedEscapeSequenceErrorUNSUPPORTED_STRING_LITERAL_ESCAPE_SEQUENCE字符串字面量含不支持的转义
IncompleteStringLiteralErrorINCOMPLETE_STRING_LITERAL字符串未闭合
UnsupportedNumericSuffixUNSUPPORTED_NUMERIC_LITERAL_SUFFIX数字字面量带字母后缀

在 pnpm 生态中的真实应用

该库不是孤立的工具包,而是 pnpm 11 中若干命令的底层依赖:

  • pnpm pkg get / set / delete:命令实现位于 pkg.ts,它对package.json清单直接调用getObjectValueByPropertyPathStringsetObjectValueByPropertyPathStringdeleteObjectValueByPropertyPathString。因此你可以这样操作嵌套字段:

    pnpm pkg get scripts.build pnpm pkg set scripts.build=tsc pnpm pkg set contributors[0].name=Alice --json pnpm pkg delete scripts.build

    其中--json模式下值会先经JSON.parse再写入,支持数组等结构化值;--recursive/-r--filter可对工作区多个项目批量执行。

  • pnpm config get <key>:实现位于 configGet.ts,当键包含.[(或为空串)时,会被判定为属性路径并走lookupByPropertyPath,将配置扁平化(configToRecord)后交给getObjectValueByPropertyPath读取。例如读取 catalog 或 packageExtensions 等嵌套配置时,就能用点号/方括号路径直接定位。

  • pnpm config set的键校验:实现位于 configSet.ts,它用parsePropertyPath解析用户传入的键:空路径抛出CONFIG_SET_EMPTY_KEY,深度超过 1 段则抛出CONFIG_SET_DEEP_KEY("Setting deep property path is not supported")——即config set刻意只允许单层键,深层路径目前仅供config get读取。

  • 配置路径的 camelCase 适配:parseConfigPropertyPath.ts 在parsePropertyPath之上做了包装——把首段统一转换为 camelCase,以匹配配置内部configToRecord产出的驼峰键。

从这些调用关系可以看出,该库承担着 pnpm 命令与用户输入之间的"路径语言"职责:用户写路径字符串,库负责解析与安全读写。

测试覆盖与质量保障

包的测试位于 pnpm11/object/property-path/test 目录,与源码一一对应:

  • parse.test.ts:合法/非法路径、连字符键、流式部分解析;
  • get.test.ts:路径存在/不存在、原型属性不泄漏、非对象输入、字符串不可按下标访问;
  • set.test.ts:中间容器创建、形状替换、数组/对象互相转换、覆盖写、不安全键拒绝、空路径抛错;
  • 另有 delete 测试与 token 级单元测试(Identifier、NumericLiteral、StringLiteral、tokenize)。

这些测试既是对外契约的固化,也是理解库行为边界的权威参考。包脚本中test命令为pn compile && pn .test,使用 Jest(preset 为@pnpm/jest-config)运行。

小结

@pnpm/object.property-path虽是一个"基础库",却在 pnpm 11 的命令层扮演关键角色:它定义了统一、严格、安全的属性路径语法,提供解析、读取、写入、删除四类能力,并通过Object.hasOwn自省读取、Object.defineProperty安全写入、__proto__/constructor/prototype黑名单三重机制防范原型污染。理解它,也就理解了pnpm pkgpnpm config get等命令背后"用字符串操作嵌套对象"的完整链路——无论是希望在自己的工具中复用这类路径语法,还是深入 pnpm 源码,这个库都是值得研读的范本。

License

MIT

  • 包管理器
  • 开发工具
  • CLI

【免费下载链接】pnpm

Fast, disk space efficient package manager

项目地址:https://gitcode.com/gh_mirrors/pn/pnpm
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询