前言
最近在跟进一个项目,其中使用 Options
选择器组件的业务场景还是挺多的,思来想去还是花些时间封装一下,因此浅浅记录一下前端小菜鸡的封装过程。(此处,诚挚感谢我的 mentor 叔叔多次指点迷津,教我成功封装一个全局通用组件,mentor 叔叔牛逼!)
相关 Hooks 的前置知识与应用
Context 的应用场景
当需要在多个子组件之间共享状态时,初学者通常会考虑将这些共享状态提升到最近的共同父组件当中,然后使用 props
传递这些状态。
如果子组件的层级较少,使用 props
传递状态是可行的;但随着子组件的嵌套逻辑越来越复杂,使用 props
传递状态就显得很冗长,因为需要逐层传递,并且也不利于数据源追踪。此时,需要考虑有没有方法能够穿透中间的状态无关组件,直接将状态传递到目标组件当中。
React
官网对 Context
上下文对象有一句介绍:Context
允许父组件向其下层无论多深的任何组件提供信息,而无需通过 props
显式传递。
更具体的内容可以阅读:Context:传递 props 的另一种方法
Context 的创建与使用
在 React
中,通过 createContext()
创建一个 Context
上下文对象,用于存放需要局部或全局共享的数据。
每个 Context
都会产生 Context.Provider
和 Context.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
属性。
我使用 NextUI
、shadcn/ui
与 tailwindCSS
框架实现 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>
)
};
组件的使用效果如下:

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