immer.js 实战讲解文档

  • 2021.02.22

简介

Immer.js是 Mobx作者写的Immutable库,利用ES6的 proxy进行实现,巧妙的实现了Javascript不可变数据结构,使用上比Immutable.js方便了不少。

我们都知道在Javascript中变量类型可以分为 基本类型引用类型

引用类型的使用中,经常会产生一些无法意识到的副作用(比如在不想要改变原有数据结构的改变了原来的数据结构)。

数据处理问题

我们先看这么一个例子:

let currentState = {
  p: {
    x: [2],
  },
}

以下操作都会修改到原有的数据

// way1 直接修改
let o1 = currentState;
o1.p = 1; // currentState 被修改了
o1.p.x = 1; // currentState 被修改了


// way2 通过函数修改
function fn(o) {
  o.p1 = 1;
  return o;
};
fn(currentState); // currentState 被修改了

// way3 通过结构修改
let o3 = {
  ...currentState
};
o3.p.x = 1; // currentState 被修改了

// way4
let o4 = currentState;
o4.p.x.push(1); // currentState 被修改了

如何解决上述产生的问题?

  1. 深度拷贝,但是深拷贝的成本较高,会影响性能;

  2. 使用 ImmutableJS,跟 Immer 比起来,ImmutableJS 有两个较大的不足:

    • 需要使用者学习它的数据结构操作方式,没有 Immer 提供的使用原生对象的操作方式简单、易用;

    • 它的操作结果需要通过toJS方法才能得到原生对象,这使得在操作一个对象的时候,时刻要注意操作的是原生对象还是 ImmutableJS 的返回结果,稍不注意,就会产生意想不到的 bug。

immer功能介绍

安装

npm i --save immer

yarn add immer

解决方案

  • 处理way1、way3
import produce from 'immer';
let o1 = produce(currentState, draft => {
  draft.p.x = 1;
})
  • 处理way2
import produce from 'immer';
function fn(o) {
  return produce(o, draft => {
    draft.p1 = 1;
  })
};

fn(currentState);
  • 处理way4
import produce from 'immer';
let o4 = produce(currentState, draft => {
  draft.p.x.push(1);
})

概念说明

Immer 涉及概念不多,在此将涉及到的概念先行罗列出来:

  • currentState

    被操作对象的最初状态。

  • draftState

    根据 currentState 生成的草稿(副本),对 draftState 所做的任何修改都将被记录并用于生成 nextState在此过程中,currentState 将不受影响

  • nextState

    根据 draftState 生成的最终状态。

  • produce 生产

    用来生成 nextState 或 producer 的函数。

  • roducer 生产者

    通过 produce 生成,用来生产 nextState ,每次执行相同的操作。

  • recipe 生产机器

    用来操作 draftState 的函数。

API使用说明

produce

import produce from 'immer';
// or
import { produce } from 'immer';

produce(currentState, recipe: (draftState) => void | draftState, ?PatchListener): nextState

//eg1
let nextState = produce(currentState, (draft) => {

})

currentState === nextState; // true

// eg2
let currentState = {
  a: [],
  p: {
    x: 1
  }
}

let nextState = produce(currentState, (draft) => {
  draft.a.push(2);
})

currentState.a === nextState.a; // false
currentState.p === nextState.p; // true

自动冻结功能

Immer 还在内部做了一件很巧妙的事情,那就是通过 produce 生成的 nextState 是被冻结(freeze)的,(Immer 内部使用Object.freeze方法,只冻结 nextState 跟 currentState 相比修改的部分),这样,当直接修改 nextState 时,将会报错。这使得 nextState 成为了真正的不可变数据。

let nextState = produce(currentState, (draft) => {
  draft.p.x.push(2);
})

currentState === nextState; // true

produce(recipe: (draftState) => void | draftState, ?PatchListener)(currentState): nextState

let producer = produce((draft) => {
  draft.x = 2
});
let nextState = producer(currentState);

recipe的返回值

recipe 是否有返回值,nextState 的生成过程是不同的:

  • recipe 没有返回值时:nextState 是根据 recipe 函数内的 draftState 生成的;

  • recipe 有返回值时:nextState 是根据 recipe 函数的返回值生成的;

let nextState = produce(
  currentState, 
  (draftState) => {
    return {
      x: 2
    }
  }
)

此时,nextState 不再是通过 draftState 生成的了,而是通过 recipe 的返回值生成的。

recipe中的this

recipe 函数内部的this指向 draftState ,也就是修改this与修改 recipe 的参数 draftState ,效果是一样的。

WARNING

注意:此处的 recipe 函数不能是箭头函数,如果是箭头函数,this就无法指向 draftState 了。

produce(currentState, function(draft){
  // 此处,this 指向 draftState
  draft === this; // true
})

patch补丁功能

使用patch可以方便的进行代码的调整和跟踪,追踪recipe 内的做的每次修改。

interface Patch {
  op: "replace" | "remove" | "add" // 一次更改的动作类型
  path: (string | number)[] // 此属性指从树根到被更改树杈的路径
  value?: any // op为 replace、add 时,才有此属性,表示新的赋值
}
produce(
  currentState, 
  recipe,
  // 通过 patchListener 函数,暴露正向和反向的补丁数组
  patchListener: (patches: Patch[], inversePatches: Patch[]) => void
)

