Deep Copy ออบเจ็กต์ใน JavaScript ด้วย structuredClone

Nuttavut Thongjor

ออบเจ็กต์ใน JavaScript นั้นประกอบด้วยพร็อพเพอร์ตี้ที่มีส่วนของ key และ value ตามแต่การนิยามของนักพัฒนา บ่อยครั้งที่เราต้องการสร้างออบเจ็กต์ใหม่ด้วยการสำเนาพร๊อพเพอร์ตี้จากออบเจ็กต์เดิม การสำเนาข้อมูลที่ปลอดภัยต้องไม่ทำให้ออบเจ็กต์ใหม่ได้รับผลกระทบเมื่อออบเจ็กต์เดิมเปลี่ยนค่าของพร็อพเพอร์ตี้ใด ๆ การสำเนาประเภทนี้เรียกว่า Deep Copy

บทความนี้เราจะนำไปสู่การทำสำเนาแบบ Deep Copy รูปแบบต่าง ๆ รวมถึงการใช้คำสั่งใหม่ใน JavaScript คือ structuredClone

การสำเนาออบเจ็กต์แบบ Shallow Copy

สำหรับภาษา JavaScript ตัวแปรที่มีชนิดข้อมูลเป็น Primitive Data Types เช่น string, number, boolean เป็นต้น เมื่อกำหนดค่าตัวแปรที่เก็บค่านี้ไปสำเนาให้ตัวแปรใหม่ค่าข้อมูลย่อมถูกสำเนา การเปลี่ยนแปลงที่ตัวแปรเดิมย่อมไม่กระทบค่าข้อมูลในตัวแปรใหม่

JavaScript
1let x = 20;
2let y = x;
3
4x = 30;
5console.log(y); // 20

ตัวอย่างข้างต้นเมื่อกำหนดค่า x ให้กับ y ค่าข้อมูลจะถูกสำเนา เมื่อทำการเปลี่ยนค่าของ x ค่าเดิมของ y ย่อมไม่ได้รับผลกระทบ เนื่องจากทั้งสองต่างไม่ใช่ข้อมูลชุดเดียวกันอีกต่อไป

ไม่ใช่สำหรับชนิดข้อมูลแบบออบเจ็กต์ การกำหนดข้อมูลให้กับตัวแปรใหม่ไม่ใช่การสำเนาข้อมูลหากแต่เป็นการชี้ไปยังชุดข้อมูลเดียวกัน เมื่อข้อมูลที่ถูกชี้เปลี่ยนแปลง ทั้งตัวแปรต้นฉบับและตัวแปรใหม่ย่อมเห็นผลลัพธ์ใหม่จากการเปลี่ยนแปลงนั้น ๆ

JavaScript
1let x = { a: 1 };
2let y = x;
3
4x.a = 2;
5console.log(y.a); // 2

ผลลัพธ์จากตัวอย่างข้างต้น เมื่อ x เป็นตัวแปรที่มีชนิดข้อมูลเป็นออบเจ็กต์ การกำหนดค่า x ให้กับ y ย่อมหมายถึงการแบ่งปันให้ y ทำการชี้ตำแหน่งข้อมูลไปยังชุดข้อมูลเดียวกับที่ x ชี้อยู่คือ { a: 1} เมื่อ x ทำการเปลี่ยนค่าของพร็อพเพอร์ตี้ a จึงยังผลให้ y เห็นค่า a เปลี่ยนไปด้วยเช่นกัน

การทำสำเนาใด ๆ ถ้ามีพร็อพเพอร์ตี้บางส่วนที่แชร์ข้อมูลเดียวกันกับตัวแปรเก่า (เกิดขึ้นในกรณีที่พร็อพเพอร์ตี้นั้นเป็นออบเจ็กต์) การทำสำเนานั้นจะถูกเรียกว่าการทำสำเนาแบบตี้นหรือ Shallow Copy

เราสามารถใช้ Object.assign และ Spread Operator เพื่อสร้างการสำเนาแบบตื้นได้ ดังนี้

