五、继续深入学习useEffect

参考文章:

黄子毅的精读useEffect指南

useEffect 完整指南

一、问题

​ 三大基础hook中,最难理解,也最难用好、在使用过程中出问题最多的是useEffect这个hook。我们在使用useEffect的时候,常常会将useEffect去和class组件中的生命周期函数去做对比。一开始,这种想法会让我们学习useEffect入门很快,好像useEffect就是componentDidMount和componentDidUpdate,componentWillMount三个class生命周期函数的结合体。但一旦抱着这种想法去使用useEffect,那么你可能就会发现很多问题。

想要真正学明白useEffect就必须先得深入理解 Function Component 的渲染机制,而且Function ComponentClass Component 不仅仅是功能上的不同,在思维上也有着不同。

Function Component 是更彻底的状态驱动抽象,甚至没有 Class Component 生命周期的概念,只有一个状态,而 React 负责同步到 DOM。

  • 🤔 如何用useEffect模拟componentDidMount生命周期?

    ​ 虽然可以使用useEffect(fn, []),但它们并不完全相等。和componentDidMount不一样,useEffect捕获 props和state。所以即便在回调函数里,你拿到的还是初始的props和state。如果你想得到“最新”的值,你可以使用ref。不过,通常会有更简单的实现方式,所以你并不一定要用ref。记住,effects的心智模型和componentDidMount以及其他生命周期是不同的,试图找到它们之间完全一致的表达反而更容易使你混淆。想要更有效,你需要“think in effects”,它的心智模型更接近于实现状态同步,而不是响应生命周期事件。

  • 🤔 如何正确地在useEffect里请求数据?[]又是什么?

    []表示effect没有使用任何React数据流里的值,因此该effect仅被调用一次是安全的。[]同样也是一类常见问题的来源,你以为没使用数据流里的值,但其实使用了。

  • 🤔 我应该把函数当做effect的依赖吗?

    ​ 一般建议把不依赖props和state的函数提到你的组件外面,并且把那些仅被effect使用的函数放到effect里面。如果这样做了以后,你的effect还是需要用到组件内的函数(包括通过props传进来的函数),可以在定义它们的地方用useCallback包一层。为什么要这样做呢?因为这些函数可以访问到props和state,因此它们会参与到数据流中。

  • 🤔 为什么有时候会出现无限重复请求的问题?

    ​ 这个通常发生于你在effect里做数据请求并且没有设置effect依赖参数的情况。没有设置依赖,effect会在每次渲染后执行一次,然后在effect中更新了状态引起渲染并再次触发effect。无限循环的发生也可能是因为你设置的依赖总是会改变。你可以通过一个一个移除的方式排查出哪个依赖导致了问题。但是,移除你使用的依赖(或者盲目地使用[])通常是一种错误的解决方式。你应该做的是解决问题的根源。举个例子,函数可能会导致这个问题,你可以把它们放到effect里,或者提到组件外面,或者用useCallback包一层。useMemo 可以做类似的事情以避免重复生成对象。

  • 🤔 为什么有时候在effect里拿到的是旧的state或prop?

    ​ Effect拿到的总是定义它的那次渲染中的props和state。这能够避免一些bugs,但在一些场景中又会有些讨人嫌。对于这些场景,你可以明确地使用可变的ref保存一些值(上面文章的末尾解释了这一点)。如果你觉得在渲染中拿到了一些旧的props和state,且不是你想要的,你很可能遗漏了一些依赖。可以尝试使用这个lint 规则来训练你发现这些依赖。可能没过几天,这种能力会变得像是你的第二天性

二、每次 Render 都有自己的 Props 与 State

可以认为每次 Render 的内容都会形成一个快照并保留下来,因此当状态变更而 Rerender 时,就形成了 N 个 Render 状态,而每个 Render 状态都拥有自己固定不变的 Props 与 State。

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

在每次点击时,count 只是一个不会变的常量,而且也不存在利用 Proxy 的双向绑定,只是一个常量存在于每次 Render 中。

初始状态下 count 值为 0,而随着按钮被点击,在每次 Render 过程中,count 的值都会被固化为 123

