React Hooks 时代的高阶组件
最初发表于https://www.developerway.com。该网站还有更多类似的文章 😉
React Hooks 真的淘汰了高阶组件吗?它们唯一的用途就是作为过去遗留的组件,存在于我们应用程序的一些老旧角落里。高阶组件到底是什么?我们当初为什么需要它们?
回答这些问题并证明即使在现代应用程序中,高阶组件对于某些类型的任务仍然有用。
但让我们从头开始。
什么是高阶组件?
根据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} />;
};
这里的关键是函数的返回部分——它只是一个组件,就像任何其他组件一样。与渲染 props 模式类似,我们需要将 props 传递给返回的组件,否则它们将被吞噬。
然后,当使用它的时候,它看起来会像这样:
const Button = ({ onClick }) => <button onClick={func}>Button</button>;
const ButtonWithSomeLogic = withSomeLogic(Button);
你将Button
组件传递给函数,它会返回 new Button
,其中包含高阶组件中定义的所有逻辑。然后这个按钮就可以像其他按钮一样使用了:
const SomePage = () => {
return (
<>
<Button />
<ButtonWithSomeLogic />
</>
);
};
如果我们想创建一个思维导图来标明什么东西可以放在哪里,它可能看起来像这样:
在 codesandbox 中尝试这些示例。
在引入 hooks 之前,高阶组件被广泛用于访问上下文和任何外部数据订阅。Redux connect或react-router 的 withRouter
函数就是高阶组件:它们接受一个组件,向其中注入一些 props,然后返回它。
// location is injected by the withRouter higher-order component
// would you guessed that by the look at this component alone?
const SomeComponent = ({ location }) => {
return <>{location}</>;
};
const ComponentWithRouter = withRouter(SomeComponent);
可见,高阶组件的编写和理解都相当复杂。所以,当Hooks被引入时,大家都转向使用它们也就不足为奇了。
现在,我们不用再创建复杂的思维导图来了解哪个道具放在哪里,也不用试图弄清楚location
道具是如何最终到达那里的,我们只需这样写:
const SomeComponent = () => {
// we see immediately where location is coming from
const { location } = useRouter();
return <>{location}</>;
};
组件中发生的一切都可以从上到下读取,并且所有数据的来源都是显而易见的,这大大简化了调试和开发。
虽然钩子可能取代了 90% 的共享逻辑问题和 100% 的访问上下文的用例,但仍然有至少三种类型的功能,其中高阶组件可能会有用。
让我们看一下这些。
第一:增强回调和 React 生命周期事件
假设你需要在某些回调函数中发送某种高级日志记录。例如,当你点击一个按钮时,你想发送一些包含数据的日志事件。如何使用钩子来实现呢?你可能会有一个Button
带有回调函数的组件onClick
:
type ButtonProps = {
onClick: () => void;
children: ReactNode;
}
const Button = ({ onClick }: { onClick }: ButtonProps) => {
return <button onClick={onClick}>{children}</button>
}
然后在消费者方面,您将挂接到该回调并将日志事件发送到那里:
const SomePage = () => {
const log = useLoggingSystem();
const onClick = () => {
log('Button was clicked');
};
return <Button onClick={() => onClick}>Click here</Button>;
};
如果你只想触发一两个事件,这当然没问题。但是,如果你想让日志事件在整个应用中始终触发,只要按钮被点击,该怎么办?我们或许可以把它嵌入到Button
组件本身中。
const Button = ({ onClick }: { onClick }: ButtonProps) => {
const log = useLoggingSystem();
const onButtonClick = () => {
log('Button was clicked')
onClick();
}
return <button onClick={() => onClick()}>{children}</button>
}
但接下来呢?为了获得正确的日志,你还必须发送一些数据。我们当然可以Button
用一些loggingData
props 来扩展组件并将其传递下去:
const Button = ({ onClick, loggingData }: { onClick, loggingData }: ButtonProps) => {
const onButtonClick = () => {
log('Button was clicked', loggingData)
onClick();
}
return <button onClick={() => onButtonClick()}>{children}</button>
}
但是,如果您想在其他组件上发生点击时触发相同的事件,该怎么办?Button
通常,点击事件并非我们应用中唯一可以点击的事件。如果我想在一个ListItem
组件上添加相同的日志记录,该怎么办?复制粘贴完全相同的逻辑到那里?
const ListItem = ({ onClick, loggingData }: { onClick, loggingData }: ListItemProps) => {
const onListItemClick = () => {
log('List item was clicked', loggingData)
onClick();
}
return <Item onClick={() => onListItemClick()}>{children}</Item>
}
复制粘贴太多,容易出错,而且有人忘记根据我的口味改变一些东西。
本质上,我想要的是将“某些触发回调 - 发送一些日志事件”的逻辑封装onClick
在某个地方,然后在我想要的任何组件中重新使用它,而无需以任何方式更改这些组件的代码。
这是第一个钩子无用但高阶组件可能派上用场的用例。
高阶组件增强 onClick 回调
我不需要到处复制粘贴“点击发生→记录数据”的逻辑,我只需创建一个withLoggingOnClick
函数即可:
- 接受组件作为参数
- 拦截其 onClick 回调
- 将我需要的数据发送到用于日志记录的任何外部框架
- 返回带有 onClick 回调的组件以供进一步使用
它看起来是这样的:
type Base = { onClick: () => void };
// just a function that accepts Component as an argument
export const withLoggingOnClick = <TProps extends Base>(Component: ComponentType<TProps>) => {
return (props: TProps) => {
const onClick = () => {
console.log('Log on click something');
// don't forget to call onClick that is coming from props!
// we're overriding it below
props.onClick();
};
// return original component with all the props
// and overriding onClick with our own callback
return <Component {...props} onClick={onClick} />;
};
};
现在我可以把它添加到任何我想要的组件中了。我可以内置一个Button
日志功能:
export const ButtonWithLoggingOnClick = withLoggingOnClick(SimpleButton);
或者在列表项中使用它:
export const ListItemWithLoggingOnClick = withLoggingOnClick(ListItem);
或者任何其他我想要跟踪回调的组件onClick
。无需更改任何Button
代码ListItem
!
向高阶组件添加数据
现在,剩下要做的就是从外部向日志函数添加一些数据。考虑到高阶组件只不过是一个函数,我们可以轻松做到这一点。只需要向函数添加一些其他参数,就是这样:
type Base = { onClick: () => void };
export const withLoggingOnClickWithParams = <TProps extends Base>(
Component: ComponentType<TProps>,
// adding some params as a second argument to the function
params: { text: string },
) => {
return (props: TProps) => {
const onClick = () => {
// accessing params that we passed as an argument here
// everything else stays the same
console.log('Log on click: ', params.text);
props.onClick();
};
return <Component {...props} onClick={onClick} />;
};
};
现在,当我们用高阶组件包装按钮时,我们可以传递想要记录的文本:
const ButtonWithLoggingOnClickWithParams = withLoggingOnClickWithParams(SimpleButton, { text: 'button component' });
在消费者方面,我们只需将此按钮用作普通按钮组件,而不必担心日志记录文本:
const Page = () => {
return <ButtonWithLoggingOnClickWithParams onClick={onClickCallback}>Click me</ButtonWithLoggingOnClickWithParams>;
};
但如果我们真的想处理这些文本怎么办?如果我们想在按钮使用的不同情境下发送不同的文本怎么办?我们肯定不想为每个用例创建一百万个包装按钮。
解决这个问题也很容易:与其将该文本作为函数的参数传递,不如将其作为 prop 注入到结果按钮中。代码如下:
type Base = { onClick: () => void };
export const withLoggingOnClickWithProps = <TProps extends Base>(Component: ComponentType<TProps>) => {
// our returned component will now have additional logText prop
return (props: TProps & { logText: string }) => {
const onClick = () => {
// accessing it here, as any other props
console.log('Log on click: ', props.logText);
props.onClick();
};
return <Component {...props} onClick={onClick} />;
};
};
然后像这样使用它:
const Page = () => {
return (
<ButtonWithLoggingOnClickWithProps onClick={onClickCallback} logText="this is Page button">
Click me
</ButtonWithLoggingOnClickWithProps>
);
};
在安装时发送数据而不是单击
这里我们并不局限于点击和回调。记住,它们只是组件,我们可以做任何我们想做的事情 🙂 我们可以使用 React 提供的所有功能。例如,我们可以在组件挂载时发送以下日志事件:
export const withLoggingOnMount = <TProps extends unknown>(Component: ComponentType<TProps>) => {
return (props: TProps) => {
// no more overriding onClick, just adding normal useEffect
useEffect(() => {
console.log('log on mount');
}, []);
// just passing props intact
return <Component {...props} />;
};
};
这与通过参数或属性添加数据完全一样onClick
。这里就不复制粘贴了,大家可以在codesandbox中查看。
我们甚至可以更进一步,将所有这些高阶组件组合起来:
export const SuperButton = withLoggingOnClick(
withLoggingOnClickWithParams(
withLoggingOnClickWithProps(
withLoggingOnMount(withLoggingOnMountWithParams(withLoggingOnMountWithProps(SimpleButton), { text: 'button component' })),
),
{ text: 'button component' },
),
);
当然,我们不应该这么做😅 就算有办法,也不一定就是好主意。想象一下,在调试的时候,要追踪哪些 props 是从哪里来的。如果我们真的需要把几个高阶组件合并成一个,至少可以更具体一点:
const ButtonWithLoggingOnClick = withLoggingOnClick(SimpleButton);
const ButtonWithLoggingOnClickAndMount = withLoggingOnMount(ButtonWithLoggingOnClick);
// etc
第二:拦截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 }: ModalProps) => {
const onKeyPress = (event) => event.stopPropagation();
return <div onKeyPress={onKeyPress}>...// dialog code</div>;
};
但与日志记录一样onClick
,如果您有多个组件想要查看此逻辑,该怎么办?
这里我们可以做的是再次实现一个高阶组件。这次它将接受一个组件,将其包装在一个带有 onKeyPress 回调的 div 中,并返回该组件,无需任何修改。
export const withSupressKeyPress = <TProps extends unknown>(Component: ComponentType<TProps>) => {
return (props: TProps) => {
const onKeyPress = (event) => {
event.stopPropagation();
};
return (
<div onKeyPress={onKeyPress}>
<Component {...props} />
</div>
);
};
};
就是这样!现在我们可以在任何地方使用它了:
const ModalWithSupressedKeyPress = withSupressKeyPress(Modal);
const DropdownWithSupressedKeyPress = withSupressKeyPress(Dropdown);
// etc
这里需要注意一点:焦点管理。为了使上述代码能够正常工作,你需要确保对话框类型的组件在打开时将焦点移动到打开的部分。不过,关于焦点管理,这又是另一个话题了,下次再聊吧。
为了举例说明,我们可以在模态框中手动包含自动对焦功能:
const Modal = () => {
const ref = useRef<HTMLDivElement>();
useEffect(() => {
// when modal is mounted, focus the element to which the ref is attached
if (ref.current) ref.current.focus();
}, []);
// adding tabIndex and ref to the div, so now it's focusable
return <div tabIndex={1} ref={ref}>
<!-- modal code -->
</div>
}
在codesandbox中尝试一下。
第三:上下文选择器
高阶组件的最后一个非常有趣的用例:类似选择器的 React 上下文功能。众所周知,当上下文值发生变化时,无论其特定状态部分是否发生变化,都会导致所有上下文使用者重新渲染。(如果您不了解,这里有一篇文章:如何使用 Context 编写高性能 React 应用)。
在进入高阶组件之前,让我们先实现一些上下文和形式。
我们将使用 Contextid
和name
API 来改变这些:
type Context = {
id: string;
name: string;
setId: (val: string) => void;
setName: (val: string) => void;
};
const defaultValue = {
id: 'FormId',
name: '',
setId: () => undefined,
setName: () => undefined,
};
const FormContext = createContext<Context>(defaultValue);
export const useFormContext = () => useContext(FormContext);
export const FormProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState(defaultValue);
const value = useMemo(() => {
return {
id: state.id,
name: state.name,
setId: (id: string) => setState({ ...state, id }),
setName: (name: string) => setState({ ...state, name }),
};
}, [state]);
return <FormContext.Provider value={value}>{children}</FormContext.Provider>;
};
然后是一些带有Name
和Countries
组件的形式
const Form = () => {
return (
<form css={pageCss}>
<Name />
<Countries />
</form>
);
};
export const Page = () => {
return (
<FormProvider>
<Form />
</FormProvider>
);
};
在组件中,Name
我们将有一个输入来改变的值Context
,并且Countries
只需使用id
表单的来获取国家列表(不会实现实际的获取,对于示例来说并不重要:
const Countries = () => {
// using only id from context here
const { id } = useFormContext();
console.log("Countries re-render");
return (
<div>
<h3>List on countries for form: {id}</h3>
<ul>
<li>Australia</li>
<li>USA</li>
<!-- etc -->
</ul>
</div>
);
};
const Name = () => {
// using name and changing it here
const { name, setName } = useFormContext();
return <input onChange={(event) => setName(event.target.value)} value={name} />;
};
现在,每次我们在名称输入框中输入内容时,都会更新上下文值,这将导致所有使用上下文的组件(包括“Countries”)重新渲染。而这个问题无法通过将该值提取到 hook 中并进行记忆来解决:hooks 总是会重新渲染(为什么自定义 React hooks 可能会破坏你的应用性能)。
当然,如果这种行为导致性能问题,还有其他方法可以处理它,例如记忆渲染树的各个部分或将 Context 拆分为不同的提供程序(请参阅描述这些技术的文章:如何使用 Context 编写高性能 React 应用程序和如何编写高性能 React 代码:规则、模式、注意事项)。
但上述所有技术的一大缺点是,它们不可共享,需要根据具体情况逐一实现。如果我们有一些类似 select 的功能,可以id
在任何组件中安全地提取该值,而无需在整个应用程序中进行大规模重构,那不是很好吗useMemo
?
有趣的是,我们可以用高阶组件实现类似的功能。原因是组件拥有 hooks 所不具备的功能:它们可以记忆数据,并阻止向下传递到子组件的重新渲染链。基本上,这就能满足我们的需求了:
export const withFormIdSelector = <TProps extends unknown>(
Component: ComponentType<TProps & { formId: string }>
) => {
const MemoisedComponent = React.memo(Component) as ComponentType<
TProps & { formId: string }
>;
return (props: TProps) => {
const { id } = useFormContext();
return <MemoisedComponent {...props} formId={id} />;
};
};
然后我们就可以创建CountriesWithFormIdSelector
组件:
// formId prop here is injected by the higher-order component below
const CountriesWithFormId = ({ formId }: { formId: string }) => {
console.log("Countries with selector re-render");
return (
<-- code is the same as before -->
);
};
const CountriesWithFormIdSelector = withFormIdSelector(CountriesWithFormId);
并在我们的表单中使用它:
const Form = () => {
return (
<form css={pageCss}>
<Name />
<CountriesWithFormIdSelector />
</form>
);
};
在 codesandbox 中查看
。 在输入时要特别注意控制台的输出 - CountryWithFormIdSelector 组件不会重新渲染!
通用 React 上下文选择器
withFormIdSelector
很有趣,而且可以用于小型的基于上下文的应用。但如果它能通用一点就好了,这样我们就不用为每个状态属性都实现一个自定义选择器了。
只要有一点创意,就没问题!看看选择器本身:
export const withContextSelector = <TProps extends unknown, TValue extends unknown>(
Component: ComponentType<TProps & Record<string, TValue>>,
selectors: Record<string, (data: Context) => TValue>,
): ComponentType<Record<string, TValue>> => {
// memoising component generally for every prop
const MemoisedComponent = React.memo(Component) as ComponentType<Record<string, TValue>>;
return (props: TProps & Record<string, TValue>) => {
// extracting everything from context
const data = useFormContext();
// mapping keys that are coming from "selectors" argument
// to data from context
const contextProps = Object.keys(selectors).reduce((acc, key) => {
acc[key] = selectors[key](data);
return acc;
}, {});
// spreading all props to the memoised component
return <MemoisedComponent {...props} {...contextProps} />;
};
};
然后将其与组件一起使用:
// props are injected by the higher order component below
const CountriesWithFormId = ({ formId, countryName }: { formId: string; countryName: string }) => {
console.log('Countries with selector re-render');
return (
<div>
<h3>List of countries for form: {formId}</h3>
Selected country: {countryName}
<ul>
<li>Australia</li>
<li>USA</li>
</ul>
</div>
);
};
// mapping props to selector functions
const CountriesWithFormIdSelector = withContextSelector(CountriesWithFormId, {
formId: (data) => data.id,
countryName: (data) => data.country,
});
就是这样!我们基本上在 context 上实现了 mini-Redux,甚至具备了应有的功能 🙂 在codesandboxmapStateToProps
中查看。
今天就到这里!希望高阶组件不再只是一些令人望而生畏的遗留妖怪,而是一些即使在现代应用中也能派上用场的东西。让我们回顾一下它们的用例:
- 通过附加功能增强回调和 React 生命周期事件,例如发送日志或分析事件
- 拦截 DOM 事件,例如在模态对话框打开时阻止全局键盘快捷键
- 提取 Context 的一部分,而不会导致组件中不必要的重新渲染
愿和平与爱与你同在✌🏼
...
最初发表于https://www.developerway.com。该网站还有更多类似的文章 😉
订阅时事通讯、在 LinkedIn 上联系或在 Twitter 上关注,以便在下一篇文章发布时立即收到通知。
文章来源:https://dev.to/adevnadia/higher-order-components-in-react-hooks-era-3d9b