JavaScript
1const person = {
2 name: 'Somchai',
3 age: 24,
4 tels: ['0811111111', '0822222222'],
5};
6
7const somchai = { ...person };
8
9person.age = 25;
10console.log(somchai.age); // 24
11
12person.tels[0] = '0833333333';
13console.log(somchai.tels[0]); // 0833333333

จากตัวอย่างข้างต้นพร็อพเพอร์ตี้ age ของ person มีชนิดข้อมูลเป็น number ซึ่งเป็นหนึ่งในพร๊อพเพอร์ตี้แบบ Primitive การเปลี่ยนแปลงค่า age ของ person จึงไม่กระทบกับ age ของ somchai เหตุการณ์นี้จะตรงกันข้ามกับ tels ที่เป็นออบเจ็กต์ประเภทอาร์เรย์ การเปลี่ยนแปลงค่า tels บน person ย่อมกระทบกับ tels ใน somchai เหตุนี้จึงกล่าวได้ว่าการใช้ Spread Operator (เครื่องหมายจุดสามตัว) เป็นการสร้างการสำเนาแบบตื้นนั่นเอง

Deep Copy ด้วย JSON.stringify และ JSON.parse

ตรงข้ามกับการสำเนาแบบตื้นคือ Deep Copy (Deep Clone) ที่เป็นการคัดลอกชุดข้อมูลของตัวแปรเดิมมาเป็นค่าใหม่ที่แยกอิสระจากพร็อพเพอร์ตี้ของตัวแปรเก่าโดยสิ้นเชิง การเปลี่ยนแปลงใด ๆ บนตัวแปรเก่าจึงไม่กระทบค่าของตัวแปรใหม่ ในภาษา JavaScript หนึ่งในวิธีสำเนาแบบ Deep Copy ทำได้โดยการใช้ JSON.stringify ควบคู่กับ JSON.parse

JavaScript
1const person = {
2 name: 'Somchai',
3 age: 24,
4 tels: ['0811111111', '0822222222'],
5};
6
7const somchai = JSON.parse(JSON.stringify(person));
8
9person.tels[0] = '0833333333';
10console.log(somchai.tels[0]); // 0811111111

อย่างไรก็ตามการใช้ JSON.stringify ควบคู่กับ JSON.parse จะไม่สำเนาพร็อพเพอร์ตี้บางอย่างเช่น ฟังก์ชัน

JavaScript
1const person = {
2 name: 'Somchai',
3 age: 24,
4 tels: ['0811111111', '0822222222'],
5 printDetails() {
6 console.log(this.name, this.age, this.tels);
7 },
8};
9
10const somchai = JSON.parse(JSON.stringify(person));
11
12somchai.hasOwnProperty('printDetails'); // false

กรณีที่ต้องการทำ Deep Copy อย่างสมบูรณ์สามารถใช้ไลบรารี่อย่าง Lodash ผ่านฟังก์ชัน cloneDeep ได้ ดังนี้

JavaScript
1const somchai = _.cloneDeep(person);
2
3somchai.hasOwnProperty('printDetails'); // true

Structure Cloning คืออะไร

ด้วยโครงสร้างของออบเจ็กต์ใน JavaScript ที่ถูกประกาศอย่างซับซ้อน การส่งค่าออบเจ็กต์ข้ามขอบเขต (Realm) จึงเป็นเรื่องยาก นั่นเพราะออบเจ็กต์ดั้งเดิมอาจมีพร็อพเพอร์ตี้แบบออบเจ็กต์ที่ชี้ไปยังพื้นที่เก็บค่าข้อมูล การจะให้ออบเจ็กต์ใหม่ใน Realm อื่น เข้าถึงพื้นที่จัดเก็บเดียวกันนั้นเป็นไปไม่ได้ ตัวอย่างเช่น หากเรามีออบเจ็กต์หนึ่งและต้องการส่งค่าออบเจ็กต์นี้ไปยัง Web Workers ผ่าน postMessage ออบเจ็กต์ต้องได้รับการการันตีว่าตัวมันเองจะไม่มีการแชร์โครงสร้างภายในร่วมกันข้าม Realm

