如果你也喜欢使用react的函数组件,并喜欢使用react原生的hook进行状态管理,但为了跨组件状态流而不得不引入redux,MboX这种具有自己独立的状态管理的重量级/对象级的状态流框架的话,本文会给你提供一种新的极其轻量的解决跨组件状态流方案。

Context的问题

首先探讨如果不采用redux,mobx,使用原生的react的跨组件共享状态方案Context,会具备那些问题?

react原生的跨组件通信为Context。在使用Context进行组件之间通信时,需要进行状态提升,提升到需要通信的组件的公共的祖先节点之中。这会导致当数据的变化时祖先节点产生re-render, 从而祖先节点中的整个组件树都会re-render,带来非常大的性能损失。react官方推荐使用React.memo包裹函数,降低非必要组件渲染。如:

const Context = React.createContext<any>({})
const SubCompA: React.FC<{}> = React.memo(() => {
  console.log('渲染了A');
  const { number } = React.useContext(Context);
  return (<div>
    {number}
  </div>);
});
const SubCompC: React.FC<{}> = React.memo(() => {
  console.log('渲染了C');
  const { setNumber } = React.useContext(Context);
  return (<button className='__button' onClick={() => {
    setNumber(10);
  }}>我是按钮</button>);
});
const SubCompB: React.FC<{}> = React.memo(() => {
  console.log('渲染了B');
  return (<div>
    <SubCompC />
  </div>);
});
const SubCompD: React.FC<{}> = React.memo(() => {
  console.log('渲染了D');
  return (<div></div>);
});
const Root: React.FC<{}> = React.memo(() => {
  console.log('渲染了Root');
  const [number, setNumber] = React.useState(1);
  return (<Context.Provider value={{ number, setNumber }}>
    <SubCompA />
    <SubCompB />
    <SubCompD />
  </Context.Provider>);
});

在本案例中,点击按钮后,会导致组件SubCompA, SubCompC, Root组件re-render,但SubCompC, Root都是不受期望的re-render。且在实际使用情况下,性能会损失更大,因为:

  • 不会把每一个状态单独放到一个的Context中。当Context中包含多个状态时,任何一个状态发生变化后,不管有没有依赖具体发生变化的那个状态,所有使用了该Context的组件都会更新,导致re-render的非法扩散(不受期望的re-render)。
  • 非常依靠React.memo发挥效果,但在实际开发过程,使React.memo保持完美运行是一件非常困难的事情。如不应该传递给组件的属性值使用对象和函数的字面量。

如下面的对于组件的使用:

const CompA: React.FC<{}> = React.memo(() => {
  return (<div>1</div>);
});

const Root: React.FC<{}> = React.memo(() => {
  return (<CompA objectProp={{ name: 'joy' }} onClick={() => {
    // ....
  }} />);
});

在本案例中,上文对于CompA进行React.memo包裹将没有一点意义。需要调整为:

const CompA: React.FC<{}> = React.memo(() => {
  return (<div>1</div>);
});

const Root: React.FC<{}> = React.memo(() => {
  const objectProp = React.useMemo(() => ({ name: 'joy' }));
  const handleClick = React.useCallback(() => {
    // ....
  }, []);
  return (<CompA objectProp={objectProp} onClick={handleClick} />);
});
这里并不是想说memo没有必要。memo是提升性能的一个很重要的手段,在平常开发过程中,非常需要严格遵循,努力使memo发挥作用。

综上所述,Context中的性能损失,主要的原因是状态提升导致更大范围的组件re-render造成。

新的方案

为了解决原生Context的问题,不能进行状态进行提升,而是在不同的组件中存在多个相同含义的状态,然后通过统一的机制管理这些状态的值,使它实际效果跟Context状态提升的状态一致即可。管理机制可以采取事件。

如:

const eventEmitter = new EventEmitter();
const CompA: React.FC<{}> = React.memo(() => {
  const [age, setAge] = React.useState(0);
  React.useEffect(() => {
    eventEmitter.addListener('updateAge', setAge);
  }, []);
  return (<div>{state}</div>);
});

const CompB: React.FC<{}> = React.memo(() => {
  return (<div onClick={() => {
    eventEmitter.emit('updateAge', 10);
  }}>1</div>);
});

