精读《unstated 与 unstated-next 源码》

7,065 阅读11分钟

1 引言

unstated 是基于 Class Component 的数据流管理库,unstated-next 是针对 Function Component 的升级版,且特别优化了对 Hooks 的支持。

与类 redux 库相比,这个库设计的别出心裁,而且这两个库源码行数都特别少,与 180 行的 unstated 相比,unstated-next 只有不到 40 行,但想象空间却更大,且用法符合直觉,所以本周精读就会从用法与源码两个角度分析这两个库。

2 概述

首先问,什么是数据流?React 本身就提供了数据流,那就是 setStateuseState,数据流框架存在的意义是解决跨组件数据共享与业务模型封装。

还有一种说法是,React 早期声称自己是 UI 框架,不关心数据,因此需要生态提供数据流插件弥补这个能力。但其实 React 提供的 createContextuseContext 已经能解决这个问题,只是使用起来稍显麻烦,而 unstated 系列就是为了解决这个问题。

unstated

unstated 解决的是 Class Component 场景下组件数据共享的问题。

相比直接抛出用法,笔者还原一下作者的思考过程:利用原生 createContext 实现数据流需要两个 UI 组件,且实现方式冗长:

const Amount = React.createContext(1);

class Counter extends React.Component {
  state = { count: 0 };
  increment = amount => {
    this.setState({ count: this.state.count + amount });
  };
  decrement = amount => {
    this.setState({ count: this.state.count - amount });
  };
  render() {
    return (
      <Amount.Consumer>
        {amount => (
          <div>
            <span>{this.state.count}</span>
            <button onClick={() => this.decrement(amount)}>-</button>
            <button onClick={() => this.increment(amount)}>+</button>
          </div>
        )}
      </Amount.Consumer>
    );
  }
}

class AmountAdjuster extends React.Component {
  state = { amount: 0 };
  handleChange = event => {
    this.setState({
      amount: parseInt(event.currentTarget.value, 10)
    });
  };
  render() {
    return (
      <Amount.Provider value={this.state.amount}>
        <div>
          {this.props.children}
          <input
            type="number"
            value={this.state.amount}
            onChange={this.handleChange}
          />
        </div>
      </Amount.Provider>
    );
  }
}

render(
  <AmountAdjuster>
    <Counter />
  </AmountAdjuster>
);

而我们要做的,是将 setState 从具体的某个 UI 组件上剥离,形成一个数据对象实体,可以被注入到任何组件。

这就是 unstated 的使用方式:

import React from "react";
import { render } from "react-dom";
import { Provider, Subscribe, Container } from "unstated";

class CounterContainer extends Container {
  state = {
    count: 0
  };

  increment() {
    this.setState({ count: this.state.count + 1 });
  }

  decrement() {
    this.setState({ count: this.state.count - 1 });
  }
}

function Counter() {
  return (
    <Subscribe to={[CounterContainer]}>
      {counter => (
        <div>
          <button onClick={() => counter.decrement()}>-</button>
          <span>{counter.state.count}</span>
          <button onClick={() => counter.increment()}>+</button>
        </div>
      )}
    </Subscribe>
  );
}

render(
  <Provider>
    <Counter />
  </Provider>,
  document.getElementById("root")
);

首先要为 Provider 正名:Provider 是解决单例 Store 的最佳方案,当项目与组件都是用了数据流,需要分离作用域时,Provider 便派上了用场。如果项目仅需单 Store 数据流,那么与根节点放一个 Provider 等价。

其次 CounterContainer 成为一个真正数据处理类,只负责存储与操作数据,通过 <Subscribe to={[CounterContainer]}> RenderProps 方法将 counter 注入到 Render 函数中。

unstated 方案本质上利用了 setState,但将 setState 与 UI 剥离,并可以很方便的注入到任何组件中。

类似的是,其升级版 unstated-next 本质上利用了 useState,利用了自定义 Hooks 可以与 UI 分离的特性,加上 useContext 的便捷性,利用不到 40 行代码实现了比 unstated 更强大的功能。

unstated-next

unstated-next 用 40 行代码号称 React 数据管理库的终结版,让我们看看它是怎么做到的!

还是从思考过程说起,笔者发现其 README 也提供了对应思考过程,就以其 README 里的代码作为案例。