// During first render
function Counter() {
  const count = 0; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

// After a click, our function is called again
function Counter() {
  const count = 1; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

// After another click, our function is called again
function Counter() {
  const count = 2; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

其实不仅是对象,函数在每次渲染时也是独立的。这就是 Capture Value 特性。

每次 Render 都有自己的事件处理

const App = () => {
  const [temp, setTemp] = React.useState(5);

  const log = () => {
    setTimeout(() => {
      console.log("3 秒前 temp = 5,现在 temp =", temp);
    }, 3000);
  };

  return (
    <div
      onClick={() => {
        log();
        setTemp(3);
        // 3 秒前 temp = 5,现在 temp = 5
      }}
    >
      xyz
    </div>
  );
};

log 函数执行的那个 Render 过程里,temp 的值可以看作常量 5执行 **setTemp(3)** 时会交由一个全新的 Render 渲染,所以不会执行 log 函数。而 3 秒后执行的内容是由 **temp** **5** 的那个 Render 发出的,所以结果自然为 5

原因就是 templog 都拥有 Capture Value 特性。

扩展:如果我就希望获取当前最新的值,应该怎么办?

利用 useRef 就可以绕过 Capture Value 的特性。可以认为 **ref** 在所有 Render 过程中保持着唯一引用,因此所有对 **ref** 的赋值或取值,拿到的都只有一个最终状态,而不会在每个 Render 间存在隔离。

const App = () => {
  const tempRef = useRef(null);
  const [temp, setTemp] = React.useState(5);

  const log = () => {
    setTimeout(() => {
      console.log("3 秒前 temp = 5,现在 temp =", tempRef);
    }, 3000);
  };

  return (
    <div
      onClick={() => {
        log();
    		tempRef=3;
        setTemp(3);
      }}
    >
      xyz
    </div>
  );
}; 

每次 Render 都有自己的 Effects

useEffect 也一样具有 Capture Value 的特性。

useEffect 在实际 DOM 渲染完毕后执行,那 useEffect 拿到的值也遵循 Capture Value 的特性:

回收机制

在组件被销毁时,通过 useEffect 注册的监听需要被销毁,这一点可以通过 useEffect 的返回值做到:

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
  };
});

在组件被销毁时,会执行返回值函数内回调函数。同样,由于 Capture Value 特性,每次 “注册” “回收” 拿到的都是成对的固定值。

用同步取代 “生命周期”

Function Component 不存在生命周期,所以不要把 Class Component 的生命周期概念搬过来试图对号入座。Function Component 仅描述 UI 状态,React 会将其同步到 DOM,仅此而已。

既然是状态同步,那么每次渲染的状态都会固化下来,这包括 state props useEffect 以及写在 Function Component 中的所有函数。

然而舍弃了生命周期的同步会带来一些性能问题,所以我们需要告诉 React 如何比对 Effect。

告诉 React 如何对比 Effects

虽然 React 在 DOM 渲染时会 diff 内容,只对改变部分进行修改,而不是整体替换,但却做不到对 Effect 的增量修改识别。因此需要开发者通过 useEffect 的第二个参数告诉 React 用到了哪些外部变量:

useEffect(() => {
  document.title = "Hello, " + name;
}, [name]); // Our deps

直到 name 改变时的 Rerender,useEffect 才会再次执行。

然而手动维护比较麻烦而且可能遗漏,因此可以利用 eslint 插件自动提示 + FIX

不要对 Dependencies 撒谎

如果你明明使用了某个变量,却没有申明在依赖中,你等于向 React 撒了谎,后果就是,当依赖的变量改变时,useEffect也不会再次执行:

useEffect(() => {
  document.title = "Hello, " + name;
}, []); // Wrong: name is missing in dep

还有一个例子

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

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

setInterval 我们只想执行一次,所以我们自以为聪明的向 React 撒了谎,将依赖写成 []

“组件初始化执行一次 setInterval,销毁时执行一次 clearInterval,这样的代码符合预期。” 你心里可能这么想。

但是你错了,由于 useEffect 符合 Capture Value 的特性,拿到的 count 值永远是初始化的 0相当于 **setInterval** 永远在 **count** **0** 的 Scope 中执行,你后续的 **setCount** 操作并不会产生任何作用。

诚实的代价

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);

代码可以正常运行了,拿到了最新的 count

但是:

  1. 计时器不准了,因为每次 count 变化时都会销毁并重新计时。
  2. 频繁 生成/销毁 定时器带来了一定性能负担。

怎么既诚实又高效呢?

上述例子使用了 count,然而这样的代码很别扭,因为你在一个只想执行一次的 Effect 里依赖了外部变量。

既然要诚实,那只好 想办法不依赖外部变量

setCount 还有一种函数回调模式,你不需要关心当前值是什么,只要对 “旧的值” 进行修改即可。这样虽然代码永远运行在第一次 Render 中,但总是可以访问到最新的 state

useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

将更新与动作解耦

但这样做并没有彻底所有场景的问题。其实我们可以利用 useEffect 的兄弟 useReducer 函数,将更新与动作解耦就可以了:

const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: "tick" }); // Instead of setCount(c => c + step);
  }, 1000);
  return () => clearInterval(id);
}, [dispatch]);

