JavaScript|JavaScript 数据处理 - 映射表篇

JavaScript 的常用数据集合有列表 (Array) 和映射表 (Plain Object)。列表已经讲过了,这次来讲讲映射表。
由于 JavaScript 的动态特性,其对象本身就是一个映射表,对象的「属性名?属性值」就是映射表中的「键?值」。为了便于把对象当作映射表来使用,JavaScript 甚至允许属性名不是标识符 —— 任意字符串都可以作为属性名。当然非标识符属性名只能使用 [] 来访问,不能使用 . 号访问。
使用 [] 访问对象属性更契合映射表的访问形式,所以在把对象当作映射表使用时,通常会使用 [] 访问表元素。这个时候 [] 中的内容称为“键”,访问操作存取的是“值”。因此,映射表元素的基本结构称为“键值对”。
在 JavaScript 对象中,键允许有三种类型:number、string 和 symbol。
number 类型的键主要是用作数组索引,而数组也可以认为是特殊的映射表,其键通常是连续的自然数。不过在映射表访问过程中,number 类型的键会被转成 string 类型来使用。
symbol 类型的键用得比较少,一般都是按规范使用一些特殊的 Symbol 键,比如 Symbol.iterator。symbol 类型的键通常会用于较为严格的访问控制,在使用 Object.keys()Object.entries() 访问相关元素时,会忽略掉键类型是 symbol 类型的元素。
一、CRUD 创建对象映射表直接使用 { } 定义 Object Literal 就行,基本技能,不用详述。但需要注意的是 { } 在 JavaScript 也用于封装代码块,所以把 Object Literal 用于表达式时往往需要使用一对小括号把它包裹起来,就像这样:({ })。在使用箭头函数表达式直接返回一个对象的时候尤其需要注意这一点。
对映射表元素的增、改、查都用 [] 运算符。
如果想判断某个属性是否存在,有人习惯用 !!map[key] ,或者 map[key] === undefined 来判断。使用前者要注意 JavaScript 假值的影响;使用后者则要注意有可能值本身就是 undefined。如果想准确地判断是否存在某个键,应该使用 in 运算符:

const a = { k1: undefined }; console.log(a["k1"] !== undefined); // false console.log("k1" in a); // trueconsole.log(a["k2"] !== undefined); // false console.log("k2" in a); // false

类似地,要删除一个键也不是将其值改变为 undefined 或者 null,而是使用 delete 运算符:
const a = { k1: "v1", k2: "v2", k3: "v3" }; a["k1"] = undefined; delete a["k2"]; console.dir(a); // { k1: undefined, k3: 'v3' }

使用 delete a["k2"] 操作后 ak2 属性不复存在。
注 上述两个示例中,由于 k1k2k3 都是合法标识符,ESLint 可能会报违反 dot-notation 规则。这种情况下可以关闭此规则,或者改用 . 号访问(由团队决定处理方式)。
二、映射表中的列表 【JavaScript|JavaScript 数据处理 - 映射表篇】映射表可以看作是键值对的列表,所以映射表可以转换成键值对列表来处理。
键值对用英语一般称为 key value pair 或 entry,Java 中用 Map.Entry 来描述;C# 中用 KeyValuePair 来描述;JavaScript 中比较直接,使用一个仅含两个元素的数组来表示键值对,比如 ["key", "value"]
在 JavaScript 中,可以使用 Object.entries(it) 来得到一个由 [键, 值] 形成的键值对列表。
const obj = { a: 1, b: 2, c: 3 }; console.log(Object.entries(obj)); // [ [ 'a', 1 ], [ 'b', 2 ], [ 'c', 3 ] ]

映射表除了有 entry 列表之外,还可以把键和值分开,得到单独的键列表,或者值列表。要得到一个对象的键列表,使用 Object.keys(obj) 静态方法;相应的要得到值列表使用 Object.values(obj) 静态方法。
const obj = { a: 1, b: 2, c: 3 }; console.log(Object.keys(obj)); // [ 'a', 'b', 'c' ] console.log(Object.values(obj)); // [ 1, 2, 3 ]

