React Hooks踩坑分享

前言:React Hooks被越来越多的人认可,整个社区都以积极的态度去拥抱它。在最近的一段时间笔者也开始在一些项目中尝试去使用React Hooks。原本以为React Hooks很简单,和类组件差不多,看看API就能用起来了。结果在使用中遇到了各种各样的坑,通过阅读React Hooks相关的文章发现React Hooks和类组件有很多不同。由此,想和大家做一些分享。

如果要在项目中使用React Hooks,强烈推荐先安装eslint-plugin-react-hooks(由React官方发布)。在很多时候,这个eslint插件在我们使用React Hooks的过程中,会帮我们避免很多问题。

本文主要讲以下内容:

  1. 函数式组件和类组件的不同
  2. React Hooks依赖数组的工作方式
  3. 如何在React Hooks中获取数据

一、函数式组件和类组件的不同

React Hooks由于是函数式组件,在异步操作或者使用useCallBack、useEffect、useMemo等API时会形成闭包。

先看一下以下例子。在点击了展示现在的值按钮三秒后,会alert点击次数:

function Demo() {
  const [num, setNum] = useState(0);

  const handleClick = () => {
    setTimeout(() => {
      alert(num);
    }, 3000);
  };

  return (
    <div>
      <div>当前点击了{num}次</div>
      <button onClick={() => { setNum(num + 1) }}>点我</button>
      <button onClick={handleClick}>展示现在的值</button>
    </div>
  );
};

我们按照下面的步骤去操作:

  • 点击num到3
  • 点击展示现在的值按钮
  • 在定时器回调触发之前,点击增加num到5。

可以猜一下alert会弹出什么?

分割线


其最后弹出的数据是3。

为什么会出现这样的情况,最后的num不是应该是5吗?

上面例子中,num仅是一个数字而已。 它不是神奇的“data binding”, “watcher”, “proxy”,或者其他任何东西。它就是一个普通的数字像下面这个一样:

const num = 0;
// ...
setTimeout(() => {
  alert(num);
}, 3000);
// ...

我们组件第一次渲染的时候,从useState()拿到num的初始值为0,当我们调用setNum(1),React会再次渲染组件,这一次num是1。如此等等:

// 第一次渲染
function Demo() {
  const num = 0; // 从useState()获取
  // ...
  setTimeout(() => {
    alert(num);
  }, 3000);
  // ...
}

// 在点击了一次按钮之后
function Demo() {
  const num = 1; // 从useState()获取
  // ...
  setTimeout(() => {
    alert(num);
  }, 3000);
  // ...
}


// 又一次点击按钮之后
function Demo() {
  const num = 2; // 从useState()获取
  // ...
  setTimeout(() => {
    alert(num);
  }, 3000);
  // ...
}

在我们更新状态之后,React会重新渲染组件。每一次渲染都能拿到独立的num状态,这个状态值是函数中的一个常量。

所以在num为3时,我们点击了展示现在的值按钮,就相当于:

function Demo() {
  // ...
  setTimeout(() => {
    alert(3);
  }, 3000)
  // ...
}

即便num的值被点击到了5。但是触发点击事件时,捕获到的num值为3。


上面的功能,我们尝试用类组件实现一遍:

class Demo extends Component {
  state = {
    num: 0,
  }

  handleClick = () => {
    setTimeout(() => {
      alert(this.state.num);
    }, 3000);
  }

  render() {
    const { num } = this.state;
    return (
      <div>
        <p>当前点击了{num}次</p>
        <button onClick={() => { this.setState({ num: num + 1 }) }}>点击</button>
        <button onClick={this.handleClick}>展示现在的值</button>
      </div>
    );
  }
};

我们按照之前同样的步骤去操作:

  • 点击num到3
  • 点击展示现在的值按钮
  • 在定时器回调触发之前,点击增加num到5

这一次弹出的数据是5。

为什么同样的例子在类组件会有这样的表现呢?

我们可以仔细看一下handleClick方法:

handleClick = () => {
  setTimeout(() => {
    alert(this.state.num);
  }, 3000)
}

这个类方法从this.state.num中读取数据,在React中state是不可变的。然而,this是可变的。

通过类组件的this,我们可以获取到最新的state和props。

所以如果在用户再点击了展示现在的值按钮的情况下我们对点击按钮又点击了几次,this.state将会改变。handleClick方法从一个“过于新”的state中得到了num

