複製物件是 JavaScript 的實用技巧之一,十幾前我就學會用 jQuery.extend() 搞定,常見應用是結合參數預設值及函式傳入的設定值,例如:

const defaults = {
    fontSize: '10pt',
    color: 'white',
    backgroundColor: 'red'
}

funtion showSomething(options) {
    const finalOptions = $.extended({}, defaults, options);
    //...略...
    something.style.fontSize = finalOptions.fontSize;
    something.style.color = finalOptions.color;
    something.style.backgroundColor = finalOptions.backgroundColor;
    //...略...
}

靠 jQuery.extend() 再戰十年也是沒問題的,不過最近剛解封,我對 JavaScript 好奇心正盛,花了點時間研究複製物件這件事。

開始前先說物件複製常要考慮的事:

  1. 除了複製屬性 (Property) 外,函式 (Function/Method) 也要複製嗎?
  2. Shallow Copy (淺層複製) 還是 Deep Copy (深層複製) ?
    當屬性型別為物件(Non-Primitive Type,可想成 C# 的 Reference Type)時,Shallow Copy 複製屬性會指向原物件、Deep Copy 則是為該物件產生副本,事後修改原物件不會對 Deep Copy 物件產生影響。

參考網路文章,我整理了幾種 JavaScript 複製物件方法:

  1. Spread 法 (Shallow Copy,會複製函式)
    寫成 { ...src },相當於將 src 屬性展開傳入,等於 { src.prop1, src.prop2, src.prop3,... },套用前篇文章提到的 Shorthand Property Name,巧妙地讓新物件具有跟原物件一樣的屬性與方法。
    Spread in object literals 語法,Safari 要 11.3 才支援,Deno 不支援,需留意瀏覽器支援性
  2. Object.assign 法 (Shallow Copy,會複製函式)
    寫成 Object.assign({}, src)
  3. JSON 大絕 (Deep Copy,不複製函式)
    JSON.parse(JSON.stringify(src)),要留意還原失真問題(例如:Date() 序列化再還原會變成字串 JSON.parse(JSON.stringify(new Date())) )
  4. jQuery.extend (Shallow Copy,會複製函式)
    寫成 $.extend({}, src),還可一次合併多個 $.extend({}, src1, src2)
  5. Lodash .clone() & .cloneDeep() (有 Shallow Copy 及 Deep Copy 兩種,會複製函式)
    寫成 _.clone(src)_.cloneDeep()

最後,用彙整範例結束這回合:

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <style>
        body { display: flex; font-size: 9pt; }
        .tab { width: 320px;}
        table { border-collapse: collapse; border-spacing: 0; }
        td { padding: 3px 6px; border: 1px solid gray; }
        thead td, tbody td:first-child { text-align: left; background-color: #eee; }
        tbody td { text-align: center; }
    </style>
</head>

<body>
    <div class="tab">
        <table>
            <thead>
                <tr>
                    <td>Method</td>
                    <td>Deep/<br />Shallow</td>
                    <td>Functions<br />Included?</td>
                    <td>Date Type<br /> Lost?</td>
                </tr>
            </thead>
            <tbody></tbody>
        </table>
    </div>
    <pre></pre>
    <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script>
        const instance = {
            status: "before"
        };
        const src = {
            i: 123,
            a: [1, 2, 3],
            s: 'String',
            d: new Date(2012, 11, 21),
            hi() { console.log('Hi'); },
            obj: { p: 'test' },
            ins: instance
        };
        const bySpread = { ...src };
        const byAssign = Object.assign({}, src);
        const byJson = JSON.parse(JSON.stringify(src));
        const byJQuery = $.extend({}, src);
        const byLodashShallow = _.clone(src);
        const byLodashDeep = _.cloneDeep(src);
        instance.status = 'after';
        src.hi();
        const res = [];
        const logs = [];
        const check = (o) => {
            const varName = Object.keys(o)[0];
            const obj = o[varName];
            res.push([
                varName,
                obj.ins.status == 'before' ? 'Deep' : 'Shallow',
                typeof (obj.hi) == 'function' ? 'Y' : 'N',
                obj.d instanceof Date ? 'N' : 'Y'
            ]);
            logs.push(`=== ${varName} ===`);
            Object.keys(obj).forEach(propName =>
                logs.push(` * ${propName}[${typeof (obj[propName])}] = ${JSON.stringify(obj[propName])}`));
        }
        check({ bySpread });
        check({ byAssign });
        check({ byJson });
        check({ byLodashShallow });
        check({ byLodashDeep });
        check({ byJQuery });
        $('pre').text(logs.join('\n'));
        $('tbody').html(
            res.map(a => `<tr>${a.map(e => `<td>${e}</td>`).join()}</tr>`).join('\n')
        );
    </script>
</body>

</html>

其中的 check() 有點意思,特別說一下。我用了一個小技巧去抓變數名稱,JavaScript 不像 C# 有 nameof(varName) 可將變數名稱轉成字串,但再次借用前幾天學到的 Shorthand Property Name 宣告物件 ,透過 Object.keys(o) 取屬性名稱便能得到 "varName",還蠻有趣的。而 check() 函式會進行以下檢查:

  1. 複製物件後,instance.status 從 'before' 被改為 'after',若為 Deep Copy,ins.status 應為 before;若為 after 即為 Shallow Copy。
  2. 用 typeof 檢查複製物件的 hi() 是否為 function 檢查函式有無被複製。
  3. 用 .d instanceof Date 檢查 d 屬性型別是否失真。

六種做法的處理特性如下表:

MethodDeep/ShallowFunctions?Date Type Lost?
bySpreadShallowYN
byAssignShallowYN
byJsonDeepNY
byLodashShallowShallowYN
byLodashDeepDeepYN
byJQueryShallowYN

附上完整測試結果:

【參考資料】

[2022-05-07 補充] PO 文後再獲新知(感謝莊志弘與張清忠兩位先進不約而同分享),Chrome 98+ 新增 structuredClone() API,主要用於資料傳輸物件之複製,具有一些特異功能如:能處理循環參照、複製後讓原物件無法使用... 等。但其能複製的型別有限,若包含不支援型別(例如函式)會出錯,例如:Failed to execute 'structuredClone' on 'Window': hi() { console.log('Hi'); } could not be cloned.

This article summarizes common object cloning methods in JavaScript.


Comments

Be the first to post a comment

Post a comment