[译]Mixins 是反模式

Posted by jiananshi on 2019-04-10

“如何在不同的组件间共用一部分代码?”,这是学习 React 的人最常问的问题之一,对此,我们的答案总是让他们组合使用组件,你可以定义一个组件然后在其他组件中引用他。

如何使用组合来解决问题并不总是那么清晰明确,React 受函数式影响,但是在绝大多数 JS 库都是面向对象的,这一点对于无论是 Facebook 内工作的人还是其他开发者都很难放弃他们原本习惯的开发方式。

为了降低学习成本,我们引入了包括 mixin 在内的接口,他的目标是当你不确定如何通过组合解决某个代码复用的问题时提供一个额外的选择。

距离 React 发布已经过去三年了,很多处理视图的库也都实现了类似 React Component 的模型,通过组合而非继承创建界面已经非常普遍了,我们也对 React Component 模型更加自信了。这篇文章中我们会讨论 mixin 带来的问题,并且我们会针对这些场景提供候选方案,这些方案经过我们的验证要比使用 mixin 有更好的拓展性。

为什么 Mixin 很脆弱

React 在 Facebook 的应用已经从几个组件进化到了成千上万个组件,这给了我们一个机会观察其他人是怎样使用 React 的,感谢声明式渲染和从上到下的数据流,很多使用 React 的团队可以不影响开发新功能的情况下修复 bug。

然而还是有很多不可避免的情况,一些用 React 写的代码变得难以理解,有时我们会发现一些开发者连用都不敢用的组件,因为他们太脆弱了,这其中大部分原因都是 mixin 造成的,尽管当时我还没在 Facebook 工作,但我也客观的描述了我遇到非常糟糕的对 mixin 的使用 Mixins Are Dead. Long Live Composition – Dan Abramov – Medium

我并不是说 mixin 是一个失败的设计理念,他在其他语言和范式里得到了很好的应用,甚至包括一些函数式编程语言,在 Facebook 我们也有大量用到非常类似 mixin 的地方,话虽如此,我们还是认为在 React 中 mixin 是不必要的而且会很容易带来问题,下面我将对此作出解释。

Mixins 带来了隐式的依赖

有时候组件会依赖 mixin 上的方法,比如 getClassName(),有的情况则是 mixin 调用组件上的方法,比如 renderHeader(),由于 JavaScript 是一门动态语言,想要强制声明这些依赖非常困难。

Mixin 打破了以往你可以通过在组件中搜索所有 state key 出现的地方并更改它们,设想你写了一个有状态的组件,你的同事为他加了一个 mixin,一段时间后你想把这个状态提升到父组件上去,你会记得更新这个 mixin 改为从 prop 中读取吗?又或者其他组件也在用这个 mixin 呢?

这些隐式的依赖让项目变得很难上手,一个组件的 render 方法可能会调用一些并不是在自己本身类上定义的方法,那么我们可以移除这些调用吗?如果是定义在 mixin 上的,那么具体是哪一个 mixin 呢?你只能一个个搜索长长的 mixin 的列表,并且由于 mixin 可以嵌套,这个过程会无比痛苦。

通常,mixin 之间是互相依赖的,移除其中一个可能会导致其他的崩溃,在这种情况下很难说明白 mixin 中的数据流是怎样的,以及依赖的顺序是怎样的,同组件不同的是,mixin 并不是继承的关系,它们是扁平的共用同一个命名空间。

Mixins 会造成命名冲突

两个 mixin 可能并不能同时共用,举个例子:如果 FluxListenerMixin 定义了 handleChange 方法,而 WindowSizeMixin 也定义了一个方法叫做 handleChange,那么你就无法同时使用这两个mixin,同样的你自己的组件上也不能有同名方法。

即使你能很好的处理自己的 mixin 方法的命名,但是当你遇到其他的组件也许已经直接调用这些冲突的命名时,你不得不去一一修改他们。

更进一步,如果你的命名同第三方库的 mixin 命名冲突了,那就不只是改个名字就完事了,你不得不把自己组件上方法的命名改的面目全非。

即使站在 mixin 的创作者角度来说情况也是很恶劣的,哪怕是在 mixin 上加一个方法都会有潜在的 breaking change,因为组件上可能已经有这个方法了,又或者共用的其他 mixin 已经在用这个方法了,一旦添加之后,mixin 就变得很难变更,重构的成本非常高。

Mixins 会带来滚雪球般的复杂度

