头图

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);              // true

console.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 规则。这种情况下可以关闭此规则,或者改用 . 号访问(由团队决定处理方式)。

二、映射表中的列表

映射表可以看作是键值对的列表,所以映射表可以转换成键值对列表来处理。

键值对用英语一般称为 key value pair 或 entry,Java 中用 Map.Entry<K, V> 来描述;C# 中用 KeyValuePair<TKey, TValue> 来描述;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 APIReflect API

掌握对列表和映射表的操作方法,基本上可以解决日常遇到的各种 JavaScript 数据处理问题。像什么数据转换、数据分组、分组展开、树形数据 …… 都不在话下。一般情况下 JavaScript 原生 API 足够用了,但如果遇到处理起来较为复杂的情况(比如分组),不妨去查查 Lodash 的 API,毕竟是个专业的数据处理工具。

别忘了去看上一篇:JavaScript 数据处理 - 列表篇


边城客栈
全栈技术专栏,公众号「边城客栈」,[链接]

一路从后端走来,终于走在了前端!

56.2k 声望
26.5k 粉丝
0 条评论
推荐阅读
2022,二着二着又混过一年
收到思否小姐姐的活动提醒,才发觉又到了年底,该写“总结”了。说起总结,总有些倦——每天工作要写日报、项目上要写周报、月底要写月报、季度还有季总结,当然还有半年总结和年终总结……一年大约是 250 个工作日、50...

边城6阅读 787评论 2

封面图
从零搭建 Node.js 企业级 Web 服务器(零):静态服务
过去 5 年,我前后在菜鸟网络和蚂蚁金服做开发工作,一方面支撑业务团队开发各类业务系统,另一方面在自己的技术团队做基础技术建设。期间借着 Node.js 的锋芒做了不少 Web 系统,有的至今生气蓬勃、有的早已夭折...

乌柏木150阅读 12.3k评论 10

正则表达式实例
收集在业务中经常使用的正则表达式实例,方便以后进行查找,减少工作量。常用正则表达式实例1. 校验基本日期格式 {代码...} {代码...} 2. 校验密码强度密码的强度必须是包含大小写字母和数字的组合,不能使用特殊...

寒青56阅读 7.9k评论 11

JavaScript有用的代码片段和trick
平时工作过程中可以用到的实用代码集棉。判断对象否为空 {代码...} 浮点数取整 {代码...} 注意:前三种方法只适用于32个位整数,对于负数的处理上和Math.floor是不同的。 {代码...} 生成6位数字验证码 {代码...} ...

jenemy46阅读 6k评论 12

从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望
总结截止到本章 “从零搭建 Node.js 企业级 Web 服务器” 主题共计 16 章内容就更新完毕了,回顾第零章曾写道:搭建一个 Node.js 企业级 Web 服务器并非难事,只是必须做好几个关键事项这几件必须做好的关键事项就...

乌柏木66阅读 6.2k评论 16

再也不学AJAX了!(二)使用AJAX ① XMLHttpRequest
「再也不学 AJAX 了」是一个以 AJAX 为主题的系列文章,希望读者通过阅读本系列文章,能够对 AJAX 技术有更加深入的认识和理解,从此能够再也不用专门学习 AJAX。本篇文章为该系列的第二篇,最近更新于 2023 年 1...

libinfs39阅读 6.3k评论 12

封面图
从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
分层规范从本章起,正式进入企业级 Web 服务器核心内容。通常,一块完整的业务逻辑是由视图层、控制层、服务层、模型层共同定义与实现的,如下图:从上至下,抽象层次逐渐加深。从下至上,业务细节逐渐清晰。视图...

乌柏木44阅读 7.4k评论 6

一路从后端走来,终于走在了前端!

56.2k 声望
26.5k 粉丝
宣传栏