☰
30 seconds of code:使用递归扁平化(Flatten)与还原(Unflatten)JavaScript 嵌套对象
2026/10/4 8:55:46 网站建设 项目流程
  • 教程
  • 文档

【免费下载链接】30-seconds-of-code

Coding articles to level up your development skills

项目地址:https://gitcode.com/gh_mirrors/30/30-seconds-of-code
点击查看免费下载

对象是 JavaScript 中最常用的数据结构之一,但在不同场景下,数据往往需要以不同的形态呈现:有时你需要把深层嵌套的对象「拍平」成带路径分隔符的扁平键值对(例如用于序列化、存储或表格展示),有时又需要把扁平数据还原回嵌套结构。本指南基于 30 seconds of code 仓库的 JavaScript 片段 flatten-unflatten-object.md,完整讲解基于递归策略实现对象扁平化与还原的算法思路、完整可运行代码、分隔符定制技巧,以及关键 API(Object.keys()、Array.prototype.reduce()、Object.assign()、String.prototype.split())的底层配合方式,读完即可在自己的项目中直接复制使用。

为什么需要扁平化对象:数据形态的转换场景

数据表示形式往往各不相同,对数据结构的需求也因场景而异。例如:

  • 存储与序列化:某些存储引擎(如 Key-Value 数据库、表格视图)需要扁平的键值对,而嵌套对象无法直接落库;
  • 配置与表单处理:表单提交的数据常以user.address.city这类路径式键名传递,需要先拍平再处理;
  • 日志与监控:将嵌套指标拍平为src.styles.main这样的键,便于统一索引和检索。

在这类需求中,将嵌套对象转换为扁平对象或反向还原,是非平凡的任务,但可以借助递归策略优雅地解决。该片段被收录在 object.yaml 的 JavaScript 对象文章集合中,标签为object与recursion,是递归处理对象结构的典型范例。

扁平化对象:将每个叶节点转换为带前缀的路径节点

给定一个嵌套对象,扁平化的核心思路是:将每一个「叶子节点」转换为一个「带完整路径前缀的扁平节点」。为便于理解,先看一个实际例子——一个描述文件大小的嵌套对象:

const fileSizes = { package: 256, src: { index: 1024, styles: { main: 128, colors: 16 }, }, assets: { images: { logo: 512, background: 512 }, fonts: { serif: 64 } } };

扁平化之后,得到的是以.作为路径分隔符的扁平对象:

const flattenedFileSizes = { 'package': 256, 'src.index': 1024, 'src.styles.main': 128, 'src.styles.colors': 16, 'assets.images.logo': 512, 'assets.images.background': 512, 'assets.fonts.serif': 64 };

[!NOTE]

结果键的顺序可能与原始键的顺序不一致,因为 JavaScript对象本身是无序的。本文所有示例为便于理解均保留了原始顺序。

递归的基线条件(Base Case)

构建递归解决方案的第一步是定义基线条件。在此场景下,基线条件是值为非对象的键:此时可以直接将该键值对加入结果对象。

而对于值为对象的键,则需要递归调用函数本身,并把当前键作为前缀附加到子键上;在更深层级时,还必须把此前累积的所有前缀都拼接到当前键前面。按照这一算法,就能逐步构建出完整的扁平化对象。

JavaScript 实现:reduce + Object.assign 的组合

在 JavaScript 中落地时,可以使用Object.keys()获取对象的所有键,配合Array.prototype.reduce()将每个叶子节点转换为扁平路径节点:

  • 若某键的值是对象,则以合适的前缀递归调用函数,并通过Object.assign()把递归结果合并进累积器;
  • 否则,直接把「带前缀的键 → 值」加入累积器对象。

完整代码如下:

const flattenObject = (obj, delimiter = '.', prefix = '') => Object.keys(obj).reduce((acc, k) => { const pre = prefix.length ? `${prefix}${delimiter}` : ''; if ( typeof obj[k] === 'object' && obj[k] !== null && Object.keys(obj[k]).length > 0 ) Object.assign(acc, flattenObject(obj[k], delimiter, pre + k)); else acc[pre + k] = obj[k]; return acc; }, {});

三个关键设计细节

这段代码虽然短小,但包含了几个容易忽略的细节,值得逐一拆解:

  1. 空对象必须当作叶子节点:条件中的Object.keys(obj[k]).length > 0确保空对象{}不会被继续递归,而是作为普通值直接写入结果。若不处理这一点,空对象会递归出「无键」的结果,造成信息丢失。
  2. null必须显式排除:typeof null === 'object',因此必须用obj[k] !== null把null视为叶子值,否则会出现对null调用Object.keys()的运行时错误。
  3. 前缀只在非空时拼接分隔符:const pre = prefix.length ?${prefix}${delimiter}: ''保证顶层键不带多余的前缀分隔符,而深层键会拼接为prefix + delimiter + key的形式。

自定义分隔符

上面的示例默认使用.作为键的分隔符,但通过额外的参数即可自由定制,比如改为路径风格的分隔符/:

// 沿用前面的 fileSizes 对象 flattenObject(fileSizes, '/'); /* { 'package': 256, 'src/index': 1024, 'src/styles/main': 128, 'src/styles/colors': 16, 'assets/images/logo': 512, 'assets/images/background': 512, 'assets/fonts/serif': 64 } */

需要注意,最后一个参数prefix仅供递归调用使用:除非你希望每个键都带一个全局前缀(例如在多个对象合并到同一命名空间时),否则正常调用时应省略该参数,传入任何值都会让所有键被统一加上此前缀。

