博客 / 詳情

返回

react跨組件狀態流:用事件流實現一個極其輕量高效的狀態流工具

如果你也喜歡使用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進行改造也非常簡單,只需要對跨組件的狀態進行一一改造即可,還可以漸進式慢慢調整。對於不考慮後續可維護性和可讀性的話,可以簡單的將一個頁面的跨組件狀態都放在同一個地方,且這種行為不會影響性能。
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.