JavaScript 資料處理 - 對映表篇

邊城發表於2022-03-07

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 資料處理 - 列表篇

相關文章