新的 React Hooks 模式?返回一个组件

2025-06-07

新的 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比较复杂,因为isOpenopenPanelclosePanel无法被封装,因为父组件需要使用它们来控制面板何时打开。

*这里将很多东西烘焙到 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;
  }
`;

文章来源:https://dev.to/droopytersen/new-react-hooks-pattern-return-a-component-31bh
PREV
效应器:我们需要更深入地研究
NEXT
使用 React Paginate 在 React 中构建分页功能简介安装库 App.js CSS CodeSandbox 结论