❝
原文链接:Introducing Immer: Immutability the easy way[1],2018.01.11,by Michel Weststrate
❞
不可变(immutable)、数据结构共享(structurally shared)的数据结构是在存储状态时一个很好范式。尤其是与事件溯源架构(event-sourcing architecture)结合使用时。不过,是有付出一定代价的。JavaScript 并没有内置不变性,从当前状态生成下一个状态是一件枯燥乏味的任务。

Immer 就是来解决这个问题的,不仅解决了,而且还很优雅。本文我们就来学习 Immer 的基本使用以及它的工作原理。
你可以保存下面这段样板代码,方便在你本地的浏览器中进行测试。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script src="https://unpkg.com/immer@9"></script>
<script>
const currentState = { count: 0 }
const nextState = immer.produce(currentState, draft => {
draft.count++
})
console.log(nextState === currentState, { nextState, currentState})
</script>
</body>
</html>
❝
注意:Immer 从 v10 版本开始,不再支持 UMD 导出,因此我们使用的是 v9 版本。
❞
Immer 支持 npm、CDN 两种使用方式。除了具体的引用方式有点不同之外,CDN 与 npm 使用方式完全一致。
我们先从 Immer 的基本使用开始,会涉及到几个简单的概念的学习。
Producers
Immer 通过编写 producer 来工作,最简单的 producer 如下所示:
import produce from 'immer'
const nextState = produce(currentState, draft => {
// empty function
})
console.log(nextState === currenState) // true
空 producer 将返回原始状态
produce 函数包含两个参数。currentState 和 producer 函数。当前状态决定状态修改的起点,producer 中描述发生的变化。producer 函数接收一个参数 draft,是当前传入状态的一个代理。对 draft 所做的任何修改都将被记录并用于生成 nextState,在这个过程中 currentState 始终保持不变。
上面的示例中,由于 producer 并没有修改任何内容,因此 nextStat 与 currentState 相等。
现在来看看,如果 producer 中我们对 draft 进行了修改会发生什么。
import produce from 'immer'
const todos = [ /* 2 todo objects in here */ ]
const nextTodos = produce(todos, draft => {
draft.push({ text: "learn immer", done: true })
draft[1].done = true
})
// 旧状态没变
console.log(todos.length) // 2
console.log(todos[1].done) // false
// 新状态反映的 draft 修改后的结果
console.log(nextTodos.length) // 3
console.log(nextTodos[1].done) // true
// todos 与 nextTodos 有一样的数据结构
// 但是—— 两者是不同的对象
console.log(todos === nextTodos) // false
console.log(todos[0] === nextTodos[0]) // true
console.log(todos[1] === nextTodos[1]) // false
producer 中对 draft 的修改的最终结果会被赋值给 nextTodos
这里是 produce 函数的一个实际使用场景。我们基于 draft 创建了一个新的状态对象,并且向其中添加了一个额外的待办事项,并且新的状态对象与旧的状态对象结构一样,但却是两个不同的对象。
还要注意的是,我们并没有在 producer 中返回任何值,只是对 draft 进行修改,最终结果也能返回。
有 produce
加持的 Redux reducer
现在我们学习了如何产生新的状态。接下来,我们会基于 Redux 官方购物车案例代码,修改其中的 reducer 部分,来阐述 Immer 的作用。
// Shortened, based on: https://github.com/reactjs/redux/blob/master/examples/shopping-cart/src/reducers/products.js
const byId = (state, action) => {
switch (action.type) {
case RECEIVE_PRODUCTS:
return {
...state,
...action.products.reduce((obj, product) => {
obj[product.id] = product
return obj
}, {})
}
default:
return state
}
}
这块代码中存在两个问题:
-
必须创建一个新的状态对象,并且需要使用对象解构来混入新的数据以及保留旧的数据 -
如果 reducer 中不做任何修改,确保现有的状态被返回
使用 Immer,我们只需要知道如何修改当前状态就行,不需要写为了保留旧状态很多样板代码。所以当我们在 reducer 中使用 produce
时,我们的代码就变成了下面这样:
const byId = (state, action) {
produce(state, draft => {
switch (action.type) {
case RECEIVE_PRODUCTS:
action.procucts.forEach(product => {
draft[product.id] = product
})
}
})
}
使用 Immer 简化 reducer 的书写。
这块代码跟上述代码的作用完全相同,不过 RECEIVE_PRODUCTS action 的更新逻辑变得更简单了,代码噪音基本消除,我们也不需要处理默认状况——draft 不修改的情况下相当于直接返回原来状态。
无需任何附加条件
Immer 中对 draft 的修改就像普通的对象修改一样,无需你学习任何其他的附加概念。Immer 在内置的 JavaScript 结构上运行,修改数据的方式就是你已经熟悉了的 API。
自动冻结
Immer 的另一个很酷的功能是它会自动冻结你使用 produce 创建的任何数据结构。冻结整个状态的成本相当昂贵,而 Immer 可以只冻结更改的部分,因此会非常高效。如果你的所有状态都是由 produce 函数生成的,那么冻结的将是整个状态。冻结的状态修改不会成功。
柯里化
这是 Immer 的最后一个特性。到目前为止,我们总是使用两个参数来调用 produce,即 baseState 和 producer。当然,也可以只使用 producer。
const byId = produce((draft, action) => {
switch (action.type) {
case RECEIVE_PRODUCTS:
action.products.forEach(product => {
draft[product.id] = product
})
break
}
})
这种情况下,producer 函数的第一个参数就是在调用 byId 时传入的第一个参数,第二参数是 action。
那么 Immer 的工作原理是如何的呢?
Immer 的工作原理
关键两个点:1)写时复制(Copy-on-write[2])2)代理(Proxies[3])。我们来画一张图:

producer 的 source tree 和遮盖它的 draft tree。
绿树是原始状态树,带蓝色边框的绿色树称为代理。最初,当 producer 启动时,只有一个这样的代理,就是传递到 producer 函数中的 draft 对象。每当从第一个代理读取任何非原始值时,它都会为这个值再创建一个代理。这样一系列步骤下来,我们就得到了一个覆盖(或隐藏)原始基础树(original base tree)的代理树。当然,这个树中只包含 producer 中我们访问过的那部分对象。

代理内部决策的一份概述图。委托给基础树(base tree)或基础树的克隆节点。
一旦你尝试更改代理上的某些内容(直接或间接(通过 API)),它将立即在与其相关的源树中创建节点的浅副本(shallow copy),并设置一个“已修改(modified)”的标志。从现在开始,未来对这个代理的任何读取和写入都不会在源树中结束,而是在副本中结束。此外,任何父级将会被标记为“已修改”。
当 producer 执行结束时,它只会遍历代理树,如果一个代理被修改,就获取副本;如果没有修改,就直接返回原始节点。这个过程会生成一棵在结构上与先前状态结构共享的树。这差不多就是 Immer 的全部内容了。
No proxies?
Immer 内部使用 Proxy API 进行代理。如果你要支持的浏览器不支持这个 API,可以通过 import produce from “immer/es5” 来使用 Immer。
性能?
在我们的基准测试中,Immer 的速度与同类型的 ImmutableJS 一样快,但比较手工书写的 Redux reducer 慢 2 倍,但基本可以忽略不计。不过,ES5 实现要慢得多,因此如果在一个比较大的 reducer 上做对象修改时,可能需要跳过 Immer。幸运的是,Immer 是完全选择性加入的,你可以根据每个 reducer 的目的或操作决定是否要使用。

不过,针对开发人员体验进行优化总是比针对运行时性能进行优化更好,除非你真的那么的在乎性能。
TL;DR
在处理 JavaScript 中的不可变数据时,Immer 提供了一些非常独特的功能。它不选择站在语言的对立面,而是拥抱它。
-
Immer 能让你使用标准 JavaScript 数据结构和 API 来生成不可变状态 -
强类型支持 -
开箱就支持的结构共享 -
开箱就支持的对象冻结 -
显著减少样板代码——噪音更少,代码更简洁
参考资料
Introducing Immer: Immutability the easy way: https://medium.com/hackernoon/introducing-immer-immutability-the-easy-way-9d73d8f71cb3
[2]
Copy-on-write: https://en.wikipedia.org/wiki/Copy-on-write
[3]
Proxies: https://developer.mozilla.org/nl/docs/Web/JavaScript/Reference/Global_Objects/Proxy
原文始发于微信公众号(写代码的宝哥):Immer:使用简单的方式实现不可变性
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/243667.html