อีกหนึ่งกรณีของความซับซ้อนในการใช้งานออบเจ็กต์คือการจัดเก็บค่าออบเจ็กต์นี้ลงพื้นที่จัดเก็บ เช่น IndexedDB กระบวนการจัดเก็บหรือ Serialization ต้องการันตีได้ว่าออบเจ็กต์ที่จัดเก็บนั้นมีโครงสร้างที่จะถูกแปลงเพื่อการจัดเก็บได้สมบูรณ์ (Serializable Objects) นอกจากนี้เมื่อต้องการดึงค่าข้อมูลกลับมาผ่านขั้นตอนของ Deserialization ข้อมูลนั้นต้องแปลงกลับเป็นข้อมูลออบเจ็กต์ได้อย่างถูกต้องด้วย

เพื่อให้ขั้นตอนการส่งออบเจ็กต์ข้าม Realm เป็นไปอย่างสมบูรณ์ เราต้องการขั้นตอนของการทำ Serialization และ Deserialization ออบเจ็กต์ให้เป็นรูปแบบที่อิสระจากโครงสร้างของออบเจ็กต์เดิม ขั้นตอนวิธีนี้เรียกว่า Structure Cloning

Structure Cloning ถูกใช้เป็นกลไกภายในของคำสั่งต่าง ๆ เช่น postMessage เมื่อมีการส่งค่าออบเจ็กต์ผ่านคำสั่งนี้ ออบเจ็กต์ดังกล่าวจะถูก Serialize ตามกลวิธีของ Structure Cloning และถูกทำ Deserialize อีกครั้งในฝั่งของ Web Worker

คำสั่ง structuredClone

structuredClone เป็นคำสั่งสำหรับทำสำเนาแบบลึก (deep clone) ด้วยอัลกอริทึมของ Structure Cloning มีสองรูปแบบในการใช้งาน

JavaScript
1structuredClone(value);
2structuredClone(value, { transfer });

สำหรับบทความนี้จะนำเสนอเฉพาะรูปแบบแรกที่ไม่มีส่วนของ transfer

JavaScript
1const person = {
2 name: 'Somchai',
3 age: 24,
4 tels: new Set(['0811111111', '0822222222']),
5};
6
7const somchai = structuredClone(person);
8console.log(person === somchai); // false
9console.log(somchai.tels === person.tels); // false

จากตัวอย่างข้างต้น structuredClone เป็นคำสั่งสำหรับการสำเนาแบบลึกที่ออบเจ็กต์ผลลัพธ์จะไม่ใช่ออบเจ็กต์เดิมอีกต่อไป รวมถึงค่าข้อมูลของพร็อพเพอร์ตี้ต่าง ๆ แม้จะเป็นออบเจ็กต์ก็ยังคงได้รับการทำสำเนา

ขั้นตอนวิธีของ Structure Cloning

โดยทั่วไปแล้ว structuredClone สามารถสำเนา Primitive Values ได้ทั้งหมด

JavaScript
1structuredClone(true) === true; // true
2structuredClone(123) === 123; // true
3structuredClone('hello') === 'hello'; // true

ออบเจ็กต์แบบ Built-in ที่มาพร้อมภาษาส่วนมาก เช่น Date, RegExp, Blob, File, FileList, ArrayBuffer, ArrayBufferView, ImageBitmap, ImageData, Array, Map และ Set ก็สนับสนุนการทำงานกับ structuredClone

JavaScript
1const arr = [1, 2, 3];
2const clone = structuredClone(arr);
3
4Array.isArray(clone); // true
5clone.length === 3; // true

กรณีของการสำเนา RegExp ค่าของพร็อพเพอร์ตี้ lastIndex จะถูกรีเซ็ตเป็น 0 เสมอ

แม้ว่าออบเจ็กต์แบบ Built-in ส่วนใหญ่ล้วนใช้กับ structuredClone ได้ แต่ก็มีบางส่วนที่จะเกิดข้อผิดพลาดเมื่อนำมาใช้กับคำสั่งนี้ เช่น ฟังก์ชัน หรือ DOM nodes โดยข้อผิดพลาดที่เกิดขึ้นจะเป็นชนิด DOMException ที่มีชื่อว่า DataCloneError