三、遍历映射表 既然映射表可以看作键值对列表,也可以单独取得键或值的列表,那么遍历映射表的方法也比较多。
最基本的方法就是用 for 循环。不过需要注意的是,由于映射表通常不带序号(索引号),不能通过普通的 for(; ; ) 循环来遍历,而是需要使用 for each 来遍历。不过有意思的是,for...in 可以用于会遍历映射表所有的 Key;但在映射表上使用 for...of 会出错,因为对象“is not iterable”(不可迭代,或不可遍历)。
const obj = { a: 1, b: 2, c: 3 }; for (let key in obj) { console.log(`${key} = ${obj[key]}`); // 拿到 key 之后通过 obj[key] 来取值 } // a = 1 // b = 2 // c = 3

既然映射表可以单独拿到键集和值集,所以在遍历的处理上会比较灵活。但是通常情况下我们一般都会同时使用键和值,所以在实际使用中,比较常用的是对映射表的所有 entry 进行遍历:
Object.entries(obj) .forEach(([key, value]) => console.log(`${key} = ${value}`));

四、从列表到映射表 前面两个小节都是在讲映射表怎么转成列表。反过来,要从列表生成映射表呢?
要从列表生成映射表,最基本的操作是生成一个空映射表,然后遍历列表,从每个元素中去取到“键”和“值”,将它们添加到映射表中,比如下面这个示例:
const items = [ { name: "size", value: "XL" }, { name: "color", value: "中国蓝" }, { name: "material", value: "涤纶" } ]; function toObject(specs) { return specs.reduce((obj, spec) => { obj[spec.name] = spec.value; return obj; }, {}); }console.log(toObject(items)); // { size: 'XL', color: '中国蓝', material: '涤纶' }

这是常规操作。注意到 Object 还提供了一个 fromEntries() 静态方法,只要我们准备好键值对列表,使用 Object.fromEntries() 就能快速得到相应的对象:
function toObject(specs) { return Object.fromEntries( specs.map(({ name, value }) => [name, value]) ); }

五、一个小小的应用案例 数据处理过程中,列表和映射表之间往往需要相互转换以达到较为易读的代码或更好的性能。本文前面的内容已经讲到了转换的两个关键方法:
  • Object.entries() 把映射表转换成键值对列表
  • Object.fromEntries() 从键值对列表生成映射表
在哪些情况下可能用到这些转换呢?应用场景很多,比如这里就有一个比较经典的案例。
提出问题: 从后端拿到了一棵树的所有节点,节点之间的父关系是通过 parentId 字段来描述的。现在想把它构建成树形结构该怎么办?样例数据:
[ { "id": 1, "parentId": 0, "label": "第 1 章" }, { "id": 2, "parentId": 1, "label": "第 1.1 节" }, { "id": 3, "parentId": 2, "label": "第 1.2 节" }, { "id": 4, "parentId": 0, "label": "第 2 章" }, { "id": 5, "parentId": 4, "label": "第 2.1 节" }, { "id": 6, "parentId": 4, "label": "第 2.2 节" }, { "id": 7, "parentId": 5, "label": "第 2.1.1 点" }, { "id": 8, "parentId": 5, "label": "第 2.1.2 点" } ]

一般思路是先建一个空树(虚根),然后按顺序读取节点列表,每读到一个节点,就从树中找到正确的父节点(或根节点)插入进去。这个思路并不复杂,但实际操作起来会遇到两个问题
  1. 在已生成的树中查找某个节点本身是个复杂的过程,不管是用递归通过深度遍历查找,还是用队列通过广度遍历查找,都需要写相对复杂的算法,也比较耗时;
  2. 对于列表所有节点顺序,如果不能保证子节点在父节点之后,处理的复杂度会大大增加。
要解决上面两个问题也不难,只需要先遍历一遍所有节点,生成一个 [id => node] 的映射表就好办了。假设这些数据拿到之后由变量 nodes 引用,那么可以用如下代码生成映射表:
const nodeMap = Object.fromEntries( nodes.map(node => [node.id, node]) );

具体过程就不详述了,有兴趣的读者可以去阅读:从列表生成树 (JavaScript/TypeScript)
六、映射表的拆分 映射表本身不支持拆分,但是我们可以按照一定规则从中选择一部分键值对出来,组成新的映射表,达到拆分的目的。这个过程就是 Object.entries()? filter() ? Object.fromEntries()。比如,希望把某配置对象中所有带下划线前缀的属性剔除掉:
const options = { _t1: 1, _t2: 2, _t3: 3, name: "James", title: "Programmer" }; const newOptions = Object.fromEntries( Object.entries(options).filter(([key]) => !key.startsWith("_")) ); // { name: 'James', title: 'Programmer' }

不过,对于非常明确地知道要清除掉哪些元素的时候,使用 delete 会更直接。
这里再举一个例子:
提出问题: 某项目做技术升级,原来的异步请求是在参数中传递 successfail 回调事处理异步,新的接口改为 Promise 风格,参数中不再需要 successfail。现在的问题是:大量应用这个异步操作的代码需要一定的时间来完成迁移,而在这期间,仍需要保证旧接口能正确执行。
为了迁移期间的兼容性,这段代码需要把参数对象中的 successfail 拿出来,从原参数对象中去掉,再把处理过的参数对象交给新的业务处理逻辑。这里去掉 successfail 两个 entry 的操作就可以用 delete 来完成。
async function asyncDoIt(options) { const success = options.success; const fail = options.fail; delete options.success; delete options.fail; try { const result = await callNewProcess(options); success?.(result); } catch (e) { fail?.(e); } }

这是中规中矩的做法,花了 4 行代码来处理两个特殊 entry。其中前两句很容易想到可以使用解构来简化:
const { success, fail } = options;

但是有没有发现,后两句也可以合并进去?你看 ——
const { success, fail, ...opts } = options;

这里拿到的 opts 可不就是排除了 successfail 两个 entry 的选项表!
更进一步,我们可以利用解构参数语法把解构过程移到参数列表中去。下面是修改后的 asyncDoIt
async function asyncDoIt({ success, fail, ...options } = {}) { // TODO try { ... } catch (e) { ... } }

利用解构拆分映射表让代码看起来非常简洁,这样的函数定义方式可以照搬到箭头函数上,作为链式数据处理过程中的处理函数。这样一来,拆分数据在定义参数的时候顺手就解决了,代码整体看起来会非常简洁清晰。
七、合并映射表 合并映射表,基本操作肯定还是循环添加,不推荐。
既然 JavaScript 的新特性提供了更便捷的方法,干嘛不用呢!新特性基本上也就两种:
  • Object.assign()
  • 展开运算符
语法和接口说明都可以在 MDN 上去看,这里还是用案例来说:
提出问题 有一个函数的参数是一个选项表,为了方便使用不需要调用者提供全部选项,没提供的选项全部采用默认选项值。但是一个个去判断太繁琐了,有没有比较简单的办法?
有,当然有!用 Object.assign() 啊:
const defaultOptions = { a: 1, b: 2, c: 3, d: 4 }; function doSomthing(options) { options = Object.assign({}, defaultOptions, options); // TODO 使用 options }

提出这个问题可能是因为不知道 Object.assign(),一旦知道了,会发现用起来还是很简单。不过简单归简单,坑还是有的。
这里 Object.assign() 的第一个参数一定要给一个空映射表,否则 defaultOptions 会被修改掉,因为 Object.assign() 会把每个参数中的 entries 合并到它的第一个参数(映射表)中。
为了避免 defaultOptions 被意外修改,可以把它“冻”住:
const defaultOptions = Object.freeze({ //^^^^^^^^^^^^^^ a: 1, b: 2, c: 3, d: 4 });

这样一来,Object.assign(defaultOptions, ...) 会报错。
另外,使用展开运算符也可以实现:
options = { ...defaultOptions, ...options };

使用展开运算符更大的优势在于:要添加单个 entry 也很方便,不像 Object.assign() 必须要把 entry 封装成映射表。
function fetchSomething(url, options) { options = { ...defaultOptions, ...options, url,// 键和变量同名时可以简写 more: "hi"// 普通的 Object Literal 属性写法 }; // TODO 使用 options }

讲了半天,上面的合并过程还是有个大坑,不知道你发现了没?—— 上面一直在说合并映射表,而不是合并对象。虽然映射表就是对象,但映射表的 entry 就是简单的键值对关系;而对象不同,对象的属性存在层次和深度。
举例来说,
const t1 = { a: { x: 1 } }; const t2 = { a: { y: 2 } }; const r = Object.assign({}, t1, t2); // { a: { y: 2 } }

结果是 { a: { y: 2} } 而不是 { a: { x: 1, y: 2 } }。前者是浅层合并的结果,合并的是映射表的 entries;后者是深度合并的结果,合并的是对象的多层属性。
手写深度合并工作量不小,不过 Lodash 有提供 _.merge() 方法,不妨用现成的。_.merge() 在合并数组的时候可能会不符合预期,这情况使用 _.mergeWith() 自定义处理数组合并就好,文档中就有现成的例子。
八、Map 类 JavaScript 也提供了专业的 Map 类,和 Plain Object 相比,它允许任意类型的“键”,而不局限于 string。
上面提到的各种操作在 Map 都有对应的方法。无需详述,简单介绍一下即可:
  • 添加/修改,使用 set() 方法;
  • 通过键取值,使用 get() 方法;
  • 根据键删除,使用 delete() 方法,还有一个 clear() 直接清空映射表;
  • has() 访求用来判断是否存在某个键值对;
  • size 属性可以拿到 entry 数,不像 Plain Object 需要用 Object.entries(map).length 来获取;
  • entries()keys()values() 方法用来获取 entry、键、值的列表,但结果不是数组,而是 Iterator;
  • 还有个 forEach() 方法直接用来遍历,处理函数不接收整个 entry (即 ([k, v])),而是分离的 (value, key, map)
小结 在 JavaScript 中你用的到底是对象还是映射表呢?说实在的并不太容易说得清楚。作为映射表来说,上面提到的各种方法足够使用 了,但是作为对象,JavaScript 还提供了更多的工具方法,需要了解可以查查 Object API 和 Reflect API。
掌握对列表和映射表的操作方法,基本上可以解决日常遇到的各种 JavaScript 数据处理问题。像什么数据转换、数据分组、分组展开、树形数据 …… 都不在话下。一般情况下 JavaScript 原生 API 足够用了,但如果遇到处理起来较为复杂的情况(比如分组),不妨去查查 Lodash 的 API,毕竟是个专业的数据处理工具。
别忘了去看上一篇:JavaScript 数据处理 - 列表篇

    推荐阅读