Simba
Simba
REACT

React之道:useEffect两次执行与React哲学的探秘之旅

React之道:useEffect两次执行与React哲学的探秘之旅
45 min read
#React

一个多月前再次经历一场 RV 大战(React vs Vue),大佬们负责 battle,我负责捡大佬们掉落一地的知识。今天就结合部分业务场景,讲讲自己最近深入浅出 React 泥潭的收获。

noob

说在前面

本篇文章建立在你有一定 React 基础上,不过我个人强烈推荐你看以下所有内容,很多观点和 demo 是参考自这些宝藏地方🤗。

  • React Doc:是谁还没看过 React 新版的文档?(是一个月前的我🤡
  • Sukka‘s Blog:击败全国 99.99% 的 React 开发者。
  • Dominik:TanStack Query (原 React Query) 的 maintainer。
  • CodeSandbox:小 demo 利器,提供多种环境的 code snippets、支持自定义 npm 依赖,并且支持 copilot。本文的 demo 大多运行在上面进行测试。(我真的没收米

useEffect 哲学

每当说起 useEffect,有个话题大概是所有 React 开发者都会有的 PTSD:“为什么在开发环境的 Strict Mode 下会执行两次啊(恼😠”。

如果你仍有疑惑 / 抱怨,请听我细嗦。

执行两次的只是 useEffect 吗

首先答案是 NO ❌。在开发环境中,React 是刻意进行了额外的一次 remount 操作,这是框架针对组件层面的行为,与 useEffect 本身无关。在最新版的React文档中给出了更全面的回复:How to handle the Effect firing twice in development?

我写了个小 demo 来说明这个执行两次的点:

App.tsx
export default function App() {
  console.log("mount");
 
  useEffect(() => {
    // do something
  }, []);
 
  return <div className="App">Hello World</div>;
}
 
// mount
// mount

这个小 demo 只用来记录组件挂载的 log,但在最新版的 React 18 中你仍然可以在控制台看到两次 mount 输出。不管是新旧文档都有意识地引导你注意「Add cleanup if needed」,但是大多数开发者并不能意识到。

所以与其抱怨这种非预期的行为,不如想想如何以「React 的方式」在开发环境 Strict Mode 下来处理这些负担。把它看作在帮你做组件单测的方式,来审查 useEffect 的业务逻辑中是否有不合理的地方。

学会审查 useEffect 中的代码

OK,回到出发点,试着思考下:

  1. 为什么这段逻辑需要使用到 useEffect
    • 我强烈推荐你带着代码一起对照文档中 You Might Not Need an Effect 的示例,审查下自己是否中招,里面几乎包括了我在业务中发现过度使用 useEffect 的场景。
  2. 如果必须要用,执行两次为什么会对你会产生影响(很烦?
    • 当出现 bug 时,不要最先排除业务代码出现 bug 的可能性。在日常开发中往往 bug 是出现在业务代码实现的逻辑中。
    • remount / re-render 是组件会发生的正常情况,当你感觉在开发环境执行两次的表现你很难以接受时,是否正好说明业务逻辑存在一些 effects。试着把它们找出来,相信你会发现一些疏忽的问题。

在使用 useEffect 来编写业务逻辑时,我建议你在最开始不要使用依赖数组,即使是空数组也暂时不要添加。

useEffect(() => {
  // do something
})

通常这时候就会帮你暴露 effects,你需要意识到:当这部分业务逻辑在不管怎么样 re-render 时不会对页面造成非预期的影响,才应该去考虑是否需要使用依赖数组去划分挂载 / 部分状态变化时的 re-render 优化。依赖数组是可选的优化手段,而不是必需品。

相信通过上述引导,你会开始思考自己是否遇到需要使用 useEffect 的场景并初步pua发现自己在平常开发不太「React」的地方。下面我将列举几种 useEffect 会遇到的情况,并且附上我认为的最佳实践。

useEffect 渲染元素

在常见的业务中针对需要在「DOM挂载后」这个时机进行元素的添加的需求,比如初始化一次 canvas、创建全局的 video 元素并添加相关事件。以下我将以 canvas 为例:

编写带 cleanup 操作的 useEffect

App.tsx
export default function App() {
  useEffect(() => {
    const el = document.body.querySelector(".App");
    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d");
  
    canvas.width = 200;
    canvas.height = 200;
    ctx?.fillRect(0, 0, 200, 200);
    el?.appendChild(canvas);
    
    return () => {
      el?.removeChild(canvas);
    };
  }, []);
  
  return <div className="App" />;
}

以上代码是初始化一个 canvas 元素并绘制在 div 元素中,很简单吧。但是不妨关注下高亮的部分,动态添加元素是简单的,难的是你需要思考当前创建元素的这段逻辑是否会产生额外的 effect

接着我们修改下,当你的 useEffect 包含着其他逻辑呢?试试下面这个 demo

interval-canvas
function CanvasTimer() {
  useEffect(() => {
    const el = document.body.querySelector(".myCanvas");
    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d");
    
    canvas.width = 200;
    canvas.height = 200;
    el?.appendChild(canvas);
 
    setInterval(() => {
      console.log("interval");
      if (ctx) {
        ctx.font = "30px serif";
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.fillText(new Date().toLocaleTimeString(), 10, 50);
      }
    }, 1000);
    
  }, []);
  
  return <div className="myCanvas" />;
}
 
export default function App() {
  const [remount, setRemount] = useState(false);
  
  const handleClick = () => {
    setRemount(true);
    
    setTimeout(() => {
      setRemount(false);
    }, 3000);
  };
  
  return (
    <div>
      <button onClick={handleClick}>Remount Canvas</button>
      {remount ? <CanvasTimer /> : null}
    </div>
  );
}

这是一段模拟组件挂载时,定时在 canvas 中绘制时间戳的demo,我们来简单说下组件之间的交互表现。

  • 在父组件 <App /> 中我们有个控制子组件 <CanvasTimer /> 重新挂载的 <button>,点击以后我们会立即挂载它,并在 3s 后进行卸载。
  • 子组件 <CanvasTimer /> 则是在挂载时创建 canvas,并且定时在 canvas 上绘制当前时间。
  • 等待 3s 后 <CanvasTimer /> 正常卸载。

OK,打开控制台,点击 button,你将看到在不断打印的 interval。试着思考下,此时 React 是不是已经帮我们暴露出潜在的 effect

  • 组件多次挂载时重复出现的canvas

    在这个demo中,依赖「生命周期」添加元素就是一个 effect。但是需不需要进行 cleanup,区分的点在于这个 effect 是影响组件内部还是外部的。

    如果只是组件内部的 effect,即会随着组件的卸载正常清除,那么你不编写 cleanup 函数,也是可以正常运行的,只是开发环境与生产环境表现不一致。

    但是试想下如果我们添加元素的宿主不是组件内部的 div,而是外部 DOM 中某个节点。那么这个 effect 就会升级成一个外部的 effect,即不会随着组件卸载自动清除。这就意味着在 demo 中,页面获得的 canvas 数量将随着我们的点击而无限增加。

    这时我们就不能依赖框架提供的「自动清除」能力,需要再次手动加上cleanup函数。

    useEffect(() => {
      // ...
     
      return () => {
        el?.removeChild(canvas);
      }
    }, [])

    这时候你会发现:不仅在不同环境的表现对齐了,还不用再去区分这是影响组件内部还是外部的 effect 了。添加元素只是一个例子,我们业务中还会有需要使用 addEventListener 监听各种事件的情况,我们下面再详细说说。

  • 一直在不断打印的 interval

    在上述 demo 中,只是做了定时打印一条日志的操作,可能不会对性能有太大影响。但是如果我们内部是更复杂的 addEventListener 的 listener 函数,并且引用了组件的 state 变量,由于 JS 的闭包特性,这意味着即使组件已经卸载,定时器仍然可以访问和操作组件中的 state。定时器不断地运行,listener 函数也在不断地执行,这种情况极大可能导致内存泄漏和不必要的资源占用。

    试下这个 demo 吧,永不消逝的闭包点击就送,喜欢内存泄漏的小伙伴有福了🤗。

    bad-listener-effect
    function BadCount() {
      const [count, _] = useState(Math.random() * 100);
      
      useEffect(() => {
        const globalHandleClick = () => {
          // a lot of work here
          console.log("count", count);
        };
      
        window.addEventListener("click", globalHandleClick);
      }, []);
      
      return <div>{count}</div>;
    }
     
    export default function App() {
      const [remount, setRemount] = useState(false);
      
      const handleClick = (e: MouseEvent) => {
        e.stopPropagation();
        setRemount(true);
        
        setTimeout(() => {
          setRemount(false);
        }, 100);
      };
      
      return (
        <div>
          <button onClick={handleClick}>Remount BadCount</button>
          {remount ? <BadCount /> : null}
        </div>
      );
    }

所以在平常开发 / Code Review 过程中,当发现有需要绑定 useEffect 来实现逻辑的时候,就要关注代码中是否有需要清除的 effects。只要做到「发现 effect,就清除 effect,不依赖自动清理的能力」,自然就可以轻松避免这类潜在的问题🤗。

或许你可以尝试下 ref 回调函数

如果你的业务逻辑只是想在组件挂载时执行一次,那么可以试试下面的 demo

useCallback-case
export default function App() {
  const ref = useCallback((el: HTMLDivElement | null) => {
    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d");
 
    canvas.width = 200;
    canvas.height = 200;
 
    if (ctx) {
      ctx.font = "30px serif";
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.fillText(new Date().toLocaleTimeString(), 10, 50);
    }
 
    el?.appendChild(canvas);
  }, []);
 
  return <div ref={ref} />;
}

这样不会像 useEffect 那样在开发环境中渲染两次,很神奇吧!这是我在重新翻看文档时,发现之前忽略掉的一个 ref特性

Instead of a ref object (like the one returned by useRef), you may pass a function to the ref attribute.

useRef 返回的 ref 对象不同,你或许可以将函数传递给 ref 属性。

<div ref={(node) => console.log(node)} />

可以使用回调函数的方式代替原有 useRef 对象,这样做有什么好处呢🤔️?

  • 更 React 的方式ref 回调函数可以直接将 DOM 元素实例作为参数传递,不再需要我们以 document.xxx 的方式来获取。同时,更好的聚焦这部分 DOM 的操作,与 useEffect 生命周期里本就复杂的逻辑进行解耦。
  • 执行时机更早:在 commit layout 阶段才会调用 useEffect,而 ref 的值在 render 阶段就创建好了,并且更早地进行了 commit 赋值。

但是需要注意的是,虽然使用 ref 回调函数的表现看似只在组件挂载时渲染了一次,但实际上它会有两次触发的时机:组件挂载时 & 组件卸载时

在组件挂载时,接收参数为元素的实例;但在组件卸载时,它还会额外触发一次回调函数,传递一个参数为 null 的值。你可以把这次触发时机当作 useEffectcleanup 操作,同样提供给你选择是否需要清除某些 effect

const ref = useCallback((el: HTMLDivElement | null) => {
  if (el) {
    // do something
  } else {
    // cleanup
  }
}, []);

还有一个题外话,是关于为什么我认为在 demo 这部分逻辑中可以使用 useCallback

首先是考虑到通常业务组件中会包含着很多的交互、子组件以及依赖条件的判断。当一个组件重新渲染时, React 将递归渲染它的所有子组件,这意味着我们组件内部大量内容会进行新一轮的初始化。

此时通常像这类初始化单例 DOM 操作的逻辑,是不会有太大变动的。尽管逻辑中可能依赖于外部的 state / props,但是可以手动维护下 useCallback 的依赖数组来解决这方面的需求。

当然了,如果你的逻辑非常简短,那么滥用 useCallback 也是不必要的。上游组件 useCallback & 下游组件 useEffect 经常存在多层配套使用以实现全量缓存的情况orz。务必记住使用缓存的过程不是免费的,依赖关系的比较是在你看不到的地方发生的,这部分同样是用性能去换的。(看不到即合理!❌

- Feb 17 [UPDATED] 📝 -:DAMN!就在写完这篇文章前夕,React Labs 发布了 2024 年的工作计划。里面提到了一直处于研究状态的 React Compiler 模块,这里面包含着我下文会提到的 React Forget 。简单来说这就意味着最晚 2024 年年底,我们终于终于以后不用再去主动操作 useMemouseCallback 以及 memo 去做优化了!害😢,今夕是何年......

useEffect 获取API数据

很多 React 开发者在需要进行 API 请求的时候,会一股脑的加在 useEffect 中,比如我🤡。让我们从下面这个小 demo 开始,来看看平常编写一个 fetch 数据的流程及会产生什么问题吧。🤗

Quick Start

我们借用 DummyJSON 的数据来编写第一个 demo

App.tsx
export default function App() {
  const [products, setProducts] = useState<any[]>([]);
  const [error, setError] = useState<boolean>(false);
 
  useEffect(() => {
    fetch("https://dummyjson.com/products")
      .then((res) => res.json())
      .then((data) => setProducts(data.products))
      .catch((e) => setError(e));
  }, []);
 
  return (
    <div>
      {error
        ? "FETCH ERROR"
        : products.map((product) => (
            <div key={product.id}>{product.title}</div>
          ))}
    </div>
  );
}

先找下我们的「老朋友」,打开 Network 面板一看,/products 果然也请求了两次,这意味着 fetch 请求在开发环境的 Strict Mode 中也会与生产环境行为不一致🤗。那怎么去做表现对齐呢?

单次请求的表现对齐

在业务中经常会有的「xx模块详情页」,这类数据我们通常只会请求一次并且不会经常改变,那可以试试以下方法来做表现对齐:

  1. 参照 #useEffect 渲染元素 中使用 ref 回调函数的方法来修改 demo。

  2. 使用 useRef hook 创建的 ref 对象:

    在 DOM 上添加额外的 ref 属性看起来是不必要的,我们可以使用原本 useRef 提供的 ref 对象来实现请求一次的效果,试试下面这个 demo

    fetch-once
    export default function App() {
      const mounted = useRef<boolean>(false);
      const [products, setProducts] = useState<any[]>([]);
      const [error, setError] = useState<boolean>(false);
     
      useEffect(() => {
        if (!mounted.current) {
          fetch("https://dummyjson.com/products")
            .then((res) => res.json())
            .then((data) => setProducts(data.products))
            .catch((e) => setError(e));
        }
     
        return () => {
          mounted.current = true;
        };
      }, []);
     
      return (
        <div>
          {error
            ? "FETCH ERROR"
            : products.map((product) => (
                <div key={product.id}>{product.title}</div>
              ))}
        </div>
      );
    }

只请求一次的接口表现对齐解决了!那么如果一个 API 请求会潜在触发多次的情况,怎么办呢?咱们接着往下看👇。

添加 Loading 状态

在业务中大部分的页面组件包含的 API 请求其实是可以请求 n 次的。通常在多次 API 请求中,我们会加入相关的交互效果来优化 fetch 数据还未返回时的用户体验。比如进度条、Loading 图以及骨架屏等等。我将简单下修改之前的 demo,添加一个 Loading 状态:

loading-state
export default function App() {
  const [loading, setLoading] = useState<boolean>(true);
  const [products, setProducts] = useState<any[]>([]);
  const [error, setError] = useState<boolean>(false);
 
  useEffect(() => {
    setLoading(true);
    fetch("https://dummyjson.com/products")
      .then((res) => res.json())
      .then((data) => setProducts(data.products))
      .catch((e) => setError(e))
      .finally(() => setLoading(false));
  }, []);
 
  return (
    <div>
      {loading
        ? "Loading..."
        : error
          ? "FETCH ERROR"
          : products.map((product) => (
              <div key={product.id}>{product.title}</div>
            ))}
    </div>
  );
}

Yeah,我们现在拥有了一个包含 Loading 加载状态的组件。但是不得不吐槽一点,在最后 Render JSX 中的这段三元表达式,是在项目中经常看到的。尽管有 ESlint、Prettier 等这类工具帮助这段代码进行格式化,提高可读性。但是在一个复杂的业务组件中,它的段落缩进将会随着多种三元表达式无限增长。

所以我强烈推荐大家在使用内部 state 来管理边界情况时,将它们单独移出来并在最后 Render 之前进行截断:

edge-case
export default function App() {
  // ...fetch data
 
  if (loading) {
    return <div>Loading...</div>;
  }
 
  if (error) {
    return <div>FETCH ERROR</div>;
  }
 
  return (
    <div>
      {products.map((product) => (
        <div key={product.id}>{product.title}</div>
      ))}
    </div>
  );
}

多次请求的 Race Condition

当发现 useEffect 会让 fetch 操作执行两次时,不妨想想这么几种业务场景。

  • 后台管理系统的工单页:

    在后台管理系统中,我们有大量的业务模块查询页面,其中包含常见的下拉框添加筛选条件、输入框输入关键词等等交互。此时快速地触发交互去进行 API 请求时,是否有处理过两个请求出现快慢时的情况🤔️?

  • C端拍卖业务:

    在拍卖业务中,最致命的问题就是存在「串数据」的情况。这经常会发生在拍卖出价、获取拍卖最新出价等业务需求中。作为客户端请求,如果我们没有后端返回的成功的结果是无法更新页面的状态的。但是在这种对请求响应要求实时性高的情况下,快慢请求将会导致非预期的结果发生。

以上都是很明显的「Race Condition」的特征,可能文字描述会有点绕,我画了个时序图来解释这样的情况:

race_condition

与此同时,我重新写了一个 demo 来模拟这张图里的场景:

function fetchData(count: number): Promise<number> {
  return new Promise((resolve, _) => {
    setTimeout(() => {
      resolve(count);
    }, (count % 2) * 3000);
  });
}
 
export default function App() {
  const [count, setCount] = useState<number>(1);
  const [result, setResult] = useState<number>(0);
 
  useEffect(() => {
    fetchData(count).then((data) => {
      setResult(data);
    });
 
    // mock: race condition
    setTimeout(() => {
      setCount(2);
    }, 500);
  }, [count]);
 
  if (result === 0) {
    return <div>Loading...</div>;
  }
 
  return <div>{result} 次返回结果</div>;
}

这块逻辑可能会因为一些 mock 操作变得很绕,我来简单梳理下:

  • fetchData 函数是模拟我们的 API 请求,不用过多关注里面的逻辑,你知道他是让第二次请求结果优先于第一次请求返回的即可。

  • count 变量是用于记录当前为第 n 次请求,作为传递给 fetchData 的参数。

  • result 变量是用于接收第 n 次请求的返回结果,即它的值为 n。

  • 完整的执行过程:

    1. 发送 fetchData(1),等待返回结果⌛️,同时渲染 Loading 状态。

    2. 等待约 500ms 后,setCount(2) 让我们的组件进行了一次 re-render。

    3. 发送 fetchData(2),等待返回结果⌛️。

      -> fetchData(2) 会立刻返回结果。

      -> fetchData(1) 则需要 3s。

    4. fetchData(2) 率先返回,故这时页面将渲染 result = 2 的结果。

    5. 等待约 3s 后,fetchData(1) 的结果才姗姗来迟。

    6. 最后因为闭包的关系,fetchData(1) 并没有在 re-render 时清除,故最终页面将渲染 result = 1 的结果。

不知道你有没有和我一样的疑惑🤔️:函数组件重新渲染不就应该意味着函数体所有内容都会重新执行,旧的 setResult 为什么能更新当前新的 result 状态呢?

其实每次 count 改变时,都会触发一个新的 useEffect 调用,而之前的异步操作(即使是针对旧的 count 值)依然可以在完成时更新状态,因为它们仍然闭包了当时的 setResult 函数。所以无论新旧的 setResult 对于 React Fiber 来说都是对于当前组件 memoizedState 的改变,即都是指向 result

在日常开发过程中,往往因为请求响应数据很快,导致我们非常自然地忽略掉 API 请求这种异步操作本身很容易出现的 race condition 的情况。不过这并不是 React 造成的,Vue 的 onUpdatedwatch 同样会出现这样的问题,这是我们在进行异步操作时需要注意到。

那么在 React 中我们如何解决这个问题呢?

首先我们要确定发生这种情况的原因,是因为异步请求带来的闭包导致的。那我们就想办法在组件 re-render 之前,将闭包中组件的状态标记为「已过期」来解决。

我们可以通过增加一个 outdated 的 flag 变量🚩,配合 useEffectcleanup 函数来实现「已过期」状态:

ingore-outdated-state
export default function App() {
  const [count, setCount] = useState<number>(1);
  const [result, setResult] = useState<number>(0);
 
  useEffect(() => {
    let outdated = false;
 
    fetchData(count).then((data) => {
      if (!outdated) {
        setResult(data);
      }
    });
 
    // mock: race condition
    setTimeout(() => {
      setCount(2);
    }, 1000);
 
    return () => {
      outdated = true;
    };
  }, [count]);
 
  if (result === 0) {
    return <div>Loading...</div>;
  }
 
  return <div>{result} 次返回结果</div>;
}

目前来看暂时不会再有 race condition 的情况发生了。试着把之前遗忘掉的 loadingerror 请求状态添加回来。思考下之前在 #添加 Loading 状态 时我们处理边界情况时是否同样缺少了什么🤔️?

error-case
function fetchData(count: number): Promise<number> {
  return new Promise((resolve, reject) => {
    if (count === 1) {
      setTimeout(() => {
        reject(count);
      }, 3000);
    } else {
      setTimeout(() => {
        resolve(count);
      }, 100);
    }
  });
}
 
export default function App() {
  const [count, setCount] = useState<number>(1);
  const [result, setResult] = useState<number>(0);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<boolean>(false);
 
  useEffect(() => {
    let outdated = false;
 
    setLoading(true);
    fetchData(count)
      .then((data) => {
        if (!outdated) {
          setResult(data);
        }
      })
      .catch(() => {
        setError(true);
      })
      .finally(() => {
        setLoading(false);
      });
 
    // mock: race condition
    setTimeout(() => {
      setCount(2);
    }, 1000);
 
    return () => {
      outdated = true;
    };
  }, [count]);
 
  if (loading) {
    return <div>Loading...</div>;
  }
 
  if (error) {
    return <div>FETCH ERROR</div>;
  }
 
  return <div>{result} 次返回结果</div>;
}

乍一看 demo 好像没什么问题,但是实际运行时,你会发现最后的结果始终是 FETCH ERROR。在两个请求存在 race condition 时,如果慢请求出现异常,那么最终组件 state 中 error 变量会被赋值为 true。通过时序图来描述这段文字:

data_vs_error.jpg

所以我们在改变 API 请求状态时也同样需要判断当前组件状态是否「已过期」:

useEffect(() => {
  let outdated = false;
 
  fetchData(count)
    .then((data) => {
      if (!outdated) {
        setResult(data);
      }
    })
    .catch(() => {
      if (!outdated) {
        setError(true);
      }
    });
    .finally(() => {
      if (!outdated) {
        setLoading(false);
      }
    });
  
  return () => {
    outdated = true;
  };
}, [count]);

Congrats🎉!我们在闭包里打败了闭包🤗!(bushi

以 hook 之名

显然,我们不可能重复在业务组件中编写这些请求处理的逻辑,抽离成公共的 hook 是必要的。

hooks/useFetch.ts
export default function useFetch<T>(url: string) {
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<boolean>(false);
  const [data, setData] = useState<T | null>(null);
 
  useEffect(() => {
    let outdated = false;
 
    setLoading(true);
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        if (!outdated) {
          setData(data);
        }
      })
      .catch(() => {
        if (!outdated) {
          setError(true);
        }
      })
      .finally(() => {
        if (!outdated) {
          setLoading(false);
        }
      });
 
    return () => {
      outdated = true;
    };
  }, [url]);
 
  return {
    loading,
    error,
    data,
  };
}

然后我们在组件中可以在顶部直接使用这个 hook:

App.tsx
export default function App() {
  const { loading, error, data } = useFetch<any>(
    "https://dummyjson.com/products"
  );
  
  // ...
}

一切看起来都非常的好。但是打开「Network」一看,/products 请求依然执行了两次,因为我们上述的逻辑只包含 race condition 的处理,但并没有清理过期的请求。

这个时候可以用到一个新的 Web API —— AbortController。它是允许你通过一个信号来取消一个或多个 Web 请求(比如由 fetch 发出的)的 API。这个机制特别有用,因为它提供了一种方式来终止还未完成的异步任务,这在处理需要长时间运行的请求或者需要在组件卸载时取消请求以避免内存泄漏的情况下特别有价值。

我们在 useFetch 中加入它并在 demo 的 Network 中看看它的表现:

abortable-fetch
// 是否支持 AbortController API
const isAbortControllerSupported = typeof AbortController !== "undefined";
 
export default function useFetch<T>(url: string) {
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<boolean>(false);
  const [data, setData] = useState<T | null>(null);
 
  useEffect(() => {
    let outdated = false;
    let abortController: AbortController | null = null;
    if (isAbortControllerSupported) {
      // 创建 AbortController 实例
      abortController = new AbortController();
    }
 
    setLoading(true);
    // 添加请求控制信号
    fetch(url, { signal: abortController?.signal })
      .then((res) => res.json())
      .then((data) => {
        if (!outdated) {
          setData(data);
        }
      })
      .catch(() => {
        if (!outdated) {
          setError(true);
        }
      })
      .finally(() => {
        if (!outdated) {
          setLoading(false);
        }
      });
 
    return () => {
      outdated = true;
      // 取消请求
      abortController?.abort();
    };
  }, [url]);
 
  return {
    loading,
    error,
    data,
  };
}

目前,解决了存在过时请求的问题。但是由此问题延伸,在现代前端项目中日益复杂的业务环境,这个 hook 显然是不够打的。我们需要更健壮的一个 useFetch ,特性包括但不限于: ️

  • 请求合并去重:优化无聊的用户多次快速触发 API 请求。
  • 请求缓存和缓存刷新:控制数据的新鲜度,多个组件共享缓存。并且支持主动触发交互、聚焦组件时刷新数据。
  • 无限加载:优化对于无限滚动列表的加载体验。
  • 预加载数据:避免连续的网络请求导致的页面加载延迟。
  • 中间件:处理基础的鉴权以及用户的埋点上报。
  • 请求重试策略:尽可能重试埋点数据的异常上报。
  • 乐观更新:前端优先更新 UI,同时发送 API 请求给后端,如果出错了再进行 UI 的回滚。
  • ...🤯

这一部分在我之前的团队也积极造了轮子🤗,但是对于期望的功能来说还是不够完善。所以目前如果没有特殊的需求,我会更偏向于用现成的轮子,在 React 中 SWRTanStack Query 都是很好的管理异步请求的工具库,基本上能包括上方提到的所有特性。

最近的项目中用到的是 TanStack Query,所以我将以它来举例。TanStack Query 默认去重正在进行的相同请求,即如果有多个组件同时使用相同的 queryKey 发起请求,它会智能合并这些请求为单个请求,避免不必要的网络负载。以下是用它来修改之前请求的 demo:

index.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 
const queryClient = new QueryClient();
 
root.render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>
);
App.tsx
import { useQuery } from "@tanstack/react-query";
 
// 定义一个获取数据的异步函数
const fetchProducts = async () => {
  const data = await fetch("https://dummyjson.com/products");
  return data.json();
};
 
export default function App() {
  // 使用 useQuery 钩子获取数据
  const { isLoading, error, data } = useQuery({
    queryKey: ["products"],
    queryFn: fetchProducts,
  });
 
  if (isLoading) return <div>Loading...</div>;
 
  if (error) return <div>An error has occurred: {error.message}</div>;
 
  // 渲染数据
  return (
    <div>
      <h1>Products</h1>
      <ul>
        {data.products.map((product: any) => (
          <li key={product.id}>{product.title}</li>
        ))}
      </ul>
    </div>
  );
}

对于这两个工具库来说,它们都默认集成了「请求合并去重」以及「请求缓存和缓存刷新」功能,并且支持自定义配置。美中不足的可能就是 TanStack Query 的设计理念里不提供 Middleware 的拓展,但是仍然可以使用它的 Mutation API 的 callback 来曲线救国实现简单的日志以及上报。

有关更多 TanStack Query 实现的异步功能特性可以参考官方提供的 Examle 🤗。

思考 React 哲学

以下仅代表个人观点 ?_?

生命周期️ の 执念

看到这里,不知道你有没有汗流浃背,反正我写得已经汗流浃背了🥴。在大多数前端开发者中,从 Vue 2.x、React Class Component 开始就被组件的生命周期绑定了思想,不管是什么都定义成组件的 state,不知道应该放在哪里的逻辑就放在组件的生命周期中。这是错的吗?不,这可以正常运行;这是对的吗?不,在 React 中你就能发现一些端倪。

#useEffect 哲学 中我曾提到,最新的 React 文档中单独列出了 You Might Not Need an Effect,里面的例子是不管在 Vue Composition API、React Hooks 都会发生的问题。 也许你需要开始摆脱之前的一些「随意」的行为,并不是什么值都需要进行 useState,也并不是什么逻辑都需要使用 useEffect 。当我们在视图层的生命周期编写一些业务逻辑的时候,你需要意识到:当你的逻辑不依赖于 UI 如 API 请求时,那么它完全可以与 useEffect 脱钩。

在 Native APP 开发中可以学习到,不论是在 Android 还是 iOS 平台,框架都提供生命周期,但都不推荐在 UI 线程(主线程)中直接进行网络请求或其他耗时操作。因为如果在 UI 线程内执行,就会阻塞 UI 线程,导致应用界面卡顿,甚至出现应用无响应(ANR)的情况。这样的用户体验是非常差的。

在之前提到的 TanStack Query 中,它的核心模块 Query Observer 就是独立于 React 之外来实现异步请求及状态管理,并且通过 useSyncExternalStore 传递数据来进行 UI 的同步,这也是社区中认为的一种 Best Pratice。

或许不是你想的 React

我们先来聊聊 React 它想做的是啥,引用 React 官方文档首页 的 slogan:

The library for web and native user interfaces —— React

相信大家很多人都看到过这句话,但是是否有真正理解过 React 想要表达的意思。

React 对于 Web 开发者来说,它是一个很好的工具库,没错,只是一个 UI 工具库。它可以让我们摆脱之前使用的命令式 API,更轻松地在 DOM 上添加任何交互。

所以不得不承认你在构建一个完整的 Web 项目时,你需要考虑的并不只是如何实现 UI,而是多方面的考虑。这一点 React 依赖于社区,将所有在 Web 项目所需的模块(路由、状态管理)都丢给你自行选择,这也造就了早期 React 带来的灵活性和庞大的社区生态。

但是 React 只是 React。😇

而反观 Vue, 我们同样引用 Vue 官方文档首页 的 slogan:

The Progressive JavaScript Framework: An approachable, performant and versatile framework for building web user interfaces. —— Vue

「Progressive」 体现在 Vue 允许你根据项目的规模和需求,决定使用框架的哪些部分。你可以仅仅利用它来快速构建 UI,也可以深入使用它提供的内置功能和集成解决方案(如 Vue Router 和 Vuex)去开发大型应用,减少了配置和引入第三方库的需要,在很大程度上已经减轻了开发者的心智负担。同时现在 Vue 的社区不见得比 React 差,在现代前端中开箱即用的一个框架,体验远大于 React 这样的「毛坯房」。

函数式编程思想

关于这个话题,我只能给 React 一个大大的❌。对比 Haskell 来说,React 一直坚持所谓的函数式编程是不纯粹的。

它做到了吗?一部分做到了。譬如它所期望的纯函数 UI = f(data)、useEffect 开发环境运行两次要求开发者保证组件幂等性,不会因为执行次数不同而产生不同的结果、immutable 的概念等等。

但它做对了吗?一部分做对了。

  • UI = f(data):这个概念是很好的,在现在推崇 local first 的多端 app 中,这种模式促使开发者通过不变性和纯函数来构建用户界面,提高了代码的可预测性和可测试性,这也是开发者期望的样子。

    但是在 React 中,不是所有组件都可以成为这样的纯函数并且无状态的组件,更多的是复杂的业务组件。这仅仅是理解 React 基本工作原理的一个有力抽象,它将组件视为将数据转换为 UI 的函数,但这个模型并不完全捕捉到实际 React 应用的所有复杂性。

    特别是考虑到副作用、状态管理的复杂性,以及如何处理计算属性和上下文,React 应用的实际模型更加动态和复杂。或许一个业务组件的表示应该是 UI = f(data, state, context) + effects

  • 状态与副作用:在 React 中,useEffect 被用于处理副作用,如数据请求、DOM 操作等,这些都是函数式编程中通常避免的操作。尽管 useEffect 的设计旨在尽可能地将副作用的处理与组件的主体逻辑分离,但它的存在本身就表明 React 需要处理不纯的操作,也就是已经超越纯函数。

  • useEffect执行两次:追求组件的幂等性是好的,组件的行为变得更可预测和稳定,减少了因副作用引起的意外行为。有助于我们发现那些可能在生产环境中引起问题的副作用实现,如未清理的订阅或事件监听器等。

    但是对于 React 本身来说,自认为帮开发者在框架内部中做类似白盒自动化测试的同时,不同环境表现不一致却抛出给开发者并让你自行忽略 ?_?。大部分组件优化的心智负担抛出让开发者自己来承担,未免有些过于拉扯了。如果能提供更多像 React Forget 的实现,或许会更好吧。

react-forget-meme

后来我悟了,就像这个章节的 title 一样。React 只是想推崇函数式编程的「思想」,并不是函数式编程的终极形态。当我们在编写组件时,能往「纯」了去写,能想到这么多负担,那就已经是 React 哲学所需要沉淀的形状了。

Come on, let's go

(watch eyes 🤡👉✨)

那么说回所谓开发体验(DX) ,很大程度上取决于个人偏好、习惯和项目需求,更多是前者主观上面的看法。针对项目而言,如果这个框架能满足你目前所有的需求,并且它的周边生态足够你进行拓展,我觉得这就够了。

正如本篇中大量章节用来优化副作用代码以避免开发环境中的表现以及性能问题,导致代码变得愈发复杂,甚至偏离了实际需要解决的问题,又或者说是去解决一个实际上并不存在于生产环境的「问题」。在使用 React 和 Vue 的生命周期时你就会发现,比起 useEffect「三合一」造就的 n 种使用技巧,Vue 暴露出的一个个清晰且必要的生命周期 hook 会更深入人心。

归根结底我认为他们并没有没做到满足自己宣传的一堆 React 哲学 的东西,并且对于一个现代前端项目中的 DX 毫不关心。在大家使用 React 来做业务需求时,不管你遇到什么反直觉的开发场景,他们仍然固执地在寻求大家针对 React 哲学设计上的共情,而不是如何让 DX 更舒服。在大趋势不管是 Vue 还是 Svelte 都在不断优化框架带来的 DX,React 依然一路走到黑。

对于端水大师我来说,React 和 Vue 各有各的好。不同的在于上述所提到的 React 哲学确实在某些思想方面给开发者带来心智负担,这是它的缺点也是它的问题,但它的心智负担在很多时候的初衷是想让你写更好的组件、逻辑。或许当你看过太多的 Best Practices 以后,你还是会更喜欢以前那个什么都是 axios、生命周期一把梭的自己。

如果你是一个愿意折腾的人,React 并不会让你失望。最新的 React Server Components 和 Concurrent Mode 概念中 useTransitionSuspenseuse 等API来管理状态过渡和数据加载,都是特别有趣的东西。Dan 的文档写得真的很好,相比他们的 issues 那会是另外一个世界😇。

试着思考并接受 pua 所谓的 React 哲学吧!