目录

实现仿 Recoil 的状态共享

本文是我最近阅读一篇英文技术文章后写的小结。阅读前请注意,本文不涉及任何 Recoil 源码。仿写的代码并不是 Recoil 真正的实现方式,本文只仿造实现了 Recoil 中两个重要的 API 接口:Atom 和 Selector。

如果你不熟悉 Recoil,请先阅读我的这篇 文章 或者阅读它的 官方文档 。然后新建 React 项目:

1
npx create-react-app recoil-clone --typescript

在根目录下新建 coiled.tsx 文件,下面的代码都在这个文件中实现。

状态基类

定义一个 Stateful 表示共享状态的基类,Atom 和 Selector 需继承这个基类。为了监听状态的变化,我们使用观察者模式。这种设计模式在 RxJS 之类的库中很常见,我将从头开始编写一个简单的同步版本。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
interface Disconnect {
  disconnect: () => void;
}

export class Stateful<T> {
  // Set 是 callback 的集合
  private listeners = new Set<() => void>();

  constructor(protected value: T) {}
  // 取值函数
  snapshot(): T {
    return this.value;
  }
  // 此处才会调用所有的监听者
  private emit() {
    for (const listener of Array.from(this.listeners)) {
      console.log("调用监听者: " + listener);
      listener();
    }
  }
  // update 方法可以被 Stateful 的子类 Atom 和 Selector 继承
  protected update(value: T) {
    if (this.value !== value) {
      this.value = value;
      console.log("新值: " + this.value);
      this.emit();
    }
  }
  // 订阅就加入监听者的 Set 集合,此方法接收 callback,返回也是 callback
  subscribe(callback: () => void): Disconnect {
    console.log("注册监听者:" + callback);
    this.listeners.add(callback);
    return {
      disconnect: () => {
        console.log("注销监听者:" + callback);
        this.listeners.delete(callback);
      },
    };
  }
}

自定义 hook

下面是只读 hook 的实现方式。atom 和 selector 均可读,因此参数只需满足 Stateful 类型。这里注册的监听者 updateState 巧妙地利用了函数组件的重渲染机制,因为 useState 的参数为引用数据类型,{} === {} 的值为 false,因此只要调用 updateState 函数就会重渲染组件。关于 React 组件何时会重渲染可以读这篇 文章

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export function useCoiledValue<T>(value: Stateful<T>): T {
  // 只要调用 updateState 就会触发重渲染
  const [, updateState] = useState({});
  useEffect(() => {
    console.log("渲染结束调用 useEffect, 添加监听者");
    // 注册 updateState 为监听者, 监听者是 callback 的 Set 集合
    const { disconnect } = value.subscribe(() => updateState({}));
    return () => disconnect();
  }, [value]);
  console.log("此时 useCoiledValue 的值: " + value.snapshot());
  return value.snapshot();
}

下面是读写 hook 的实现方式,这里的读写 hook 只适用于 atom,默认 selector 不可写。

1
2
3
4
export function useCoiledState<T>(atom: Atom<T>): [T, (value: T) => void] {
  const value = useCoiledValue(atom);
  return [value, useCallback((value) => atom.setState(value), [atom])];
}

Atom

Atom 继承 Stateful,需要一个默认的写值方法。

1
2
3
4
5
export class Atom<T> extends Stateful<T> {
  public setState(value: T) {
    super.update(value);
  }
}

暴露的接口函数是仿写 Recoil 中的 atom 函数。

1
2
3
export function atom<V>(value: { key: string; default: V }): Atom<V> {
  return new Atom(value.default);
}

Selector

Selector 继承 Stateful,Selector 是 Atom 或其他 Selector 的派生值,因此需要添加依赖。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export class Selector<T> extends Stateful<T> {
  // 将 dep 加入 Set 集合
  private registeredDeps = new Set<Stateful<any>>();

  private addDep<V>(dep: Stateful<V>): V {
    if (!this.registeredDeps.has(dep)) {
      // 注册 updateSelector 为监听者,并将 dep 加入 Set 集合
      dep.subscribe(() => this.updateSelector());
      this.registeredDeps.add(dep);
    }
    return dep.snapshot();
  }
  // 调用 generate 直接返回当前的 dep 值
  private updateSelector() {
    this.update(this.generate({ get: (dep) => this.addDep(dep) }));
  }

  constructor(private readonly generate: SelectorGenerator<T>) {
    super(undefined as any);
    this.value = generate({ get: (dep) => this.addDep(dep) });
  }
}
// selector 接收 atom 或者其他 selector 作为依赖
type SelectorGenerator<T> = (context: { get: <V>(dep: Stateful<V>) => V }) => T;

暴露的接口函数是仿写 Recoil 中的 selector 函数。

1
2
3
4
5
6
export function selector<V>(value: {
  key: string;
  get: SelectorGenerator<V>;
}): Selector<V> {
  return new Selector(value.get);
}

使用

将 index.tsx 作如下修改后,启动项目 yarn start,查看浏览器的 cosole 面板,项目成功运行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import React from "react";
import ReactDOM from "react-dom";
import { atom, useCoiledState, useCoiledValue, selector } from "./coiled";
import "./App.css";

const textState =
  atom <
  string >
  {
    key: "textState",
    default: "",
  };

const charCountState =
  selector <
  number >
  {
    key: "charCountState",
    get: ({ get }) => {
      const text = get(textState);
      return text.length;
    },
  };

function TextInput() {
  const [text, setText] = useCoiledState(textState);

  const onChange = (event: any) => {
    setText(event.target.value);
  };

  return (
    <div>
      <input type="text" value={text} onChange={onChange} />
      <br />
      Echo: {text}
    </div>
  );
}

function CharacterCount() {
  const count = useCoiledValue(charCountState);
  return <>Character Count: {count}</>;
}

function App() {
  return (
    <div className="App">
      <TextInput />
      <CharacterCount />
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));

codesandbox 中查看完整代码。

思考

我们仿造 Recoil 实现了自己的状态共享。但请思考以下内容:

  • Selectors 不会取消对 atoms 的监听。这意味着当你不再使用他们时,会造成内存泄漏。

  • Selectors 和 Atoms 在重渲染前仅做一个浅比较。在某些场景下,使用深比较更加合理。

  • Recoil 使用唯一 key 值标识每一个 atom 或 selector,并且它被用作支持 “App-wide observation” 的元数据。这里的实现仅仅是为了保持 API 相似。

  • Recoil 在 selectors 里支持异步,这里没有实现这个特性。

我在 github 上发现了 jotai 项目。它与我的仿写非常相似,并且支持异步。

参阅资料