从零封装一个React按钮组件:props设计、事件处理与TypeScript实践

发布时间:2026/10/13 15:17:15

从零封装一个React按钮组件:props设计、事件处理与TypeScript实践 1. 为什么要自己动手封装一个组件1.1 项目里第N次复制粘贴之后我决定做一件事先讲个真实场景。几个月前我在做公司的一个中后台管理系统需求本身不复杂无非是表格、表单、弹窗、按钮。但做到第三天我就发现不对劲了同一个确认操作按钮在列表页、详情页、弹窗里分别用到了三次而每次的代码几乎一模一样——同样的圆角、同样的hover效果、同样的loading状态。我当时的做法非常原始直接复制粘贴三份改改文字就交差。问题在第四天爆发了。UI同事说按钮在禁用状态下要加一个禁止光标而且hover的阴影要从原来的2px改成4px。改就改吧但我要改的地方是三份代码不是在三个文件里引同一个组件。从那一刻起我开始意识到所谓封装本质上解决的不是代码量的问题而是需求变化时你只需要改一处其余地方全部跟着变。这个道理听起来特别简单但很多刚接触React的朋友或者做了一两年业务开发但一直用现成组件库的人其实并没有真正经历从零封装一个组件的过程。你以为你在用组件其实你在用别人封装好的结果一旦这些现成的东西满足不了业务你就得自己下场。所以这篇文章我想用最朴素的思路拿一个按钮当例子把封装的全流程完整拆给你看。1.2 封装不是造轮子三条标准先掂量一下在动手之前必须先说清楚一个边界问题。React生态里已经有Ant Design、Element Plus、MUI这些非常成熟的组件库按钮这种基础组件它们写了几千遍为什么还要自己封装我的判断标准一直很朴素满足任意一条就值得封装业务复用频率高同一个UI形态在项目里出现三次以上且形态基本稳定需要统一业务逻辑比如按钮点击后要统一埋点、统一判权限、统一做防重复提交这些逻辑散落在各处就会失控组件库满足不了定制需求二次改造的成本高于自己写一遍的成本。按钮看似是基础组件里最没技术含量的但它恰恰是封装入门最合适的载体——因为它小、五脏俱全有样式、有交互、有状态、有事件能让你在最小范围内把组件的核心机制过一遍又不会因为复杂度而劝退。拿它练手之后再去封装表格、表单、弹窗这类复杂组件路会顺很多。1.3 环境准备用Vite把React项目跑起来既然是从零环境就尽量简单。我用的是Vite React创建命令就一行npm create vitelatest my-button-demo -- --template react装依赖启动cd my-button-demo npm install npm run dev这里补充一个我踩过的坑如果你用的是旧一点的npm版本npm create vite那行命令可能会卡在交互式提问上多问几个是否安装之类的选项。我的建议是把npm升级到较新版本或者直接用pnpm create vite也行交互会顺畅得多。项目跑起来之后把src/App.jsx里默认的示例代码清理掉留一个空壳function App() { return ( div classNameapp-container {/* 后面我们的按钮就放在这里 */} /div ); } export default App;到这一步环境就绪。但先别急着写代码因为封装一个组件之前有一件比写代码更重要的功课要做——把组件的接口想清楚。2. 在写代码之前先设计好组件的使用说明书2.1 props就是组件的API把参数表列出来很多初学者上手写组件第一反应就是写个带样式的button标签放进去就完事。这种想法错在把组件当成一段固定的模板而组件正确的定位应该是一个可配置的单元。配置靠什么靠props。props就是组件的API使用组件的人通过props告诉组件我要什么样子、什么行为、什么内容。以按钮为例我先列一个最精简的props参数表这个表就是我要介绍的使用说明书props名称类型默认值说明childrenReactNode无按钮内容typestringprimary按钮类型primary / default / dangersizestringmd尺寸sm / md / lgdisabledbooleanfalse禁用状态loadingbooleanfalse加载状态显示菊花并阻止点击onClickfunction无点击回调参数为事件对象这张表里的每一项都是有讲究的。不要小看这个提前列清楚的动作在实际项目里组件用起来卡不卡手90%取决于props设计得好不好。你可以在脑中列也可以写在笔记里但一定要在动手前把用户怎么用你这个组件想明白。2.2 默认值兜底宁可让组件多管一点别让使用方多写一点props设计里有一条很关键的原则凡是大部分场景都一样的配置就给默认值。拿type来说一个项目里80%的按钮都是主按钮primary那用户在使用时就不应该每次写MyButton typeprimary而是直接MyButton登录/MyButton就该默认渲染出主按钮的样子。反过来说如果哪次需要次按钮再显式传typedefault覆盖默认值。再看children。React有个机制是组件标签内部的内容会作为children传递进来。这意味着你封装的组件不应该写死文本而应该把内容位置让出来由使用方决定// 错误示范写死内容 function MyButton() { return button确定/button; } // 正确示范children插槽 function MyButton({ children }) { return button{children}/button; }children就是React里最基础、最强大的插槽机制。它不仅接受字符串还接受任何JSX节点这意味着你可以传图标、传文字、传一段带样式的复杂结构。这个简单的设计让同一个按钮组件既能做确定按钮也能做带小图标的下载按钮。2.3 边界行为使用方没传的参数组件不能默默出错props设计里还要考虑没传怎么办和传了奇怪的怎么办。没传onClick时按钮要能正常渲染点击不报错没传loading时按钮不展示任何loading效果行为保持普通按钮children为空时按钮要能正常显示不崩溃。处理方式很简单就是用默认值兜底同时给回调做一个空函数保护function MyButton({ children, type primary, size md, disabled false, loading false, onClick () {} }) { ... }这里我重点说下onClick () {}这个默认值。很多初学者只给数据类props设默认值事件回调不设。结果使用方没传onClick组件里又直接调用onClick()就跑出onClick is not a function。给事件回调设一个空函数默认值能避免这类低级但非常常见的报错。3. 首个版本让按钮先活起来3.1 从函数组件开始写最基础的骨架光说不练假把式。现在开始写第一个版本的组件。React的函数组件本质就是一个函数输入props输出JSX。我习惯在src/components/MyButton/index.jsx下维护这个组件独立目录方便以后加样式文件和测试文件。第一个版本先把骨架搭出来// src/components/MyButton/index.jsx function MyButton({ children, type primary, size md, disabled false, loading false, onClick () {}, }) { return ( button className{my-btn my-btn--${type} my-btn--${size}} disabled{disabled || loading} onClick{loading ? () {} : onClick} {loading span classNamemy-btn__spinner /} span{children}/span /button ); } export default MyButton;这个版本做了什么它已经把props的核心机制用起来了className根据传入的type和size动态拼接disabled{disabled || loading}表示加载中或禁用都必须不可点击onClick{loading ? () {} : onClick}防止loading状态下误触点击loading时渲染一个小菊花同时保留文本内容。3.2 接入样式三种衔接方式我推荐你用第二种这里有个必须做的决定样式怎么管理React组件的样式方案五花八门CSS Modules、styled-components、tailwind、纯CSS文件……刚入门的同学容易挑花眼。我先给一个务实的结论用CSS Modules。CSS Modules在Vite里是开箱即用的把CSS文件命名成index.module.css然后在组件里import styles from ./index.module.css直接通过styles.xxx引用类名。它的好处是类名会被自动哈希不会污染全局样式非常适合组件封装场景。/* src/components/MyButton/index.module.css */ .my-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; transition: all 0.2s ease; } .my-btn--primary { background: #1677ff; color: #fff; } .my-btn--primary:hover { background: #4096ff; } .my-btn--default { background: #fff; color: #333; border: 1px solid #d9d9d9; } .my-btn--default:hover { border-color: #1677ff; color: #1677ff; } .my-btn--danger { background: #ff4d4f; color: #fff; } .my-btn--danger:hover { background: #ff7875; } .my-btn--sm { padding: 4px 12px; font-size: 12px; } .my-btn--md { padding: 6px 16px; } .my-btn--lg { padding: 8px 20px; font-size: 16px; } .my-btn:disabled { opacity: 0.5; cursor: not-allowed; } .my-btn__spinner { width: 14px; height: 14px; border: 2px solid rgba(255, 255, 255, 0.6); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; } keyframes spin { to { transform: rotate(360deg); } }组件代码相应改为引入这个CSS Moduleimport styles from ./index.module.css; function MyButton({ children, type primary, size md, disabled false, loading false, onClick () {} }) { return ( button className{${styles[my-btn]} ${styles[my-btn--${type}]} ${styles[my-btn--${size}]}} disabled{disabled || loading} onClick{loading ? () {} : onClick} {loading span className{styles[my-btn__spinner]} /} span{children}/span /button ); }注意CSS Modules不能直接用styles.my-btn--primary这种写法因为连字符在属性访问里不合法所以我统一用方括号 字符串的形式访问。这是CSS Modules使用里最常见的小坑先帮你排掉了。3.3 在App里试用第一眼看到效果到这一步就可以在App.jsx里试用这个组件了import MyButton from ./components/MyButton; function App() { return ( div style{{ display: flex, gap: 12, padding: 40 }} MyButton主按钮/MyButton MyButton typedefault次按钮/MyButton MyButton typedanger危险按钮/MyButton MyButton sizesm小尺寸/MyButton MyButton sizelg大尺寸/MyButton MyButton disabled禁用/MyButton MyButton loading提交中/MyButton MyButton onClick{() console.log(clicked)}点击我/MyButton /div ); }浏览器里看到不同大小、不同类型、不同状态的按钮后你才算真正跨过了封装组件的第一道门槛。组件不再是写死的而是根据外部传参动态变化的。这就是数据驱动UI的雏形。4. React事件处理封装的关节处也是最容易踩坑的地方4.1 为什么回调要从props传进来而不是在组件内部处理把按钮组件做到了基本可用之后我们碰一下封装里最核心的关节——事件处理。你可能会问点击按钮弹个窗这不就是写个onClick吗确实是但关键是这个onClick写在哪儿。一种错误做法是直接在组件内部写死逻辑// 反面教材业务逻辑写死在组件里 function MyButton() { const handleClick () { alert(你点击了按钮); }; return button onClick{handleClick}{children}/button; }这样写的问题在于你的组件一旦弹alert就永远弹alert别的页面要跳转、要发请求、要改状态全都没法用这个组件。组件彻底失去了复用性变成一次性用品。正确做法是把行为的选择权交给使用方。组件只负责触发事件不负责具体干什么。通过props把回调函数传进来让使用方决定点击后发生什么function MyButton({ children, onClick () {} }) { return button onClick{onClick}{children}/button; } // 使用方 MyButton onClick{() setVisible(true)}打开弹窗/MyButton MyButton onClick{() submitData()} 提交数据/MyButton一句话总结组件管什么时候通知使用方管通知之后干什么。这个思想是业务组件设计中最重要的分界线几乎所有优秀的组件库都是这么做的。4.2 传递参数event对象和额外数据的处理方式第二步要处理的问题是使用方需要在回调里拿到数据。最常见的是两种情况情况一使用方想拿到原生事件对象。React对事件做了合成处理但你仍然可以从回调参数里拿到它MyButton onClick{(e) console.log(e.target, e.clientX)}坐标/MyButton这里有一个权重分配的问题如果组件内部已经用到事件对象做了某些事比如阻止冒泡又要把事件对象传给使用方可能会产生预期外的冲突。我的处理原则是组件内部尽量不要拦截事件把事件原样交还给使用方。情况二使用方需要拿到业务数据。比如按钮在列表中使用方需要知道点击的是哪一行// 组件内部透传参数 MyButton onClick{(e) onClick(e)}删除/MyButton // 使用方在回调里绑定自己的数据 rowList.map((row) ( MyButton key{row.id} onClick{(e) handleDelete(row.id, e)} 删除 /MyButton ));这里要注意不要直接在组件里凭空给回调塞参数比如onClick{() onClick(hello)}因为组件并不知道使用方的业务数据是什么。标准做法是让使用方在箭头函数里自行绑定数据。4.3 防重复点击一次真实的线上事故事件处理这块我再分享一个真实教训。有次做支付相关的页面测试环境怎么点都没问题上线后用户反馈我一不小心点了两下生成了两笔订单。问题的根源非常经典按钮没有防重复点击。后端理论上可以靠幂等标识去重但前端把这一层兜住体验会好很多。我在封装的按钮组件里直接内置了loading状态不就是防重复的天然手段吗所以规范用法是function handleSubmit() { setLoading(true); await submitOrder(); setLoading(false); } MyButton loading{loading} onClick{handleSubmit} 提交订单 /MyButton这里的关键在于组件的onClick{loading ? () {} : onClick}逻辑它保证了在loading为true时点击事件不会发出去。这个东西说穿了不值钱但没封装的时候每个页面都得自己写防重复逻辑封装之后全部统一了。5. 从能用到好用细节是魔鬼也是分水岭5.1 补上TypeScript给组件装一道边界铁丝网前面几节的代码都是用JS写的对于刚入门的学习者来说足够简单但放到工程里没有类型约束的组件在团队协作时是灾难。你想想看队友根本不知道你的组件有哪些props、每个props是什么类型用错了只能运行时报错调试成本很高。给组件加TypeScript本质上就是把我们第2节设计的那张props参数表用代码语言固化下来// src/components/MyButton/index.tsx type ButtonType primary | default | danger; type ButtonSize sm | md | lg; interface MyButtonProps { children: React.ReactNode; type?: ButtonType; size?: ButtonSize; disabled?: boolean; loading?: boolean; onClick?: (event: React.MouseEventHTMLButtonElement) void; } function MyButton({ children, type primary, size md, disabled false, loading false, onClick () {}, }: MyButtonProps) { // ...组件实现不变 } export default MyButton;你在Vite项目里可以用这个命令安装TypeScript相关依赖并把文件后缀改成.tsx。这样写的好处是使用方在编辑器里一敲MyButtonIDE就会弹出所有可用props的提示传错类型比如把size传成sizexl直接编译不过问题在开发阶段就被拦死了。我个人的体会是TS对组件封装的意义比对业务代码更大。因为组件是给别人用的它的接口契约必须清晰JS里接口契约只存在于文档里而TS让契约存在于代码里跑都跑不掉。5.2 拆出逻辑与展示当按钮里出现业务逻辑时怎么办还有一类很常见的困扰封装组件的过程中写着写着发现组件内部不知不觉混进了业务逻辑比如按钮点击后要跳转、要调接口、要更新store。这时候就该停下来想一想了。我的经验是分两层处理纯展示组件只管长什么样、触发什么事件不关心业务。比如MyButton应该只负责渲染和触发回调。业务组件可以跟业务状态交互但它通常是由展示组件组装出来的。例如OrderSubmitButton内部可能调接口但它内部同样会用到MyButton。保持这个分层习惯后你的MyButton才能在不同项目里复用万一要跳过它改用组件库按钮也只是换一个底层组件的事业务逻辑完全不受影响。5.3 无障碍与细节照顾到键盘用户和屏幕阅读器写按钮组件时还有一类细节常常被忽略但它在大型企业和政务项目里几乎是硬性要求——无障碍访问。代码层面要做的事情不多但影响很大如果MyButton是业务上用作按钮的div一定要加rolebutton和tabIndex{0}并支持键盘的Enter和Space触发事件。为了避免给大家埋坑我强烈建议直接用原生button标签它天然支持键盘操作和焦点管理。aria-disabled属性可以告诉屏幕阅读器这个按钮当前不可用。loading状态下给菊花加一个提示文本比如span classNamesr-only加载中/spansr-only是一个只对屏幕阅读器可见的CSS类。.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }这些细节不会让按钮看起来更漂亮但会让使用你组件的人感觉更可靠。一个人做封装和一群人都做封装的区别很多时候就体现在这些看不见的小地方。6. 测试与收尾封装完不是结束而是维护的开始6.1 写第一个单元测试验证核心行为组件封装完了紧接着的问题就是怎么保证以后改样式、加功能的时候不把原来好的地方改坏答案不是小心一点而是测试。以按钮组件为例最值得测的其实是几个核心行为禁用状态下点击onClick不会被调用loading状态下显示spinner且点击不会触发回调不同type渲染出不同className。用Vitest React Testing Library写第一个测试// src/components/MyButton/MyButton.test.tsx import { describe, it, expect, vi } from vitest; import { render, screen, fireEvent } from testing-library/react; import MyButton from ./MyButton; describe(MyButton, () { it(renders children correctly, () { render(MyButton确定/MyButton); expect(screen.getByText(确定)).toBeInTheDocument(); }); it(does not trigger onClick when disabled, () { const handleClick vi.fn(); render(MyButton disabled onClick{handleClick}确定/MyButton); fireEvent.click(screen.getByRole(button)); expect(handleClick).not.toHaveBeenCalled(); }); it(does not trigger onClick when loading, () { const handleClick vi.fn(); render(MyButton loading onClick{handleClick}提交/MyButton); fireEvent.click(screen.getByRole(button)); expect(handleClick).not.toHaveBeenCalled(); }); });这几个测试看着简单但它们守护的是整个项目所有使用MyButton页面的基础契约。以后无论是谁往这个组件里加新功能跑一遍测试就知道有没有破坏原有行为。6.2 手动验证清单在不同场景里过一遍除了自动化测试我每次封装完组件还会列一个手动验证清单。按钮组件至少要在下面这些场景里过一遍在列表页使用确认点击行能正确传参在弹窗里使用确认弹出/关闭正常焦点没有失控在表单里使用确认能正确触发表单提交且loading时会锁住按钮在移动端宽度下缩放确认padding和字号不会溢出如果支持图标icon确认图标和文字对齐正常。这个清单不需要每次都写进测试但它覆盖了真实用户会遇到的场景。我在测试之外保留手动验证是因为某些交互比如焦点管理、布局挤压很难用断言描述清楚但肉眼看一眼就明白了。6.3 组件维护少改接口多铺测试最后说点维护层面的经验也算是对封装这件事的一个完整收尾。封装出来的组件一旦被多个页面使用就一定会遇到改接口还是改实现的抉择。我的建议是接口props尽量保持稳定改动倾向于在内部实现上做文章。因为改props意味着所有调用方都要跟着改成本是乘法级的。如果你觉得某个props设计得不好宁可新增一个props并保留旧props的兼容逻辑也别直接删除旧接口。同时随着组件被更多项目使用你会在它身上发现越来越多可以标准化的点。比如后来我在MyButton上加了htmlType属性用来透传原生button的type字段submit、reset等因为表单里确实需要。这就是封装最真实的状态——它不是一次性的活而是跟随项目一起迭代成长的。我个人在这件事上的体会是封装组件最大的收获不是代码复用而是逼着你用接口思维去看待一段UI。你会在写代码之前先想清楚别人怎么用会用最合理的默认值降低使用成本会主动为边界情况兜底。这种思维方式一旦养成再回头看那些混乱的业务代码你会自然地产生拆解的冲动。到那时候React组件封装对你而言就不再是个概念而是工具箱里一件顺手称心的工具。
延伸阅读

