当前期刊数: 130
1 引言 unstated 是基于 Class Component 的数据流管理库,unstated-next 是针对 Function Component 的升级版,且特别优化了对 Hooks 的支持。
与类 redux 库相比,这个库设计的别出心裁,而且这两个库源码行数都特别少,与 180 行的 unstated 相比,unstated-next 只有不到 40 行,但想象空间却更大,且用法符合直觉,所以本周精读就会从用法与源码两个角度分析这两个库。
2 概述 首先问,什么是数据流?React 本身就提供了数据流,那就是 setState
与 useState
,数据流框架存在的意义是解决跨组件数据共享与业务模型封装。
还有一种说法是,React 早期声称自己是 UI 框架,不关心数据,因此需要生态提供数据流插件弥补这个能力。但其实 React 提供的 createContext
与 useContext
已经能解决这个问题,只是使用起来稍显麻烦,而 unstated 系列就是为了解决这个问题。
unstated unstated 解决的是 Class Component 场景下组件数据共享的问题。
相比直接抛出用法,笔者还原一下作者的思考过程:利用原生 createContext
实现数据流需要两个 UI 组件,且实现方式冗长:
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 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
的使用方式:
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 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 的你会这样使用数据流:
1 2 3 4 5 6 7 8 9 10 11 12 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 就可以完成,这不需要借助任何框架:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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
就可以完成,这不需要借助任何框架:
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 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
的使用方式:
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 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
设定初始化数据,且不同作用域可以嵌套并赋予不同的初始化值:
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 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
一样能解决:
1 2 3 function Profile ( ) { const { data, error } = useSWR ("/api/user" ); }
取数的业务逻辑封装在 fetcher
中,这个在 SWRConfigContext.Provider
时就已注入,还可以控制作用域!完全利用 React 提供的 Context 能力,可以感受到实现底层原理的一致性和简洁性,越简单越优美的数学公式越可能是真理。
而取数状态已经封装在 useSWR
中,配合 Suspense 能力,连 Loading 状态都不用关心了。
3 精读 unstated 我们再梳理一下 unstated
这个库做了哪些事情。
利用 Provider
申明作用范围。
提供 Container
作为可以被继承的类,继承它的 Class 作为 Store。
提供 Subscribe
作为 RenderProps 用法注入 Store,注入的 Store 实例由参数 to
接收到的 Class 实例决定。
对于第一点,Provider
在 Class Component 环境下要初始化 StateContext
,这样才能在 Subscribe
中使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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
的代码实现:
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 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
源码:
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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 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
这个库只做了一件事情:
提供 createContainer
将自定义 Hooks 封装为一个数据对象,提供 Provider
注入与 useContainer
获取 Store 这两个方法。
正如之前解析所说,unstated-next
可谓将 Hooks 用到了极致,认为 Hooks 已经完全具备数据流管理的全部能力,我们只要包装一层规范即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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 进行嵌套封装,支持一些更复杂的数据场景,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 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 许可证 )