React 中的 useEffect 用途和一些坑

    Feb 5, 2024

    文章讨论了在 React 中使用 useEffect 的挑战和陷阱,强调其在幂等视图初始化和刷新方面的正确应用,并警告不要进行非幂等操作,以避免数据和 UI 之间的不一致。

    对于 useEffect 这个 hook,我认为 React 官方文档中所述备矣。但不幸的是,对于 useEffect,它是 React 中受到争议最大的 API 设计。人们普遍认为他是 React 中最难以理解、最难以在项目中实际投入使用的 hook,源于 React 过于理想化设计与实际开发需求脱节之矛盾。

    本文在采取社区的主流观点后就此问题来谈谈我的个人理解:你应当避免使用 useEffect,它在大多数用途中可以以另一种更好的方案替代之。

    副作用:Effect

    useEffect,顾名思义:它描述的是组件渲染时的副作用。要理解这个 hooks,我们必须先理解什么是副作用。

    在函数式编程中,"副作用" 是指函数除了返回值之外对程序状态产生的任何影响。函数式编程强调纯函数的概念,而纯函数是指在相同的输入条件下总是产生相同的输出,并且没有副作用。

    举个例子,假设有一个函数,模拟银行查询流水记录,只需要输入账号、时间范围两个参数,就返回这段时间范围内指定账户的所有流水记录。如果只返回查询结果,什么也不操作。函数大约如下:

    GetBankStatement(accountId,timeRange){
      let data = ...... // fetch the data from database;
      return data;
    }
    

    这种函数叫纯函数。

    但是,银行的数据库是很敏感的数据信息,为了防止滥用查询职权,要求必须监视每一个查询请求,每查询一笔流水记录,就必须在日志中记录何时查询的、被查询账户等信息。

    GetBankStatement(accountId,timeRange){
      let data = ...... // fetch the data from database;
      doLog("xxxx 在 xxxx 年 xxxx 日 查询了 xxxx 的账户,被查询的日期范围为 xxxx");
      return data;
    }
    

    它除了返回查询结果,还暗自做了查询日志记录,记录每一笔查询操作。这种操作就是除了返回结果值以外,也对程序日志记录的状态造成了影响,这种操作就是副作用。

    在实际编程中,副作用可能包括但不限于:

    1. 改变可变数据状态: 函数修改外部变量、对象属性或数组元素,从而改变程序状态。

    2. I/O 操作: 包括读写文件、发送网络请求、在数据库中进行操作等。

    3. 打印输出或日志记录: 将信息输出到控制台或记录到日志文件。

    4. 抛出异常: 改变正常的程序流程,引发异常。

    5. 修改全局变量: 改变全局作用域中的变量值。

    函数式编程试图最小化或避免副作用,因为它们会增加程序的复杂性、降低可维护性,并且可能引发不可预测的行为。纯函数(无副作用的函数)更易于测试、组合和推理。

    通过遵循函数式编程的原则,可以更好地控制代码,提高可读性,减少错误,并使程序更容易并发执行。在函数式编程中,通常鼓励将副作用隔离在程序的特定区域,例如通过使用纯函数和单一职责原则。如果需要进行有副作用的操作,可以在程序的边缘处理,而不是在核心业务逻辑中。

    首先我们就从函数式编程来入手

    React 作为描述 UI 的基础库,早期拥抱了函数式编程的部分思想:它使用 JSX 将页面元组封装成一个具有属性的组件,给定该组件的属性输入,则组件将输入属性进行逻辑处理后渲染成 UI。实际上这类似于函数中属性到输出 UI 直接的映射关系。大概为

    F(props)=>UIF(props) => UI

    在数学语言中,函数(Function)是对映射关系的描述:对于给定的确定输入,总是有确定的、唯一的输出。这也是函数式编程中对函数的要求。

    这就是理想的 React 组件。

    但问题是:React 虽然借鉴了函数式思想,但大多数组件并不是理想组件,因为每个组件可能会有自己的状态 (State),而状态是会跟着组件内部逻辑发生改变的,按照上面的定义,这种改变也叫副作用,所以 React 并不是 按照严格的函数思想来描述输入属性与输出 UI 的关系的。也就是说,输入属性并不一定严格映射到唯一的 UI 输出。

    两大用途

    useEffect 在官方文档中展示了 10 类样例来展示用途。在我看来,其实它们的用途 本质 上可以分为两大类:初始化组件和刷新组件。

    初始化组件

    考虑我们开设一个在线书店的 APP,我们有一个商品书目列表,它标注了书名、作者、价格的基础信息。

    function BooksList() {
      const [listData,setListData] = useState([]);
      return (
        <ol>
          {listData.map((item,index) => (
            <li>
              <h2>{item.title}</h2>
              <p>{item.author}</p>
              <p>{item.price}</p>
            </li>)
          )}
        </ol>
      )
    }
    

    当 App 启动后,这个组件被挂载时,它的初始数据状态 listData 为空列表,它当然什么也不会渲染。

    那么,如果我希望 App 一启动后,就给用户推送最新上市的书目列表,那么我们就要在组件挂载后,从服务端拉取数据,给组件初始化一下,赋予拉取的数据作为一个初始的数据值放到listData中。

    所以,useEffect 提供了一种 只在组件首次挂载后执行 的副作用方法:把依赖数组置为空数组即可。把上面的代码再修改一下,大概写成如此:

    function BooksList() {
      const [listData,setListData] = useState([]);
    
      useEffect(()=>{
        fetch('https://my-api.com/get-latest-books')
          .then((res)=>{
            setListData([...res.json()])
          });
      },[])
      // 在这里,effect 只会在组件首次挂载时拉取服务器数据,
      // 设置到 state 中并渲染出来。
      // 这样组件就获得了一个初始值,也就完成了组件的初始化。
    
      return (
        <ol>
          {listData.map((item,index) => (
            <li>
              <h2>{item.title}</h2>
              <p>{item.author}</p>
              <p>{item.price}</p>
            </li>)
          )}
        </ol>
      )
    }
    

    这样,当 BooksList 被挂载后,会执行 fetch 操作获取初始数据,更新到 listData 中,并且把初始数据渲染出来,就完成了组件的初始化。

    那么问题来了,如果我希望在加载数据时给予用户以反馈,在加载时显示「数据正在加载中」这个提示,又该怎么做呢?

    我们再加上一个 state,为 isLoading ,来指示列表是否正在加载中。如果加载中,那么显示加载动画和文字提示,且加载完以后显示列表,并取消加载状态。

    那么我们的代码改写如下

    function BooksList() {
      const [listData,setListData] = useState([]);
      const [isLoading,setIsLoading] = useState(false);
    
      useEffect(()=>{
        setIsLoading(true)
        fetch('https://my-api.com/get-latest-books')
          .then((res)=>{
            setListData([...res.json()])
            setIsLoading(false)
          });
      },[])
    
      return (
        <>
        { isLoading ?
          <div>
            <LoadingAnimation/>
            <p>{"正在加载中"}</p>
          </div>
          :
          <ol>
            {listData.map((item,index) => (
              <li>
                <h2>{item.title}</h2>
                <p>{item.author}</p>
                <p>{item.price}</p>
              </li>)
            )}
          </ol>
        }
        </>
      )
    }
    

    在这里 fetch 和 setIsLoading 都是在组件挂载、渲染时的 effect。在这里,effect 提供了首次挂在组件时的状态、视图行为的指示,属于「加载的逻辑」。

    伴随刷新组件状态

    一个常用的场景就是分页查询。

    比如,我有一个书籍列表页,简化如下:

    
    export default function BooksListPage() {
    
      const [listData,setListData] = useState([]);
      const [currentPage,setCurrentPage] = useState(1);
    
      const handleLoadListData = (pageNum:number) => {
        fetch(`https://my-api.com/books-list/${pageNum}`)
          .then((res)=>{
            setListData([...res.json()])
          })
      }
    
      useEffect(()=>{
        handleLoadListData(currentPage);
      },[])
    
      return (
        <main>
          <BooksList data={listData} />
          <p>{`现在是第 ${pageNum} 页`}</p>
          <button>
            {"上一页"}
          </button>
          <button>
            {"下一页"}
          </button>
        </main>
      )
    }
    
    

    以上写法是对 BooksList 列表的分页查询。我们放了一个 currentPage 来指示当前的页码。并且根据当前的页码 currentPage 来调用 API,加载对应的列表。现在,初始化组件的代码已经写好了,那么我们应该如何实现点击上一页、下一页按钮后,当前页码状态变化后,加载新页码对应的列表呢?

    首先注意到:我们改变的是 currentPage 这个 State,而加载对应的列表则是受到 state 变化影响后的操作,加载对应的列表也同时修改了 listData 的 state。

    实际上,加载对应列表就是一个 effect。

    所以,我们可以设置一个 useEffect,加一个加载新页列表的 effect,这个 effect 的生效前提就是 currentPage 发生变化。

    
    export default function BooksListPage() {
    
      const [listData,setListData] = useState([]);
      const [currentPage,setCurrentPage] = useState(1);
    
      const handleLoadListData = (pageNum:number) => {
        fetch(`https://my-api.com/books-list/${pageNum}`)
          .then((res)=>{
            setListData([...res.json()])
          })
      }
    
      useEffect(()=>{
        handleLoadListData(currentPage);
      },[])
    
      useEffect(()=>{
        handleLoadListData(currentPage)
      },[currentPage])
    
      return (
        <main>
          <BooksList data={listData} />
          <p>{`现在是第 ${pageNum} 页`}</p>
          <button
            onClick={()=>setCurrentPage(currentPage - 1)}
          >
            {"上一页"}
          </button>
          <button
            onClick={()=>setCurrentPage(currentPage + 1)}
          >
            {"下一页"}
          </button>
        </main>
      )
    }
    
    

    这样当 currentPage 的 state 发生变化后,它会自动执行加载对应列表的 effect,这样就实现了组件的状态刷新。

    缺陷

    以上两类用途都有一个严格的前提:effect 操作必须是幂等性操作。幂等性,也就是说对于一个操作,初始操作和后续操作必须完全等效。

    举个例子,在数据库操作中的增、查、删、改。我们查找数据库中的数据时,库中的数据并没有发生任何变化,我们第一次查询某个数据和后续多次查询这条数据,都不会对这个数据本身造成任何改动,既第一次操作和后续操作是完全等效的。所以查询数据就是一个幂等性操作。

    但是我们要增、删、改数据时,被操作的数据就发生了变化:原先库中不存在的数据存在了,或者原先存在的数据不存在了,或者原先存在的数据被改动了一部分,都对数据库的总体数据添加了变化;这种操作就是非幂等性操作。

    这也是 useEffect 的尴尬所在。

    React 最初只是作为一种声明式构建、描述 UI 的库,它只重点关注输入数据流、状态数据流和输出 UI 三者之间的关系。它依赖输入→状态→输出这条单向链式数据流路线得到最终结果。

    因为输入数据流是不确定的、随时的,也就导致状态也是不确定,再加上状态可以在组件内被业务逻辑随时更改,而状态的不确定性也就决定了 UI 渲染结果的不确定性。

    注意: 出于性能优化的原因,即使是 setState 操作更新 state 到最新数据后,React 也不一定会立即根据最新的 state 来渲染 UI,而是等待收集到足够多的最新 state 后批量更新、渲染。具体内容请了解官方文档。

    所以 React 并不能准确确定组件什么时候会根据状态重新被刷新、渲染,也不能确定它会被刷新、渲染多少次。也就是说,你不能准确预料到某个操作后的下一步,组件是否会被刷新、重渲染。

    所以,如果你把非幂等性操作作为 effect,那么就会导致数据与 UI 的不一致。

    在 twitter, github 上,我常看到很多开发者抱怨 React 会在 debug 模式下,useEffect 中会执行两次 effect 操作而产生 BUG。实际上,他们是完全误解了 effect 的实际意义,误把 effect 当成 state 的事件监听器来使用。

    只需要记住这条法则就能避免误用 effect :

    useEffect 的 Effect 只能用于幂等性的视图初始化、视图刷新,除此之外别无用途,更不应当用于任何业务逻辑。

    虽然话说得很绝对,有一棒子打死的嫌疑,但是可以从根本上杜绝对 useEffect 误解而造成的滥用。

    举一个例子,假如我们在在线商城的后台维护一个商品书目列表,对于「添加新书」的操作就不应当作为 effect。

    function BooksManager() {
      const [listData, setListData] = useState([]);
    
      const handleAddNewBook = (book) => {
        setListData((prevListData) => [book, ...prevListData]);
      };
    
      useEffect(() => {
        axios.post("https://my-api.com/add-book", {
          body: listData[0],
        });
      });
    
      // ....
    }
    

    以上代码有什么问题?

    它试图在数据列表添加新项后马上同步提交到服务器,但是「添加新书」本身就不是一个幂等操作,这个 effect 不止会在 listData 的 state 改变后触发,也会在父组件重渲染后触发,触发次数也不确定。

    那么,为这个 useEffect 加上依赖数组,限定只能在 listData 变化后触发,如何?

    useEffect(() => {
      axios.post("https://my-api.com/add-book", {
        body: listData[0],
      });
    }, [listData]);
    

    这么做是非常有风险的。

    因为我一旦执行

    setListData([...listData]);
    

    表数据本身没变,没有添加新书,根据 Diff 比较各个渲染节点也没变,但是状态数据中的数组引用变了,还是可能会触发 effect,会重复添加已有的书籍。

    积重难返

    上面说了那么多,接下来讲讲我的个人对 useEffect 的观点。很遗憾,useEffect 是非常失败的、无法挽回的错误。因 useEffect 造成的 bug、事故数不胜数。

    传言 twitter 的网页端就曾发生过,twitter 程序员忘了给 useEffect 加上依赖数组,导致 useEffect 大量发送 GET 请求,以至于 twitter 的服务器直接崩溃。而且这种 Bug 事故原因一开始也没查出来,twitter 服务负载崩溃事件传到高层后,Elon Musk 以为是对手发动的网络爬虫攻击,于是给 twitter 设置了一天浏览量上限的乌龙事件。

    因为它根植于 React 的底层核心设计思想,所以这种错误无法挽回。

    我曾在知乎上看到过一个回答,很好地点出了问题所在:

    GUI 的本质是一个有生命周期的状态机,是状态数据、视图相互作用的结果,而不是单纯的 F(x)。

    React 的 useEffect 写法确实很别扭,它从一开始就犯了很多根本性错误,由此衍生出的很多 hooks 优化手段不过是拆东补西。

    比如状态的不确定性导致渲染的不确定性,有时会造成重复渲染浪费性能,就不得不用 useMemouseCallbackReact.memo 之类的方法来避免重复渲染,一切优化必须靠手动,徒增心智负担,导致 React 的性能在诸位框架中属于垫底的存在。

    像 Vue 的框架就完全没有这个问题。Vue 的性能更是比 React 高出不少。

    不过它是首批声明式前端 UI 框架,在这方面虽然走出了一些弯路,但经过多年实践,探索结果也未必没有意义。React 官方文档里也详细记录了很多坑,以及大大小小的修补方案。

    虽然 React 这方面的坑比较多,但经过多年发展 React 仍然不失为一种优秀的前端框架。在学习 React 时,对于这些「坑」应当吸取教训、避开这种写法,而不是在茴香豆的写法上钻牛角尖。

    正如同一位知友的总结:

    下面很多人都提到了最关键的一点,不同步副作用中的状态不是 hooks 的标准做法,useEffect 更不是生命周期,不是 didUpdate. deps 只是受制于 JS 语言特性的无奈之举,并不是一种特性,所以任何业务不应该依赖于 deps 去实现。