JavaScript
1try {
2 structuredClone(() => 'arrow function');
3} catch (error) {
4 console.log(error instanceof DOMException); // true
5 console.log(error.name === 'DataCloneError'); // true
6 console.log(error.code === DOMException.DATA_CLONE_ERR); // true
7}
8
9try {
10 structuredClone({
11 a: 1,
12 b() {
13 console.log('fn');
14 },
15 });
16} catch (error) {
17 console.log(error instanceof DOMException); // true
18 console.log(error.name === 'DataCloneError'); // DataCloneError
19 console.log(error.code === DOMException.DATA_CLONE_ERR);
20}

นอกเหนือจากข้อจำกัดข้างต้น prototype chain ของออบเจ็กต์ต้นฉบับจะไม่ถูกสำเนาด้วยเช่นกัน ทำให้การสำเนาออบเจ็กต์ของคลาสใด ๆ ออบเจ็กต์ใหม่จะไม่ถือเป็น instance ของคลาสนั้นอีกต่อไป กล่าวคือเมื่อสำเนา instance ออบเจ็กต์ใหม่จะเหมือนออบเจ็กต์ทั่วไปที่ถูกคัดค่าพร็อพเพอร์ตี้จาก instance เดิม พร้อมทำการตั้งค่า prototype เป็น Object.prototype

JavaScript
1class Foo {}
2
3const ori = new Foo();
4console.log(ori instanceof Foo); // true
5
6const clone = structuredClone(ori);
7console.log(clone instanceof Foo); // false
8console.log(Object.getPrototypeOf(clone) === Foo.prototype); // false
9console.log(Object.getPrototypeOf(clone) === Object.prototype); // true

ข้อจำกัดอีกประการของ structuredClone คือค่าของ property attributes ในออบเจ็กต์อาจไม่ได้ค่าแบบเดิมเสมอ โดย Accessors จะเปลี่ยนเป็น data properties และ property attributes ใหม่ทุกตัวจะมีค่า default

JavaScript
1const obj = Object.defineProperties(
2 {},
3 {
4 accessor: {
5 get: function () {
6 return 'hello';
7 },
8 set: undefined,
9 enumerable: true,
10 configurable: true,
11 },
12 }
13);
14const clone = structuredClone(obj);
15const desc = Object.getOwnPropertyDescriptors(clone);
16console.log(desc);
17
18// {
19// "accessor": {
20// "value": "hello",
21// "writable": true,
22// "enumerable": true,
23// "configurable": true
24// }
25// }

สรุป

เราสามารถทำ Deep Copy ได้ด้วยคำสั่ง structuredClone ที่สนับสนุนทั้งบน Node, Deno และเว็บเบราว์เซอร์หลักทั่วไป โดยต้องคำนึงถึงว่าไม่ใช่ทุก ๆ พร็อพเพอร์ตี้ที่สามารถสำเนาค่าได้ เช่น การสำเนาฟังก์ชันจะทำให้เกิดข้อผิดพลาด เป็นต้น กรณีที่ต้องการสำเนาแบบ Deep Copy อย่างสมบูรณ์สามารถใช้คำสั่ง cloneDeep ของ Lodash ได้เช่นกัน

เอกสารอ้างอิง

Deep-copying in JavaScript using structuredClone. Retrieved March, 9, 2022, from https://web.dev/structured-clone/

Safe passing of structured data. Retrieved March, 9, 2022, from https://html.spec.whatwg.org/#safe-passing-of-structured-data

structuredClone(). Retrieved March, 9, 2022, from https://developer.mozilla.org/en-US/docs/Web/API/structuredClone

structuredClone(): deeply copying objects in JavaScript. Retrieved March, 9, 2022, from https://2ality.com/2022/01/structured-clone.html

The structured clone algorithm. Retrieved March, 9, 2022, from https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#see_also

สารบัญ

สารบัญ

  • การสำเนาออบเจ็กต์แบบ Shallow Copy
  • Deep Copy ด้วย JSON.stringify และ JSON.parse
  • Structure Cloning คืออะไร
  • คำสั่ง structuredClone
  • ขั้นตอนวิธีของ Structure Cloning
  • สรุป
  • เอกสารอ้างอิง