为什么 React Hooks 依赖调用顺序?
December 13, 2018
在 React Conf 2018 上,React 团队发布了 Hooks 提案。
如果你想了解什么是 Hooks 以及它们解决了哪些问题,可以观看我们关于 Hooks 的介绍演讲以及我写的后续文章,里面解答了一些常见误解。
很可能你一开始并不会喜欢 Hooks:
它们就像一张需要多听几遍才会喜欢上的唱片:
阅读文档时,千万不要错过最重要的那一页,讲的是如何构建你自己的 Hook!太多人纠结于我们某些观点(比如“学习 class 很难”),却忽略了 Hooks 背后的更大图景。更大的图景是:Hooks 就像函数式 mixin,让你可以创建并组合属于你自己的抽象。
Hooks 受到了前人工作的影响,但在 Sebastian 向团队分享这个想法之前,我还没见过完全类似的东西。不幸的是,人们很容易忽略具体 API 选择与这种设计所带来的宝贵特性的联系。写这篇文章,是希望能帮助更多人理解 Hooks 提案中最具争议部分背后的理由。
接下来的内容假设你已经了解了 useState()
Hook 的 API 以及如何编写自定义 Hook。如果不了解,请先阅读前面的链接。另外,请记住 Hooks 目前还是实验性的,你现在不一定非得学会它们!
(免责声明:这是一篇个人文章,不一定代表 React 团队的观点。文章内容较长,话题也很复杂,难免有疏漏之处。)
当你第一次了解 Hooks 时,最让人震惊、也可能是最大的问题,就是它们依赖于每次重新渲染时调用顺序的持久索引。这会带来一些影响。
这个决定显然很有争议。因此,违背我们的一贯原则,我们直到觉得文档和演讲已经足够清晰、能让大家公平评判时才发布了这个提案。
如果你对 Hooks API 设计的某些方面感到担忧,建议你阅读 Sebastian 对 1000+ 条 RFC 评论的完整回应。 这份回应内容详实但也很密集,我大概能把里面的每一段都写成一篇博客。(事实上我已经写过一次了!)
今天我想重点关注其中的一个具体部分。你可能还记得,每个 Hook 在一个组件中可以多次使用。例如,我们可以通过多次调用 useState()
来声明多个状态变量:
function Form() {
const [name, setName] = useState('Mary'); // 状态变量 1
const [surname, setSurname] = useState('Poppins'); // 状态变量 2
const [width, setWidth] = useState(window.innerWidth); // 状态变量 3
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
function handleNameChange(e) {
setName(e.target.value);
}
function handleSurnameChange(e) {
setSurname(e.target.value);
}
return (
<>
<input value={name} onChange={handleNameChange} />
<input value={surname} onChange={handleSurnameChange} />
<p>Hello, {name} {surname}</p>
<p>Window width: {width}</p>
</>
);
}
注意,我们用数组解构语法给 useState()
的状态变量命名,但这些名字并不会传递给 React。实际上,在这个例子中,React 会把 name
当作“第一个状态变量”,surname
当作“第二个状态变量”,以此类推。它们的调用索引才是让它们在多次渲染之间保持身份稳定的关键。这种思维模型在这篇文章中有很好的描述。
表面上,依赖调用顺序感觉不对劲。直觉是有用的信号,但有时也会误导我们——尤其是当我们还没有真正理解所要解决的问题时。本文将列举几种常见的 Hooks 替代设计,并展示它们为何行不通。
本文不会面面俱到。根据你如何细分,我们见过从十几种到上百种不同的替代提案。过去五年里我们也一直在思考各种组件 API 的可能性。
写这种博客很难,因为即使你列举了一百种替代方案,总有人能再变个花样说:“哈,你还没想到这个!”
实际上,不同的替代提案往往在缺陷上是重叠的。与其枚举所有建议的 API(那得花我几个月),不如用具体例子展示最常见的缺陷。把其他可能的 API 按这些问题归类,可以留给读者自己思考。🧐
这并不是说 Hooks 完美无缺。 但当你熟悉了其他方案的缺点后,可能会发现 Hooks 的设计其实挺有道理。
缺陷一:无法提取自定义 Hook
令人惊讶的是,许多替代提案根本不支持自定义 Hook。也许我们在“动机”文档里对自定义 Hook 的强调还不够。只有当大家理解了底层原理,才容易体会到它的意义,这就像鸡和蛋的问题。但自定义 Hook 基本上就是这个提案的核心。
比如,有一种替代方案禁止在组件中多次调用 useState()
,而是让你把所有状态放在一个对象里。这种方式在类组件中没问题,对吧?
function Form() {
const [state, setState] = useState({
name: 'Mary',
surname: 'Poppins',
width: window.innerWidth,
});
// ...
}
需要说明的是,Hooks 确实允许你这样写。你不必把状态拆成一堆变量(参见我们在 FAQ 里的建议)。
但支持多次调用 useState()
的意义在于,你可以把部分带状态的逻辑(状态 + 副作用)提取到自定义 Hook 里,这些 Hook 也可以独立地使用本地状态和副作用:
function Form() {
// 在组件体内直接声明一些状态变量
const [name, setName] = useState('Mary');
const [surname, setSurname] = useState('Poppins');
// 把部分状态和副作用移到了自定义 Hook
const width = useWindowWidth();
// ...
}
function useWindowWidth() {
// 在自定义 Hook 里声明一些状态和副作用
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
// ...
});
return width;
}
如果你只允许每个组件调用一次 useState()
,那么自定义 Hook 就无法引入本地状态。而这正是自定义 Hook 的意义所在。
缺陷二:命名冲突
有一种常见建议是让 useState()
接受一个 key 参数(比如字符串),用来唯一标识组件内的某个状态变量。
这种想法有不少变体,大致像这样:
// ⚠️ 这不是 React Hooks 的 API
function Form() {
// 给 useState() 传递某种状态 key
const [name, setName] = useState('name');
const [surname, setSurname] = useState('surname');
const [width, setWidth] = useState('width');
// ...
这样做试图避免依赖调用索引(显式 key 看起来不错!),但又引入了另一个问题——命名冲突。
当然,你大概率不会在同一个组件里两次调用 useState('name')
,除非手滑。但我们可以说,任何 bug 都有可能偶然发生。更关键的是,当你写自定义 Hook时,很可能会增删状态变量和副作用。
采用这种方案,每次你在自定义 Hook 里添加新状态变量,都有可能破坏使用它的组件(无论是直接还是间接),因为这些组件可能已经用过同样的名字来声明自己的状态变量。
这就是一个没有为变化优化的 API。当前代码也许总是“优雅”的,但对需求变化极其脆弱。我们应该吸取教训。
实际的 Hooks 提案通过依赖调用顺序解决了这个问题:即使两个 Hook 都有名为 name
的状态变量,它们也是彼此隔离的。每次 useState()
调用都会获得自己的“内存单元”。
当然还有别的办法可以绕过这个缺陷,但它们也有自己的问题。我们接下来再详细探讨。
缺陷三:无法多次调用同一个 Hook
“带 key 的 useState”方案的另一个变体是用 Symbol 之类的东西。这样就不会冲突了,对吧?
// ⚠️ 这不是 React Hooks 的 API
const nameKey = Symbol();
const surnameKey = Symbol();
const widthKey = Symbol();
function Form() {
// 给 useState() 传递某种状态 key
const [name, setName] = useState(nameKey);
const [surname, setSurname] = useState(surnameKey);
const [width, setWidth] = useState(widthKey);
// ...
这种方案在提取 useWindowWidth()
Hook 时似乎没问题:
// ⚠️ 这不是 React Hooks 的 API
function Form() {
// ...
const width = useWindowWidth();
// ...
}
/*********************
* useWindowWidth.js *
********************/
const widthKey = Symbol();
function useWindowWidth() {
const [width, setWidth] = useState(widthKey);
// ...
return width;
}
但如果我们尝试提取输入处理逻辑,就会出错:
// ⚠️ 这不是 React Hooks 的 API
function Form() {
// ...
const name = useFormInput();
const surname = useFormInput();
// ...
return (
<>
<input {...name} />
<input {...surname} />
{/* ... */}
</>
)
}
/*******************
* useFormInput.js *
******************/
const valueKey = Symbol();
function useFormInput() {
const [value, setValue] = useState(valueKey);
return {
value,
onChange(e) {
setValue(e.target.value);
},
};
}
(我承认这个 useFormInput()
Hook 本身用处不大,但你可以想象它处理类似 Formik 的校验、脏状态等。)
你能发现 bug 吗?
我们调用了两次 useFormInput()
,但 useFormInput()
总是用同一个 key 调用 useState()
。实际上等价于:
const [name, setName] = useState(valueKey);
const [surname, setSurname] = useState(valueKey);
于是又发生了冲突。
实际的 Hooks 提案没有这个问题,因为每次调用 useState()
都会获得自己独立的状态。 依赖持久的调用索引,让我们不必担心命名冲突。
缺陷四:钻石问题
严格来说,这和上一个缺陷是同一个问题,但因为它很有名,值得单独说说。它甚至在维基百科上有描述。(有时还叫“死亡钻石”——听起来挺酷。)
我们自己的 mixin 系统也遇到过这个问题。
比如,两个自定义 Hook(如 useWindowWidth()
和 useNetworkStatus()
)可能都想在内部用同一个自定义 Hook(比如 useSubscription()
):
function StatusMessage() {
const width = useWindowWidth();
const isOnline = useNetworkStatus();
return (
<>
<p>Window width is {width}</p>
<p>You are {isOnline ? 'online' : 'offline'}</p>
</>
);
}
function useSubscription(subscribe, unsubscribe, getValue) {
const [state, setState] = useState(getValue());
useEffect(() => {
const handleChange = () => setState(getValue());
subscribe(handleChange);
return () => unsubscribe(handleChange);
});
return state;
}
function useWindowWidth() {
const width = useSubscription(
handler => window.addEventListener('resize', handler),
handler => window.removeEventListener('resize', handler),
() => window.innerWidth
);
return width;
}
function useNetworkStatus() {
const isOnline = useSubscription(
handler => {
window.addEventListener('online', handler);
window.addEventListener('offline', handler);
},
handler => {
window.removeEventListener('online', handler);
window.removeEventListener('offline', handler);
},
() => navigator.onLine
);
return isOnline;
}
这是一个完全合理的用例。自定义 Hook 的作者应该可以随意开始或停止使用另一个自定义 Hook,而不用担心它是否在调用链的其他地方已经被用过。 事实上,你永远无法知道完整的调用链,除非你每次变更都审查所有用到你 Hook 的组件。
(作为反例,旧版 React 的 createClass()
mixin 就不允许这样。有时你会遇到两个 mixin 都正好做了你需要的事,但由于都继承了同一个“基类” mixin,导致它们互不兼容。)
这就是我们的“钻石”:💎
/ useWindowWidth() \ / useState() 🔴 冲突
Status useSubscription()
\ useNetworkStatus() / \ useEffect() 🔴 冲突
依赖持久调用顺序可以自然地解决这个问题:
/ useState() ✅ #1. 状态
/ useWindowWidth() -> useSubscription()
/ \ useEffect() ✅ #2. 副作用
Status
\ / useState() ✅ #3. 状态
\ useNetworkStatus() -> useSubscription()
\ useEffect() ✅ #4. 副作用
函数调用不会有“钻石”问题,因为它们天然形成一棵树。🎄
缺陷五:复制粘贴容易出错
也许我们可以通过引入某种命名空间机制来拯救带 key 的状态方案。实现方式有很多种。
一种做法是用闭包隔离状态 key。这样你需要“实例化”自定义 Hook,并给每个 Hook 加一层函数包装:
/*******************
* useFormInput.js *
******************/
function createUseFormInput() {
// 每次实例化都唯一
const valueKey = Symbol();
return function useFormInput() {
const [value, setValue] = useState(valueKey);
return {
value,
onChange(e) {
setValue(e.target.value);
},
};
}
}
这种方式其实有点繁琐。Hooks 的设计目标之一就是避免 HOC 和 render props 那种深层嵌套的函数式风格。而这里,你必须先“实例化”每一个自定义 Hook,然后在组件体内只调用一次。这和无条件调用 Hook 并没有简单多少。
此外,你还得在组件里把每个用到的自定义 Hook 写两遍。一次在顶层作用域(或者自定义 Hook 的函数体内),一次在实际调用的位置。哪怕只是小改动,也得在渲染和顶层声明之间来回跳转:
// ⚠️ 这不是 React Hooks 的 API
const useNameFormInput = createUseFormInput();
const useSurnameFormInput = createUseFormInput();
function Form() {
// ...
const name = useNameFormInput();
const surname = useNameFormInput();
// ...
}
而且你必须非常注意命名。你总是要有“两层”名字——像 createUseFormInput
这样的工厂函数,以及 useNameFormInput
、useSurnameFormInput
这样的实例化 Hook。
如果你在同一个组件里两次调用同一个 Hook 实例,就会发生状态冲突。事实上,上面的代码就有这个错误——你注意到了吗?应该写成:
const name = useNameFormInput();
const surname = useSurnameFormInput(); // 不能再用 useNameFormInput!
这些问题并非不可克服,但我认为它们比遵守Hooks 规则更麻烦。
更重要的是,这种方式破坏了复制粘贴的预期。直接提取自定义 Hook(不加闭包包装)在只调用一次时还能用,但只要你多次调用就会冲突。一旦你发现调用链深处有冲突,就不得不“全都包一层™️”。这种 API 表面上看似可用,实则埋下了隐患。
缺陷六:我们仍然需要 Linter
还有一种避免 key 冲突的方法。如果你知道这种方式,可能会很纳闷我怎么还没提到!抱歉。
思路是,每次写自定义 Hook 时都组合 key。类似这样:
// ⚠️ 这不是 React Hooks 的 API
function Form() {
// ...
const name = useFormInput('name');
const surname = useFormInput('surname');
// ...
return (
<>
<input {...name} />
<input {...surname} />
{/* ... */}
</>
)
}
function useFormInput(formInputKey) {
const [value, setValue] = useState('useFormInput(' + formInputKey + ').value');
return {
value,
onChange(e) {
setValue(e.target.value);
},
};
}
在各种替代方案里,我对这种方式的反感最小。但我觉得它也不值得采用。
如果代码传递了非唯一或拼接不当的 key,偶尔也能正常工作,直到某个 Hook 被多次调用或和其他 Hook 冲突。更糟的是,如果你希望它能支持条件调用(我们其实是想“修复”必须无条件调用的限制,对吧?),那么冲突可能要等很久才暴露出来。
每次都要记得把 key 传递到所有自定义 Hook 层级,这种做法太容易出错,以至于你肯定会想加个 linter 检查。而且这些 key 还会增加运行时负担(别忘了它们要作为 key用),每一个都是包体积的小割伤。但既然我们无论如何都得加 linter,那到底解决了什么问题?
如果条件声明状态和副作用真的很有价值,这种做法或许有意义。但实际上我觉得它很让人困惑。事实上,我没见过有人要求能有条件地定义 this.state
或 componentDidMount
。
比如这段代码到底是什么意思?
// ⚠️ 这不是 React Hooks 的 API
function Counter(props) {
if (props.isActive) {
const [count, setCount] = useState('count');
return (
<p onClick={() => setCount(count + 1)}>
{count}
</p>;
);
}
return null;
}
当 props.isActive
为 false
时,count
会被保留吗?还是因为没调用 useState('count')
而被重置?
如果条件状态被保留,那副作用呢?
// ⚠️ 这不是 React Hooks 的 API
function Counter(props) {
if (props.isActive) {
const [count, setCount] = useState('count');
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
return (
<p onClick={() => setCount(count + 1)}>
{count}
</p>;
);
}
return null;
}
显然,在 props.isActive
第一次为 true
之前,副作用不会运行。但一旦变成 true
,副作用会一直运行吗?当 props.isActive
变回 false
时,定时器会重置吗?如果会重置,那副作用的行为就和状态(我们说不会重置)不一致。如果副作用一直运行,那外层的 if
其实并没有让副作用变成条件性的。这不是我们想要的“条件副作用”吗?
如果状态在某次渲染没“用到”时就会被重置,那如果多个 if
分支都包含 useState('count')
,但每次只有一个分支会运行,这算不算合法代码?如果我们的心智模型是“带 key 的 map”,为什么 map 里的东西会“消失”?开发者会不会期望组件提前 return
后,所有后面的状态都被重置?如果我们真的想重置状态,其实可以通过提取组件来显式实现:
function Counter(props) {
if (props.isActive) {
// 明确有自己的状态
return <TickingCounter />;
}
return null;
}
这大概也会成为“最佳实践”,以避免这些令人困惑的问题。所以无论你怎么回答这些问题,条件声明状态和副作用的语义本身就够怪了,你可能还是会想加 linter 禁止。
既然无论如何都要加 linter,那正确组合 key 的要求就成了“累赘”。它并没有带来我们真正想要的东西。而回归最初的提案确实带来了好处。它让你可以安全地把组件代码复制粘贴到自定义 Hook 里,无需命名空间;减少了 key 带来的包体积损耗;还能让实现略微高效(不需要 Map 查找)。
细微之处,积少成多。
缺陷七:无法在 Hook 之间传递值
Hooks 最棒的特性之一,就是你可以在它们之间传递值。
比如下面这个消息接收人选择器的例子,可以显示当前选中的好友是否在线:
const friendList = [
{ id: 1, name: 'Phoebe' },
{ id: 2, name: 'Rachel' },
{ id: 3, name: 'Ross' },
];
function ChatRecipientPicker() {
const [recipientID, setRecipientID] = useState(1);
const isRecipientOnline = useFriendStatus(recipientID);
return (
<>
<Circle color={isRecipientOnline ? 'green' : 'red'} />
<select
value={recipientID}
onChange={e => setRecipientID(Number(e.target.value))}
>
{friendList.map(friend => (
<option key={friend.id} value={friend.id}>
{friend.name}
</option>
))}
</select>
</>
);
}
function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);
const handleStatusChange = (status) => setIsOnline(status.isOnline);
useEffect(() => {
ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
};
});
return isOnline;
}
当你切换接收人时,useFriendStatus()
Hook 会取消订阅上一个好友的状态,并订阅下一个好友的状态。
这之所以可行,是因为我们可以把 useState()
Hook 的返回值传递给 useFriendStatus()
Hook:
const [recipientID, setRecipientID] = useState(1);
const isRecipientOnline = useFriendStatus(recipientID);
在 Hook 之间传递值非常强大。例如,React Spring 让你创建多个值的“跟随”动画:
const [{ pos1 }, set] = useSpring({ pos1: [0, 0], config: fast });
const [{ pos2 }] = useSpring({ pos2: pos1, config: slow });
const [{ pos3 }] = useSpring({ pos3: pos2, config: slow });
(这里有个演示。)
如果把 Hook 初始化写在默认参数值里,或者用装饰器形式写 Hook,就很难表达这种逻辑。
如果 Hook 的调用不发生在函数体内,你就无法轻松地在它们之间传递值,也无法在不增加组件层级的情况下对这些值做变换,或者用 useMemo()
缓存中间计算结果。你也很难在副作用里引用这些值,因为闭包无法捕获它们。虽然可以用某些约定来绕过这些问题,但你得在脑海里“对齐”输入和输出,非常容易出错,也违背了 React 一贯的直接风格。
在 Hook 之间传递值是我们提案的核心。render props 模式是没有 Hooks 时能做到的最接近的方案,但如果没有类似 Component Component 这样的工具,始终会有很多语法噪音,因为存在“伪层级”。Hooks 则把这种层级扁平化为值的传递——而函数调用是实现这一点最简单的方式。
缺陷八:仪式感太重
有很多提案都属于这一类。它们大多试图避免 Hooks 对 React 的“依赖”。实现方式五花八门:比如把内置 Hook 挂在 this
上、让你必须手动传递 Hook 作为额外参数等等。
我觉得 Sebastian 的回答已经把这个问题讲得很清楚,建议你直接看那篇回应的第一部分(“注入模型”)。
我只想说,程序员更喜欢用 try
/ catch
处理错误,而不是每个函数都传递错误码。就像我们更喜欢用 ES Modules 的 import
(或 CommonJS 的 require
),而不是 AMD 那种“显式”定义、把 require
传给你的方式。
// 有人怀念 AMD 吗?
define(['require', 'dependency1', 'dependency2'], function (require) {
var dependency1 = require('dependency1'),
var dependency2 = require('dependency2');
return function () {};
});
没错,AMD 可能更“诚实”地反映了模块在浏览器环境下并不是同步加载的。但一旦你明白了这一点,写 define
三明治就变成了机械劳动。
try
/ catch
、require
和 React Context API 都是我们希望有某种“环境”处理器可用的例子,而不是每一层都显式传递——即使我们通常很重视显式性。我认为 Hooks 也是如此。
这就像我们定义组件时,直接从 React
拿 Component
。也许如果每个组件都导出一个工厂函数,代码会和 React 更解耦:
function createModal(React) {
return class Modal extends React.Component {
// ...
};
}
但实际上,这样做只会带来烦人的间接性。真要替换 React,我们完全可以在模块系统层面处理。
Hooks 也是一样。正如 Sebastian 的回答所说,技术上确实可以“重定向”从 react
导出的 Hook 到其他实现。(我以前的文章也提到过。)
还有一种方式是让 Hooks 变成 monad,或者引入类似 React.createHook()
这样的一级概念。除了运行时开销,任何需要包装的方案都会失去使用普通函数的巨大优势:调试极其简单。
普通函数让你可以用调试器直接单步进出,不会有任何库代码插手,也能清楚看到值在组件体内的流动。间接性让这一切变得困难。类似高阶组件(“装饰器” Hook)或 render props(比如 adopt
提案或用 generator 的 yield
)的方案也有同样的问题。间接性还会让静态类型变复杂。
正如前面提到的,本文并不打算面面俱到。还有其他有趣的问题存在于不同的提案中。有些更隐晦(比如和并发或高级编译技术相关),也许以后会专门写一篇博客讨论。
Hooks 也不是完美的,但这是我们为解决这些问题所能找到的最佳权衡。我们还有需要改进的地方,也确实存在用 Hooks 比类组件更别扭的场景。这也是另一个值得单独写一篇博客的话题。
无论我有没有提到你最喜欢的替代方案,希望这篇文章能帮助你理解我们的思考过程,以及我们选择 API 时考虑的标准。你可以看到,很多标准(比如确保复制粘贴、移动代码、添加和移除依赖都能正常工作)都和为变化优化有关。希望 React 用户能体会到这些设计考量。
Pay what you like