ตัวลดที่ดีขึ้นด้วย Immer
เผยแพร่แล้ว: 2022-03-10ในฐานะนักพัฒนา React คุณควรคุ้นเคยกับหลักการที่ว่า รัฐไม่ควรกลายพันธุ์โดยตรงอยู่แล้ว คุณอาจสงสัยว่านั่นหมายถึงอะไร (พวกเราส่วนใหญ่มีความสับสนนั้นเมื่อเราเริ่มต้น)
บทช่วยสอนนี้จะใช้ความยุติธรรมกับสิ่งนั้น: คุณจะเข้าใจว่าสถานะที่ไม่เปลี่ยนรูปคืออะไรและจำเป็นสำหรับมัน คุณยังจะได้เรียนรู้วิธีการใช้ Immer เพื่อทำงานกับสถานะที่ไม่เปลี่ยนรูปและประโยชน์ของการใช้มัน คุณสามารถค้นหารหัสในบทความนี้ใน repo Github นี้
ความไม่เปลี่ยนรูปใน JavaScript และเหตุใดจึงสำคัญ
Immer.js เป็นไลบรารี JavaScript ขนาดเล็กที่เขียนขึ้นโดย Michel Weststrate ซึ่งภารกิจดังกล่าวคืออนุญาตให้คุณ "ทำงานกับสถานะที่ไม่เปลี่ยนรูปได้ในวิธีที่สะดวกกว่า"
แต่ก่อนที่จะดำดิ่งสู่ Immer เรามาทบทวนอย่างรวดเร็วเกี่ยวกับความไม่เปลี่ยนรูปใน JavaScript และเหตุใดจึงสำคัญในแอปพลิเคชัน React
มาตรฐาน ECMAScript (aka JavaScript) ล่าสุดกำหนดประเภทข้อมูลในตัวเก้าประเภท จากเก้าประเภทเหล่านี้ มีหกประเภทที่เรียกว่าค่า/ประเภท primitive
พื้นฐานทั้งหกนี้ไม่มีการ undefined
number
string
boolean
bigint
และ symbol
การตรวจสอบอย่างง่ายด้วยตัวดำเนินการ typeof
ของ JavaScript จะเปิดเผยประเภทของประเภทข้อมูลเหล่านี้
console.log(typeof 5) // number console.log(typeof 'name') // string console.log(typeof (1 < 2)) // boolean console.log(typeof undefined) // undefined console.log(typeof Symbol('js')) // symbol console.log(typeof BigInt(900719925474)) // bigint
primitive
คือค่าที่ไม่ใช่อ็อบเจกต์และไม่มีเมธอด สิ่งสำคัญที่สุดสำหรับการอภิปรายในปัจจุบันของเราคือข้อเท็จจริงที่ว่าค่าดั้งเดิมไม่สามารถเปลี่ยนแปลงได้เมื่อสร้างขึ้นแล้ว ดังนั้น ดึกดำบรรพ์จึงกล่าวกันว่า immutable
อีกสามประเภทที่เหลือคือ null
object
และ function
นอกจากนี้เรายังสามารถตรวจสอบประเภทได้โดยใช้ประเภทของ typeof
ดำเนินการ
console.log(typeof null) // object console.log(typeof [0, 1]) // object console.log(typeof {name: 'name'}) // object const f = () => ({}) console.log(typeof f) // function
ประเภทเหล่านี้ mutable
ได้ ซึ่งหมายความว่าสามารถเปลี่ยนแปลงค่าได้ตลอดเวลาหลังจากที่สร้างขึ้น
คุณอาจสงสัยว่าทำไมฉันถึงมีอาร์เรย์ [0, 1]
อยู่ที่นั่น ใน JavaScriptland อาร์เรย์เป็นเพียงวัตถุประเภทพิเศษ ในกรณีที่คุณยังสงสัยเกี่ยวกับ null
และความแตกต่างจาก undefined
อย่างไร undefined
หมายความว่าเราไม่ได้ตั้งค่าตัวแปรในขณะที่ null
เป็นกรณีพิเศษสำหรับอ็อบเจกต์ หากคุณรู้ว่าบางสิ่งควรเป็นวัตถุ แต่ไม่มีวัตถุอยู่ คุณเพียงแค่ส่งคืน null
เพื่อแสดงตัวอย่างง่ายๆ ให้ลองเรียกใช้โค้ดด้านล่างในคอนโซลเบราว์เซอร์ของคุณ
console.log('aeiou'.match(/[x]/gi)) // null console.log('xyzabc'.match(/[x]/gi)) // [ 'x' ]
String.prototype.match
ควรส่งคืนอาร์เรย์ ซึ่งเป็นประเภท object
เมื่อไม่พบวัตถุดังกล่าว จะส่งคืน null
การส่งคืนที่ undefined
ก็ไม่สมเหตุสมผลเช่นกัน
แค่นั้นพอ กลับมาพูดถึงความไม่เปลี่ยนรูปกัน
ตามเอกสาร MDN:
“ทุกประเภทยกเว้นวัตถุกำหนดค่าที่ไม่เปลี่ยนรูป (นั่นคือค่าที่ไม่สามารถเปลี่ยนแปลงได้)”
คำสั่งนี้มีฟังก์ชันเนื่องจากเป็นอ็อบเจ็กต์ JavaScript ชนิดพิเศษ ดูคำจำกัดความของฟังก์ชันที่นี่
มาดูกันว่าในทางปฏิบัติประเภทข้อมูลที่ไม่แน่นอนและไม่เปลี่ยนแปลงนั้นมีความหมายอย่างไร ลองเรียกใช้โค้ดด้านล่างในคอนโซลเบราว์เซอร์ของคุณ
let a = 5; let b = a console.log(`a: ${a}; b: ${b}`) // a: 5; b: 5 b = 7 console.log(`a: ${a}; b: ${b}`) // a: 5; b: 7
ผลลัพธ์ของเราแสดงให้เห็นว่าแม้ว่า b
จะ "ได้มาจาก a
" การเปลี่ยนค่าของ b
จะไม่ส่งผลต่อค่าของ a
สิ่งนี้เกิดขึ้นจากข้อเท็จจริงที่ว่าเมื่อกลไกจัดการ JavaScript รันคำสั่ง b = a
จะสร้างตำแหน่งหน่วยความจำใหม่ที่แยกจากกัน ใส่ 5
ไว้ที่นั่น และจุด b
ที่ตำแหน่งนั้น
แล้ววัตถุล่ะ? พิจารณารหัสด้านล่าง
let c = { name: 'some name'} let d = c; console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"some name"}; d: {"name":"some name"} d.name = 'new name' console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"new name"}; d: {"name":"new name"}
เราจะเห็นว่าการเปลี่ยนคุณสมบัติชื่อผ่านตัวแปร d
จะเปลี่ยนใน c
ด้วย สิ่งนี้เกิดขึ้นจากข้อเท็จจริงที่ว่าเมื่อเอ็นจิ้น JavaScript รันคำสั่ง c = { name: 'some name
'
}
เอ็นจิ้น JavaScript จะสร้างช่องว่างในหน่วยความจำ วางอ็อบเจกต์ไว้ข้างใน และชี้ไปที่ c
จากนั้น เมื่อรันคำสั่ง d = c
น JavaScript จะชี้ d
ไปยังตำแหน่งเดียวกัน มันไม่ได้สร้างตำแหน่งหน่วยความจำใหม่ ดังนั้นการเปลี่ยนแปลงใดๆ กับรายการใน d
จึงเป็นการดำเนินการกับรายการใน c
โดยปริยาย โดยไม่ต้องใช้ความพยายามมาก เราสามารถเห็นได้ว่าทำไมสิ่งนี้ถึงเป็นปัญหาในการสร้าง
ลองนึกภาพว่าคุณกำลังพัฒนาแอปพลิเคชัน React และบางแห่งที่คุณต้องการแสดงชื่อผู้ใช้เป็น some name
โดยอ่านจากตัวแปร c
แต่ที่อื่นคุณได้แนะนำจุดบกพร่องในโค้ดของคุณโดยจัดการอ็อบเจกต์ d
ซึ่งจะส่งผลให้ชื่อผู้ใช้ปรากฏเป็น new name
ถ้า c
และ d
เป็นพื้นฐาน เราจะไม่มีปัญหานั้น แต่พื้นฐานทั่วไปนั้นง่ายเกินไปสำหรับประเภทของสถานะที่แอปพลิเคชัน React ทั่วไปต้องรักษาไว้
นี่เป็นเหตุผลหลักว่าทำไมการรักษาสถานะที่ไม่เปลี่ยนรูปในแอปพลิเคชันของคุณจึงเป็นสิ่งสำคัญ ฉันแนะนำให้คุณตรวจสอบข้อควรพิจารณาอื่นๆ อีกสองสามข้อโดยอ่านส่วนสั้นๆ นี้จาก Immutable.js README: กรณีของการไม่เปลี่ยนรูป
เมื่อเข้าใจแล้วว่าทำไมเราถึงต้องการความไม่เปลี่ยนรูปในแอปพลิเคชัน React ตอนนี้เรามาดูกันว่า Immer จัดการกับปัญหาด้วยฟังก์ชันการ produce
ได้อย่างไร
ฟังก์ชั่นการ produce
ของ Immer
API หลักของ Immer นั้นเล็กมาก และฟังก์ชันหลักที่คุณจะใช้งานคือฟังก์ชันการ produce
produce
เพียงใช้สถานะเริ่มต้นและการโทรกลับที่กำหนดว่าสถานะควรจะกลายพันธุ์อย่างไร การเรียกกลับเองจะได้รับสำเนาของสถานะที่ทำการปรับปรุงทั้งหมด (ที่เหมือนกัน แต่ยังคงเป็นสำเนา) ในที่สุดก็ produce
สถานะใหม่ที่ไม่เปลี่ยนรูปพร้อมกับการเปลี่ยนแปลงทั้งหมดที่ใช้
รูปแบบทั่วไปสำหรับการอัปเดตสถานะประเภทนี้คือ:
// produce signature produce(state, callback) => nextState
เรามาดูกันว่ามันทำงานอย่างไรในทางปฏิบัติ
import produce from 'immer' const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], } // to add a new package const newPackage = { name: 'immer', installed: false } const nextState = produce(initState, draft => { draft.packages.push(newPackage) })
ในโค้ดด้านบนนี้ เราเพียงแค่ส่งผ่านสถานะเริ่มต้นและการเรียกกลับที่ระบุว่าเราต้องการให้การกลายพันธุ์เกิดขึ้นอย่างไร มันง่ายอย่างนั้น เราไม่จำเป็นต้องสัมผัสส่วนอื่นของรัฐ มันปล่อย initState
ไม่ถูกแตะต้อง และแบ่งปันโครงสร้างส่วนต่างๆ ของรัฐที่เราไม่ได้สัมผัสระหว่างสถานะเริ่มต้นและสถานะใหม่ ส่วนหนึ่งในรัฐของเราคืออาร์เรย์ pets
การ produce
d nextState
เป็นแผนผังสถานะที่ไม่เปลี่ยนรูปซึ่งมีการเปลี่ยนแปลงที่เราได้ทำรวมถึงส่วนที่เราไม่ได้แก้ไข
ด้วยความรู้ที่เรียบง่าย แต่มีประโยชน์นี้ มาดูกันว่าการ produce
สามารถช่วยให้เราลดความซับซ้อนของ React ของเราได้อย่างไร
การเขียนตัวลดขนาดด้วย Immer
สมมติว่าเรามีวัตถุสถานะที่กำหนดไว้ด้านล่าง
const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], };
และเราต้องการเพิ่มวัตถุใหม่และในขั้นตอนต่อไป ให้ตั้งค่าคีย์ที่ installed
true
const newPackage = { name: 'immer', installed: false };
หากเราทำสิ่งนี้ตามปกติกับวัตถุ JavaScript และไวยากรณ์การแพร่กระจายอาร์เรย์ ตัวลดสถานะของเราอาจมีลักษณะดังนี้
const updateReducer = (state = initState, action) => { switch (action.type) { case 'ADD_PACKAGE': return { ...state, packages: [...state.packages, action.package], }; case 'UPDATE_INSTALLED': return { ...state, packages: state.packages.map(pack => pack.name === action.name ? { ...pack, installed: action.installed } : pack ), }; default: return state; } };
เราจะเห็นได้ว่านี่เป็นรายละเอียดที่ไม่จำเป็นและมีแนวโน้มที่จะผิดพลาดสำหรับวัตถุสถานะที่ค่อนข้างง่ายนี้ เรายังต้องสัมผัสทุกส่วนของรัฐซึ่งไม่จำเป็น เรามาดูกันว่าเราจะทำให้สิ่งนี้ง่ายขึ้นด้วย Immer ได้อย่างไร
const updateReducerWithProduce = (state = initState, action) => produce(state, draft => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'UPDATE_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
และด้วยโค้ดไม่กี่บรรทัด เราได้ลดความซับซ้อนของตัวลดของเราลงอย่างมาก นอกจากนี้ หากเราตกอยู่ในกรณีเริ่มต้น Immer จะคืนค่าสถานะแบบร่างโดยที่เราไม่ต้องดำเนินการใดๆ สังเกตว่ามีรหัสสำเร็จรูปน้อยลงและกำจัดการแพร่กระจายของรัฐ ด้วย Immer เรากังวลเฉพาะส่วนของรัฐที่เราต้องการอัปเดตเท่านั้น หากเราไม่พบรายการดังกล่าว เช่นเดียวกับในการดำเนินการ `UPDATE_INSTALLED' เราก็ดำเนินการต่อไปโดยไม่แตะต้องสิ่งอื่นใด ฟังก์ชัน 'ผลิต' ยังช่วยให้แกงกะหรี่อีกด้วย การส่งการเรียกกลับเป็นอาร์กิวเมนต์แรกที่ "ผลิต" มีวัตถุประสงค์เพื่อใช้สำหรับการแกง ลายเซ็นของแกงกะหรี่ 'ผลิต' คือ //curried produce signature produce(callback) => (state) => nextState
เรามาดูกันว่าเราจะปรับปรุงสถานะก่อนหน้าของเราด้วยผลิตภัณฑ์แกงกะหรี่ได้อย่างไร ผลิตภัณฑ์แกงกะหรี่ของเราจะมีลักษณะดังนี้: const curriedProduce = produce((draft, action) => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'SET_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
ฟังก์ชันผลิตผลแกงกะหรี่ยอมรับฟังก์ชันเป็นอาร์กิวเมนต์แรกและส่งกลับผลผลิตที่แกงกะหรี่ซึ่งตอนนี้ต้องการเพียงสถานะที่จะให้สถานะถัดไป อาร์กิวเมนต์แรกของฟังก์ชันคือสถานะแบบร่าง (ซึ่งจะได้มาจากสถานะที่จะถูกส่งต่อเมื่อเรียกผลิตภัณฑ์แกงกะหรี่นี้) จากนั้นติดตามอาร์กิวเมนต์ทุกจำนวนที่เราต้องการส่งต่อไปยังฟังก์ชัน
สิ่งที่เราต้องทำตอนนี้เพื่อใช้ฟังก์ชันนี้คือส่งผ่านในสถานะที่เราต้องการสร้างสถานะถัดไปและวัตถุการกระทำเช่นนั้น
// add a new package to the starting state const nextState = curriedProduce(initState, { type: 'ADD_PACKAGE', package: newPackage, }); // update an item in the recently produced state const nextState2 = curriedProduce(nextState, { type: 'SET_INSTALLED', name: 'immer', installed: true, });
โปรดทราบว่าในแอปพลิเคชัน React เมื่อใช้ useReducer
hook เราไม่จำเป็นต้องส่งสถานะอย่างชัดแจ้งเหมือนที่ฉันได้ทำไว้ข้างต้น เพราะเราจะดูแลเรื่องนั้นเอง
คุณอาจสงสัยว่า Immer จะได้รับ hook
เหมือนทุกอย่างใน React หรือไม่? คุณอยู่ในบริษัทพร้อมกับข่าวดี Immer มีสอง hooks สำหรับการทำงานกับสถานะ: useImmer
และ useImmerReducer
hooks เรามาดูกันว่าพวกเขาทำงานอย่างไร
การใช้ the useImmer
และ useImmerReducer
Hooks
คำอธิบายที่ดีที่สุดของ useImmer
hook มาจาก use-immer README เอง
useImmer(initialState)
คล้ายกับuseState
มาก ฟังก์ชันส่งคืน tuple ค่าแรกของ tuple คือสถานะปัจจุบัน ฟังก์ชันที่สองคือฟังก์ชัน updater ซึ่งยอมรับฟังก์ชัน immer Producer ซึ่งdraft
สามารถกลายพันธุ์ได้อย่างอิสระ จนกว่าผู้ผลิตจะสิ้นสุดและจะทำการเปลี่ยนแปลง ไม่เปลี่ยนรูปและกลายเป็นสถานะต่อไป
หากต้องการใช้ hooks เหล่านี้ คุณต้องติดตั้งแยกต่างหาก นอกเหนือจากไลบรารี Immer หลัก
yarn add immer use-immer
ในแง่ของ useImmer
hook มีลักษณะดังนี้
import React from "react"; import { useImmer } from "use-immer"; const initState = {} const [ data, updateData ] = useImmer(initState)
และมันก็ง่ายอย่างนั้น คุณสามารถพูดได้ว่ามันคือ useState ของ React แต่มีสเตียรอยด์เล็กน้อย การใช้ฟังก์ชั่นอัพเดทนั้นง่ายมาก ได้รับสถานะร่างและคุณสามารถแก้ไขได้มากเท่าที่คุณต้องการด้านล่าง
// make changes to data updateData(draft => { // modify the draft as much as you want. })
ผู้สร้าง Immer ได้จัดเตรียมตัวอย่างโค้ดแซนด์บ็อกซ์ซึ่งคุณสามารถทดลองเล่นเพื่อดูว่ามันทำงานอย่างไร
useImmerReducer
นั้นใช้งานง่ายเหมือนกันหากคุณเคยใช้เบ็ด useReducer
ของ React มีลายเซ็นที่คล้ายกัน มาดูกันว่าหน้าตาจะเป็นอย่างไรในแง่ของโค้ด
import React from "react"; import { useImmerReducer } from "use-immer"; const initState = {} const reducer = (draft, action) => { switch(action.type) { default: break; } } const [data, dataDispatch] = useImmerReducer(reducer, initState);
เราจะเห็นได้ว่าตัวลดขนาดได้รับสถานะ draft
ซึ่งเราสามารถปรับเปลี่ยนได้มากเท่าที่เราต้องการ นอกจากนี้ยังมีตัวอย่างโค้ดแซนด์บ็อกซ์ให้คุณทดลองด้วย
และนั่นคือความง่ายในการใช้ Immer hooks แต่ในกรณีที่คุณยังคงสงสัยว่าเหตุใดคุณจึงควรใช้ Immer ในโครงการของคุณ นี่คือบทสรุปของเหตุผลที่สำคัญที่สุดบางประการที่ฉันพบว่าใช้ Immer
ทำไมคุณควรใช้ Immer
หากคุณได้เขียนตรรกะการจัดการสถานะไว้เป็นระยะเวลาหนึ่ง คุณจะประทับใจกับความเรียบง่ายที่เสนอโดย Immer ได้อย่างรวดเร็ว แต่นั่นไม่ใช่ข้อดีเพียงอย่างเดียวของ Immer
เมื่อคุณใช้ Immer คุณจะต้องเขียนโค้ดต้นแบบน้อยลง ตามที่เราเคยเห็นด้วยตัวลดขนาดที่ค่อนข้างง่าย นอกจากนี้ยังทำให้การอัปเดตเชิงลึกค่อนข้างง่าย
ด้วยไลบรารี่เช่น Immutable.js คุณต้องเรียนรู้ API ใหม่เพื่อเก็บเกี่ยวผลประโยชน์ของการไม่เปลี่ยนรูป แต่ด้วย Immer คุณจะบรรลุสิ่งเดียวกันด้วย JavaScript Objects
, Arrays
, Sets
และ Maps
ปกติ ไม่มีอะไรใหม่ให้เรียนรู้
Immer ยังจัดเตรียมการแบ่งปันโครงสร้างตามค่าเริ่มต้น นี่หมายความว่าเมื่อคุณทำการเปลี่ยนแปลงกับอ็อบเจ็กต์สถานะ Immer จะแบ่งปันส่วนที่ไม่เปลี่ยนแปลงของรัฐระหว่างสถานะใหม่กับสถานะก่อนหน้าโดยอัตโนมัติ
ด้วย Immer คุณยังได้รับการแช่แข็งวัตถุโดยอัตโนมัติ ซึ่งหมายความว่าคุณไม่สามารถเปลี่ยนแปลงสถานะที่ produced
ได้ ตัวอย่างเช่น เมื่อฉันเริ่มใช้ Immer ฉันพยายามใช้วิธี sort
กับอาร์เรย์ของวัตถุที่ส่งคืนโดยฟังก์ชันการผลิตของ Immer มันเกิดข้อผิดพลาดบอกฉันว่าฉันไม่สามารถเปลี่ยนแปลงอาร์เรย์ได้ ฉันต้องใช้วิธีอาร์เรย์สไลซ์ก่อนที่จะใช้ sort
อีกครั้งที่ nextState
ที่ผลิตขึ้นนั้นเป็นต้นไม้สถานะที่ไม่เปลี่ยนรูป
Immer ยังพิมพ์ได้ดีและมีขนาดเล็กมากเพียง 3KB เมื่อ gzipped
บทสรุป
เมื่อพูดถึงการจัดการการอัปเดตสถานะ การใช้ Immer ไม่ใช่เรื่องง่ายสำหรับฉัน เป็นไลบรารี่ขนาดเล็กมากที่ช่วยให้คุณใช้ทุกสิ่งที่คุณได้เรียนรู้เกี่ยวกับ JavaScript ต่อไปได้โดยไม่ต้องพยายามเรียนรู้สิ่งใหม่ทั้งหมด ฉันแนะนำให้คุณติดตั้งในโครงการของคุณและเริ่มใช้งานได้ทันที คุณสามารถเพิ่มการใช้งานในโครงการที่มีอยู่และอัปเดตตัวลดของคุณทีละน้อย
ฉันยังสนับสนุนให้คุณอ่านโพสต์บล็อกแนะนำ Immer โดย Michael Weststrate ส่วนที่ฉันพบว่าน่าสนใจเป็นพิเศษคือ "Immer ทำงานอย่างไร" ส่วนที่อธิบายวิธีที่ Immer ใช้ประโยชน์จากคุณลักษณะทางภาษา เช่น พร็อกซี่และแนวคิด เช่น การคัดลอกเมื่อเขียน
ฉันยังสนับสนุนให้คุณดูโพสต์บนบล็อกนี้: ความไม่เปลี่ยนรูปใน JavaScript: มุมมองที่ตรงกันข้าม ซึ่งผู้แต่ง Steven de Salas ได้นำเสนอความคิดของเขาเกี่ยวกับข้อดีของการใฝ่หาความไม่เปลี่ยนรูป
ฉันหวังว่าสิ่งที่คุณได้เรียนรู้ในโพสต์นี้จะช่วยให้คุณเริ่มใช้ Immer ได้ทันที
แหล่งข้อมูลที่เกี่ยวข้อง
-
use-immer
, GitHub - อิมเมอร์, GitHub
-
function
, เอกสารเว็บ MDN, Mozilla -
proxy
, เอกสารเว็บ MDN, Mozilla - วัตถุ (วิทยาการคอมพิวเตอร์), Wikipedia
- “ไม่เปลี่ยนรูปใน JS” Orji Chidi Matthew, GitHub
- “ECMAScript ประเภทข้อมูลและค่า” Ecma International
- คอลเลกชันที่ไม่เปลี่ยนรูปสำหรับ JavaScript, Immutable.js , GitHub
- “กรณีของการไม่เปลี่ยนรูป” Immutable.js , GitHub