基于 React 实现选择器 Options 组件

434 阅读5分钟

前言

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

先展示一下最终效果:

demo.gif

相关 Hooks 介绍

Context 的应用场景

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

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

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

更具体的内容,可以阅读 React Context 官方文档。

Context 的创建与使用

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

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

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

组件的设计与实现

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

<Options>
  <OptionItem label={<div>javascript</div>}>
    <div>javascript</div>
  </OptionItem>
  <OptionItem label={<div>vue</div>}>
    <div>vue</div>
  </OptionItem>
  <OptionItem label={<div>react</div>}>
    <div>react</div>
  </OptionItem>
</Options>

创建上下文对象

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

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

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

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

// 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 的相关状态和逻辑提取成 useOptions Hook,这样有助于实现代码的模块化和解耦,也便于后期迭代与维护。

// 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 组件的作用是:遍历所有 Children,并展示每个子组件的 label 属性。初次调用时,Options 组件将遍历子节点,并展示每个节点的 label 属性。

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

// 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>
  )
};

export 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">
            <OptionList>
              {children}
            </OptionList>
          </ScrollShadow>
        </div>
      </Command>
    </OptionsProvider>
  )
};

实现 OptionList 组件

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

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

// Options.tsx
// ...
export const OptionList = ({
  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>
  )
}

实现 OptionItem 组件

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

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

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

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

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

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

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

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

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

// Options.tsx
// ...
export const OptionItem = (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>
      )}
    </>
  )
}

组件的实际使用

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

// index.tsx
import { Options, OptionList, OptionItem } from './Options.tsx';

export default function Index() {
  const optionContext = useOptions();

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

demo.gif

最后

欢迎大家提出不足之处或其他建议,一起进步。