1. 项目概述与核心价值
如果你正在用 Next.js 和 React 构建现代 Web 应用,并且已经爱上了 shadcn/ui 那种“代码归你所有”的哲学,那么你很可能和我一样,在兴奋之余也遇到过一个痛点:官方组件库虽然优雅、基础,但在面对一些更复杂、更具体的交互场景时,总感觉缺了那么“几块拼图”。比如,一个能优雅处理海量选项的多选器、一个自带加载状态且防重复点击的按钮、或者一个无缝衔接的无限滚动列表。这些组件你当然可以自己从头写,但时间成本高,且要兼顾无障碍访问、键盘导航、响应式设计等细节,着实是个挑战。
这就是shadcn-ui-expansions项目诞生的背景。它不是一个全新的 UI 库,而是一个精准的“扩展包”。其核心价值在于,它完全继承了 shadcn/ui 的设计理念与技术栈(基于 Tailwind CSS 和 Radix UI 原语),并在此基础上,补充了一系列社区呼声高、但官方尚未提供的实用组件。你可以把它看作是 shadcn/ui 的“官方风格社区补完计划”。所有组件都遵循相同的使用模式:直接复制代码到你的项目中,然后随心所欲地修改。它没有额外的运行时依赖,不会增加你的打包体积,只是为你提供了一套经过精心设计和测试的高质量“代码片段”,让你能快速填补项目中的功能空白,把精力集中在更核心的业务逻辑上。
2. 核心组件深度解析与选型思路
这个扩展包里的每一个组件,都瞄准了实际开发中的某个具体痛点。下面我们来深入拆解几个最具代表性的组件,看看它们解决了什么问题,以及为什么这样的设计是合理的。
2.1 Multiple Selector:超越基础选择框的交互方案
原生的<select multiple>或者大多数 UI 库的基础多选组件,在处理几十上百个选项时,用户体验往往很糟糕。Multiple Selector组件提供了一个现代化的解决方案:它通常结合了一个输入框和一个下拉弹出层,支持搜索过滤、键盘导航、以及已选项的标签化展示。
设计考量:
- 可搜索与可过滤:当选项过多时,搜索是刚需。组件内部需要高效地处理输入变化,并对选项列表进行实时过滤,这通常需要结合
useState管理输入值,并用useMemo来优化过滤计算,避免不必要的渲染。 - 标签化展示与删除:已选项以“标签”形式展示,每个标签可单独删除。这里的关键是实现一个稳定的状态管理,确保添加、删除操作能正确更新组件内部和传递给父组件的值。通常使用数组来管理选中值,并为每个标签绑定独立的删除事件。
- 与表单集成:作为表单控件,它必须能无缝接入像
React Hook Form或Formik这样的流行表单库。这意味着组件需要支持value、onChange等标准的受控组件接口,并能够通过ref暴露 DOM 节点或方法。
注意:实现此类组件时,无障碍访问是重中之重。务必确保组件可以通过键盘完全操作(Tab 聚焦、方向键选择、Enter 确认、Backspace 删除),并为屏幕阅读器提供清晰的 ARIA 属性,如
aria-multiselectable、aria-activedescendant等。
2.2 Loading Button:提升用户体验与防止重复提交
提交表单时,给用户一个明确的“正在处理”反馈,并防止因网络延迟导致的重复点击,是提升应用健壮性和用户体验的基本功。Loading Button组件封装了这个逻辑。
实现要点:
- 状态集成:按钮内部管理一个
isLoading状态。当点击触发异步操作(如onClick返回 Promise)时,自动置为true,并在操作结束后置为false。 - 视觉反馈:在加载状态下,按钮应禁用(
disabled),并显示一个加载指示器(如 Spinner)。指示器的位置(左侧、右侧或替换文本)需要可配置。 - 防重复提交:通过
disabled属性或拦截点击事件,从根本上防止在加载期间再次触发动作。一个更完善的实现还会考虑操作失败的情况,确保即使出错,按钮也能恢复到可点击状态。
// 一个简化的实现思路 interface LoadingButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { isLoading?: boolean; loadingText?: string; onClick?: (event: React.MouseEvent) => Promise<void> | void; } const LoadingButton: React.FC<LoadingButtonProps> = ({ isLoading, children, onClick, ...props }) => { const [internalLoading, setInternalLoading] = useState(false); const handleClick = async (e: React.MouseEvent) => { if (internalLoading || props.disabled) return; setInternalLoading(true); try { await onClick?.(e); } finally { setInternalLoading(false); // 确保无论成功失败都恢复状态 } }; const showLoading = isLoading !== undefined ? isLoading : internalLoading; return ( <button {...props} disabled={props.disabled || showLoading} onClick={handleClick}> {showLoading ? ( <> <Spinner className="mr-2 h-4 w-4" /> {props.loadingText || 'Processing...'} </> ) : ( children )} </button> ); };2.3 Infinite Scroll:高效渲染长列表的利器
在社交动态、商品列表等场景下,一次性加载所有数据既不现实也不高效。无限滚动通过监听滚动位置,动态加载更多数据,提供了无缝的浏览体验。
核心机制:
- 观察器(Intersection Observer):这是现代浏览器实现无限滚动的标准方案。在列表底部放置一个“哨兵”元素(通常是一个
div),当这个元素进入视口时,触发加载更多数据的函数。相比监听scroll事件,Intersection Observer的性能开销更小,也更精确。 - 数据拼接与状态管理:组件需要维护一个已加载数据的列表。每次触发加载,将新获取的数据追加到现有列表末尾。这里的关键是处理好分页逻辑(如
page、limit)和加载状态(isLoading、hasMore)。 - 防抖与错误处理:快速滚动可能多次触发观察器,需要简单的防抖或标志位来避免并发请求。同时,网络请求可能会失败,组件需要提供重试机制或错误状态展示。
与分页组件的取舍:无限滚动适合沉浸式浏览内容,用户目标不明确;而分页导航更适合用户有明确目标、需要跳转或知晓总进度的场景。选择哪种方式取决于你的产品逻辑。
2.4 Datetime Picker:复杂的日期时间交互封装
一个功能完整的日期时间选择器,可能是前端最复杂的组件之一。它需要处理日历视图、时间选择、时区、格式化、解析等多种问题。shadcn-ui-expansions提供的这个组件,大概率是基于react-day-picker等成熟库进行二次封装,并集成了时间选择功能,同时保持与 shadcn/ui 一致的视觉风格。
使用建议:直接使用此类封装好的组件,可以节省大量开发时间。但在集成时,务必注意:
- 值格式:明确组件接收和返回的日期时间格式是什么?是 JavaScript 的
Date对象,还是 ISO 8601 字符串,或是时间戳? - 时区处理:组件是否处理时区?如果应用涉及多时区,最好确保所有日期时间都以 UTC 存储和传输,仅在显示时转换为本地时间。
- 表单集成:确保其
onChange事件返回的值能被你的表单状态正确处理。
3. 项目集成与实操指南
了解了核心组件后,下一步就是将它们应用到你的 Next.js 项目中。整个过程非常“shadcn/ui”,如果你熟悉添加官方组件,那这就是一模一样的流程。
3.1 环境准备与安装
假设你已经有一个使用 shadcn/ui 的 Next.js 项目(使用 App Router)。如果没有,可以先用官方 CLI 初始化一个。
# 在项目根目录下,通过 npx 直接运行扩展包的 CLI 工具来添加组件 # 注意:以下命令为示例,具体请参考项目 README 的最新说明 npx shadcn-ui-expansions@latest add multiple-selector或者,更符合 shadcn/ui 哲学的方式是手动复制代码:
- 访问
shadcn-ui-expansions的 GitHub 仓库或演示网站。 - 找到目标组件(如
Multiple Selector)的源代码文件。 - 将代码文件(如
multiple-selector.tsx)复制到你项目中的components/ui/目录下(你可以自定义这个路径,但保持统一便于管理)。 - 检查该组件是否有依赖的其他内部工具函数或类型定义,一并复制。
- 根据你的项目情况,可能需要调整一下导入路径(例如,引用的
@/lib/utils中的工具函数)。
3.2 组件使用与定制化案例
以Multiple Selector为例,我们来看一个完整的集成示例。
步骤一:复制组件代码将components/ui/multiple-selector.tsx文件复制到你的项目中。
步骤二:在表单中使用
// app/page.tsx 或你的表单组件中 'use client'; // 因为这是交互组件,需要客户端渲染 import { useState } from 'react'; import { MultipleSelector } from '@/components/ui/multiple-selector'; import { Button } from '@/components/ui/button'; // 你的 shadcn/ui 按钮 // 定义选项类型 type Option = { label: string; value: string; }; const FRAMEWORKS: Option[] = [ { label: 'Next.js', value: 'next' }, { label: 'Remix', value: 'remix' }, { label: 'Astro', value: 'astro' }, { label: 'Gatsby', value: 'gatsby' }, { label: 'Nuxt.js', value: 'nuxt' }, { label: 'SvelteKit', value: 'sveltekit' }, ]; export default function DemoPage() { const [selectedFrameworks, setSelectedFrameworks] = useState<Option[]>([]); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async () => { setIsSubmitting(true); // 模拟 API 调用 await new Promise(resolve => setTimeout(resolve, 1000)); console.log('提交的框架:', selectedFrameworks.map(f => f.value)); setIsSubmitting(false); }; return ( <div className="max-w-md mx-auto p-8 space-y-6"> <div> <label className="text-sm font-medium mb-2 block">选择你感兴趣的前端框架</label> <MultipleSelector value={selectedFrameworks} onChange={setSelectedFrameworks} options={FRAMEWORKS} placeholder="搜索并选择框架..." emptyIndicator={ <p className="text-center text-sm text-muted-foreground">未找到匹配项。</p> } // 许多扩展组件都支持 shadcn/ui 的 `className` 属性来覆盖样式 className="border" /> <p className="text-xs text-muted-foreground mt-2"> 已选择 {selectedFrameworks.length} 个项目。 </p> </div> <Button onClick={handleSubmit} disabled={isSubmitting || selectedFrameworks.length === 0}> {isSubmitting ? '提交中...' : '提交选择'} </Button> </div> ); }步骤三:深度定制假设你觉得默认的标签颜色太单调,想根据值来变化。由于你拥有全部代码,可以直接修改multiple-selector.tsx文件中渲染标签的部分。
// 在你的 multiple-selector.tsx 中找到渲染标签的组件 // 通常是一个 map 循环,返回类似下面的结构 const Badge = ({ option, onRemove }: { option: Option; onRemove: () => void }) => { // 你可以在这里添加自定义逻辑 const getBadgeVariant = (value: string) => { if (value === 'next') return 'bg-blue-100 text-blue-800'; if (value === 'remix') return 'bg-purple-100 text-purple-800'; // ... 其他 return 'bg-gray-100 text-gray-800'; }; return ( <div key={option.value} className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getBadgeVariant(option.value)}`} > {option.label} <button type="button" onClick={onRemove} className="ml-1.5 rounded-sm opacity-70 ring-offset-background hover:opacity-100 focus:outline-none" > <span className="sr-only">移除 {option.label}</span> × </button> </div> ); };这就是“代码归你所有”的最大优势:完全的掌控力。你可以修改交互逻辑、调整样式、甚至修复你发现的任何 bug,而无需等待上游更新。
3.3 与其他 shadcn/ui 组件的协同
扩展组件在设计时就已经考虑到了与原生组件的协同。例如,Loading Button的 Props 接口应该与原始的Button组件高度兼容,这样你可以在不改变现有代码结构的情况下直接替换。Responsive Modal应该和基于@radix-ui/react-dialog的官方Dialog组件有相似的 API 设计,使得状态管理(open,onOpenChange)的方式保持一致。
这种一致性极大地降低了学习成本和集成风险。你可以像搭积木一样,将官方组件和扩展组件混合使用,构建出复杂而一致的界面。
4. 高级技巧与性能优化
当你在项目中大量使用这些组件,尤其是在复杂列表或频繁交互的场景下,一些性能和实践上的考量就变得重要。
4.1 列表性能优化
对于Infinite Scroll或Multiple Selector渲染超长列表的情况,直接渲染所有 DOM 节点会导致性能急剧下降。此时,引入虚拟滚动是必要的。
虚拟滚动原理:只渲染当前视口及附近区域可见的列表项,随着滚动动态回收和创建 DOM 节点。这能保证即使有上万条数据,活跃的 DOM 节点数量也维持在一个很低的水平。
集成建议:shadcn-ui-expansions的基础无限滚动可能未内置虚拟滚动。对于超长列表,你可以考虑:
- 将
Infinite Scroll组件与专门的虚拟滚动库(如tanstack/react-virtual)结合。让Infinite Scroll负责数据加载的逻辑,而react-virtual负责高效渲染。 - 或者,寻找一个集成了虚拟滚动的无限滚动扩展组件。
// 一个结合 tanstack/react-virtual 的简化思路 import { useVirtualizer } from '@tanstack/react-virtual'; import { useInfiniteQuery } from '@tanstack/react-query'; // 假设用 React Query 管理数据 function VirtualInfiniteList() { const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({/* ... */}); const allItems = data?.pages.flatMap(page => page.items) || []; const parentRef = useRef<HTMLDivElement>(null); const rowVirtualizer = useVirtualizer({ count: hasNextPage ? allItems.length + 1 : allItems.length, // +1 给加载指示器留位置 getScrollElement: () => parentRef.current, estimateSize: () => 50, // 每行大约高度 overscan: 5, // 视口外预渲染的行数 }); // 监听是否滚动到了底部,用于触发加载更多 const lastItem = rowVirtualizer.getVirtualItems()[rowVirtualizer.getVirtualItems().length - 1]; useEffect(() => { if (lastItem?.index >= allItems.length - 1 && hasNextPage) { fetchNextPage(); } }, [lastItem, allItems.length, hasNextPage, fetchNextPage]); return ( <div ref={parentRef} className="h-[500px] overflow-auto"> <div style={{ height: `${rowVirtualizer.getTotalSize()}px` }}> {rowVirtualizer.getVirtualItems().map(virtualItem => { const isLoaderRow = virtualItem.index > allItems.length - 1; const item = allItems[virtualItem.index]; return ( <div key={virtualItem.key} style={{position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualItem.size}px`, transform: `translateY(${virtualItem.start}px)`}}> {isLoaderRow ? 'Loading more...' : <div>{item.name}</div>} </div> ); })} </div> </div> ); }4.2 状态管理与组件通信
当页面上有多个复杂交互组件时(例如,一个Multiple Selector的选择结果影响另一个Datetime Picker的可选范围),状态管理变得关键。
推荐模式:
- 状态提升:对于紧密相关的组件,将共享状态提升到它们最近的共同父组件中管理。这是 React 最基础也最有效的模式。
- 使用 Context:当状态需要被深层次嵌套的多个组件访问,且传递 props 变得繁琐时,使用 React Context。例如,可以创建一个
FormContext来管理整个复杂表单的状态。 - 谨慎使用全局状态库:对于并非全局通用的 UI 状态(如某个特定选择器的值),不建议轻易引入 Redux 或 Zustand。这会导致状态流难以追踪。优先考虑上述两种方式。
示例:联动选择
// 父组件管理状态 const [selectedCategory, setSelectedCategory] = useState<string>(''); const [availableSubOptions, setAvailableSubOptions] = useState<Option[]>([]); // 当分类改变时,动态更新子选项 useEffect(() => { const subs = fetchSubOptionsBasedOnCategory(selectedCategory); // 模拟获取 setAvailableSubOptions(subs); }, [selectedCategory]); return ( <div> <MultipleSelector options={CATEGORIES} onChange={(val) => setSelectedCategory(val[0]?.value)} /> <MultipleSelector options={availableSubOptions} disabled={!selectedCategory} /> </div> );4.3 自定义主题与样式覆盖
shadcn/ui 使用 CSS Variables 和 Tailwind CSS 来管理主题。扩展组件也遵循这一规范。如果你想统一修改所有扩展组件的某些样式,最佳实践是修改项目的全局 CSS 变量或扩展 Tailwind 配置。
1. 通过 CSS 变量定制: 在app/globals.css中,你可以覆盖或定义新的变量。这些变量通常在组件的@layer components中被使用。
@layer base { :root { --radius: 0.75rem; /* 覆盖默认圆角,会影响所有使用该变量的组件 */ --primary: 222.2 47.4% 11.2%; /* 修改主色调 */ } .dark { --background: 0 0% 3.9%; /* 深色模式背景 */ } }2. 通过 Tailwind 类名覆盖: 大部分组件都接受className属性,你可以直接传递 Tailwind 类名进行微调。对于更复杂的覆盖,你可能需要检查组件源代码,找到目标元素的类名,然后用全局 CSS 或:global选择器进行覆盖(需谨慎,避免样式冲突)。
5. 常见问题排查与实战心得
在实际使用中,你可能会遇到一些典型问题。这里记录了我踩过的一些坑和解决方案。
5.1 组件样式丢失或错乱
问题:复制组件代码后,样式没有生效,或者布局乱了。排查步骤:
- 检查依赖的工具函数:确保你复制了组件所依赖的所有工具文件,最常见的是
@/lib/utils.ts中的cn函数。这个函数用于安全地合并 Tailwind CSS 类名。如果缺失,组件的条件类名逻辑会失效。 - 检查 CSS 导入:shadcn/ui 的样式是通过
@layer指令注入的。确保你的app/globals.css文件正确导入了 Tailwind 的基础层、组件层和工具层。扩展组件的样式通常也定义在@layer components中,需要被编译。 - 审查 Tailwind 配置:有些组件可能使用了你项目中未定义的 Tailwind 类或自定义颜色。检查
tailwind.config.js中的content字段,确保它包含了你的组件文件路径(如./components/**/*.{ts,tsx}),以便 PurgeCSS 不会误删样式。
5.2 表单集成时值绑定失败
问题:使用React Hook Form时,Multiple Selector或Datetime Picker的值无法正确注册或验证。解决方案:
- 使用
Controller:对于非原生输入组件,必须使用react-hook-form的Controller组件进行包装。import { useForm, Controller } from 'react-hook-form'; import { MultipleSelector } from '@/components/ui/multiple-selector'; const { control, handleSubmit } = useForm(); <form onSubmit={handleSubmit(data => console.log(data))}> <Controller name="frameworks" // 表单字段名 control={control} render={({ field }) => ( <MultipleSelector value={field.value || []} onChange={field.onChange} // 将组件的 onChange 绑定到 RHF 的 field.onChange options={FRAMEWORKS} placeholder="Select..." /> )} /> <Button type="submit">Submit</Button> </form> - 自定义值转换:如果组件返回的值格式(如
Option[])与你表单期望的格式(如string[])不匹配,可以在Controller的render方法中进行转换。render={({ field }) => ( <MultipleSelector value={convertValueToOptions(field.value)} // 将存储的 string[] 转为 Option[] onChange={(options) => field.onChange(options.map(opt => opt.value))} // 将 Option[] 转为 string[] 再更新 /> )}
5.3 无限滚动在严格模式下重复请求
问题:在 React 18 的严格模式(Strict Mode)下,useEffect会执行两次,可能导致无限滚动组件在初始化时连续触发两次数据加载。解决方案:这是 React 故意为之,旨在帮助发现副作用中的错误。对于数据请求,应使用AbortController或在useEffect清理函数中设置标志位来避免重复请求。更推荐的做法是使用useEffect的依赖项和条件判断来精确控制请求时机。
useEffect(() => { let isMounted = true; // 标志位 const fetchData = async () => { if (!hasMore || isLoading || !isMounted) return; // 关键:增加加载中检查和挂载检查 setIsLoading(true); try { const newData = await api.fetchPage(page); if (isMounted) { setData(prev => [...prev, ...newData]); setPage(p => p + 1); setHasMore(newData.length > 0); } } finally { if (isMounted) setIsLoading(false); } }; fetchData(); return () => { isMounted = false; }; // 清理时标记为未挂载 }, [page, hasMore, isLoading]); // 依赖项要包含 isLoading5.4 移动端触摸体验不佳
问题:某些交互组件(如Dual Range Slider)在移动设备上拖动不跟手或容易误触。优化方向:
- 使用支持触摸的原生库:确保底层使用的交互原语库(如 Radix UI Slider)对触摸事件有良好支持。
- 增加触摸目标尺寸:通过 CSS 增加滑块手柄(
thumb)的尺寸,特别是在移动端视图下。@media (max-width: 768px) { .slider-thumb { width: 24px; height: 24px; } } - 考虑移动端替代交互:对于非常复杂的桌面端交互,在移动端可以考虑提供简化的替代方案。例如,双滑块在移动端可以暂时拆分为两个独立的数字输入框。
5.5 类型错误与 TypeScript 配置
问题:复制代码后,TypeScript 报错,提示找不到模块或类型定义。排查:
- 路径别名:确保项目
tsconfig.json中正确配置了路径别名(如"@/*": ["./*"]),并且组件导入语句使用了正确的别名。 - 类型导出:检查你复制的组件文件是否导出了其 Props 类型。如果没有,你可能需要手动声明,或者从组件中提取。
// 如果原组件没有导出类型,你可以这样提取: import { MultipleSelector } from '@/components/ui/multiple-selector'; type MultipleSelectorProps = React.ComponentProps<typeof MultipleSelector>; - 依赖库类型:如果组件内部使用了第三方库(如
date-fns),确保你的项目也安装了对应的@types包(如@types/date-fns)。
6. 项目贡献与自定义开发指南
shadcn-ui-expansions是一个开源项目,其生命力来源于社区。如果你在使用过程中发现了 bug,或者有一个绝妙的组件点子,积极参与贡献是让整个生态变得更好的方式。
6.1 如何提交有效的 Issue
当你遇到问题时,在仓库的 Issues 页面创建一个新 issue 前,请先做好功课:
- 搜索:先搜索是否已有类似 issue,避免重复。
- 清晰描述:标题简明扼要,正文详细描述。
- 环境:提供你的 Next.js、React、Node.js 版本。
- 复现步骤:一步步说明如何能重现这个问题。最好能提供一个最小的、可复现的代码仓库链接(例如 CodeSandbox 或 GitHub Repo)。
- 预期行为:你期望组件如何工作。
- 实际行为:组件实际发生了什么错误。
- 截图/录屏:如果涉及 UI 问题,附上视觉证据。
- 提供代码:贴出你使用组件的相关代码片段。
6.2 如何发起 Pull Request (PR)
如果你想修复 bug 或添加新组件,发起 PR 是标准流程:
- Fork 仓库:在 GitHub 上 Fork
hsuanyi-chou/shadcn-ui-expansions到你的账户。 - 克隆并创建分支:
git clone https://github.com/你的用户名/shadcn-ui-expansions.git cd shadcn-ui-expansions git checkout -b feat/my-awesome-component # 或 fix/xxx-bug - 开发与测试:
- 在新分支上进行修改或开发。
- 遵循项目现有的代码风格和目录结构。
- 为你的组件编写清晰的 JSDoc 注释。
- 如果可能,添加示例到演示应用 (
app目录下) 中。 - 确保代码能通过现有的质量检查(如
npm run lint)。
- 提交与推送:
git add . git commit -m "feat: add MyAwesomeComponent" git push origin feat/my-awesome-component - 发起 PR:回到 GitHub 你的 Fork 页面,通常会有一个提示让你对比并发起 Pull Request 到原仓库。填写清晰的 PR 标题和描述,说明你的改动内容和原因。
6.3 从使用者到贡献者的思维转变
当你决定自己开发一个符合 shadcn/ui 风格的扩展组件时,需要关注以下几点:
- 一致性:组件的 API 设计、命名规范(
onValueChangevsonChange)、文件结构(如components/ui/)应尽量与官方组件和其他扩展组件保持一致。 - 可访问性:这是 shadcn/ui(基于 Radix UI)的核心优势之一。确保你的组件支持键盘导航、拥有正确的 ARIA 属性、并能被屏幕阅读器良好识别。
- 可定制性:通过
className属性暴露关键元素的样式覆盖点,并通过...props将剩余属性传递给底层 DOM 元素或 Radix 原语。 - 文档与示例:一个优秀的组件离不开清晰的文档。在组件文件的顶部用 JSDoc 说明用途、Props,并提供一个简单的使用示例。
实战心得:我最开始贡献组件时,曾过度设计 API,提供了太多细粒度的配置项,反而让组件变得难以使用。后来我意识到,shadcn/ui 哲学的精髓是“提供高质量的默认值,同时保留完整的定制出口”。所以,现在设计组件时,我会先思考最常见的 80% 的使用场景,为这些场景提供开箱即用的完美体验;然后,通过暴露底层原语、支持className覆盖、或者提供一些关键的renderProp,来满足剩下 20% 的特殊需求。这种平衡之道,才是打造受欢迎工具库的关键。