[翻译 | 转载]React Higher-Order Components(https://tylermcginnis.com/react-higher-order-components/)
开始前我们要关注两件事。第一,我们将要讨论的只是一个模式。甚至都不是React中的玩意儿,而是组件架构。第二,讨论的也不是构建一个React app必须的知识。不看这篇文章,你也能构造出好的React应用。但,就像建造别的东西,能用的工具越多,产出就越好。如果你要写一个React app,你的"工具箱"里没有高阶组件,那肯定内什么。
在听说Don't Repeat Yourself(D.R.Y)
这个咒语之前,你在研究软件开发的路上一定不会走的太远。有时可能会有点过分,但对于大多数时候,它是一个值得追求的目标。在这篇文章中,我们将讨论在React代码库中实现DRY的最流行模式,即高阶组件。然而,在我们探索解决办法之前,我们首先必须充分理解这个问题。
假设我们负责重新创建一个类似于Stripe的仪表板。随着大多数项目的进行,直到最后一切都很顺利。就在我们认为即将完成的时候,你注意到,当悬停在某些元素上面时,仪表板上显示一堆不同的工具提示。
有几种方法可以解决这个问题。您决定使用的是检测单个组件的悬停状态,并从该状态决定显示或不显示工具提示。需要将悬停检测功能添加到三个组件-Info
、TrendChart
和DailyChart
.
我们从Info
开始。现在它只是一个简单的SVG图标。
class Info extends React.Component {
render() {
return (
<svg
className="Icon-svg Icon--hoverable-svg"
height={this.props.height}
viewBox="0 0 16 16"
width="16"
>
<path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
</svg>
)
}
}
现在我们需要为它添加功能,这样它就可以检测到它是否悬停在上面。我们可以使用React附带的onMouseOver
和onMouseOut
鼠标事件。当组件悬停时,我们将调用传递的onMouseOver
函数;当鼠标滑出时,我们将调用传递的onMouseOut
函数。为此,我们将向组件添加一个悬停状态属性,以便在悬停状态改变时重新渲染,显示或隐藏工具提示。
class Info extends React.Component {
state = { hovering: false }
mouseOver = () => this.setState({ hovering: true })
mouseOut = () => this.setState({ hovering: flase })
render() {
return (
<>
{this.state.hovering === true ? <Tooltip id={this.props.id} /> : null }
<svg
className="Icon-svg Icon--hoverable-svg"
height={this.props.height}
viewBox="0 0 16 16"
width="16"
>
<path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
</svg>
</>
)
}
}
现在我们需要将相同的功能添加到其他两个组件中,TrendChart
和DailyChart
。如果它没有坏,就不要修理它。Info
悬停逻辑工作得很好,所以复用这些代码。
class TrendChart extends React.Component {
state = { hovering: false }
mouseOver = () => this.setState({ hovering: true })
mouseOut = () => this.setState({ hovering: flase })
render() {
return (
<>
{this.state.hovering === true ? <Tooltip id={this.props.id} /> : null }
<svg
className="Icon-svg Icon--hoverable-svg"
height={this.props.height}
viewBox="0 0 16 16"
width="16"
>
<path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
</svg>
</>
)
}
}
class DailyChart extends React.Component {
state = { hovering: false }
mouseOver = () => this.setState({ hovering: true })
mouseOut = () => this.setState({ hovering: flase })
render() {
return (
<>
{this.state.hovering === true ? <Tooltip id={this.props.id} /> : null }
<svg
className="Icon-svg Icon--hoverable-svg"
height={this.props.height}
viewBox="0 0 16 16"
width="16"
>
<path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
</svg>
</>
)
}
}
你可能写过这样的React。这不是世界末日,但也不是很D.R.Y
。我们复用了完全相同的悬停处理逻辑。
在这一点上,问题非常清楚,我们期望避免在新组件需要时再去复制悬停处理。那么解决办法是什么?在我们开始之前,让我们先来讨论一些编程概念,这些概念将使我们更容易理解解决方案,callbacks
和higher-order functions
。
在JavaScript中,函数是“一等公民”。这意味着,就像对象/数组/字符串可以分配给变量、作为参数传递给函数或从函数返回一样,函数也可以。
function add(x, y) {
return x + y
}
function addFive(x, addReference) {
return addReference(x, 5)
}
addFive(10, add) // 15
如果你不习惯的话,你可能觉得有点怪。我们将add
函数作为参数传递给了addFive
函数,将其重命名为addReference
,然后调用它。
执行此操作时,作为参数传递的函数称为回调函数,接收回调函数的函数称为高阶函数。
因为这些术语重要,所以这里用相同的代码,只是将其中的变量重新命名,以匹配它们解释的概念。
function add(x, y) {
return x + y
}
function higherOrderFunction (x, callback) {
return callback(x, 5)
}
higherOrderFunction(10, add)
这个模式看起来应该很熟悉,到处都是。如果你曾经使用过任何JavaScript数组方法、jQuery或类似lodash的库,那么您同时使用了高阶函数和回调。
[1, 2, 3].map((i) => i + 5)
_.filter([1, 2, 3, 4], (n) => n % 2 === 0)
$('#btn').on('click', () =>
console.log('Callbacks are everywhere')
)
回到我们的例子。如果我们不只是创建一个addFive
函数,我们还需要一个addTen
函数、addTwenty
函数等,那么当前的实现中,每当我们需要一个新函数时,我们就必须复制大量的代码。
function add (x, y) {
return x + y
}
function addFive (x, addReference) {
return addReference(x, 5)
}
function addTen (x, addReference) {
return addReference(x, 10)
}
function addTwenty (x, addReference) {
return addReference(x, 20)
}
addFive(10, add) // 15
addTen(10, add) // 20
addTwenty(10, add) // 30
重申一次,这并不可怕,但我们重复了很多相同的逻辑。这里的目标是能够创建尽可能多的adder
函数(addFive
、addTen
、addtwenty
等),同时尽可能减少代码重复。为了实现这一点,创建一个makeAdder
函数如何?这个函数可以接受一个number和对原始add
函数的引用。因为这个函数的目标是生成一个新的adder函数,所以我们可以让它返回一个接受要添加的number的全新函数。让我们看看代码。
function add (x, y) {
return x + y
}
function makeAdder (x, addReference) {
return function (y) {
return addReference(x, y)
}
}
const addFive = makeAdder(5, add)
const addTen = makeAdder(10, add)
const addTwenty = makeAdder(20, add)
addFive(10) // 15
addTen(10) // 20
addTwenty(10) // 30
Cool!现在我们能创建尽可能多的adder
函数,同时减少重复的代码。
这种 让一个多参数函数返回一个少参数的新函数的概念被称为"Partial Application",这是一种函数式编程技术。JavaScript的".bind"方法就是一个常见的例子。
好吧,但是这与React有什么关系,与问题有什么关系?正如创建makeAdder
高阶函数允许我们减少重复代码一样,制作类似的“高阶组件”也能以同样的方式帮助我们减少重复代码。但是,高阶组件返回的是一个渲染"callback"组件的新组件,而不是返回调用回调的新函数。太复杂了,让我们分而治之。
高阶函数
- 是一个函数
- 接收一个回调函数作为参数
- 返回一个新函数
- 返回的函数可以调用传入的回调函数
function higherOrderFunction (callback) {
return function () {
return callback()
}
}
高阶组件
- 是一个组件
- 接收一个组件作为参数
- 返回一个新组件
- 返回的组件可以渲染传入的组件
function higherOrderComponent (Component) {
return class extends React.Component {
render() {
return <Component />
}
}
}
现在我们已经基本了解了高阶组件是干嘛的,让我们开始构建我们的组件。之前的问题是,我们在所有需要该功能的组件之间复制了所有的悬停处理逻辑。
state = { hovering: false }
mouseOver = () => this.setState({ hovering: true })
mouseOut = () => this.setState({ hovering: false })
考虑到这一点,我们希望我们的高阶组件(我们将命名为withHover
)能够将该悬停逻辑封装在自身中,然后将hovering
状态传递给它渲染的组件。这将允许我们不用再复制所有的悬停逻辑,而是将其放在一个单一的位置(withHover
)。
最后,这是最终目标。每当我们需要一个知道它处于悬停状态的组件时,我们可以将原始组件传递给我们的withHover
高阶组件。
const InfoWithHover = withHover(Info)
const TrendChartWithHover = withHover(TrendChart)
const DailyChartWithHover = withHover(DailyChart)
然后,每当withHover
返回的组件被渲染时,它们都将渲染原始组件,并传递hovering
属性。
function Info ({ hovering, height }) {
return (
<>
{hovering === true ? <Tooltip id={this.props.id} /> : null}
<svg
className="Icon-svg Icon--hoverable-svg"
height={height}
viewBox="0 0 16 16" width="16">
<path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
</svg>
</>
)
}
现在我们需要做的最后一件事是实现withHover
。正如我们在上面看到的,它需要做三件事。
- 接收一个
Component
参数 - 返回一个新组件
- 渲染"Component"组件,并传递
hovering
状态给Component
参数
接收一个Component
参数
function withHover (Component) {
}
返回一个新组件
function withHover (Component) {
return class withHover extends React.Component {
}
}
渲染Component
组件,并传递hovering
状态
现在问题变成了,我们如何得到hovering
状态?好吧,我们已经有了之前构建的代码。我们只需要将其添加到新组件,然后在渲染"Component"参数组件时将悬停状态作为prop传递。
function withHover (Component) {
return class withHover extends React.Component {
state = { hovering: false }
mouseOver = () => this.setState({ hovering: true })
mouseOut = () => this.setState({ hovering: false })
render() {
return (
<div onMousemOver={this.mouseOver} onMousemOutr={this.mouseOut} >
<Component hovering={this.state.hovering} />
</div>
)
}
}
我喜欢这样来理解(以及在React文档中提到的),是一个组件将props转换为UI,一个高阶组件将一个组件转换为另一个组件。在我们的例子中,我们将的Info
、TrendChart
和DailyChart
组件转换为通过hovering
属性感知悬停状态的新组件。
到此,我们已理解了高阶组件的基本原理。不过,还有一些更重要的问题需要讨论。
如果你回顾一下我们的withHover HOC,它的一个缺点是它假设consumer可以接受一个hovering
的prop。在大多数情况下,这可能没问题,但在某些用例中可能不是这样。例如,如果组件已经有一个名为hovering
的prop怎么办?我们会有命名冲突。我们可以做的一个更改是,允许withHover
HOC的consumer指定,当悬停状态作为prop传递给组件时,期望悬停状态的名称是什么。因为withHover
只是一个函数,让我们将其更改为接受第二个参数,该参数指定将传递给组件的属性的名称。
function withHover (Component, propName = 'hovering') {
return class withHover extends React.Component {
state = { hovering: false }
mouseOver = () => this.setState({ hovering: true })
mouseOut = () => this.setState({ hovering: false })
render() {
const props = {
[propName]: this.state.hovering
}
return (
<div onMousemOver={this.mouseOver} onMousemOutr={this.mouseOut} >
<Component {...props} />
</div>
)
}
}
}
现在我们已经设置默认prop名为hovering(通过ES6的默认参数),但是如果withHover的使用者想要更改它,他们可以将新的prop name作为第二个参数传入。
function withHover(Component, propName = 'hovering') {
return class WithHover extends React.Component {
state = { hovering: false }
mouseOver = () => this.setState({ hovering: true })
mouseOut = () => this.setState({ hovering: false })
render() {
const props = {
[propName]: this.state.hovering
}
return (
<div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}>
<Component {...props} />
</div>
);
}
}
}
function Info ({ showTooltip, height }) {
return (
<>
{showTooltip === true
? <Tooltip id={this.props.id} />
: null}
<svg
className="Icon-svg Icon--hoverable-svg"
height={height}
viewBox="0 0 16 16" width="16">
<path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />
</svg>
</>
)
}
const InfoWithHover = withHover(Info, 'showTooltip')
您可能也注意到了我们实现的withHover
的另一个问题。查看我们的Info
组件,您会注意到它还应该包含一个height
属性。按照目前的设置方式,height
将是不确定的。原因是我们的withHover
组件是渲染组件的组件。目前,我们是如何设置的,除了我们创建的hovering
属性之外,我们不会向<Component/>
传递其他属性。
const InfoWithHover = withHover(Info)
...
return <InfoWithHover height="16px" />
height
属性传递给了InfoWithHover
组件。但那个组件到底是什么?是withHover
返回的组件。
function withHover(Component, propName = 'hovering') {
return class WithHover extends React.Component {
state = { hovering: false }
mouseOver = () => this.setState({ hovering: true })
mouseOut = () => this.setState({ hovering: false })
render() {
console.log(this.props) // { height: "16px" }
const props = {
[propName]: this.state.hovering
}
return (
<div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}>
<Component {...props} />
</div>
);
}
}
}
在WithHover
组件的内部this.props.height
是16px
,但在那里我们什么也没做。我们需要确保将其传递给了要渲染组件的参数。
render() {
const props = {
[propName]: this.state.hovering,
...this.props,
}
return (
<div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}>
<Component {...props} />
</div>
);
}
我们已经看到了使用高阶组件在不同组件之间重用组件逻辑而不复制代码的好处。但是,它有什么陷阱吗?是的,我们已经看过了。
使用HOC时,会发生控制反转。假设我们使用第三方HOC,比如React Router的withRouter
HOC。根据他们的文档,"withRouter
将在每次渲染时将match
、location
和history
属性传递给包裹的组件。"
class Game extends React.Component {
render() {
const { match, location, history } = this.props // From React Router
...
}
}
export default withRouter(Game)
注意我们没有创建Game
元素(即<Game/>
)。我们把我们的组件完全交给React Router,我们相信他们不仅会渲染Game
,而且会传递正确的props。我们在前面讨论hovering
命名冲突时看过这个问题。为了解决这个问题,我们决定让withHover
HOC的consumer通过第二个参数来配置prop名称。而第三方withRouter
HOC中,我们没有这个参数。如果我们的Game
组件已经在使用match
、location
或history
,那么我们就没辙了,要么修改组件中的这些属性名,要么必须不用withRouter
HOC。