TypeScript of Redux 心得体会

Redux 是一个非常经典的状态管理库,在 2019 年接近年底的时候这个项目用 TypeScript 重写了。网上有很多分析 Redux JavaScript 代码实现的文章,然而 TypeScript 部分的却很少。我在看重写的 TypeScript 代码时发现有很多地方比较有意思,也启发我提炼了一些东西,所以整理成了这篇博客,欢迎一起来讨论和学习。

本文内容分成两个部分,第一部分是关于 Redux 中类型定义和推导的技巧,这部分完全是 TypeScript 代码和相关概念,如果不熟悉 TypeScript 的话基本是没法看,可以找官方文档补课后再来;第二部分是我提炼的一些个人心得,包括我理解的 Redux 设计思路,我们从中怎么学习和应用等等,这部分只要知道函数式编程思想就好了。

Types

Redux 把所有的类型定义都放在 types 文件夹中。主要描述了 Redux 中的抽象定义,比如什么是 ActionReducer;还有一部分是推导类型,比如:ActionFromReducerStateFromReducersMapObject 等等。

我列了几个比较有意思的来一起康康。

ReducerFromReducersMapObject

export type ReducerFromReducersMapObject = M extends {
  [P in keyof M]: infer R
}
  ? R extends Reducer
    ? R
    : never
  : never

这个推导类型的目的是从 ReducersMapObject 中推导出 Reducer 的类型。这里有个知识点:在映射类型中,infer 会推导出联合类型。请看下面的例子:

export type ValueType = M extends {
  [P in keyof M]: infer R
}
  ? R
  : never

type Person = {
  name: string;
  age: number;
  address: string;
}

type T1 = ValueType; // T1 = string | number

ExtendState

export type ExtendState = [Extension] extends [never]
  ? State
  : State & Extension

这个类型是用来推导扩展 State 的。如果没有扩展,就返回 State 本身,否则返回 State 和 Extension 的交叉类型。这里比较奇怪的是为什么判断 never 要用 [Extension] extends [never] 而不是 Extension extends never 呢?

代码注释中很贴心的有一个此问题的讨论链接:https://github.com/microsoft/...。大概意思是有人写了个推导类型,但是行为不符合期望所以提了个 issue:

type MakesSense = never extends never ? 'yes' : 'no' // Resolves to 'yes'

type ExtendsNever = T extends never ? 'yes' : 'no'

type MakesSenseToo = ExtendsNever<{}> // Resolves to 'no'
type Huh = ExtendsNever
// Expect to resolve to 'yes', actually resolves to never 

我们注意到他用了 Extension extends never。但当泛型参数传入 never 时,结果不是 yes 而是 never

下面有人给出了答案:

This is the expected behavior, ExtendsNever is a distributive conditional type. Conditional types distribute over unions. Basically if T is a union ExtendsNever is applied to each member of the union and the result is the union of all applications (ExtendsNever<'a' | 'b'> == ExtendsNever<'a' > | ExtendsNever<'b'>). never is the empty union (ie a union with no members). This is hinted at by its behavior in a union 'a' | never == 'a'. So when distributing over never, ExtendsNever is never applied, since there are no members in this union and thus the result is never.

If you disable distribution for ExtendsNever, you get the behavior you expect:

type MakesSense = never extends never ? 'yes' : 'no' // Resolves to 'yes'

type ExtendsNever = [T] extends [never] ? 'yes' : 'no'

type MakesSenseToo = ExtendsNever<{}> // Resolves to 'no'
type Huh = ExtendsNever // is yes 

