React 在卸载之前做下面的事情要谨慎

657 阅读6分钟

我在开发过程中遇到一个非常奇怪的问题,事情是这样的。

我有一个表单,像下面这样:

image.png

qClosed 的时候会展示下面的样子,也就是会多出 as,需求是这样的,正常填写表单,只提交展现的字段到后台,所以隐藏的如果之前有数据,那么消失的时候就需要将值清空。长下面这样:

image.png

其中这个 Select 组件使用数据结构如下:

[
  { title: 'Not Identified', value: '1' },
  { title: 'Closed', value: '2' },
  { title: 'Communicated', value: '3' },
  { title: 'Identified', value: '4' },
  { title: 'Resolved', value: '5' },
  { title: 'Cancelled', value: '6' },
]

由于是 demo ,所以这几个都是使用的这个数据结构。当将 q 切换成 Closed 的时候,正常显示 as ,但是当我将 q 切换成其他的时候就出现切换不过去。看看下面的动画:

20220905025553.gif

根据上面的需求,我的实现是让组件自身去清除自己(从出现有值到消失没值),而且我想让组件统一的使用 onValueChange 函数,所以我在组件卸载之前将值清空。先见组件 Select

import { useCallback, useEffect } from 'react';
import { Select as AntdSelect } from 'antd';
const { Option } = AntdSelect;

const Select = ({ onValueChange, command, value, label, data }) => {
  useEffect(
    () => () => {
      // 卸载之前清空之前的值
      onValueChange?.(command, null);
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );
  const handleChange = useCallback(
    (e) => {
      onValueChange?.(command, e);
    },
    [onValueChange, command]
  );
  return (
    <div style={{ flexDirection: 'row', display: 'flex' }}>
      <h2 style={{ marginRight: 20 }}>{label}</h2>
      <AntdSelect
        showSearch
        style={{ width: 200 }}
        placeholder="Search to Select"
        optionFilterProp="children"
        onChange={handleChange}
        value={value}
      >
        {data.map((item) => {
          return <Option key={item.value} value={item.value}>{item.title}</Option>;
        })}
      </AntdSelect>
    </div>
  );
};

export default Select;

这样我甚至都不需要在外面处理当某个属性消失另外做处理的逻辑,非常好,其实这里我采用的是分治的思想;就相当于领导新建了一个表格,让手下的人各自把工作进度填写进去一样,一种做法就是领导全程控制,比如领导明天开会,让大家说工作进度,然后领导填写到表格中;而我的这种思想就是让手下的人自己填写工作进度到表格,这样领导就不用关心这些,要想了解进度不需要开会,只需要看进度的表格就了解目前处于什么状况。

首先我得数据是使用 Context 统一管理的,可以看 Context 代码:

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

export const COUNT_ADD = Symbol();
export const RANDOM = Symbol();
export const CHNAGE_PARAMS_A = Symbol();
export const CHNAGE_PARAMS_B = Symbol();
export const CHNAGE_PARAMS_C = Symbol();

const initial = {
  count: 0,
  randoms: 0,
  params: {
    a: {},
    b: {},
    c: {},
  },
};

let currentState = null;
const reducer = (state = initial, action) => {
  switch (action.type) {
    case COUNT_ADD:
      state.count++;
      break;
    case RANDOM:
      state.randoms = Math.random() * 1000;
      break;
    case CHNAGE_PARAMS_A:
      state.params = {
        ...state.params,
        a: action.payload,
      };
      break;
    case CHNAGE_PARAMS_B:
      state.params = {
        ...state.params,
        b: action.payload,
      };
      break;
    case CHNAGE_PARAMS_C:
      state.params = {
        ...state.params,
        c: action.payload,
      };
      break;
    default:
  }
  const nextState = { ...state };
  currentState = nextState
  return nextState;
};

export function getState() {
  return currentState;
}

const Context = React.createContext({});

export const Provider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initial);
  return (
    <Context.Provider value={{ state, dispatch }}>{children}</Context.Provider>
  );
};

export const useSelector = (extractor) => {
  const { state } = useContext(Context);
  if (typeof extractor === 'function') {
    return extractor(state);
  } else {
    return state;
  }
};

export const useDispatch = () => {
  const { dispatch } = useContext(Context);
  return dispatch;
};

export const useParams = (key) => {
  const params = useSelector((state) => state.params[key]);
  const dispatch = useDispatch();
  const type = useMemo(() => {
    switch (key) {
      case 'a':
        return CHNAGE_PARAMS_A;
      case 'b':
        return CHNAGE_PARAMS_B;
      case 'c':
        return CHNAGE_PARAMS_C;
      default:
    }
  }, [key]);

  const setParams = useCallback(
    (handle) => {
      dispatch({
        type,
        payload: handle(params),
      });
    },
    [dispatch, params, type]
  );
  return [
    params, setParams];
};

由于好多组件都需要处理 params 的情况,于是我就新建了 useParams 来进行统一处理,然后外面的使用就跟 useState 一样,只不过我只写了传入回调的情况。其实根本的原因是我改代码的时候由于之前已经按 useState 来写了,如果不这样很多地方都得修改,我比较偷懒,所以想到使用这种方式来进行,这样只需要将原来 useState 的地方替换成 useParams 即可,非常的简单方便,改动非常的小。

