TypeScript 进阶指南
基本用法
使用 TypeScript 时的区别在于,你不能直接写 create(...),而要写 create<T>()(...)(注意除了类型参数之外,还多了一对括号 ()),其中 T 是用于标注状态类型的类型参数。例如:
import { create } from 'zustand'
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
为什么不能直接从初始状态推断类型?
TLDR:因为状态泛型 T 是不变的(invariant)。
考虑这个最简版本的 create:
declare const create: <T>(f: (get: () => T) => T) => T
const x = create((get) => ({
foo: 0,
bar: () => get(),
}))
// `x` is inferred as `unknown` instead of
// interface X {
// foo: number,
// bar: () => X
// }
在这里,如果你看 create 中 f 的类型,也就是 (get: () => T) => T,它通过返回值“给出”T(使其协变),但又通过 get“接收”T(使其逆变)。“那 T 到底从哪儿来?”TypeScript 纳闷了。这就像先有鸡还是先有蛋的问题。最后 TypeScript 放弃了,把 T 推断为 unknown。
所以,只要待推断的泛型是不变的(即同时协变和逆变),TypeScript 就无法推断它。另一个简单的例子是:
const createFoo = {} as <T>(f: (t: T) => T) => T
const x = createFoo((_) => 'hello')
这里同样,x 是 unknown 而不是 string。
关于类型推断的更多内容(仅供对 TypeScript 好奇且感兴趣的读者)
在某种意义上,这个推断失败并不是问题,因为类型为 <T>(f: (t: T) => T) => T 的值根本无法写出来。也就是说,你无法写出 createFoo 真正的运行时实现。让我们试一试:
const createFoo = (f) => f(/* ? */)
createFoo 需要返回 f 的返回值。要做到这一点,我们必须先调用 f。而调用它就必须传入一个类型为 T 的值。要传入类型为 T 的值,我们又必须先把它造出来。可在完全不知道 T 是什么的情况下,怎么造出一个类型为 T 的值呢?造出类型为 T 的值的唯一办法就是调用 f,但调用 f 本身又需要一个类型为 T 的值。你看,实际上根本不可能写出 createFoo。
所以我们的意思是,createFoo 的推断失败其实算不上问题,因为 createFoo 本身就无法实现。那 create 的推断失败呢?那也算不上问题,因为 create 同样无法实现。等等,如果 create 无法实现,那 Zustand 是怎么实现它的?答案是:它并没有实现。
Zustand 谎称自己实现了 create 的类型,实际上它只实现了其中绝大部分。下面用一个简单的不健全性(unsoundness)证明来说明。考虑以下代码:
import { create } from 'zustand'
const useBoundStore = create<{ foo: number }>()((_, get) => ({
foo: get().foo,
}))
这段代码能编译通过。但如果我们运行它,就会得到异常:“Uncaught TypeError: Cannot read properties of undefined (reading 'foo')”。这是因为在初始状态创建之前 get 会返回 undefined(因此你不应该在创建初始状态时调用 get)。类型承诺 get 永远不会返回 undefined,但它在最初阶段确实会返回 undefined,这意味着 Zustand 没能真正实现它。
当然,Zustand 失败是因为无法按照类型所承诺的方式实现 create(就像无法实现 createFoo 一样)。换句话说,我们没有一个类型能表达我们实际实现的 create。我们不能把 get 标注为 () => T | undefined,因为这会带来不便,而且它仍然不正确——get 最终确实就是 () => T,只是在同步调用时,它会是 () => undefined。我们需要的是某种 TypeScript 特性,允许我们把 get 标注为 (() => T) & WhenSync<() => undefined>,而这当然极其不现实。
所以我们面临两个问题:推断能力不足和不健全性。如果 TypeScript 能改进对不变类型的推断,推断不足就能解决;如果 TypeScript 引入了类似 WhenSync 的东西,不健全性也能解决。为了绕开推断不足,我们手动标注状态类型。而不健全性我们绕不开,但这没什么大不了的,因为它影响很小——何况同步调用 get 本来就不合理。
为什么要柯里化成 `()(...)`?
TLDR:这是针对 microsoft/TypeScript#10571 的变通方案。
设想你有这样一个场景:
declare const withError: <T, E>(
p: Promise<T>,
) => Promise<[error: undefined, value: T] | [error: E, value: undefined]>
declare const doSomething: () => Promise<string>
const main = async () => {
let [error, value] = await withError(doSomething())
}
这里,T 被推断为 string,E 被推断为 unknown。你可能想把 E 标注为 Foo,因为你确信 doSomething() 所抛错误的结构。然而你做不到。你要么传入全部泛型,要么一个都不传。在把 E 标注为 Foo 的同时,你还得把 T 标注为 string,尽管它本来就能被推断出来。解决方案是做一个 withError 的柯里化版本,它在运行时什么也不做,目的只是让你能标注 E。
declare const withError: {
<E>(): <T>(p: Promise<T>) => Promise<[error: undefined, value: T] | [error: E, value: undefined]>
<T, E>(p: Promise<T>): Promise<[error: undefined, value: T] | [error: E, value: undefined]>
}
declare const doSomething: () => Promise<string>
interface Foo {
bar: string
}
const main = async () => {
let [error, value] = await withError<Foo>()(doSomething())
}
这样,T 仍然会被推断,而 E 由你标注。Zustand 有一模一样的使用场景:我们想标注状态(第一个类型参数),同时让其他参数仍由推断得出。
另外,你也可以使用 combine,它会推断出状态类型,因此你不需要手动写类型。
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
const useBearStore = create(
combine({ bears: 0 }, (set) => ({
increase: (by: number) => set((state) => ({ bears: state.bears + by })),
})),
)
要稍微小心一点
我们通过在你作为参数收到的 set、get 和 store 的类型上撒一点小谎来实现推断。这个谎是:它们的类型被写成以第一个参数作为状态,而实际上状态是第一个参数与第二个参数返回值经过浅合并({ ...a, ...b })后的结果。例如,第二个参数中的 get 类型是 () => { bears: number },这就是一个谎,它应该是 () => { bears: number, increase: (by: number) => void }。而 useBearStore 的类型仍然是正确的;例如 useBearStore.getState 被标注为 () => { bears: number, increase: (by: number) => void }。
严格来说它并不算谎,因为 { bears: number } 仍然是 { bears: number, increase: (by: number) => void } 的子类型。因此大多数情况下不会出问题。你只需在使用 replace 时小心。例如,set({ bears: 0 }, true) 能通过编译,但并不健全,因为它会删掉 increase 函数。另一个需要小心的场景是使用 Object.keys 时。Object.keys(get()) 会返回 ["bears", "increase"] 而不是 ["bears"]。get 的返回类型可能让你掉进这些坑里。
combine 用一点点类型安全换取了不必为状态写类型的便利。因此,你应该相应地使用 combine。大多数情况下它都没问题,你可以放心地使用它。
注意,使用 combine 时我们不用柯里化版本,因为 combine“创建”了状态。使用会创建状态的中间件时,没必要用柯里化版本,因为此时状态类型可以被推断出来。另一个会创建状态的中间件是 redux。所以使用 combine、redux 或任何其他会创建状态的自定义中间件时,我们不推荐使用柯里化版本。
如果你想在状态声明之外也推断状态类型,可以使用 ExtractState 类型辅助工具:
import { create, ExtractState } from 'zustand'
import { combine } from 'zustand/middleware'
type BearState = ExtractState<typeof useBearStore>
const useBearStore = create(
combine({ bears: 0 }, (set) => ({
increase: (by: number) => set((state) => ({ bears: state.bears + by })),
})),
)
使用中间件
在 TypeScript 中使用中间件不需要做任何特殊处理。
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearState>()(
devtools(
persist(
(set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}),
{ name: 'bearStore' },
),
),
)
只需确保你直接在 create 内部使用它们,以便上下文推断生效。做任何稍微花哨一点的事情,比如下面的 myMiddlewares,就需要更高级的类型技巧。
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
const myMiddlewares = (f) => devtools(persist(f, { name: 'bearStore' }))
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearState>()(
myMiddlewares((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
})),
)
另外,我们建议尽可能把 devtools 中间件放在最外层。例如,当你把它与 immer 中间件一起使用时,应该写成 devtools(immer(...)) 而不是 immer(devtools(...))。这是因为 devtools 会修改 setState 并在其上添加一个类型参数,如果其他中间件(如 immer)在 devtools 之前也修改了 setState,这个类型参数就可能丢失。因此,把 devtools 放在最外层可以确保没有中间件在它之前修改 setState。
编写中间件与高级用法
设想你需要编写下面这个假想的中间件。
import { create } from 'zustand'
const foo = (f, bar) => (set, get, store) => {
store.foo = bar
return f(set, get, store)
}
const useBearStore = create(foo(() => ({ bears: 0 }), 'hello'))
console.log(useBearStore.foo.toUpperCase())
Zustand 中间件可以修改 store。但我们怎样才能在类型层面表达这种修改呢?也就是说,怎样给 foo 标注类型,才能让这段代码编译通过?
对于通常的静态类型语言,这是不可能的。但多亏了 TypeScript,Zustand 有了所谓的“高阶 mutator(higher-kinded mutator)”,让这成为可能。如果你要处理复杂的类型问题,比如为中间件标注类型或使用 StateCreator 类型,你就必须理解这个实现细节。关于这一点,你可以查看 #710。
如果你迫不及待想知道这个特定问题的答案,可以在这里查看。
处理动态的 replace 标志
如果 replace 标志的值在编译期未知、需要在运行时动态确定,你可能会遇到问题。为了处理这种情况,你可以用一个变通方案:把 replace 参数标注为 setState 函数的参数类型:
const replaceFlag = Math.random() > 0.5
const args = [{ bears: 5 }, replaceFlag] as Parameters<typeof useBearStore.setState>
store.setState(...args)
使用 as Parameters 变通方案的示例
import { create } from 'zustand'
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
const replaceFlag = Math.random() > 0.5
const args = [{ bears: 5 }, replaceFlag] as Parameters<typeof useBearStore.setState>
useBearStore.setState(...args) // Using the workaround
按照这种做法,你可以确保代码能处理动态的 replace 标志,而不会遇到类型问题。
常见做法
不改变 store 类型的中间件
import { create, StateCreator, StoreMutatorIdentifier } from 'zustand'
type Logger = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
>(
f: StateCreator<T, Mps, Mcs>,
name?: string,
) => StateCreator<T, Mps, Mcs>
type LoggerImpl = <T>(f: StateCreator<T, [], []>, name?: string) => StateCreator<T, [], []>
const loggerImpl: LoggerImpl = (f, name) => (set, get, store) => {
const loggedSet: typeof set = (...a) => {
set(...(a as Parameters<typeof set>))
console.log(...(name ? [`${name}:`] : []), get())
}
const setState = store.setState
store.setState = (...a) => {
setState(...(a as Parameters<typeof setState>))
console.log(...(name ? [`${name}:`] : []), store.getState())
}
return f(loggedSet, get, store)
}
export const logger = loggerImpl as unknown as Logger
// ---
const useBearStore = create<BearState>()(
logger(
(set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}),
'bear-store',
),
)
改变 store 类型的中间件
import { create, StateCreator, StoreMutatorIdentifier, Mutate, StoreApi } from 'zustand'
type Foo = <
T,
A,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
>(
f: StateCreator<T, [...Mps, ['foo', A]], Mcs>,
bar: A,
) => StateCreator<T, Mps, [['foo', A], ...Mcs]>
declare module 'zustand' {
interface StoreMutators<S, A> {
foo: Write<Cast<S, object>, { foo: A }>
}
}
type FooImpl = <T, A>(f: StateCreator<T, [], []>, bar: A) => StateCreator<T, [], []>
const fooImpl: FooImpl = (f, bar) => (set, get, _store) => {
type T = ReturnType<typeof f>
type A = typeof bar
const store = _store as Mutate<StoreApi<T>, [['foo', A]]>
store.foo = bar
return f(set, get, _store)
}
export const foo = fooImpl as unknown as Foo
type Write<T extends object, U extends object> = Omit<T, keyof U> & U
type Cast<T, U> = T extends U ? T : U
// ---
const useBearStore = create(foo(() => ({ bears: 0 }), 'hello'))
console.log(useBearStore.foo.toUpperCase())
不使用柯里化变通方案的 create
使用 create 的推荐方式是像这样使用柯里化变通方案:create<T>()(...)。这是因为它能让你推断出 store 类型。但如果出于某种原因你不想用这个变通方案,可以像下面这样传入类型参数。注意,在某些情况下这起的是断言而非标注的作用,所以我们不推荐这样做。
import { create } from "zustand"
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<
BearState,
[
['zustand/persist', BearState],
['zustand/devtools', never]
]
>(devtools(persist((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}), { name: 'bearStore' }))
切片模式
import { create, StateCreator } from 'zustand'
interface BearSlice {
bears: number
addBear: () => void
eatFish: () => void
}
interface FishSlice {
fishes: number
addFish: () => void
}
interface SharedSlice {
addBoth: () => void
getBoth: () => number
}
const createBearSlice: StateCreator<BearSlice & FishSlice, [], [], BearSlice> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
})
const createFishSlice: StateCreator<BearSlice & FishSlice, [], [], FishSlice> = (set) => ({
fishes: 0,
addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})
const createSharedSlice: StateCreator<BearSlice & FishSlice, [], [], SharedSlice> = (set, get) => ({
addBoth: () => {
// you can reuse previous methods
get().addBear()
get().addFish()
// or do them from scratch
// set((state) => ({ bears: state.bears + 1, fishes: state.fishes + 1 })
},
getBoth: () => get().bears + get().fishes,
})
const useBoundStore = create<BearSlice & FishSlice & SharedSlice>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
...createSharedSlice(...a),
}))
关于切片模式的详细说明可以在这里找到。
如果你使用了中间件,就把 StateCreator<MyState, [], [], MySlice> 替换为 StateCreator<MyState, Mutators, [], MySlice>。例如,如果你使用 devtools,那就写成 StateCreator<MyState, [["zustand/devtools", never]], [], MySlice>。所有 mutator 的列表见“中间件及其 mutator 参考”一节。
为原生 store 提供有界的 useStore Hook
import { useStore } from 'zustand'
import { createStore } from 'zustand/vanilla'
interface BearState {
bears: number
increase: (by: number) => void
}
const bearStore = createStore<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
function useBearStore(): BearState
function useBearStore<T>(selector: (state: BearState) => T): T
function useBearStore<T>(selector?: (state: BearState) => T) {
return useStore(bearStore, selector!)
}
如果你经常需要创建有界的 useStore Hook 并希望消除重复,也可以写一个抽象的 createBoundedUseStore 函数……
import { useStore, StoreApi } from 'zustand'
import { createStore } from 'zustand/vanilla'
interface BearState {
bears: number
increase: (by: number) => void
}
const bearStore = createStore<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
const createBoundedUseStore = ((store) => (selector) => useStore(store, selector)) as <
S extends StoreApi<unknown>,
>(
store: S,
) => {
(): ExtractState<S>
<T>(selector: (state: ExtractState<S>) => T): T
}
type ExtractState<S> = S extends { getState: () => infer X } ? X : never
const useBearStore = createBoundedUseStore(bearStore)
中间件及其 mutator 参考
devtools — ["zustand/devtools", never]
persist — ["zustand/persist", YourPersistedState]
YourPersistedState 是你要持久化的状态类型,即 options.partialize 的返回类型;如果不传 partialize 选项,YourPersistedState 就变成 Partial<YourState>。另外,有时传入实际的 PersistedState 并不管用,这种情况下可以试试传 unknown。
immer — ["zustand/immer", never]
subscribeWithSelector — ["zustand/subscribeWithSelector", never]
redux — ["zustand/redux", YourAction]
combine — 没有 mutator,因为 combine 不会修改 store