接下来,我们要讨论的是高阶组件。在钩子函数出现前,高阶组件是用于共享状态和上下文的最佳模式。这个模式在今天也是常用的,特别是老的库。所以,也许在现在的代码中使用该模式也许不是最合适的,但是理解其工作原理还是必要的。
那么让我们从头开始,并在这个过程中学习吧:
- 什么是高阶组件模式
- 我们要如何使用高阶组件来提升回调函数和React生命周期事件?
- 传递数据给高阶组件的不同方式
- 如何创建可拦截DOM和键盘事件的可复用组件。
什么是高阶组件
根据 React 文档所述,高阶组件是一种用于复用组件逻辑的高级技术,该技术常用于处理横切关注点(跨多个模块的通用逻辑问题)。
在英语中,高阶组件是把组件作为其中一个参数,执行特定逻辑,然后依据参数组件返回另一个组件的函数。
它最简单的变体(什么都不做的那种)如下:
// accept a Component as an argument
const withSomeLogic = (Component) => {
// do something
// return a component that renders the component from the argument
return (props) => <Component {...props} />
}
这段代码的核心在于其返回函数 - 它是一个组件,正如其他组件一样。
之后,如果要使用它,是这么使用:
// just a button
const Button = ({ onClick }) => (
<button oncClick={onClick}>Button</button>
)
// same button, but with enchanced functionality
const ButtonWithSomeLogic = withSomeLogic(Button);
你将你的按钮(Button)组件传递给这个函数,然后它会返回一个新的按钮组件,这个新组件包含了在高阶组件中定义的任何逻辑。之后,这个按钮就可以像其他任何按钮一样被使用了:
const SomePage = () => {
return (
<>
<Button />
<ButtonWithSomeLogic />
</>
)
}
人们常常会用这个模式来为组件注入一些属性。比如说,我们可以实现一个withTheming组件来提取当前浏览器的明暗模式,然后再把这个模式传递给theme属性。这是代码:
const withTheme = (Component) => {
// isDark will come from something like context
const theme = isDark ? 'dark' : 'light';
// making sure that we pass all props to the component back
// and also inject the new one: theme
return (props) => <Component {...props} theme={theme} />
}
而现在,如果我们将它应用到我们的按钮上,那么这个按钮就能使用 theme 这个属性了:
const Button = ({ theme }) => {
// theme prop here will come from withTheme HOH below
return <button theme={theme} ...>Button</button>
}
const ButonWithTheme = withTheme(Button);
代码示例: advanced-react.com/examples/07…
在进入钩子函数时代之前,高阶组件被广泛地用于访问上下文和数据订阅。Redux的connect函数和React Router的withRouter函数都是高阶函数。
如你所见,高阶函数是比较复杂的,因此难于编写和理解。所以当钩子函数被推广后,很多人转向了钩子函数。
有了钩子函数后,我们可以更简单地实现刚才的功能:
const Button = () => {
// we see immediatle where the theme is coming from
const { theme } = useTheme();
return <button appearance={theme} ...>Button</button>
}
这种写法如此明晰,以至于从头读到尾就可以明白这个过程发生了什么,大大的简化了调试和开发的流程。
尽管钩子函数在很多场景取代了高阶函数,高阶函数在当下还是很有用的。特别是在提升回调函数性能,处理React生命周期,拦截DOM和键盘事件时,高阶组件还是很有用的。
让我看看高阶组件在这些场景是如何发挥作用的。
提升回调函数
设想一下,你需要针对某些回调发送某种高级日志记录。例如,当你点击一个按钮时,你想要发送一些带有相关数据的日志记录事件。使用钩子(hooks)的话你会怎么做呢?你很可能会有一个带有 onClick 回调的按钮(Button)组件:
const Button = ({ onClick, children }) => {
return <button onClick={onClick}>{children}</button>
}
而在消费者的视角,你可以在onClick函数内调用高级日志的钩子:
const SomePage = () => {
const log = useLoggingSystem();
const onClick = () => {
log('Button was clicked');
};
return <Button onClick={onClick}>Click here</Button>;
};
如果你只想触发一两个事件的话,这样做没问题。但要是你希望无论何时点击按钮,整个应用程序中都能持续触发日志记录事件,那该怎么办呢?我们或许可以将其融入按钮(Button)组件本身当中:
const Button = ({ onClick }) => {
const log = useLoggingSystem();
const onButtonClick = () => {
log('Button was clicked');
onClick();
};
return <button onClick={onButtonClick}>Click me</button>;
};
但这之后呢?为了更好地打日志,你还需要传送一些数据:
const Button = ({ onClick, loggingData }) => {
const onButtonClick = () => {
log('Button was clicked', loggingData);
onClick();
}
return <button onClick={onButtonClick}>Click me</button>
}
如果你想把这个打日志的功能扩展到其他组件,该怎么办?人们在app上可不仅仅是点击Button。如果我想对ListItem组件做同样的事情,该怎么办?把同样的逻辑拷贝过去吗?
const ListItem = ({ onClick, loggingData }) => {
const onListItemClick = () => {
log('List item was clicked', loggingData);
onClick();
}
return <button onClick={onButtonClick}>Click me</button>
}
太多的复制粘贴操作了,容易出错,而且可能会有人忘记按照我的要求更改某些内容。
从本质上讲,我想要的是将 “某个东西触发了 onClick 回调 —— 发送一些日志记录事件” 这样的逻辑封装在某个地方,然后可以在我想要的任何组件中复用它,而无需以任何方式更改那些组件的代码。
而这个效果,钩子函数是无法实现的,而高阶组件可以实现。
与其四处拷贝刚刚的逻辑,我们可以生成一个withLoggingOnClick函数:
- 以组件为参数
- 拦截这个组件的
onClick回掉 - 把数据发送出去
- 返回带有完好无损的 onClick 回调的组件以供后续使用。
代码会是这样的:
// just a function that accepts Component as an argument
export const withLoggingOnClick = (Component) => {
return (props) => {
const onClick = () => {
console.log('Log on click something');
props.onClick();
}
return <Component {...props} onClick={onClick} />;
}
}
现在,我可以把这个函数用于任何组件。比如:
export const ButtowWithLoggingOnClick =
withLoggingOnClick(SimpleBUtton);
又或者:
export const ListItemWithLoggingOnClick =
withLoggingOnClick(ListItem);
代码示例: advanced-react.com/examples/07…
为高阶组件添加数据
现在,我们可以为打日志函数添加外部数据。我们知道高阶组件本质上是一个函数,所以添加外部数据并不难。我们只要为这个函数添加另一个参数:
export const withLogginOnClickWithParams = (
Component,
// adding some params as a second argument to the fucntion
params,
) => {
return (props) => {
const onClick = () => {
console.log('Log on click', params.text);
props.onClick();
}
return <Component {...props} onClick={onClick} />;
}
}
现在,当我们用一个高阶组件来包裹按钮时,我们可以传递我们想要打印的文本:
const ButtonWithLoggingOnClickWithParams =
withLogginOnClickWithParams(simpleButton, {
text: 'button component',
})
而在消费者的视角,我们把这个按钮组件当作普通的按钮组件即可,不用担心传递打印文本的事情:
const Page = () => {
return (
<ButtonWithLoggingOnClickWithParams
onClick={onClickCallback}
>
Click me
</ButtonWithLoggingOnClickWithParams>
)
}
但是,如果我想自定义传递打印文本,该怎么办?
这也是很容易解决的:与其把要打印的文本当作高阶组件函数的参数传递,我们可以把它当做返回结果的函数的属性。这是代码:
export const withLogginOnClickWithParams = (Component) => {
// it will be in the props here, just extract it
return ({ logText, ...props }) => {
const onClick = () => {
// and then just use it here
console.log('Log on click: ', logText);
props.onClick();
}
return <Component {...props} onClick={onClick} />;
}
}
代码示例: advanced-react.com/examples/07…
提升React生命周期事件
我们不仅仅可以使用高阶组件处理事件和回调函数。记住,我们在处理的都是组件,所以我们可以使用React提供的能力来处理它们。比如,我们可以在一个组件被挂载时,打印相关日志:
export const withLoggingOnMount = (Component) => {
return (props) => {
// no more overriding onClick
// use normal useEffect - it's just a component!
useEffect(() => {
console.log('log on mount');
}, []);
// and pass back props intact
return <Component {...props} />
}
}
甚至可以读取属性(props),并且当某个属性发生变化时,在重新渲染时发送它们:
export const withLoggingOnReRender = (Component) => {
return ({ id, ...props }) => {
// fire logging every time "id" prop changes
useEffect(() => {
console.log('log on mount');
}, [id]);
// and pass back props intact
return <Component {...props} />
}
}
代码示例: advanced-react.com/examples/07…
拦截DOM事件
高阶组件的另一个使用场景,就是拦截DOM事件和键盘事件。想象一下,你需要为你的应用实现快捷键功能。当特定的按钮被按下时,你想实现类似打开对话框的功能。你也许会为window添加一个事件监听器:
useEffect(() => {
const keyPressListener = (event) => {
// do stuff
};
window.addEventListener('keypress', keyPressListener);
return () =>
window.removeEventListener(
);'keypress',
keyPressListener,
}, []);
然后,你的应用程序有不同的部分,比如模态对话框、下拉菜单、抽屉式菜单等等,当这些对话框打开时,你会希望阻止那个全局监听器起作用。如果只有一个对话框,你可以手动地给对话框本身添加 onKeyPress 事件处理函数,并且在那里针对该事件执行 event.stopPropagation() 操作。 这样就能阻止事件继续向上传播,避免触发全局监听器的相关逻辑了,保证在该对话框处于打开状态时,其内部操作不受全局监听器干扰。
export const Modal = ({ onClose }) => {
const onKeyPress = (event) => event.stopPropagation();
return (
<div onKeyPress={onKeyPress}>...// dialog code</div>
);
};
但这个场景和onClick的打印日志很像 - 如果你要对多个组件实现这个逻辑,该怎么办?把这段代码四处复制吗?
我们可以使用一个高阶组件来解决这个问题:
export const withSuppressKeyPress = (Component) => {
return (props) => {
const onKeyPress = (event) => {
event.stopPropagation();
};
return (
<div onKeyPress={onKeyPress}>
<Component {...props} />
</div>
);
};
};
然后,我们就可以把任何组件传递进去了:
const ModalWithSuppressedKeyPress =
withSuppressKeyPress(Modal);
const DropdownWithSuppressedKeyPress =
withSuppressKeyPress(Dropdown);
// etc
现在,当这个模态框处于打开且获取焦点的状态时,任何按键按下事件都会沿着元素层级向上冒泡,直至到达我们那个包裹着模态框的 withSuppressKeyPress 中的 div 元素,然后就会在那里停止传播。这样一来,任务就完成了,而且实现模态框组件的开发人员甚至都无需知晓或关心这件事。
代码示例: advanced-react.com/examples/07…
知识概要
- 高阶组件就是一个接受组件作为参数,并返回一个新组件的函数。这个新组件会渲染作为参数传入的那个组件。
- 我们可以将属性(props)或者额外的逻辑注入到被高阶组件包裹的组件当中。以下是示例代码:
// accept a Component as an argument
const withSomeLogic = (Component) => {
// inject some logic here
// return a component that renders the component from the
argument
// inject some prop to it
return (props) => {
// or inject some logic here
// can use React hooks here, it's just a component
return <Component {...props} some="data" />;
};
};
- 我们可以通过函数的参数或者属性(props)的方式,向高阶组件传递额外的数据。