在数据处理、接口适配、配置读取、日志上报等开发任务中,经常需要面对多层嵌套的对象结构。嵌套结构能够自然地表达层级关系,但在进行属性查找、字段比对、缓存存储、表格渲染或生成查询参数时,深层路径往往会让代码变得繁琐。为了解决这个问题,可以把嵌套对象中不同层级的键值对合并到一个单层对象中,使每个属性都拥有明确、完整且可直接访问的键名。

嵌套对象扁平化的核心目标
所谓扁平合并,并不是简单地把所有值复制到同一个对象里,而是要在合并过程中保留原始数据的路径信息。例如,原始对象中的 address.city 所对应的值,在扁平化之后通常会变成一个带有路径含义的新键,如 address_city。这样既消除了嵌套结构,又让开发者能够从键名中看出该字段原本所属的层级。
这种处理方式的价值在于,它可以把复杂的树形数据转换成更容易遍历、更容易序列化的键值集合。当所有字段都位于同一层时,可以使用统一的循环逻辑读取属性,也可以更方便地把数据写入缓存、生成表单字段、构造搜索索引或者进行差异比较。对于需要按字段名定位数据的场景,扁平结构通常比深层嵌套更稳定。
从实现角度看,扁平合并的核心步骤包括三个部分:第一,逐层访问对象中的每个属性;第二,为每一层属性拼接一个能够表达路径的新键名;第三,判断当前属性值是否仍然需要继续展开。如果属性值仍然是对象或数组,就继续向下处理;如果已经是字符串、数字、布尔值等叶子值,就把它写入最终结果。
纯对象嵌套的递归处理方式
如果数据结构只包含普通对象嵌套,而不包含数组、Map、Set 等复杂类型,递归是最容易理解的实现方式。递归函数会从顶层对象开始,依次读取每个属性。如果属性值仍然是普通对象,就把当前属性名作为路径的一部分传入下一层;如果属性值已经是最终值,就直接写入结果对象。
在这种实现中,前缀参数非常重要。它保存了当前属性之前已经经过的路径。每进入一层,就把当前键名追加到前缀后面。为了避免键名冲突,通常会选择一个分隔符,如下划线、点号或者中划线。分隔符本身没有绝对标准,关键是要与业务中的字段命名规则兼容,避免生成歧义路径。
下面的示例展示了一个基础的递归实现。该示例只处理普通对象嵌套,当属性值不是普通对象时,会将其作为叶子值保存。为了便于观察结果,代码使用下划线连接不同层级的键名。
// 将纯对象嵌套结构扁平化为单层对象
function flattenObject(source, prefix = '', result = {}) {
// 如果传入值不是对象,直接作为叶子值写入结果
if (source === null || typeof source !== 'object') {
if (prefix) {
result[prefix] = source;
}
return result;
}
// 基础场景只展开普通对象,数组不继续展开
if (Array.isArray(source)) {
if (prefix) {
result[prefix] = source;
}
return result;
}
// 遍历对象自身属性
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const nextKey = prefix ? prefix + '_' + key : key;
const value = source[key];
// 普通对象继续递归,其他值直接写入结果
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
flattenObject(value, nextKey, result);
} else {
result[nextKey] = value;
}
}
}
return result;
}
const profile = {
name: '张三',
age: 28,
address: {
province: '广东',
city: '深圳',
detail: {
street: '科技路',
number: 100
}
}
};
console.log(flattenObject(profile));
// 输出:
// {
// name: '张三',
// age: 28,
// address_province: '广东',
// address_city: '深圳',
// address_detail_street: '科技路',
// address_detail_number: 100
// }
从输出结果可以看到,原本位于 address.detail.street 路径下的值,被转换成了 address_detail_street 这样的单层键名。这样做之后,外部代码不需要再逐层判断中间对象是否存在,只需要通过一个明确键名即可读取目标值。
包含数组的混合结构如何保持键路径清晰
实际项目中的嵌套结构往往不会只有对象,很多字段会包含数组。例如,一个订单对象可能包含多个子项,一个用户资料可能包含多个标签,一个配置对象可能包含多组规则。如果扁平化函数不能识别数组,就可能把数组整体当作叶子值保存,从而失去数组内部的结构信息。
为了处理数组,需要把数组索引作为路径的一部分。常见的做法是将数组下标拼接进键名,例如 list_0_title 表示 list 数组第一项中的 title 字段,info_tags_1 表示 info.tags 数组中的第二项。这样即使数据被展开成单层对象,数组元素的顺序和归属关系仍然可以从键名中还原出来。
下面这段代码在基础递归逻辑上增加了对数组的判断。当遇到数组时,遍历数组中的每一项,并把索引加入当前路径;当遇到对象时,继续按照属性名拼接路径;当遇到基本类型时,将值写入结果。
// 支持对象和数组混合嵌套的扁平化函数
function flattenMixed(source, prefix = '', result = {}) {
// 处理数组:将索引作为路径的一部分
if (Array.isArray(source)) {
source.forEach(function(item, index) {
const nextKey = prefix ? prefix + '_' + index : String(index);
if (item !== null && typeof item === 'object') {
flattenMixed(item, nextKey, result);
} else {
result[nextKey] = item;
}
});
return result;
}
// 处理普通对象:将属性名作为路径的一部分
if (source !== null && typeof source === 'object') {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const nextKey = prefix ? prefix + '_' + key : key;
const value = source[key];
if (value !== null && typeof value === 'object') {
flattenMixed(value, nextKey, result);
} else {
result[nextKey] = value;
}
}
}
return result;
}
// 基本类型作为叶子值写入结果
if (prefix) {
result[prefix] = source;
}
return result;
}
const order = {
id: 1,
list: [
{ title: '第一项', value: 10 },
{ title: '第二项', value: 20 }
],
info: {
owner: '李四',
tags: ['前端', 'JavaScript']
}
};
console.log(flattenMixed(order));
// 输出:
// {
// id: 1,
// list_0_title: '第一项',
// list_0_value: 10,
// list_1_title: '第二项',
// list_1_value: 20,
// info_owner: '李四',
// info_tags_0: '前端',
// info_tags_1: 'JavaScript'
// }
这种混合结构的扁平化方式特别适合用于生成表单字段名、构造动态校验规则或者导出结构化数据。因为数组索引被显式写入键名,所以即使原始数据包含多个相似对象,展开后的字段也不会互相覆盖。
使用迭代栈降低深层递归风险
递归写法的优点是逻辑清晰,接近树形结构本身的定义。不过,当嵌套层级非常深,或者对象中存在大量连续嵌套节点时,递归调用栈可能不断增长,进而带来栈溢出风险。虽然普通业务数据通常不会达到危险深度,但在处理自动生成的大型结构、深层配置树或复杂元数据时,仍然值得提前考虑更稳妥的方案。
迭代实现的核心思想是不再依赖函数调用栈,而是手动维护一个待处理任务集合。每个任务包含两部分信息:当前要处理的数据值,以及该数据值对应的键名前缀。程序从栈中取出一个任务,判断它是数组、对象还是叶子值。如果仍然是可展开结构,就把下一层任务重新压入栈中;如果已经是叶子值,就写入结果对象。
下面的示例使用栈来模拟递归过程。它支持对象和数组混合结构,同时避免递归调用带来的深层调用栈。对于需要处理未知深度数据的工具函数来说,这种实现更具可控性。
// 使用栈模拟递归,避免深层嵌套造成调用栈过长
function flattenByStack(source) {
const result = {};
const stack = [{ value: source, prefix: '' }];
while (stack.length) {
const current = stack.pop();
const value = current.value;
const prefix = current.prefix;
// 数组:把索引加入路径后继续处理
if (Array.isArray(value)) {
value.forEach(function(item, index) {
const nextKey = prefix ? prefix + '_' + index : String(index);
if (item !== null && typeof item === 'object') {
stack.push({ value: item, prefix: nextKey });
} else {
result[nextKey] = item;
}
});
} else if (value !== null && typeof value === 'object') {
// 对象:把属性名加入路径后继续处理
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
const nextKey = prefix ? prefix + '_' + key : key;
const child = value[key];
if (child !== null && typeof child === 'object') {
stack.push({ value: child, prefix: nextKey });
} else {
result[nextKey] = child;
}
}
}
} else if (prefix) {
// 叶子值直接写入结果
result[prefix] = value;
}
}
return result;
}
const deepConfig = {
a: {
b: {
c: {
d: {
e: '深层值'
}
}
}
},
list: [
{ name: '甲' },
{ name: '乙' }
]
};
console.log(flattenByStack(deepConfig));
// 输出:
// {
// a_b_c_d_e: '深层值',
// list_0_name: '甲',
// list_1_name: '乙'
// }
与递归版本相比,迭代版本的执行流程稍微复杂一些,但它把展开过程变成了显式的循环任务。开发者可以在此基础上继续扩展,例如限制最大处理深度、跳过空对象、过滤特定键名,或者在写入结果前对值进行统一转换。
键名规则、边界情况与工程化建议
在选择扁平化方案时,首先要确定键名生成规则。下划线、点号、中划线都可以作为路径分隔符,但不同规则会影响后续使用方式。如果结果对象要作为表单字段名提交,需要确认后端是否能正确解析;如果结果要参与缓存键生成,则需要避免分隔符与业务字段本身包含的符号冲突。
其次要注意边界情况。null 的 typeof 结果也是 object,因此必须单独判断,避免把 null 当作普通对象继续遍历。空对象和空数组也需要明确策略:可以忽略,可以保留占位值,也可以根据业务需求写入特定默认值。此外,如果原始对象中存在循环引用,无论递归还是迭代都需要额外记录已访问节点,否则会导致无限展开。
总体而言,把嵌套对象扁平合并为单个无嵌套对象,本质上是在树形结构和键值表之间建立一种可追踪的映射关系。对于简单对象嵌套,递归实现足够直观;对于包含数组的复杂数据,需要把索引纳入路径;对于层级很深或结构不可控的数据,可以改用栈或队列进行迭代处理。只要根据业务场景选择合适的键名规则和边界处理策略,就能让扁平化结果既便于访问,又保留必要的层级信息。
对象扁平化嵌套对象处理JavaScript键值对合并修改时间:2026-06-28 18:54:35