applyPatches(currentState, changes: (patches | inversePatches)[]): nextState
import produce, { applyPatches } from "immer"

let state = {
  x: 1
}

let replaces = [];
let inverseReplaces = [];

state = produce(
  state,
  draft => {
    draft.x = 2;
    draft.y = 2;
  },
  (patches, inversePatches) => {
    replaces = patches.filter(patch => patch.op === 'replace');
    inverseReplaces = inversePatches.filter(patch => patch.op === 'replace');
  }
)

state = produce(state, draft => {
  draft.x = 3;
})
console.log('state1', state); // { x: 3, y: 2 }

state = applyPatches(state, replaces);
console.log('state2', state); // { x: 2, y: 2 }

state = produce(state, draft => {
  draft.x = 4;
})
console.log('state3', state); // { x: 4, y: 2 }

state = applyPatches(state, inverseReplaces);
console.log('state4', state); // { x: 1, y: 2 }

patches数据如下:

[
  {
    op: "replace",
    path: ["x"],
    value: 2
  },
  {
    op: "add",
    path: ["y"],
    value: 2
  },
]

inversePatches数据如下:

[
  {
    op: "replace",
    path: ["x"],
    value: 1
  },
  {
    op: "remove",
    path: ["y"],
  },
]

patchListener内部对数据操作做了记录,并分别存储为正向操作记录和反向操作记录,供我们使用。

使用immer优化react代码

首先定义一个state对象,后面的例子使用到变量state或访问this.state时,如无特殊声明,都是指这个state对象

state = {
  members: [
    {
      name: 'ronffy',
      age: 30
    }
  ]
}

Q: 如何给members中的第一个成员年龄加上一岁?

// 方式一
const { members } = this.state;
this.setState({
  members: [
    {
      ...members[0],
      age: members[0].age + 1,
    },
    ...members.slice(1),
  ]
})

// 方式二
this.setState(state => {
  const { members } = state;
  return {
    members: [
      {
        ...members[0],
        age: members[0].age + 1,
      },
      ...members.slice(1)
    ]
  }
})

// 方式三
const { members } = this.state;
const newMembers = [...members];
newMembers[0].age += 1;

this.setState({
    members: newMembers
})

// 使用immer更新state
import produce from "immer";

this.setState(produce(draft => {
  draft.members[0].age++;
}))

为什么需要在react中使用immer?

在探讨这个话题之前,我们先再次了解下什么是不可变数据。

不可变数据指的就是当你修改一个数据的时候,这个数据会给你返回一个新的引用,而它自己的引用保持不变,有点像是经常用到的数组的map方法:

const arr1 = [1, 2, 3];
const arr2 = arr1.map(item => item * 10);

console.log(arr1 === arr2)
// false

通过这种方式每次修改数据,新返回的数据就和原来不相等了。

TIP

如果数据变更,节点类型不相同的时候会怎样呢?React 的做法非常简单粗暴,直接将 原 VDOM 树上该节点以及该节点下所有的后代节点 全部删除,然后替换为新 VDOM 树上同一位置的节点,当然这个节点的后代节点也全都跟着过来了。

这样的话非常浪费性能,父组件数据一变化,子组件全部都移除,再换新的,所以才有了shouldComponentUpdate这个生命周期,如果返回false的话子组件就不会更新,但是每次在这个函数里面写对比会很麻烦,所以有了PureComponent和Memo,但是只提供了浅比较,所以这时候不可变数据就派上用场了,每次修改数据都和原数据不相等的话,就可以精确的控制更新。

在Hooks中,我们还可以使用 use-immer来替代你的useState。

yarn add immer use-immer

import React from "react";
import { useImmer } from "use-immer";


export default function () {
  const [person, setPerson] = useImmer({
    name: "Sally",
    salary: '3000'
  });

  function setName(name) {
    setPerson(draft => {
      draft.name = name;
    });
  }

  function becomeRicher() {
    setPerson(draft => {
      draft.salary += '$¥';
    });
  }

  return (
    <div className="App">
      <h1>
        {person.name} ({person.salary})
      </h1>
      <input
        onChange={e => {
          setName(e.target.value);
        }}
        value={person.name}
      />
      <br />
      <button onClick={becomeRicher}>变富</button>
    </div>
  );
}

useImmer的用法和useState十分相似,在保持住了简洁性的同时还具备了immutable的数据结构,十分便捷。

use-immer对useReducer进行了加强封装,同样也几乎没什么学习成本:

import React from "react";
import { useImmerReducer } from "use-immer";

const initialState = { salary: 0 };

function reducer(draft, action) {
  switch (action.type) {
    case "reset":
      return initialState;
    case "increment":
      return void draft.salary++;
    case "decrement":
      return void draft.salary--;
  }
}

export default function () {
  const [state, dispatch] = useImmerReducer(reducer, initialState);
  return (
    <>
      期待工资: {state.salary}K
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "reset" })}>重置</button>
    </>
  );
}
上次更新时间: 2021-02-22 22:09:00