跳到主要内容

Using TypeScript or Flow

egghead.io 第12课: Immer + TypeScript

Immer 包附带了类型定义,TypeScript 和 Flow 开箱即可获取这些定义,无需进一步配置

TypeScript 类型会自动从 draft 类型中删除 readonly 修饰符,并返回与原始类型匹配的值。看这个实际的例子:

import {produce} from "immer"

interface State {
readonly x: number
}

// `x` 不能被修改
const state: State = {
x: 0
}

const newState = produce(state, draft => {
// `x` 可以被修改
draft.x++
})

// `newState.x` 不能在这里被修改

这确保了您可以修改状态的唯一位置是在您的 produce 回调中。它甚至可以递归地和 ReadonlyArray 一起工作。

最佳实践

  1. 始终尽可能将您的 state 定义为只读。这最好地反映了心智模型和现实,因为 Immer 将冻结其所有返回值。
  2. 您可以使用实用类型 Immutable 递归地使整个类型树成为只读的,例如:type ReadonlyState = Immutable<State>
  3. 如果输入状态的原始类型不是不可变的,则 Immer 不会自动将所有返回的类型包装在 Immutable 中。这是为了确保它不会破坏不使用不可变类型的代码库。

柯里化 producers 的提示

我们尝试尽可能多地推断。因此,如果创建了一个柯里化 producer 并直接传递给另一个函数,我们可以从那里推断出类型。这适用于例如 React:

import {Immutable, produce} from "immer"

type Todo = Immutable<{
title: string
done: boolean
}>

// 然后...

const [todo, setTodo] = useState<Todo>({
title: "test",
done: true
})

// 然后...

setTodo(
produce(draft => {
// draft 将是强类型和可变的!
draft.done = !draft.done
})
)

当柯里化 producers 没有直接传递到其他地方时,Immer 可以从 draft 参数推断状态类型。例如在执行以下操作时:

// 请参阅下文以获得更好的解决方案

const toggler = produce((draft: Draft<Todo>) => {
draft.done = !draft.done
})

// typeof toggler = (state: Immutable<Todo>) => Writable<Todo>

请注意,我们确实用 Draft 包装了 draft 参数的 Todo 类型,因为 Todo 是只读类型。对于非只读类型,这不是必需的

对于返回的柯里化函数 toggler,我们将输入类型缩小为 Immutable<Todo>,这样即使 Todo 是可变类型,我们仍将接受不可变的 todo 作为切换器的输入参数。

与之相反,Immer 会将柯里化函数的输出类型扩展Writable<Todo>,以确保它的输出状态也可分配给未明确键入为不可变的变量。

这种类型的缩小/扩大行为可能不受欢迎,甚至可能因为它会导致类型非常多的噪音。因此,我们建议为柯里化 produces 指定 state 泛型 ,以防它无法直接推断,例如上面的 toggler。通过这样做,将跳过自动输出扩大/输入缩小。然而,draft 参数本身仍将被推断为可写 Draft<Todo>

const toggler = produce<Todo>(draft => {
draft.done = !draft.done
})

// typeof toggler = (state: Todo) => Todo

但是,如果柯里化 producer 定义了初始状态,Immer 可以从初始状态推断状态类型,因此在这种情况下也不需要指定泛型:

const state0: Todo = {
title: "test",
done: false
}

// 不需要类型注释,因为我们可以从 state0 推断。
const toggler = produce(draft => {
draft.done = !draft.done
}, state0)

// typeof toggler = (state: Todo) => Todo

如果 toggler 没有初始状态,并且它有柯里化参数,并且您显式设置 state 泛型,则任何附加参数的类型也应显式定义为元组类型:

const toggler = produce<Todo, [boolean]>((draft, newState) => {
draft.done = newState
})

// typeof toggler = (state: Todo, newState: boolean) => Todo

类型转换

produce 内部和外部的类型在概念上可以相同,但从实际角度来看是不同的。例如,上面示例中的 State 应被视为在 produce 外部不可变,但在 produce 内部是可变的。

有时这会导致实际冲突。举个例子:

type Todo = {readonly done: boolean}

type State = {
readonly finishedTodos: readonly Todo[]
readonly unfinishedTodos: readonly Todo[]
}

function markAllFinished(state: State) {
produce(state, draft => {
draft.finishedTodos = state.unfinishedTodos
})
}

这将产生错误:

The type 'readonly Todo[]' is 'readonly' and cannot be assigned to the mutable type '{ done: boolean; }[]'

这个错误的原因是我们将只读的、不可变的数组分配给我们的 draft,draft 需要一个可变的类型,并带有 .push 等方法。就 TS 而言,这些并没有从我们的原始 State 中暴露出来。为了提示 TypeScript 我们希望将此处的集合向上转换为可变数组以用于 draft,我们可以使用函数 castDraft

draft.finishedTodos = castDraft(state.unfinishedTodos) 将使错误消失。

还有函数 castImmutable,以防您需要实现相反的效果。请注意,这些函数出于所有实际目的都是无操作的,它们只会返回其原始值。

提示:您可以将 castImmutableproduce 结合起来,将 produce 的返回类型定义为不可变的内容,即使原始 state 是可变的

// 一个可变数据结构
const baseState = {
todos: [{
done: false
}]
}

const nextState = castImmutable(produce(baseState, _draft => {}))

// nextState 的推断类型现在是:
{
readonly todos: ReadonlyArray<{
readonly done: boolean
}>
})

兼容性

注意: Immer v5.3+ 仅支持 TypeScript v3.7+

注意: Immer v3.0+ 仅支持 TypeScript v3.4+

注意: Immer v1.9+ 仅支持 TypeScript v3.1+

注意: 在未来的版本中可能会删除 flow 支持,我们建议使用 TypeScript