新的 React Hooks 模式?返回一个组件
我最近听了一个播客,其中 React Router 的创建者 Michael Jackson 提到了一种带有钩子的新模式,可以返回一个组件。
一开始我不明白这和直接调用渲染函数或其他 React 组件有什么区别,而且这似乎违背了“组件负责 UI,钩子负责行为”的理念。不过我想我找到了一个用例。
在文章的最后,我会解释我是如何得出这个结论的:
function ThingWithPanel() {
let { Panel, panelProps, isOpen, openPanel, closePanel } = usePanel();
return (
<div>
{!isOpen && <button onClick={openPanel}>Open Panel</button>}
{isOpen && <button onClick={closePanel}>Close Panel</button>}
<Panel {...panelProps}>
<div>I am stuff in a panel</div>
</Panel>
</div>
);
};
而不是这个
import { Panel } from "office-ui-fabric-react/lib/Panel";
import { IconButton } from "office-ui-fabric-react/lib/Button";
import styled from "styled-components";
function ThingWithPanel() {
let [isOpen, setIsOpen] = useState(startOpen);
let openPanel = useCallback(() => {
setIsOpen(true);
}, [setIsOpen]);
let closePanel = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
// If dealing with IFrames in the Panel,
// usually want to wire up a way for the Iframed page
// to tell the Parent to close the panel
useEffect(() => {
let handler = (event) => {
try {
let msg = JSON.parse(event.data);
if (msg.type === "CLOSE_PANEL") {
closePanel();
}
} catch (err) {
// Couldn't parse json
}
};
window.addEventListener("message", handler, false);
return () => {
window.removeEventListener("message", handler);
};
});
return (
<div>
{!isOpen && <button onClick={openPanel}>Open Panel</button>}
{isOpen && <button onClick={closePanel}>Close Panel</button>}
<Panel
isOpen={isOpen}
isLightDismiss={true}
onDismiss={closePanel}
{/* Override the default Panel Header */}
onRenderNavigation={() => (
<StyledClose>
<IconButton iconProps={{ iconName: "ChromeClose" }} onClick={closePanel} />
</StyledClose>
)}
>
<div>I am stuff in a panel</div>
</Panel>
</div>
);
}
const StyledClose = styled.div`
position: absolute;
top: 5px;
right: 23px;
z-index: 10;
background: #ffffffbf;
border-radius: 50%;
opacity: 0.85;
&:hover {
opacity: 1;
}
`;
使用组件库的痛点
在工作中,我经常使用微软的 Material UI 版本Fluent UI。总的来说,我喜欢使用这个库。然而,Panel组件给我带来了一些痛点:
- 我总是必须设置
useState
来跟踪面板是否打开,然后使用它来创建打开和关闭面板的功能。 - 我必须记住那个属性,
isLightDismiss
它说的是“当用户点击面板外的按钮时关闭此面板”。它默认是关闭的,但我几乎总是打开它。 - 默认面板标题呈现一堆保留的空白,因此面板内容的顶部边距看起来很奇怪。
- 因此,我覆盖标题以将其绝对定位,以便我的内容移至面板顶部
- 因为我覆盖了标题,所以我负责在右上角呈现我自己的“关闭”按钮。
- 如果面板正在渲染 IFrame,我通常会连接一个
PostMessage
监听器,以便 IFramed 页面可以告诉父窗口关闭面板。
上面较长的代码片段实现了这些细节。
这其实没什么大不了的,但为每个Panel 实例都写那么多样板代码确实很烦人。很容易搞砸,还会增加不必要的麻烦。
顺便说一句,我并不是在批评 UI Fabric。组件库必须针对灵活性和重用性进行优化,而不是针对我的应用程序的特定偏好。
钩子救援
大多数情况下,我会将偏好设置封装到包装器组件中。但是,这Panel
比较复杂,因为isOpen
、openPanel
和closePanel
无法被封装,因为父组件需要使用它们来控制面板何时打开。
*这里将很多东西烘焙到 MyPanel 中,但我们仍然必须管理组件isOpen
外部的状态MyPanel
。
import { MyPanel } from "./MyPanel";
function ThingWithPanel() {
// Setup the isOpen boilerplate
let [isOpen, setIsOpen] = useState(startOpen);
let openPanel = useCallback(() => {
setIsOpen(true);
}, [setIsOpen]);
let closePanel = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
return (
<div>
{!isOpen && <button onClick={openPanel}>Open Panel</button>}
{isOpen && <button onClick={closePanel}>Close Panel</button>}
{/* Use the custom MyPanel component */}
<MyPanel isOpen={isOpen} onDismiss={closePanel}>
<div>I am stuff in a panel</div>
</MyPanel>
</div>
);
}
重构,我们可以创建一个自定义钩子来处理isOpen
样板。
import { MyPanel, usePanel } from "./MyPanel";
function ThingWithPanel() {
// Use the custom hook to control the panel state
let { isOpen, openPanel, closePanel } = usePanel();
return (
<div>
{!isOpen && <button onClick={openPanel}>Open Panel</button>}
{isOpen && <button onClick={closePanel}>Close Panel</button>}
{/* Use the custom MyPanel component */}
<MyPanel isOpen={isOpen} onDismiss={closePanel}>
<div>I am stuff in a panel</div>
</MyPanel>
</div>
);
}
这个解决方案已经很接近了,但还是感觉有些不对劲。
如果钩子负责提供所有的面板道具会怎么样?
- 然后我们可以将这些道具传播到 Panel 组件上,而不必强迫每个人都记住 UI Fabric API。
如果钩子还返回 Panel 组件会怎么样?
- 那么消费者就不必担心
import
- 我们可以灵活地选择提供默认的 Fabric Panel 或自定义的 MyPanel 组件。所有这些都不会影响钩子的使用者。
function ThingWithPanel() {
let { Panel, panelProps, isOpen, openPanel, closePanel } = usePanel();
return (
<div>
{!isOpen && <button onClick={openPanel}>Open Panel</button>}
{isOpen && <button onClick={closePanel}>Close Panel</button>}
<Panel {...panelProps}>
<div>I am stuff in a panel</div>
</Panel>
</div>
);
};
感觉干净利落!所有样板代码都被移除,同时又不牺牲任何灵活性。
需要注意的是,虽然这个钩子返回的是一个组件,但它实际上只是语法糖。钩子函数每次执行时都不会创建一个新的组件定义。这会导致 React 协调器将所有内容视为新的组件;状态每次都会被重置。Dan Abramov 在Reddit 上的这篇帖子中讨论了这个问题。
usePanel
这是钩子的完整实现
import React, { useState, useCallback, useEffect } from "react";
import styled from "styled-components";
import { IconButton } from "office-ui-fabric-react/lib/Button";
import { PanelType, Panel as FabricPanel, IPanelProps } from "office-ui-fabric-react/lib/Panel";
import IFramePanel from "./IFramePanel";
export type PanelSize = "small" | "medium" | "large" | number;
export interface PanelOptions {
/** Defaults to false. Should the panel be open by default? */
startOpen?: boolean;
/** The size of the panel. "small", "medium", "large", or a Number */
size?: PanelSize;
}
let defaults: PanelOptions = {
startOpen: false,
size: "medium",
};
export function usePanel(opts: PanelOptions = {}) {
let { startOpen, size } = { ...defaults, ...opts };
let [isOpen, setIsOpen] = useState(startOpen);
let openPanel = useCallback(() => {
setIsOpen(true);
}, [setIsOpen]);
let closePanel = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
useEffect(() => listenForPanelClose(closePanel));
let panelProps = {
isOpen,
onDismiss: closePanel,
isLightDismiss: true,
type: getPanelType(size),
customWidth: typeof size === "number" ? size + "px" : undefined,
onRenderNavigation: () => (
<StyledClose>
<IconButton iconProps={{ iconName: "ChromeClose" }} onClick={closePanel} />
</StyledClose>
),
};
return {
isOpen,
openPanel,
closePanel,
panelProps,
Panel,
} as UsePanelResult;
}
export interface PanelProps extends IPanelProps {
url?: string;
}
export const Panel: React.FC<PanelProps> = function ({ url, ...panelProps }) {
if (url) return <IFramePanel url={url} {...panelProps} />;
return <FabricPanel {...panelProps} />;
};
export interface UsePanelResult {
/** Whether the panel is currently open */
isOpen: boolean;
/** A function you can call to open the panel */
openPanel: () => void;
/** A function you can call to close the panel */
closePanel: () => void;
/** The props you should spread onto the Panel component */
panelProps: IPanelProps;
/** The hook returns the UI Fabric Panel component as a nicety so you don't have to mess with importing it */
Panel?: any;
}
const getPanelType = (size) => {
if (size === "small") {
return PanelType.smallFixedFar;
}
if (size === "medium") {
return PanelType.medium;
}
if (size === "large") {
return PanelType.large;
}
if (typeof size !== "string") {
return PanelType.custom;
}
return PanelType.medium;
};
const CLOSE_MSG_TYPE = "CLOSE_PANEL";
// The parent window should create a panel then wire up this function
// to listen for anyone inside the IFrame trying to close the panel;
export const listenForPanelClose = function (cb: () => void) {
let handler = (event) => {
try {
let msg = JSON.parse(event.data);
if (msg.type === CLOSE_MSG_TYPE) {
cb();
}
} catch (err) {
// Couldn't parse json
}
};
window.addEventListener("message", handler, false);
return () => {
window.removeEventListener("message", handler);
};
};
export const triggerPanelClose = function () {
let msg = JSON.stringify({
type: CLOSE_MSG_TYPE,
});
window.top.postMessage(msg, "*");
};
const StyledClose = styled.div`
position: absolute;
top: 5px;
right: 23px;
z-index: 10;
background: #ffffffbf;
border-radius: 50%;
opacity: 0.85;
&:hover {
opacity: 1;
}
`;