unstated 是基于 Class Component 的数据流管理库,unstated-next 是针对 Function Component 的升级版,且特别优化了对 Hooks 的支持。
与类 redux 库相比,这个库设计的别出心裁,而且这两个库源码行数都特别少,与 180 行的 unstated 相比,unstated-next 只有不到 40 行,但想象空间却更大,且用法符合直觉,所以本周精读就会从用法与源码两个角度分析这两个库。
首先问,什么是数据流?React 本身就提供了数据流,那就是 setState
与 useState
,数据流框架存在的意义是解决跨组件数据共享与业务模型封装。
还有一种说法是,React 早期声称自己是 UI 框架,不关心数据,因此需要生态提供数据流插件弥补这个能力。但其实 React 提供的 createContext
与 useContext
已经能解决这个问题,只是使用起来稍显麻烦,而 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
用 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 包装成一个数据对象,这个对象有 Provider
与 useContainer
两个 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 状态都不用关心了。
我们再梳理一下 unstated
这个库做了哪些事情。
- 利用
Provider
申明作用范围。 - 提供
Container
作为可以被继承的类,继承它的 Class 作为 Store。 - 提供
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
对象,并且可通过 subscribe
与 unsubscribe
增删。
_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);
}
}
对于第三点,Subscribe
的 render
函数将 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
这个库只做了一件事情:
- 提供
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);
借用 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 许可证)