React Hooks的丑陋一面

2,746 阅读15分钟

原文:medium.com/swlh/,作者:Nir Yosef

微信搜索【前端全栈开发者】关注这个脱发、摆摊、卖货、持续学习的程序员的,第一时间阅读最新文章,会优先两天发表新文章。关注即可大礼包,准能为你节省不少钱!

在这篇文章中,我将分享我对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 的花哨名字)、useEffectuseMemouseImperativeHandle(说什么呢?)等等。

类的语法是为了处理多实例的概念和实例范围的概念(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 钩子让我们将订阅和退订逻辑配对在一起。这其实是一个非常整洁的模式,同样的,把 componentDidMountcomponentDidUpdate 配对在一起也是如此。以我的经验,这些情况并不常见,但它们仍然是有效的用例,在这里 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函数内部触发效果,我们可以确保它在每次渲染/更新时都被调用,但只有当它的一个参数被改变时,给定的函数才会再次运行,所以我们在结合 componentDidMountcomponentDidUpdate 方面实现了类似 useEffect 的效果,但遗憾的是,我们仍然需要在 componentWillUnmount 中手动进行最后的清理。另外,从render内调用效果函数也有点丑。为了得到和useEffect完全一样的效果,React需要增加对它的支持。

最重要的是 useEffect 不应该被认为是进入funclass的有效动机,它本身就是一个有效的动机,也可以为类实现。

动机4:性能

React团队说类很难优化和最小化,funclass应该以某种方式改进,关于这件事,我只有一件事要说——给我看看数字

我至今找不到任何论文,也没有我可以克隆并运行以比较Funclasses VS Class的性能的基准演示应用程序。事实上,我们没有看到这样的演示并不奇怪——Funclasses需要以某种方式实现这个功能(如果你喜欢的话,也可以用Ref),所以我很期待那些让类难以优化的问题,也会影响到Funclasses。

不管怎么说,所有关于性能的争论,在不展示数据的情况下实在是一文不值,所以我们真的不能把它作为论据。

动机5:Funclass不太冗长

你可以找到很多通过将Class转换为Funclass来减少代码的例子,但大多数甚至所有的例子都利用了 useEffect 钩子,以便将 componentDidMountcomponentWillUnmount 结合在一起,从而达到极大的效果。

但正如我前面所说,useEffect 不应该被认为是Funclass的优势,如果忽略它所实现的代码减少,那么只会留下非常小的影响。而且,如果你尝试使用 useMemouseCallback 等来优化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,有了新的 useEffec t钩子,副作用可以深深地嵌套在代码中。

假设我们检测到一些不必要的服务器调用,我们查看可疑组件的代码,然后看到以下内容:

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不那么笨拙的方式。