overreactedby Dan Abramov

为什么 React Hooks 依赖调用顺序?

December 13, 2018

在 React Conf 2018 上,React 团队发布了 Hooks 提案

如果你想了解什么是 Hooks 以及它们解决了哪些问题,可以观看我们关于 Hooks 的介绍演讲以及我写的后续文章,里面解答了一些常见误解。

很可能你一开始并不会喜欢 Hooks:

负面 HN 评论

它们就像一张需要多听几遍才会喜欢上的唱片:

同一个人四天后发表的正面 HN 评论

阅读文档时,千万不要错过最重要的那一页,讲的是如何构建你自己的 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 这样的工厂函数,以及 useNameFormInputuseSurnameFormInput 这样的实例化 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.statecomponentDidMount

比如这段代码到底是什么意思?

// ⚠️ 这不是 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.isActivefalse 时,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 / catchrequire 和 React Context API 都是我们希望有某种“环境”处理器可用的例子,而不是每一层都显式传递——即使我们通常很重视显式性。我认为 Hooks 也是如此。

这就像我们定义组件时,直接从 ReactComponent。也许如果每个组件都导出一个工厂函数,代码会和 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

Edit on GitHub