这样就引起了一个问题,如果说我们UI在概念上是当前应用状态的一个函数,那么事件处理程序和视觉输出都应该是渲染结果的一部分。我们的事件处理程序应该有一个特定的props和state

然而在类组件中,我们通过this.state读取的数据并不能保证其是一个特定的state。handleClick事件处理程序并没有与任何一个特定的渲染绑定在一起。

从上面的例子,我们可以看出React Hooks在某一个特定渲染中state和props是与其相绑定的,然而类组件并不是。

二、React Hooks依赖数组的工作方式

在React Hooks提供的很多API都有遵循依赖数组的工作方式,比如useCallBack、useEffect、useMemo等等。

使用了这类API,其传入的函数、数据等等都会被缓存。被缓存的内容其依赖的props、state等值就像上面的例子一样都是“不变”的。只有当依赖数组中的依赖发生变化,它才会被重新创建,得到最新的props、state。所以在用这类API时我们要特别注意,在依赖数组内一定要填入依赖的props、state等值。

这里给大家举一个反例:

function Demo() {
  const [num, setNum] = useState(0);

  const handleClick = useCallback(() => {
    setNum(num + 1);
  }, []);

  return (
    <div>
      <p>当前点击了{num}次</p>
      <button onClick={handleClick}>点击</button>
    </div>
  );
}

useCallback本质上是添加了一层依赖检查。当我们函数本身只在需要的时候才改变。

在上面的例子中,我们无论点击多少次点击按钮,num的值始终为1。这是因为useCallback中的函数被缓存了,其依赖数组为空数组,传入其中的函数会被一直缓存。

handleClick其实一直都是:

const handleClick = () => {
    setNum(0 + 1);
};

即便函数再次更新,num的值变为1,但是React并不知道你的函数中依赖了num,需要去更新函数。

唯有在依赖数组中传入了num,React才会知道你依赖了num,在num的值改变时,需要更新函数。

function Demo() {
  const [num, setNum] = useState(0);

  const handleClick = useCallback(() => {
    setNum(num + 1);
  }, [num]); // 添加依赖num

  return (
    <div>
      <p>当前点击了{num}次</p>
      <button onClick={handleClick}>点击</button>
    </div>
  );
};

点击点击按钮,num的值不断增加。

(其实这些归根究底,就是React Hooks会形成闭包)

三、如何在React Hooks中获取数据

在我们用习惯了类组件模式,我们在用React Hooks中获取数据时,一般刚开始大家都会这么写吧:

function Demo(props) {
  const { query } = props;
  const [list, setList] = useState([]);

  const fetchData = async () => {
    const res = await axios(`/getList?query=${query}`);
    setList(res);
  };

  useEffect(() => {
    fetchData(); // 这样不安全(调用的fetchData函数使用了query)
  }, []);

  return (
    <ul>
      {list.map(({ text }) => {
        return (
          <li key={text}>{ text }</li>
        );
      })}
    </ul>
  );
};

其实这样是不推荐的一种模式,要记住effect外部的函数使用了哪些props和state很难。这也是为什么 通常你会想要在effect内部去声明它所需要的函数。 这样就能容易的看出那个effect依赖了组件作用域中的哪些值:

function Demo(props) {
  const { query } = props;
  const [list, setList] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      const res = await axios(`/getList?query=${query}`);
      setList(res);
    };

    fetchData();
  }, [query]);

  return (
    <ul>
      {list.map(({ text }) => {
        return (
          <li key={text}>{ text }</li>
        );
      })}
    </ul>
  );
};

但是如果你在不止一个地方用到了这个函数或者别的原因,你无法把一个函数移动到effect内部,还有一些其他办法:

  • 如果这函数不依赖state、props内部的变量。可以把这个函数移动到你的组件之外。这样就不用其出现在依赖列表中了。
  • 如果其不依赖state、props。但是依赖内部变量,可以将其在effect之外调用它,并让effect依赖于它的返回值。
  • 万不得已的情况下,你可以把函数加入effect的依赖项,但把它的定义包裹进useCallBack。这就确保了它不随渲染而改变,除非它自身的依赖发生了改变。

另外一方面,业务一旦变的复杂,在React Hooks中用类组件那种方式获取数据也会有别的问题。

我们做这样一个假设,一个请求入参依赖于两个状态分别是query和id。然而id的值需要异步获取(只要获取一次,就可以在这个组件卸载之前一直用),query的值从props传入:

function Demo(props) {
  const { query } = props;
  const [id, setId] = useState();
  const [list, setList] = useState([]);

  const fetchData = async (newId) => {
    const myId = newId || id;
    if (!myId) {
      return;
    }
    const res = await axios(`/getList?id=${myId}&query=${query}`);
    setList(res);
  };

  const fetchId = async () => {
    const res = await axios('/getId');
    return res;
  };

  useEffect(() => {
    fetchId().then(id  => {
      setId(id);
      fetchData(id);
    });
  }, []);

  useEffect(() => {
    fetchData();
  }, [query]);

  return (
    <ul>
      {list.map(({ text }) => {
        return (
          <li key={text}>{ text }</li>
        );
      })}
    </ul>
  );
};

在这里,当我们的依赖的query在异步获取id期间变了,最后请求的入参,其query将会用之前的值。(引起这个问题的原因还是闭包,这里就不再复述了)

对于从后端获取数据,我们应该用React Hooks的方式去获取。这是一种关注数据流和同步思维的方式。

对于刚才这个例子,我们可以这样解决:

function Demo(props) {
  const { query } = props;
  const [id, setId] = useState();
  const [list, setList] = useState([]);

  useEffect(() => {
    const fetchId = async () => {
      const res = await axios('/getId');
      setId(res);
    };

    fetchId();
  }, []);

  useEffect(() => {
    const fetchData = async () => {
      const res = await axios(`/getList?id=${id}&query=${query}`);
      setList(res);
    };
    if (id) {
      fetchData();
    }
  }, [id, query]);

  return (
    <ul>
      {list.map(({ text }) => {
        return (
          <li key={text}>{ text }</li>
        );
      })}
    </ul>
  );
}

一方面这种方式可以让我们的代码更加清晰,一眼就能看明白获取这个接口的数据依赖了哪些state、props,让我们更多的去关注数据流的改变。另外一方面也避免了闭包可能会引起的问题。

但是同步思维的方式也会有一些坑,比如这样的场景,有一个列表,这个列表可以通过子元素的按钮增加数据:

function Children(props) {
  const { fetchData } = props;

  return (
    <div>
      <button onClick={() => { fetchData(); }}>点击</button>
    </div>
  );
};

function Demo() {
  const [list, setList] = useState([]);

  const fetchData = useCallback(async () => {
    const res = await axios(`/getList`);
    setList([...list, ...res]);
  }, [list]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return (
    <div>
      <ul>
        {list.map(({ text }) => {
          return (
            <li key={text}>{ text }</li>
          );
        })}
      </ul>
      <Children fetchData={fetchData} />
    </div>
  );
};

这种场景下,会一直加载数据,造成死循环。

每次调用fetchData函数会更新listlist更新后fetchData函数就会被更新。fetchData更新后useEffect会被调用,useEffect中又调用了fetchData函数。fetchData被调用导致list更新…

当出现这种 根据前一个状态更新状态 的时候,我们可以用useReducer去替换useState:

function Children(props) {
  const { fetchData } = props;

  return (
    <div>
      <button onClick={() => { fetchData(); }}>点击</button>
    </div>
  );
};

const initialList = [];

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return [...state, ...action.payload];
    default:
      throw new Error();
  }
}

export default function Demo() {
  const [list, dispatch] = useReducer(reducer, initialList);

  const fetchData = useCallback(async () => {
    const res = await axios(`/getList`);
    dispatch({
      type: 'increment',
      payload: res
    });
  }, []);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return (
    <div>
      <ul>
        {list.map(({ text }) => {
          return (
            <li key={text}>{ text }</li>
          );
        })}
      </ul>
      <Children fetchData={fetchData} />
    </div>
  );
};

React会保证dispatch在组件的声明周期内保持不变。所以上面的例子中不需要依赖dispatch

用了useReducer我们就可以移除list依赖。不会再出现死循环的情况。

通过dispatch了一个action来描述发生了什么。这使得我们的fetchData函数和list状态解耦。我们的fetchData函数不再关心怎么更新状态,它只负责告诉我们发生了什么。更新的逻辑全都交由reducer去统一处理。

(我们使用函数式更新也能解决这个问题,但是更推荐使用useReducer)

在某些场景下useReducer会比useState更适用。例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的state等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数。

如果大家遇到其它的一些复杂场景,用上面介绍的方法无法解决。那就试试用useRef吧。

文章如有疏漏、错误欢迎批评指正。

作者介绍

本文转载自公众号有赞coder(ID:youzan_coder)。

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/yQEtdq5xUPPO1ZwghSVw
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券