乐趣区

关于react.js:React-hooks-状态管理方案解析

本文作者:EllieSummer

React v16.8 之后,Function Component 成为支流,React 状态治理的计划也产生微小的转变。Redux 始终作为支流的 React 状态治理计划,尽管提供了一套标准的状态治理流程,但却有着让人饱受诟病的问题:概念太多、上手老本高、反复的样板代码、须要联合中间件应用等。

一个真正易用的状态管理工具往往不须要过多简单的概念。Hooks 诞生之后,代码优雅简洁变成一种趋势。开发者也偏向于用一种小而美、学习成本低的计划来实现状态治理。因而,除了 React local state hooks 之外,社区还孵化了很多状态治理库,如 unstated-next、hox、zustand、jotai 等。

对于状态治理,有个十分经典的场景:实现一个计数器,点击 + 号的时候将数字加一,点击 – 号的时候将数值减一。这简直是所有状态治理库标配的入门案例。

本文将从实现「计数器」这个经典场景登程,逐渐剖析 Hooks 时代下,React 状态治理计划的演进过程和背地的实现原理。

React local state hooks

React 提供了一些治理状态的原生 hooks API,简洁易懂,十分好上手。用原生的 hooks 办法就能够很轻松地实现计数器性能,只有通过 useState 办法在根组件定义计数器的状态和扭转状态的办法,并层层传递给子组件就能够了。

源码

// timer.js
const Timer = (props) => {const { increment, count, decrement} = props;
  return (
    <>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </>
  );
};

// app.js
const App = () => {const [count, setCount] = React.useState(0);
    const increment = () => setCount(count + 1);
    const decrement = () => setCount(count - 1);

    return <Timer count={count} increment={increment} decrement={decrement} />
}

然而这种办法存在很重大的缺点。

首先,计数器的业务逻辑和组件耦合重大,须要将逻辑进行形象拆散,放弃逻辑与组件的纯正性。

其次,多组件内共享状态是通过层层传递的形式实现的,带来冗余代码的同时,根组件的状态将会逐步变成“硕大无朋”。

unstated-next

React 开发者在设计之初,也思考到下面提到的两个问题,自身也提供了对应的解决方案。

React Hooks 就是打着「逻辑复用」的口号而诞生的,自定义 hook 能够解决以前在 Class Component 组件内无奈灵便共享逻辑的问题。

因而,针对业务逻辑耦合的问题,能够提取一个自定义计数器 hook useCount

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

为了防止组件间层层传递状态,能够应用 Context 解决方案。Context 提供了在组件之间共享状态的办法,而不用在树的每个层级显式传递一个 prop。

因而,只须要将状态存储在 StoreContext 中,Provider 下的任意子组件都能够通过 useContext 获取到上下文中的状态。

// timer.js
import StoreContext from './StoreContext';

const Timer = () => {const store = React.useContext(StoreContext);
    // 组件内 render 局部先省略
}

// app.js
const App = () => {const StoreContext = React.createContext();
    const store = useCount();

    return <StoreContext.Provider value={store}><Timer /></StoreContext.Provider>
}

这样代码看起来清新了一些。

然而在应用的时候还是免不了要先定义很多 Context,并且在子组件中进行援用,稍微有点繁琐。

因而,能够对代码进行进一步的封装,将 Context 定义和援用的步骤形象成公共的办法 createContainer

function createContainer(useHook) {
    // 定义 context
    const StoreContext = React.createContext();
    
    function useContainer() {
        // 子组件援用 context
        const store = React.useContext(StoreContext);
        return store;
    }

    function Provider(props) {const store = useHook();

        return <StoreContext.Provider value={store}>{props.children}</StoreContext.Provider>
    }

    return {Provider, useContainer}
}

createContainer 封装后会返回两个对象 Provider 和 useContainer。Provider 组件能够传递状态给子组件,子组件能够通过 useContainer 办法获取全局的状态。通过革新,组件内的代码就会变得十分精简。

const Store = createContainer(useCount);

// timer.js
const Timer = () => {const store = Store.useContainer();
    // 组件内 render 局部先省略
}

