精读《useEffect 彻底指南》

1. 引言

工具型文章要跳读,而文学经典就要反复研读。若是说 React 0.14 版本带来的各类生命周期能够类比到工具型文章,那么 16.7 带来的 Hooks 就要像文学经典同样反复研读。javascript

Hooks API 不管从简洁程度,仍是使用深度角度来看,都大大优于以前生命周期的 API,因此必须反复理解,反复实践,不然只能停留在表面原地踏步。html

相比 useState 或者自定义 Hooks 而言,最有理解难度的是 useEffect 这个工具,但愿借着 a-complete-guide-to-useeffect 一文,深刻理解 useEffect前端

原文很是长,因此概述是笔者精简后的。做者是 Dan Abramov,React 核心开发者。java

2. 概述

unLearning,也就是学会忘记。你以前的学习经验会阻碍你进一步学习。react

想要理解好 useEffect 就必须先深刻理解 Function Component 的渲染机制,Function Component 与 Class Component 功能上的不一样在上一期精读 精读《Function VS Class 组件》 已经介绍,而他们还存在思惟上的不一样:git

Function Component 是更完全的状态驱动抽象,甚至没有 Class Component 生命周期的概念,只有一个状态,而 React 负责同步到 DOM。 这是理解 Function Component 以及 useEffect 的关键,后面还会详细介绍。github

因为原文很是很是的长,因此笔者精简下内容再从新整理一遍。原文很是长的另外一个缘由是采用了启发式思考与逐层递进的方式写做,笔者最大程度保留这个思惟框架。api

从几个疑问开始

假设读者有比较丰富的前端 & React 开发经验,而且写过一些 Hooks。那么你也许以为 Function Component 很好用,但美中不足的是,总有一些疑惑萦绕在心中,好比:浏览器

  • 🤔 如何用 useEffect 代替 componentDidMount?
  • 🤔 如何用 useEffect 取数?参数 [] 表明什么?
  • 🤔useEffect 的依赖能够是函数吗?是哪些函数?
  • 🤔 为什么有时候取数会触发死循环?
  • 🤔 为何有时候在 useEffect 中拿到的 state 或 props 是旧的?

第一个问题可能已经自问自答过无数次了,但下次写代码的时候仍是会忘。笔者也同样,并且在三期不一样的精读中都分别介绍过这个问题:安全

但次日就忘记了,由于 用 Hooks 实现生命周期确实别扭。 讲真,若是想完全解决这个问题,就请你忘掉 React、忘掉生命周期,从新理解一下 Function Component 的思惟方式吧!

上面 5 个问题的解答就不赘述了,读者若是有疑惑能够去 原文 TLDR 查看。

要说清楚 useEffect,最好先从 Render 概念开始理解。

每次 Render 都有本身的 Props 与 State

能够认为每次 Render 的内容都会造成一个快照并保留下来,所以当状态变动而 Rerender 时,就造成了 N 个 Render 状态,而每一个 Render 状态都拥有本身固定不变的 Props 与 State。

看下面的 count

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 特性,后面遇到这种状况就不会一一展开,只描述为 “此处拥有 Capture Value 特性”。

每次 Render 都有本身的事件处理

解释了为何下面的代码会输出 5 而不是 3:

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 秒后执行的内容是由 temp5 的那个 Render 发出的,因此结果天然为 5

缘由就是 templog 都拥有 Capture Value 特性。

每次 Render 都有本身的 Effects

useEffect 也同样具备 Capture Value 的特性。

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

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

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

上面的 useEffect 在每次 Render 过程当中,拿到的 count 都是固化下来的常量。

如何绕过 Capture Value

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

function Example() {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);

  useEffect(() => {
    // Set the mutable latest value
    latestCount.current = count;
    setTimeout(() => {
      // Read the mutable latest value
      console.log(`You clicked ${latestCount.current} times`);
    }, 3000);
  });
  // ...
}

也能够简洁的认为,ref 是 Mutable 的,而 state 是 Immutable 的。

回收机制

在组件被销毁时,经过 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 永远在 count0 的 Scope 中执行,你后续的 setCount 操做并不会产生任何做用。

诚实的代价

笔者稍稍修改了一下标题,由于诚实是要付出代价的:

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

