Immer 最佳实践(走心教程)

29,804 阅读9分钟

一、前言

Immer  是 mobx 的作者写的一个 immutable 库,核心实现是利用 ES6 的 proxy,几乎以最小的成本实现了 js 的不可变数据结构,简单易用、体量小巧、设计巧妙,满足了我们对 JS 不可变数据结构的需求。

二、学习前提

阅读这篇文章需要以下知识储备:

  • JavaScript 基础语法
  • es6 基础语法
  • node、npm 基础知识

三、历史背景

在 js 中,处理数据一直存在一个问题:

拷贝一个值的时候,如果这个值是引用类型(比如对象、数组),直接赋值给另一个变量的时候,会把值的引用也拷贝过去,在修改新变量的过程中,旧的变量也会被一起修改掉。

要解决这个问题,通常我们不会直接赋值,而是会选择使用深拷贝,比如JSON.parse(JSON.stringify()),再比如 lodash 为我们提供的 cloneDeep 方法……

但是,深拷贝并不是十全十美的。

这个时候,immer 诞生了!

四、immer 功能介绍

基本思想是,使用 Immer,会将所有更改应用到临时  draft,它是  currentState  的代理。一旦你完成了所有的  mutations,Immer 将根据对  draft state  的  mutations  生成 nextState。这意味着你可以通过简单地修改数据来与数据交互,同时保留不可变数据的所有好处。

immer-4002b3fd2cfd3aa66c62ecc525663c0d.png

一个简单的比较示例

const baseState = [
  {
    title: 'Learn TypeScript',
    done: true,
  },
  {
    title: 'Try Immer',
    done: false,
  },
];

假设我们有上述基本状态,我们需要更新第二个 todo,并添加第三个。但是,我们不想改变原始的 baseState,我们也想避免深度克隆以保留第一个 todo

不使用 Immer

如果没有 Immer,我们将不得不小心地浅拷贝每层受我们更改影响的 state 结构

const nextState = [...baseState]; // 浅拷贝数组

nextState[1] = {
  // 替换第一层元素
  ...nextState[1], // 浅拷贝第一层元素
  done: true, // 期望的更新
};

// 因为 nextState 是新拷贝的, 所以使用 push 方法是安全的,
// 但是在未来的任意时间做相同的事情会违反不变性原则并且导致 bug!
nextState.push({ title: 'Tweet about it' });

使用 Immer

使用 Immer,这个过程更加简单。我们可以利用  produce  函数,它将我们要更改的 state 作为第一个参数,对于第二个参数,我们传递一个名为 recipe 的函数,该函数传递一个  draft  参数,我们可以对其应用直接的  mutations。一旦  recipe  执行完成,这些  mutations  被记录并用于产生下一个状态。 produce  将负责所有必要的复制,并通过冻结数据来防止未来的意外修改。

import produce from 'immer';

const nextState = produce(baseState, draft => {
  draft[1].done = true;
  draft.push({ title: 'Tweet about it' });
});

使用 Immer 就像拥有一个私人助理。助手拿一封信(当前状态)并给您一份副本(草稿)以记录更改。完成后,助手将接受您的草稿并为您生成真正不变的最终信件(下一个状态)。

第二个示例

如果有一个层级很深的对象,你在使用 redux 的时候,想在 reducer 中修改它的某个属性,但是根据 reduce 的原则,我们不能直接修改 state,而是必须返回一个新的 state

不使用 Immer

const someReducer = (state, action) => {
  return {
    ...state,
    first: {
      ...state.first,
      second: {
        ...state.first.second,
        third: {
          ...state.first.second.third,
          value: action,
        },
      },
    },
  };
};

使用 Immer

const someReducer = (state, action) => {
  state.first.second.third.value = action;
};

好处

  • 遵循不可变数据范式,同时使用普通的 JavaScript 对象、数组、Sets 和 Maps。无需学习新的 API 或 "mutations patterns"!
  • 强类型,无基于字符串的路径选择器等
  • 开箱即用的结构共享
  • 开箱即用的对象冻结
  • 深度更新轻而易举
  • 样板代码减少。更少的噪音,更简洁的代码

更新模式

