原文作者:Sebastian Markbåge
译者:UC 国际研发 Jothy
编者按:本文摘自 React Hooks issue,由 React 作者 Sebastian Markbåge 编写,本文内容丰富,所以翻译上也有难度,如果有翻译不准确的地方欢迎指正反馈。
看完所有相关的评论之后,我想总结一下我的感想。
不得不说,React Hooks 的反响非常强烈。它很受欢迎并且表现不错。 大家普遍认可它,并把它应用到生产中。它的名声和使用规范貌似传播得很好,还被其他库直接采用了。 当然这不是说不存在其他可能的改变了,我想表达的是当前的设计并不完全失败。
围绕该机制的主要讨论是 hooks 实际实现的注入和持久调用顺序的依赖。 有些人希望其一,或者两个都能改改。 但“最纯的”模型就像 Monad(译者注:一种程序设计模式)一样。
注入模式
本质上,(传入 hooks 的)参数是为了替换掉 hooks 的实现代码。这有点像一般的依赖注入和控制反转问题。 React 没有自己的依赖注入系统(与 Angular 不同),它也并不需要,因为大多数入口点是(主动) pull 而不是(被动) push 的(译者注:可参照 Rxjs 中关于 push&pull 概念的解释)。至于其它代码,模块系统已经提供了良好的依赖注入边界。就测试而言,我们比较推荐其它技术,比如在模块系统级别进行 mock(例如使用 jest)。
与之不同的是 setState,replaceState,isMounted,findDOMNode,batchedUpdates 等 API。事实上,React 已经用依赖注入将 updater 插入到 Component 基类中,作为构造函数的第三个参数。Component 实际上啥都没干。这也正是 React 在 React ART 或 React Test Renderer 等相同环境中的不同版本中,具有多种不同类型的渲染器的原理。自定义渲染器也是这么干的。
理论上,类似 React-clones 这样的第三方库可以使用 updater 来注入它们的实现。在实践中,大部分人倾向于通过 shim 模块来替换整个 react 模块,因为可能存在某些权衡或者他们想要实现某些 API(例如,移除开发模式内容,或者合并基类及其实现细节)。
在 Hooks 的世界里,这两个选项仍然保留。 Hooks 其实并不是在 react 包中实现的。它只是调用当前的调度程序(dispatcher)。正如我上面所说,你可以随时将其临时重载为任何你想要的实现,React 渲染器正是通过这一点,实现多个渲染器共享同一个 API。例如。你可以专门为单元测试 hooks 创建一个 hooks 测试调度程序。“调度程序”这个名字有点吓人,但我们可以随时改变它,这不是设计缺陷。现在,你可以把调度程序移到用户空间中,但这会给那些与该组件的作者几乎毫无关联的用户带来额外的干扰,正如这个 issue 中的大部分人并不了解 React 的 updater 一样。
总的来说,我们可能会引入更多的静态函数调用,因为它们更适合摇树优化技术,可以更好地优化及内联。
另一个问题是,hooks 的主要入口是 react 而不是第三方包。很可能将来 react 会移除许多现有的东西,而 hooks 会被保留,所以 hooks 的膨胀并不是问题。唯一的问题是,这些 hooks 隶属于 react,而不是其它更通用的东西。例如 Vue 也曾考虑过实现 hooks API。然而,hooks 的关键是它的原语(primitives)很明确。在这一点上,Vue 有完全不同的原语,而我们已经对原语进行了迭代。其他库可能会提出略有不同的原语。在这一点上,过早地使这些过于笼统是没有意义的。我们选择把第一次迭代在 react 上实现,只是为了表明这就是我们对原语的愿景。如果其它库有雷同,那么我们将创建第三方包以整合这些库,并将 react 的库重定向到该包。
依赖持久调用索引
要明确的是,我们想谈的并不是执行顺序的依赖。 先使用 useState 还是先使用 useEffect 之类的问题并不重要。
React 中有很多依赖于执行顺序的模式,正是由于允许在渲染(render)中变异(mutation)(这仍然保持渲染本身的纯净)。
我不能更改代码中 children 和 header 的顺序。
Hooks 不关心你按什么顺序使用它们,它只关心顺序是否持久,每次都按照同一个顺序。这与调用之间隐含的依赖性大相径庭。
最好不要依赖持久顺序 - 所有事情都是平等的。但是,你得做权衡 - 比如说 - 语法干扰或其他令人困惑的东西。
有些人认为就算只是为了纯粹主义,那也该这么做。但是,有些人也考虑得比较实际。
有人担心它会变得令人困惑,这可能发生在许多层面。我认为这一点并不令人困惑,因为人们完全无能为力或只能放弃。但事实上,基础知识非常容易掌握。
更有甚者,担心一旦出现问题,我们很难弄清楚哪里出了问题。即使你了解它的工作原理,你仍然可能犯错误,在这种情况下,你必须得轻松找出问题并修复它。我们发现了不少这类问题。一般来说它会被 lint 规则捕获,报错信息足以解释原因。但是我们可以做得更多。我们可以制造编译硬错误,在开发模式中跟踪 hooks 的相关信息,在顺序切换时发出警告。在这些情况下,我们可以优化错误消息,以显示更改的堆栈,而不仅仅是显示 something changed。类型系统中逐渐有模拟效果的趋势,例如 Koka。我敢打赌 JavaScript 肯定会应用它,只是时间问题。
另一个问题是,这些约束是否使编码更加困难。对于普通的条件代码,情况似乎并非如此。它一般比较容易重构。缺乏早期响应有点恼人,但也没什么大不了的。 Hooks 还有其他与顺序无关的难题。
但是,重构循环中的 Hooks 可能会非常烦人。解决方案通常是将循环体分解为单独的函数。这也挺不方便的,因为你需要把所有数据通过 props 传递,否则将会抱闭包问题。这是 React 中更难的问题了,不仅限于 Hooks。这么做是最佳实践,有助于优化。例如,在更改列表中的单个项目时保持低渲染成本,确保每个子项都可以独立。使用 Suspense 意味着每个子项都可以并行获取并渲染而不是按顺序获取,报错边界具有类似的要求。因此就算是单独解决 Hooks 问题,将循环体拆分为单独的组件仍然是最佳实践 - 这也解决了 Hooks 的循环问题。
也就是说,Hooks 的最初实现可以创建一个用作编译器目标的键控嵌套作用域。它们确实创建了一种以嵌套方式支持 Hook 的机制。但它不是非常符合人体工程学或易于解释,并且无论如何都会遇到上述问题。如果需要,我们可以在将来添加它。这现在它不应该是常见情况。
我们考虑的各种替代方案各自都存在许多缺点。
- 大多数方案不支持循环。这是关于 Hooks 无条件性的最大限制因素,似乎许多提案都忽略了这一点。只是让它在自己的条件下可用并没有价值。
- 大多数方案并不解释自定义 hooks 作为常见情况的原因。我们认为这是实现性能和语法轻量级的重要目标。
- 一旦你允许 hooks 用于条件语句,有些地方会变得奇怪起来。例如,你可能会在条件中看到 useState,但这意味着什么?这是否意味着它的作用范围只在该块中,还是说它的生命周期随之变化? if (c) useEffect(...) 是什么意思?这是否意味着当条件为真时该 effect 会触发,还是说每次 effect 为真时它都会触发?当条件为否是卸载还是继续组件的生命周期?
- 对于像在 body 外声明 hooks 的提议,多次调用 hooks 意味着什么?仅仅因为技术上可以实现,不会让它变得不那么混乱。
- 大多数方案使用大量的间接和虚拟调度,很难进行静态分析,这使得死代码消除,内联和其他类型的优化变得更加困难。当前的 hooks 提议是非常有效的,因为它只有索引属性,可以轻松 minify,并且具有可预测的 O(1) 查找成本。请记住,文件大小很重要,这是当前设计真正的亮点。
此为旁注:有人提到大家都关注并发的全局状态。 如果将来 JS 支持线程,或者如果我们编译出某些支持线程的东西,我们希望能够支持多个组件的并行执行。 但是,这在实践中不是问题,因为我们存储的用于跟踪当前正在执行的组件和当前 hook 索引的小状态可以轻易进入线程本地存储 - 无论如何,这在某些形式的解决方案中始终是必需的,无论是可变域(mutable field)还是代数效应(algebraic effects)。
调试
大家普遍关注调试会是什么样的。我们从以下几个角度来看。
首先是错误信息。为了带来更好的错误消息,我们下了些工夫。当在 DEV(开发环境)中检测到违反 hook 规则时,我们至少能很好地处理错误。
断点调试变得非常简单,因为它只是使用正常的执行堆栈,这点不像 React 通常的做法。有些替代方案使用了更多的间接性,会让断点调试更难。
另一个问题是树的反射。 React DevTools 允许你检查树中任何内容的当前状态。在生产包中,我们常会将类名等进行 minify。很可能在我们添加更多的生产优化,例如内联和删除不必要的 props 对象之后,如果没有 source map,更多这样的事情将不会自动进行。我们没有为了生产调试而将元数据添加到 API 设计中的信仰。但是我们可以在开发模式时,进行辅助元数据(如 source map)等操作。
也就是说,我们已经证明了我们可以使用 Debug Tools API 提取大量反射元数据。我们还计划添加更多,以便让库具有良好的扩展点,以便为调试提供更丰富的反射数据,或者解析源代码行以将名称添加到单个 useState 调用。
测试
大家都知道测试很重要,所以我们得在更广泛的发布之前清楚地记录它。这一点毋庸置疑。
至于技术细节,我想上文提到的依赖注入点已经告诉了你可以如何做到。
我认为 API 设计中有一种感觉,就是存在“可测试”的 API。当听人这么说时,我觉得他们会想到纯函数之类的东西,只有一些输入变量可以单独测试。 React API 非常简单,你可能只会想直接调用 render 函数或直接调用单个 hook。
可惜 API 的丰富性也带来了些微妙差别。你不总是依赖它,所以你可以经常在特殊情况,或者在简单的测试中一步使用它。但是,随着代码库的增长,你会遇到越来越多这样的情况,并且你不希望在每个代码库中都重新实现 React 运行时的所有细微差别。所以你想要一个测试框架。
比如说,我们为这个用例构建了浅层渲染器类。它允许你“使用”或者“遵循”(诸如此类的动词)正确的语义来调用所有生命周期。测试 Hooks 原语的所有细微差别也挺有必要的。
然而在实践中,我们发现大家不怎么使用浅渲染器(shallow renderer)。使用深层渲染更为常见,因为你正在测试的工作单元通常依赖于更低的几个级别,它们已经通过了测试。
也就是说,我们还将包含一种与组件隔离的直接测试自定义 hooks 的方法。我们要做的就是添加一些模拟调度程序的东西,并保持原语的语义一致。
API设计
useReducer
这会取代 Redux 吗?它会增加必须学习所有 Flux 知识的负担吗?与一般的 Flux 框架相比,Reducer 的使用范围要窄得多。它很简单。但是,如果你看一下框架/语言的方向,比如 Vue,Reason,Elm。这种调度并归集逻辑,以在更高级别的状态之间转换的模式似乎取得了巨大成功。它还解决了 React 中回调的许多奇怪问题,为复杂的状态转换带来了更多直观的解决方案。特别是在并行的世界中。
在膨胀性方面(In terms of bloat),它并未给 React 添加其不需要的任何代码。在概念方面,我认为这是一个值得学习的概念,因为相同的模式不断以各种形式出现,最好有一个中央 API 来管理它。
所以我更多地把 useReducer 当成中心 API,而非 useState。 useState 还是很棒的,因为它对于简单用例来说非常简洁并且易于解释,但人们应该尽早研究 useReducer 或类似的模式。
也就是说,useReducer 并没有做 Redux 和其他 Flux 框架所做的许多事情。我一般认为你不会需要它,所以它可能不像现在那样普遍存在,但它仍然存在。
Context Provider
有人说过,当你只想暴露一种消费 Context 的方法时,理想情况下你不应该从模块中暴露 Context Provider。看似 useContext 鼓励你暴露 Context 对象。我认为这样做的方法是暴露一个自定义 hooks 以供消费。比如 useMyContext = () => useContext(Private),这通常会更好些,因为你可以自由添加自定义逻辑、将其更改为全局逻辑或再行添加弃用警告。它似乎不是需要框架进一步抽象来执行的东西。
我们可以考虑让 createContext 直接返回一个 hooks ,我们也鼓励使用这个常见的模式。 [MyContextProvider, useMyContext] = createContext()
Context Provider 的另一个怪异点是无法用 hooks 提供新的上下文,你仍然需要一个包装器组件。类似的问题还有你无法通过 Hooks 或类似 findDOMNode 的方式将事件监听器附加到当前组件。
这么做的原因在于,Hooks 在设计上要么是独立的,要么只是观察值。这意味着使用自定义 Hook 不会影响组件中未明确传递给该 Hook 的任何内容,它从不钻入任何抽象层次。这也意味着顺序依赖无关紧要。唯一的例外是在处理类似遍历 DOM 的全局可变状态时。它是一个逃生口,但不是你能在 React 世界中滥用的东西。这也意味着使用 Hooks 不依赖于顺序。像 useA(); useB(); 或者 useB(); useA();这样调用都行。除非你通过共享数据的方式显式创建依赖项。let a = useA(); useB(a);
useEffect
到目前为止,最怪异的 Hook 是 useEffect。需要明确的是,它预计是迄今为止最难使用的 Hook,因为它使用较难管理的命令式代码,这就是我们试着保持声明式的原因。但是,从声明式变为命令式很难,因为声明式可以处理更多不同类型的状态和每行代码的转换。当你实现某个效果时,理想情况下也应处理所有随之而来的 case。这么做的部分目的是鼓励处理更多 case,这样的话有些怪异点也是可以解决的。
毫无疑问,第二个参数挺古怪的。把它作为第二个而不是第一个参数是因为对于所有这些方法,你可以先编写代码,然后再进行添加。该属性的好处是你可以在 IDE 中使用 linter 或代码重构工具,或者让编译器根据你的回调自动添加它。这是从 C# 中吸收的经验,其中语法顺序旨在支持自动完成等功能。
也许它该有个比较函数。我还没有看到不能将它重写为输入的情况,但无论我们是否添加比较函数,我们都可以放到之后做。那也需要一个输入数组,来让我们知道要存储及传递什么给比较函数。
现在不允许使用异步函数作为 effect,意思是你必须费尽心思来做异步清理。很难保证异步 effect 的正确,因为这些步骤之间一切皆有可能。在初始化新 effect 之前不能进行清理,否则可能会影响 effect 的属性。之后我们有可能放宽这种约束,但我怀疑它是一个糟糕的模式,也许我们不应该鼓励在第一个版本中使用它。
useEffect 最奇怪的情况是在使用闭包时。这会在我们想要跟踪某个取值的时候混淆视听。因为闭包的值实际上不是 reactive 的,它们会捕捉当前的状态,实际上这是一个不错的优点。由于批处理和并发模式,多数情况下,事物以意想不到的方式交织。由于违反直觉的闭包,捕获的值确实会导致错误,但一旦修复,会大大减少它们的竞争条件问题。
另一个是内存使用问题。我想说 React 的内存使用量一般都是不可预测的,因为我们基本上都记得树中的所有东西。但是,由于闭包共享执行环境,它可能导致额外的反直觉延长。这些可以通过使它成为一个自定义 hooks 来解决,但它不总是那么明显,所以有时候你必须这么做。了解此模式的优化编译器也可以轻松解决此问题。
针对这个问题,有个解决方案是我们可以引入 useEffect 作为相同的函数,将所有输入参数传递为函数的参数,并且鼓励挂起它们。但这是有问题的,因为闭包的好处是方便引用计算值。其他模式我们也鼓励使用闭包。所以这似乎破坏了「一切都进入方法体」的思想。这反过来又解决了其他问题,例如默认 props,计算值等。我不确定这对于剩下的少数情况来说是否值得去做,但不做的话会遗留更多。
缺失的 API
有几个人指出我们缺少了一些 API。
setState 的第二个参数在此模型中不能很好地运行。同样,我们在 ReasonReact 中还没有类似 UpdateWithSideEffects 的东西。我们已经考虑好了如何使用它,并会在之后做补充。例如,在 reducer 中调用 emitEffect。
由于状态转换,我们没有办法改动单个组件。如果有方法,那么我们可能反过来需要像 forceUpdate 那样绕过它来进行改变。
我们还没有 getDerivedStateFromError 和 componentDidCatch 的替代方法,但我们有 HOC 来提供此功能。比如 catch((props, error) => { if (error) setState(...); useEffect(() => { if (error) ... }); })。我们会在之后添加。
一直以来总有人问,是否可以有更底层的 API 来实现其他语言(如 ClojureScript 或 Reason)的特定语义。这是我们肯定想要支持的,但我不确定是否该使用类似公共 API 的机制来完成。比如说,React 的优化编译器需要一个不同的入口点来定位该格式。该机制要可以用于各种较底层的操作,不必进行易用性优化。因此,我们可能会将其与公共 API 分开添加。
类型
我相信大多数 JavaScript 类型的问题都已得到解决,因为 Flow 和 TypeScript 现在都已定义。
有个有趣的问题还没有被提及:即使发生了调用顺序错误的情况,是否仍可以在其他语言(例如 Rust 或 Reason)中正确地编码。 这还没有得到证实 - 至少在非运行时没有。
编译优化
有些人担心这些非纯函数会影响编译器的优化。对此我有异议:我们也想,并且已经做了许多运行时或静态执行的优化。
事实上我们努力地想将 Hooks 拿出来,因为它非常适合优化。它谨慎地鼓励许多静态的解决模式产生。
有两个优化是关于合并组件的。对于由父组件无条件渲染的组件来说,这是普通 hooks,你可以直接调用。你可以在用户空间中执行此优化。即使是对于循环和条件,我们也知道如何为它们添加相同作用的作用域。即使是动态渲染的组件,我们也可以跳过额外 Fibers 的创建,例如当一组父组件渲染一个也是函数组件的子组件时。我们只需要在函数类型改变的情况下,跟踪切换发生的顺序就好了。
这对于基于记忆(memoization)的优化同样有效。在具有代数 effect 的语言中,记忆功能只需要同时记住 effect 就像。这里也同理。记忆只需要跟踪调用期间发出的 hooks 。
许多使用对象或传递头等函数的替代方案,需要以某种方式展开,这种方式由于其间接性而加大了实际优化的难度,其中尤以 Generator 的难度最甚。
安全性
有人提到关于 setState 中的重载 API,它接受函数或函数的返回值作为参数。 这是一个艰难的设计决定,我们进行了诸多权衡。 确实,重载的 API 有时会导致难以预料的安全问题。 我们就曾有过一例因子组件同时接受字符串或元素而导致的问题。 但也有许多重载的 API 是非常有用的抽象,不会导致安全问题。 我会更深入地研究这一点,以确保对风险做合理的评估。
还有一个尚未提出但我想说明的问题。 如果你认为第三方 Hook 不可信,因为它可以有条件地添加/删除其状态 hooks ,然后从其 hooks 的末尾开始读取。 你允许它从外部组件读取状态。 如果你可以执行代码,通常你就输了,但在类似 Caja/SES 的环境中,这可能是相关的。 这是比较不幸的情况。
动机
为什么把所有特殊的 hooks 都是核心?由于所有机制都必须存在,因此它们中的大多数并没有真正增加体积,而更多的是概念开销。其中大多数是无法在用户空间中实现或通过描述意图提供重要价值的原语。
例如,你现在可以在用户空间中使用 useMemo,但我们希望,将来在低内存情况或窗口组件中丢弃记忆值的情况下,状态仍能被保留。
useCallback 只是一个围绕 useMemo 的简单包装器,但是我们已经想好未来如何进一步优化它们,无论是静态还是使用运行时技术。
useImperativeMethods 提供了一个可以在用户空间中构建的 API,但由于我们有几种不同的方式与 refs 交互,因此以单一规范方式维护它们更好一些。我们已经两度更改了 refs。
我一直听到的一个争论点是此次改变的动机不足,因为“类(class)挺好的呀”。据说那帮试图学习的用户奔溃了。我认为这个论点过于片面,有人还强调 class 对于新手来说难比登天呢——所以这不是重点。
主要动机是像闭包这样的模式自然会创建值的副本,这使得编写并发代码更加简单,因为你可以在任何给定点存储 n 个状态,而不是在可变类的情况下只存储一个状态。这避免了许多类的坑,因为类看起来很直观但实际结果难以预料。
类(class)似乎是保持状态的理想方式,因为它本就为此而生。但是,React 更像是一个声明函数,它不断被反复执行以模拟反应状态。二者有一个阻抗不匹配,当我们把这些看作是类时会不断泄漏。
另一个是 JS 中的类在同一命名空间中合并方法和值的问题。这是的优化难以进行,因为有时方法的行为类似于静态方法,有时又类似于包含函数的值。 Hooks 模式鼓励对辅助函数使用更静态可解析的调用。
在类中,每个方法都有自己的作用域。它导致像我们这样的问题必须重新发明默认 props,以便我们可以创建一个单独的共享解析对象。我们还鼓励你使用类中的可变字段在这些方法之间共享数据,因为唯一共享的是 this。这对于并发性来说也是有问题的。
另一个问题是,React 的概念心智模型只是递归调用其他函数的函数。用这些术语表达它有很多价值,有助于建立正确的心智模型。
我非常同情的一个问题是,这只会增加学习 React 的脑力成本 - 短期内。那是因为你可能要在可预见的未来学习 Hooks 和 class。要么是因为二者你都使用,你的代码库中有之前的类或者其他人编写的类、你在 stackoverflow 上读过的一个例子或教程使用的类,或者你正在调试的一个库使用了它。
虽然需要很多年,但我打赌其中一种方法将取得胜利。要么我们必须回滚 Hooks,要么逐渐减少 class 的使用,直到它们完全消失为止。
我认为大家的批评很公正。我们的确没有明确的答复,或给出「可以合理地将 class 移出核心并外化为 compat 层」的预计路线图。我认为短时间内我们都不会给出明确答复,直到实际看到 Hooks 变得更好。到那时候,我们会看是否给出降低类重要性的时间线。
英文原文:
https://github.com/reactjs/rfcs/pull/68#issuecomment-439314884