Introduction
Immer德语意为always,他让我们更方便的处理“不可变状态”,大小仅有3kb(gzip后)。
Immer simplifies handling immutable data structures
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.
不可变数据优点在于允许高效的进行变化检测:对象引用没改变时、对象本身也没改变。也会使克隆对象相对便宜:数据树里未改变的部分不需要复制,且在内存中与相同状态的旧版本共享。
缺点是:写起来很麻烦,一般来说,你会需要不断地...
展开符、或者使用lodash-es.cloneDeep
之类的深复制去创建新状态的样板代码。有些时候也会很意外的违反不可变的约束。Immer通过以下行为帮助我们避免这些缺点:
- 检测意外状态变动然后error
- 不需要你创建样板代码和使用
...
展开符。draft
对象会记录更改并创建必要的副本,不会影响原始对象。 - 不需要学习专用的API和数据结构:使用原生js
一个案例
const baseState = [
{
title: "Learn TypeScript",
done: true
},
{
title: "Try Immer",
done: false
}
]
// 1. 不使用Immer
const nextState = baseState.slice() // 浅拷贝数组
nextState[1] = {
// 替换第一层元素
...nextState[1], // 浅拷贝第一层元素
done: true // 期望的更新
}
// 因为 nextState 是新拷贝的, 所以使用 push 方法是安全的,
// 但是在未来的任意时间做相同的事情会违反不变性原则并且导致 bug!
nextState.push({title: "Tweet about it"})
// 2. 使用Immer
import produce from "immer"
const nextState = produce(baseState, draft => {
draft[1].done = true
draft.push({title: "Tweet about it"})
})
How Immer works
安装
版本选择
可以选择加入的功能有:
- ES5支持:
enableES5()
- ES5 Map and Set支持:
enableMapSet()
- JSON Patch支持:
enablePatches()
- All:
enableAllPlugins()
使用
// 在你的应用程序入口文件
import {enableMapSet} from "immer"
enableMapSet()
// ...然后
import produce from "immer"
const usersById_v1 = new Map([
["michel", {name: "Michel Weststrate", country: "NL"}]
])
const usersById_v2 = produce(usersById_v1, draft => {
draft.get("michel").country = "UK"
})
expect(usersById_v1.get("michel").country).toBe("NL")
expect(usersById_v2.get("michel").country).toBe("UK")
使用produce
Immer提供了一个能完成所有功能的默认函数。
produce(currentState, recipe: (draftState) => void): nextState
The interesting thing about Immer is that the
baseState
will be untouched, but thenextState
will reflect all changes made todraftState
.Inside the recipe, all standard JavaScript APIs can be used on the
draft
object, including field assignments,delete
operations, and mutating array, Map and Set operations likepush
,pop
,splice
,set
,sort
,remove
, etc.Any of those mutations don't have to happen at the root, but it is allowed to modify anything anywhere deep inside the draft:
draft.todos[0].tags["urgent"].author.age = 56
- 传入一个基础状态和一个
recipe
函数,这个函数用于对传入的draft
进行各种更改 recipe
中你可以使用所有标准JS APImutations
中的任何一个都不必发生在初始对象上,但是可以修改draft
深处的任何内容recipe
函数通常不会返回任何内容draft
参数的命名不绝对
Curried producers
Currying(柯里化)
柯里化使一种函数的转换,将一函数从fn(a,b,c)
转换为fn(a)(b)
两个案例
案例1(一般实现):
function curry(f) { // curry(f) 执行柯里化转换
return function(a) {
return function(b) {
return f(a, b);
};
};
}
// 用法
function sum(a, b) {
return a + b;
}
let curriedSum = curry(sum);
alert( curriedSum(1)(2) ); // 3
使用两个包装器(wrapper)实现
curry(func)
的结果就是一个包装器function(a)
。- 当它被像
curriedSum(1)
这样调用时,它的参数会被保存在词法环境中,然后返回一个新的包装器function(b)
。 - 然后这个包装器被以
2
为参数调用,并且,它将该调用传递给原始的sum
函数。
案例2(高级实现):
lodash的curry
,返回一个包装器,允许函数被正常调用或者以偏函数(partial)的方式调用:
function sum(a, b) {
return a + b;
}
let curriedSum = _.curry(sum); // 使用来自 lodash 库的 _.curry
alert( curriedSum(1, 2) ); // 3,仍可正常调用
alert( curriedSum(1)(2) ); // 3,以偏函数的方式调用
柯里化的好处
// 1. 用于打印日志的函数
function log(date,importance,message) {
alert(`[${date.getHours()}:${date.getMinutes()}] [${importance}] ${message}`)
}
// 2. 柯里化
log = _.curry(log)
// 2.1 正常调用
log(new Date(),"DEBUG","some debug");
// 2.2 柯里化运行
log(new Date()).("DEBUG").("some debug");
// 3. 创建偏函数
let logNow = log(new Date());
logNow("INFO","message");
let debugNow = logNow("DEBUG");
debugNow("message");
可以看到,柯里化(高级)后,我们:
- 依然可以正常调用该函数
- 还可以轻松生成偏函数
高级柯里化实现
function curry(func) {
return function curried(...args) {
if (args.length >= func.length) {
return func.apply(this,args);
} else {
return function(...args2) {
return curried.apply(this,args.concat(args2));
}
}
}
}
curry(func)
调用的结果是包装器curried
- 如果传入的
args
长度与原始函数func.length
定义的长度相同或更长,直接低矮用func.apply
将调用传给它 - 否则,获取一个偏函数(确定参数
args
),返回一个包装器来重新应用curried
,将之前还传入的参数与新参数一起传入
function sum(a, b, c) {
return a + b + c;
}
let curriedSum = curry(sum);
alert( curriedSum(1, 2, 3) ); // 仍然可以被正常调用
alert( curriedSum(1)(2,3) ); // 对第一个参数的柯里化
alert( curriedSum(1)(2)(3) ); // 全柯里化
要点
- 柯里化仅允许用于具有确定参数长度的函数(需要比较原函数的长度
func.length
- 我们更常用的使高级版的柯里化
Immer中的produce柯里化使用
一般使用
import produce from "immer"
function toggleTodo(state, id) {
return produce(state, draft => {
const todo = draft.find(todo => todo.id === id)
todo.done = !todo.done
})
}
const baseState = [
{
id: "JavaScript",
title: "Learn TypeScript",
done: true
},
{
id: "Immer",
title: "Try Immer",
done: false
}
]
const nextState = toggleTodo(baseState, "Immer")
柯里化使用
import produce from "immer"
// curried producer:
const toggleTodo = produce((draft, id) => {
const todo = draft.find(todo => todo.id === id)
todo.done = !todo.done
})
const baseState = [
/* as is */
]
const nextState = toggleTodo(baseState, "Immer")
其中,produce
仅接受recipe函数,并返回一个应用recipe到基础状态的新函数。这非常适合react的useState
React & Immer
useState + Immer
react的useState
假定存储与其中的state不可变,这很适合react
一般使用:
import React, { useCallback, useState } from "react";
import produce from "immer";
const TodoList = () => {
const [todos, setTodos] = useState([
{
id: "React",
title: "Learn React",
done: true
},
{
id: "Immer",
title: "Try Immer",
done: false
}
]);
const handleToggle = useCallback((id) => {
setTodos(
produce((draft) => {
const todo = draft.find((todo) => todo.id === id);
todo.done = !todo.done;
})
);
}, []);
const handleAdd = useCallback(() => {
setTodos(
produce((draft) => {
draft.push({
id: "todo_" + Math.random(),
title: "A new todo",
done: false
});
})
);
}, []);
return (<></>)
}
useImmer
使用包use-immer
简化上述操作
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
});
});
}, []);
// etc
useReducer + Immer
一般用法:
import React, {useCallback, useReducer} from "react"
import produce from "immer"
const TodoList = () => {
const [todos, dispatch] = useReducer(
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
}
}),
[
/* initial todos */
]
)
const handleToggle = useCallback(id => {
dispatch({
type: "toggle",
id
})
}, [])
const handleAdd = useCallback(() => {
dispatch({
type: "add",
id: "todo_" + Math.random()
})
}, [])
// etc
}
useImmerReducer
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 */ ]
);
//etc
Update patterns
在没有Immer之前,我们使用不可变数据就意味着要学习所有的不可变数据的更新模式(cloneDeep、...)。现在我们应该忘记掉这些更新模式。来看下面的代码吧:
更新对象
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"}) // 可以一次插入多个:todos.unshift(...items)
})
// 根据 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)
})
Note that when working with arrays that contain objects that are typically identified by some id, we recommend to use
Map
or index based objects (as shown above) instead of performing frequent find operations, lookup tables perform much better in general.
注:当处理包含这由某id标识的对象组成的数组时,Immer建议使用Map
数据类型或者基于索引的对象代替执行频繁的查找操作,查找表的效率一般会高一些。