如何在React中构建动态下拉组件 - 解释React复合组件模式

发布于:2024-04-25 ⋅ 阅读:(25) ⋅ 点赞:(0)

下拉菜单长期以来一直是网站和应用程序中的重要组成部分。它们是用户交互的默默英雄,通过简单的点击或轻触默默地促进着无数的操作和决策。

今天你可能已经遇到了其中之一,无论是在你最喜爱的在线商店上选择类别,还是在注册表单上选择你的出生日期。

但如果我告诉你,有一个秘密配方可以将你的下拉菜单从平凡提升到华丽呢?

加入我,我将解剖复合组件模式的奥秘,并利用其能力构建一个动态下拉组件。

(本文视频讲解:java567.com)

先决条件

  • HTML、CSS和Tailwind CSS的基础知识
  • React和React Hooks的基础知识。

我们将涵盖的内容:

  1. 理解下拉菜单组件
  2. 理解复合组件
  3. 如何构建下拉菜单组件
    • 常规函数式 React 方法
    • 复合组件模式方法
  4. 常规方法和复合组件方法的比较
  5. 结论

理解下拉菜单组件

下拉菜单组件在用户界面设计中起着至关重要的作用,作为交互式菜单,它们赋予用户从一系列选项中进行选择的能力。通常,它们由一个可点击的区域组成,在激活时展示用户可以进行选择的一系列选项。

下拉菜单组件的操作很简单:当用户与之交互时——通常通过点击或轻触——下拉菜单会展开,显示可用的选项。

随后,用户可以从这些选择中选择一个,然后该选择要么显示在下拉菜单本身中,要么用于更新界面中相关字段或元素。

下拉菜单组件提供了一种清晰高效的方法,向用户呈现各种选择,使其非常适用于需要同时保持整洁界面的情况下访问多个选项的场景。

下拉菜单还具有以下作用:

  • 导航辅助:作为导航辅助工具,下拉菜单通过提供跳转到不同部分或页面的菜单来帮助用户浏览网站。
  • 表单输入:简化数据输入,下拉菜单为用户提供了预定义选项供选择,例如在账户注册期间选择国家、出生日期或首选语言。
  • 过滤器:在电子商务平台上,下拉菜单使购物者能够通过选择选项,如产品类别、价格范围或品牌,来细化其搜索结果。
  • 菜单选择器:在餐厅网站上常见的,下拉菜单显示菜单或允许用户选择菜系类型,便于轻松探索和选择餐饮选项。
  • 数据展示:下拉菜单可以有效地组织和展示数据,允许用户通过日期范围、地理区域或产品类别等条件在仪表板或分析工具中过滤信息。

下拉菜单组件的示例可在此处看到:

01-展示下拉菜单演示展示下拉菜单演示

或在Semantic UI页面上。

理解复合组件

复合组件模式就像使用乐高积木一样:你组装小的部件来创建更大更复杂的东西。在React中,这是一种巧妙的设计组件的方式,由几个小部件组成,它们能够无缝地协同工作。

想象一下你正在构建一个下拉菜单。与其创建一个处理所有事情的单一组件,不如将其拆分成较小、可重用的部件。你可能有一个用于下拉按钮的组件,另一个用于选项列表,还有一个用于处理状态和交互逻辑的组件。

01-复合组件示意图复合组件示意图

这里有一个有趣的地方:这些小组件通过共享上下文进行通信。上下文就像是一个信使,可以在不需要通过组件树的每个级别传递信息的情况下,将信息从一个组件传递到另一个组件。

这是一个强大的工具,可以简化组件之间共享数据的过程,特别是当它们被深度嵌套时。

那么,为什么这种模式如此有益呢?

  • 首先,它提高了可读性。通过将复杂的组件分解为更小、更专注的部件,代码变得更易于理解和维护。每个组件都有明确的责任,这使得调试和更新变得更加容易。
  • 其次,复合组件增强了可维护性。由于组件的每个部分都处理特定的任务,因此进行更改或添加新功能变得更加简单。你可以修改组件的一个部分而不影响其他部分,减少引入错误的风险。
  • 最后,复合组件提供了极大的灵活性。你可以组合不同的部件来创建组件的特殊版本,而无需重写任何代码。这使得调整组件以适应不同目的和设计需求变得更加容易。