更多相关文章

2026/10/13 15:12:14

SpringBoot外卖系统源码拆解:从订单状态机到并发扣库存

简介:这套基于Spring Boot构建的外卖系统源码,面向Java Web初学者、毕业设计及课程大作业开发人群,覆盖用户注册登录、商家入驻、商品发布、订单管理、支付对接等核心业务,帮助理解真实项目的分层架构与前后端分离开发流程。压缩包…

2026/10/13 16:13:06

上下文工程实战:从设计到优化,解决大模型答非所问

1. 从一个真实困境说起:为什么模型总是“答非所问”我最早接触大语言模型应用开发的时候,踩过一个特别典型的坑。当时我在做一个智能客服原型的问答模块,用户问“我上周买的那个订单什么时候到”,模型给出的回答是“请提供您的订单…

2026/10/13 16:13:06

名企AI面试100题:高频考点与实战解析

简介:《名企AI面试100题》是面向求职互联网大厂AI岗位的笔试面试备考资料,源自七月在线近4000道题库的精选题集,适合计算机相关专业学生及转行求职者系统复习。本资源为PDF格式,共1个文件,压缩包约49.22MB,…

2026/10/13 16:13:06

TCN时间序列预测实战:因果卷积与膨胀卷积详解

简介:本资源是一份面向初级及以上数据科学学习者与时间序列分析实践者的完整TCN项目教程,聚焦利用Python与TensorFlow实现时间卷积神经网络进行高精度时序预测,特别以外汇市场趋势建模为落地场景,有效解决RNN在长序列中梯度消失、…

