zustand 基本使用如下
ts
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 })),
}))
function Comp() {
const [brears, increase] = useBearStore(s => [s.brears, s.increase]);
return (
<>
<span>{brears}</span>
<button onClick={() => increase(1)}>add brear</button>
</>
)
}
使用起来非常方便简洁,但是你可能会注意到通过typescript编写代码时,必须给create方法声明 BearState 类型,typescript不是可以自动推导泛型类型么?为什么zustand无法自动推导store类型?
那为什么redux可以自动推导store 类型?
ts
import { createSlice } from '@reduxjs/toolkit'
const bearStore = createSlice({
initialState: {
bears: 0,
},
reducers: {
increase: (state) => {
state.bears += 1 // bears: number
}
}
})
其实zustand官方文档已经给出了解释:
大概的意思是,因为create中定义的state泛型T被推断为 invariant (不变),所以typescript无法自动推导T的实际类型。如果我们不了解ts这些概念,就会感觉明明每个字都认识,为什么连起来就看不懂了?🤡
让我们通过几个例子来简单了解什么事ts中的协变、逆变、不变和双变。我们先记住一个重要的概念,typescript一切都是为了保护类型安全。
首先定义两个类
ts
interface Jser {
codeJS: () => void;
}
interface Tser extends Jser {
codeTS: () => void;
}
let jser: Jser = {codeJS: () => {}};
let tser: Tser = {codeTS: () => {}, codeJS: () => {}};
其中 Tser 是 Jser 的子类型
协变(covariance)
协变比较容易理解,有如下例子
这里应该比较容易理解,因为子类 tser 中所有类型都满足 jser , 所以我们在使用 jser 变量是不会有任何问题的(虽然这俩类型不一样,但这样赋值类型是安全的)。
反之如果将父类 jser 赋值给子类 tser,有可能在实际使用过程中会调用 tser.codeTS
, 然而 jser 并没有这个属性,所以此时类型时不安全的,ts 就会报错。
现在让我们加入泛型进一步分析
为什么 getJser 赋值给 getTser 会报错?其实与上面的分析差不多,假设可以编译通过,因为调用getTser函数时还是按照 GetEr<Tser>
类型约束,所以实际运行时可能会访问到无效属性,类型是不安全的
ts
const er = getTser();
er.codeTS() // error. 因为此时getTser实际为getJser, 返回的er是jser, 并没有codeTs, 所以运行时会报错
总结: 子类型可以赋值给父类型的情况就叫做协变。ts函数返回值类型是协变的
逆变(contravariance)
假设有如下两个函数
这个场景恰好与协变相反,为什么 setJser 函数可以赋值给setTser, 反之则不行呢?
假设 setJser = setTser
能编译通过,此时调用setJser 还是会按照 SetEr<Jser>
类型约束,那么当我们执行函数 setJser(jser)
时就会报错,因为此时 setJser 实际上为 setTser, 我们将 jser 传给了 setTser, 当执行 jser.codeTS()
时发现 jser 上并没有 codeTS
函数,就会发生运行时错误,这种情况下ts类型是不安全的
相当于
ts
setTser = (er: Tser) => {
// jser并无 codeTS, 运行时会报错
er.codeTS();
};
setTser(jser)
setJser
可以赋值给 setTser
是没有问题的,因为在这种情况下,ts类型是安全的。执行 setTser 时就是在调用 setJser, 然后传入一个 tser 参数,都能满足setJser的任何属性或方法调用
相当于
ts
setJser = (er: Jser) => {
// tser都能满足Jser所需的类型
er.codeJS();
};
setJser(tser)
这种场景就是逆变,总结一下为:父类型可以赋值给子类型的情况就叫做逆变,ts函数参数类型是逆变的
为什么要有逆变协变等操作?我理解是typescript为了不让类型安全的限制太死板,让其更有灵活性。比如子类型是可以赋值给父类型的变量的,可以完全当成父类型来使用
双变(bivariance、双向协变)
双变是指既可以是逆变也可以是协变,也就是父类型可以赋值给子类型,子类型可以赋值给父类型
在 ts 2.6之前,函数参数类型默认是双变的,允许更宽泛的函数参数传值,但也带来了一些安全性问题。在 ts 2.6 之后,新增了 strictFunctionType 配置,默认为true, 即函数参数类型是逆变的,设为false表示函数参数类型是双变的
比如上述逆变的例子,我们在 tsconfig.json 中新增 strictFunctionType: false
, 那么ts报错就会消失
不变(Invariance)
从上述内容我们可以推断出不变就是既不能协变也不能逆变,只要类型不一样就报错。
举个例子,我们将上述的 GetEr<T>
与 SetEr<T>
(一个满足协变,一个满足逆变) 结合起来生成一个新类型
ts
type Invariance<T> = (er: T) => T
函数类型 Invariance 是不变的(因为ts无法推断到底是协变还是逆变,无论是哪种型变都会有安全性问题)。
由于两个函数都是不变的,所以相互赋值都会报错
回头再看为什么zustand不能自动推断store类型
因为 create<T>
类型中的泛型T是不变的
其实zustand官网给了一个最简单的例子
ts
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返回值是T,所以此时可以考虑 T 为协变的
我们来看f的类型 (get: () => T) => T
, 第二个T为函数的返回值,根据上述结论:ts函数返回值是协变的,所以此时T为协变的
第一个T比较复杂可以将get的类型 () => T
考虑为一个整体参数,根据上述结论:ts函数参数是逆变的,所以此时T为逆变的
既是协变又是逆变(鸡生蛋?蛋生鸡?)?此时ts只能认为T是不变的。如果泛型T是不变的,那么ts就无法默认推断出T的实际类型
为什么不变就无法推到类型呢?我们可以换个思路想想。
zustand可以将state和action一些写到store中(其实zustand内部并不会区分state和action,都视为state)。
create函数接收一个 createState 函数作为参数,createState 函数第二个参数为get, get的函数签名为
() => T
, 因为create 需要返回T, 而T中包含所有的action,而 action中又可以调用get获取state(T),那么这个T不就相当于死循环了么? 根本无法推断T的具体类型
tsconst useBearStore = create((set, get) => ({ bears: 0, increase: () => { const states = get(); set((state) => ({ bears: state.bears + 1 })); }))
如果使用zustand你实在不想定义store类型,我们可以换一种写法,不使用第一个T。也就是将state和action 拆开
这样create的返回值能自动推断出来(协变)
这也是为什么redux能自动推导store类型的原因,redux就是将state和action分开的写法
zustand一些类型骚操作
骚操作一
zustand有个类型定义是这样的,看到时是不是会一脸懵逼?黑人问号???
ts
type SetStateInternal<T> = {
_(
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
): void
}['_']
简化一下为
ts
type SetStateInternal<T> = {
_(partial: T): void
}['_']
那可以直接写成这样么?
ts
type SetStateInternal<T> = (partial: T) => void
其实是可以的,两者唯一的区别是第二种方式类似于我们上面提到的逆变的场景,SetStateInternal<T>
满足逆变的规则,参数类型是逆变的。而第一种方式会绕过严格模式下的参数类型检查,将参数类型变为双变的(是的,即使开启了严格模式也不会变为逆变!)。
可能是在 zustand 代码中,需要更宽松的类型检查,所以使用了第一种写法。但是为什么第一种写法能产生这样的效果,不知道😛
骚操作二
ts
const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
你有没有想过为什么我们需要柯里化调用create函数么?
zustand同样支持非柯里化调用
ts
const useBearStore = create((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
那么柯里化调用的意义是什么?
让我们来看下面的例子
假设我们有如下场景
ts
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
被推导为 unknow
。有时我们可能想跳过 T
声明 E
为 Foo
, 因为此时你可以确定 doSomething()
将抛错。但遗憾的是ts不支持这样做,你必须先声明 T
为 string
,然后才能再声明 E
为 Foo
,即使声明 T
为 string
没有任何意义(可以被推断出来)
ts
withError<, Foo> // error
withError<string, Foo>
另一种解决方式是重载 withError
类型成为柯里化版本,运行时不做任何额外的逻辑,仅仅是为了让我们可以跳过 T
只声明 E
ts
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 create 函数使用相同的方式让我们只声明state泛型(第一个类型参数),其他泛型自动推导。
ts
type CreateStore = {
<T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
): Mutate<StoreApi<T>, Mos>
<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
) => Mutate<StoreApi<T>, Mos>
}