[译]-100行代码从零实现 Facebook 的 Recoil 库 | 掘金技术征文-双节特别篇

2,191 阅读8分钟

来源:Rewriting Facebook's "Recoil" React library from scratch in 100 lines

译者:塔希

协议:CC BY-NC-SA 4.0

Atoms

Recoil 是围绕着 “atoms” 这个概念构建的。Atoms 是组成整个状态中的原子性的一部分,你可以在组件中订阅它或更改它的值。

开始,我将创建一个叫做 Atom 的类 ,用来包裹一些值 T 。我加了一些辅助方法 updatesnapshot 允许你获得或更改 Atom 的值。

class Atom<T> {
  constructor(private value: T) {}

  update(value: T) {
    this.value = value;
  }

  snapshot(): T {
    return this.value;
  }
}

为了能够监听到状态的变化,你需要使用观察者模式。这种模式常见于像 RxJS 这样的库,不过我们将从头开始写一个简单同步的版本,方便使用理解。

为了知道是谁在监听状态,我使用 Set 来存储用于监听的回调函数。一个 Set (或 Hash Set) 是一个存储独一无二值的数据结构。在 JavaScript 中,它可以很容易的转变成数组,并且带有一些富有帮助性的方法来高效的添加或移除值。

通过 subscrible 方法我们可以增加一个监听者。 subscrible 方法返回一个 Disconnecter - 一个带有停止监听方法的接口。 这个方法的调用时机是在当一个 React 组件被卸载,你不再想监听状态变化时。

接下来,一个叫做 emit 的方法被添加了。这个方法会遍历所有的监听函数,将当前存储的值传递给他们。

最终,我们重写了 update 方法,当新的值被设置时,我们会执行 emit 操作。

type Disconnecter = { disconnect: () => void };

class Atom<T> {
  private listeners = new Set<(value: T) => void>();

  constructor(private value: T) {}

  update(value: T) {
    this.value = value;
    this.emit();
  }

  snapshot(): T {
    return this.value;
  }

  emit() {
    for (const listener of this.listeners) {
      listener(this.snapshot());
    }
  }

  subscribe(callback: (value: T) => void): Disconnecter {
    this.listeners.add(callback);
    return {
      disconnect: () => {
        this.listeners.delete(callback);
      },
    };
  }
}

呼!

是时候将 atom 和 React 组件连接在一起了。为了做到这点,我创建一个叫 useCoiledValue 的 hook。(听起来很熟悉?)

这个 hook 会返回 atom 当前存储的状态值,并监听其变化,当状态值变化时进行重渲染。当这个 hook 被卸载时,它会清除掉开始设置的监听函数。

关于 updateState 这个 hook 可能会有点奇怪。通过 updateState 设置一个新({})的引用,React 会重新渲染这个组件。这种手段可能有点 hack ,不过却是简单有效的方式来保证组件一定会被重渲染(当 atom 的值更新时)。

export function useCoiledValue<T>(value: Atom<T>): T {
  const [, updateState] = useState({});

  useEffect(() => {
    const { disconnect } = value.subscribe(() => updateState({}));
    return () => disconnect();
  }, [value]);

  return value.snapshot();
}

接下来,我添加了一个 useCoiledState 方法。它的 API 很像 useState - 它会给你 atom 当前存储的最新值并允许你进行重新设置。

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

现在,我们已经这些理解了这些 hooks,是时间来研究下 Selectors 了。在那之前,我们先将之前的代码重构下。

和 atom 一样,一个 selector 可以看作是一个带有状态的值。为了能够实现 selector 时简单点,我先将大部分逻辑从 Atom 中抽出到一个叫做 Stateful 的基类中。

class Stateful<T> {
  private listeners = new Set<(value: T) => void>();

  constructor(private value: T) {}

  protected _update(value: T) {
    this.value = value;
    this.emit();
  }

  snapshot(): T {
    return this.value;
  }

  subscribe(callback: (value: T) => void): Disconnecter {
    this.listeners.add(callback);
    return {
      disconnect: () => {
        this.listeners.delete(callback);
      },
    };
  }
}

class Atom<T> extends Stateful<T> {
  update(value: T) {
    super._update(value);
  }
}

接着来!

Selectors

Selector 是 Recoil 版本的 “计算属性” 或 "reducers". 用他们的原话讲

一个选择器代表着一份派生状态。你可以将派生状态视为某种状态传递纯函数,在其内部进行修改然后输出的结果

Recoil 中的 selectors 的 API 很简单,你创建一个带有 get 方法的对象, get 的返回值就是你当前的状态值。在 get 方法内部,你可以订阅其他的状态值,当他们更新时,你的 selector 同样会更新。

在我们版本中,我将 get 方法重命名成了 generator。我之所以这样称呼它,是因为本质上 generator 是一个工厂函数,能够根据流入的状态生产出新的状态值。

在代码中,我们用下面这个函数签名来标注 generator 方法

type SelectorGenerator<T> = (context: GeneratorContext) => T;

针对那些对不熟悉 Typescript 的人解释下,这这是一个接受环境对象(GeneratorContext)作为参数,返回 T 类型值的函数。这个返回值会成为 selector 内部存储的状态值。

GeneratorContext 对象的作用是什么?