首先,使用 Function Component 的你会这样使用数据流:

function CounterDisplay() {
  let [count, setCount] = useState(0);
  let decrement = () => setCount(count - 1);
  let increment = () => setCount(count + 1);
  return (
    <div>
      <button onClick={decrement}>-</button>
      <p>You clicked {count} times</p>
      <button onClick={increment}>+</button>
    </div>
  );
}

如果想将数据与 UI 分离,利用 Custom Hooks 就可以完成,这不需要借助任何框架:

function useCounter() {
  let [count, setCount] = useState(0);
  let decrement = () => setCount(count - 1);
  let increment = () => setCount(count + 1);
  return { count, decrement, increment };
}

function CounterDisplay() {
  let counter = useCounter();
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  );
}

如果想将这个数据分享给其他组件,利用 useContext 就可以完成,这不需要借助任何框架:

function useCounter() {
  let [count, setCount] = useState(0);
  let decrement = () => setCount(count - 1);
  let increment = () => setCount(count + 1);
  return { count, decrement, increment };
}

let Counter = createContext(null);

function CounterDisplay() {
  let counter = useContext(Counter);
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  );
}

function App() {
  let counter = useCounter();
  return (
    <Counter.Provider value={counter}>
      <CounterDisplay />
      <CounterDisplay />
    </Counter.Provider>
  );
}

但这样还是显示使用了 useContext 的 API,并且对 Provider 的封装没有形成固定模式,这就是 usestated-next 要解决的问题。

所以这就是 unstated-next 的使用方式:

import { createContainer } from "unstated-next";

function useCounter() {
  let [count, setCount] = useState(0);
  let decrement = () => setCount(count - 1);
  let increment = () => setCount(count + 1);
  return { count, decrement, increment };
}

let Counter = createContainer(useCounter);

function CounterDisplay() {
  let counter = Counter.useContainer();
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  );
}

function App() {
  return (
    <Counter.Provider>
      <CounterDisplay />
      <CounterDisplay />
    </Counter.Provider>
  );
}

可以看到,createContainer 可以将任何 Hooks 包装成一个数据对象,这个对象有 ProvideruseContainer 两个 API,其中 Provider 用于对某个作用域注入数据,而 useContainer 可以取到这个数据对象在当前作用域的实例。

对 Hooks 的参数也进行了规范化,我们可以通过 initialState 设定初始化数据,且不同作用域可以嵌套并赋予不同的初始化值:

function useCounter(initialState = 0) {
  let [count, setCount] = useState(initialState);
  let decrement = () => setCount(count - 1);
  let increment = () => setCount(count + 1);
  return { count, decrement, increment };
}

const Counter = createContainer(useCounter);

function CounterDisplay() {
  let counter = Counter.useContainer();
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <span>{counter.count}</span>
      <button onClick={counter.increment}>+</button>
    </div>
  );
}

function App() {
  return (
    <Counter.Provider>
      <CounterDisplay />
      <Counter.Provider initialState={2}>
        <div>
          <div>
            <CounterDisplay />
          </div>
        </div>
      </Counter.Provider>
    </Counter.Provider>
  );
}

可以看到,React Hooks 已经非常适合做状态管理,而生态应该做的事情是尽可能利用其能力进行模式化封装。

有人可能会问,取数和副作用怎么办?redux-saga 和其他中间件都没有,这个数据流是不是阉割版?

首先我们看 Redux 为什么需要处理副作用的中间件。这是因为 reducer 是一个同步纯函数,其返回值就是操作结果中间不能有异步,且不能有副作用,所以我们需要一种异步调用 dispatch 的方法,或者一个副作用函数来存放这些 “脏” 逻辑。

而在 Hooks 中,我们可以随时调用 useState 提供的 setter 函数修改值,这早已天然解决了 reducer 无法异步的问题,同时也实现了 redux-chunk 的功能。

而异步功能也被 useEffect 这个 React 官方 Hook 替代。我们看到这个方案可以利用 React 官方提供的能力完全覆盖 Redux 中间件的能力,对 Redux 库实现了降维打击,所以下一代数据流方案随着 Hooks 的实现是真的存在的