// app.js
const App = () => {return <Store.Provider><Timer /></Store.Provider>}

这样,一个根本的状态治理计划成型了!体积小,API 简略,能够说是 React 状态治理库的最小集。源码能够见这里。

这种计划也是状态治理库 unstated-next 的实现原理。

hox

先不要快乐得太早。unstated-next 的计划虽好,但也有缺点的,这也是 React context 广为人诟病的两个问题:

  • Context 须要嵌套 Provider 组件,一旦代码中应用多个 context,将会造成嵌套天堂,组件的可读性和纯正性会直线升高,从而导致组件重用更加艰难。
  • Context 可能会造成不必要的渲染。一旦 context 里的 value 产生扭转,任何援用 context 的子组件都会被更新。

那有没有什么办法能够解决下面两个问题呢?答案是必定的,目前曾经有一些自定义状态治理库解决这两个问题了。

从 context 的解决方案里,其实能够失去一些启发。状态治理的流程能够简化成三个模型:Store(存储所有状态)、Hook(形象公共逻辑,更改状态)、Component(应用状态的组件)。

如果要自定义状态治理库,在脑海中能够先构思下,这三者之前的关系应该是怎么样的?

  • 订阅更新:初始化执行 Hook 的时候,须要收集哪些 Component 应用了 Store
  • 感知变更:Hook 中的行为可能扭转 Store 的状态,也要能被 Store 所感知到
  • 公布更新:Store 一旦变更,须要驱动所有订阅更新的 Component 更新

只有实现这三步,状态治理基本上就实现了。大抵思路有了,上面就能够具体实现了。

状态初始化

首先须要初始化 Store 的状态,也就是 Hook 办法执行返回的后果。同时定义一个 API 办法,供子组件获取 Store 的状态。这样状态治理库的模型就搭进去了。

从业务代码应用办法上能够看出,API 简洁的同时,也防止了 Provider 组件嵌套。

// 状态治理库的框架
function createContainer(hook) {const store = hook();
    // 提供给子组件的 API 办法
    function useContainer() {const storeRef = useRef(store);
        return storeRef.current;
    }
    return useContainer;
}

// 业务代码应用:API 简洁
const useContainer = createContainer(useCount);

const Timer = () => {const store = useContainer();
    // 组件内 render 局部先省略
}

订阅更新

为了实现 Store 状态更新的时候,可能驱动组件更新。须要定义一个 listeners 汇合,在组件初始化的时候往数组增加 listener 回调,订阅状态的更新。

function createContainer(hook){const store = hook();

    const listeners = new Set();    // 定义回调汇合
    
    function useContainer() {const storeRef = useRef(store);
    
        useEffect(() => {listeners.add(listener);  // 初始化的时候增加回调,订阅更新
            
            return () =>  listeners.delete(listener) // 组件销毁的时候移除回调
        },[])
        return storeRef.current;
    }

    return useContainer;
}

那么当状态更新后,如何驱动组件更新呢?这里能够利用 useReducer hook 定义一个自增函数,应用 forceUpdate 办法即可让组件重刷。

const [, forceUpdate] = useReducer((c) => c + 1, 0);

function listener(newStore) {forceUpdate();
    storeRef.current = newStore;
}

感知状态变更

状态变更驱动组件更新的局部曾经实现。当初比拟重要的问题是,如何感知到状态产生变更了呢?

状态变更是在 useCount Hook 函数内实现的,用的是 React 原生的 setState 办法,也只能在 React 组件内执行。因而,很容易想到,如果应用一个函数组件 Executor 援用这个 Hook,那么在这个组件内就能够初始化状态,并感知状态变更了。

思考到状态治理库的通用性,能够通过 react-reconciler 结构一个 react 渲染器来挂载 Executor 组件,这样就能够别离反对 React、ReactNative 等不同框架。

// 结构 react 渲染器
function render(reactElement: ReactElement) {const container = reconciler.createContainer(null, 0, false, null);
  return reconciler.updateContainer(reactElement, container);
}