因此,虽然使用上下文来构建UI组件的想法起初可能看起来不同寻常,但这是一种巧妙的方式,可以创建动态和可重用的组件,从而赋予开发人员构建出色用户体验的能力。

在接下来的部分中,我们将深入探讨如何使用上下文将复合组件带入实际。

如何构建下拉菜单组件

我已经准备了一个GitHub存储库,其中包含启动文件,以加快进度。只需克隆这个存储库并安装依赖项。

在这一部分,我们将使用常规的函数式React构建一个下拉菜单组件,然后将其与CC模式进行比较,以充分理解它们之间的区别。PS:你一定会喜欢复合组件模式的。😁

02-Oh-fo-sho-memeOh Fo sho Snoop Dogg gif

常规函数式React方法

我们将从创建下拉菜单组件的基本结构开始。这将涉及设置主下拉菜单容器、触发下拉菜单的按钮以及选项列表。

const Dropdown = () => {
  return (
    <div>
      <label className="mt-4">为任务分配用户:</label>

      <button className="  px-4 w-full py-2 flex items-center justify-between  rounded border border-[#828FA340] hover:border-primary cursor-pointer relative ">
        <span className="block">
          <FiChevronDown color="#635FC7" size={24} />
        </span>
      </button>
    </div>
  );
};

这将呈现:

02-Dropdown-button-rendered下拉按钮呈现

然后将用户数组传递给下拉菜单,以创建用户列表。

const Dropdown = ({ usersArray }) => {
  return (
    <div>
      <label className="mt-4">为任务分配用户:</label>

      <button className="  px-4 w-full py-2 flex items-center justify-between  rounded border border-[#828FA340] hover:border-primary cursor-pointer relative ">
        <span className="block">
          <FiChevronDown color="#635FC7" size={24} />
        </span>
        {
          <div className="absolute bottom-full translate-x-9  left-full translate-y-full rounded bg-[#20212c] w-max">
            <ul className="flex flex-col p-2">
              {usersArray.map((user) => (
                <li
                  key={user.id}
                  className={`flex items-center gap-2 p-4 hover:bg-[#2b2c37] rounded transition-all duration-200 `}>
                  <img
                    className="w-6 h-6 "
                    src={user.imgUrl}
                    alt={`${user.name} image`}
                  />
                  <span>{user.name}</span>
                </li>
              ))}
            </ul>
          </div>
        }
      </button>
    </div>
  );
};

这将呈现:

03-Dropdown-list-rendered下拉列表呈现

目前,你的下拉列表默认显示。为了添加切换行为,请为其可见性创建一个状态。

 const [isDropdownOpen, setIsDropdownOpen] = useState(false);

然后将它们作为props传递给Dropdown组件。

<Dropdown
 usersArray={usersArray}
 isDropdownOpen={isDropdownOpen}
 setIsDropdownOpen={setIsDropdownOpen}
 />

在看到结果之前,将一个切换函数附加到下拉按钮,用于将下拉状态更改为true。

const toggleDropdown = () => {
    setIsDropdownOpen(true);
};

现在你的下拉组件应该是这样的:

const Dropdown = ({ usersArray, setIsDropdownOpen, isDropdownOpen }) => {
  const toggleDropdown = () => {
    setIsDropdownOpen(true);
  };

  return (
    <div>
      <label className="mt-4">为任务分配用户:</label>

      <button
        className="  px-4 w-full py-2 flex items-center justify-between  rounded border border-[#828FA340] hover:border-primary cursor-pointer relative "
        // Function to show the dropdown on click
        onClick={toggleDropdown}>
        <span className="block">
          <FiChevronDown color="#635FC7" size={24} />
        </span>
	  // Conditionally rendering your dropdown list
        {isDropdownOpen && (
          <div className="absolute bottom-full translate-x-9  left-full translate-y-full rounded bg-[#20212c] w-max">
            <ul className="flex flex-col p-2">
              {usersArray.map((user) => (
                <li
                  key={user.id}
                  className={`flex items-center gap-2 p-4 hover:bg-[#2b2c37] rounded transition-all duration-200 `}>
                  <img
                    className="w-6 h-6 "
                    src={user.imgUrl}
                    alt={`${user.name} image`}
                  />
                  <span>{user.name}</span>
                </li>
              ))}
            </ul>
          </div>
        )}
      </button>
    </div>
  );
};