最后,相比 Redux 自身以及其生态库的理解成本(笔者不才,初学 Redux 以及其周边 middleware 时理解了好久),Hooks 的理解学习成本明显更小。

很多时候,人们排斥一个新技术,并不是因为新技术不好,而是这可能让自己多年精通的老手艺带来的 “竞争优势” 完全消失。可能一个织布老专家手工织布效率是入门学员的 5 倍,但换上织布机器后,这个差异很快会被抹平,老织布专家面临被淘汰的危机,所以维护这份老手艺就是维护他自己的利益。希望每个团队中的老织布工人都能主动引入织布机。

再看取数中间件,我们一般需要解决 取数业务逻辑封装取数状态封装,通过 redux 中间件可以封装在内,通过一个 dispatch 解决。

其实 Hooks 思维下,利用 swr useSWR 一样能解决:

function Profile() {
  const { data, error } = useSWR("/api/user");
}

取数的业务逻辑封装在 fetcher 中,这个在 SWRConfigContext.Provider 时就已注入,还可以控制作用域!完全利用 React 提供的 Context 能力,可以感受到实现底层原理的一致性和简洁性,越简单越优美的数学公式越可能是真理。

而取数状态已经封装在 useSWR 中,配合 Suspense 能力,连 Loading 状态都不用关心了。

3 精读

unstated

我们再梳理一下 unstated 这个库做了哪些事情。

  1. 利用 Provider 申明作用范围。
  2. 提供 Container 作为可以被继承的类,继承它的 Class 作为 Store。
  3. 提供 Subscribe 作为 RenderProps 用法注入 Store,注入的 Store 实例由参数 to 接收到的 Class 实例决定。

对于第一点,Provider 在 Class Component 环境下要初始化 StateContext,这样才能在 Subscribe 中使用:

const StateContext = createReactContext(null);

export function Provider(props) {
  return (
    <StateContext.Consumer>
      {parentMap => {
        let childMap = new Map(parentMap);

        if (props.inject) {
          props.inject.forEach(instance => {
            childMap.set(instance.constructor, instance);
          });
        }

        return (
          <StateContext.Provider value={childMap}>
            {props.children}
          </StateContext.Provider>
        );
      }}
    </StateContext.Consumer>
  );
}

对于第二点,对于 Container,需要提供给 Store setState API,按照 React 的 setState 结构实现了一遍。

值得注意的是,还存储了一个 _listeners 对象,并且可通过 subscribeunsubscribe 增删。

_listeners 存储的其实是当前绑定的组件 onUpdate 生命周期,然后在 setState 时主动触发对应组件的渲染。onUpdate 生命周期由 Subscribe 函数提供,最终调用的是 this.setState,这个在 Subscribe 部分再说明。

以下是 Container 的代码实现:

export class Container<State: {}> {
  state: State;
  _listeners: Array<Listener> = [];

  constructor() {
    CONTAINER_DEBUG_CALLBACKS.forEach(cb => cb(this));
  }

  setState(
    updater: $Shape<State> | ((prevState: $Shape<State>) => $Shape<State>),
    callback?: () => void
  ): Promise<void> {
    return Promise.resolve().then(() => {
      let nextState;

      if (typeof updater === "function") {
        nextState = updater(this.state);
      } else {
        nextState = updater;
      }

      if (nextState == null) {
        if (callback) callback();
        return;
      }

      this.state = Object.assign({}, this.state, nextState);

      let promises = this._listeners.map(listener => listener());

      return Promise.all(promises).then(() => {
        if (callback) {
          return callback();
        }
      });
    });
  }

  subscribe(fn: Listener) {
    this._listeners.push(fn);
  }

  unsubscribe(fn: Listener) {
    this._listeners = this._listeners.filter(f => f !== fn);
  }
}

对于第三点,Subscriberender 函数将 this.props.children 作为一个函数执行,并把对应的 Store 实例作为参数传递,这通过 _createInstances 函数实现。

_createInstances 利用 instanceof 通过 Class 类找到对应的实例,并通过 subscribe 将自己组件的 onUpdate 函数传递给对应 Store 的 _listeners,在解除绑定时调用 unsubscribe 解绑,防止不必要的 renrender。

以下是 Subscribe 源码:

export class Subscribe<Containers: ContainersType> extends React.Component<
  SubscribeProps<Containers>,
  SubscribeState