我结合官方文档来说一下为什么这个行为是符合预期的。因为 ExtendsNever 在这里是分发的条件类型:Distributive conditional types。分发的条件类型在实例化时会自动分发成联合类型。 例如,实例化 T extends U ? X : YT 的类型为 A | B | C,会被解析为 (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

而当实例化的泛型为 never 时,ExtendsNever 不会执行,因为联合类型是 never 相当于没有联合类型成员,所以上面的结果是根本不会进入条件判断而直接返回 never。所以要解决这个问题需要的就是打破分发的条件类型,使其不要分发。

官方文档上写了分发的条件类型的触发条件:如果待检查的类型是naked type parameter。那什么是 naked type 呢?简单点来说就是没有被其他类型包裹的类型,其他类型包括:数组、元组、或其他泛型等。这里我也找了一个 stack overflow 上面的解答大家可以参考一下。

看到这里问题就迎刃而解了:[Extension] extends [never]never 包裹成元组就是为了打破分发的条件类型以实现正确地判断是否 never

CombinedState

declare const $CombinedState: unique symbol

export type CombinedState = { readonly [$CombinedState]?: undefined } & S

这个类型是用来区分 State 是否由 combineReducers 创建的,combineReducers 函数会返回这个类型。我们知道 TypeScript 的对象类型兼容是结构子类型,也就是说只要对象的结构满足就好了。而 combineReducers 构造的 State 又需要与普通的 State 对象区分开来,这个时候就需要一个标识的属性来检查不同——$CombinedState

首先注意到的是,$CombinedState 是一个 unique symbol,这说明这个 symbol 的类型是唯一的,TypeScript 可以追踪和识别它的类型;然后我们看到 $CombinedStatedeclare 出来的并且没有导出,这表明 $CombinedState 只用来做类型定义用的(不需要实现)并且不能被外部的类型伪造。

接着看下面 CombinedState 里面的 { readonly [$CombinedState]?: undefined } 部分。[$CombinedState] 属性是可选的并且类型是 undefined 而且不能被赋值修改,这就说明这个对象里面啥也没有嘛。然后与 S 做交叉,保持了看起来与 S 的“结构一样”(S 类型的变量可以赋值给 CombinedState 类型的变量),但又被完美地从结构类型上区分开了,这个玩法有点高级!

来看下面的测试好好体会一下:

type T1 = {
  a: number;
  b: string;
}

declare const $CombinedState: unique symbol;

type T2 = { readonly [$CombinedState]?: undefined } & T;

type T3 = {} & T;

type T4 = Required extends {
  [$CombinedState]: undefined
} ? 'yes' : 'no';

type S1 = T2;
// type S1 = { readonly [$CombinedState]?: undefined; } & T1;
type S2 = T4;
// type S2 = "yes";
type S3 = T3;
// type S3 = T1;
type S4 = T4;
// type S4 = "no";

let s: S1 = { a: 1, b: '2' };

PreloadedState

export type PreloadedState = Required extends {
  [$CombinedState]: undefined
}
  ? S extends CombinedState
    ? {
        [K in keyof S1]?: S1[K] extends object ? PreloadedState : S1[K]
      }
    : never
  : {
      [K in keyof S]: S[K] extends string | number | boolean | symbol
        ? S[K]
        : PreloadedState
    }

PreloadedState 是调用 createStore 时 State 预设值的类型,只有 Combined State 的属性值才是可选的。类型的推导方案借助了上面的 CombinedState 来完成,并且是一个递归的推导。这里我有个疑问是为什么判断是否原始类型 primitive 的方式上下不一致?

以上是我觉得 Redux 类型定义中比较有意思的地方,其他的类型定义内容应该比较好理解大家可以多康康,如果有疑问也可以提出来一起讨论。

接着是第二部分的内容,我个人对于 Redux 设计思想与实现的心得理解,还有一些观点和建议。

Redux 好在哪里?

说起来我用 Redux 已经很久了。2016 年决定把主要精力放在前端时是学习的 React,接触的第一个状态管理框架就是 Redux,并且现在公司的前端业务层也是围绕着 Redux 技术栈打造的。我很早就看过 Redux 的 JavaScript 代码,加上 TypeScript 的代码部分可以说我对 Redux 已经很熟悉了,所以这次决定要好好总结一下。

Redux 是函数式编程的经典模板

我认为 Redux 具有非常学院派的函数式编程思想,如果你想编写一个功能库给别人使用,完全可以使用 Redux 的思想当做模板来应用。为什么我会这么说呢,来看下以下几点。

隐藏数据

思考一个问题:为何 Redux 不用 Class 来实现,是编写习惯吗?

由于 JavaScript 目前的语言特性,Class 产生的对象无法直接隐藏数据属性,在运行时健壮性有缺陷。仔细看看 Redux 的实现方式:createStore 函数返回一个对象,在 createStore 函数内部存放数据变量,返回的对象只暴露了方法,这就是典型的利用闭包隐藏数据,是我们常用的函数式编程思想之一。

抽象行为

在设计一个给别人使用的功能库时,我们首先要考虑的问题是什么?我认为是能提供什么样的功能,换句话说就是功能库的行为是怎样的。我们来看看 Redux 是怎么考虑这个问题的,在 types\store.ts 中有一个 Store 接口(我把注释都去掉了):

export interface Store<
  S = any,
  A extends Action = AnyAction,
  StateExt = never,
  Ext = {}
> {
  dispatch: Dispatch
  getState(): S
  subscribe(listener: () => void): Unsubscribe
  replaceReducer(
    nextReducer: Reducer
  ): Store, NewActions, StateExt, Ext> & Ext
  [Symbol.observable](): Observable
}

这个接口定义就是 createStore 返回的对象类型定义。从定义可以看出这个对象提供了几个方法,这就是 Redux 提供给用户使用的主要行为了。

行为是一种契约,用户将按照你给出的行为来使用你提供的功能。在函数式编程中,函数就是行为,所以我们要重点关注行为的设计。并且行为的变更一般来说代价很大,会造成不兼容,所以在函数式编程中我们一定要学习如何去抽象行为。

如何扩展?

Redux 是如何扩展功能的,​我们可能会联想到 Redux middleware。但是你仔细想想,中间件的设计就代表了 Redux 的功能扩展吗?

Redux 中间件的本质

直接说结论:中间件扩展的是 dispatch 的行为,不是 Redux 本身。为了解决 action 在派送过程中的异步、特殊业务处理等各种场景需求,Redux 设计了中间件模式。但中间件仅代表这个特殊场景的扩展需求,这个需求是高频的,所以 Redux 专门实现了这个模式。

Redux 扩展:StoreEnhancer

types\store.ts 中有一个 StoreEnhancer 的定义,这个才是 Redux 扩展的设计思路:

export type StoreEnhancer = (
  next: StoreEnhancerStoreCreator
) => StoreEnhancerStoreCreator

export type StoreEnhancerStoreCreator = <
  S = any,
  A extends Action = AnyAction
>(
  reducer: Reducer,
  preloadedState?: PreloadedState
) => Store, A, StateExt, Ext> & Ext

不难看出 StoreEnhancer 是一个高阶函数,通过传入原来的 createStore 函数而返回一个新的 createStore 函数来实现扩展。Store & Ext 在保留原有行为的基础上实现了扩展,所以高阶函数是常用的扩展功能的方式。对于使用者来说, 编写扩展时也要遵守 里氏替换原则

“订阅/发布”中的小细节

let currentListeners: (() => void)[] | null = []
let nextListeners = currentListeners

/**
  * This makes a shallow copy of currentListeners so we can use
  * nextListeners as a temporary list while dispatching.
  */
function ensureCanMutateNextListeners() {
  if (nextListeners === currentListeners) {
    nextListeners = currentListeners.slice()
  }
}

function subscribe(listener: () => void) {
  // ...
  ensureCanMutateNextListeners()
  nextListeners.push(listener)
  
  return function unsubscribe() {
    // ...
    ensureCanMutateNextListeners()
    const index = nextListeners.indexOf(listener)
    nextListeners.splice(index, 1)
    currentListeners = null
  }
}

function dispatch(action: A) {
  // ...
  try {
    isDispatching = true
    currentState = currentReducer(currentState, action)
  } finally {
    isDispatching = false
  }

  const listeners = (currentListeners = nextListeners)
  for (let i = 0; i < listeners.length; i++) {
    const listener = listeners[i]
    listener()
  }

  return action
}

以上就是 Redux“订阅/发布”的关键代码了,我说两点可以借鉴学习的地方。

  1. subscribe 中返回 unsubscribe

    原来我刚开始写“订阅/发布”模式时,会把“取消订阅”写成一个独立的函数 囧。把 subscrible 写成一个高阶函数,返回 unsubscribe,这样对于使用者来说可以更方便地使用匿名函数来接收通知。

  2. 稳定的发布链

    currentListenersnextListeners 可以保证在发布时通知是稳定的。因为可能在发布通知期间有新的订阅者或者退订的情况,那么在这种情况下这一次的发布过程是稳定的不会受影响,变化始终在 nextListeners

总结

当我们去看源码学习一个项目时,不要只看一次就完了。应隔一段时间去重温一下,从不同维度、不同视角去观察,多想想,多问几个为什么,提炼出自己的心得,那就真的是学到了。

欢迎 star 和关注我的 JS 博客:小声比比 JavaScript