在 React 中使用事件分离状态

最近在 React 项目遇到了一个问题, Web App 有一个登录弹窗, 在没有登录的情况下进行一些需要用户信息的操作, 首先会弹出登录弹窗.

然而这种操作分布在各个组件, 所以在 redux 保留一个变量 loginDialogVisible, 通过 action 控制登录弹窗是否展示.

// reducer
import TYPE from 'path/type';

export const showLoginDialog = () => ({
  type: TYPE.SHOW_LOGIN_DIALOG,
});

export const hideLoginDialog = () => ({
  type: TYPE.HIDE_LOGIN_DIALOG,
});

const initialState = {
  // ...
  loginDialogVisible: false,
};

export default (state = initialState, { type, payload }) => {
  switch (type) {
    // ...
    case TYPE.SHOW_LOGIN_DIALOG:
      return {
        ...state,
        loginDialogVisible: true,
      };
    case TYPE.HIDE_LOGIN_DIALOG:
      return {
        ...state,
        loginDialogVisible: false,
      };
    // ...
  }
};
// 登录弹窗
import React from 'react';
import { useSelector, shallowEqual } from 'react-redux';

const LoginDialog = () => {
  const visible = useSelector(
    (state) => state.loginDialogVisible,
    shallowEqual,
  );
  // ...

  return visible && <div>...</div>;
};

export default LoginDialog;
// 需要弹出登录弹窗的组件
import React from 'react';
import { useDispatch } from 'react-redux';

import { showLoginDialog } from 'path/reducer';

const Component = () => {
  const dispatch = useDispatch();
  const onShowLoginDialog = () => dispatch(showLoginDialog());

  return (
    <button type="button" onClick={onShowLoginDialog}>
      show login dialog
    </button>
  );
};

export default Component;

随着功能的迭代, 需要弹出登录弹窗的组件越来越多, 所以下面的代码会重复出现.

import { useDispatch } from 'react-redux';

import { showLoginDialog } from 'path/reducer';

// ...
const dispatch = useDispatch();
const onShowLoginDialog = () => dispatch(showLoginDialog());
// ...

为了减少重复代码, 可以提取成一个自定义 hooks.

// useShowLoginDialog.js
import { useDispatch, useCallback } from 'react-redux';

import { showLoginDialog } from 'path/reducer';

export default () => {
  const dispatch = useDispatch();
  const onShowLoginDialog = useCallback(() => dispatch(showLoginDialog()), [
    dispatch,
  ]);
  return onShowLoginDialog;
};

在需要弹出弹窗的组件移除重复的代码.

import React from 'react';

import useShowLoginDialog from 'path/useShowLoginDialog';

const Component = () => {
  const onShowLoginDialog = useShowLoginDialog();

  return (
    <button type="button" onClick={onShowLoginDialog}>
      show login dialog
    </button>
  );
};

export default Component;

虽然上面的解决方案已经挺好, 但有一个问题, loginDialogVisible 这个状态有且只有 LoginDialog 使用, 应属于 LoginDialog 的 局部状态, 没有必要放在 redux 影响全局状态, 放在 redux 的目的只是为了其他组件能够控制这个状态.

怎么使 loginDialogVisible 成为 LoginDialog 的局部状态, 并且能在其他组件控制这个状态?

答案就是通过 事件.

首先实现一个全局事件触发器, 当然, 也可以选用一些非常受欢迎的库, 比如 eventemitter3.

为了重复造轮子(其实是为了减小包体积), 下面以自定义全局事件触发器为例, 原理是利用浏览器自身的 CustomEvent 接口.

// eventemitter.js

// 自定义事件类型
export const TYPE = {
  SHOW_LOGIN_DIALOG: 'show_login_dialog',
};

export default {
  on: (type, listener) => document.addEventListener(type, listener),
  off: (type, listener) => document.removeEventListener(type, listener),
  /**
   * payload就是在触发事件的同时携带数据
   * 但是在登录弹窗这个例子里没有用到
   * 通过event.detail可以获取到payload
   */
  emit: (type, payload) =>
    document.dispatchEvent(
      new CustomEvent(type, {
        detail: payload,
        bubbles: false,
        cancelable: false,
      }),
    ),
};
// 登录弹窗
import React, { useState, useEffect } from 'react';

import eventemitter, { TYPE } from 'path/eventemitter';

const LoginDialog = () => {
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const listener = () => setVisible(true);
    eventemitter.on(TYPE.SHOW_LOGIN_DIALOG, listener);
    return () => eventemitter.off(TYPE.SHOW_LOGIN_DIALOG, listener);
  }, []);

  return visible && <div>...</div>;
};