你老实告诉 React “嘿,等 count 变化后再执行吧”,那么你会获得一个好消息和两个坏消息。

好消息是,代码能够正常运行了,拿到了最新的 count

坏消息有:

  1. 计时器不许了,由于每次 count 变化时都会销毁并从新计时。
  2. 频繁 生成/销毁 定时器带来了必定性能负担。

怎么既诚实又高效呢?

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

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

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

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

将更新与动做解耦

你可能发现了,上面投机取巧的方式并无完全解决全部场景的问题,好比同时依赖了两个 state 的状况:

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

你会发现不得不依赖 step 这个变量,咱们又回到了 “诚实的代价” 那一章。固然 Dan 必定会给咱们解法的。

利用 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" }) 因此无论更新时须要依赖多少变量,在调用更新的动做里都不须要依赖任何变量。 具体更新操做在 reducer 函数里写就能够了。在线 Demo

Dan 也将 useReducer 比做 Hooks 的的金手指模式,由于这充分绕过了 Diff 机制,不过确实能解决痛点!

将 Function 挪到 Effect 里

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

你的直觉会告诉你这样作会带来更多麻烦,好比如何复用函数?是的,只要不依赖 Function Component 内变量的函数均可以安全的抽出去:

// ✅ Not affected by the data flow
function getFetchUrl(query) {
  return "https://hn.algolia.com/api/v1/search?query=" + query;
}

可是依赖了变量的函数怎么办?

若是非要把 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 的代码里,若是但愿参数变化就从新取数,你不能直接比对取数函数的 Diff:

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 封装的取数函数,能够直接做为依赖传入 useEffectuseEffect 只要关心取数函数是否变化,而取数参数的变化在 useCallback 时关心,再配合 eslint 插件的扫描,能作到 依赖不丢、逻辑内聚,从而容易维护。

更更更内聚

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

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

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

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

笔者利用 useCallback 对原 Demo 进行了改造。

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

  // 取数函数:只关心依赖的 id
  const fetchArticle = useCallback(async () => {
    const article = await API.fetchArticle(id);
    if (!didCancel) {
      setArticle(article);
    }
  }, [id]);

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

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

  // ...
}

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

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

3. 精读

原文有 9000+ 单词,很是长。但同时也配合一些 GIF 动图生动解释了 Render 执行原理,若是你想用好 Function Component 或者 Hooks,这篇文章几乎是必读的,由于没有人能猜到什么是 Capture Value,然而不能理解这个概念,Function Component 也不能用的顺手。

从新捋一下这篇文章的思路:

  1. 从介绍 Render 引出 Capture Value 的特性。
  2. 拓展到 Function Component 一切都可 Capture,除了 Ref。
  3. 从 Capture Value 角度介绍 useEffect 的 API。
  4. 介绍了 Function Component 只关注渲染状态的事实。
  5. 引起了如何提升 useEffect 性能的思考。
  6. 介绍了不要对 Dependencies 撒谎的基本原则。
  7. 从不得不撒谎的特例中介绍了如何用 Function Component 思惟解决这些问题。
  8. 当你学会用 Function Component 理念思考时,你逐渐发现它的一些优点。
  9. 最后点出了逻辑内聚,高阶封装这两大特色,让你同时领悟到 Hooks 的强大与优雅。

能够看到,比写框架更高的境界是发现代码的美感,好比 Hooks 本是为加强 Function Component 能力而创造,但在抛出问题-解决问题的过程当中,能够不断看到规则限制,换一个角度打破它,最后体会到总体的逻辑之美。

从这篇文章中也能够读到如何加强学习能力。做者告诉咱们,学会忘记能够更好的理解。咱们不要拿生命周期的固化思惟往 Hooks 上套,由于那会阻碍咱们理解 Hooks 的理念。

另补充一些零碎的内容。

useEffect 还有什么优点

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

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

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

因为在 DOM 执行完毕后才执行,因此能保证拿到状态生效后的 DOM 属性。

4. 总结

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

  1. Capture Value 特性。
  2. 一致性。将注意放在依赖上(useEffect 的第二个参数 []),而不是关注什么时候触发。

你对 “一致性” 有哪些更深的解读呢?欢迎留言回复。

讨论地址是:精读《useEffect 彻底指南》 · Issue #138 · dt-fe/weekly

若是你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

special Sponsors

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证