基于 Context 和 React Hooks 封装一个 Options 组件

前言

最近在跟进一个项目,其中使用 Options 选择器组件的业务场景还是挺多的,思来想去还是花些时间封装一下,因此浅浅记录一下前端小菜鸡的封装过程。(此处,诚挚感谢我的 mentor 叔叔多次指点迷津,教我成功封装一个全局通用组件,mentor 叔叔牛逼!)

相关 Hooks 的前置知识与应用

Context 的应用场景

当需要在多个子组件之间共享状态时,初学者通常会考虑将这些共享状态提升到最近的共同父组件当中,然后使用 props 传递这些状态。

如果子组件的层级较少,使用 props 传递状态是可行的;但随着子组件的嵌套逻辑越来越复杂,使用 props 传递状态就显得很冗长,因为需要逐层传递,并且也不利于数据源追踪。此时,需要考虑有没有方法能够穿透中间的状态无关组件,直接将状态传递到目标组件当中。

React 官网对 Context 上下文对象有一句介绍:Context 允许父组件向其下层无论多深的任何组件提供信息,而无需通过 props 显式传递。

更具体的内容可以阅读:Context:传递 props 的另一种方法

Context 的创建与使用

React 中,通过 createContext() 创建一个 Context 上下文对象,用于存放需要局部或全局共享的数据。

每个 Context 都会产生 Context.ProviderContext.Consumer。简单来说,Context.Provider 提供了存储在 Context 中的值,而 Context.Consumer 则允许组件访问共享数据对象。

在使用过程中,我们可以通过 useContext() 来获取 Context 的共享数据。

Options Context 的设计

初步实现 Options 选择器组件,需要考虑的有:保存当前选中的 item 项、保存目前为止每个层级被选中的 items 集合,以及与这些状态相关的变更函数。

首先,根据需求定义数据类型,并根据这个类型创建一个 Context 上下文对象。其中:

  • onSelect 事件:最基础的选中操作,将当前选中项设置为 activePath,并压入所有已选中项的集合。

  • onBack 事件:最基础的回退操作,将当前选中项弹出所有已选中项的集合,并将 activePath 变更为上一个已选中项,也就是 paths[paths.length - 2] 的值。

tsx 复制代码
// context.tsx
import { createContext, useContext } from 'react';

export interface OptionsProps {
  activePath: string; // 当前选中 item 对应的 pathId
  setActivePath: (curId: string) => void;
  paths: string[]; // 当前选中的所有 items 对应的 pathId 集合
  setPaths: (curId: string[]) => void;
  onSelect: (pathId: string, isLeaf: boolean) => void; // 默认选中事件
  onBack: () => void; // 默认回退事件
  // ...
}

export const OptionsContext = createContext<OptionsProps>({} as OptionsProps)

export const OptionsProvider = (
  props: OptionsProps & { children: React.ReactNode }
) => {
  const { children, ...config } = props
  return (
    <OptionsContext.Provider value={config}>
      {children}
    </OptionsContext.Provider>
  )
}

export const useOptionsContext = () => {
  return useContext(OptionsContext)
}

其中,我们将 OptionsContext 相关的状态和逻辑提取出一个自定义 Hook,有助于实现代码的模块化和解耦,同时也便于后期迭代与维护。

tsx 复制代码
// useOptions.ts
import { useState } from 'react';

export function useOptions() {
  const [activePath, setActivePath] = useState('');
  const [paths, setPaths] = useState<string[]>([]);

  // 选中时触发事件
  const onSelect = (pathId: string, isLeaf: boolean) => {
    console.log('select')
    // 将当前项的 pathId 设置为选中的 activePath
    setActivePath(pathId);
    // 如果当前项不是叶子节点,则将其对应的 pathId 添加到已选中项的集合中
    if (!isLeaf) {
      setPaths([...paths, pathId]);
    }
  }

  // 回退时触发事件
  const onBack = () => {
    if (!paths.length) {
      return
    }
    console.log('back')
    setActivePath(paths[paths.length - 2])
    setPaths(paths => paths.slice(0, -1))
  }

  return {
    activePath,
    setActivePath,
    paths,
    setPaths,
    onSelect,
    onBack,
  }
};