在 Immer 之前,使用不可变数据意味着学习所有不可变的更新模式。

为了帮助“忘记”这些模式,这里概述了如何利用内置 JavaScript API 来更新对象和集合

更新对象

import produce from 'immer';

const todosObj = {
  id1: { done: false, body: 'Take out the trash' },
  id2: { done: false, body: 'Check Email' },
};

// 添加
const addedTodosObj = produce(todosObj, draft => {
  draft['id3'] = { done: false, body: 'Buy bananas' };
});

// 删除
const deletedTodosObj = produce(todosObj, draft => {
  delete draft['id1'];
});

// 更新
const updatedTodosObj = produce(todosObj, draft => {
  draft['id1'].done = true;
});

更新数组

import produce from 'immer';

const todosArray = [
  { id: 'id1', done: false, body: 'Take out the trash' },
  { id: 'id2', done: false, body: 'Check Email' },
];

// 添加
const addedTodosArray = produce(todosArray, draft => {
  draft.push({ id: 'id3', done: false, body: 'Buy bananas' });
});

// 索引删除
const deletedTodosArray = produce(todosArray, draft => {
  draft.splice(3 /*索引 */, 1);
});

// 索引更新
const updatedTodosArray = produce(todosArray, draft => {
  draft[3].done = true;
});

// 索引插入
const updatedTodosArray = produce(todosArray, draft => {
  draft.splice(3, 0, { id: 'id3', done: false, body: 'Buy bananas' });
});

// 删除最后一个元素
const updatedTodosArray = produce(todosArray, draft => {
  draft.pop();
});

// 删除第一个元素
const updatedTodosArray = produce(todosArray, draft => {
  draft.shift();
});

// 数组开头添加元素
const addedTodosArray = produce(todosArray, draft => {
  draft.unshift({ id: 'id3', done: false, body: 'Buy bananas' });
});

// 根据 id 删除
const deletedTodosArray = produce(todosArray, draft => {
  const index = draft.findIndex(todo => todo.id === 'id1');
  if (index !== -1) {
    draft.splice(index, 1);
  }
});

// 根据 id 更新
const updatedTodosArray = produce(todosArray, draft => {
  const index = draft.findIndex(todo => todo.id === 'id1');
  if (index !== -1) {
    draft[index].done = true;
  }
});

// 过滤
const updatedTodosArray = produce(todosArray, draft => {
  // 过滤器实际上会返回一个不可变的状态,但是如果过滤器不是处于对象的顶层,这个依然很有用
  return draft.filter(todo => todo.done);
});

嵌套数据结构

import produce from 'immer';

// 复杂数据结构例子
const store = {
  users: new Map([
    [
      '17',
      {
        name: 'Michel',
        todos: [{ title: 'Get coffee', done: false }],
      },
    ],
  ]),
};

// 深度更新
const nextStore = produce(store, draft => {
  draft.users.get('17').todos[0].done = true;
});

// 过滤
const nextStore = produce(store, draft => {
  const user = draft.users.get('17');
  user.todos = user.todos.filter(todo => todo.done);
});

异步 producers & createDraft

允许从 recipe 返回 Promise 对象。或者使用 async / await。这对于长时间运行的进程非常有用,只有在 Promise 链解析后才生成新对象

注意,如果 producer 是异步的,produce 本身也会返回一个 promise。

例子:

import produce from 'immer';

const user = { name: 'michel', todos: [] };

const loadedUser = await produce(user, async draft => {
  draft.todos = await (await fetch('http://host/' + draft.name)).json();
});

请注意,draft 不应从异步程序中“泄露”并存储在其他地方。异步过程完成后,draft 仍将被释放

createDraftfinishDraft

createDraftfinishDraft 是两个底层函数,它们对于在 immer 之上构建抽象的库非常有用。避免了为了使用 draft 始终创建函数。

相反,人们可以创建一个 draft,对其进行修改,并在未来的某个时间完成该 draft,在这种情况下,将产生下一个不可变状态。

例如,我们可以将上面的示例重写为:

import { createDraft, finishDraft } from 'immer';

const user = { name: 'michel', todos: [] };

const draft = createDraft(user);
draft.todos = await (await fetch('http://host/' + draft.name)).json();
const loadedUser = finishDraft(draft);

