JavaScript 数据处理 - 映射表篇
作者:边城
来源:SegmentFault 思否社区
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"] 操作后 a 的 k2 属性不复存在。
注
上述两个示例中,由于 k1、k2、k3 都是合法标识符,ESLint 可能会报违反 dot-notation 规则。这种情况下可以关闭此规则,或者改用 . 号访问(由团队决定处理方式)。
二、映射表中的列表
映射表可以看作是键值对的列表,所以映射表可以转换成键值对列表来处理。
键值对用英语一般称为 key value pair 或 entry,Java 中用 Map.Entry
在 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() 从键值对列表生成映射表
提出问题:
[
{ "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 点" }
]
在已生成的树中查找某个节点本身是个复杂的过程,不管是用递归通过深度遍历查找,还是用队列通过广度遍历查找,都需要写相对复杂的算法,也比较耗时;
对于列表所有节点顺序,如果不能保证子节点在父节点之后,处理的复杂度会大大增加。
const nodeMap = Object.fromEntries(
nodes.map(node => [node.id, node])
);
六、映射表的拆分
映射表本身不支持拆分,但是我们可以按照一定规则从中选择一部分键值对出来,组成新的映射表,达到拆分的目的。这个过程就是 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' }
提出问题:
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);
}
}
const { success, fail } = options;
const { success, fail, ...opts } = options;
async function asyncDoIt({ success, fail, ...options } = {}) {
// TODO try { ... } catch (e) { ... }
}
七、合并映射表
Object.assign() 展开运算符
提出问题
const defaultOptions = {
a: 1, b: 2, c: 3, d: 4
};
function doSomthing(options) {
options = Object.assign({}, defaultOptions, options);
// TODO 使用 options
}
const defaultOptions = Object.freeze({
// ^^^^^^^^^^^^^^
a: 1, b: 2, c: 3, d: 4
});
options = { ...defaultOptions, ...options };
function fetchSomething(url, options) {
options = {
...defaultOptions,
...options,
url, // 键和变量同名时可以简写
more: "hi" // 普通的 Object Literal 属性写法
};
// TODO 使用 options
}
const t1 = { a: { x: 1 } };
const t2 = { a: { y: 2 } };
const r = Object.assign({}, t1, t2); // { a: { y: 2 } }
八、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)。