抽屉组件(Drawer)是前端开发中一个看似简单、实则暗藏玄机的交互元素。很多开发者习惯直接使用 Ant Design、Material-UI 等现成组件库,但当业务需求变得独特——比如需要复杂的动画序列、与全局状态深度绑定、或者要在微前端架构下保持样式隔离时,现成方案往往捉襟见肘。此时,从零开始用 React 和 TypeScript 构建一个高定制化、强类型的抽屉组件,就从一个“可有可无”的练习,变成了解决实际工程问题的关键技能。
这篇文章要解决的,正是这个痛点。我们将不止步于实现一个能滑入滑出的 UI,而是要深入探讨:如何设计一个兼具灵活性(支持各种位置、动画、内容)、健壮性(完备的 TypeScript 类型、严格的错误边界)和开发者体验(清晰的 API、易于集成的状态管理)的抽屉组件。你会发现,自己动手实现的过程,会让你对 React 生命周期、CSS 动画、Portal、泛型等核心概念有更深的理解,这种理解是单纯调用antd.drawer所无法获得的。
我们将从零开始,一步步构建这个组件。你会看到完整的代码实现、详尽的类型定义、常见的坑与解决方案,以及如何将这个组件优雅地集成到你的项目中。无论你是想深入 React 实践,还是正在为下一个复杂项目寻找 UI 组件解决方案,这篇文章都将提供一条清晰的路径。
1. 为什么需要自己实现抽屉组件?现成方案不够用吗?
在开始写代码之前,我们必须先回答一个根本问题:为什么不用现成的?Ant Design 的 Drawer 不香吗?这直接决定了我们投入精力自研的价值所在。
现成组件库的抽屉在大多数场景下是完美的解决方案。它们开箱即用,经过充分测试,设计美观,能覆盖 80% 的常见需求。然而,当你遇到以下情况时,自研的优势就凸显出来了:
- 极致定制化需求:你需要一个从屏幕底部弹起、但顶部有特殊弧形切割的抽屉;或者需要抽屉内部嵌套另一个可以横向滑出的子抽屉。这些高度定制化的 UI 效果,修改第三方组件的样式和逻辑可能比从头写更痛苦。
- 包体积敏感:如果你的项目对 bundle size 极其敏感(如移动端 H5),引入整个 Ant Design 或 MUI 仅仅为了一个抽屉组件,显然不划算。一个自研的、功能聚焦的抽屉可能只有几 KB。
- 框架或环境限制:在一些特殊的框架(如某些微前端子应用)或构建工具中,第三方组件库可能会带来样式污染、版本冲突或构建问题。
- 学习与掌控:这是最重要的原因。通过自研,你能彻底掌控组件的每一个生命周期、每一次重渲染、每一段 CSS 动画。当出现诡异 bug 时,你能够深入源码定位,而不是在第三方库的 issues 里大海捞针。
因此,本文的目标不是教你造一个替代antd的轮子,而是通过“造轮子”这个高密度的实践,系统性地掌握用 React + TypeScript 构建高质量可复用组件的完整心法和技法。这个组件本身,将成为一个高度可定制的基础设施,随时准备被你的业务需求所改造。
2. 核心概念与设计目标
在动手前,我们需要明确抽屉组件的核心构成和我们的设计目标。
2.1 抽屉组件的核心要素
一个功能完整的抽屉组件通常包含以下部分:
- 遮罩层 (Overlay/Backdrop):半透明的底层,用于聚焦内容、阻止背景交互,点击可关闭抽屉。
- 内容容器 (Drawer Content):承载实际内容的区域。
- 位置 (Placement):抽屉从屏幕的哪一侧滑入,常见的有
top,right,bottom,left。 - 动画 (Animation):滑入滑出的过渡效果,通常与
placement相关。 - 标题与关闭按钮 (Title & Close):可选的头部区域。
- 状态管理:控制抽屉的打开 (
open) 与关闭 (onClose)。 - 可访问性 (A11y):键盘导航(ESC 关闭)、焦点管理、ARIA 属性。
2.2 我们的 TypeScript 设计目标
我们将用 TypeScript 强化组件的可靠性:
- 严格的 Props 类型:使用接口明确定义所有传入属性,包括可选、必选以及默认值。
- 字面量联合类型:对于
placement这类有限选项,使用'top' | 'right' | 'bottom' | 'left'确保传入值合法。 - 泛型的应用:使组件能够推断其子组件或某些回调函数的类型,提升开发体验。
- Ref 转发:支持外部获取抽屉内容 DOM 节点的引用,以便进行更精细的操作。
2.3 组件 API 设计预览
在开始前,我们先预览一下最终希望如何使用这个组件。清晰的 API 是良好设计的开端。
import { Drawer } from './Drawer'; function App() { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen(true)}>打开抽屉</button> <Drawer open={isOpen} onClose={() => setIsOpen(false)} placement="right" width={400} title="这是一个抽屉标题" destroyOnClose={false} > <p>这里是抽屉的内容,可以是任何React节点。</p> <button onClick={() => console.log('内部操作')}>内部按钮</button> </Drawer> </div> ); }3. 环境准备与项目初始化
我们将在一个标准的 React + TypeScript 项目环境中进行开发。请确保你的环境满足以下条件:
- Node.js: 版本 16 或以上(推荐 LTS 版本)。
- 包管理器: npm 或 yarn 或 pnpm。
- IDE: Visual Studio Code(推荐),并安装 ESLint 和 Prettier 插件以保持代码风格统一。
如果你还没有项目,最快的方式是使用create-react-app或Vite创建一个。
使用 Vite 创建项目(推荐,速度更快):
npm create vite@latest my-drawer-app -- --template react-ts cd my-drawer-app npm install项目创建后,结构大致如下:
my-drawer-app/ ├── src/ │ ├── App.tsx │ ├── main.tsx │ └── ... ├── index.html ├── package.json └── ...我们将在src/components目录下创建我们的Drawer组件。
4. 构建组件基础骨架与类型定义
首先,创建src/components/Drawer目录,并在其中创建核心文件:
src/components/Drawer/ ├── index.tsx // 组件主文件 ├── Drawer.tsx // 组件实现 (也可以和index.tsx合并) ├── Drawer.css // 组件样式 └── types.ts // TypeScript 类型定义让我们先从类型定义开始,这是用 TypeScript 开发组件的关键第一步。
文件:src/components/Drawer/types.ts
// 定义抽屉出现的位置 export type DrawerPlacement = 'top' | 'right' | 'bottom' | 'left'; // 定义组件接收的 Props export interface DrawerProps { /** 控制抽屉打开或关闭 */ open: boolean; /** 关闭抽屉时的回调函数 */ onClose: () => void; /** 抽屉的位置 */ placement?: DrawerPlacement; /** 抽屉的宽度(placement 为 left/right 时生效) */ width?: number | string; /** 抽屉的高度(placement 为 top/bottom 时生效) */ height?: number | string; /** 抽屉的标题,传入 string 或 ReactNode。不传则不显示标题栏 */ title?: React.ReactNode; /** 是否显示遮罩层 */ mask?: boolean; /** 点击遮罩层是否可关闭抽屉 */ maskClosable?: boolean; /** 是否显示右上角的关闭按钮 */ closable?: boolean; /** 关闭后是否销毁抽屉里的子元素 */ destroyOnClose?: boolean; /** 自定义类名 */ className?: string; /** 自定义样式 */ style?: React.CSSProperties; /** 抽屉内容 */ children: React.ReactNode; /** 用于获取抽屉内容容器的 ref */ drawerRef?: React.Ref<HTMLDivElement>; }这个类型接口清晰地定义了组件的“契约”。每个属性都有详细的 JSDoc 注释,这在团队协作和后期维护中非常有用。placement使用了字面量联合类型,确保了传入值的合法性。
5. 实现组件逻辑与 JSX 结构
接下来,我们实现组件的主体部分。我们将使用 React 的Portal将抽屉渲染到body元素下,这是一种常见的最佳实践,可以避免父组件的 CSS 属性(如overflow: hidden)影响抽屉的显示。
文件:src/components/Drawer/Drawer.tsx
import React, { useEffect, useState, useRef } from 'react'; import ReactDOM from 'react-dom'; import { DrawerProps } from './types'; import './Drawer.css'; // 引入样式 const Drawer: React.FC<DrawerProps> = (props) => { const { open, onClose, placement = 'right', width = 300, height = 300, title, mask = true, maskClosable = true, closable = true, destroyOnClose = false, className = '', style, children, drawerRef, } = props; // 状态:用于控制抽屉内容是否应该被销毁 const [shouldDestroy, setShouldDestroy] = useState(!open); // 状态:用于控制动画的激活状态 const [active, setActive] = useState(open); // Ref:指向抽屉内容容器DOM const drawerContainerRef = useRef<HTMLDivElement>(null); // 处理打开/关闭动画 useEffect(() => { if (open) { // 打开时,先确保内容不被销毁,然后触发激活动画 setShouldDestroy(false); // 下一个事件循环再激活,确保CSS过渡生效 requestAnimationFrame(() => { setActive(true); }); } else { // 关闭时,先取消激活状态触发离开动画 setActive(false); // 动画结束后,根据 destroyOnClose 决定是否销毁内容 const timer = setTimeout(() => { if (destroyOnClose) { setShouldDestroy(true); } }, 300); // 这个时间需要和CSS动画时间匹配 return () => clearTimeout(timer); } }, [open, destroyOnClose]); // 处理ESC键关闭 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && open) { onClose(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [open, onClose]); // 处理遮罩层点击 const handleMaskClick = (e: React.MouseEvent<HTMLDivElement>) => { if (maskClosable && e.target === e.currentTarget) { onClose(); } }; // 计算内容区域的样式 const contentStyle: React.CSSProperties = { ...style, }; if (placement === 'left' || placement === 'right') { contentStyle.width = width; } else { contentStyle.height = height; } // 如果应该销毁且未打开,直接返回null if (shouldDestroy && !open) { return null; } // 抽屉的DOM结构 const drawerContent = ( <div className={`drawer-wrapper ${open ? 'drawer-open' : ''}`} // 阻止事件冒泡,避免干扰 onClick={(e) => e.stopPropagation()} > {/* 遮罩层 */} {mask && ( <div className={`drawer-mask ${active ? 'drawer-mask-active' : ''}`} onClick={handleMaskClick} /> )} {/* 抽屉内容容器 */} <div className={`drawer-content drawer-${placement} ${active ? 'drawer-content-active' : ''} ${className}`} style={contentStyle} ref={(node) => { // 同时处理内部ref和外部转发ref drawerContainerRef.current = node; if (drawerRef) { if (typeof drawerRef === 'function') { drawerRef(node); } else { (drawerRef as React.MutableRefObject<HTMLDivElement | null>).current = node; } } }} role="dialog" aria-modal="true" aria-labelledby={title ? 'drawer-title' : undefined} > {/* 标题栏 */} {(title || closable) && ( <div className="drawer-header"> {title && ( <div id="drawer-title" className="drawer-title"> {title} </div> )} {closable && ( <button className="drawer-close" onClick={onClose} aria-label="关闭抽屉" > × </button> )} </div> )} {/* 内容主体 */} <div className="drawer-body">{children}</div> </div> </div> ); // 使用 Portal 将抽屉渲染到 body 下 return ReactDOM.createPortal(drawerContent, document.body); }; export default Drawer;这段代码包含了几个关键点:
- 动画状态管理:使用
active状态配合 CSS 类名控制动画,使用shouldDestroy状态管理子元素的生命周期,实现了destroyOnClose功能。 - 事件处理:实现了 ESC 键关闭、遮罩层点击关闭(
maskClosable控制)。 - Ref 转发:同时处理了组件内部使用的
ref和外部传入的drawerRef,使父组件能访问到抽屉的 DOM 元素。 - 可访问性:添加了
role="dialog"和aria-labelledby等属性。 - Portal 的使用:使用
ReactDOM.createPortal确保抽屉渲染在 body 层级,避免样式冲突。
6. 编写核心样式与动画
样式是实现流畅动画和视觉表现的核心。我们使用 CSS 来实现过渡动画。
文件:src/components/Drawer/Drawer.css
/* 抽屉包装器,用于定位Portal内容 */ .drawer-wrapper { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1000; pointer-events: none; /* 默认不拦截事件,由内部元素控制 */ } /* 遮罩层样式 */ .drawer-mask { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.45); opacity: 0; transition: opacity 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86); pointer-events: auto; /* 允许接收点击事件 */ } .drawer-mask-active { opacity: 1; } /* 抽屉内容容器基础样式 */ .drawer-content { position: absolute; background: #fff; box-shadow: -6px 0 16px -8px rgba(0, 0, 0, 0.08), -9px 0 28px 0 rgba(0, 0, 0, 0.05), -12px 0 48px 16px rgba(0, 0, 0, 0.03); transition: all 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86); pointer-events: auto; /* 允许内部交互 */ } /* 不同位置的初始状态和激活状态 */ .drawer-left { top: 0; left: 0; height: 100%; transform: translateX(-100%); } .drawer-right { top: 0; right: 0; height: 100%; transform: translateX(100%); } .drawer-top { top: 0; left: 0; width: 100%; transform: translateY(-100%); } .drawer-bottom { bottom: 0; left: 0; width: 100%; transform: translateY(100%); } /* 激活状态:移动到可视位置 */ .drawer-content-active.drawer-left, .drawer-content-active.drawer-right { transform: translateX(0); } .drawer-content-active.drawer-top, .drawer-content-active.drawer-bottom { transform: translateY(0); } /* 头部样式 */ .drawer-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; border-bottom: 1px solid #f0f0f0; border-radius: 2px 2px 0 0; } .drawer-title { margin: 0; color: rgba(0, 0, 0, 0.85); font-weight: 500; font-size: 16px; line-height: 22px; } .drawer-close { display: inline-block; margin-right: 12px; color: rgba(0, 0, 0, 0.45); font-weight: 700; font-size: 16px; font-style: normal; line-height: 1; text-align: center; text-transform: none; text-decoration: none; background: transparent; border: 0; outline: 0; cursor: pointer; transition: color 0.3s; padding: 0; width: 22px; height: 22px; } .drawer-close:hover { color: rgba(0, 0, 0, 0.75); } /* 内容主体样式 */ .drawer-body { padding: 24px; font-size: 14px; line-height: 1.5715; word-wrap: break-word; overflow-y: auto; /* 内容过长时允许滚动 */ flex: 1; }样式关键点解析:
- 动画曲线:使用
cubic-bezier(0.78, 0.14, 0.15, 0.86)这个缓动函数,模拟了类似 Ant Design 的“先快后慢”的动画效果,比单纯的ease-in-out更自然。 - 变换动画:通过
transform: translateX/Y来实现滑动,性能远优于改变left/top属性。 - 指针事件控制:通过
pointer-events属性精细控制遮罩层和内容容器的点击事件,这是实现maskClosable的 CSS 基础。 - 层级管理:使用
z-index: 1000确保抽屉覆盖在常规内容之上。
7. 导出组件与使用示例
创建入口文件并导出组件。
文件:src/components/Drawer/index.tsx
import Drawer from './Drawer'; export default Drawer; export type { DrawerProps, DrawerPlacement } from './types';现在,我们可以在主应用中使用这个组件了。修改src/App.tsx文件进行测试。
文件:src/App.tsx
import React, { useState } from 'react'; import Drawer from './components/Drawer'; import './App.css'; function App() { const [openLeft, setOpenLeft] = useState(false); const [openRight, setOpenRight] = useState(false); const [openTop, setOpenTop] = useState(false); const [openBottom, setOpenBottom] = useState(false); const [openCustom, setOpenCustom] = useState(false); return ( <div className="App"> <h1>React + TS 抽屉组件演示</h1> <div className="button-group"> <button onClick={() => setOpenLeft(true)}>左侧抽屉</button> <button onClick={() => setOpenRight(true)}>右侧抽屉</button> <button onClick={() => setOpenTop(true)}>顶部抽屉</button> <button onClick={() => setOpenBottom(true)}>底部抽屉</button> <button onClick={() => setOpenCustom(true)}>自定义抽屉</button> </div> {/* 左侧抽屉 */} <Drawer open={openLeft} onClose={() => setOpenLeft(false)} placement="left" title="左侧抽屉" > <p>这是一个从左侧滑出的抽屉。</p> <p>内容可以很丰富。</p> </Drawer> {/* 右侧抽屉 */} <Drawer open={openRight} onClose={() => setOpenRight(false)} placement="right" width={350} closable={false} // 不显示关闭按钮 maskClosable={false} // 点击遮罩不能关闭 > <p>这是一个没有关闭按钮的右侧抽屉。</p> <p>你需要通过其他逻辑(如按钮)来关闭它。</p> <button onClick={() => setOpenRight(false)}>关闭我</button> </Drawer> {/* 顶部抽屉 */} <Drawer open={openTop} onClose={() => setOpenTop(false)} placement="top" height="30vh" // 使用视口单位 title="顶部通知" > <p>这是一个从顶部滑出的通知栏。</p> </Drawer> {/* 底部抽屉 */} <Drawer open={openBottom} onClose={() => setOpenBottom(false)} placement="bottom" height={400} destroyOnClose // 关闭时销毁内容 > <p>这是一个底部抽屉,关闭后内容会被销毁。</p> <p>再次打开时,下面的输入框状态会重置:</p> <input type="text" placeholder="输入一些内容..." /> </Drawer> {/* 自定义样式抽屉 */} <Drawer open={openCustom} onClose={() => setOpenCustom(false)} placement="right" width="80%" title={<span style={{ color: '#1890ff' }}>自定义标题</span>} className="custom-drawer" // 自定义类名 style={{ backgroundColor: '#f6ffed' }} // 自定义内联样式 > <p>这是一个高度自定义的抽屉。</p> <p>背景色、宽度、标题样式都可以自定义。</p> </Drawer> </div> ); } export default App;添加一些基础样式到src/App.css:
.App { text-align: center; padding: 20px; } .button-group { display: flex; gap: 10px; justify-content: center; margin-top: 20px; flex-wrap: wrap; } .button-group button { padding: 10px 20px; font-size: 16px; cursor: pointer; border: 1px solid #d9d9d9; background: #fff; border-radius: 4px; transition: all 0.3s; } .button-group button:hover { border-color: #1890ff; color: #1890ff; } /* 自定义抽屉样式示例 */ .custom-drawer .drawer-header { border-bottom-color: #b7eb8f; } .custom-drawer .drawer-body { color: #135200; }现在,运行npm run dev启动开发服务器,你将在浏览器中看到一个功能齐全的抽屉组件演示页面,可以测试不同位置、不同配置的抽屉行为。
8. 运行结果与效果验证
启动项目后,浏览器会自动打开(通常是http://localhost:5173)。页面会显示一排按钮,分别对应不同配置的抽屉。
验证点:
- 基本功能:点击每个按钮,抽屉应从指定方向平滑滑入,带有遮罩。点击遮罩或关闭按钮(ESC 键也可),抽屉应平滑滑出。
- 配置测试:
- 右侧抽屉:点击遮罩无法关闭,且无关闭按钮,只能通过抽屉内的“关闭我”按钮关闭。
- 底部抽屉:在输入框输入文字后关闭抽屉,再次打开,输入框内容应被清空(因为设置了
destroyOnClose)。 - 自定义抽屉:观察其独特的背景色和标题颜色。
- 动画流畅性:打开和关闭动画应平滑,无卡顿或闪烁。
- 可访问性:打开抽屉后,按 ESC 键应能关闭。使用屏幕阅读器(如 VoiceOver)测试,应能正确识别为对话框 (
role=”dialog”)。
如果一切符合预期,说明组件核心功能已正确实现。
9. 常见问题与排查思路
在开发和使用过程中,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 抽屉不显示 | 1.open状态未正确传递或更新。2. Portal目标容器 (document.body) 可能被样式影响(如display: none)。3. CSS 样式未正确加载。 | 1. 检查父组件 state 和事件处理函数。 2. 使用浏览器开发者工具检查 body下是否生成了抽屉对应的 DOM 节点。3. 检查 Drawer.css是否被导入,样式类名是否正确应用。 | 1. 确保open和onClose回调正确绑定。2. 确保 document.body在组件挂载时存在且可见。3. 检查导入路径和 CSS 类名拼写。 |
| 动画不生效或生硬 | 1. CSS 过渡属性 (transition) 未正确设置或与transform属性不匹配。2. React 状态更新 ( setActive) 与浏览器渲染帧不同步。 | 1. 检查Drawer.css中.drawer-content和.drawer-mask的transition属性。2. 检查 useEffect中是否使用了requestAnimationFrame来触发激活状态。 | 1. 确保transition属性设置在正确的元素和正确的 CSS 属性上。2. 保持 useEffect中setActive在requestAnimationFrame内调用的模式。 |
| 遮罩层点击无法关闭 | 1.maskClosable被设置为false。2. handleMaskClick事件处理函数中,e.target === e.currentTarget判断失败(可能有子元素)。3. 遮罩层的 pointer-events被覆盖。 | 1. 检查传入的maskClosable值。2. 在 handleMaskClick中打印e.target和e.currentTarget进行调试。3. 检查浏览器开发者工具中遮罩层元素的 pointer-events计算值。 | 1. 确保事件处理函数正确绑定到遮罩层元素。 2. 确保遮罩层元素没有被子元素“穿透”。我们的实现中,遮罩层是一个独立的 div,通常不会有此问题。 |
| 抽屉内容在关闭后依然残留 | destroyOnClose逻辑或动画结束计时器有问题。 | 1. 检查useEffect中处理关闭的定时器逻辑。2. 确认 CSS 动画时长(300ms)与 setTimeout延时是否匹配。 | 确保setTimeout的延迟时间略大于或等于 CSS 动画的持续时间,以确保动画完成后才销毁内容。 |
| TypeScript 类型报错 | 1. 导入路径错误。 2. DrawerProps接口定义与使用处不匹配。3. 使用了未定义的属性。 | 1. 检查import语句。2. 将鼠标悬停在报错变量上,查看 VS Code 的类型提示。 3. 确保传递的 props 符合 DrawerProps接口。 | 遵循 TypeScript 错误提示进行修正,确保类型安全。 |
10. 最佳实践与进阶扩展建议
一个基础的抽屉组件已经完成,但要将其用于生产环境或应对更复杂的需求,还需要考虑以下方面:
10.1 性能优化
- 避免不必要的重渲染:使用
React.memo包装Drawer组件,防止父组件状态变化导致抽屉不必要的重渲染。export default React.memo(Drawer); - 条件渲染 Portal:仅在
open为true或shouldDestroy为false时才调用ReactDOM.createPortal,减少对 DOM 的操作。 - 动画性能:我们已经使用了
transform和opacity这类由合成器线程处理的属性,动画性能较好。避免在动画过程中改变width、height、margin等可能引发布局重排的属性。
10.2 可访问性增强
- 焦点管理:抽屉打开时,应将焦点移动到抽屉内的第一个可聚焦元素(或标题),关闭时移回触发按钮。这可以通过
useRef和useEffect结合element.focus()实现。 - 屏幕阅读器通告:可以使用
aria-live区域在抽屉打开/关闭时向屏幕阅读器用户发出通告。
10.3 功能扩展
- 多层抽屉:支持在抽屉内再打开一个抽屉。这需要全局管理一个抽屉栈(Z-Index 和焦点)。
- 自定义页脚:增加
footer属性,像title一样接受一个ReactNode。 - 预设尺寸:除了具体的数值,可以支持
'small' | 'medium' | 'large'等预设值,内部映射为具体的宽高。 - 动画钩子:提供
afterOpen和afterClose回调,让使用者能在动画结束后执行某些操作。
10.4 与状态管理库集成
在大型应用中,抽屉的打开状态可能由全局状态管理(如 Redux, MobX, Zustand)。我们的组件设计完全支持这种模式,只需将open和onClose连接到你的全局状态即可。
// 例如,在Zustand store中 const useStore = create((set) => ({ isDrawerOpen: false, openDrawer: () => set({ isDrawerOpen: true }), closeDrawer: () => set({ isDrawerOpen: false }), })); // 在组件中 const { isDrawerOpen, closeDrawer } = useStore(); return <Drawer open={isDrawerOpen} onClose={closeDrawer} {...otherProps} />;10.5 样式主题化
可以将 CSS 替换为 CSS-in-JS 方案(如 styled-components, Emotion),或将颜色、尺寸等提取为 CSS 自定义属性(CSS Variables),以便轻松切换主题。
通过这个从零构建 React + TypeScript 抽屉组件的完整过程,我们不仅得到了一个可用的 UI 组件,更重要的是实践了一套构建高质量 React 组件的完整方法论:从类型设计、状态逻辑、动画编排、可访问性到性能优化和扩展性思考。下次当你面对一个独特的 UI 需求时,你将更有信心和能力去打造一个完全贴合业务、性能优异且易于维护的解决方案,而不是在第三方库的配置项里艰难地寻找出路。