export default LoginDialog;
// 触发弹窗的组件
import React, { useCallback } from 'react';

import eventemitter, { TYPE } from 'path/eventemitter';

const Component = () => {
  const onShowLoginDialog = useCallback(
    () => eventemitter.emit(TYPE.SHOW_LOGIN_DIALOG),
    [],
  );

  return (
    <button type="button" onClick={onShowLoginDialog}>
      show login dialog
    </button>
  );
};

export default Component;

这样, 使 loginDialogVisible 成为 LoginDialog 的局部状态, 其他组件依然能够控制 loginDialogVisible, 并且完全脱离了 redux 的依赖.

通过事件机制, 可以很大程度上分离 react 中的状态, 不仅可以分离全局状态, 而且可以提升 React 性能.

import React, { useState } from 'react';

import Increase from 'path/increase';
import Decrease from 'path/decrease';
import Display from 'path/display';

const App = () => {
  const [count, setCount] = useState(0);
  const onIncrease = () => setCount((c) => c + 1);
  const onDecrease = () => setCount((c) => c - 1);

  return (
    <div>
      <Display count={count} />
      <Increase onIncrease={onIncrease} />
      <Decrease onDecrease={onDecrease} />
    </div>
  );
};

上面点击 Increase 组件使 count + 1, 点击 Decrease 组件使 count - 1, Display 组件负责展示 count.

因为状态 count 属于根组件, 所以无论点击 Increase 或者 Decrease 组件导致的变更, 三个子组件都会重新执行各自的 render 方法. 然而实际上无论 count 怎么变, 我们都知道 Increase 和 Decrease 的 render 结果都不会变, 所以重新执行 render 是没有必要的, 唯一需要更新的只有 Display 组件.

当然, 通过 React 自带的一些工具方法可以达到性能优化的目的.

import React, { useCallback, useMemo } from 'react';

// ...
const onIncrease = useCallback(() => setCount((c) => c + 1), []);
const onDecrease = useCallback(() => setCount((c) => c - 1), []);
return (
  <div>
    <Display count={count} />
    {useMemo(
      () => (
        <Increase onIncrease={onIncrease} />
      ),
      [onIncrease],
    )}
    {useMemo(
      () => (
        <Decrease onIncrease={onIncrease} />
      ),
      [onDecrease],
    )}
  </div>
);

通过 useCallback 能够保证 onIncrease 和 onDecrease 在组件存活周期内不变, 而通过 useMemo 可以缓存 Increase 和 Decrease 的渲染结果, 但是 useCallback 和 useMemo 依然要消耗性能.

通过事件机制, 可以更简单地实现性能优化.

Increase 和 Decrease 组件保持不变, 将状态 count 放到 Display 中, 通过事件传递 count 的变更.

// app.js
import React, { useState } from 'react';

import eventemitter from 'path/eventemitter';

import Increase from 'path/increase';
import Decrease from 'path/decrease';
import Display from 'path/display';

const App = () => {
  const onIncrease = () => eventemitter.emit(TYPE.INCREASE);
  const onDecrease = () => eventemitter.emit(TYPE.DECREASE);

  return (
    <div>
      <Display />
      <Increase onIncrease={onIncrease} />
      <Decrease onDecrease={onDecrease} />
    </div>
  );
};
// display.js
import React, { useState, useEffect } from 'react';

import eventemitter from 'path/eventemitter';

const Display = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const increaseListener = () => setCount((c) => c + 1);
    const decreaseListener = () => setCount((c) => c - 1);
    eventemitter.on(TYPE.INCREASE, increaseListener);
    eventemitter.on(TYPE.DECREASE, decreaseListener);
    return () => {
      eventemitter.off(TYPE.INCREASE, increaseListener);
      eventemitter.off(TYPE.DECREASE, decreaseListener);
    };
  }, []);

  return <div>{count}</div>;
};

export default Display;

这样, 无论 count 怎么发生变更, 只会 rerender Display 组件. 当然, 为了节省内存, 可以合并 INCREASE 和 DECREASE, 通过 eventemitter 的 payload 表示增加还是减少.

// app.js
const onIncrease = () => eventemitter.emit(TYPE.COUNT_CHANGE, 1);
const onDecrease = () => eventemitter.emit(TYPE.COUNT_CHANGE, -1);

// display.js
useEffect(() => {
  const listener = (event) => setCount((c) => c + event.detail);
  eventemitter.on(TYPE.COUNT_CHANGE, listener);
  return () => eventemitter.off(TYPE.COUNT_CHANGE, listener);
}, []);

最后, 要记得移除事件监听器.

使用 Discussions 讨论 在 Github 上编辑 分享到 X