你的下拉现在的行为是这样的:

03-Dropdown-with-list-conditionally-rendering有条件地呈现下拉列表的下拉菜单

我知道你已经注意到你的下拉只能打开,而不能关闭。别担心,我们稍后会以更加简洁的方式来修复它。😉

04-Trust-the-process相信过程

接下来,让我们创建一种方法来为任务分配用户。首先,在App组件中创建一个状态以存储已分配的用户。

 const [assignedList, setAssignedList] = useState([]);

然后将其作为props传递给Dropdown组件。

<Dropdown
  usersArray={usersArray}
  isDropdownOpen={isDropdownOpen}
  setIsDropdownOpen={setIsDropdownOpen}
  assignedList={assignedList}
  setAssignedList={setAssignedList}
/>

要为任务分配用户,创建一个处理函数,首先检查你试图添加的用户是否已经在数组中,如果还没有,将其添加进去,如果已经存在,则将其移除。

  function handleAssign(user) {
    setAssignedList((prevList) => {
      // 检查用户是否已存在于列表中
      if (prevList.includes(user)) {
        // 如果用户存在,则从列表中移除它
        const updatedList = prevList.filter((item) => item !== user);
        return updatedList;
      } else {
        // 如果用户不存在,则将其添加到列表中
        return [...prevList, user];
      }
    });
  }

为了确认这个函数是否有效,使用assignedList数组为每个已分配的用户添加一个检查图标。

<ul className="flex flex-col p-2">
  {usersArray.map((user) => (
    <li
      key={user.id}
      className={`flex items-center gap-2 p-4 hover:bg-[#2b2c37] rounded transition-all duration-200 `}
      onClick={() => handleAssign(user)}
    >
      {assignedList.includes(user) && <FiCheck />}

      <img
        className="w-6 h-6 "
        src={user.imgUrl}
        alt={`${user.name} image`}
      />
      <span>{user.name}</span>
    </li>
  ))}
</ul>

通过这个改变,下拉应该会在点击每个用户时进行分配和取消分配。

05-Assigning-and-unassigning-users-to-the-task为任务分配和取消分配用户

为了改进UI,让我们创建一个组件来显示所有已分配的用户。

创建一个AssignedList组件,并传递其相应的状态。

 <AssignedList
   assignedList={assignedList}
   setAssignedList={setAssignedList}
  />

然后使用已分配的数组来创建一些JSX。