它使得 selectors 可以访问其他的状态值,并基于他们计算出自己的状态值。从现在开始,我们将这些其他状态值称呼为 “依赖”

interface GeneratorContext {
  get: <V>(dependency: Stateful<V>) => V
}

任何时候有人调用了 GeneratorContext 上的 get 方法,都会把被访问的状态值作为依赖加入到依赖数组中。这意味这,当任何一个依赖项更新时, selector 同样会更新。

下面是一个创建 selector 的生产函数的样子

function generate(context) {
  // Register the NameAtom as a dependency
  // and get it's value
  const name = context.get(NameAtom);
  // Do the same for AgeAtom
  const age = context.get(AgeAtom);

  // Return a new value using the previous atoms
  // E.g. "Bob is 20 years old"
  return `${name} is ${age} years old.`;
};

先把生产函数放到一边,我们来一个 Selector 类。这个类应该接受一个生产函数作为构造函数的参数,然后使用类上的 getDep 方法取得所依赖的 Atom 们存储的值。

你可能注意到我写的构造函数里的 super(undefined as any). 这是因为 super 关键字必须作为派生类构造函数的第一行。如果有助理解,这里你可以认为 undefined 表示着未初始化的内存.

export class Selector<T> extends Stateful<T> {
  private getDep<V>(dep: Stateful<V>): V {
    return dep.snapshot();
  }

  constructor(
    private readonly generate: SelectorGenerator<T>
  ) {
    super(undefined as any);
    const context = {
      get: dep => this.getDep(dep) 
    };
    this.value = generate(context);
  }
}

这个 selector 仅仅能生产状态值一次。为了能够在依赖变化时对状态值进行更新,我们需要订阅这些依赖。

为了做到这点,我们来升级下 getDep 方法来订阅依赖和调用updateSelector 方法。为了确保 selector 对于每一次依赖的变更只更新一次,我们将这些依赖放到 Set 里来追踪。

updateSelector 方法和先前例子的构造函数很相似。它创建一个 GeneratorContext ,执行 generate 方法,然后调用来自基类 Statefulupdate 方法。

export class Selector<T> extends Stateful<T> {
  private registeredDeps = new Set<Stateful>();

  private getDep<V>(dep: Stateful<V>): V {
    if (!this.registeredDeps.has(dep)) {
      dep.subscribe(() => this.updateSelector());
      this.registeredDeps.add(dep);
    }

    return dep.snapshot();
  }

  private updateSelector() {
    const context = {
      get: dep => this.getDep(dep)
    };
    this.update(this.generate(context));
  }

  constructor(
    private readonly generate: SelectorGenerator<T>
  ) {
    super(undefined as any);
    const context = {
      get: dep => this.getDep(dep) 
    };
    this.value = generate(context);
  }
}

快要完成了。Recoil 有一些辅助函数来帮助创建 atoms 和 selectors。因为大部分 JavaScript 开发者认为类是邪恶的,所以他们可以帮助掩盖我们的逆天大罪。

一个用于创建 atom

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

一个用于创建 selector

export function selector<V>(value: {
  key: string;
  get: SelectorGenerator<V>;
}): Selector<V> {
  return new Selector(value.get);
}

对了,还记得先前的 useCoiledValue hook 吗?我们对它重构下,使其同样能够接受 selectors:

export function useCoiledValue<T>(value: Stateful<T>): T {
  const [, updateState] = useState({});

  useEffect(() => {
    const { disconnect } = value.subscribe(() => updateState({}));
    return () => disconnect();
  }, [value]);

  return value.snapshot();
}

就这样!我们完成了!🎉

表扬下自己吧!

这就完了?

为了简洁(和能够使用"100行"作为标题吸引点击率)起见,我决定省略评论,测试和例子。如果你想要更详尽的解释(或想要尝试下例子),这些东西都在我的 “recoil-clone” Github 仓库 里。

这里同样有个实例网站,你可以进行尝试

结论

我曾经读到过,所有的好软件都应该足够简单,当任何人想要重写的时候就可以进行重写。 Recoil 依然有很多我没实现的特性,但令人兴奋的是,如此的简洁和直观的设计能够合理的被手动实现出来。

在你决定将我的山寨 Recoil 投入生产环境前,确保你已经知道下列这些事情:

  • Selectors 不会取消对 atoms 的监听。这意味着当你不再使用他们时,会造成内存泄漏。
  • React 已经介绍了一个叫 useMutableSource 的 hook 。 如果你正在使用最新版本的 React ,你应该使用它来替换 useCoiledValue 中的 setState 。
  • Selectors 和 Atoms 仅仅会在重新渲染前做一个浅比较。在有些场景下,也许使用深比较比较合理。
  • Recoil 使用 key 属性了标识每一个 atom 或 selector ,其被用作元数据用于支持 “应用范围的状态观察” 。我这里使用它的目的是为了保持 API 相似。
  • Recoil 在 selectors 里支持 async 函数,这将是一个艰巨的任务,所以我将这个特性排除在外了。

除此之外,希望我已经向你展示了在决定使用什么状态管理解决方案时,你不必总是需要依赖第三方库。那样做的话,您往往无法找出完全适合您的解决方案的-毕竟这就是 Recoil 的诞生方式

🏆 掘金技术征文|双节特别篇