作者 | Nir Yosef
译者 | 王强
策划 | 李俊辰
在这篇文章中,作者按照官方文档的描述,分析用 React Hooks 代替类的动机,一如标题所示,作者并不是很喜欢这一特性。
本文最初发布于 Medium 网站,经原作者授权翻译并分享。
1. 类令人困惑
我们发现,类可能是学习 React 道路上的一大障碍。你必须了解 this 在 JavaScript 中的工作机制,这和大多数语言中的机制截然不同。你必须记得绑定事件处理程序。没有不稳定的语法提案(https://babeljs.io/docs/en/babel-plugin-transform-class-properties/),代码就非常冗长 [……]React 中函数和类组件之间的区别,以及何时该使用哪一个的话题,即便在经验丰富的 React 开发人员之间也存在分歧。
好的,我同意当你刚开始使用 Javascript 时,this 可能会有些令人头疼,但是箭头函数解决了这种困惑;而且调用一个 Typescript 已经开箱支持的阶段 3 特性都被称作是“不稳定的语法提案”,这就纯粹是耸人听闻。React 团队指的是 类字段语法,这种语法已经被广泛使用并且可能很快就会得到正式支持:
class Foo extends React.Component {
onPress = () => {
console.log(this.props.someProp);
}
render() {
return <Button onPress={this.onPress} />
}
}
如你所见,使用类字段箭头函数时,你无需在构造函数中绑定任何内容,并且 this 始终指向正确的上下文。
如果类是令人困惑的,那么新的 hooked 函数又能强到哪儿去呢?一个 hooked 函数并不是一个常规函数,因为它具有状态,有一个看上去很奇怪的 this(也就是 useRef),并且可以具有多个实例。但它绝对不是类,而是介于两者之间,后文我都会叫它 Funclass。那么,对于人类和机器而言,那些 Funclass 理解起来会更容易吗?机器这边我不确定,但我真的不认为 Funclass 从概念上来讲比类更容易理解。类是一个广为人知且经过深思熟虑的概念,每位开发人员都熟悉 this 的概念,就算在 javascript 中有所不同也不是大事。相比之下,Funclass 是一个新概念,一个很奇怪的概念。它们更像是魔法,而且过多地依赖约定而不是严格的语法。你必须遵循一些严格而怪异的规则:
https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level
需要注意代码放置的位置,并且这里面存在许多陷阱。我不能将一个 hook 放在一个 if 语句中,因为 hooks 的内部机制是基于调用顺序的,这简直太疯狂了!这种事情更像是半吊子的 POC 库会做出来的,而不是像 React 这样的知名库中应该做的。而且我们还要准备好接受一些可怕的名称,例如 useRef(this 的一个花哨的叫法)、useEffect、useMemo、useImperativeHandle(这啥?)等等。
类的语法是精心设计的,以便处理多实例的概念和实例范围的概念(this 就是做这个的)。Funclass 只是用错误的拼图达到相同目标的一种怪异方法。许多人分不清 Funclass 与函数式编程,但 Funclass 实际上只是变相的类。类是一个概念,而不是语法。
还有最后这句:
React 中函数和类组件之间的区别,以及何时该使用哪一个的话题,即便在经验丰富的 React 开发人员之间也存在分歧。
到目前为止,这里说的区别还是很清楚的——如果需要状态或生命周期方法,则使用类,否则,使用函数或类都行。就个人而言,我喜欢这样的想法:当我偶然碰到一个函数组件时,我可以立即知道这是一个没有状态的“哑组件”。然而引入 Funclass 之后,就再也没这么简单明了了。
2. 很难在组件之间重用有状态逻辑
React 没有提供一种将可重用行为“附加”到组件的方法(例如,将其连接到一个存储)……React 需要更好的原语来共享状态逻辑。
很讽刺不是吗?至少在我看来,React 的最大问题是它没有提供开箱即用的状态管理解决方案,这给我们留下了关于如何填补这一空白的难题,久久争不出来一个答案,并为一些非常糟糕的设计模式打开了窗口,例如 Redux。因此,经过多年的挫折经历,React 团队终于得出结论,说他们很难在组件之间共享有状态的逻辑……谁能想到竟然是这么个结果。不管怎样,hooks 会让情况变得更好吗?答案是不见得。hooks 无法与类一起使用,因此如果你的代码库是由类编写,那还是需要另一种共享状态逻辑的方法。另外,hooks 只能解决按实例逻辑共享的问题,但如果要在多个实例之间共享状态,则仍然需要使用存储和第三方状态管理解决方案;而且正如我之前所说,如果已经用上它们了,那其实就用不着 hooks 了。因此我们不能治标不治本,也许是时候让 React 采取行动,实现一个合适的状态管理工具来管理全局状态(存储)和局部状态(按实例),从而一劳永逸解决问题了。
3. 复杂的组件会变得难以理解
我们经常不得不维护一些复杂的组件,这些组件起初很简单,但逐渐发展成为状态逻辑和副作用难以控制的混乱状态。每个生命周期方法往往会包含一大堆不相关逻辑。[……] 共同变化的相互关联的代码被分开,而完全不相关的代码被合并进了同一个方法。[……]hooks 使你可以根据各个部分的相关性(例如设置订阅或获取数据)来将一个组件拆分为一些较小的函数,而不是根据生命周期方法强行拆分。
如果你在使用存储,那么上面这段话基本没意义。我们看看原因:
class Foo extends React.Component {
componentDidMount() {
doA();
doB();
doC();
}
}
如本例所示,我们可能在 componentDidMount 中混合了不相关的逻辑,但这会使我们的组件膨胀吗?不见得。整个实现位于类之外,而状态位于存储中。没有存储,所有状态逻辑都必须在类内部实现,那么这个类当然会膨胀。但是同样,React 似乎正在解决一个大多数情况下都是因为没有状态管理工具才会出现的问题。实际上,大多数大型应用已经在使用状态管理工具,已经解决了这个问题。而且在大多数情况下,我们可能会将这个类拆分为一些较小的组件,并将每个 doSomething() 放入子组件的 componentDidMount 中。
使用 Funclass 时,我们可以编写如下代码:
function Foo() {
useA();
useB();
useC();
}
看起来干净一些,但真的是这样?我们仍然需要在某个地方编写 3 个不同的 useEffecthooks,因此到头来我们要编写更多代码。看看我们在这里所做的事情——使用类组件,你一看就会知道这个组件在挂载时正在做什么。在 Funclass 示例中,你需要跟随这些 hooks 的踪迹,并尝试使用空的依赖项数组寻找 useEffect,以便了解组件在挂载时正在做什么。生命周期方法的声明性本质多数情况下是一件好事,同时我发现研究 Funclass 的流程要困难得多。我见过很多情况下 Funclass 会让开发人员更容易编写不良代码,后面会介绍这样一个示例。但是首先,我必须承认 useEffect 有一些好处,请看以下示例:
useEffect(() => {
subscribeToA();
return () => {
unsubscribeFromA();
};
}, []);
useEffect hook 使我们可以将订阅和退订逻辑配对在一起。这实际上是一个非常简洁的模式。将 componentDidMount 和 componentDidUpdate 配对在一起也是如此。以我的经验,这些案例并不常见,但它们毕竟是真实存在的用例,在这里 useEffect 确实很有帮助。问题是——为什么我们必须使用 Funclass 才能获得 useEffect?为什么我们的类不能有类似的东西?答案是我们其实可以这么做:
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 函数将 memoize 给定的函数,并且仅当其参数之一更改时才会再次调用它。通过在渲染函数中触发效果,我们可以确保在每次渲染 / 更新时都调用该效果,但是给定的函数只有在其参数之一更改的情况下才会再次运行,因此我们可以结合 componentDidMount 和 componentDidUpdate 来达到与 useEffect 相似的结果。遗憾的是我们仍然需要在 componentWillUnmount 中手动做最后的清理工作。同样,从渲染器中调用效果函数也有点难看。为了获得与 useEffect 完全相同的结果,React 需要为其添加支持。最重要的是,useEffect 不应被视为使用 Funclass 的现实动机。它本身就是一个现实动机,而且也可以为类实现。
你可以在此处查看 effect 函数的实现:
https://gist.github.com/Niryo/82127a23af88b45f7668146f5a866aa2
如果你想查看其实际效果,请参考这里的运行示例:
https://jsfiddle.net/cnkgjb53/3/
4. 性 能
我们发现类组件会在无意中导向一些模式,这些模式会让这些优化回退到较慢的路径。类也为当下的一些工具设置了障碍。例如,类的缩小效果不佳,并且让热重载变得很不可靠。
React 团队说类很难优化和缩小,而 Funclass 应该能带来一些进步。好吧,关于这一点我只想讲一件事——给我看看数字。
我至今找不到任何文章,也找不到任何我可以克隆并运行的基准测试演示应用,用来对比 Funclass 和类的性能。我们之所以还没有看到这样的演示,原因并不复杂——Funclass 需要以某种方式实现 this(你喜欢的话也可以叫它 useRef),因此我敢说让类难以优化的那些问题也会影响 Funclass。
无论如何,如果不能提供数字证据的话,关于性能的所有辩论实际上都没有意义,因此我们并不能将其用作真正可靠的论据。
5. Funclass 没那么冗长
你可以找到许多将类转换为 Funclass 来减少代码量的示例,但是大多数(如果不是全部)示例都利用了 useEffect hook 来组合 componentDidMount 和 componentWillUnmount,从而获得显著的效果。但正如我之前所说,useEffect 不应被视为 Funclass 的优势,并且如果你忽略它所带来的代码精简比例,那么剩下的效果就不值一提了。而且,如果你尝试使用 useMemo、useCallback 等来优化 Funclass,你甚至可能得到比等效的类更冗长的代码。在对比小型组件和常见组件时 Funclass 无疑会获胜,因为类有一些固有的样板,无论你的类有多小,你都需要付出这些代价。但是在对比大型组件时,你几乎看不到它们之间有什么差异,甚至有时就像我说的那样,类可以更加简洁。
最后我得谈一谈 useContext:useContext 实际上是对我们当前为类提供的原始上下文 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 团队选择只改进 useContextAPI 而不是常规上下文 API 呢?我不知道。但这并不意味着 Funclass 本质上更干净。这意味着 React 应该为类实现相同的 API 改进,这样才是更好的办法。
关于动机的话题我们已经质疑了这么多内容了,下面我们看一下关于 Funclass 还有哪些我不喜欢的东西。
6. 隐藏的副作用
在 Funclass 的 useEffect 的实现中,最令我困扰的事情中有一个是,给定组件的副作用有哪些是不清不楚的。使用类时,如果你想了解组件挂载时在做什么,只需检查 componentDidMount 中的代码或检查构造函数即可。如果看到重复的调用,那就可能该检查一下 componentDidUpdate 了。但使用新的 useEffect hook 时,副作用可能会深深地嵌套在代码中隐藏起来。
假设我们检测到一些不必要的服务器调用。我们查看可疑组件的代码,然后看到以下内容:
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(() => {
//** lots of code, all related to some animations that are relevant for loading contacts*//
}, [loadingStatus]);
//..rest of code
}
好的,事情开始变得棘手起来。隐藏的副作用在哪里?如果我们深入调查 useSwipeToRefresh,将看到:
export const useSwipeToRefresh = (loadingStatus) => {
// ..lot's of code
// ...
useEffect(() => {
if(loadingStatus === 'refresing') {
refreshContacts(); // bingo! our hidden sideEffect!
}
}); //<== we forgot the dependencies array!
}
我们发现了隐藏的效果。refreshContacts 会在每个组件渲染上意外调用获取联系人。在大型代码库和某些结构不良的组件中,嵌套的 useEffect 可能会带来让人头疼的麻烦。
我并不是说你用类就不会编写错误的代码,但是 Funclass 更容易出错,并且如果没有严格定义的生命周期方法结构,做坏事情就会容易得多。
7. 膨胀的 API
在类旁边添加 hooks API 后,React 的 API 实际上增加了一倍。现在每个人都需要学习两种完全不同的方法。我必须说,新 API 比旧 API 晦涩得多。获得以前的 props 和状态之类本该很简单的事情,正成为面试新人时很好的面试材料。你能否在不借助谷歌的情况下写一个 hook 来获取上一个 props?
像 React 这样的大型库在 API 中添加如此巨大的更改时必须非常谨慎,而且这里的动机其实并没有那么充分。
8. 缺乏声明性
在我看来,Funclass 比类更混乱。例如找到组件的入口点要难得多——在类中你只要找到 render 函数即可,但是对于 Funclass 来说,想找到主要的 return 语句很困难。另,理解不同的 useEffect 语句并了解组件的流程也困难许多,而相比之下,常规的生命周期方法为你提供了一些很好的提示,告诉你在哪里寻找代码。如果我正在寻找某种初始化逻辑,我将跳转(VS Code 中是 cmd+shift+o)到 componentDidMount。如果我正在寻找某种更新机制,则可能会跳到 componentDidUpdate,等等。使用 Funclass 时,我发现在大型组件内定位要难得多。
9. 将所有内容耦合到 React
人们开始使用特定的 React 库来做一些简单的事情,这些事情大多由纯逻辑组成,并且很容易与 React 解耦。看一下从一个称为 react-use 的库中导入的位置跟踪 hook:
https://github.com/streamich/react-use/blob/master/docs/useLocation.md
import {useLocation} from 'react-use';
const Demo = () => {
const state = useLocation();
return (
<div>
{JSON.stringify(state)}
</div>
);
};
但是,只使用一个纯常规库不是更好吗?像这样的东西:
import {tracker} from 'someVanillaJsTracker';
const Demo = () => {
const [location, setLocation] = useState({});
useEffect() {
tracker.onChange(setLocation);
}, []);
return (
<div>
{JSON.stringify(state)}
</div>
);
};
会更冗长吗?是的。第一个解决方案肯定更短。但是第二种解决方案会让 JS 世界与 React 解耦,而增加几行代码的代价相比之下不值一提。自定义 hooks 太容易让我们将纯逻辑耦合到 React 状态上了,并且这些库正像山火一样飞速扩散。
10. 总之感觉不对
你会有那种觉得某件事不对头的感觉吗?这就是我对 Funclass 的感觉。有时我会专注于具体的问题,但有时我只是会有一种总体上的感觉,觉得我们走错了路。当你发现的是一个好的概念时,你会发现事情都会顺风顺水。但是,当你为错误的概念而苦苦挣扎时,事实证明你需要添加越来越多的具体内容和规则才能让事情正常运作下去。使用 hooks 时,就会出现越来越多的怪异事物,出现更多“useful”的 hooks 来帮助你做一些其实很简单的事情,这就意味着有更多的东西要学习。如果我们在日常工作中需要那么多工具,却只是为了隐藏一些奇怪的并发症,那么这就足以说明我们是走错路了。
几年前,当我从 Angular 1.5 切换到 React 时,我曾赞叹 React 的 API 是如此简单,文档也如此之薄。Angular 曾有大量文档,需要花费你几天时间来了解所有内容,包括摘要机制、不同的编译阶段、transclude、绑定、模板等。这就足够让我意识到有什么东西出问题了。另一方面,React 第一眼看上去就很顺眼,你可以在几个小时内浏览完整个文档,然后就可以放心上手了。可是在第一次、第二次以及之后无数次尝试 hooks 时,我发现自己被迫一次又一次地回到文档中寻找答案。
11. 重要说明
阅读了一些评论后,我发现许多人认为我是类的拥护者。好吧,但这并不是事实。
类有很多缺点,但 Funclass 的缺陷更加突出。正如我在文章开始时说过的,类是一个概念,而不是语法。还记得那些可怕的原型语法吗?它们用最尴尬的方式达成了和类一样的目标。这就是我对 Funclass 的看法。你用不着因为讨厌旧的原型语法而喜欢类,也不必因为讨厌 Funclass 而喜欢类:)
这不是 OOP 与函数式编程之间的斗争,因为 Funclass 和函数式编程并没有什么关系,并且严格来说,无论是否使用类,使用 React 编写应用都不是 OOP。
12. 结 论
我并不想打搅大家的雅兴,但我真的认为 Funclass 可能是 React 社区发生的第二大糟糕的事件(Redux 仍然排名第一)。它给本就脆弱的生态系统带来了另一场毫无用处的争论,目前尚不清楚 Funclass 到底是推荐的路径,还是说它只是另一个新增特性,是否用它取决于个人喜好。
我希望 React 社区能够觉醒,并呼吁在 Funclass 和类的特性之间保持平衡。我们可以在类中提供更好的 Context API,并且可以为类提供 useEffect 甚至 use State 之类的东西。如果需要,React 应该让我们保留继续使用类的权利,而不是不断为 Funclass 添加更多专属特性,从而强行杀死类。
顺便说一句,在 2017 年底,我发表了一篇题为“Redux 的丑陋一面”的帖子,今天,甚至 Redux 的创建者 Dan Abramov 自己也已经承认 Redux 是一个巨大的错误:
https://mobile.twitter.com/dan_abramov/status/1191495127358935040
历史是在重演吗?时间会证明一切。
无论如何,我和我的队友决定暂时坚持使用类,并使用基于 Mobx 的解决方案作为状态管理工具。我认为,在独立开发人员和团队开发人员之间,hooks 的普及率存在很大差异——hooks 的缺陷在大型代码库中更加明显,你需要在这种代码库中处理其他人的代码。我个人真的希望 React 可以把所有 hooks ctrl+z 掉。
我将开始研究一个 RFC,该 RFC 将为 React 提供一个简单、干净、内置的状态管理解决方案,这个解决方案一劳永逸地解决共享状态逻辑的问题,希望这个方法不会像 Funclass 那样尴尬。
延伸阅读:
https://medium.com/swlh/the-ugly-side-of-hooks-584f0f8136b6