因为本文篇幅超出掘金单篇文章长度限制,所以分成了两篇文章:
《 为什么我们要删掉 100% 的 useEffect(一) 》
《 为什么我们要删掉 100% 的 useEffect(二) 》
GitHub 源码 : fool-proofing-hooks
完整原文(阅读体验更佳) :《 为什么我们要删掉 100% 的 useEffect 》
五、Effect 和"同步"
在梳理完本文第三章中描述的那些问题时,我首先想到的就是用一个新的方案去代替 useEffect,但是当我们要推广和介绍 useInit 方案前,真正理解 Effect Hook 是很有必要的,所以本章将较为深入且辩证地讨论 Effect Hook 的设计哲学,并涉及到了几个关键的问题:
-
为什么 React Hook 要引入**副作用(Side Effects)**这个概念,以及 React 中的 Effect 表示什么?
-
React 官方文档为什么没有强调不应该用 Effect 处理用户事件(包括获取数据),而只是委婉地提了一句,甚至举了很多充满迷惑性的示例。
-
支持了 React Hook 函数组件为什么选择了同步的心智模型(useEffect),而不是继续沿用生命周期的心智模型,否则当初官方完全可以搞一套类似 useMount / useUpdate / useUnmount 的 Hook,前者到底好在哪?
1、Side Effects
函数式编程
首先,副作用(Side Effects)的概念应该是来源于函数式编程,因为我其实并不了解函数式编程,所以问了下 DeepSeek,以下是 DeepSeek 回答的部分内容:
不可变数据 很好理解,比如 JavaScript 中通过字面量形式创建的对象、数组等引用类型数据,都是可变的数据,变量定义好了之后可以在任意能访问到这个变量的地方去修改对象、数组。我们可以通过 Object.freeze 或者借助其他第三方库创建不可变的数据。
那什么是 纯函数 呢?继续问:
OK,现在关于 函数式编程 、纯函数 、副作用 的概念很清晰了。
useEffect 的 Effect
再来看 React 对于 Effect 的定义:
从 React 官方介绍中可以看出,在 React 中有两个地方可以触发副作用(改变程序状态或外部系统的行为):
-
由用户事件触发的副作用,比如用户点击事件的事件响应函数中触发了一个 HTTP 接口调用,或读取了 localStorage 中的值等。
-
渲染过程自身引发的副作用,比如根据 title 这个 state 去修改 document.title,或根据 URL 中的 query 参数去查询数据等。
只有前者才被定义为 Effect,所以我们完全可以认为 useEffect 是 useSideEffectNotTriggeredByUserEvent 的简称,因为在这个定义中由用户事件触发的副作用完全不属于 Effect !
果然只是我们用错了 useEffect,这么看来如果正确使用 useEffect ,那么根本就不会遇到本文第三章中"过度的响应式编程"里提到的问题。然而我 Google 简单搜了下,在 2023 年 6 月(新版官方文档发布)之前,绝大部分关于 Hook 的讨论都没有提到这个观点,我只搜到 《 为什么你不应该在 React 中直接使用 useEffect 从 API 获取数据 》 这一篇技术文章提到了一句"绝大部分触发网络请求的原因都是用户操作,应该在 Event Handler 中发送网络请求"。所以即使官方文档这么定义了,由用户事件触发的副作用不属于 Effect 并不是开发者的共识(可能因为官方举的文字示例都是由用户事件引发的提交类 HTTP 接口,但是查询类 HTTP 接口一样属于副作用)。
React 中的副作用
当我继续认真对比 函数式编程 和 React 中副作用的概念时,我还发现了一个问题:如果改变函数外部状态的行为都属于副作用,那么 React 函数组件中的 setState 是不是也应该属于副作用,而且函数组件执行过程中的入参是 props,在整个组件的生命周期中,即使 props 不变,函数组件返回的 JSX 是不固定的,因为每次 update 函数组件都从函数外部读取了可能会变化的 state 状态。
但是看官方对于 Effect 的各种示例中从来没有提到过 setState,也从来没有说过 setState 不是副作用,所以我只能调戏 DeepSeek 了,我新建了三个会话(避免在大模型一个会话里根据上下文出现墙头草行为)分别问了以下三个问题:
-
为什么 React 函数组件中的 setState 属于副作用?
-
为什么 React 函数组件中的 setState 不属于副作用?
-
React 函数组件中的 setState 是否属于副作用?
从 DeepSeek 的回答中,我也感受到了正反两种观点,不过第 3 个问题的回答是认为 setState 不属于副作用的。在这几个问题的回答中,最能说服我的可能是 DeepSeek 提到的以下这部分观点:
React 只是借鉴了函数式编程的思想,特别是将视角限定在函数组件的单次渲染时,setState 并没有修改因闭包而形成的时间切片中 state 的值(在这个比喻中类组件的 this 和函数组件的 ref 就像是可以穿越到未来的时光机)。
2、重新认识 useEffect
在知道了由用户事件触发的副作用不属于 Effect 后,我就在想本文第三章提到的这些问题会不会都是错误使用 useEffect 造成的?所以我决定重新仔仔细细地看一遍 React 官方文档。又恰好 2023 年 6 月上线了新的 React 官方文档《 介绍 react.dev 》,在新版官方文档的教程中已经完全抛弃了类组件和生命周期的概念,因此我也可以体验一下从零开始的 React Hook 学习路径。
脱围机制 vs 基础 Hook
首先通过观察新版官方教程中 《 脱围机制(Escape Hatches) 》 左侧的目录设计,你会发现 ref 和 Effect 等的介绍都归属于"脱围机制",官方文档也指出了这是一个高级的功能,并且和"脱围机制"这个名字表示的意义一样,官方也说了大多数应用不应该用到这些功能。
然而这和我们日常的感知是截然相反的,我从来没见过哪个用到了 React Hook 的前端页面可以不使用 useEffect 来实现业务功能。为什么 useEfect 不像 useState 一样是被作为一个必要且基础的 Hook 来介绍,而是当作一个貌似 React 初学者可以先不用学习的高级 Hook 来介绍,我不理解。
陡峭的 useEffect 学习曲线
我们继续看官方对于 useEffect 的介绍过程,总共分了五章
-
《 使用 Effect 进行同步 》介绍了 useEffect 正确的使用方法,如何确定 useEffect 依赖项。
-
《 你可能不需要 Effect 》介绍了一些不应该使用 useEffect 的场景。
-
《 响应式 Effect 的生命周期 》分析了 useEffect 回调函数的触发逻辑。
-
《 将事件从 Effect 中分开 》这个标题很怪,其实是介绍了在一类特殊的场景中,如果用原来官方标准的判断 useEffect 依赖的方式去实现,则会遇到功能性问题,为了应对这个场景而介绍了一个还没有发布的实验性 Hook 及其对应的局限性。
-
《 移除 Effect 依赖 》强调了依赖项必须和回调中实际的引用情况保持一致,以及保持了一致却还是出现问题时要如何调整代码和依赖项。
当我把自己想象成一个初学者时,我认为前两章还可以接受,但是再加上后三章,我不得不说相比于老版官方文档,新版文档对于 useEffect 的介绍实在是太详细了,面面俱到,而且如此之多的章节和示例,真的能让大部分开发者学明白吗?
下面这个图真的不只是搞笑的(我现在就认为 useEffect 就是构建屎山的一把利器):
我后来统计了一下官方文档的中文字符数,介绍 useEffect 这一个 Hook 的 5 篇官方中文教程(除去两侧的目录和每篇教程底部的挑战习题)加起来大概有 2.9w 个中文字符。
作为对比本文全文也有大概 3w 个中文字符(快逃!),但是其中介绍 useEffect 替代方案的第四章中文字符还不到 7k,不到官方文档的四分之一,而且还介绍了两个 Hook,平均一个 3.5k,简直物超所值!
用户事件触发的接口请求
React 官方文档中从用途上把 HTTP 接口请求的概念分为了"数据请求"(应该指 GET 请求)和"POST 请求"。
阅读 《 你可能不需要 Effect 》 这个章节时,我对于其中关于**"由用户事件触发的接口请求"的描述和示例产生了 极大的困惑**。其中 如何移除不必要的 Effect、发送 POST 请求、获取数据 这三个小节都提到了相关描述和示例:
对于 如果使用 Effect 来处理用户事件,当一个 Effect 运行时,你无法知道用户做了什么(例如,点击了哪个按钮) 这个说法,我非常非常认同,也就是本文在第三章中"过度的响应式编程"里提到的,使用 useEffect 时会丢失"从用户行为到接口请求"之间的函数调用关系,特别是当页面逻辑变得复杂时会让这个问题成倍地放大。
然而在 获取数据 中,却说分页请求的逻辑放在 useEffect 中没有问题,但是我们之前看过 React 对于 Effect 的定义其实是 side effects that are not triggered by user events,而这里乍一眼看上去就是用户点击下一页等交互操作触发的接口请求:
按照示例中对代码逻辑的分析,我理解完整的代码应该是 query 在父组件中并不是一个 state 状态,用户每次修改关键词后事件处理函数会将其自动填充到当前的 URL 中,路由更新让父组件重新渲染,父组件从 URL 中读取 query 参数并传递给子组件:
typescript
const getUrlParams = () => qs.parse(window.location.search.slice(1));
/**
* 假设 SearchPage 是 SearchResults 的父组件
*/
const SearchPage = (props) => {
const { query = '' } = getUrlParams();
const [searchInputValue, setSearchInputValue] = useState('');
const handleInputSearchChange = (e) => {
const value = e.target.value;
setSearchInputValue(value);
};
const handleSearchBtnClick = () => {
if (!searchInputValue) {
return;
}
const queryString = qs.stringify({
query: searchInputValue,
});
// 🟢 点击搜索时触发路由变化,因为只是更新 query 参数,所以只是触发当前 SearchPage 组件的更新,更新后 getUrlParams 方法读取到新的 query 值
props.history.push(`/search-page?${queryString}`);
};
return (
<>
<div>
<input value={searchInputValue} onChange={handleInputSearchChange} />
<button onClick={handleSearchBtnClick}>Search</button>
</div>
<SearchResults query={query} />
</>
);
};
基于这个设定确实 query 的变化不是由用户事件直接触发的,所以文中 query 的来源不重要 这句话勉强说得通。但是从 handleNextPageClick 触发的翻页逻辑中看出,表示页码的 page 状态并不需要填充到 URL 中,是一个很常规的用户事件直接触发接口请求的逻辑,那么文中 page 的来源不重要 这句话就是**完全错误的****,**因此点击下一页按钮触发的数据请求,就应该放在事件响应函数中,所以代码应该这么写:
typescript
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
const fetchData = (queryStr, pageNo) => {
fetchResults(queryStr, pageNo).then((json) => {
setResults(json);
});
};
useEffect(() => {
fetchData(query, page);
}, [query]); // 只监听来源于 URL 中的 query 变量变化
function handleNextPageClick() {
const targetPage = page + 1;
setPage(targetPage);
fetchData(query, targetPage);
}
// ...
}
**调整过后的代码的功能和原来完全一样,但是你会发现 useEffect 中的依赖项数组中只有 query,而回调函数中却依赖了 query 和 page 两个状态!**所以我怀疑是因为这样写会导致依赖项和 react-hooks/exhaustive-deps 这个 ESLint 规则相冲突,所以官方的这个示例中就把所有的副作用逻辑都实现在了 useEffect 中,因为和 ESLint 规则冲突本质上是和 useEffect 的设计理念相违背了 ,参考《 使用 Effect 进行同步 》和《 useEffect 》中关于开发者应该如何书写依赖项的描述:
不过如果从监听思维来解读修改后的代码是很好理解的:我们要监听 URL 中 query 的变化,一旦 query 变化后,就执行请求逻辑,请求逻辑会用到哪些状态和监听什么并没有直接关系。
所以我个人认为,当你不需要把状态同步到 URL 中时,我们是不应该使用 useEffect 的,补充说明《 你可能不需要 Effect 》中开头说那句话就是:
绝大多数情况下,你不必使用 Effect 来处理用户事件(包括 GET 请求),请优先 将用户事件相关的副作用逻辑写在事件处理函数中***!***
绝大多数情况下,你不必使用 Effect 来处理用户事件(包括 GET 请求),请优先 将 用户事件相关的副作用逻辑写在事件处理函数中!
绝大多数情况下,你不必使用 Effect 来处理用户事件(包括 GET 请求),请优先 将 用户事件相关的副作用逻辑写在事件处理函数中!
然而我发现这么重要的事情,其实并不是 React 开发者们的共同认知,因为在 2018 年 React Hook 发布时的老版的官方文档中也没有提到不应该用 Effect 来处理用户事件,2023 年新版官方文档提出这个观点时,也只是配了这么一个充满迷惑性的 获取数据 示例。
我真的觉得这个事情的重要程度完全值得将 useEffect 改名为我前面所说的 useSideEffectNotTriggeredByUserEvent ,因为 useEffect 这个名字就像有魔法一样,不断地在吸引开发者把所有的副作用逻辑尽可能地放到里面。
更迷惑的是,官方教程还基于这个例子教你把 useEffect 进一步封装成自定义 Hook,并不是这个例子错了,而是这样会进一步引导开发者认为所有的 GET 请求都推荐这么封装:
但是实际业务代码中,很大部分的由用户事件触发的接口请求,并不需要把接口入参同步到 URL 中,更不应该封装成这样的自定义 Hook,因为这样会进一步加大阅读事件响应函数中代码逻辑的难度,并且修改 useEffect 中的代码时难以评估影响面。
所以为什么官方教程不在教程最显眼的位置,直接大大方方用一个代码示例告诉我们,这么做是不推荐的,比如:
typescript
// 如果 query 和 page 都是单纯的 React State
// 🔴 那么下面的示例是不推荐的 !
// 🔴 那么下面的示例是不推荐的 !
// 🔴 那么下面的示例是不推荐的 !
function SearchResults() {
const [results, setResults] = useState([]);
const [query, setQuery] = useState('React');
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 避免:因为 query 和 page 都是单纯的 React State,所以副作用逻辑不应该用 useEffect 来处理
fetchResults(query, page).then((json) => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
function handleQueryInputChange(e) {
const value = e.target.value;
setQuery(value);
}
// ...
}
// 🟡 并且很容易出问题 !
// 🟡 并且很容易出问题 !
// 🟡 并且很容易出问题 !
function SearchResults() {
const [results, setResults] = useState([]);
const [query, setQuery] = useState('React');
const [page, setPage] = useState(1);
const theme = useContext(ThemeContext);
// 如果在请求结束后要用到其他状态,参考 useEffect 官方的依赖项声明规则,需要把这些状态也声明为依赖项
useEffect(() => {
fetchResults(query, page).then((json) => {
setResults(json);
showNotification('Data fetched!', theme);
});
}, [query, page, theme]); // 🔴 BUG:仅 theme 变动时也触发了不必要的接口请求和用户通知
// 解决方案:移除 theme 依赖项,并忽略这个 ESLint 依赖校验
// 但这样会导致后续请求增加状态入参时非常容易遗漏依赖
// eslint-disable-next-line react-hooks/exhaustive-deps
// }, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
function handleQueryInputChange(e) {
const value = e.target.value;
setQuery(value);
}
// ...
}
// 🟢 推荐示例,将首次之外的接口请求,都实现在用户事件中,因为它们都是由用户事件触发的
// 🟢 推荐示例,将首次之外的接口请求,都实现在用户事件中,因为它们都是由用户事件触发的
// 🟢 推荐示例,将首次之外的接口请求,都实现在用户事件中,因为它们都是由用户事件触发的
function SearchResults() {
const [results, setResults] = useState([]);
const [query, setQuery] = useState('React');
const [page, setPage] = useState(1);
const theme = useContext(ThemeContext);
const fetchList = (queryText, pageNo) => {
fetchResults(queryText, pageNo).then((json) => {
setResults(json);
showNotification('Data fetched!', theme);
});
};
const handleNextPageClick = () => {
const targetPage = page + 1;
setPage(targetPage);
// 🟢 查询下一页行为是一个事件,因为它是由特定的交互引起的。
fetchList(query, targetPage);
};
const handleQueryInputChange = (e) => {
const value = e.target.value;
setQuery(value);
// 🟢 输入行为是一个事件,因为它是由特定的交互引起的。
fetchList(value, page);
};
useEffect(() => {
fetchList(query, page);
// 🟡 初始请求很确定只需要在 didMount 时触发,所以这里只能忽略 ESLint 依赖校验
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ...
}
我觉得 React 官方是知道这件事情的,却刻意没有使用代码示例来强调说明。如果前端开发者们能在 2018 年就被明确地告知不应该用 Effect 处理用户事件,估计也不会有今天这篇文章。但 2025 年的今天,仅靠开发规范的约束已经完全无法让开发者们达成 useEffect 使用方式上的共识了(更别提本文第三章提到的各种其他问题)。
为此我又往前看了看官方文档《 使用 Effect 进行同步 》中的另一个 获取数据 的官方示例,这个示例中 useEffect 的依赖项是 userId,可以猜测这确实不是由用户触发的副作用。但是紧接其后的 深入探讨 中,提到了一篇作者为 Robin Wieruch 的博客《 How to fetch data with React Hooks 》,这篇博客就是"完美地"用 useEffect 来处理了由用户事件引起的副作用,最后甚至还额外加了一个 UI 渲染时用不到名为 activeSearch 的 state 作为 useEffect 的依赖项实现点击搜索按钮触发接口请求:
我认为这简直是魔怔了,首先在 UI 渲染中用不到的变量,不应该定义为 state,其次 Effect 的定义是 由渲染自身引起的副作用 ,而 activeSearch 的变化并不会改变 UI 视图,所以这个 useEffect 其实是用监听思维写出来的!
但是官方认为这个示例的问题不在于用 useEffect 来处理了由用户事件引起的副作用,也不在于用监听思维使用 useEffect,而在于直接使用 useEffect 无法支持 SSR、可能会产生网络瀑布、无法进行缓存优化、需要手动处理竞态条件等等:
官方文档推荐了一些支持缓存的请求库,比如 React Query 和 useSWR,然而在将业务中所有的获取数据逻辑都用这些类库来实现时,本质上其实也都是在用 useEffect 来处理由用户事件引起的副作用:
被推荐的方案还有例如 Next.js 等的框架,这些框架支持了 和流式服务器渲染(Streaming Server Rendering)等能力,对服务端渲染的支持上非常好,体现了 React 强大的生态。
不过以上这些优化方案都是有适用场景的,当我们要构建一些有复杂交互逻辑的页面时,往往不需要用到以上的优化手段,我们需要的是先把代码逻辑写得足够清楚!
所以我认为 React 没有在官方文档中着重强调"不应该用 useEffect 来处理由用户事件引起的获取数据类副作用逻辑",是因为解决缓存、服务端渲染等问题的各种优化方案,非常适合用封装了 useEffect 的自定义 Hook 来实现,因此很自然地也顺带将所有的获取数据逻辑(不管是否由用户事件触发)都放到了 Effect 中,即便这和 Effect 的设计理念相冲突。
强烈建议所有开发者评估一下对应的业务形态,如果前端交互逻辑是比较复杂的,并且不需要用到以上这些优化方案,请谨用 useEffect,更建议完整看完本文后考虑下是否要替换 useEffect 这个 Hook,否则随着产品功能的不断叠加,基于 useEffect 构建的页面会大大降低研发效率。
在请求方法中处理竞态条件
获取数据 中也提到了 竞态条件 问题,但是这种场景下肯定是要优先使用防抖 debounce 来处理高频的用户输入事件,一般情况下防抖处理后出现竞态问题的几率就很低了,这也是为什么大多数情况下我们并没有感知到竞态问题的原因。
但如果网络环境长期波动,或者服务端处理请求的时间有较大波动,我们还是要想办法解决竞态问题。同时我们依旧要避免将由用户事件触发的副作用逻辑实现在 Effect 中,所以解决方案就是直接在请求方法中处理竞态条件:
typescript
// 方案 1
// 🟢 给每个请求设定标识符
function SearchResults() {
const requestIdRef = useRef(0);
const fetchList = useCallback(async (queryText, pageNo) => {
const currentRequestId = ++requestIdRef.current;
const json = await fetchResults(queryText, pageNo);
// 只处理最后一次请求的响应
if (currentRequestId === requestIdRef.current) {
setResults(json);
}
}, []);
useEffect(() => {
return () => {
// 组件卸载时修改标识符,处理接口请求在组件销毁后才拿到响应的场景
requestIdRef.current++;
};
}, []);
}
typescript
// 方案 2
// 🟢 利用 AbortController 取消过期请求
function SearchResults() {
const abortControllerRef = useRef(null);
const fetchList = useCallback(async (queryText, pageNo) => {
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const json = await fetchResults(queryText, pageNo, { signal: controller.signal });
setResults(json);
} catch (err) {
if (err.name !== 'AbortError') {
// 处理真实错误
}
}
}, []);
useEffect(() => {
return () => {
// 组件卸载时取消未完成的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, []);
// ...
}
牵强的 useEffectEvent
《 将事件从 Effect 中分开 》这个章节的标题很绕,这一章其实是介绍了一类特殊的场景,如果用原来官方标准的判断 useEffect 依赖的方式去实现,则会遇到功能性问题。为了解决这个问题,官方介绍了一个还没有发布的实验性 API useEffectEvent。
这一类特殊的场景,如果我们用监听思维去使用 useEffect 时,就很好描述:useEffect 要监听的依赖项和 useEffect 回调中实际要使用的依赖项不一致(其实就是本文第三章的"不应该的 WebSocket 性能问题"和"找不出问题的错误用法"中提及过的问题)。
在截图的示例中,开发者的"本意"是只想监听 roomId,但是 useEffect 回调中依赖了一个会不断变化的 props.theme:
-
如果不把 theme 放到 useEffect 依赖中,则会遇到闭包问题,notification 提示中的 theme 可能会是旧的值。
-
如果把 theme 放到 useEffect 依赖中,每次 roomId 没变,只是 theme 变化时也会重新断连和建连并通过 notification 通知用户。
陷入了两难,所以官方引入了一个实验性 API useEffectEvent 来解决这个问题:
从官方文档的介绍来看,我认为 useEffectEvent 的实际功能和 ahooks 中的 useMemoizedFn 没有任何区别,分析 useMemoizedFn 的 源码 可以发现,其实 useMemoizedFn 也是利用了 ref 来保持引用地址不变并在每次组件 update 时更新函数内部实现,从而绕过了闭包问题,即用 useEffectEvent / useMemoizedFn 包裹后函数可以一直访问到最新的 state / props。不过 ahooks 中对 useMemoizedFn 的定位是性能优化,解决闭包问题则推荐用 useLatest 包裹单个的 state 或 props,但是我个人觉得 useMemoizedFn 来解决闭包问题比 useLatest 更合适。
而 useEffectEvent 和 useMemoizedFn 唯一的不同可以通过下一个官方示例可以感受到:
再次引用《 使用 Effect 进行同步 》和《 useEffect 》中提到的关于开发者应该如何书写依赖项的描述:_响应式值包括 props、state 以及所有直接在组件内部声明的变量和函数。_在这个示例中 onVisit 是一个在组件内部声明的变量,却不需要写到 useEffect 依赖中,所以 useEffectEvent 和 useMemoizedFn 唯一的不同就是官方的 ESLint 代码校验会自动忽略由 useEffectEvent 导出的函数,这是一个特例。
在《 将事件从 Effect 中分开 》中还写了一篇注意事项来补充说明了另一个问题(截图中的红字是我个人的解读):
不过我感觉 setTimeout 这个例子很牵强,把 url 和 setTimeout 都放到 useEffectEvent 中不就好了:
typescript
const onVisit = useEffectEvent( => {
setTimeout(() => {
logVisit(url, numberOfItems);
}, 5000); // 延迟记录访问
});
// 依赖不一致,但是功能依旧没问题
useEffect(() => {
onVisit();
}, [url]);
官方文档中提到的 抑制依赖项检查是可行的吗?也说明了我们平常忽略 ESLIint 代码提示可能会造成的问题(不过这个例子也恰恰体现了本文提出的 useInit 解决方案的优势),并给出了最重要的目的:等 useEffectEvent 成为 React 稳定部分后,我们会推荐 永远不要抑制代码检查工具。
在 Effect Event 的局限性 中还提到了两条使用上的约定:
-
只在 Effect 内部调用他们。
-
永远不要把他们传给其他的组件或者 Hook。
虾仁猪心的时候到了,如果你是从头阅读本文的,你可能会感受到:useEffectEvent 的最核心本质不是一个新的 API(单纯要解决闭包问题可以直接使用 useMemoizedFn 这类 Hook),而是我在第三章"难以自动化"的最后提到过的,如果要在构建时自动化计算出 useEffect 依赖,需要所有开发者对现有代码做一个排查并进行一些"标记",标记哪些变量是不需要进行 ESLint 依赖项自动检查的。因为自动化的前提就是有一个固定的规则可以让开发者无脑遵循,之前 "响应式值包括 props、state 以及所有直接在组件内部声明的变量和函数" 这个官方规则在某些场景下是有漏洞的,而 useEffectEvent 则填补了这个漏洞。
不过你应该也能明显地感受到,用 useEffectEvent 来填补这个漏洞非常牵强,因为按照官网说的如何使用 useEffectEvent 这个 Hook 本身就并不无脑,相反你要十分小心地去确保每一个 useEffect 中的依赖项(排除 useEffectEvent 导出的特殊函数)和官方 ESLint 校验规则一致。整件事情让我觉得不是官方 ESLint 插件在帮助开发者提升编码效率,而是在让开发者帮助官方 ESLint 插件提高依赖检查的正确率,可能这也是为什么 useEffectEvent 这个 API 这么久了还一直处于实验性 API 的阶段。
至于 《 将事件从 Effect 中分开 》这个奇怪的标题,其实就是想要通过"响应式的 Effect"和"非响应式的用户事件"这两个基本概念衍生,从设计思想上引导开发者如何正确地使用 useEffectEvent。
但其实 React 在 useEffect 这个 API 上就从来没有做到过统一开发者思想:从 2018 到 2025 年,所有的官方文档在介绍 useEffect 时都没有用到"监听依赖项"这类词汇(并且从 useEffect 入参把回调函数作为第一个参数而不是依赖项作为第一个参数),一直用的是"副作用"、"与外部系统同步"等等的概念。然而每次当我们讨论和 useEffect 有关的代码时,绝大部分人说的都是"监听了 xxx 请求得到 yyy",而不是"yyy 通过网络和 xxx 进行同步"。
所以我认为这是 React Hook 设计非常失败的地方,因为大部分开发者一直习惯用监听思维理解 useEffect,因此导致各种错误的使用方式层出不穷。而 React 官方的解决方案是继续堆叠这类和普通人习惯性认知相冲突的各种概念,妄想通过不断地"说教"来改变开发者的思维方式。
最后额外提一句,useEffectEvent 或 useMemoizedFn 这类 Hook 导出的函数若作为 render props 传递给子组件时,可能会出现子组件无法及时更新的问题:
typescript
import React, { useState, useEffect } from "react";
import { useMemoizedFn } from "ahooks";
const MemoizedPanel = React.memo((props) => {
return (
<>
<h1>
{typeof props.header === "function" ? props.header() : props.header}
</h1>
<p>{props.children}</p>
</>
);
});
const Page = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
const renderHeader = useMemoizedFn(() => {
return `header count: ${count}`;
});
return (
<>
<p>real count: {count}</p>
<br />
<MemoizedPanel header={renderHeader}>
Panel 的内容
</MemoizedPanel>
</>
);
};
export default Page;
关键是这类问题是非常隐性的,如果 Panel 组件不用 React.memo 包裹或者 Page 组件写成下面这样这个问题就被掩盖了:
typescript
const Page = () => {
// ...
return (
<>
<p>real count: {count}</p>
<br />
<MemoizedPanel header={renderHeader}>
<span>Panel 的内容</span>
</MemoizedPanel>
</>
);
};
export default Page;
在类组件中如果将类方法直接作为 render props 传递给 PureComponet 类型的子组件,也会出现这种"渲染优化过度"的情况。不过在函数组件中因为每次渲染时,在组件内定义的函数都是新的引用,其实原本不会出现这类问题。所以使用 useEffectEvent 或 useMemoizedFn时还要注意不要用于 render props 这种场景。其实类组件中定义的所有原型方法如果作为 render props 也会有这个问题,因为原型方法的引用地址也是固定的,所以这也是函数组件优于类组件的一个方面。
声明依赖项增加了复杂度
《 移除 Effect 依赖 》这一章节中强调了依赖项必须和回调中实际的引用情况保持一致,并列举了一些保持一致后还是出问题了的示例及其对应的解决方案,其中部分示例其实在官方文档前序章节都有介绍了。看完这个章节,我除了觉得声明依赖项又复杂又繁琐之外,还想对其中的一个示例提出异议:
这种场景非常非常多,因此忽略 linter 校验的开发者也非常多,而 React 还是一直坚持倡导将 roomId 写到 useEffect 依赖中,我认为将 roomId 写到 useEffect 中反而会让代码丢失开发者的设计意图,即你无法知道这个 roomId 在组件的单次生命周期中到底是会变的还是不会变的。
当然如果 useEffect 保持空依赖 [],当 ChatRoom 这个组件作为一个需要编写详细文档的基础组件时,需要指出 props.roomId 是不是响应式的,即开发者在首次渲染 <ChatRoom /> 时就必须确定 roomId 的值并确保在整个 ChatRoom 组件的生命周期中不会变化。这种情况其实是很常见的,只是大多数此类 Component / Hook 的文档并没有显式地标记出这些属性,比如 ahooks 中的用于生成防抖函数的 useDebounceFn 方法,这个方法中的 wait 等参数就不是响应式的:
typescript
import React, { useState, useEffect } from "react";
import { useDebounceFn } from "ahooks";
const DebounceDemo = () => {
const [debounceWait, setDebounceWait] = useState(100);
useEffect(() => {
setTimeout(() => {
setDebounceWait(1000);
}, 5000);
}, []);
const { run: handleSubmitBtnClick } = useDebounceFn(
() => {
console.log("btn clicked.");
},
{
wait: debounceWait, // 🔴 实际 debounce 的 wait 不会从 100 变为 1000,因为 useDebounceFn 内部只会读取 wait 的初始值
}
);
return <button onClick={handleSubmitBtnClick}>Submit</button>;
};
export default DebounceDemo;
我们在项目中可能还看到过直接使用 lodash debounce 的代码,但是在函数组件中直接使用 debounce 的代码其实都是不健壮的,因为这样实现的防抖功能在迭代中是非常脆弱的。比如将下面这个示例中的 ButtonA -> ButtonB -> ButtonC 想象成的功能的持续迭代过程:
typescript
import React, { useState, useCallback, useMemo } from "react";
import { debounce } from "lodash";
const ButtonA = () => {
const handleBtnClick = debounce(
() => {
console.log("Button A clicked."); // 🟢 防抖成功:用户频繁点击时,确实也只 log 了一次
},
500,
{ leading: true, trailing: false } // 用 debounce 防止用户频繁点击时,一般只需响应第一次用户点击即可
);
return (
<div>
<button onClick={handleBtnClick}>Button A</button>
</div>
);
};
const ButtonB = () => {
const [count, setCount] = useState(0);
// 🔴 防抖失效:每次点击触发函数组件更新,导致每次都生成了新的 handleBtn1Click 防抖函数
const handleBtn1Click = debounce(
() => {
setCount(count + 1);
console.log("Button B1 clicked.");
},
500,
{ leading: true, trailing: false }
);
// 🟢 解决方案:这个场景可以使用 state 更新函数 和 useMemo 避免反复创建新的防抖函数
// React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead.
const handleBtn2Click = useMemo(
() =>
debounce(
() => {
setCount((c) => c + 1); // 改用 state 更新函数
console.log("Button B2 clicked.");
},
500,
{ leading: true, trailing: false }
),
[]
);
// 🟡 为什么不用 useCallback ?
// 因为 react-hooks/exhaustive-deps 的校验规则貌似不支持 debounce 这种高阶函数,会报如下的错误
/*
const handleBtn2Click = useCallback( // React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead.
debounce(
() => {
setCount((c) => c + 1); // 改用 state 更新函数
console.log("Button B2 clicked.");
},
500,
{ leading: true, trailing: false }
),
[]
);
*/
return (
<div>
<button onClick={handleBtn1Click}>Button B1</button>
<button onClick={handleBtn2Click}>Button B2</button>
<span>clicked {count} times</span>
</div>
);
};
const ButtonC = () => {
const [count, setCount] = useState(0);
// 🔴 防抖再次失效:当 handleBtnClick 内的逻辑必须依赖 count 时,useMemo 也无法阻止防抖再次失效
const handleBtnClick = useMemo(
() =>
debounce(
() => {
setCount((c) => c + 1);
if (count < 10) {
console.log("Button C clicked.");
} else {
console.log("Button C was clicked too many times.");
}
},
500,
{ leading: true, trailing: false }
),
[count]
);
return (
<div>
<button onClick={handleBtnClick}>Button C</button>
<span>clicked {count} times</span>
</div>
);
};
const DebounceButtons = () => {
return (
<div>
<ButtonA />
<ButtonB />
<ButtonC />
</div>
);
};
export default DebounceButtons;
所以每当需要在函数组件中创建防抖函数时,我们应该在一开始就使用 useDebounceFn 这类封装好的 Hook 工具。回到 ahooks 的 useDebounceFn 方法,我们来看一下源码:
下面是我尝试在 useDebounceFn 的源码基础上支持所有 options 参数响应式后的满血版 useDebounceFn(代码并没有经过严格的测试,请不要直接用于生产环境):
typescript
import React, { useState, useEffect, useMemo, useRef } from "react";
import _ from "lodash";
import { useLatest, useUnmount, useMemoizedFn } from "ahooks";
type noop = (...args: any[]) => any;
interface DebounceOptions {
wait?: number;
leading?: boolean;
trailing?: boolean;
maxWait?: number;
}
/**
* "满血版" useDebounceFn
*/
function useDebounceFn<T extends noop>(fn: T, options?: DebounceOptions) {
// ...
const fnRef = useLatest(fn);
const wait = options?.wait ?? 1000;
// 🟡 因为 options 在外部必定是字面量形式定义的,导致每次外部组件 update 时 options 的引用地址都变化
// 所以不能直接把 options 放到 useMemo 的依赖项中,需要在 useMemo 外面把所有属性在从 options 解构出来
const { maxWait, leading, trailing } = options || {};
const previousDebouncedFn = useRef<any>(null);
const debouncedFn = useMemo(() => {
const debounced = _.debounce(
(...args: Parameters<T>): ReturnType<T> => {
return fnRef.current(...args);
},
wait,
// 🟡 这里踩了个坑:_.debounce 方法的 options 中各个选项不支持传入 undefined,需要过滤一下
_.omitBy(
{
maxWait,
leading,
trailing,
},
_.isNil
)
);
// 🟡 每当重新生成新的防抖函数时,都先去取消之前的防抖函数的在途延迟任务(这个逻辑应该抽成配置项让用户选择是否开启)
previousDebouncedFn.current?.cancel?.();
previousDebouncedFn.current = debounced;
return debounced;
}, [fnRef, wait, maxWait, leading, trailing]);
useUnmount(() => {
debouncedFn.cancel();
});
const memoizedDebouncedFn = useMemoizedFn(debouncedFn);
const memoizedDebouncedFnCancel = useMemoizedFn(debouncedFn.cancel);
const memoizedDebouncedFnFlush = useMemoizedFn(debouncedFn.flush);
return {
run: memoizedDebouncedFn,
cancel: memoizedDebouncedFnCancel,
flush: memoizedDebouncedFnFlush,
};
}
const DebounceDemo = () => {
const [debounceWait, setDebounceWait] = useState(100);
useEffect(() => {
setTimeout(() => {
setDebounceWait(1000);
}, 5000);
}, []);
const { run: handleSubmitBtnClick } = useDebounceFn(
() => {
console.log("btn clicked."); // 🟢 实际 debounce 的 wait 支持从 100 变为 1000 啦
},
{
wait: debounceWait,
}
);
return <button onClick={handleSubmitBtnClick}>Submit</button>;
};
export default DebounceDemo;
不过这就说明原本 ahooks 官方的 useDebounceFn 这个方法有缺陷吗?我认为完全不是,因为在绝大部分防抖的场景中,wait、maxWait、leading、trailing 这几个 debounce 的参数是不需要变化的(即不需要支持响应式),仅读取初始值完全够用了。在我实现 "满血版" useDebounceFn 的过程中,并不只是无脑让 useMemo 的依赖项和 linter 保持一致即可,还需要额外固定防抖函数的引用地址,以及在 options 参数变化时取消先前的防抖函数对应的在途延迟任务,并且这个代码还没有经过严格的测试,不知道有没有其他 bug。
众所周知,占用相同的测试资源时,系统的稳定性和复杂度必定是负相关的。当一个功能已经可以满足绝大多数场景时,为了兼容极少部分场景而大大增加系统复杂度是需要进行认真评估的,因为增加了复杂度之后,要保证原本的稳定性,需要投入更多的测试成本,所以我们应该优先考虑能否优化或者调整这些少数场景的实现方式,从而降低整个系统的复杂度。
在阅读完所有官网文档中关于 useEffect 章节后,我个人更加确信围绕依赖项的各类问题(如何确定依赖项、使用户事件响应逻辑变得难以阅读、忽略依赖检查引发问题、修改依赖困难、代码噪音等等),就是从类组件过渡到函数组件后引入的最大问题。
3、A Complete Guide to useEffect
那么到底为什么 React Hook 当初要设计 useEffect 这么一个难用的 Hook 呢?并且我也很好奇,难道别的团队也一直没有感受到这些问题吗?所以我又问了 DeepSeek:
前端技术社区里有没有批判 useEffect 的观点或者文章等内容?
DeepSeek 提到了 Dan Abramov 的这篇写于 2019 年的文章《 A Complete Guide to useEffect 》,所以我又尝试从这篇文章中寻找 useEffect 设计的由来:
解释闭包特性
文章的前三个章节 Each Render Has Its Own Xxx 中解释了在函数组件中,因为闭包特性,其实每次渲染中所有的变量(props、state、事件处理程序)都是一次渲染的快照,以及每次渲染过程中都伴随着一次 effect 的执行(当不传递 useEffect 的第二个参数时)。
在快照中访问未来的状态
Each Render Has Its Own... Everything 这个章节中提到了类组件的 setTimeout 中的 this.state.count 会一直取到最新值的问题,并且原文也给出了如何取到点击时的值的解决方案:
类组件中这类代码确实比较脆弱,建议添加上注释表明代码意图,否则在迭代中稍作调整就会意外地读取到最新值/点击时的值:
typescript
class ComponentA extends React.Component {
state = { count: 0 };
handleClick = () => {
setTimeout(() => {
// 🟡 输出的是当前最新的 state.count,而非点击时的值
console.log(this.state.count);
}, 1000);
this.setState({ count: this.state.count + 1 });
};
// ...
}
class ComponentB extends React.Component {
state = { count: 0 };
handleClick = () => {
// 解决方案:利用闭包
const count = this.state.count;
setTimeout(() => {
// 🟡 输出的永远是点击时的 state.count,而不会突变成最新的值
console.log(count);
}, 1000);
this.setState({ count: count + 1 });
};
// ...
}
而函数组件中,默认只能拿到点击时的值,但是当你想要拿到最新值时,你必须不断地将 count 状态额外同步到 ref 中:
typescript
const ComponentA = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
console.log(count); // 🟡 输出的是点击时的值,而非最新值
}, 1000);
setCount(count + 1);
};
return <button onClick={handleClick}>Click {count}</button>;
};
const ComponentB = () => {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count; // 同步最新值到 ref
const handleClick = () => {
setTimeout(() => {
console.log(countRef.current); // 🟡 总是输出最新值
}, 1000);
const c = ;
setCount(count + 1);
};
return <button onClick={handleClick}>Click {count}</button>;
};
所以考虑到代码在迭代中的稳定性,虽然函数组件读取最新值代码会更冗余一点,但是这也将两种模式明显地区分开来了,函数组件确实是更优的设计。
不过文章中还表达了一个观点:_在过去的渲染快照中试图访问未来的状态,是一种逆势而为(Swimming Against the Tide)。_但我认为这两种情况完全没有好坏之分,要拿到最新值还是点击时的值,完全取决于需求目的,两者只是实现功能时不同的选择。而且若按照这个观点去评价的话,那后来提出的 useEffectEvent 这个 API 是不是可以说是官方 逆势而为 的方法了,因为这个 API 本质上也是从历史的渲染快照中访问到未来的状态。
最后,这个话题其实只和闭包有关,与 Effect 关系不大,所以继续看......
同步思维的由来
Synchronization, Not Lifecycle 这一章中介绍了同步概念的由来:
原文提到,相比于 jQuery 需要关注 DOM 操作过程,React 生来就只需要关注渲染结果,即在 React 中重要的是目的地,而不是过程 。而之前类组件中只有 UI 渲染是关注目的地(没有 mount 和 update 的区别,都是直接执行 render 函数),而生命周期这一套则是面向过程的,因为开发者要感知到 mount / update / unmount 这个心智模型。
所以在支持了 Hook 的函数组件中不仅仅是 UI 渲染,所有的事物都将只关注目的地。为此函数组件中 UI 渲染之外的所有逻辑(现在被称为副作用),就像 UI 渲染是 根据 props 和 state "同步" DOM ,剩余的逻辑也需要开发者改成 用根据 props 和 state "同步" React 组件树之外的事物 的心智模型(而不是原本 mount / update / unmount 的心智模型)进行开发。
感觉是统一了 React 组件内所有事物的心智模型:关注目的地、同步。**讲实话还是挺抽象的,充满哲学意味,但是这么做本身的目的是什么呢?只是为了统一中心思想?这样做相比于在函数组件中也搞一套类似 useMount / useUpdate / useUnmount 的生命周期 Hook 有什么额外的优势呢?**不知道,继续看......
setInterval 频繁创建问题
Decoupling Updates from Actions 和 Why useReducer Is the Cheat Mode of Hooks 这两章中介绍了如何使用 useReducer 去解决 setInterval 被频繁创建的问题。
不过我觉得这个方法太绕了,真还不如用 ref 绕过闭包问题,或者使用 useEffectEvent 来得直接,正常人想不到的(对不起,还是忍不住吐槽了)。而且利用 useReducer 的这个方法本质上是把多个状态强行合并成了一个状态,然后继续用 setState 的函数式更新来绕过闭包问题,但是很多业务场景中这些状态就应该是独立的,并不适合合并状态。
把 reducer 定义在函数组件内部来解决 props 的闭包问题,emm...... 确实有种官方大佬下场教你后门骚操作的感觉。
这个话题依旧只和闭包有关,与 Effect 关系也不大,还是继续看......
Just let it throw !
在 Moving Functions Inside Effects 这章中,我感觉我终于找到我想知道的东西了!
因为看到这里之前我的观点和 Dan 截然相反,我认为大部分情况下 We're just "appeasing React",也就是我在本文的第三章的"大量的冗余代码"中提到的感觉:不是 React 在给我们提效,而是我们在给 React 当牛马。但是 Dan 提到了一个很重要的观点:
The design of useEffect forces you to notice the change in our data flow and choose how our effects should synchronize it --- instead of ignoring it until our product users hit a bug.
useEffect 的设计迫使我们要注意到数据流中的变化,并思考我们的 Effect 应该如何同步它,而不是忽略它,否则我们产品的真实用户可能会遇到因此产生的 Bug。
我大概 get 到 Dan 的意思了:如果我们严格遵守 useEfect 的依赖项声明规则,那么我们的系统在应对外部变量的变化时,将会变得非常健壮,因为这能让整个 React 系统都能根据 useEffect 依赖项进行完美的"同步"。我想到一个不恰当的比喻:用 useEffect 这套新的 React 哲学构建出来的高楼,默认能抗八级地震,非常可靠!
然后我顺着这个思路继续想,那我们业务中有没有遇到过类似的因为外部变量变化导致的真线问题呢(因为我们团队显然没有严格遵守 useEfect 的依赖项声明规则,空数组 [] 的依赖项声明随处可见)?我突然想到了一个实际遇到过的问题,背景是有一个项目页面,不同页面状态复用同一个页面级组件,即匹配了多个路由:
typescript
// --------------- ProjectPage.jsx ---------------
import React, { Component } from "react";
// ...
class ProjectPage extends Component {
constructor(props) {
super(props);
this.state = {
// ...
};
}
componentDidMount() {
const { action } = this.props?.match?.params || {};
// 🟡 根据 action 执行不同的初始化逻辑
if (action === "create") {
// ...
} else if (action === "edit") {
// ...
} else if (action === "audit") {
// ...
} else if (action === "detail") {
// ...
}
}
// ... 1000 多行其他逻辑,以及各种子组件引用(部分是函数组件)
}
export default ProjectPage;
// --------------- router.js ---------------
import ProjectPage from "./ProjectPage.jsx";
export default [
{
/**
* 项目 创建/编辑/审核/详情 页面
*
* action 操作类型:create | edit | audit | detail
*/
url: "/project/:action",
component: ProjectPage,
},
];
问题出在同一个项目在不同 action 状态之间跳转的时候:
typescript
// 原本代码中的跳转都是通过刷新浏览器页面的形式进行跳转的
window.location.href = `/base-route/project/${targetAction}`;
// 团队来了一个新成员,认为这是项目内部跳转,应该使用 react-router 进行跳转
props.history.push(`/project/${targetAction}`);
新成员说得很对,但很显然这个 class 组件只在 componentDidMount 中判断了 action,如果使用 react-router 的 History 路由进行跳转,React 会沿用原来的组件实例,导致 targetAction 对应的初始化逻辑不会被执行。当时我们的解决方案是通过定义多个路由和多个页面级组件的方式让 React 无法复用组件:
typescript
// --------------- ProjectPage.jsx ---------------
import React, { Component } from "react";
// ...
class ProjectPage extends Component {
// ...
componentDidMount() {
// const { action } = this.props?.match?.params || {};
const { action } = this.props || {}; // 直接从 props 中读取 action
// ...
}
// ...
}
const withAction = (Comp, action) => {
return (props) => <ProjectPage {...props} action={action} />;
};
// 🟢 利用 HOC 高阶组件创建了四个不同的套壳 Page 组件
export const ProjectCreatePage = withAction(ProjectPage, "create");
export const ProjectEditPage = withAction(ProjectPage, "edit");
export const ProjectAuditPage = withAction(ProjectPage, "audit");
export const ProjectDetailPage = withAction(ProjectPage, "detail");
// --------------- router.js ---------------
import {
ProjectCreatePage,
ProjectEditPage,
ProjectAuditPage,
ProjectDetailPage,
} from "./ProjectPage.jsx";
export default [
{
/**
* 项目创建页面
*/
url: "/project/create",
component: ProjectCreatePage,
},
{
/**
* 项目编辑页面
*/
url: "/project/edit",
component: ProjectEditPage,
},
{
/**
* 项目审核页面
*/
url: "/project/audit",
component: ProjectAuditPage,
},
{
/**
* 项目详情页面
*/
url: "/project/detail",
component: ProjectDetailPage,
},
];
后来我们还发现,react-router 只要路由不同,即使组件相同,也不会复用组件实例,所以还有一个更简单的骚操作,组件源码完全不用改,定义多个路由且依旧保持 action 为动态路由参数,但是每个路由仅限定一个 action 枚举值:
typescript
// --------------- ProjectPage.jsx ---------------
import React, { Component } from "react";
// ...
class ProjectPage extends Component {
// ... 不做任何修改
}
export default ProjectPage;
// --------------- router.js ---------------
import ProjectPage from "./ProjectPage.jsx";
export default [
{
/**
* 项目创建页面
*/
url: "/project/:action(create)", // 🟢 一个路由仅允许一个 action 枚举值
component: ProjectPage,
},
{
/**
* 项目编辑页面
*/
url: "/project/:action(edit)",
component: ProjectPage,
},
{
/**
* 项目审核页面
*/
url: "/project/:action(audit)",
component: ProjectPage,
},
{
/**
* 项目详情页面
*/
url: "/project/:action(detail)",
component: ProjectPage,
},
];
当时在处理这个问题时,我们根本没有考虑过让 ProjectPage 组件及其所有的子组件都监听 action 的变化,使其支持在整个页面的生命周期内支持 action 的变化,原因也非常明显:支持了组件销毁之后的 History 路由切换在体验上也已经比直接修改 location.href 触发整个页面资源的重新加载好了很多。进一步让整个页面完全支持 action 动态变化能够带来的性能受益完全取决于不同 action 之间页面的差异化逻辑有多少,差异化逻辑越多,受益越小。况且这是一个复杂的历史页面,综合考虑下来,这么做的 ROI(投入产出比) 实在是太低了。
但是如果这个页面及其所有子组件都是由一个完全遵守了 useEffect 依赖项声明规则的开发者来实现的,就能完全避免这个问题了!因为整个页面可以完美地根据 action 进行"同步":
typescript
const { action } = props?.match?.params || {}
useEffect(() => {
if (action === "create") {
// ...
} else if (action === "edit") {
// ...
} else if (action === "audit") {
// ...
} else if (action === "detail") {
// ...
}
// }, []); // 🟡 原本的类组件只在 didMount 时才执行的逻辑,相当于欺骗了 Effect 没有依赖项
}, [action, /* ... */]); // 🟢 依赖项声明完全遵守 react-hooks/exhaustive-deps 给出的提示
useEffect(() => {
// ...
}, [/* ... */]); // 🟢 依赖项声明完全遵守 react-hooks/exhaustive-deps 给出的提示
useEffect(() => {
// ...
}, [/* ... */]); // 🟢 依赖项声明完全遵守 react-hooks/exhaustive-deps 给出的提示
// ...
在我 get 到这个意思的时候,我确实是被震撼到了,感觉 useEffect 是非常超前的设计,甚至有几分 上工治未病 的感觉。
但是当我冷静下来仔细思考时,我发现了一个很大的漏洞:有些时候即使我们将某些组件中原本刻意声明了空依赖 [] 的 useEffect 改成真实的依赖项,也无法得到一个"完美的响应式组件"(可以兼容任意 props 在中途变化的组件),因为我们忘了要重置 state 状态。
还是举我们常见的中后台列表查询页的例子,再额外加上一个列表数据批量选择后删除的功能,假设 URL 中的 projectType 在一个页面的生命周期中是不会变化的,代码如下:
typescript
/**
* projectType 从 URL 中的 query 中读取
*
* pageSize 从高阶组件 withPageSize 中获取
*/
const ProjectListPage = (props) => {
const { projectType, pageSize } = props;
const [pageNo, setPageNo] = useState(1);
const [projectList, setProjectList] = useState([]);
const [selectedProjectIds, setSelectedProjectIds] = useState([]);
const fetchProjectList = useCallback(
async (params = {}) => {
const query = qs.stringify({
projectType,
pageSize,
pageNo: params.pageNo ?? 1,
});
const response = await fetch(`/project/list?${query}`);
const json = await response.json();
setProjectList(json);
},
[projectType, pageSize] // 🟢 依赖项声明完全遵守 react-hooks/exhaustive-deps 给出的提示
);
/** 点击每条项目的 checkbox */
const handleProjectItemSelect = (item) => {
const { id } = item || {};
if (selectedProjectIds.includes(id)) {
setSelectedProjectIds(selectedProjectIds.filter((pId) => pId !== id));
} else {
setSelectedProjectIds([...selectedProjectIds, id]);
}
};
/** 点击「批量删除」按钮 */
const handleBatchDeleteBtnClick = () => {
if (selectedProjectIds.length === 0) {
message.warn('请先选择要删除的项目');
return;
}
// 根据 selectedProjectIds 调用批量删除接口
};
const handlePageNoBtnClick = (targetPageNo) => {
setPageNo(targetPageNo);
fetchProjectList({ pageNo: targetPageNo });
};
const handleNextPageBtnClick = () => {
/* ... */
};
const handlePrevPageBtnClick = () => {
/* ... */
};
useEffect(() => {
fetchProjectList();
}, [fetchProjectList]); // 🟢 依赖项声明完全遵守 react-hooks/exhaustive-deps 给出的提示
return <div>{/* ... */}</div>;
};
export default withPageSize(ProjectListPage, 20);
系统正常运行完全没有问题,假设在一个意想不到的场景中,projectType 中途变化了,但是你会发现 pageNo 和 selectedProjectIds 这两个状态并没有重置:
typescript
const ProjectListPage = (props) => {
const { projectType, pageSize } = props;
const [pageNo, setPageNo] = useState(1);
const [selectedProjectIds, setSelectedProjectIds] = useState([]);
/** 点击「批量删除」按钮 */
const handleBatchDeleteBtnClick = () => {
if (selectedProjectIds.length === 0) {
message.warn('请先选择要删除的项目');
return;
}
// 根据 selectedProjectIds 调用批量删除接口
};
// ...
useEffect(() => {
// 如果 URL 中的 projectType 变化了,fetchProjectList 确实重新会重新执行请求到第一页的数据
// 🔴 但页面会有如下问题:
// 1、最严重的就是 selectedProjectIds 没有清空,可能导致用户错误删除页面中没有显示出来的项目
// 2、页面上展示的是新的 projectType 的第 1 页的数据,但是页面上的翻页器中可能展示的并不是第 1 页
fetchProjectList();
}, [fetchProjectList]);
return <div>{/* ... */}</div>;
};
其中 selectedProjectIds 没有清空的问题甚至让我觉得还不如 useEffect 的依赖中写个空数组,因为那样虽然 projectType 变化之后列表数据完全不会变化,但是至少不会导致用户误删数据。所以我感觉用 useEffect 这套新的 React 哲学构建出来的高楼,其实并抗不住八级地震,反而更像是一座非常昂贵(需要开发者维护好所有依赖项)但却一碰就碎的水晶琉璃塔(纯粹个人观点)。
然后当我试图在这个代码的基础上修复这两个问题时,发生了一个更诡异的事情,我不自觉地写出了一个完全错误的 useEffect 示例:
typescript
/**
* projectType 从 URL 中的 query 中读取
*
* pageSize 从高阶组件 withPageSize 中获取
*/
const ProjectListPage = (props) => {
const { projectType, pageSize } = props;
const [pageNo, setPageNo] = useState(1);
const [projectList, setProjectList] = useState([]);
const [selectedProjectIds, setSelectedProjectIds] = useState([]);
const fetchProjectList = useCallback(
async (params = {}) => {
const query = qs.stringify({
projectType,
pageSize,
pageNo: params.pageNo ?? 1,
});
const response = await fetch(`/project/list?${query}`);
const json = await response.json();
setProjectList(json);
},
[projectType, pageSize]
);
// ...
useEffect(() => {
// 🟡 不能这样修复,因为每当 projectType 或 pageSize 变化时会触发这个 Effect,而 pageSize 变化时不需要重置 state 状态
// PS: 这个示例中的 pageSize 可能不那么贴合业务场景,我想表达的其实是很多组件中这类原本只需在 didMout 时执行的 Effect 其实会有很多依赖项
// setPageNo(1);
// setProjectList([]);
// selectedProjectIds([]);
fetchProjectList();
}, [fetchProjectList]);
useEffect(() => {
// 所以我们必须明确只在 projectType 变化时进行 state 状态重置
setPageNo(1);
setProjectList([]);
selectedProjectIds([]);
}, [projectType]); // 🔴 但是这么做的话依赖项声明就完全不符合 useEffect 依赖项声明规则,根据 Effect 中的代码,应该声明空数组 [] 依赖
return <div>{/* ... */}</div>;
};
export default withPageSize(ProjectListPage, 20);
你可能还会发现,我在第二个 useEffect 中实现的其实并不是副作用逻辑,setState 类的方法是完全属于 UI 渲染的逻辑,因为第二个 useEffect 是我用监听思维写出来的!但是问题确实是被我修复了。
这貌似形成了一个悖论,当我想通过完全遵守 useEffect 依赖项声明规则来得到一个"完美的响应式组件"时,我却依赖了一个完全不遵守依赖项声明规则的 useEffect......我认为这是 Effect "同步"心智模型中非常大的漏洞。
抛开这个悖论,当我们再来分析一下做完全做到遵守 useEffect 依赖项声明规则这个事情,其实是非常难的,并且非常脆弱的,综合考量下来的 ROI 也是非常低的:
-
整个前端工程,包括所有依赖的二方包、三方包中的所有代码都要遵守 useEffect 依赖项声明规则,首先涉及的代码范围非常广,三方包甚至是不可控的,其次在每个组件中去关注所有 Effect 中的数据流变化,是非常耗费开发人员心智的,极端的例子就是前面提到过的"满血版" useDebounceFn,常见的例子就是这里的列表批量删除。
-
目前的正式版 React 对应的 useEffect 依赖项声明规则是有漏洞的,但若真的在正式版本中引入了 useEffectEvent 这个 API,对开发者的心智负担又会大大增加,加上原本就有很多人在用监听思维使用 useEffect,可想而知 eslint-disable-next-line 的情况依旧会很多,这是典型的破窗效应。
-
Dan 在后续的 Are Functions Part of the Data Flow? 这一章中也说了,如果整个工程中存在类组件,类组件的原型方法因为引用地址是固定的(我们前面提到过的 render props 问题也是这个原因),将类方法传递给子组件的 useEffect 依赖项时,是对这种数据流的一种破坏,即类组件会让这个模式在迭代中变得非常脆弱,因此整个前端工程包括所有依赖的二方包、三方包最好不包含任何类组件。
再回到刚才的示例,事实上:
-
若 projectType 变化时,要重置组件内的所有 state,那我们应该在父组件中(这里虽然没有父组件但也可以设计一个例如 withProjectType 的高阶组件)给当前组件设置 key={projectType} 使 ProjectListPage 组件在 projectType 变化时自动销毁并创建新组件(具体参考《 当 props 变化时重置所有 state 》和《 有 key 的非可控组件 》)。
-
若 projectType 变化时,只需要组件内的部分 state,那你只能写一个上方示例中的监听思维的 useEffect 去重置部分 state,或者重构 ProjectListPage 组件,将需要重置的 state 和不需要重置的 state 分别收敛到不同的子组件中,并给中一个子组件设置 key={projectType} 实现部分 state 的重置。
然而不管是哪种情况,是否遵守 useEffect 的依赖项声明规则并没有什么差别,或者说遵守了 useEffect 的依赖项声明规则并不能提前预防问题的发生,甚至可能帮倒忙(在上面的示例中,增加了用户误删数据的可能性)。
在我们的 useInit 方案中,如果设计上不需要过考虑 projectType 的变化,那就不用特意去关注什么数据流的变化:
typescript
const ProjectListPage = (props) => {
// ...
// 🟢 最简单的函数定义,永远不需要 useCallback,永远不用关心函数实现中的依赖项
const fetchProjectList = async (params = {}) => {
const query = qs.stringify({
projectType,
pageSize,
pageNo: params.pageNo ?? 1,
});
const response = await fetch(`/project/list?${query}`);
const json = await response.json();
setProjectList(json);
};
// ...
// 🟢 代码可以"自说明":仅需在初始化的时候自动执行一次 fetchProjectList 即可,依旧不必关心什么依赖项
useInit(() => {
fetchProjectList();
});
return <div>{/* ... */}</div>;
};
所以我理想中的研发流程是这样的:
-
首先在平时开发时,对于一个组件的 props 属性(以及其他外部系统的变量),哪些是会变的,哪些是不会变的,我们要有明确的预期(根据产品需求制定技术方案的时候要确定),根据这个预期去写代码:若 props 已明确不会变化,那么对应的逻辑则只需要事先在 didMount 中;若 props 是明确会变化的,则需要用合适的方法去兼容这种变化。
-
如果遇到原本预期不会变化的 props 出现了变化,则需要分两种情况:若分析后认为该 props 确实应该变化,说明技术方案制定得有问题,需要重新执行 1 的流程;若认为该 props 不应该变化,则应该调整代码让这个 props 不再变化。
至于如何兼容 props 属性(以及其他外部系统的变量)的变化,在本文第四章的"谨慎使用 useWatch"中已经有详细的说明,监听思维(即列表查询示例中用于重置状态的 useEffect)应该是我们最后的手段。
这其实就是原本的类组件的研发习惯,因为这很符合开发者的直觉,原本的类组件研发中我们并没有感知到这个点是非常严重的痛点问题,类组件的问题主要还是在于逻辑复用困难以及围绕 this 出现的一堆"麻烦事",所以在 React Hook 中花费这么多精力去维护依赖项是很没有道理的,这不仅没有带来什么收益,反而引来了另一堆围绕依赖项的"麻烦事"。
最后再次明确我的观点:当我们已经在正向研发环节做好了代码设计这个环节的工作,并不需要提前去关注所有组件的数据流变化,而应该基于实际需求去关注数据流变化,不要过于担心因为数据流变化而引发意想不到的问题。万一问题真的发生了:
Just let it throw !
竞态条件
在 Speaking of Race Conditions 这一章中提到了 useEffect 如何处理竞态条件,但是这个问题在类组件中也是一样可以处理的。前面我们已经介绍了在请求方法中使用 标识符 和 AbortController 这两种实现方式处理竞态条件,在类组件中只需将 ref 换成 this 即可。所以这一点上也并不能体现 Effect 的"同步"模型优于生命周期模型。
"更上一层楼"
看到 Raising the Bar 这一章时,我感觉虽然这一章没有出现任何代码,但是这里说的全部都是重点中的重点!也说明了 React 核心团队成员们其实也很清楚我前面所说的这些关于 useEffect 的情况。
-
试图使用 useEffect 对应的"同步"思维去处理所有理论上的边缘情况,这个前期成本非常高(需要耗费开发者非常多的额外精力),并且相比于那种只在 didMount 时执行一次的副作用逻辑,要困难得多。
-
官方对 useEffect 的定位其实是一个底层的 API,在 React Hook 发展的初期可能在各种教程中频繁出现,并且开发者平时也会用到,但是随着社区生态的发展,对于大多数人而言 useEffect 应该是一个使用频率非常非常低 Hook,业务代码中应该使用进一步封装过后的更高级的 Hook 来实现各种功能。
-
目前为止开发者们使用 useEffect 最多的场景还是声明空数组 [] 依赖项,然后在回调中进行数据接口的请求,这是完全违背"同步"理念的。
在空数组 [] 依赖项的 useEffect 中获取接口数据的情况,直到现在 2025 年也依旧还是很常见,所以有没有一种可能不是 React 用户有问题,而是这个"同步"的设计理念本身就是在 逆势而为 ?
依靠社区生态的发展逐渐降低 useEffect 使用频率的这个观点,让我想起了 React 对自己的定位,从始至终 React 都认为自己只是一个用于构建 UI 界面的库,而不是一个框架:
所以副作用除了作为函数式编程中的一个概念,还可以认为 React 这个库本身并不应该负责 UI 渲染之外的所有逻辑,useEffect 只是 React 干的"副业"。但是说 React 是一个普通的库也不对,负责 UI 渲染的库是整个前端技术栈选型的基础,只要选择了 React,在 UI 渲染之外的其他技术需求,也只能在 React 生态中选择合适的三方库,因为不同的 UI 渲染库对于副作用的支持形式是不一样的(在 React 中原来是组件生命周期,现在是 useEffect)。所以 useEffect 更像是一些大公司的开放平台给各种 ISV 公司提供的 API,并不是应该让用户直接使用的能力!
那么使用 useInit / useWatch 完全替代 useEffect 也是非常合理了,只不过我们还把 Effect 副作用和"同步"的理念也一锅端了,有点倒反天罡、欺师灭祖的味了......
六、useInit 方案的设计由来
首先这两个替代 Hook 的设计思路,肯定是站在了 useEffect 这个巨人的肩膀上,例如清理函数(cleanup callback)的设计让两个 callback(setup 和 cleanup)天然就有了共享的私有作用域,非常巧妙。
在此之外,本章会简单讲述一下我们新的 React Hook 编码范式的其他设计由来,帮助大家更好的理解用 useInit 和少量 useWatch 替代 useEffect 的这套范式。
1、忘不掉的 Class 模型
当我们在学习 React Hook 的时候,有一个非常重要的观点:我们要先学会忘记类组件和生命周期,因为之前的学习经验会阻碍你进一步学习。我们要重新用同步思维取代生命周期的概念,告诉 React 如何对比 Effects,以及不要对 Dependencies 撒谎......
"Unlearn what you have learned." --- Yoda
我们可以尝试忘记类组件、忘记生命周期,但是 React Hook 必须要确保当我们已经在用新的思维写代码时,在大部分场景下实现相同的功能,都应该优于类组件和生命周期。可就我观察到的事实,并非如此,所以我认为 Class 这个模型,包括生命周期等的概念本身是非常适合用在 UI 组件这个场景中的,不能因为 this 和复用代码的问题,就忘记了它的优点。
"If you don't know where you're from, you'll never know where you're going." --- 鲁迅
言归正传,比如当我们设计 state 的时候,有一个原则是只把渲染过程需要用到的变量作为 state,那么有时候一些渲染过程用不到的,不需要响应式的变量定义在哪里呢?在类组件中,我们可以直接定义在实例上,我觉得这非常合理,比如之前的定时器示例:
typescript
class Demo extends React.Component {
constructor(props) {
super(props);
// 🟢 直接在 this 实例上定义一些非响应式的值
this.renderCount = 0;
};
}
在函数组件中,我们需要用 ref 来做类似的事情,比如在项目列表页点击某一条数据的"分派"按钮,先唤起一个通用弹框进行分派目标的选择,然后调用接口进行分派:
typescript
const projectInfoRef = useRef(null);
const handleAssignBtnClick = (row) => {
projectInfoRef.current = row;
setCommonSelectModalVisible(true);
};
const handleCommonSelectModalOk = async (personInfo) => {
setCommonSelectModalVisible(false);
const projectInfo = projectInfoRef.current;
// ......(根据 projectInfo 和 personInfo 发送分派接口请求)
};
const handleCommonSelectModalCancel = () => {
setCommonSelectModalVisible(false);
};
我认为 projectInfoRef 这个语义是不好的,因为 ref 的原本表示的是一个组件的引用,只看命名可能会觉得这是 <ProjectInfo /> 组件的引用。而且每次对其赋值和读取的时候都要用到 current 这一层没有任何语义的"命名空间"。所以我写了一个自定义 Hook 来模仿类组件中的 this 实现更好的语义化:
typescript
const useInstance = <T extends {}>(defaultParams: T): T => {
const ref = useRef(defaultParams);
return ref.current;
};
改写上述例子之后代码结构没有变化,语义会更好一些:
typescript
// 在这个用法中 instance 就等同于类组件中的 this 可以挂载自定义实例属性
const instance = useInstance({ projectInfo: null });
const handleAssignBtnClick = (row) => {
instance.projectInfo = row;
setCommonSelectModalVisible(true);
};
const handleCommonSelectModalOk = async (personInfo) => {
setCommonSelectModalVisible(false);
const projectInfo = instance.projectInfo;
// ......(根据 projectInfo 和 personInfo 发送分派接口请求)
};
const handleCommonSelectModalCancel = () => {
setCommonSelectModalVisible(false);
};
并且当你深入了解过社区里的各种函数组件闭包解决方案时,你会发现所有的方案其实都离不开 useRef,在这些场景中 ref 并不是指原生组件或者自定义组件的引用(reference),而是更像 class 组件中不需要触发响应式更新且没有闭包问题的实例属性。
2、类组件 vs 函数组件
在团队内试行这套新的编码范式之前,我详细对比并分享过类组件和函数组件的优缺点:
很明显,支持了 Hook 之后的函数组件在解决了类组件的问题之后,却带来了新的问题。导致很多原来在类组件中我们可以直接写原生代码就能快速实现的功能,在函数组件中变得十分复杂,以至于我们需要依赖一些三方 Hook 工具来协助完成这些功能。
比如防抖功能在类组件中可以直接使用 loadsh 的 debounce 方法实现,但是在函数组件中我们必须使用 useDebounceFn 这类工具 Hook 来完成。拥抱函数组件后,闭包问题等从根本上是无法避免的,所以我们应该设计一套能让开发者能尽可能避开这些问题的基础 Hook,而 useEffect 设计不仅没能让我们避开这些问题,还引入了更恶心的依赖项管理问题。
所以我们的设计思路就是把两者的优点都结合起来,把缺点都规避掉,扬长避短。
3、繁简一致的编码思路
在使用 useEffect 时,我需要不断地和新加入我们团队的成员沟通如何用好 useEffect,然而在 useInit 方案中,我根本不再需要花费精力去做沟通这么基础的事情。新的 useInit 方案也只是在原本的 React Hook 基础上去掉副作用的概念,只是保留了组件实例的心智模型,以及初始化和监听的概念(勉强算简化后的生命周期)。
在习惯了这个方式写代码时,除了第四章中提到的可以极大地避免依赖项声明、闭包问题、不必要的 useCallback 和 useMemo,还有一个关键的优势:**不论遇到什么场景,写代码的思路都是一样的。**当遇到《 将事件从 Effect 中分开 》中提到的场景(类似第四章中的"useEffect 解决不了的问题")时,用我们的方案去写代码,和其他任何常规的场景没有差别,更不需要引入一个新的 API,就像在类组件中这些场景原本就不是什么特殊情况:
typescript
function ChatRoom({ roomId, theme }) {
const handleConnectedEvent = () => {
showNotification('Connected!', theme);
};
/**
* 和 useInit 一样,当使用了 useWatch 的第三个参数 funcMap 时,回调中的逻辑就应该只剩下非常少量的两类代码:
* 1、设置各类监听器(addEventListener / WebSocket / setInterval 等等),并把 self 上的方法直接作为处理函数
* 2、清理对应的监听器
*/
useWatch(
[roomId],
(self) => {
// 设置监听器
const connection = createConnection(serverUrl, roomId);
connection.on('connected', self.handleConnectedEvent);
connection.connect();
return () => {
// 清理监听器
connection.disconnect();
};
},
{
handleConnectedEvent,
}
);
return <h1>Welcome to the {roomId} room!</h1>;
}
typescript
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useWatch([url], () => {
logVisit(url, numberOfItems);
});
// ...
}
不过还是要再次提醒,即使这个代码实现起来很简单,但是在使用 useWatch 前,还是要参考第四章中"谨慎使用 useWatch" 里提到的方式去判断能否优先采用其他实现方案,因为其他实现方案相比于监听方案是更易维护的。
并且我们的方案也没有类似 Effect Event 的局限性 中提到的问题,因为 useInit 和 useWatch 在用 ref 解决闭包问题时,将"闭包消除魔法"的生效范围很好地控制在了 useInit 和 useWatch 中。
4、防呆设计
在我们团队对 useEffect 最初的讨论中,就认为 useEffect 太灵活了,看似门槛很低,其实非常容易玩脱,即 useEffect 大大提高了写出好代码的门槛,也正因为其灵活性,导致无法做到本文开头说的两个点:保持代码结构一致性 和 代码意图的正确传达。如果让我对 useEffect 的问题做一个抽象的总结,我会这么说:
useEffect 看似很简单,然而要用好 useEffect 则对开发者的要求很高。就像你完全可以把自动挡汽车当作碰碰车开上路,但是如果每个人都只会油门刹车却不懂交通规则、不懂防御性驾驶,整个城市的交通会迅速陷入瘫痪。并且很多开发者其实并没有注意到使用 useEffect 还要注意这么多规则,这很可能是因为大多数时候这些额外的规则并没有带来对应的额外收益。
前面提到过 Dan 认为 useEffect 的设计迫使我们要注意到数据流中的变化,从而能提前针对数据变化建立防御性措施,这其实属于系统稳定性的话题。即使这么做真的能带来稳定性收益,很多开发者依旧都没有完全遵守官方 ESlint 插件来写代码,我认为这其中有一个重要的设计原因,就是这个 useEffect 的设计中没有任何防呆(Fool-proofing)措施:首先 react-hooks/exhaustive-deps 这个 ESLint 校验规则不防呆,不仅仅因为类组件生命周期的概念其实依旧深入人心,导致大多数开发者都是靠自己的感觉来决定要不要参考这个校验规则,而且本身这套校验规则就有漏洞,对应的 useEffectEvent 解决方案也还只是实验性 API;其次 useEffect 到底应该用在哪些场景,不应该用在哪些场景,这也非常不防呆,你需要阅读完官网 5 篇总计 3w 字的教程才可能搞明白......
在工业生产中、在生活中,各种领域都有很多优秀的防呆设计:
-
电脑的固态、内存条上都有防止反向错误安装而设计的非对称缺口。
-
所有手机的关机都需要长按或二次确认以防误触。
-
各种场景中的显著标识:红色表示紧急,绿色表示通行等等。
-
......
防呆设计通过一些限制手段或者显著标识来降低一个行为对于操作人精力、经验与专业知识的要求,从而达到预防错误的目的。不论新手、老手;不论你头脑清醒,还是疲惫不堪;不论是繁忙的并行工作,还是安静的专注工作,都能依靠防呆设计正确完成任务。
回到 useInit 的设计,就像 initialization 这个单词表示的意思一样,你只能应该放一些组件初始化时的逻辑,并且因为就像 componentDidMount 一样,useInit 的 callback 只会执行一次,任谁也玩不出花来。而 useWatch 就是最后的兜底手段,用监听思维去实现一些逻辑,不过遗憾的是我们也只能通过一些显著的约定来告诉开发者,这是一个 danger 的 Hook,要谨慎使用。
Over,感谢阅读!