Mixins 被认为是有害的

目前团队在维护的项目中,主要使用的前端框架是 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
2
3
4
5
6
7
8
var PureRenderMixin = require('react-addons-pure-render-mixin');

var Button = React.createClass({
mixins: [PureRenderMixin],

// ...

});

解决方案

要表达同样没有mixin,你可以直接使用shallowCompare函数:

1
2
3
4
5
6
7
8
9
10
var shallowCompare = require('react-addons-shallow-compare');

var Button = React.createClass({
shouldComponentUpdate: function(nextProps, nextState) {
return shallowCompare(this, nextProps, nextState);
},

// ...

});

如果你使用一个自定义的mixin以不同的算法实现一个shouldComponentUpdate函数,我们建议从一个模块导出这个单一的函数,并直接从你的组件中调用它。

我们知道更多的打字可能会令人讨厌。 对于最常见的情况,我们计划在下一个小版本中引入一个名为React.PureComponent的新基类。 它使用与PureRenderMixin相同的浅度比较。

订阅和副作用

我们遇到的第二种最常见的mixin类型是将一个React组件订阅到第三方数据源的mixin。 无论这个数据源是Flux Store还是Rx Observable,这个模式都非常相似:订阅是在componentDidMount中创建的,在componentWillUnmount中销毁,并且更改处理程序调用this.setState()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
var SubscriptionMixin = {
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},

componentDidMount: function() {
DataSource.addChangeListener(this.handleChange);
},

componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},

handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
}
};

var CommentList = React.createClass({
mixins: [SubscriptionMixin],

render: function() {
// Reading comments from state managed by mixin.
var comments = this.state.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}
});

module.exports = CommentList;

解决方案

如果只有一个组件订阅了这个数据源,那么将订阅逻辑嵌入组件中就好了。 避免过早抽象。

如果有几个组件使用这个mixin来订阅一个数据源,避免重复的一个好方法就是使用一个叫做“higher-order components”的模式。 这听起来有些吓人,所以我们将仔细看看这种模式是如何从组件模型中自然产生的。

更高级的组件解释

让我们忘记一秒钟的反应。 考虑这两个函数添加和乘数,记录结果,因为他们这样做:

1
2
3
4
5
6
7
8
9
10
11
function addAndLog(x, y) {
var result = x + y;
console.log('result:', result);
return result;
}

function multiplyAndLog(x, y) {
var result = x * y;
console.log('result:', result);
return result;
}

这两个函数并不是非常有用,但是它们帮助我们演示了一种可以稍后应用于组件的模式。

假设我们想从这些函数中提取日志逻辑而不改变他们的签名。 我们应该怎么做? 一个优雅的解决方案是编写一个更高阶的函数,也就是一个将函数作为参数并返回一个函数的函数。

再一次,这听起来比实际上更吓人:

1
2
3
4
5
6
7
8
9
10
function withLogging(wrappedFunction) {
// Return a function with the same API...
return function(x, y) {
// ... that calls the original function
var result = wrappedFunction(x, y);
// ... but also logs its result!
console.log('result:', result);
return result;
};
}

withLogging高级函数让我们可以在没有日志语句的情况下编写add和multiply,然后将它们包装到addAndLog和multiplyAndLog中,并使用与之前完全相同的签名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function add(x, y) {
return x + y;
}

function multiply(x, y) {
return x * y;
}

function withLogging(wrappedFunction) {
return function(x, y) {
var result = wrappedFunction(x, y);
console.log('result:', result);
return result;
};
}

// Equivalent to writing addAndLog by hand:
var addAndLog = withLogging(add);

// Equivalent to writing multiplyAndLog by hand:
var multiplyAndLog = withLogging(multiply);

高阶组件是一个非常相似的模式,但应用于React中的组件。 我们将通过两步来从mixin中应用这个转换。

作为第一步,我们将把我们的CommentList组件分成两部分,一个孩子和一个父母。 孩子只会关心评论。 家长将通过道具设置订阅并将最新的数据传递给孩子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// This is a child component.
// It only renders the comments it receives as props.
var CommentList = React.createClass({
render: function() {
// Note: now reading from props rather than state.
var comments = this.props.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}
});

// This is a parent component.
// It subscribes to the data source and renders <CommentList />.
var CommentListWithSubscription = React.createClass({
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},

componentDidMount: function() {
DataSource.addChangeListener(this.handleChange);
},

componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},

handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
},

render: function() {
// We pass the current state as props to CommentList.
return <CommentList comments={this.state.comments} />;
}
});

