NotJustCode

在 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 怎么变, 我们都知道 IncreaseDecreaserender 结果都不会变, 所以重新执行 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 能够保证 onIncreaseonDecrease 在组件存活周期内不变, 而通过 useMemo 可以缓存 IncreaseDecrease 的渲染结果, 但是 useCallbackuseMemo 依然要消耗性能.

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

IncreaseDecrease 组件保持不变, 将状态 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 组件. 当然, 为了节省内存, 可以合并 INCREASEDECREASE, 通过 eventemitterpayload 表示增加还是减少.

// 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);
}, []);

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