Immer:使用简单的方式实现不可变性

原文链接:Introducing Immer: Immutability the easy way[1],2018.01.11,by Michel Weststrate

不可变(immutable)、数据结构共享(structurally shared)的数据结构是在存储状态时一个很好范式。尤其是与事件溯源架构(event-sourcing architecture)结合使用时。不过,是有付出一定代价的。JavaScript 并没有内置不变性,从当前状态生成下一个状态是一件枯燥乏味的任务。

Immer:使用简单的方式实现不可变性

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"donetrue })
    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
  }
}

这块代码中存在两个问题:

  1. 必须创建一个新的状态对象,并且需要使用对象解构来混入新的数据以及保留旧的数据
  2. 如果 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])。我们来画一张图:

Immer:使用简单的方式实现不可变性

producer 的 source tree 和遮盖它的 draft tree。

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

Immer:使用简单的方式实现不可变性

代理内部决策的一份概述图。委托给基础树(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 的目的或操作决定是否要使用。

Immer:使用简单的方式实现不可变性

不过,针对开发人员体验进行优化总是比针对运行时性能进行优化更好,除非你真的那么的在乎性能。

TL;DR

在处理 JavaScript 中的不可变数据时,Immer 提供了一些非常独特的功能。它不选择站在语言的对立面,而是拥抱它。

  1. Immer 能让你使用标准 JavaScript 数据结构和 API 来生成不可变状态
  2. 强类型支持
  3. 开箱就支持的结构共享
  4. 开箱就支持的对象冻结
  5. 显著减少样板代码——噪音更少,代码更简洁

参考资料

[1]

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

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!