这就是一个局部 “Redux”,由于更新变成了 dispatch({ type: "tick" }) 所以不管更新时需要依赖多少变量,在调用更新的动作里都不需要依赖任何变量

将 Function 挪到 Effect 里

在 “告诉 React 如何对比 Diff” 一章介绍了依赖的重要性,以及对 React 要诚实。那么如果函数定义不在 useEffect 函数体内,不仅可能会遗漏依赖,而且 eslint 插件也无法帮助你自动收集依赖。

你的直觉会告诉你这样做会带来更多麻烦,比如如何复用函数?是的,只要不依赖 Function Component 内变量的函数都可以安全的抽出去。但是如果依赖了变量的函数怎么办?

如果非要把 Function 写在 Effect 外面呢?

如果非要这么做,就用 useCallback 吧!

function Parent() {
  const [query, setQuery] = useState("react");

  // ✅ Preserves identity until query changes
  const fetchData = useCallback(() => {
    const url = "https://hn.algolia.com/api/v1/search?query=" + query;
    // ... Fetch data and return it ...
  }, [query]); // ✅ Callback deps are OK

  return <Child fetchData={fetchData} />;
}

function Child({ fetchData }) {
  let [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(setData);
  }, [fetchData]); // ✅ Effect deps are OK

  // ...
}

由于函数也具有 Capture Value 特性,经过 useCallback 包装过的函数可以当作普通变量作为 useEffect 的依赖。useCallback 做的事情,就是在其依赖变化时,返回一个新的函数引用,触发 useEffect 的依赖变化,并激活其重新执行。

useCallback 带来的好处

在 Class Component 的代码里,如果希望参数变化就重新取数,你不能直接比对取数函数是否发生了变化

componentDidUpdate(prevProps) {
  //   This condition will never be true
  if (this.props.fetchData !== prevProps.fetchData) {
    this.props.fetchData();
  }
}

反之,要比对的是取数参数是否变化:

componentDidUpdate(prevProps) {
  if (this.props.query !== prevProps.query) {
    this.props.fetchData();
  }
}

但这种代码不内聚,一旦取数参数发生变化,就会引发多处代码的维护危机。

反观 Function Component 中利用 useCallback 封装的取数函数,可以直接作为依赖传入 useEffect

**useEffect** 只要关心取数函数是否变化,而取数参数的变化在 **useCallback** 时关心,再配合 eslint 插件的扫描,能做到 依赖不丢、逻辑内聚,从而容易维护。

更内聚

除了函数依赖逻辑内聚之外,我们再看看取数的全过程:

一个 Class Component 的普通取数要考虑这些点:

  1. didMount 初始化发请求。
  2. didUpdate 判断取数参数是否变化,变化就调用取数函数重新取数。
  3. unmount 生命周期添加 flag,在 didMount``didUpdate 两处做兼容,当组件销毁时取消取数。

你会觉得代码跳来跳去的,不仅同时关心取数函数与取数参数,还要在不同生命周期里维护多套逻辑。那么换成 Function Component 的思维是怎样的呢?

function Article({ id }) {
  const [article, setArticle] = useState(null);

  // 副作用,只关心依赖了取数函数
  useEffect(() => {
    // didCancel 赋值与变化的位置更内聚
    let didCancel = false;

    async function fetchData() {
      const article = await API.fetchArticle(id);
      if (!didCancel) {
        setArticle(article);
      }
    }

    fetchData();

    return () => {
      didCancel = true;
    };
  }, [fetchArticle]);

  // ...
}

当你真的理解了 Function Component 理念后,就可以理解 这句话:虽然 useEffect 前期学习成本更高,但一旦你正确使用了它,就能比 Class Component 更好的处理边缘情况。

useEffect 只是底层 API,未来业务接触到的是更多封装后的上层 API,比如 useFetch 或者 useTheme,它们会更好用。

useEffect 还有什么优势

useEffect 在渲染结束时执行,所以不会阻塞浏览器渲染进程,所以使用 Function Component 写的项目一般都有用更好的性能。

自然符合 React Fiber 的理念,因为 Fiber 会根据情况暂停或插队执行不同组件的 Render,如果代码遵循了 Capture Value 的特性,在 Fiber 环境下会保证值的安全访问,同时弱化生命周期也能解决中断执行时带来的问题。

useEffect 不会在服务端渲染时执行。

由于在 DOM 执行完毕后才执行,所以能保证拿到状态生效后的 DOM 属性。

总结

最后,提两个最重要的点,来检验你有没有读懂这篇文章:

  1. Capture Value 特性。
  2. 一致性。将注意放在依赖上(useEffect的第二个参数[]),而不是关注何时触发。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

代码搬运工_田先森

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值