2026/10/13 16:13:06

LiteLLM实战:用OpenAI风格接口统一调度多种大模型API

1. 这不是一个“模型”,而是一个“总闸”——用一分钟认识 LiteLLM如果你和我一样,过去一年里被各种大模型 API 折腾到头秃,你应该能秒懂我在说什么:OpenAI 的接口长一个样子,Anthropic 的接口是另一个样子&#xff0c…

2026/10/13 16:08:06

CS+ for CC编译器设置记录

瑞萨编译器在创建新工程后必须设置以下参数用以让工程顺利进行 1,设置选项字节: 不同与其他MCU,瑞萨单片机的时钟与看门狗是在用户选项字节中设置的,选项字节供4个Byte。 其一位置在:CC-RL(Build Tool)->Link Optio…

2026/10/13 5:19:27

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/12 12:01:26

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/12 12:54:07

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/13 0:04:41

基于Triton部署YOLO目标检测:从训练脚本到线上服务

简介:这份资源面向希望将目标检测模型落地到生产环境的深度学习开发者与算法工程师,聚焦于用 Triton Inference Server 部署 YOLOv9 的完整实战方案,解决从训练权重到线上推理服务之间的工程化难题。压缩包共 16 个文件,约 886KB&…

2026/10/13 0:04:41

手把手教你云端部署Qwen-Image-2.1:从GPU选型到在线文生图服务

Qwen-Image-2.1 这个开源的图像生成模型,最近在内容创作和 AI 应用圈子里热度相当高。它属于通义 Qwen 系列里的视觉生成分支,让我眼前一亮的是它对中文长文本的理解能力——以前用那些老方案生成带中文文字的图,十个里九个是“乱码印刷体”&…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