// react 组件,感知 hook 内状态的变更
const Executor = (props) => {const store = props.hook();
    const mountRef = useRef(false);
    
    // 状态初始化
    if (!mountRef.current) {props.onMount(store);
        mountRef.current = true;
    }

    // store 一旦变更,就会执行 useEffect 回调
    useEffect(() => {props.onUpdate(store); // 一旦状态变更,告诉依赖的组件更新
    });

    return null;
};
function createContainer(hook) {
    let store;
    const onUpdate = () => {};

    // 传递 hook 和更新的回调函数        
    render(<Executor hook={hook} onMount={val => store = val}  onUpdate={onUpdate} />);

    function useContainer() {}
    return useContainer;
}

准确更新

一旦感知到状态变更后,在 onUpdate 回调里能够告诉之前订阅过更新的组件从新渲染,也就是遍历 listeners 汇合,执行之前增加的更新回调。

const onUpdate = (store) => {for (const listener of listeners) {listener(store);
    }
}

然而,组件往往可能只依赖了 Store 里的某一个状态,所有组件都更新的操作太粗犷,会带来不必要的更新,须要进行准确的更新渲染。因而,能够在组件的更新回调里判断以后依赖的状态是否变动,从而决定是否触发更新。

// useContainer API 扩大减少依赖属性
const store = useContainer('count'); // 组件仅依赖 store.count 值

// 更新回调里判断
function listener(newStore) {const newValue = newStore[dep];          
    const oldValue = storeRef.current[dep];

    // 仅仅在依赖产生变更,才会组件进行更新
    if (compare(newValue, oldValue)) {forceUpdate();
    }
    storeRef.current = newStore;
}

实现以上的步骤,一个简略又好用的状态治理库就实现啦!源码能够看这里。
状态更新的流程如下图所示。

API 简洁,逻辑和 UI 拆散,能跨组件传输状态,没有冗余的嵌套组件,并且能实现准确更新。

这也是状态治理库 hox 背地的实现原理。

zustand

对于如何感知状态变更这一节中,因为 useCount 函数中是通过操作 react 原生 hook 办法实现状态变更的,所以咱们须要用 Executor 作为两头桥梁来感知状态变更。

然而,这其实是一种委屈求全的计划,不得已将计划复杂化了。试想下,如果变更状态的办法 setState 是由状态治理库本身提供的,那么一旦执行该办法,就能够感知状态变更,并触发后续的比拟更新操作,整体流程会简略很多!

// 将扭转状态的 setState 办法传递给 hook
// hook 内一旦执行该办法,即可感知状态变更,拿到最新的状态
function useCount(setState) {const increment = () => setState((state) => ({...state, count: state.count + 1}));
  const decrement = () => setState((state) => ({...state, count: state.count - 1}));
  return {count: 0, increment, decrement};
}
function createContainer(hook) {
    let store;
    
    const setState = (partial) => {const nexStore = partial(store);
        // hook 中一旦执行 setState 的操作,且状态变更后,将触发 onUpdate 更新
        if(nexStore !== store){store = Object.assign({}, store, nexStore);
            onUpdate(store);
        }
    };
    // 将扭转状态的办法 setState 传递给 hook 函数
    store = hook(setState);
}

const useContainer = createContainer(useCount);

这种计划更加高超,让状态治理库的实现更加简洁明了,库的体积也会小不少。源码可见这里。

这种计划是 zustand 背地的大抵原理。尽管须要开发者先相熟下对应的写法,然而 API 与 Hooks 相似,学习老本很低,上手容易。

总结

本文从实现一个计数器场景登程,论述了多种状态治理的计划和具体实现。不同状态治理计划产生都有着各自的背景,也有着各自的优劣。

然而自定义状态治理库的设计思维都是差不多的。目前开源社区比拟沉闷的状态治理库大多是如此,不同点次要是在如何感知状态变更这块做些文章。

看完本文,想必你曾经晓得如何进行 React Hooks 下的状态治理了,那就连忙口头吧!

本文公布自网易云音乐技术团队,文章未经受权禁止任何模式的转载。咱们长年招收各类技术团队,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe (at) corp.netease.com!

退出移动版