在这篇文章中,我将分享我对React Hooks的观点,正如这篇文章的标题所暗示的那样,我不是一个忠实的粉丝。
让我们来分析一下React官方的文档中描述的放弃类而使用钩子的动机。
动机1:class令人困惑
我们发现,class可能是学习React的一大障碍,你必须了解 this 在JavaScript中的工作方式,这与大多数语言中的工作方式截然不同。你必须记住要绑定事件处理程序,代码会非常啰嗦,React中函数和类组件之间的区别,以及何时使用每个组件,甚至在有经验的React开发人员之间也会导致分歧。 |
好吧,我可以同意 this 在你刚开始使用Javascript的时候可能会有点混乱,但是箭头函数解决了混乱,把一个已经被Typescript开箱即用支持的第三阶段功能称为“不稳定的语法建议”,这纯粹是煽动性的。React团队指的是class字段语法,该语法已经被广泛使用并且可能很快会得到正式支持:
- class Foo extends React.Component {
- onPress = () => {
- console.log(this.props.someProp);
- } render() { return <Button onPress={this.onPress} />
- }
- }
如你所见,通过使用class字段箭头函数,你无需在构造函数中绑定任何内容,并且它始终指向正确的上下文。
如果Class令人困惑,那么对于新的钩子函数我们能说些什么呢?钩子函数不是常规函数,因为它具有状态,看起来很奇怪的 this(又名 useRef ),并且可以具有多个实例。但这绝对不是类,介于两者之间,从现在开始,我将其称为 Funclass。那么,对于人类和机器而言,那些Funclass会更容易吗?我不确定机器,但我真的不认为Funclass从概念上比类更容易理解。
类是一个众所周知的思想概念,每个开发人员都熟悉 this 的概念,即使在javascript中也有所不同。另一方面,Funclass是一个新概念,一个很奇怪的概念。它们让人感觉更神奇,而且它们过于依赖惯例而不是严格的语法。你必须遵循一些严格而奇怪的规则,你需要小心你的代码放在哪里,而且有很多陷阱。还要准备好一些可怕的命名,比如 useRef( this 的花哨名字)、useEffect、useMemo、useImperativeHandle(说什么呢?)等等。
类的语法是为了处理多实例的概念和实例范围的概念(this 的确切目的)而专门发明的。Funclass只是一种实现相同目标的奇怪方式,许多人将Funclass与函数式编程相混淆,但Funclass实际上只是变相的类。类是一个概念,而不是语法。
在React中,函数和类组件之间的区别,以及何时使用每一种组件,甚至在有经验的React开发人员之间也会产生分歧。
到目前为止,这种区别非常明显——如果需要状态或生命周期方法,则使用类,否则,使用函数或类实际上并不重要。就我个人而言,我很喜欢这样的想法:当我偶然发现一个函数组件时,我可以立即知道这是一个没有状态的“哑巴组件”。遗憾的是,随着Funclasses的引入,情况不再是这样了。
动机2:很难在组件之间重用有状态逻辑
具有讽刺意味吗?至少在我看来,React最大的问题是它没有提供一个开箱即用的状态管理方案,让我们对应该如何填补这个空白的问题争论了很久,也为Redux等一些非常糟糕的设计模式打开了一扇门。所以在经历了多年的挫折之后,React团队终于得出了一个结论:组件之间很难共享有状态逻辑......谁能想到呢?
无论如何,勾子会使情况变得更好吗?答案是不尽然。钩子不能和类一起工作,所以如果你的代码库已经用类来编写,你还是需要另一种方式来共享有状态的逻辑。另外,钩子只解决了每个实例逻辑共享的问题,但如果你想在多个实例之间共享状态,你仍然需要使用stores和第三方状态管理解决方案,正如我所说,如果你已经使用它们,你并不真正需要钩子。
所以,与其只是治标不治本,或许React是时候行动起来,实现一个合适的状态管理工具,同时管理全局状态(stores)和本地状态(每个实例),从而彻底扼杀这个漏洞。
动机3:复杂的组件变得难以理解
如果你已经在使用stores,这种说法几乎没有意义,让我们看看为什么。
- class Foo extends React.Component {
- componentDidMount() {
- doA();
- doB();
- doC();
- }
- }
在这个例子中,你可以看到,我们可能在 componentDidMount 中混合了不相关的逻辑,但这是否会使我们的组件膨胀?不完全是。整个实现位于类之外,而状态位于store中,没有store 所有状态逻辑都必须在类内部实现,而该类确实会臃肿。但看起来React又解决了一个问题,这个问题大多存在于一个没有状态管理工具的世界里。实际上,大多数大型应用程序已经在使用状态管理工具,并且该问题已得到缓解。另外,在大多数情况下,我们也许可以将这个类分解成更小的组件,并将每个 doSomething() 放在子组件的 componentDidMount 中。
使用Funclass,我们可以编写如下代码:
- function Foo() {
- useA();
- useB();
- useC();
- }
看起来有点干净,但是是吗?我们还需要在某个地方写3个不同的useEffect钩子,所以最后我们要写更多的代码,看看我们在这里做了什么——有了类组件,你可以一目了然地知道组件在mount上做什么。在Funclass的例子中,你需要按照钩子并尝试搜索带有空依赖项数组的 useEffect,以了解组件在mount上做什么。生命周期方法的声明性本质上是一件好事,我发现研究Funclasss的流程要困难得多。我见过很多案例是Funclasses让开发者更容易写出糟糕的代码,我们后面会看到一个例子。
但是首先,我必须承认 useEffect 有一些好处,请看以下示例:
- useEffect(() => {
- subscribeToA(); return () => {
- unsubscribeFromA(); }; }, []);
useEffect 钩子让我们将订阅和退订逻辑配对在一起。这其实是一个非常整洁的模式,同样的,把 componentDidMount 和 componentDidUpdate 配对在一起也是如此。以我的经验,这些情况并不常见,但它们仍然是有效的用例,在这里 useEffect 确实很有用。问题是,为什么我们必须使用Funclass才能获得 useEffect?为什么我们的Class不能有类似的东西?答案是我们可以:
- class Foo extends React.Component {
- someEffect = effect((value1, value2) => {
- subscribeToA(value1, value2); return () => {
- unsubscribeFromA(); }; }) render(){ this.someEffect(this.props.value1, this.state.value2);
- return <Text>Hello world</Text>
- }}
effect 函数将记住给定的函数,并且仅当其参数之一已更改时才会再次调用它。通过从我们的render函数内部触发效果,我们可以确保它在每次渲染/更新时都被调用,但只有当它的一个参数被改变时,给定的函数才会再次运行,所以我们在结合 componentDidMount 和 componentDidUpdate 方面实现了类似 useEffect 的效果,但遗憾的是,我们仍然需要在 componentWillUnmount 中手动进行最后的清理。另外,从render内调用效果函数也有点丑。为了得到和useEffect完全一样的效果,React需要增加对它的支持。
最重要的是 useEffect 不应该被认为是进入funclass的有效动机,它本身就是一个有效的动机,也可以为类实现。
动机4:性能
React团队说类很难优化和最小化,funclass应该以某种方式改进,关于这件事,我只有一件事要说——给我看看数字。
我至今找不到任何论文,也没有我可以克隆并运行以比较Funclasses VS Class的性能的基准演示应用程序。事实上,我们没有看到这样的演示并不奇怪——Funclasses需要以某种方式实现这个功能(如果你喜欢的话,也可以用Ref),所以我很期待那些让类难以优化的问题,也会影响到Funclasses。
不管怎么说,所有关于性能的争论,在不展示数据的情况下实在是一文不值,所以我们真的不能把它作为论据。
动机5:Funclass不太冗长
你可以找到很多通过将Class转换为Funclass来减少代码的例子,但大多数甚至所有的例子都利用了 useEffect 钩子,以便将 componentDidMount 和 componentWillUnmount 结合在一起,从而达到极大的效果。
但正如我前面所说,useEffect 不应该被认为是Funclass的优势,如果忽略它所实现的代码减少,那么只会留下非常小的影响。而且,如果你尝试使用 useMemo,useCallback 等来优化Funclass,你甚至可能得到比等效类更冗长的代码。
当比较小而琐碎的组件时,Funclasses毫无疑问地赢了,因为类有一些固有的模板,无论你的类有多小你都需要付出。但在比较大的组件时,你几乎看不出差别,有时正如我所说,类甚至可以更干净。
最后,我不得不对 useContext 说几句:useContext其实比我们目前原有的类的context API有很大的改进。但是再一次,为什么我们不能为类也有这样漂亮而简洁的API呢? 为什么我们不能做这样的事情。
- //inside "./someContext" :
- export const someContext = React.Context({helloText: 'bla'});
- //inside "Foo":
- import {someContext} from './someContext';
- class Foo extends React.component {
- render() {
- <View>
- <Text>{someContext.helloText}</Text>
- </View>
- }
- }
当上下文中的 helloText 发生变化时,组件应该重新渲染以反映这些变化。就是这样,不需要丑陋的高阶组件(HOC)。
那么,为什么React团队选择只改进useContext API而不是常规content API?我不知道,但这并不意味着Funclass本质上更干净。这意味着React应该通过为类实现相同的API改进来做得更好。
因此,在提出有关动机的问题之后,让我们看一下我不喜欢的有关Funclass的其他内容。
隐藏的副作用
在Funclasses的 useEffect 实现中,最让我困扰的一件事,就是没有弄清楚某个组件的副作用是什么。对于类,如果你想知道一个组件在挂载时做了什么,你可以很容易地检查 componentDidMount 中的代码或检查构造函数。如果你看到一个重复的调用,你可能应该检查一下 componentDidUpdate,有了新的 useEffect 钩子,副作用可以深深地嵌套在代码中。
假设我们检测到一些不必要的服务器调用,我们查看可疑组件的代码,然后看到以下内容:
- const renderContacts = (props) => {
- const [contacts, loadMoreContacts] = useContacts(props.contactsIds);
- return (
- <SmartContactList contacts={contacts}/>
- )
- }
这里没什么特别的,我们应该研究 SmartContactList,还是应该深入研究 useContacts?让我们深入研究一下 useContacts 吧:
- export const useContacts = (contactsIds) => {
- const {loadedContacts, loadingStatus} = useContactsLoader(); const {isRefreshing, handleSwipe} = useSwipeToReresh(loadingStatus); // ... many other useX() functions
- useEffect(() => {
- //** 很多代码,都与一些加载联系人的动画有关。*//
- }, [loadingStatus]); //..rest of code
- }
好的,开始变得棘手。隐藏的副作用在哪里?如果我们深入研究 useSwipeToRefresh,我们将看到:
- export const useSwipeToRefresh = (loadingStatus) => {
- // ..lot's of code
- // ...
- useEffect(() => {
- if(loadingStatus === 'refresing') {
- refreshContacts(); // bingo! 我们隐藏的副作用
- }
- }); //<== 我们忘记了依赖项数组!
- }
我们发现了我们的隐藏效果,refreshContacts 会在每个组件渲染时意外地调用fetch contacts。在大型代码库和某些结构不良的组件中,嵌套的 useEffect 可能会造成麻烦。
我并不是说你不能用类编写糟糕的代码,但是Funclasses更容易出错,而且没有严格定义生命周期方法的结构,更容易做糟糕的事情。
膨胀的API
通过在类的同时增加钩子API,React的API实际上增加了一倍。现在每个人都需要学习两种完全不同的方法,我必须说,新API比旧API晦涩得多。一些简单的事情,如获得之前的props和state,现在都成了很好的面试材料。你能写一个钩子获得之前得 props 在不借助google的情况下?
像React这样的大型库必须非常小心地在API中添加如此巨大的更改,这样做的动机甚至是不合理的。
缺乏说明性
在我看来,Funclass比类更混乱。例如,要找到组件的切入点就比较困难——用classes只需搜索 render 函数,但用Funclasses就很难发现主return语句。另外,要按照不同的 useEffect 语句来理解组件的流程是比较困难的,相比之下,常规的生命周期方法会给你一些很好的提示,让你知道自己的代码需要在哪里寻找。如果我正在寻找某种初始化逻辑,我将跳转(VSCode中的cmd + shift + o)到 componentDidMount,如果我正在寻找某种更新机制,则可能会跳到 componentDidUpdate 等。通过Funclass,我发现很难在大型组件内部定位。
约定驱动的API
钩子的主要规则(可能也是最重要的规则)之一是使用前缀约定。
就是感觉不对
你知道有什么不对劲的感觉吗?这就是我对钩子的感觉。有时我能准确地指出问题所在,但有时只是一种普遍的感觉,即我们走错了方向。当你发现一个好的概念时,你可以看到事情是如何很好地结合在一起的,但是当你在为错误的概念而苦恼的时候,发现你需要添加更多更具体的东西和规则,才能让事情顺利进行。
有了钩子,就会有越来越多奇怪的东西跳出来,有更多“有用的”钩子可以帮助你做一些琐碎的事情,也有更多的东西需要学习。如果我们需要这么多的utils在我们的日常工作中,只是为了隐藏一些奇怪的复杂,这是一个巨大的迹象,说明我们走错了路。
几年前,当我从Angular 1.5转到React时,我惊讶于React的API是如此简单,文档是如此的薄。Angular曾经有庞大的文档,你可能要花上几天的时间才能涵盖所有内容——消化机制、不同的编译阶段、transclude、绑定、模板等等。光是这一点就给我很大的启示,而React它简洁明了,你可以在几个小时内把整个文档看一遍就可以了。在第一次,第二次以及以后的所有次尝试使用钩子的过程中,我发现自己有义务一次又一次地使用文档。
总结
我讨厌成为聚会的扫兴者,但我真的认为Hooks可能是React社区发生的第2件最糟糕的事情(第一名仍然由Redux占据)。它给已经脆弱的生态系统增加了另一场毫无用处的争论,目前尚不清楚钩子是否是推荐的使用方式,还是只是另一个功能和个人品味的问题。
我希望React社区能够醒来,并要求在Funclass和class的功能之间保持平衡。我们可以在类中拥有更好的Context API,并且可以为类提供诸如useEffect之类的东西。如果需要,React应该让我们选择继续使用类,而不是通过仅为Funclass添加更多功能而强行杀死它而将类抛在后面。
另外,早在2017年底,我就曾以《Redux的丑陋面》为题发表过一篇文章,如今连Redux的创造者Dan Abramov都已经承认Redux是一个巨大的错误。
只是历史在重演吗?时间会证明一切。
无论如何,我和我的队友决定暂时坚持用类,并使用基于Mobx的解决方案作为状态管理工具。我认为,在独立开发人员和团队工作人员之间,Hooks的普及率存在很大差异——Hooks的不良性质在大型代码库中更加明显,你需要在该代码库中处理其他人的代码。我个人真的希望React能把 ctrl+z 的钩子全部放在一起。
我打算开始着手制定一个RFC,为React提出一个简单、干净、内置的状态管理方案,一劳永逸地解决共享状态逻辑的问题,希望能用一种比Funclasses不那么笨拙的方式。