使用Redux-Toolkit,由“object is not extensible”引发的思考及解决方案
文章目錄
- 問題重述
- 使用redux-toolkit
- 使用`createSlice()`
- 在`index.js`中融合兩個silce
- 在store.js配置store,并使用`<Provider store={store}>`讓所有組件都可以使用redux中管理的狀態(tài)
- 繪制節(jié)點鏈接圖
- 報錯重現(xiàn)
- 解決方案
- 采用拷貝對象的方式解決(笨方法)
- 把redux中存儲的數(shù)據對象替換為數(shù)據名
- 產生原因分析
- immer.js => 不可變數(shù)據結構
- Redux-toolkit中```createSlice()```的使用
- 狀態(tài)的不可變性,為什么會引入Immer.js
- 更改狀態(tài)的兩種方式:```reset```與```replace```
- 如何輸出當前狀態(tài)
- 為什么會引入Immer?
- 思考
問題重述
? 最近在做一個數(shù)據瀏覽平臺,如圖所示
? 大致的編碼邏輯是左上角的數(shù)據集選擇器,控制全局UI的改變。比如左部的樹形控件數(shù)據,畫布中的節(jié)點鏈接圖等等,都是根據當前所選的數(shù)據集來定的。這種組件間的狀態(tài)復用,自然而然就想到把數(shù)據集作為一個狀態(tài)來交給redux管理。
使用redux-toolkit
? 好的,現(xiàn)在開始查redux官方文檔。因為剛學會react,教程中redux的store中使用的是createStore()創(chuàng)建的,但是這個方法目前已經棄用了,官方建議使用的是configureStore()。經過一番文檔的查閱,開始使用createSlice()來重寫reducer。
使用createSlice()
? 這里直接貼上我這部分slice的錯誤代碼
- 創(chuàng)建slice
slice有兩個導出,一個是在內部負責操作狀態(tài)的action;一個是reducer
我還有一另外一個selectionSlice負責管理其他的狀態(tài),這里考慮到篇幅就不給出了。
在index.js中融合兩個silce
// redux/index.js import optionReducer from './optionSlice'; //注意,這里引入的是slice中導出的reducer,slice有兩個導出:reducer和action import selectionReducer from "./selectionSlice"export const reducers = {option: optionReducer,selection: selectionReducer }在store.js配置store,并使用<Provider store={store}>讓所有組件都可以使用redux中管理的狀態(tài)
- 配置store
- 添加Provider
? 在App標簽外部套上<Provider>標簽
import { createRoot } from 'react-dom/client' import App from './App' import { BrowserRouter } from "react-router-dom"; import { Provider } from 'react-redux'; import { store } from './redux/store'createRoot(document.getElementById('root')).render(<Provider store={store}><App /></Provider> )繪制節(jié)點鏈接圖
? 用戶選擇一份數(shù)據集,就會把這份數(shù)據集交給redux管理,在其他組件中如果想要取用數(shù)據集,使用useSelector(state => state.option.data)即可取用。問題就發(fā)生在這一步
? 我先簡述一下我的代碼:
// componnets/Canvas/index.jsxexport function Canvas(){const data = useSelector(state => state.option.data)useLayoutEffect(()=>{initCanvas() //drawLayout},[data])const initCanvas = () => {// .....append canvasconst nodes = data.nodesconst links = data.links//append circle,line......let simulation = d3.forceSimulation(nodes).force("link", d3.forceLink(links).id(d => {return d.mgmt_ip}).strength(0.5).distance(10))//......some force option.on("tick",()=>{//refresh canvas})}return (<div><div className="container"> </div></div>)}? 這個代碼很簡單,我在useLayoutEffect()這個鉤子里編寫了一個畫布初始化函數(shù)initCanvas()。目的是讓組件掛載前,先在一個<div>中添加一個canvas,并繪制出數(shù)據。
報錯重現(xiàn)
? 結果這個代碼直接報紅了,報了一個我從沒見過的錯誤: “Uncaught TypeError:Cannot add property vx,object is extensible”
? 從這個報錯信息很容易就能知道,是在我設置力模擬器時,調用d3.forceLink(links)綁定連邊,和綁定節(jié)點時,無法像邊數(shù)據和點數(shù)據中添加vx,vy等屬性導致的。
? 為了進一步驗證這個特點,我用以下代碼驗證了我拿到的數(shù)據是否真的不可拓展
const nodes = data.nodes const links = data.links nodes.forEach(node => {console.log(node.isExtensible()) }) links.forEach(link=>{console.log(link.isExtensible()) })? 毫無意外,控制臺輸出了清一色的false。
解決方案
采用拷貝對象的方式解決(笨方法)
? 所以這個問題可以基本確定是因為我的數(shù)據不可拓展造成的,雖然不知道為什么。但是解決這個的辦法無非就是讓我的數(shù)據能夠被拓展。但是搜了半天解除不可拓展性的辦法,找不到。于是只能采用拷貝對象的方式,拷貝一份新的對象。
? 拷貝分兩種方式:淺拷貝與深拷貝。在有指針的情況下,淺拷貝只是增加了一個指針指向已經存在的內存,而深拷貝就是增加一個指針并且申請一個新的內存,使這個增加的指針指向這個新的內存。顯然,我們需要使用深拷貝,申請一個新的內存存放拷貝的對象。
? nodes與links數(shù)組中存放的obj如圖所示:
{nodes:[{id:xxxx,role:xxxx,type:xxxx} ],links:[{source:xxxx,target:xxxx,} ], }? 因此我們使用對象拓展符{...node},{...link}即可完成深拷貝,具體代碼如下:
const newNode = nodes.map(node => ({...node})); const newLink = links.map(link => ({...link}))? 接著我們使用newNode和newLink替換原來的nodes和links,就OK了。
把redux中存儲的數(shù)據對象替換為數(shù)據名
? 上面的辦法顯然很蠢······。我慢慢開始意識到這個對象的不可拓展性很可能是redux幫我處理的,因為我們在redux中存放的數(shù)據應該由對應的reducer來進行更改,如果外部能夠更改會導致UI組件中獲取的狀態(tài)出現(xiàn)錯誤。
? 因為發(fā)現(xiàn)這個問題已經很晚了,我沒有急著去驗證的想法是不是對的,因為我想趕緊把我的蠢方法換掉,讓我的程序看起來別那么爛。我之前建立了一個函數(shù)幫我提供數(shù)據集,代碼如下:
import case1 from "../assets/case1.json" import case2 from "../assets/case2.json" import case3 from "../assets/case3.json"export default function generate() {const datasets = {case1,case2,case3}return datasets; }export const dataSets = generate();? 這么做的目的是我在組件中直接使用import {dataSets} from "../util/getData.js"就能獲取到全部數(shù)據集了。
? 寫到這,應該很明白了。正確的思路應該是將數(shù)據集的名字,如case1,case2,case3…交給redux來管理,用戶每次切換數(shù)據集,就通知reducer更改當前的數(shù)據集名稱。在組件中如果想要使用數(shù)據的話就以下代碼來獲取。這么做顯然比把整份數(shù)據交給redux管理更加合理。
import {dataSets} from "../util/getData.jsfunction Component(){const dataName = useSelector(state => state.option.dataName)const data = dataSets[dataName]//func bodyreturn .... }這里貼上一個正確代碼,和之前相比,我把交給redux管理的狀態(tài)從data換成了dataName
// redux/optionSlice.js import { createSlice } from "@reduxjs/toolkit"; import { HIGHLIGHT } from "./constant";/**data option */ export const optionSlice = createSlice({name: 'option',initialState: {dataName: "case1",mode: HIGHLIGHT},reducers: {changedata: ((state, action) => {state.dataName = action.payload}),changemode: ((state, action) => {state.mode = action.payload})} }) export const { changedata, changemode } = optionSlice.actions export default optionSlice.reducer? 其實已經發(fā)現(xiàn)區(qū)別了,在修改之前,我把整個數(shù)據集data = {nodes:[...],links:[...]}作為了整個狀態(tài)存放到了redux中。而修改之后,我只存了數(shù)據集的名稱,使用的時候用這個名稱去一個存放了所有dataSets的地方取。這顯然是一種更加合理的編碼方式。
產生原因分析
immer.js => 不可變數(shù)據結構
? 基本能夠初步確定redux-toolkit在返回新狀態(tài)值的時候,設置了返回的obj是不可擴展的。為了驗證我的猜想,我去redux-toolkit官網找到了下面這篇Writing Reducers with Immer
? Immer,Immer是什么?讀這篇文章第一句話
Redux Toolkit’s createReducer and createSlice automatically use [Immer]
(https://immerjs.github.io/immer/) internally to let you write simpler immutable update logic using “mutating” syntax. This helps simplify most reducer implementations.
譯文:Redux ToolkitcreateReducer并在內部createSlice自動使用Immer讓您使用“mutating”語法編寫更簡單的不可變的更新邏輯。這有助于簡化大多數(shù) reducer 實現(xiàn)。
? immutable update logic不可變的更新邏輯,我想我找到答案了。于是我去google了immer.js,
? 在它的中文官方文檔中,有一段這么介紹的話:
Immer can be used in any context in which immutable data structures need to be used. For example in combination with React state, React or Redux reducers, or configuration management. Immutable data structures allow for (efficient) change detection: if the reference to an object didn’t change, the object itself did not change. In addition, it makes cloning relatively cheap: Unchanged parts of a data tree don’t need to be copied and are shared in memory with older versions of the same state.
譯文:Immer 可以在需要使用不可變數(shù)據結構的任何上下文中使用。例如與 React state、React 或 Redux reducers 或者 configuration management 結合使用。不可變的數(shù)據結構允許(高效)的變化檢測:如果對對象的引用沒有改變,那么對象本身也沒有改變。此外,它使克隆對象相對便宜:數(shù)據樹的未更改部分不需要復制,并且在內存中與相同狀態(tài)的舊版本共享
? 看完這兩段話,比較抽象,直接看官方給的代碼示例:
- 有一個Todo列表,我們要對它進行更新
- 不使用Immer
- 使用Immer
? 從上可以看出,使用Immer會把更改應用當前的草稿draft上,它是當前狀態(tài)的代理,一旦我們完成了所有的更改,Immer會根據draft上state的更改生成新的nextState,工作原理示意圖如下:
? 引用官方文檔中的一段話,來解釋Immer的作用
Using Immer is like having a personal assistant. The assistant takes a letter (the current state) and gives you a copy (draft) to jot changes onto. Once you are done, the assistant will take your draft and produce the real immutable, final letter for you (the next state).
使用 Immer 就像擁有一個私人助理。助手拿一封信(當前狀態(tài))并給您一份副本(草稿)以記錄更改。完成后,助手將接受您的草稿并為您生成真正不變的最終信件(下一個狀態(tài))。
? 這個“私人助理”其實是一個代理對象Proxy,我在redux中也做了進一步的驗證。
// /redux/optionSlice.jsreducers: {changedata: ((state, action) => {console.log(state)state.dataName = action.payload}),changemode: ((state, action) => {state.mode = action.payload})}? 我在代碼中打印了state,并在控制臺查看了它的輸出,確實是一個Proxy對象。
Redux-toolkit中createSlice()的使用
在淺了解了Immer.js后,我回到官方文檔中閱讀剩余部分。并整理了以下對我可能有幫助的點
狀態(tài)的不可變性,為什么會引入Immer.js
? 要分析狀態(tài)的不可變性,首先我們要引入的一個問題是Redux中不可改變狀態(tài)的幾個原因。官方文檔中列出了五條原因,但我認為最重要的是第一條:會導致bug,例如UI無法正確更新顯示最新值。
? 那么redux不能更改原始狀態(tài),我們如何返回更新后的狀態(tài)呢?答案是在Reducer中只能拷貝原始值,修改副本并返回副本。如:
// ? This is safe, because we made a copy return {...state,value: 123, }? 這也讓我想到了之前在寫類組件時,必須要用拷貝的方式修改,如
setState(state => {{...state,key:newValue} })? 我猜和Immer.js也有關系。
? 這樣修改當然OK沒有問題,但是如果狀態(tài)之中嵌套了許多層,那么我們需要對每一層都進行拷貝,這樣的代碼維護方式顯然是災難一樣的存在!這里我貼上官網給的例子。
手動編寫不可變的更新邏輯很困難,并且在 reducer 中意外改變狀態(tài)是 Redux 用戶最常犯的一個錯誤。
function handwrittenReducer(state, action) {return {...state,first: {...state.first,second: {...state.first.second,[action.someId]: {...state.first.second[action.someId],fourth: action.someValue,},},},} }? 所以,引入了Immer,Immer是一個庫,簡化了編寫不可變更新邏輯的過程。Immer的工作流程我們在上文中已經介紹過了,這里不做過多贅述,值得注意的是,ReactToolkit的createReducer和createSlice都在內部使用了Immer。上文我也已經驗證過了state是一個代理。
更改狀態(tài)的兩種方式:reset與replace
- reset
- replace
這里有一個易錯的地方,就是有一些修改函數(shù)會有默認返回值,那么在修改狀態(tài)后有一個返回值,reducer就不知道應該使用哪個值作為最新的狀態(tài)了。如
reducers: {// ? ERROR: mutates state, but also returns new array size!brokenReducer: (state, action) => state.push(action.payload),// ? SAFE: the `void` keyword prevents a return valuefixedReducer1: (state, action) => void state.push(action.payload),// ? SAFE: curly braces make this a function body and no returnfixedReducer2: (state, action) => {state.push(action.payload)},如何輸出當前狀態(tài)
? 想要從reducer中記錄正在進行的狀態(tài)以查看它在更新時的樣子,這個場景是很常見的。但不幸的是,直接輸出state是一個Proxy對象。為了解決這個問題,Immer提供了一個函數(shù)current(),如果需要查看狀態(tài)可以使用它
reducers: {todoToggled(state, action) {// ? ERROR: logs the Proxy-wrapped dataconsole.log(state)// ? CORRECT: logs a plain JS copy of the current dataconsole.log(current(state))},},為什么會引入Immer?
? 下面三點是我對官方文檔的一個總結與復述
-
使用Immer的優(yōu)點
- Immer極大簡化了不可變的更新邏輯
- 減少了reducer更新狀態(tài)的編寫錯誤。引入Immer后,無需創(chuàng)建副本,直接進行修改即可。(相當于你把修改的工作交給了一個代理,由代理幫你進行修改)
-
Immer在性能上的權衡
- 無需考慮,reducer幾乎從來都不是Redux應用中的性能瓶頸
-
是否考慮未來將Immer設置為可選項?
- 我有預感很多人在簡單看了Redux-toolkit文檔就拿去用了以后,都會給它們提Issue。因為這個對象的不可變性稍微不留意就會出錯(但是習慣了它們的寫法以后其實效率提升很多)。官方文檔中也給出了為什么不打算將Immer設置為可選項的理由,它們說React-toolkit的架構是通過直接導入Immer來實現(xiàn)的,需要在應用程序加載期間立即同步使用Immer。
And finally: Immer is built into RTK by default because we believe it is the best choice for our users! We want our users to be using Immer, and consider it to be a critical non-negotiable component of RTK. The great benefits like simpler reducer code and preventing accidental mutations far outweigh the relatively small concerns.
最后:**Immer 默認內置在 React-toolkit 中,因為我們相信它是我們用戶的最佳選擇!**我們希望我們的用戶使用 Immer,并將其視為 React-toolkit 的關鍵組件。更簡單的 reducer 代碼和防止意外突變等巨大好處,遠遠超過了那些可以被忽視的問題。
思考
? 這是我解決問題的完整過程,最近在做項目,寫了好久的文檔,好久沒有沉淀自己的代碼能力了。碰巧周日,碰巧遇到了一個值得記錄的問題,趕緊把自己的思考過程落實在了文字。
? 從組件中選擇狀態(tài)升格為全局這是一個值得思考的問題,我也認為這是很考驗一個React寫手能力的工作。最近剛入門React,淺記錄一下解決問題的全過程。
總結
以上是生活随笔為你收集整理的使用Redux-Toolkit,由“object is not extensible”引发的思考及解决方案的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: php获取页面视频文件,PHP获取各大视
- 下一篇: 编程面试刷题神器