module.exports = CommentListWithSubscription;

只剩下最后一步了。

还记得我们如何使用Logging()取得一个函数并返回包含它的另一个函数? 我们可以将相似的模式应用于React组件。

我们将编写一个名为withSubscription(WrappedComponent)的新函数。 它的参数可以是任何React组件。 我们将通过CommentList作为WrappedComponent,但是我们也可以将SubScription()应用到代码库中的任何其他组件。

这个函数会返回另一个组件。 返回的组件将管理订阅并使用当前数据呈现

我们称这种模式为“高阶组件”。

构图发生在React渲染级别,而不是直接函数调用。 这就是为什么使用createClass()定义包装组件是ES6类还是一个函数并不重要。 如果WrappedComponent是一个React组件,那么通过withSubscription()创建的组件可以呈现它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// This function takes a component...
function withSubscription(WrappedComponent) {
// ...and returns another component...
return React.createClass({
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},

componentDidMount: function() {
// ... that takes care of the subscription...
DataSource.addChangeListener(this.handleChange);
},

componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},

handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
},

render: function() {
// ... and renders the wrapped component with the fresh data!
return <WrappedComponent comments={this.state.comments} />;
}
});
}

现在我们可以通过应用CommentList来申明CommentListWithSubscription:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var CommentList = React.createClass({
render: function() {
var comments = this.props.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}
});

// withSubscription() returns a new component that
// is subscribed to the data source and renders
// <CommentList /> with up-to-date data.
var CommentListWithSubscription = withSubscription(CommentList);

// The rest of the app is interested in the subscribed component
// so we export it instead of the original unwrapped CommentList.
module.exports = CommentListWithSubscription;

再探解决方案

现在我们可以更好地理解高阶组件,下面再看看不涉及mixins的完整解决方案。 内嵌评论有一些小的更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function withSubscription(WrappedComponent) {
return React.createClass({
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},

componentDidMount: function() {
DataSource.addChangeListener(this.handleChange);
},

componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},

handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
},

render: function() {
// Use JSX spread syntax to pass all props and state down automatically.
return <WrappedComponent {...this.props} {...this.state} />;
}
});
}

// Optional change: convert CommentList to a functional component
// because it doesn't use lifecycle hooks or state.
function CommentList(props) {
var comments = props.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}

// Instead of declaring CommentListWithSubscription,
// we export the wrapped component right away.
module.exports = withSubscription(CommentList);

高阶组件是一个强大的模式。 如果要进一步自定义其行为,可以将其他参数传递给它们。 毕竟,它们甚至不是React的一个特征。 它们只是接收组件并返回包装它们的组件的函数。

像任何解决方案一样,高阶元件也有自己的陷阱。 例如,如果大量使用refs,则可能会注意到将某些东西包装到高阶组件中会将ref更改为指向包装组件。 在实践中,我们不鼓励使用ref来进行组件通信,所以我们不认为这是一个大问题。 将来,我们可能会考虑将ref转发给React来解决这个问题。

渲染逻辑

我们在代码库中发现的mixin的下一个最常见的用例是在组件之间共享渲染逻辑。

这是这种模式的典型例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var RowMixin = {
// Called by components from render()
renderHeader: function() {
return (
<div className='row-header'>
<h1>
{this.getHeaderText() /* Defined by components */}
</h1>
</div>
);
}
};

var UserRow = React.createClass({
mixins: [RowMixin],

// Called by RowMixin.renderHeader()
getHeaderText: function() {
return this.props.user.fullName;
},

render: function() {
return (
<div>
{this.renderHeader() /* Defined by RowMixin */}
<h2>{this.props.user.biography}</h2>
</div>
)
}
});

多个组件可能共享RowMixin来渲染标题,并且每个组件都需要定义getHeaderText()。

解决方案

如果你看到一个mixin中的渲染逻辑,那么就是抽出一个组件的时候了!

而不是RowMixin,我们将定义一个组件。 我们还将用React中的顶层数据流的标准机制替换定义getHeaderText()方法的约定:传递道具。

最后,由于这些组件当前都不需要生命周期钩子或状态,我们可以将它们声明为简单的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function RowHeader(props) {
return (
<div className='row-header'>
<h1>{props.text}</h1>
</div>
);
}

function UserRow(props) {
return (
<div>
<RowHeader text={props.user.fullName} />
<h2>{props.user.biography}</h2>
</div>
);
}

通过使用Flow和TypeScript这样的工具,支持组件的依赖关系是显式的,易于替换和可执行的。