Options 组件的拆分与设计

我的预想中,Options 选择器组件的使用方式如下:

tsx 复制代码
<Options>
  <CommandItem label={<div>javascript</div>}>
    <div>javascript</div>
  </CommandItem>
  <CommandItem label={<div>vue</div>}>
    <div>vue</div>
  </CommandItem>
  <CommandItem label={<div>react</div>}>
    <div>react</div>
  </CommandItem>
</Options>

Options 组件

Options 组件的作用是:遍历所有 Children,并展示每个子组件的 label 属性。初次调用时,Options 组件将遍历子节点,并展示每个节点的 label 属性。

我使用 NextUIshadcn/uitailwindCSS 框架实现 Options 组件,代码如下:

tsx 复制代码
import React, { isValidElement } from 'react';
import { Command } from 'cmdk';
import { ScrollShadow } from '@nextui-org/react';
import { type OptionsProps, OptionsProvider, useOptionsContext } from './Context';

export const Search = () => {
  const { onBack } = useOptionsContext()

  return (
    <div onClick={onBack} className='w-max p-0.5 rounded-sm grid place-items-center border-1 border-transparent bg-gray-300/20 hover:bg-gray-400/40 cursor-pointer'>
      <i className='i-lucide-chevron-left text-lg text-gray-500' />
    </div>
  )
}

const Options = (
  props: OptionsProps & { children: React.ReactNode }
) => {
  const { children, ...config } = props

  return (
    <OptionsProvider {...config}>
      <Command className="text-sm text-foreground bg-background placeholder:text-slate-600/50 dark:placeholder:text-white/60">
        {/* 顶部操作框,可定义回退事件、搜索事件等 */}
        <div className='py-2.5 border border-divider border-1 bg-background rounded-t-lg pl-3 pr-0'>
          <Search />
        </div>
        {/* 选择列表 */}
        <div className='border border-divider border-t-0 bg-background rounded-b-lg pl-3 pr-0'>
          <ScrollShadow className="w-full max-h-[300px] py-2 pr-2.5">
            <CommandList>
              {children}
            </CommandList>
          </ScrollShadow>
        </div>
      </Command>
    </OptionsProvider>
  )
}
Options.displayName = 'Options';
export default Options;

CommandList 组件

考虑到选择列表可能会被用作某个选项的子节点内容,因此抽取出 CommandList 组件,以便后续能够单独使用。

CommandList 组件的作用是:遍历所有子节点选项,检查其 pathId 是否在已选中项的集合中。若存在,则说明这个选项为被选中的节点,只需要展示这个选项组件。

tsx 复制代码
export const CommandList = ({
  children
}: {
  children: React.ReactNode
}) => {
  const { paths } = useOptionsContext();

  // 获取选中的选项节点
  const activeChild = React.Children.toArray(children).find((child: React.ReactNode) => {
    return isValidElement(child) && paths!.includes(child.props.pathId)
  });

  return (
    <Command.List>
      {activeChild || children}
    </Command.List>
  )
}

CommandItem 组件

在使用 CommandList 组件时,每个选项都需要使用 CommandItem 组件进行包裹,以便为选项统一设定必要的额外属性。

CommandItem 组件应该具有以下属性:

  • label 属性:当前选项的文本内容,必填。

  • pathId 属性:当前选项的 ID 标识,必填。

  • isLeaf 属性:当前选项是否为叶子节点,可选,默认值为 false

  • children 属性:当前选项的子节点内容,可选。

  • className 属性:自定义 className 以添加样式,可选。

  • onSelect 事件:自定义选中事件,可选。

CommandItem 组件的作用是:根据传入的节点属性(如上)判断当前节点是否存在子节点内容,如果有,则需要进一步展示子节点列表;否则,展示节点本身的 label 内容。

