目前团队在维护的项目中,主要使用的前端框架是 Vue.js。最近组内同学在学习 React 的时候,问了我一个问题:Vue.js 的 mixins 在 React 中是如何实现和使用的?
当时自己回忆了一下之前两个 React 的项目经历,给出的的回答是:mixins 本质上是用于代码的复用,自己在写 React 的时候,基本上是通过抽象通用组件和函数、props实现的代码复用,没太考虑过使用 mixins。
事后反复思考这个问题,觉得自己给的答案并不能让自己满意,于是又去学习了一个。学习过程中发现了 React 官博的一篇文章Mixins Considered Harmful,看过之后基本解决了心中的困惑。
现将原文翻译如下:
“如何在不同的组件之间进行代码复用?”是人们在学习 React 时首先要问的问题之一,我们的答案始终是使用组件组合来重用代码。你可以定义一个组件并在其他几个组件中使用它。
某种确定的模式通过用组合的方式进行解决并不总是显而易见的。React 受函数式编程的影响,但进入了以面向对象库为主的领域。Facebook 内部和外部的工程师很难放弃他们习惯的模式。
为了简化最初的采用和学习,我们在 React 中加入了一些“逃生”功能。mixin 系统就是其中一种逃生方法,它的目标是当你不知道如何通过组合解决问题时,给你另外一种在组件之间重用代码的方法。
React 已经发布三年了,前端领域的技术在这三年中也发生了翻天覆地的变化。现在多个用于构建用户界面的前端框架都采用了类似于 React 的组件模型。使用基于继承的组合声明式的构建用户界面,不再是新鲜事物。我们对 React 组件模型也更加自信,我们在内部和社区都看到了它的许多创造性的用途。
在这篇文章中,我们将思考通常是由 mixin 引起的问题。然后,我们将为相同的用例提出几种替代模式。我们发现随着项目代码复杂度的增加,这些替代的模式的可扩展性比使用 mixin 更好。
为什么 Mixins 在 React 中不被推荐使用
在 Facebook,React 的使用量已经从几个组件增长到了成千上万个。这给了我们一个去思考人们该如何更好的使用 React 的窗口。由于声明性渲染和自上而下的数据流,许多团队在采用 React 去实现一些新功能时,能够解决了很多之前难以去解决的 bug。
然而,使用 React 的一些代码不可避免地变得难以理解。有时候,React 团队会看到开发者不敢去碰某些项目中的组件。这些组件维护起来很容易出 bug,对新开发人员造成负担,最终组件会变得让创建这个组件的人都难以去维护。这种巨大的开发成本大部分是由 mixin 引起的。当时,我并没有在Facebook工作,但是在写下了我可怕的 mixin 之后,我得出了相同的结论—Mixins已经,组合永生。
这并不意味着 mixin 本身就是坏的。开发者们成功地在不同的语言和范例中使用 mixin,包括在一些函数式编程语言中。在 Facebook,我们广泛使用 Hack 中与 mixin 非常相似的特性。但是,我们依旧认为 mixin 在 React 代码库中是不必要的和有问题的。下面的内容是我们这样认为的原因。
Mixin 引入了隐式依赖关系
有时一个组件依赖于mixin中定义的某个方法,比如getClassName()。有时候是相反的,mixin在组件上调用renderHeader()方法。 JavaScript是一种动态语言,所以很难执行或记录这些依赖关系。
Mixin打破了常见且通常安全的假设,即可以通过在组件文件中搜索其出现来重命名状态键或方法。你可能会写一个有状态的组件,然后你的同事可能会添加一个读取这个状态的mixin。在几个月内,您可能需要将该状态移至父组件,以便与兄弟组件共享。你会记得更新mixin来读取道具吗?如果现在其他组件也使用这个mixin呢?
这些隐含的依赖性使新团队成员难以贡献代码库。一个组件的render()方法可能会引用一些未在该类上定义的方法。移除安全吗?也许它是在一个mixin中定义的。但是其中哪一个呢?您需要向上滚动到mixin列表,打开每个文件,然后查找此方法。更糟的是,mixin可以指定他们自己的mixin,所以搜索可以很深入。
mixin经常依赖于其他的mixin,而删除其中的一个会打破另一个。在这些情况下,告诉数据如何进出mixin是非常棘手的,以及它们的依赖关系图是什么样的。与组件不同,mixin不构成层次结构:它们被夷为平地并在相同的名称空间中运行。
Mixins导致名称冲突
不能保证两个特定的mixin可以一起使用。例如,如果FluxListenerMixin定义了handleChange()和WindowSizeMixin定义了handleChange(),则不能一起使用它们。你也不能在你自己的组件上定义一个带有这个名字的方法。
如果你控制混入代码,这不是什么大不了的事情。如果发生冲突,可以在其中一个mixin上重命名该方法。然而,这很棘手,因为一些组件或其他mixin可能已经直接调用这个方法,你也需要找到并修复这些调用。
如果你的名字与第三方包中的mixin有冲突,你不能只重命名一个方法。相反,您必须在您的组件上使用尴尬的方法名称以避免冲突。
mixin作者的情况并不好。即使向mixin添加一个新的方法总是一个潜在的重大改变,因为一个名称相同的方法可能已经存在于一些使用它的组件,直接或通过另一个mixin。一旦写入,mixin很难删除或更改。不好的想法不会被重构,因为重构风险太大。
Mixin导致复杂的滚雪球
即使mixin开始简单,随着时间的推移,它们往往会变得复杂。下面的例子是基于我在代码库中看到的真实场景。
组件需要一些状态来跟踪鼠标悬停。为了保持这个逻辑可重用,你可以将handleMouseEnter(),handleMouseLeave()和isHovering()提取到一个HoverMixin中。接下来,有人需要实施一个工具提示。他们不想复制HoverMixin中的逻辑,以便创建使用HoverMixin的TooltipMixin。 TooltipMixin读取HoverMixin在其componentDidUpdate()中提供的isHovering(),并显示或隐藏工具提示。
几个月后,有人想让工具提示方向可配置。为了避免代码重复,他们添加了一个名为getTooltipOptions()的新的可选方法到TooltipMixin。到目前为止,显示popovers的组件也使用HoverMixin。然而,popovers需要不同的悬停延迟。为了解决这个问题,有人增加了对可选的getHoverOptions()方法的支持,并在TooltipMixin中实现它。那些混合现在是紧密耦合的。
没有新的要求,这很好。但是这个解决方案不能很好地扩展。如果你想支持在单个组件中显示多个工具提示呢?你不能在一个组件中定义两次相同的mixin。如果工具提示需要在导游中自动显示,而不是悬停,怎么办?祝你好运解耦TooltipMixin从HoverMixin。如果您需要支持悬停区域和工具提示锚点位于不同组件的情况,该怎么办?你不能轻易地把混入到父组件中的状态提升起来。与组件不同,mixin不会自然地适应这种变化。
每一个新的要求都会让混音变得更难理解。使用相同mixin的组件越来越与时间耦合。任何新的能力被添加到使用该mixin的所有组件。如果没有复制代码或在mixin之间引入更多的依赖性和间接性,就没有办法拆分mixin的“更简单”的部分。逐渐地,封装边界逐渐消失,由于很难改变或移除现有的混合,他们不断变得抽象,直到没人理解它们是如何工作的。
这些与我们在React之前构建应用程序的问题是一样的。我们发现它们是通过声明性渲染,自顶向下的数据流和封装组件来解决的。在Facebook上,我们一直在迁移我们的代码以使用替代模式来混合,我们对结果普遍感到满意。你可以阅读下面的模式。
从Mixin迁移
让我们清楚地说明mixin在技术上并不被弃用。 如果你使用React.createClass(),你可以继续使用它们。 我们只是说他们不适合我们,所以我们不会推荐将来使用他们。
下面的每个部分对应于我们在Facebook代码库中找到的mixin使用模式。 对于他们每个人,我们描述这个问题和一个我们认为比mixin更好的解决方案。 这些例子是用ES5编写的,但是一旦你不需要mixin,你可以根据需要切换到ES6类。
我们希望你觉得这个列表有帮助。 请让我们知道,如果我们错过了重要的用例,所以我们可以修改列表或被证明是错误的!
性能优化
PureRenderMixin是最常用的混合类型之一。 当道具和状态与以前的道具和状态相似时,你可能会在某些组件中使用它来防止不必要的重新渲染:
1 | var PureRenderMixin = require('react-addons-pure-render-mixin'); |
解决方案
要表达同样没有mixin,你可以直接使用shallowCompare函数:
1 | var shallowCompare = require('react-addons-shallow-compare'); |
如果你使用一个自定义的mixin以不同的算法实现一个shouldComponentUpdate函数,我们建议从一个模块导出这个单一的函数,并直接从你的组件中调用它。
我们知道更多的打字可能会令人讨厌。 对于最常见的情况,我们计划在下一个小版本中引入一个名为React.PureComponent的新基类。 它使用与PureRenderMixin相同的浅度比较。
订阅和副作用
我们遇到的第二种最常见的mixin类型是将一个React组件订阅到第三方数据源的mixin。 无论这个数据源是Flux Store还是Rx Observable,这个模式都非常相似:订阅是在componentDidMount中创建的,在componentWillUnmount中销毁,并且更改处理程序调用this.setState()。
1 | var SubscriptionMixin = { |
解决方案
如果只有一个组件订阅了这个数据源,那么将订阅逻辑嵌入组件中就好了。 避免过早抽象。
如果有几个组件使用这个mixin来订阅一个数据源,避免重复的一个好方法就是使用一个叫做“higher-order components”的模式。 这听起来有些吓人,所以我们将仔细看看这种模式是如何从组件模型中自然产生的。
更高级的组件解释
让我们忘记一秒钟的反应。 考虑这两个函数添加和乘数,记录结果,因为他们这样做:
1 | function addAndLog(x, y) { |
这两个函数并不是非常有用,但是它们帮助我们演示了一种可以稍后应用于组件的模式。
假设我们想从这些函数中提取日志逻辑而不改变他们的签名。 我们应该怎么做? 一个优雅的解决方案是编写一个更高阶的函数,也就是一个将函数作为参数并返回一个函数的函数。
再一次,这听起来比实际上更吓人:
1 | function withLogging(wrappedFunction) { |
withLogging高级函数让我们可以在没有日志语句的情况下编写add和multiply,然后将它们包装到addAndLog和multiplyAndLog中,并使用与之前完全相同的签名:
1 | function add(x, y) { |
高阶组件是一个非常相似的模式,但应用于React中的组件。 我们将通过两步来从mixin中应用这个转换。
作为第一步,我们将把我们的CommentList组件分成两部分,一个孩子和一个父母。 孩子只会关心评论。 家长将通过道具设置订阅并将最新的数据传递给孩子。
1 | // This is a child component. |
只剩下最后一步了。
还记得我们如何使用Logging()取得一个函数并返回包含它的另一个函数? 我们可以将相似的模式应用于React组件。
我们将编写一个名为withSubscription(WrappedComponent)的新函数。 它的参数可以是任何React组件。 我们将通过CommentList作为WrappedComponent,但是我们也可以将SubScription()应用到代码库中的任何其他组件。
这个函数会返回另一个组件。 返回的组件将管理订阅并使用当前数据呈现
我们称这种模式为“高阶组件”。
构图发生在React渲染级别,而不是直接函数调用。 这就是为什么使用createClass()定义包装组件是ES6类还是一个函数并不重要。 如果WrappedComponent是一个React组件,那么通过withSubscription()创建的组件可以呈现它。
1 | // This function takes a component... |
现在我们可以通过应用CommentList来申明CommentListWithSubscription:
1 | var CommentList = React.createClass({ |
再探解决方案
现在我们可以更好地理解高阶组件,下面再看看不涉及mixins的完整解决方案。 内嵌评论有一些小的更改:
1 | function withSubscription(WrappedComponent) { |
高阶组件是一个强大的模式。 如果要进一步自定义其行为,可以将其他参数传递给它们。 毕竟,它们甚至不是React的一个特征。 它们只是接收组件并返回包装它们的组件的函数。
像任何解决方案一样,高阶元件也有自己的陷阱。 例如,如果大量使用refs,则可能会注意到将某些东西包装到高阶组件中会将ref更改为指向包装组件。 在实践中,我们不鼓励使用ref来进行组件通信,所以我们不认为这是一个大问题。 将来,我们可能会考虑将ref转发给React来解决这个问题。
渲染逻辑
我们在代码库中发现的mixin的下一个最常见的用例是在组件之间共享渲染逻辑。
这是这种模式的典型例子:
1 | var RowMixin = { |
多个组件可能共享RowMixin来渲染标题,并且每个组件都需要定义getHeaderText()。
解决方案
如果你看到一个mixin中的渲染逻辑,那么就是抽出一个组件的时候了!
而不是RowMixin,我们将定义一个
最后,由于这些组件当前都不需要生命周期钩子或状态,我们可以将它们声明为简单的函数:
1 | function RowHeader(props) { |
通过使用Flow和TypeScript这样的工具,支持组件的依赖关系是显式的,易于替换和可执行的。
注意:
将组件定义为函数不是必需的。 使用生命周期钩子和状态也没什么问题 - 它们是一流的React功能。
我们在这个例子中使用了功能组件,因为它们更容易阅读,我们不需要那些额外的功能,但类将工作得很好。
Context
我们发现的另一组mixin是提供和使用React上下文的助手。 上下文是一个实验性的不稳定的特征,有一定的问题,将来可能会改变它的API。 我们不建议您使用它,除非您确信没有其他方法可以解决您的问题。
不过,如果你今天已经使用上下文,你可能已经隐藏它的使用mixin这样的:
1 | var RouterMixin = { |
解决方案
我们同意,在上下文API稳定之前,隐藏使用组件的上下文使用是个好主意。 不过,我们建议使用更高阶的组件,而不是mixin。
让包装组件从上下文中获取一些东西,并用道具传递给包装组件:
1 | function withRouter(WrappedComponent) { |
如果您使用的是仅提供混音功能的第三方库,我们鼓励您向他们提交链接到该帖子的问题,以便他们可以提供更高级别的组件。 与此同时,您可以用完全相同的方式自行创建一个更高阶的组件。
效用方法
有时,mixin仅用于在组件之间共享实用程序功能:
1 | var ColorMixin = { |
解决方案
将实用函数放入常规JavaScript模块并导入它们。 这也使得更容易测试它们或者在你的组件之外使用它们:
1 | var getLuminance = require('../utils/getLuminance'); |
其他用例
有时候人们会使用mixins来选择性地将日志记录添加到某些组件的生命周期钩子中。在将来,我们打算提供一个官方的DevTools API,它可以让你在不触及组件的情况下实现类似的东西。然而,这仍然是一项正在进行的工作。如果你严重依赖日志混合进行调试,你可能想要继续使用这些混合。
如果你不能用一个组件,一个更高级的组件或者一个实用程序模块来完成某件事情,那么React应该意味着它应该提供这个开箱即用的功能。提出一个问题,告诉我们你的mixins用例,我们将帮助你考虑替代方案,或者实现你的功能请求。
Mixins在传统意义上不被弃用。您可以继续使用它们与React.createClass(),因为我们不会进一步改变它。最终,随着ES6类获得更多的采用,并且React中的可用性问题得到解决,我们可能会将React.createClass()分割成单独的包,因为大多数人不需要它。即使在这种情况下,你的旧mixins仍然会继续工作。
我们相信,上述替代方案对绝大多数情况都更好,我们邀请您尝试在不使用mixin的情况下编写React应用程序。