> {
  state = {};
  instances: Array<ContainerType> = [];
  unmounted = false;

  componentWillUnmount() {
    this.unmounted = true;
    this._unsubscribe();
  }

  _unsubscribe() {
    this.instances.forEach(container => {
      container.unsubscribe(this.onUpdate);
    });
  }

  onUpdate: Listener = () => {
    return new Promise(resolve => {
      if (!this.unmounted) {
        this.setState(DUMMY_STATE, resolve);
      } else {
        resolve();
      }
    });
  };

  _createInstances(
    map: ContainerMapType | null,
    containers: ContainersType
  ): Array<ContainerType> {
    this._unsubscribe();

    if (map === null) {
      throw new Error(
        "You must wrap your <Subscribe> components with a <Provider>"
      );
    }

    let safeMap = map;
    let instances = containers.map(ContainerItem => {
      let instance;

      if (
        typeof ContainerItem === "object" &&
        ContainerItem instanceof Container
      ) {
        instance = ContainerItem;
      } else {
        instance = safeMap.get(ContainerItem);

        if (!instance) {
          instance = new ContainerItem();
          safeMap.set(ContainerItem, instance);
        }
      }

      instance.unsubscribe(this.onUpdate);
      instance.subscribe(this.onUpdate);

      return instance;
    });

    this.instances = instances;
    return instances;
  }

  render() {
    return (
      <StateContext.Consumer>
        {map =>
          this.props.children.apply(
            null,
            this._createInstances(map, this.props.to)
          )
        }
      </StateContext.Consumer>
    );
  }
}

总结下来,unstated 将 State 外置是通过自定义 Listener 实现的,在 Store setState 时触发收集好的 Subscribe 组件的 rerender。

unstated-next

unstated-next 这个库只做了一件事情:

  1. 提供 createContainer 将自定义 Hooks 封装为一个数据对象,提供 Provider 注入与 useContainer 获取 Store 这两个方法。

正如之前解析所说,unstated-next 可谓将 Hooks 用到了极致,认为 Hooks 已经完全具备数据流管理的全部能力,我们只要包装一层规范即可:

export function createContainer(useHook) {
  let Context = React.createContext(null);

  function Provider(props) {
    let value = useHook(props.initialState);
    return <Context.Provider value={value}>{props.children}</Context.Provider>;
  }

  function useContainer() {
    let value = React.useContext(Context);
    if (value === null) {
      throw new Error("Component must be wrapped with <Container.Provider>");
    }
    return value;
  }

  return { Provider, useContainer };
}

可见,Provider 就是对 value 进行了约束,固化了 Hooks 返回的 value 直接作为 value 传递给 Context.Provider 这个规范。

useContainer 就是对 React.useContext(Context) 的封装。

真的没有其他逻辑了。

唯一需要思考的是,在自定义 Hooks 中,我们用 useState 管理数据还是 useReducer 管理数据的问题,这个是个仁者见仁的问题。不过我们可以对自定义 Hooks 进行嵌套封装,支持一些更复杂的数据场景,比如:

function useCounter(initialState = 0) {
  const [count, setCount] = useState(initialState);
  const decrement = () => setCount(count - 1);
  const increment = () => setCount(count + 1);
  return { count, decrement, increment };
}

function useUser(initialState = {}) {
  const [name, setName] = useState(initialState.name);
  const [age, setAge] = useState(initialState.age);
  const registerUser = userInfo => {
    setName(userInfo.name);
    setAge(userInfo.age);
  };
  return { user: { name, age }, registerUser };
}

function useApp(initialState) {
  const { count, decrement, increment } = useCounter(initialState.count);
  const { user, registerUser } = useUser(initialState.user);
  return { count, decrement, increment, user, registerUser };
}

const App = createContainer(useApp);

4 总结

借用 unstated-next 的标语:“never think about React state management libraries ever again” - 用了 unstated-next 再也不要考虑其他 React 状态管理库了。

而有意思的是,unstated-next 本身也只是对 Hooks 的一种模式化封装,Hooks 已经能很好解决状态管理的问题,我们真的不需要 “再造” React 数据流工具了。

讨论地址是:精读《unstated 与 unstated-next 源码》 · Issue #218 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证