即使 mixin 一开始看起来很简单,随着时间的推移它们倾向于变得越来越复杂,下面的场景是一个真实的例子:

一个组件需要维护鼠标悬停的状态,为了让逻辑尽可能复用,你可能会实现一个 HoverMixin,其中抽象了 handleMouseEnterhandleMouseLeaveisHovering 方法,接着有人需要实现一个 tooltip,他们不想重复实现悬停部分的代码,所以创建了 TooltipMixin 并包含了 HoverMixinTooltipMixincomponentDidUpdate 阶段通过 isHovering 判断是否展示 tooltip。

几个月后,有人希望 tooltip 的方向是可以配置的,为了防止重复代码,他们为 TooltipMixin 新增了一个 getTooltipOptions 方法,与此同时,popover 也在使用 HoverMixin,popover 的悬停延时同 tooltip 不同,因此有人为 ToolTipMixin 新增加了一个 getHoverOptions 方法,现在这些 mixin 彻底耦合在一起了。

如果没有新的需求目前这种情况也算说得过去,不过这个解决方案的拓展性很差,假设你想要在一个组件中展示多个 tooltip 呢?你不能在一个组件中多次定义同一个 mixin,如果 tooltip 需要在用户引导的时候出现而不是 hover 到上面的时候才出现呢?好吧如果你决定把 TooltipMixinHoverMixin 拆出来的话,祝你好运。再举一个例子,如果 hover 的地方和 tooltip 展示的地方不是同一个组件呢?你不能简单的将 state 提升到父组件,mixin 同组件不一样,他不能适应这种变化。

每一次有新的需求都会令 mixin 更难理解,组件间共享同一个 mixin 会使他们耦合程度越来越深,mixin 上新增的任何功能都会影响到所有使用他的组件,如果不重复代码或者引入更多的依赖增加 mixin 之间的间接联系,几乎无法分离出一个“更小单元的 mixin”。最终封装会越来越失控,直到没人能理解这段代码是怎么 work 的。

这跟我们创建 React 之前遇到的问题非常类似,最终这些问题被声明式渲染、从上到下的数据流以及封装组件解决了。在 Facebook 我们把 mixin 迁移到了其他方案,效果非常令人满意,下面我们将逐一介绍这些方案。

从 Mixin 迁移

首先要说明的是,mixin 并没有被废弃,如果你还在使用 React.createClass() 你也许希望继续使用 mixin,我们只是说未来不推荐使用 mixin。

接下来的每一节都将举出一个 Facebook 代码内的实际情况做例子,我们会针对每种场景给出一我们觉得比 mixin 更好的解决方案,示例代码都是用 es5 写的,当你不需要用 mixin 的时候你就可以切换到 es6 class。

我们希望这个清单可以帮助到你,如果有的场景我们没有覆盖到请告诉我们,我们可以完善它甚至证明我们是错的。

性能优化

最常用的 mixin 之一是 PureRenderMixin – React,你也许也用过它在 props 和 state 在 shallow equal 的情况下避免没必要的 re-render

1
2
3
4
5
6
7
8
var PureRenderMixin = require('react-addons-pure-render-mixin');

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

// ...

});

解决方案
不使用 mixin 的方案是可以使用 Shallow Compare – React 函数:

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);
},

// ...

});

我们知道每次都这么写一遍很繁琐,在下一个版本中我们计划推出 React.PureComponent,他和 PureRenderMixin 用的比较算法是一样的。

订阅通知和边际影响

第二种常见的 mixin 是将组件注册到了一个第三方的数据源上,不管这个数据源是 Flux,Rx Observerable 又或者是其他的,他们基本上做的事都是在 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;

解决方案
如果只有一个组件是这样的,可以直接把订阅这部分逻辑写在这个组件中,避免过度抽象。

如果有多个组件是这样的,推荐的方式是使用高阶组件 。这个名字听起来很高大上,下面我们详细看一下他是如何工作的。

高阶组件介绍
让我们先抛开 React 的概念,考虑下面两个函数对数字进行加法和乘法并输出结果:

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;
}

这两个方法目前看起来没啥用,不过他们可以帮助我们说明接下来我们要应用在组件上的一种模式。

假设现在我们要将 log 功能从这两个函数中抽出来,一个优雅的方式是使用 HOC,也就是一个接受一个函数参数并返回一个函数的函数。

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 的 HOC 让我们可以编写没有 log 功能的 addmultiply 并且之后在上面报一层获得 addAndLogmultiplyAndLog

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);

