ตัวลดที่ดีขึ้นด้วย 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