注意:
将组件定义为函数不是必需的。 使用生命周期钩子和状态也没什么问题 - 它们是一流的React功能。
我们在这个例子中使用了功能组件,因为它们更容易阅读,我们不需要那些额外的功能,但类将工作得很好。

Context

我们发现的另一组mixin是提供和使用React上下文的助手。 上下文是一个实验性的不稳定的特征,有一定的问题,将来可能会改变它的API。 我们不建议您使用它,除非您确信没有其他方法可以解决您的问题。

不过,如果你今天已经使用上下文,你可能已经隐藏它的使用mixin这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var RouterMixin = {
contextTypes: {
router: React.PropTypes.object.isRequired
},

// The mixin provides a method so that components
// don't have to use the context API directly.
push: function(path) {
this.context.router.push(path)
}
};

var Link = React.createClass({
mixins: [RouterMixin],

handleClick: function(e) {
e.stopPropagation();

// This method is defined in RouterMixin.
this.push(this.props.to);
},

render: function() {
return (
<a onClick={this.handleClick}>
{this.props.children}
</a>
);
}
});

module.exports = Link;

解决方案

我们同意,在上下文API稳定之前,隐藏使用组件的上下文使用是个好主意。 不过,我们建议使用更高阶的组件,而不是mixin。

让包装组件从上下文中获取一些东西,并用道具传递给包装组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function withRouter(WrappedComponent) {
return React.createClass({
contextTypes: {
router: React.PropTypes.object.isRequired
},

render: function() {
// The wrapper component reads something from the context
// and passes it down as a prop to the wrapped component.
var router = this.context.router;
return <WrappedComponent {...this.props} router={router} />;
}
});
};

var Link = React.createClass({
handleClick: function(e) {
e.stopPropagation();

// The wrapped component uses props instead of context.
this.props.router.push(this.props.to);
},

render: function() {
return (
<a onClick={this.handleClick}>
{this.props.children}
</a>
);
}
});

// Don't forget to wrap the component!
module.exports = withRouter(Link);

如果您使用的是仅提供混音功能的第三方库,我们鼓励您向他们提交链接到该帖子的问题,以便他们可以提供更高级别的组件。 与此同时,您可以用完全相同的方式自行创建一个更高阶的组件。

效用方法

有时,mixin仅用于在组件之间共享实用程序功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var ColorMixin = {
getLuminance(color) {
var c = parseInt(color, 16);
var r = (c & 0xFF0000) >> 16;
var g = (c & 0x00FF00) >> 8;
var b = (c & 0x0000FF);
return (0.299 * r + 0.587 * g + 0.114 * b);
}
};

var Button = React.createClass({
mixins: [ColorMixin],

render: function() {
var theme = this.getLuminance(this.props.color) > 160 ? 'dark' : 'light';
return (
<div className={theme}>
{this.props.children}
</div>
)
}
});

解决方案

将实用函数放入常规JavaScript模块并导入它们。 这也使得更容易测试它们或者在你的组件之外使用它们:

1
2
3
4
5
6
7
8
9
10
11
12
var getLuminance = require('../utils/getLuminance');

var Button = React.createClass({
render: function() {
var theme = getLuminance(this.props.color) > 160 ? 'dark' : 'light';
return (
<div className={theme}>
{this.props.children}
</div>
)
}
});

其他用例

有时候人们会使用mixins来选择性地将日志记录添加到某些组件的生命周期钩子中。在将来,我们打算提供一个官方的DevTools API,它可以让你在不触及组件的情况下实现类似的东西。然而,这仍然是一项正在进行的工作。如果你严重依赖日志混合进行调试,你可能想要继续使用这些混合。

如果你不能用一个组件,一个更高级的组件或者一个实用程序模块来完成某件事情,那么React应该意味着它应该提供这个开箱即用的功能。提出一个问题,告诉我们你的mixins用例,我们将帮助你考虑替代方案,或者实现你的功能请求。

Mixins在传统意义上不被弃用。您可以继续使用它们与React.createClass(),因为我们不会进一步改变它。最终,随着ES6类获得更多的采用,并且React中的可用性问题得到解决,我们可能会将React.createClass()分割成单独的包,因为大多数人不需要它。即使在这种情况下,你的旧mixins仍然会继续工作。

我们相信,上述替代方案对绝大多数情况都更好,我们邀请您尝试在不使用mixin的情况下编写React应用程序。


相关阅读

志遥 wechat
微信扫一扫,我在丁香园记公众号等你