const Root: React.FC<{}> = React.memo(() => {
  return (<>
    <CompA />
    <CompB />
  </>);
});

但实际场景中,不能这样使用,因为:

  • 在复杂系统中,需要的管理的状态流非常庞大,随着迭代事件名也非常难以管理,为解决重名问题慢慢也会蜕变成redux或者MboX那种采取对象命名空间;
  • 相同意义的状态,实际上还是会存在多个状态(不同组件上),这些状态除了受到受到事件的管理,还能自己控制,极易带来数据没有保持一致的风险;

解决事件名的问题,可以采取动态创建随机的事件名来解决。在需要通信的组件共同的祖先节点中,封装一个事件监听管理器中,屏蔽掉内部事件名的逻辑:

const eventEmitter = new EventEmitter();

function useSharedState() {
  const eventNameRef = React.useRef<string>(`SHARE_STATE_${String(Math.random()).slice(2)}`);

  React.useEffect(() => {
    const eventName = eventNameRef.current;

    return () => {
      // 注销事件
      if (emitter.eventNames().includes(eventName)) {
        emitter.removeAllListeners(eventName);
        emitter.off(eventName);
      }
    };
  }, []);

  const emit = React.useCallback((value) => {
    emitter.emit(eventNameRef.current, value);
  }, []);

  const addListener = React.useCallback((callback) => {
    eventEmitter.addListener(eventNameRef.current, callback);
  }, []);

  const channel = React.useMemo(() => ({
    emit, addListener,
  }), []);

  return channel;
}

const Context = React.createContext<any>({});
const CompA: React.FC<{}> = React.memo(() => {
  const { channel } = React.useContext(Context);
  React.useEffect(() => {
    channel.addListener(setAge);
  }, []);
  return (<div>{state}</div>);
});

const CompB: React.FC<{}> = React.memo(() => {
  return (<div onClick={() => {
    channel.emit(10);
  }}>1</div>);
});

const Root: React.FC<{}> = React.memo(() => {
  const channel = useSharedState();
  return (<Context.Provider value={{ channel }}>
    <CompA />
    <CompB />
  </Context.Provider>);
});
为了节省内存的使用,所有的事件通信将使用同一个事件流。

为了保证状态值一致性更加可控,也为了使「状态」看起来更加像一个状态,还需要将每个组件中的状态的使用和更新进行封装起来:

const eventEmitter = new EventEmitter();

function useSharedState() {
  const eventNameRef = React.useRef<string>(`SHARE_STATE_${String(Math.random()).slice(2)}`);

  React.useEffect(() => {
    const eventName = eventNameRef.current;

    return () => {
      // 注销事件
      if (emitter.eventNames().includes(eventName)) {
        emitter.removeAllListeners(eventName);
        emitter.off(eventName);
      }
    };
  }, []);

  const setValue = React.useCallback((value) => {
    emitter.emit(eventNameRef.current, value);
  }, []);

  const addListener = React.useCallback((callback) => {
    eventEmitter.addListener(eventNameRef.current, callback);
  }, []);

  const useValue = React.useMemo(() => {
    return () => {
      // eslint-disable-next-line react-hooks/rules-of-hooks
      const [state, setState] = React.useState(valueRef.current);

      React.useLayoutEffect(() => {
        addListener(setState);
      }, []);
      return state;
    };
  }, []);

  const channel = React.useMemo(() => ({ useValue, setValue }), []);

  return channel;
}

在组件的共同祖先节点中,会创建一个复杂的状态通信管理器,可以称之为通道。通道通过Context下传到各个需要的组件,由于通道都是常量值,本身是不会触发任何组件的re-render。利用通道可以创建状态,此时才会创建一个真正的react状态,状态的更新将会导致当前的组件的re-render。同时通道封装了对这个状态的值更新逻辑,当在任何一个组件中更新当前react状态时,都会通过事件同步到其他组件的同样业务含义的react状态,达到「感觉就是一个状态」的效果。

至此,一个跨组件的react状态流就已经实现。然后为了提高可用性,参考一些signal相关设计添加一些api,支持一些特殊场景,在增加亿点点细节,变为:

import * as React from 'react';
import EventEmitter from 'eventemitter3';
import isFunction from 'lodash.isfunction';