HOC 同这个非常类似,只不过 HOC 是应用在 React 组件上的,接下来我们会分两步介绍如何从 mixin 迁移到 HOC。

第一步,我们将 CommentList 组件拆成 child 和 parent,child 只关心如何渲染评论,parent 会建立订阅并且将最新的数据通过 props 传递给 child。

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;

现在只剩下最后一步了。

还记得我们之前创建的 withLogging() 接收一个函数并返回一个包装过的函数吗?我们可以在 React 组件上应用类似的模式。

我们会创建一个新的函数叫做:withSubscription(WrappedComponent),他的参数可以是任何一个 React 组件,接下来我们会将 CommentList 传进去。

这个函数会反回另一个组件,返回的组件会处理订阅并且根据数据渲染 <WrappedComponent />

我们管这种模式叫做:「高阶组件」。

被包装的函数无论是通过 createClass、ES6 class 或者函数定义都没关系,因为这一层组合是在 React render 阶段发生的,而不是通过函数调用。如果 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} />;
}
});
}

现在我们可以创建一个由 withSubscription 包装 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;

现在我们知道 HOC 为什么更好了,下面我们看一个不使用 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
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 function component
// because it doesn't use lifecycle methods 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);

HOC 是一个拓展性很好的模式,你可以通过传递给它额外的参数,毕竟他甚至都不算是 React 的一个特性,他只不过是一个函数,接收组件并且返回组件而已。

像其他任何方案一样,HOC 也有自己的不足,举个例子,如果你的应用大量使用 ref,你会发现通过 HOC 包裹的组件将 ref 指向了外层的组件,实践中我们不鼓励使用 ref 来做组件间通信,因此我们认为这不是很大的问题。未来我们也许会加入 ref forwarding 来解决这个问题。

渲染逻辑

接下来的场景是在组件中共享渲染逻辑,下面是一个典型的示范:

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 来渲染 header,他们都需要定义 getHeaderText 方法。

解决方案

如果你在 mixin 中看到了渲染逻辑,那么这说明是时候抽一个组件出来了。

抛开 RowMxin,我们定义了一个 <RowHeader> 组件,同时我们会移除定义一个 getHeaderText 方法的做法,通过 React 中标准的自顶向下的数据流的方式:传递 props。

最后,这些组件目前都不需要状态和生命周期,我们可以将它们定义为函数:

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>
);
}

Props 确保组件的依赖明确,易于替换并且可以配合 Flow 或者 TypeScript 使用。

注意:将组件定义为函数并非必须的,直接用组件也没关系,我们这里之所以使用函数是便于阅读和说明,这里的场景并不需要什么额外的功能。

Context

另外一些 mixin 是用来生产和消费 React.Context 的,Context 是一个试验性的不稳定的功能,有一些已知的问题,未来也可能会改变 API,除非你非常肯定没有其他解决方案了,否则我们不推荐使用。

尽管如此,如果你已经在用 Context 了,那么你可能通过 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;

解决方案

我们认可对消费 Context 的组件隐藏他的使用是一个好主意,前提是 Context 的 API 稳定下来,然而我们推荐使用 HOC 而非 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 的方案,我们建议你给他提一个 issue 并且链接到本文让他们提供 HOC 的方案。

Utility Method

有的时候你想在组件中共享一些工具函数:

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 模块通过 import 引入,这也会使它们更易于测试或是在组件外使用:

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>
)
}
});

其他场景

有的时候开发者会在生命周期方法上加一些日志功能,未来我们计划在React Devtool API 中提供,不过这需要一些时间,你也许想继续使用原来的 mixin 方案。

如果你通过组件、HOC 和工具函数依然无法实现某些功能,那可能意味着需要 React 原生来提供,请给我们提 issue 告诉我们你使用 mixin 的场景,我们会帮助你找到其他方式或是在 React 内实现你的需求。

这并不意味着 mixin 被废弃了,你依然可以在 React.createClass 中继续使用,当 ES6 classes 使用的更加广泛并且在 React 中使用它的一些问题得到解决后,我们也许会将 React.createClass 单独打包,因为大部分用户并不需要它了。即使这样,我们也会保证 mixin 依然可用。

我们相信以上内容已经囊括了绝大多数 mixin 的使用场景,我们建议您尝试着不使用 mixin 编写 React 代码。