function AssignedList({ assignedList, setAssignedList }) {
  return (
    <div className="mt-4 p-2 shadow-sm bg-[#828fa318] rounded">
      <h2 className="px-2 my-3 font-bold">已分配列表:</h2>
      <div className="flex flex-wrap gap-4 ">
        {assignedList?.map((user, index) => (
          <div
            key={user.id}
            className="flex items-center gap-1 w-[47.5%] p-2 hover:bg-[#20212c] rounded transition-all duration-200">
            <span>{index + 1}.</span>
            <img
              className="w-6 h-6 "
              src={user.imgUrl}
              alt={`${user.name} image`}
            />

            <span>{user.name}</span>
            <span className="ml-auto cursor-pointer p-1 hover:bg-[#2b2c37] rounded-full">
              <FaXmark />
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}

现在测试你的组件应该得到:

06-Displaying-assigned-users-using-the-AssignedList-component使用AssignedList组件显示已分配的用户

最后一个改变是一个主观的选择,因为我更喜欢在没有当前有用户分配任务时显示其他内容。

{assignedList.length === 0 ? (
  <p className="mt-4 p-2 shadow-sm bg-[#828fa318] rounded">
    尚未为任务分配用户。
  </p>
) : (
  <AssignedList
    assignedList={assignedList}
    setAssignedList={setAssignedList}
  />
)}

这将带来UI:

10-Showing-a-default-text-when-no-users-are-assigned在没有用户被分配时显示默认文本

复合组件模式方法

现在,让我们开始主要内容。首先创建一个包装整个组件的上下文。

const UserAssignContext = createContext();

然后收集我们的下拉菜单及其组件所需的所有必要数据和函数。这包括已分配用户列表、更新该列表的函数以及下拉菜单当前是否打开等内容。

然后,将这些值提供给所有子组件。

const UserAssignDropdown = ({
  children,
  assignedList,
  setAssignedList,
  users,
}) => {
  const UserAssignDropdownRef = useRef(null);
  const [isDropdownOpen, setIsDropdownOpen] = useState(false);

  return (
    <UserAssignContext.Provider
      value={{
        assignedList,
        users,
        UserAssignDropdownRef,
        isDropdownOpen,
        setIsDropdownOpen,
        setAssignedList,
      }}>
      <div ref={UserAssignDropdownRef}>{children}</div>
    </UserAssignContext.Provider>
  );
};

有了上下文设置好了,现在是时候制作组成我们下拉菜单的各个组件了。每个组件将与上下文交互,以访问和操作必要的数据和函数。

首先,从我们刚刚构建的组件中复制每个样式。

头部组件

这个组件保持不变。

const Header = () => {
  return <label className="mt-4 mb-2 text-sm">为任务分配用户:</label>;
};
关闭组件

该组件从上下文中获取用于切换下拉菜单的函数。

const Close = () => {
  const { setIsDropdownOpen } = useContext(UserAssignContext);
  return (
    <div
      className="absolute top-0 right-0 flex items-center justify-center -translate-y-full gap-2 bg-[#C0392B] px-2 py-1 rounded-t"
      onClick={(e) => {
        e.stopPropagation();
        setIsDropdownOpen(false);
      }}>
      <span>关闭</span>
      <span>
        <FaXmark size={20} />
      </span>
    </div>
  );
};
已分配列表组件

该组件显示已分配的用户列表,并从列表中删除用户。

const AssignedList = () => {
  const { assignedList, setAssignedList } = useContext(UserAssignContext);

  function handleRemove(id) {
    setAssignedList((assignedList) =>
      assignedList.filter((user) => user.id !== id)
    );
  }

  if (assignedList.length === 0)
    return (
      <p className="mt-4 p-2 shadow-sm bg-[#828fa318] rounded">
        尚未为任务分配用户。
      </p>
    );

  return (
    <div className="mt-4 p-2 shadow-sm bg-[#828fa318] rounded">
      <h2 className="px-2 my-3 font-bold">已分配列表:</h2>
      <div className="flex flex-wrap gap-4 ">
        {assignedList?.map((user, index) => (
          <div
            key={user.id}
            className="flex items-center gap-1 w-[47.5%] p-2 hover:bg-[#20212c] rounded transition-all duration-200"
            onClick={() => handleRemove(user.id)}>
            <span>{index + 1}.</span>
            <img
              className="w-6 h-6 "
              src={user.imgUrl}
              alt={`${user.name} image`}
            />

            <span>{user.name}</span>
            <span className="ml-auto cursor-pointer p-1 hover:bg-[#2b2c37] rounded-full">
              <FaXmark />
            </span>
          </div>
        ))}
      </div>
    </div>
  );
};
项组件

该组件表示每个用户,并具有添加和从已分配列表中删除用户的功能。

const Item = ({ user }) => {
  const { assignedList, setAssignedList } = useContext(UserAssignContext);

  function handleAssign(user) {
    setAssignedList((prevList) => {
      // 检查用户是否已存在于列表中
      if (prevList.includes(user)) {
        // 如果用户存在,则从列表中移除它
        const updatedList = prevList.filter((item) => item !== user);
        return updatedList;
      } else {
        // 如果用户不存在,则将其添加到列表中
        return [...prevList, user];
      }
    });
  }

  return (
    <li
      key={user.id}
      className={`flex items-center gap-2 p-4 hover:bg-[#2b2c37] rounded transition-all duration-200 `}
      onClick={() => handleAssign(user)}>
      {assignedList.includes(user) && <FiCheck />}

      <img className="w-6 h-6 " src={user.imgUrl} alt={`${user.name} image`} />
      <span>{user.name}</span>
    </li>
  );
};
按钮组件

该组件控制显示List组件(浮动下拉列表)。

const Button = () => {
  const { setIsDropdownOpen } = useContext(UserAssignContext);
  return (
    <button
      className="  px-4 py-2 flex items-center justify-between w-full rounded border border-[#828FA340] hover:border-primary cursor-pointer relative "
      onClick={() => setIsDropdownOpen(true)}>
      <span className="block">
        <FiChevronDown color="#635FC7" size={24} />
      </span>

      <UserAssignDropdown.List />
    </button>
  );
};

要将这些组件组合成一个单一的复合组件,你需要将每个组件分配给父组件,如下所示:

UserAssignDropdown.List = ListContainer;
UserAssignDropdown.Item = Item;
UserAssignDropdown.Header = Header;
UserAssignDropdown.Button = Button;
UserAssignDropdown.AssignedList = AssignedList;
UserAssignDropdown.Close = Close;

接下来,在你的App组件中作为包装组件导入你的复合组件,并传入适当的状态。

export default function App() {
  const [assignedList, setAssignedList] = useState([]);

  return (
    <div className="bg-[#2b2c37] h-[100dvh] text-white flex  p-20 gap-4 items-center flex-col">
      <div className=" w-[400px] ">
        <h1 className="text-2xl ">复合组件模式</h1>
        <UserAssignDropdown
          assignedList={assignedList}
          setAssignedList={setAssignedList}
          users={usersArray}></UserAssignDropdown>
      </div>
    </div>
  );
}

然后在包装器中呈现适当的子组件。

export default function App() {
  const [assignedList, setAssignedList] = useState([]);

  return (
    <div className="bg-[#2b2c37] h-[100dvh] text-white flex  p-20 gap-4 items-center flex-col">
      <div className=" w-[400px] ">
        <h1 className="text-2xl ">复合组件模式</h1>
        <UserAssignDropdown
          assignedList={assignedList}
          setAssignedList={setAssignedList}
          users={usersArray}>
          <UserAssignDropdown.Header />
          <UserAssignDropdown.Button />
          <UserAssignDropdown.AssignedList />
        </UserAssignDropdown>
      </div>
    </div>
  );
}

最后,使用之前创建的自定义钩子在单击组件外部时关闭下拉菜单。

const UserAssignContext = createContext();
const UserAssignDropdown = ({
  children,
  assignedList,
  setAssignedList,
  users,
}) => {
  const UserAssignDropdownRef = useRef(null);
  const [isDropdownOpen, setIsDropdownOpen] = useState(false);

  useClickOutside(UserAssignDropdownRef, () => {
    setIsDropdownOpen(false);
  });

  return (
    <UserAssignContext.Provider
      value={{
        assignedList,
        users,
        UserAssignDropdownRef,
        isDropdownOpen,
        setIsDropdownOpen,
        setAssignedList,
      }}>
      <div ref={UserAssignDropdownRef}>{children}</div>
    </UserAssignContext.Provider>
  );
};

至此,你的组件就完成了相同的功能!

11-replicating-the-same-funtionality-with-the-compound-component-pattern复制相同功能的复合组件模式

但是为什么要停在这里呢?

使用这种模式,更改组件的外观就像更改它们在父组件中呈现顺序一样简单。例如,如果你想要首先显示按钮,你只需在父组件中更改顺序。

<UserAssignDropdown
  assignedList={assignedList}
  setAssignedList={setAssignedList}
  users={usersArray}
>
  <UserAssignDropdown.Button />
  <UserAssignDropdown.Header />
  <UserAssignDropdown.AssignedList />
</UserAssignDropdown>

UI会相应地作出反应。

04-order-of-rendering-in-compound-component-changed复合组件中呈现顺序已更改

此组件还灵活到足以通过 props 更改元素的布局。

只需通过父级传递样式 props:

<UserAssignDropdown
  assignedList={assignedList}
  setAssignedList={setAssignedList}
  users={usersArray}
>
  <UserAssignDropdown.Header />
  <UserAssignDropdown.Button
    listStyles={"!-left-5 !-translate-x-full bg-[#605e80] text-white border"}
  />
  <UserAssignDropdown.AssignedList />
</UserAssignDropdown>

并在子级接收这些 props:

const Button = ({ listStyles }) => {
  const { setIsDropdownOpen, UserAssignDropdownRef } =
    useContext(UserAssignContext);
  return (
    <button
      className="  px-4 py-2 flex items-center justify-between w-full rounded border border-[#828FA340] hover:border-primary cursor-pointer relative "
      ref={UserAssignDropdownRef}
      onClick={() => setIsDropdownOpen(true)}>
      <span className="block">
        <FiChevronDown color="#635FC7" size={24} />
      </span>
      <UserAssignDropdown.List listStyles={listStyles} />
    </button>
  );
};

const ListContainer = ({ listStyles }) => {
  const { users, isDropdownOpen } = useContext(UserAssignContext);

  return (
    isDropdownOpen && (
      <ul
        className={`absolute bottom-full translate-x-9  left-full translate-y-full rounded bg-[#20212c] w-max ${listStyles}`}>
        <UserAssignDropdown.Close />
        <div className="flex flex-col p-2">
          {users?.map((user, index) => (
            <UserAssignDropdown.Item key={index} user={user} />
          ))}
        </div>
      </ul>
    )
  );
};

你可以轻松更改组件的外观。

12-Using-props-to-customize-the-compound-component使用 props 自定义复合组件

常规方法与复合组件方法的比较

好的,让我们退后一步,比较一下我们刚刚探讨过的两种方法。

简单性和组织性

  • 常规方法:想象一下就像在一个大碗里一次性烘焙蛋糕一样。使用常规方法,我们可以创建一个负责下拉菜单中所有内容的单个组件 - 按钮、列表和所有配料。就像有一个大型食谱卡片,所有步骤都混在一起。它能完成任务,但有点凌乱,而且很难跟踪,特别是当你试图调整食谱的某一部分时。
  • 复合组件方法:现在想象一下,我们为每个配料都有不同的碗,一个用于面粉,另一个用于糖,依此类推。这就是复合组件模式。下拉菜单的每个部分都有自己的空间来发光。就像组织你的厨房一样 - 每样东西都有它的位置。这使得理解和修改变得更容易。需要更改面粉?你知道该去哪里找。

灵活性和定制性

  • 常规方法:使用我们的单碗方法,对下拉菜单的特定部分进行更改有点像尝试在那个大蛋糕混合物中交换成分。当然,你可以做到,但并不总是容易。想要不同口味的蛋糕?你可能需要深入整个碗找到添加它的地方。
  • 复合组件方法:使用复合组件模式,就像为每种口味都有单独的容器。需要添加巧克力片?只需拿起巧克力容器,撒上即可。每个组件都有它的工作,使得定制变得更简单。想要更改按钮的颜色?没问题,它就在那里的容器里。

重用和维护

  • 常规方法:当你的食谱都混在一个碗里时,很难将其中的一部分重用于另一道菜。而且,随着厨房变得越来越忙,东西很容易变得凌乱,难以跟踪。每当你想要制作新菜品时,你可能会发现自己需要重写食谱。
  • 复合组件方法:使用复合组件模式,就像在你的厨房里有一套可重用的工具一样。需要制作不同种类的蛋糕?只需拿起你需要的工具,开始烘焙。每个组件就像是一个专业的小工具 - 容易重用和维护。而且当你的厨房井然有序时,制作新菜品就轻而易举了。

附加信息

这里是你可能需要的所有资源链接。

  • 起始文件
  • 常规函数模式
  • 复合组件模式

结论

最后,这两种方法都在你的代码中有其用武之地。常规方法就像你可靠的旧搅拌碗 - 可靠而熟悉,但也许并不适合每种食谱。

复合组件模式就像一个组织良好的厨房,一切井然有序,准备就绪。它可能需要一些设置,但从长远来看,它可以让你的生活变得更轻松。所以,根据你要做的事情,选择适合你口味的方法 - 并愉快地编码吧! 🍰🎨

(本文视频讲解:java567.com)