使用Immer解决React对象深度更新的痛点

2023-09-06 13:42:14 浏览数 (1)

前言

最近接到一个需求,修改一个使用React编写的工单系统,具体就是在创建工单的时候能配置一些增强工单通用性的功能然后把配置传给后端进行存储,乍一听其实挺简单,但是由于数据结构没设计好,写的时候非常的麻烦。

复杂对象的更新

在组件中,工单的所有参数都保存在一个对象中,像这样

代码语言:javascript复制
const [formConfig,setFormConfig] = useState(
    {
        type: '',// 类型
        desc: '',// 描述
        relatedPerson: { // 关联负责人
            author: {
                name: '',
                phone: ''
            }
        },
        fieldForm: [ // 字段
            {
                fieldName: '',
                fieldCode: '',
                //...
            },
            {
                fieldName: '',
                fieldCode: '',
                //...
            }
        ]
        //...
    }
)

由于对象的结构很复杂,在更新的时候就尤其的麻烦。

比如,我想修改工单的表单第二个字段的名称,那我可能就需要这样写

代码语言:javascript复制
  setFormConfig((prevState) => {
      ...prevState,
      fieldForm:prevState.fieldForm.map((item,idx) => {
          if(idx === selectIndex){
              return {
                  ...item,
                  fieldName:newName
              }
          }
          return item
      })
  }); 

这样的写法不难看出很多问题:

  • 我们不得不写很多操作修改以外的代码
  • 每深入对象一层,扩展语法后的路径也需要再进一层(如 ...prevState) ,在复制粘贴过程中极易弄错弄丢
  • 由于工单的所有参数可配置,组件里面到处都充斥着这样的代码,让代码可读性变得很差。

React的心智负担

为什么要这样写?

  • React 不允许直接更改state ,而应该使用 setState
  • setState 会合并更改(merge update),所以不需要手写完整的state,但是合并仅限于对象属性的第一级
  • setState 会 异步 地触发re-render,所以不要直接依赖 state (此时的 state.xxx 不一定是彼时的state.xxx),即下面的写法是有潜在bug的
代码语言:javascript复制
  setFormConfig({
      ...formConfig,
      fieldForm:formConfig.fieldForm.map((item,idx) => {
          if(idx === 1){
              return {
                  ...item,
                  fieldName:newName
              }
          }
          return item
      })
  }); 

对象深拷贝

既然不能直接在原对象上修改,那我们可以先深拷贝出一个新的对象,然后直接更改新对象的属性

代码语言:javascript复制
  let tempFormConfig = deepClone(formConfig);
  tempFormConfig.fieldForm[1].fieldName = newName
  setFormConfig(tempFormConfig); 

这样写代码量确实减少了很多,可读性也提高不少,但是,这种方案有明显的性能问题 —— 不管打算更新对象的哪一个属性(子节点),每次都不得不深拷贝整个对象;当对象特别大的时候,深拷贝会导致性能问题。

那么怎么样避免深拷贝所有属性,而只针对目标属性(子节点)?

为了解决这种问题,Immer来了

Immer初登场

那么Immer是个啥呢,用官方的话说就是

Immer (German for: always) is a tiny package that allows you to work with immutable state in a more convenient way. Immer可以帮助我们更方便的处理不可变的状态。

怎么用呢,Immer提供了一个produce方法

代码语言:javascript复制
produce(baseState, recipe: (draftState) => void): nextState

produce方法需要传入一个基本状态,以及一个修改传入状态的函数,在修改状态的函数中,所有标准的JavaScriptAPI都可以用于draft(草稿)对象,然后返回一个新的状态,但是原始的状态不会受到影响。

以前面修改表单配置的方法为例,使用Immer我们上面的状态修改就可以这样写:

代码语言:javascript复制
import {produce} from "immer"  
 
setFormConfig(prevState => {
    return produce(draft => {  
        draft.fieldForm[1].fieldName = newName
    })
})

如果你熟悉柯里化,你还可以这样写

代码语言:javascript复制
import {produce} from "immer"  
 
setFormConfig(produce(draft => {  
        draft.fieldForm[1].fieldName = newName
}))

是不是瞬间感觉非常的清爽,我们通过Immer提供的produce方法,可以直接像深拷贝那样,在新对象上做修改

更重要的是,在 immer 的背后做了性能优化,而不是简单的全部深度拷贝,所以不用担心性能问题

Immer 的优点

Immer有着许多便捷和性能上的优势:

  • 遵循不可变数据范式,同时使用普通的JavaScript对象、数组、集合和映射,上手即用
  • 开箱即用的结构共享
  • 开箱即用对象冻结
  • 更新轻而易举
  • 冗余代码更少
  • 对JSON补丁的一流支持
  • 仅有3KB

Immer工作原理

  • 当我们调用 immer 的 API produce时,immer 将内部暂时存储着我们的目标对象(以 state 为例)
  • immer 暴露一个 draft (草稿)给我们
  • 我们在 draft 上作修改
  • immer 接收修改后的draft,immer 基于传入的 state 照着draft 的修改 返回一个新的 state

Immer Hook

如果你觉得每次调用setState的时候都需要配合使用一次produce函数很冗余,没关系,Immer也有对应的React Hook方法

将produce封装到useState中的useImmer

代码语言:javascript复制
import React, { useCallback } from "react";
import { useImmer } from "use-immer";

const TodoList = () => {
  const [todos, setTodos] = useImmer([
    {
      id: "React",
      title: "Learn React",
      done: true
    },
    {
      id: "Immer",
      title: "Try Immer",
      done: false
    }
  ]);

  const handleToggle = useCallback((id) => {
    setTodos((draft) => {
      const todo = draft.find((todo) => todo.id === id);
      todo.done = !todo.done;
    });
  }, []);

  const handleAdd = useCallback(() => {
    setTodos((draft) => {
      draft.push({
        id: "todo_"   Math.random(),
        title: "A new todo",
        done: false
      });
    });
  }, []);

将produce封装到useReducer中的useImmerReducer

代码语言:javascript复制
import React, { useCallback } from "react";
import { useImmerReducer } from "use-immer";

const TodoList = () => {
  const [todos, dispatch] = useImmerReducer(
    (draft, action) => {
      switch (action.type) {
        case "toggle":
          const todo = draft.find((todo) => todo.id === action.id);
          todo.done = !todo.done;
          break;
        case "add":
          draft.push({
            id: action.id,
            title: "A new todo",
            done: false
          });
          break;
        default:
          break;
      }
    },
    [ /* initial todos */ ]
  );

以及配合Redux来使用

代码语言:javascript复制
import {produce} from "immer"  
  
// Reducer with initial state  
const INITIAL_STATE = [  
    /* bunch of todos */  
]  
  
const todosReducer = produce((draft, action) => {  
    switch (action.type) {  
    case "toggle":  
        const todo = draft.find(todo => todo.id === action.id)  
        todo.done = !todo.done  
        break  
    case "add":  
        draft.push({  
            id: action.id,  
            title: "A new todo",  
            done: false  
        })  
        break  
    default:  
        break  
    }  
})

最后

感谢你能看到这里,本文简单介绍了用于不可变对象更新的工具库Immer的使用方法,希望对你有用,当然,如果可以的话不妨点个赞再走呢,这对我很重要。

参考

immerjs.github.io/immer/

zhuanlan.zhihu.com/p/146773995

0 人点赞