现在我们来看看使用的父组件:

import { useCallback } from 'react';
import { useParams } from './context';
import Select from './Select';

const data = [
  { title: 'Not Identified', value: '1' },
  { title: 'Closed', value: '2' },
  { title: 'Communicated', value: '3' },
  { title: 'Identified', value: '4' },
  { title: 'Resolved', value: '5' },
  { title: 'Cancelled', value: '6' },
];
const Home = () => {
  const [params, setParams] = useParams('a');
  const onValueChange = useCallback(
    (command, value) => {
      setParams((oldParams) => {
        const temp = { ...oldParams };
        temp[command] = value;
        return temp;
      });
    },
    [setParams]
  );
  return (
    <div
      style={{
        width: '100%',
        height: '100vh',
        flexDirection: 'column',
        display: 'flex',
        alignItems: 'center',
      }}
    >
      <Select
        onValueChange={onValueChange}
        command={'q'}
        label={'q'}
        value={params['q']}
        data={data}
      />
      <Select
        onValueChange={onValueChange}
        command={'w'}
        label={'w'}
        value={params['w']}
        data={data}
      />
      <Select
        onValueChange={onValueChange}
        command={'e'}
        label={'e'}
        value={params['e']}
        data={data}
      />
      {params['q'] === '2' && (
        <>
          <Select
            onValueChange={onValueChange}
            command={'a'}
            label={'a'}
            value={params['a']}
            data={data}
          />
          <Select
            onValueChange={onValueChange}
            command={'s'}
            label={'s'}
            value={params['s']}
            data={data}
          />
        </>
      )}
    </div>
  );
};

export default Home;

出现上面的问题,我打印日志,也就是打印每次渲染之前的 oldParams 的值,我发现卸载组件调用这个函数的时候 oldParams 的值是之前的值,所以我判定是由于子组件没有拿到最新的 onValueChange 函数,所以我尝试修改 Select 组件的卸载方法:

useEffect(
    () => () => {
      onValueChange?.(command, null);
    },
    [command, onValueChange]
);

发现修改成这样根据就是不行的,因为当 commandonValueChange 改动都会调用 onValueChange?.(command, null) 是不合理的,我暂时没想到好的方法,于是将 Select 改成 Component ,也就是使用类的方式来实现,结果还是不对;但是我还是坚定是因为方法本身没更新到最新导致的,为了验证我的想法,我想不更新 onValueChange 可能是因为没有更新 setParams ,于是我尝试保存起来看看:

image.png

我发现这里当不涉及到组件消失的时候一直都是返回 true ,但是当涉及到组件销毁的时候就会是 false ,于是我就重点看什么 Select 的代码,我并没有看出什么,因为我的写法本身很简单,似乎并没有问题,但是当我仔细分析代码的时候,也就是当值发生变化的时候,也就是从显示到消失,由于 params 的值改变了,组件渲染的逻辑是先卸载子组件,再渲染父组件,也就是消失的组件在调用卸载方法前不会先渲染的,因为父组件决定了子组件直接消失,消失的逻辑并不在子组件,由于 params 改变 => setParams 改变 => onValueChange 改变;由于本次渲染不会再渲染消失的组件,故而拿不到最新的 onValueChange 的值,那么执行卸载函数拿到的 onValueChange 就是之前的,故而取的 params 也是之前的。

现在知道了原因,也就是在组件卸载之前其他 props 会保持上一次的(仅 Hooks ),导致取到的数据也是之前的;那么我们应该怎样解决这个问题呢,其实很简单,只需要保证每一次卸载之前拿到的 onValueChange 函数都是最新的,由于渲染逻辑,所以不可能通过现有方式解决,要考虑的是让 onValueChange 不变,也就是一定固定一直都不会改变,这样就能避免上面的问题,可是以我现在的实现怎样保证呢,我们知道 setParams 改变的原因是因为 params ,也就是我在里面使用了这个,导致它变了,也就跟着,主要的是 setParamsparams 还是强关联的关系。所以我们需要改变的是让 setParams 不依赖 params ,其实我们这个地方使用的目的就在于拿到最新的 params 数据,所以我们不通过里面的 params 也是可以拿到的,如果你是使用 redux ,那么可以使用使用 getStore 方法来拿到最新的 params 数据,我这里由于没有这个,所以我手动写一个就可以解决:

let currentState = null;
export function getState() {
  return currentState;
}

其中 currentState 每一次更新都将最新的值保存在起来:

const reducer = (state = initial, action) => {
  // ...其他逻辑
  const nextState = { ...state };
  currentState = nextState
  return nextState;
};

这样就完美解决了这个问题,当然除了这种方法解决,还有其他的,如果你看到了,你也可以使用其他方式来解决,只不过我更倾向于这种,其他的我觉得只是解决问题了,而我这种实现,能在一定程度上避免错误,因为一旦我选择这样, useParams 的作用就完全跟 useState ,所以外面使用的时候考虑 useState 的事情就可以,而不需要考虑 useParams 本身的实现。