五、性能提示

预冻结数据

当向 Immer producer 中的状态树添加大型数据集时(例如从 JSON 端接收的数据),可以在首先添加的数据的最外层调用 freeze(json) 来浅冻结它。这将允许 Immer 更快地将新数据添加到树中,因为它将避免递归扫描和冻结新数据的需要。

可以随时选择退出

immer 在任何地方都是可选的,因此手动编写性能非常苛刻的 reducers ,并将 immer 用于所有普通的的 reducers 是非常好的。即使在 producer 内部,您也可以通过使用 originalcurrent 函数来选择退出 Immer 的某些部分逻辑,并对纯 JavaScript 对象执行一些操作。

对于性能消耗大的的搜索操作,从原始 state 读取,而不是 draft

Immer 会将您在 draft 中读取的任何内容也递归地转换为 draft。如果您对涉及大量读取操作的 draft 进行昂贵的无副作用操作,例如在非常大的数组中使用 find(Index) 查找索引,您可以通过首先进行搜索,并且只在知道索引后调用 produce 来加快速度。这样可以阻止 Immer 将在 draft 中搜索到的所有内容都进行转换。或者,使用 original(someDraft) 对 draft 的原始值执行搜索,这归结为同样的事情。

将 produce 拉到尽可能远的地方

始终尝试将 produce “向上”拉动,例如 for (let x of y) produce(base, d => d.push(x))produce(base, d => { for (let x of y) ) d.push(x)}) 慢得多

六、陷阱

不要重新分配 recipe 参数

永远不要重新分配 draft 参数(例如:draft = myNewState)。相反,要么修改 draft,要么返回新状态。

Immer 只支持单向树

Immer 假设您的状态是单向树。也就是说,任何对象都不应该在树中出现两次,也不应该有循环引用。从根到树的任何节点应该只有一条路径。

永远不要从 producer 那里显式返回 undefined

可以从 producers 返回值,但不能以这种方式返回 undefined,因为它与根本不更新 draft 没有区别!

不要修改特殊对象

Immer 不支持特殊对象 比如 window.location

只有有效的索引和长度可以在数组上改变

对于数组,只能改变数值属性和 length 属性。自定义属性不会保留在数组上。

只有来自 state 的数据会被 draft

请注意,来自闭包而不是来自基本 state 的数据将永远不会被 draft,即使数据已成为新 darft 的一部分

const onReceiveTodo = todo => {
  const nextTodos = produce(todos, draft => {
    draft.todos[todo.id] = todo;
    // 注意,因为 todo 来自外部,而不是 draft,所以他不会被 draft,
    // 所以下面的修改会影响原来的 todo!
    draft.todos[todo.id].done = true;

    // 上面的代码相当于
    todo.done = true;
    draft.todos[todo.id] = todo;
  });
};

始终使用嵌套 producers 的结果

支持嵌套调用 produce,但请注意 produce 将始终产生新状态,因此即使将 draft 传递给嵌套 produce,内部 produce 所做的更改也不会在传递给它的 draft 中可见,只会反映在产生的输出中。

换句话说,当使用嵌套 produce 时,您会得到 draft 的 draft,并且内部 produce 的结果会被合并回原始 draft(或返回)

错误示范:

// 嵌套的错误写法:
produce(state, draft => {
  produce(draft.user, userDraft => {
    userDraft.name += '!';
  });
});

正确示范:

// 嵌套的正确写法:
produce(state, draft => {
  draft.user = produce(draft.user, userDraft => {
    userDraft.name += '!';
  });
});

Drafts 在引用上不相等

Immer 中的 draft 对象包装在 Proxy 中,因此您不能使用 == 或 === 来测试原始对象与其 draft 之间的相等性,相反,可以使用 original:

const remove = produce((list, element) => {
  const index = list.indexOf(element); // 不会工作!
  const index = original(list).indexOf(element); // 用这个!
  if (index !== -1) {
    list.splice(index, 1);
  }
});

const values = [a, b, c];
remove(values, a);

如果可以的话,建议在 produce 函数之外执行比较,或者使用 .id 之类的唯一标识符属性,以避免需要使用 original