tsx 复制代码
export const CommandItem = (props: {
  label: React.ReactNode,
  pathId: string,
  isLeaf?: boolean,
  children?: React.ReactNode,
  className?: string,
  onSelect?: () => void,
}) => {
  const {
    activePath,
    paths,
    onSelect,
  } = useOptionsContext()

  // 如果当前项的 pathId 与 activePath 相同且包含在 paths 中,并且存在子节点内容
  const isActive = !props.isLeaf && (activePath === props.pathId || paths?.includes(props.pathId!))

  const _onSelect = async () => {
    // 如果传入了自定义触发事件,优先执行
    if (props.onSelect && typeof props.onSelect === 'function') {
      await props.onSelect()
    }
    onSelect(props.pathId, props.isLeaf || false)
  }

  return (
    <>
      {isActive ? (
        <div className={props.className}>
          {props.children}
        </div>
      ) : (
        <Command.Item
          onSelect={_onSelect}
          className='px-1 py-1.5 flex items-center gap-2.5 cursor-pointer hover:rounded-md hover:bg-default-100'
        >
          {props.label}
        </Command.Item>
      )}
    </>
  )
}

组件的简单使用

根据 CommandItem 所需的 props 类型,定义一个选择列表 options 并传入组件:

jsx 复制代码
//  Index.tsx
export default function Index() {
  const optionContext = useOptions();

  const options = [{
    key: 'repo',
    label: '从 Git 仓库部署',
    icon: 'i-lucide-git-branch',
    jumpComp: (
      <CommandList>
        <CommandItem pathId={'1'} label={'hello repo1'} isLeaf onSelect={() => console.log('repo1')}></CommandItem>
        <CommandItem pathId={'2'} label={'hello repo2'} isLeaf onSelect={() => console.log('repo2')}></CommandItem>
        <CommandItem pathId={'3'} label={'hello repo3'} isLeaf onSelect={() => console.log('repo3')}></CommandItem>
        <CommandItem pathId={'4'} label={'hello repo4'} isLeaf onSelect={() => console.log('repo4')}></CommandItem>
      </CommandList>
    ),
    onSelect: () => {
      console.log('repo!')
    }
  }, {
    key: 'template',
    label: '模板部署',
    icon: 'i-lucide-layout-template',
    jumpComp: (
      <CommandList>
        <CommandItem pathId={'1'} label={'hello template1'} isLeaf></CommandItem>
        <CommandItem pathId={'2'} label={'hello template2'} isLeaf></CommandItem>
        <CommandItem pathId={'3'} label={'hello template3'} isLeaf></CommandItem>
        <CommandItem pathId={'4'} label={'hello template4'} isLeaf></CommandItem>
      </CommandList>
    ),
    onSelect: () => {
      console.log('template!')
    }
  }];

  return (
    <main className="md:w-1/2 sm:w-3/5 mx-auto my-4 flex flex-col gap-4">
      <Options {...optionContext}>
        {options.map((item) => (
          <CommandItem
            key={item.key}
            pathId={item.key}
            isLeaf={Boolean(!item.jumpComp)} // 没有子节点
            label={(
              <div className='my-1 flex items-center gap-2'>
                <i className={item.icon}></i>
                <p className='flex-1'>{item.label}</p>
              </div>
            )}
            onSelect={item.onSelect}
          >
            {item.jumpComp}
          </CommandItem>
        ))}
      </Options>
    </main>
  )
};

组件的使用效果如下:

最后

如果有什么错误或者更好的建议,欢迎大家提出,一起进步。

相关推荐
麒麟而非淇淋42 分钟前
AJAX 入门 day1
前端·javascript·ajax
2401_8581205344 分钟前
深入理解MATLAB中的事件处理机制
前端·javascript·matlab
阿树梢1 小时前
【Vue】VueRouter路由
前端·javascript·vue.js
随笔写2 小时前
vue使用关于speak-tss插件的详细介绍
前端·javascript·vue.js
史努比.2 小时前
redis群集三种模式:主从复制、哨兵、集群
前端·bootstrap·html
快乐牌刀片883 小时前
web - JavaScript
开发语言·前端·javascript
miao_zz3 小时前
基于HTML5的下拉刷新效果
前端·html·html5
Zd083 小时前
14.其他流(下篇)
java·前端·数据库
藤原拓远3 小时前
JAVAWeb-XML-Tomcat(纯小白下载安装调试教程)-HTTP
前端·firefox
重生之我在20年代敲代码4 小时前
HTML讲解(一)body部分
服务器·前端·html