export type Value<A> = (A | ((prevState: A) => A));
export type Dispatch<A> = (value: Value<A>) => void;
export type UseValue<A> = () => A;
export type GetValue<A> = () => A;
export type SubscribeCallback<A> = (value: A) => void;
export type Subscribe<A> = (callback: SubscribeCallback<A>) => () => void;

const emitter = new EventEmitter();

export interface Channel<S> {
  /**
   * 获取信号最新值,该值不支持响应式
   */
  getValue: GetValue<S>;
  /**
   * 获取信号值的hook,注意符合hook的使用规范
   */
  useValue: UseValue<S>;
  /**
   * 设置信号值
   */
  setValue: Dispatch<S>;
  /**
   * 信号值变化的订阅函数
   */
  subscribe: Subscribe<S>;
}

export default function useSharedState<S>(
  initialState: S | (() => S),
): Channel<S> {
  const eventNameRef = React.useRef<string>(`SharedState_${String(Math.random()).slice(2)}`);
  const initialValue: S = React.useMemo(() => {
    if(isFunction(initialState)) {
      return initialState();
    }
    return initialState;
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const valueRef = React.useRef<S>(initialValue);

  React.useEffect(() => {
    const eventName = eventNameRef.current;

    return () => {
      if (emitter.eventNames().includes(eventName)) {
        emitter.removeAllListeners(eventName);
        emitter.off(eventName);
      }
    };
  }, []);

  const dispatch: Dispatch<S> = React.useCallback<Dispatch<S>>((value) => {
    valueRef.current = isFunction(value) ? value(valueRef.current) : value;
    emitter.emit(eventNameRef.current, valueRef.current);
  }, []);

  const subscribe: Subscribe<S> = React.useCallback<Subscribe<S>>((callback) => {
    // 避免重复注册
    emitter.off(eventNameRef.current, callback);
    emitter.addListener(eventNameRef.current, callback);
    // 注销
    return () => {
      emitter.off(eventNameRef.current, callback);
    };
  }, []);

  const useValue: UseValue<S> = React.useMemo<UseValue<S>>(() => {
    return () => {
      // eslint-disable-next-line react-hooks/rules-of-hooks
      const [state, setState] = React.useState<S>(valueRef.current);
      const subscribeFn = React.useCallback<SubscribeCallback<S>>((value) => {
        setState(value);
      }, []);

      // eslint-disable-next-line react-hooks/rules-of-hooks
      React.useLayoutEffect(() => {
        const unsubscribe = subscribe(subscribeFn);
        return () => {
          unsubscribe();
        };
      }, [subscribeFn]);
      return state;
    };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const getValue: GetValue<S> = React.useCallback<GetValue<S>>(() => {
    return valueRef.current;
  }, []);

  const sharedState = React.useMemo<Channel<S>>(() => ({
    useValue, getValue, setValue: dispatch, subscribe,
  }), []);

  return sharedState;
}

相关库已经发布到npm上,为@joyer/react-use-shared-state, 欢迎体验。

支持react>16.18, 特别声明支持18版本, 本人项目中已经使用并上线2年多

优势

  • 非常轻量,改方案想要解决的问题非常简单,本质上也就是一个事件流工具;
  • 由于轻量,所以灵活。
  • 不依赖react.memo,连equals计算消耗都没有;
  • 保持跟useState同样的颗粒度。当你不需要redux,mobx这些基于对象的状态流,不喜欢抽象什么领域,模型的情况下,使用改方案体验非常友好,使用体验也是非常接近于useState;
  • 性能卓越,非常容易做到「真正需要渲染的地方才渲染」的效果;
  • 非常容易集成到已有系统。就算接手的系统已经是一座「屎山」,使用react-use-shared-state进行改造也非常简单,只需要对跨组件的状态进行一一改造即可,还可以渐进式慢慢调整。对于不考虑后续可维护性和可读性的话,可以简单的将一个页面的跨组件状态都放在同一个地方,且这种行为不会影响性能。

joyerli
158 声望5 粉丝

前端搬砖一枚,会分享一些对技术的个人理解和思考,还会分享一些自己解决实际碰到的业务需而设计的奇葩技术方案。