还原对象:沿路径逐级重建嵌套结构

反向操作——unflatten(还原)——的思路略有不同:给定扁平对象中的某个键,你需要以分隔符拆分路径,再用拆分得到的数组逐级创建嵌套对象。

实现上依赖String.prototype.split()将键按分隔符拆分为路径片段数组,再用Array.prototype.reduce()沿路径逐步向下构建。reduce 的累积逻辑是:

  • 若当前累积器(当前层级的对象)中已经存在该键对应的值,则直接返回该值作为下一层累积器(说明该分支已存在,可继续向下延伸);
  • 否则,在累积器中添加对应的键值对,再返回该值作为下一层累积器。

此外,代码还利用isNaN(Number(keys[i + 1]))做了一个巧妙处理:当下一个路径片段是数字时,用数组[]而非对象{}作为新层级的容器,从而兼容数组索引类型的路径键。最后,通过Object.keys()与Array.prototype.reduce()对扁平对象中的每一个键应用上述过程:

const unflattenObject = (obj, delimiter = '.') => Object.keys(obj).reduce((res, k) => { k.split(delimiter).reduce( (acc, e, i, keys) => acc[e] || (acc[e] = isNaN(Number(keys[i + 1])) ? keys.length - 1 === i ? obj[k] : {} : []), res ); return res; }, {});

还原前文flattenedFileSizes对象,即可得到与原始fileSizes完全一致的嵌套结构:

unflattenObject(flattenedFileSizes); /* { package: 256, src: { index: 1024, styles: { main: 128, colors: 16 }, }, assets: { images: { logo: 512, background: 512 }, fonts: { serif: 64 } } } */

还原算法的实现要点

  • 表达式求值而非语句赋值:内层 reduce 的回调使用acc[e] || (acc[e] = ...)的短路写法,既完成了「已存在则复用」的判断,又完成了「不存在则创建」的赋值,一行代码同时承担了查重、建分支、向下传递三层职责。
  • 叶节点定位:keys.length - 1 === i判断当前是否为路径的最后一段;若是,则将原始扁平值obj[k]写入该位置,否则先创建一个空容器({}或[])继续下钻。
  • 数组分支支持:isNaN(Number(keys[i + 1]))用于探测下一段路径是否为纯数字字符串,从而用数组承接数字索引路径,例如还原形如'list.0.name'的键时会生成数组分支。

使用边界与数据丢失警告

[!WARNING]

扁平化与还原对象可能造成数据丢失,尤其是当键本身包含分隔符时。例如,原始对象中同时存在{'a.b': 1, a: {b: 2}}这样两个键时,扁平化后都会产生'a.b',后者会覆盖前者,且无法再被准确还原。在处理和转换数据时务必谨慎。

除此之外,还有几个实践中的边界情况值得留意:

  • 数组与嵌套对象的混合:扁平化时数组会被当作「非空对象」递归展开(typeof [] === 'object'),生成的键形如'items.0.name';还原时虽有数组分支支持,但空数组[]由于Object.keys([]).length === 0会被当作叶子值直接保留,行为与预期一致。
  • 分隔符冲突:若数据中的键本身包含.、/等分隔符,务必选用原始数据中不可能出现的字符作为分隔符,否则扁平化与还原将不可逆。
  • __proto__、constructor等特殊键:还原过程会向对象写入任意路径键,若扁平键包含原型链相关的特殊名称,可能引发意外行为,在不可信数据上使用时应格外小心。

从仓库源码看该片段的定位与配套

从仓库结构可以进一步确认该片段的定位与使用方式:

  • 片段源文件位于 flatten-unflatten-object.md,frontmatter 声明了tags: [object, recursion]、language: javascript,被收录进 JavaScript 对象文章集合 object.yaml(tagMatcher: object),与仓库中其他对象处理类片段构成同一知识体系;
  • 仓库中与之互补的递归题材片段 flat-iterator.md 展示了另一类「拍平」——用生成器函数 +yield*递归扁平化嵌套可迭代对象(数组、Set 等),如果你面对的是嵌套集合而非键值对象,可以参考该片段;
  • redirects.yaml 中记录了该片段的历史跳转映射(to: /js/s/flatten-unflatten-object),说明该片段在站点路由中拥有稳定的公开地址,可直接引用。

如果你需要查看该片段在实际网站中的渲染效果,可通过仓库的构建配置(astro.config.mjs)与内容加载器(loader.js)了解片段到页面的转换流程;而Object.keys()、reduce等 API 在仓库的 ES6 辅助函数体系中也被大量复用,可作为进阶学习的参照。

小结

对象扁平化与还原是一对典型的递归算法应用:扁平化以「值为非对象」为基线条件,逐层拼接路径前缀,将嵌套对象压缩为路径式键值对;还原则以分隔符拆分路径、逐级重建嵌套容器,并在检测到数字路径片段时自动使用数组。两者都以Object.keys()+Array.prototype.reduce()为骨架,配合Object.assign()与String.prototype.split()完成数据搬运。核心代码均可在 flatten-unflatten-object.md 中直接取用,唯一需要牢记的是:当键中包含分隔符时,转换可能不可逆,请务必根据实际数据谨慎选择分隔符并做好数据校验。

  • 教程
  • 文档

【免费下载链接】30-seconds-of-code

Coding articles to level up your development skills

项目地址:https://gitcode.com/gh_mirrors/30/30-seconds-of-code
点击查看免费下载

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

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

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

立即咨询