1. 项目概述从零构建一个可复用的前端组件库最近在整理自己的开源项目时发现一个挺普遍的现象很多前端开发者包括几年前的我自己在启动一个新项目时面对重复的UI组件需求往往选择“复制粘贴大法”。从旧项目里把按钮、弹窗、表单的代码拷过来修修补补凑合着用。短期看确实快但项目一复杂或者需要维护多个产品线时噩梦就开始了——样式不统一、交互行为有差异、修复一个Bug得改好几个地方。这就是我启动zcbm135/test1这个项目的初衷。它不是一个要发布到 npm 上的、功能完备的企业级组件库而是一个高度定制化、用于学习和沉淀最佳实践的“样板间”项目。你可以把它理解为我为自己搭建的一个前端基础设施实验室核心目标是探索并固化一套从零开始搭建、维护现代前端组件库的完整工作流和设计规范。无论你是想为自己团队打造内部工具还是单纯想深入理解像 Ant Design、Element UI 这些明星项目背后的工程体系这个项目都能提供一个清晰的、可实操的参考路径。2. 整体架构设计与技术选型考量2.1 核心设计哲学原子设计理论与 Monorepo在动手写第一行代码之前明确设计哲学至关重要。我选择了“原子设计”作为组件分层的思想指导。这不是一个具体工具而是一种方法论将界面元素自底向上分为原子基础样式、基础组件、分子简单组合组件、组织体复杂功能模块、模板和页面。在test1中这直接影响了我的目录结构packages/ ├── ui-theme/ # 原子层CSS变量、设计Token、基础混入 ├── ui-core/ # 分子层无业务逻辑的纯UI组件Button, Input └── ui-business/ # 组织体层结合业务的复合组件SearchBarWithHistory为什么要分层为了极致的复用与解耦。ui-core里的按钮不应该知道任何业务逻辑它只关心点击反馈、禁用状态、加载动画。而ui-business里的搜索栏则可以自由组合ui-core的输入框和按钮并注入业务逻辑如调用搜索API、管理历史记录。这样当业务需求变更时我只需改动ui-business而当设计系统升级时我只需更新ui-theme和ui-core影响面可控。为了管理这种多包结构Monorepo是必然选择。我对比了主流方案Lerna 历史悠久但略显笨重Nx 功能强大但学习曲线陡峭Turborepo 性能卓越且配置简单。最终选择了Turborepo因为它与我的技术栈Vite TypeScript集成度极高其基于内容哈希的缓存机制能极大加速本地构建和 CI/CD 流程。在package.json中配置turbo后一条turbo run build命令就能按依赖拓扑顺序并行构建所有子包效率提升非常明显。2.2 技术栈的深度权衡Vite、TypeScript 与 React构建工具Vite vs. WebpackWebpack 依然是工业标准但其复杂的配置和缓慢的冷启动在开发组件库时让人痛苦。Vite 利用原生 ES 模块实现了秒级启动和即时热更新这对需要频繁调试组件的开发体验是质的飞跃。更重要的是Vite 对于库模式lib的支持已经非常成熟可以轻松配置输出多种格式ESM, CJS, UMD。因此我毫不犹豫地选择了 Vite 作为构建基石。语言全面拥抱 TypeScript对于组件库类型系统不是“锦上添花”而是“安全护栏”。它能在编码阶段就捕获属性名拼写错误、传入非法值等常见问题并提供无与伦比的代码提示体验。我配置了严格的tsconfig.json开启strict模式并利用microsoft/api-extractor工具将分散的.d.ts文件打包成一个整洁的类型定义文件方便使用者查阅。UI 框架为什么是 React这更多是基于生态和团队现状的选择。React 庞大的社区意味着遇到任何问题都能快速找到解决方案其 Hook 范式也让逻辑复用变得非常优雅。当然这个架构本身是框架无关的。ui-core包理论上可以适配任何框架Vue、Svelte只需通过不同的“适配器”包来渲染。在当前版本我专注于 React 实现但保留了未来扩展的可能性。3. 开发环境与基础配置实战3.1 初始化 Monorepo 与包管理首先初始化项目并设置工作区。我使用 pnpm 作为包管理器因为它对 Monorepo 的支持最好磁盘空间利用和安装速度都优于 npm 和 yarn。mkdir test1 cd test1 pnpm init编辑根目录的package.json设置private: true并定义工作区{ name: test1, private: true, scripts: { dev: turbo run dev, build: turbo run build, lint: turbo run lint }, workspaces: [packages/*], devDependencies: { turbo: latest } }然后创建子包。以ui-core为例mkdir -p packages/ui-core/src cd packages/ui-core pnpm init在子包的package.json中需要精心设计入口文件和依赖声明。main指向 CommonJS 入口module指向 ES 模块入口types指向类型定义文件exports字段提供更精细的条件导出。3.2 样式方案CSS-in-JS 还是 Sass样式方案是组件库的核心争议点之一。我评估了三种主流方案纯 CSS BEM简单直接无运行时开销但需要手动管理样式隔离和主题。CSS-in-JS (Emotion, styled-components)样式与组件共存动态主题能力强但会增加运行时体积和性能开销。Sass/SCSS CSS Modules提供变量、混入等强大功能通过模块化实现局部作用域编译后是静态CSS性能好。考虑到组件库的性能和可移植性可能用于非React环境我选择了Sass CSS Modules方案。我在ui-theme包中定义所有设计 Token颜色、间距、字体等为 Sass 变量和 CSS 自定义属性CSS Variables实现主题切换的核心。// packages/ui-theme/src/tokens/_colors.scss $color-primary: #1677ff !default; $color-error: #ff4d4f !default; :root { --color-primary: #{$color-primary}; --color-error: #{$color-error}; }在组件中通过import引用这些变量并利用 CSS Modules 确保样式局部化。注意使用 CSS Modules 时需要在 Vite 中正确配置css.modules确保生成的类名在开发和生产环境下保持一致避免样式错乱。我通常将generateScopedName设置为[name]__[local]__[hash:base64:5]这样的格式便于调试。3.3 质量保障体系的搭建Lint、Test 与 Storybook代码规范ESLint Prettier Husky统一代码风格是团队协作的基础。我配置了 ESLint 扩展 Airbnb 规则并集成 Prettier 进行自动格式化。通过lint-staged和Husky在pre-commit钩子中自动对暂存区的文件进行检查和格式化将问题扼杀在提交之前。// .husky/pre-commit #!/usr/bin/env sh . $(dirname -- $0)/_/husky.sh npx lint-staged组件测试Vitest React Testing Library测试策略遵循“测试用户交互而非实现细节”的原则。我选择了 Vitest 作为测试框架与 Vite 生态完美契合配合 React Testing Library。测试重点在于组件的渲染、用户事件点击、输入和属性传递。// Button.test.tsx import { render, screen, fireEvent } from testing-library/react; import { Button } from ./Button; describe(Button, () { it(renders with correct text, () { render(ButtonClick Me/Button); expect(screen.getByText(Click Me)).toBeInTheDocument(); }); it(calls onClick handler when clicked, () { const handleClick vi.fn(); render(Button onClick{handleClick}Click/Button); fireEvent.click(screen.getByText(Click)); expect(handleClick).toHaveBeenCalledTimes(1); }); });组件开发与文档StorybookStorybook 是开发和展示组件的绝佳工具。我为每个组件创建.stories.tsx文件定义不同的使用场景Stories。这既是交互式文档也是视觉测试的沙盒。我配置了storybook/addon-essentials来获得控件Controls、操作Actions等面板并集成了storybook/addon-a11y来检查可访问性。4. 核心组件开发与设计模式4.1 基础组件Button的完整实现剖析以最基础的Button组件为例展示如何从设计到实现。1. 定义组件接口Props首先用 TypeScript 定义清晰、可扩展的 Props 接口。这相当于组件的“使用说明书”。// packages/ui-core/src/Button/types.ts export type ButtonType default | primary | dashed | link | text; export type ButtonSize large | middle | small; export type ButtonHTMLType submit | button | reset; export interface BaseButtonProps { /** 按钮类型 */ type?: ButtonType; /** 按钮尺寸 */ size?: ButtonSize; /** 是否禁用 */ disabled?: boolean; /** 是否加载中 */ loading?: boolean; /** 点击回调 */ onClick?: React.MouseEventHandlerHTMLElement; /** 原生 button 的 type 属性 */ htmlType?: ButtonHTMLType; /** 子元素 */ children?: React.ReactNode; } // 合并原生 button 元素的所有属性提供最大灵活性 export type ButtonProps BaseButtonProps OmitReact.ButtonHTMLAttributesHTMLButtonElement, type | onClick;2. 实现组件逻辑与样式组件主体需要处理属性合并、类名生成和事件绑定。// packages/ui-core/src/Button/Button.tsx import React, { useState } from react; import classNames from classnames; import { ButtonProps } from ./types; import styles from ./Button.module.scss; // CSS Modules 样式 const Button: React.FCButtonProps (props) { const { type default, size middle, disabled false, loading false, onClick, htmlType button, children, className, ...restProps } props; const [innerLoading, setInnerLoading] useState(false); const handleClick async (e: React.MouseEventHTMLButtonElement) { if (disabled || loading || innerLoading) { return; } if (onClick) { // 模拟异步操作时的 loading 状态可选 const result onClick(e); if (result typeof result.then function) { setInnerLoading(true); try { await result; } finally { setInnerLoading(false); } } } }; const classes classNames( styles.button, styles[button-${type}], styles[button-${size}], { [styles[button-loading]]: loading || innerLoading, [styles[button-disabled]]: disabled, }, className ); return ( button type{htmlType} className{classes} disabled{disabled || loading || innerLoading} onClick{handleClick} {...restProps} {(loading || innerLoading) span className{styles.loadingIcon} /} {children} /button ); }; export default Button;3. 编写配套样式Sass with CSS Modules样式文件使用 Sass并充分利用ui-theme包中的设计 Token。// packages/ui-core/src/Button/Button.module.scss import test1/ui-theme/tokens; // 导入设计变量 .button { position: relative; display: inline-flex; align-items: center; justify-content: center; font-weight: 400; white-space: nowrap; text-align: center; background-image: none; border: 1px solid transparent; cursor: pointer; transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); user-select: none; touch-action: manipulation; line-height: 1.5715; border-radius: var(--border-radius-base, 6px); padding: var(--button-padding-vertical, 4px) var(--button-padding-horizontal, 15px); font-size: var(--font-size-base, 14px); // 处理禁用状态 -disabled, [disabled] { cursor: not-allowed; opacity: 0.65; * { pointer-events: none; } } // 类型变体 -default { background-color: var(--color-bg-container, #fff); border-color: var(--color-border, #d9d9d9); color: var(--color-text, rgba(0, 0, 0, 0.88)); :hover { color: var(--color-primary, #1677ff); border-color: var(--color-primary, #1677ff); } } -primary { background-color: var(--color-primary, #1677ff); border-color: var(--color-primary, #1677ff); color: var(--color-text-light-solid, #fff); :hover { background-color: var(--color-primary-hover, #4096ff); border-color: var(--color-primary-hover, #4096ff); } } // 尺寸变体 -small { padding: 0px 7px; font-size: var(--font-size-sm, 12px); border-radius: var(--border-radius-sm, 4px); } -large { padding: 6.4px 15px; font-size: var(--font-size-lg, 16px); border-radius: var(--border-radius-lg, 8px); } // Loading 状态 .loadingIcon { // 加载动画的实现 margin-right: 8px; animation: spin 1s linear infinite; } } keyframes spin { 100% { transform: rotate(360deg); } }实操心得在定义 Props 时务必使用Omit或Intersection Types处理好原生 HTML 属性与自定义属性的关系。像上面的例子我们扩展了原生的type和onClick所以需要用Omit将其从原生属性中排除再与我们的自定义接口合并避免属性冲突。4.2 复杂组件Modal的设计模式Context、Portal 与 Compound Components对于弹窗Modal、下拉菜单Dropdown这类需要脱离当前 DOM 层级、管理复杂状态的组件需要更高级的模式。1. 使用 React.createPortal 渲染到 body 末尾这是为了避免弹窗被父容器的overflow: hidden或z-index样式裁剪。在组件内部使用ReactDOM.createPortal将内容渲染到document.body下的一个特定容器中。// packages/ui-core/src/Modal/Modal.tsx 片段 import ReactDOM from react-dom; const Modal: React.FCModalProps ({ visible, children, getContainer }) { const container getContainer ? getContainer() : document.body; if (!visible) { return null; } return ReactDOM.createPortal( div className{styles.modalOverlay} div className{styles.modalContent}{children}/div /div, container ); };2. 使用 Context 管理全局状态如确认框对于confirm、message这类命令式调用的组件全局需要一个管理器。我使用 React Context 和 useReducer Hook 来创建一个全局的 Modal 管理器。// packages/ui-core/src/Modal/ModalContext.tsx import React, { createContext, useReducer, useContext } from react; interface ModalState { id: string; content: React.ReactNode; config: any; } type ModalAction { type: OPEN; payload: ModalState } | { type: CLOSE; payload: string }; const ModalContext createContext{ state: ModalState[]; dispatch: React.DispatchModalAction; } | null(null); const modalReducer (state: ModalState[], action: ModalAction): ModalState[] { switch (action.type) { case OPEN: return [...state, action.payload]; case CLOSE: return state.filter((modal) modal.id ! action.payload); default: return state; } }; export const ModalProvider: React.FC{ children: React.ReactNode } ({ children }) { const [state, dispatch] useReducer(modalReducer, []); return ModalContext.Provider value{{ state, dispatch }}{children}/ModalContext.Provider; }; // 自定义 Hook 用于打开/关闭弹窗 export const useModal () { const context useContext(ModalContext); if (!context) { throw new Error(useModal must be used within a ModalProvider); } const openModal (content: React.ReactNode, config?: any) { const id modal_${Date.now()}; context.dispatch({ type: OPEN, payload: { id, content, config } }); return id; // 返回 id 用于后续关闭 }; const closeModal (id: string) { context.dispatch({ type: CLOSE, payload: id }); }; return { openModal, closeModal, modals: context.state }; };3. 复合组件Compound Components模式提升 API 灵活性为了让组件的结构更清晰、更易定制我采用了复合组件模式。以 Modal 为例不通过一堆 Props 传递标题、内容、底部按钮而是将它们作为子组件暴露。// 使用方式 Modal visible{visible} onCancel{handleCancel} Modal.Header title自定义标题 / Modal.Content p这里是弹窗内容.../p /Modal.Content Modal.Footer Button onClick{handleCancel}取消/Button Button typeprimary onClick{handleOk}确定/Button /Modal.Footer /Modal实现上将Modal作为主组件并通过静态属性挂载子组件。// packages/ui-core/src/Modal/index.ts import Modal from ./Modal; import ModalHeader from ./ModalHeader; import ModalContent from ./ModalContent; import ModalFooter from ./ModalFooter; Modal.Header ModalHeader; Modal.Content ModalContent; Modal.Footer ModalFooter; export default Modal;这种模式将控制权交给了使用者结构一目了然也便于通过 CSS 选择器对各个部分进行样式定制。5. 构建、打包与发布流程5.1 配置 Vite 库模式构建组件库的打包输出至关重要。在packages/ui-core/vite.config.ts中需要针对库模式进行专门配置。import { defineConfig } from vite; import react from vitejs/plugin-react; import { resolve } from path; import dts from vite-plugin-dts; export default defineConfig({ plugins: [ react(), dts({ // 生成类型声明文件 insertTypesEntry: true, outDir: dist/types, }), ], build: { lib: { entry: resolve(__dirname, src/index.ts), // 库的入口文件 name: Test1UI, // 全局变量名UMD格式时使用 fileName: (format) index.${format}.js, }, rollupOptions: { // 确保外部化处理那些你不想打包进库的依赖 external: [react, react-dom, react/jsx-runtime], output: { globals: { react: React, react-dom: ReactDOM, react/jsx-runtime: jsxRuntime, }, }, }, outDir: dist, sourcemap: true, // 生成 sourcemap 便于调试 }, });入口文件src/index.ts需要集中导出所有公共组件和工具。// packages/ui-core/src/index.ts export { default as Button } from ./Button; export { default as Modal } from ./Modal; export { useModal } from ./Modal/ModalContext; // ... 导出其他组件5.2 版本管理与变更日志Conventional Commits Changesets在 Monorepo 中管理多个包的版本是个挑战。我使用Changesets工具来半自动化这个过程。它基于约定式提交Conventional Commits来识别变更并引导你生成CHANGELOG.md和正确的版本号。安装并初始化 Changesets。开发完成后运行pnpm changeset它会询问哪些包发生了变更ui-core,ui-theme是主版本Major、次版本Minor还是修订版本Patch更新并让你编写变更描述。Changesets 会在.changeset目录生成一个 Markdown 文件记录这次变更。当准备发布时运行pnpm changeset version它会根据所有累积的变更文件更新对应包的package.json版本号并生成更新日志。最后运行pnpm publish -r来发布所有版本已更新的包到 npm 仓库。5.3 自动化 CI/CD 流水线GitHub Actions我将构建、测试和发布流程自动化。在.github/workflows/release.yml中配置 GitHub Actionsname: Release on: push: branches: [main] jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 with: fetch-depth: 0 - uses: pnpm/action-setupv2 - uses: actions/setup-nodev3 with: node-version: 18 cache: pnpm - run: pnpm install - run: pnpm run lint - run: pnpm run test - name: Create Release Pull Request or Publish id: changesets uses: changesets/actionv1 with: publish: pnpm run release # 这个脚本会执行 changeset publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # 需要在仓库设置中添加 npm token这个工作流会在代码推送到 main 分支后自动安装依赖、运行代码检查和测试。然后 Changesets Action 会检查是否有待发布的变更集。如果有它会创建一个包含版本更新和变更日志的 Pull Request。维护者合并这个 PR 后Action 会自动将新版本发布到 npm。6. 开发、调试与文档最佳实践6.1 高效的本地开发与调试循环在 Monorepo 中我经常需要同时开发ui-core和引用它的演示应用或另一个业务包。pnpm的--filter参数和 Turborepo 的管道pipeline是高效的关键。并行启动所有包在根目录运行pnpm run devTurborepo 会根据依赖关系并行启动所有包的开发服务器。单独构建某个包pnpm run build --filterui-core只构建ui-core包。在演示应用中链接本地包在packages/demo-app的package.json中直接通过工作区协议引用ui-coretest1/ui-core: workspace:*。这样在ui-core中的任何修改在demo-app中通过热更新能立刻看到效果无需手动npm link。6.2 使用 Storybook 进行视觉测试与文档编写Storybook 不仅是文档工具更是强大的视觉测试和组件开发沙盒。我为每个组件的每个状态如按钮的默认、主要、加载、禁用都写一个 Story。// Button.stories.tsx import type { Meta, StoryObj } from storybook/react; import { Button } from ./Button; const meta: Metatypeof Button { title: General/Button, component: Button, argTypes: { type: { control: select, options: [default, primary, dashed, link, text] }, size: { control: select, options: [large, middle, small] }, onClick: { action: clicked }, }, }; export default meta; type Story StoryObjtypeof Button; export const Default: Story { args: { children: Default Button, }, }; export const Primary: Story { args: { type: primary, children: Primary Button, }, }; export const Loading: Story { args: { type: primary, loading: true, children: Loading Button, }, };通过argTypes我可以在 Storybook 的 Controls 面板上动态调整组件属性实时查看效果这极大地提升了开发效率。我还配置了storybook/addon-interactions可以在 Storybook 内直接编写和运行交互测试。6.3 编写可用的文档从 JSDoc 到静态站点良好的文档是组件库成功的一半。我采用三层文档策略源码文档JSDoc在组件 Props 接口和复杂函数上使用 JSDoc 注释。这些注释会被 TypeScript 和 IDE 识别提供智能提示也能通过工具提取生成 API 文档。interface ButtonProps { /** * 设置按钮类型 * default default */ type?: ButtonType; /** * 设置按钮的图标组件 */ icon?: React.ReactNode; }Storybook作为交互式文档和开发沙盒展示组件的各种用例和状态。静态文档站点使用Docusaurus或VitePress构建一个独立的、可搜索的文档网站。这个网站可以集成 Storybook 的 iframe也可以直接展示从源码中通过typedoc或react-docgen-typescript提取的 API 表格并编写详细的使用指南、设计原则和更新日志。7. 常见问题、排查技巧与性能优化7.1 样式污染与 CSS 作用域管理问题在大型应用中不同组件库或模块的 CSS 类名可能冲突导致样式污染。解决方案坚持使用 CSS Modules这是最根本的解决方案。Vite 默认支持确保每个组件的样式文件都是.module.scss后缀。定义清晰的 CSS 命名空间即使在 CSS Modules 内也建议为顶级类名使用有意义的、带前缀的名字例如.test1-button作为额外的保险。谨慎使用:global在 CSS Modules 中只有明确用:global包裹的样式才会全局生效。除非是重置样式或定义 CSS 变量否则尽量避免使用。7.2 树摇Tree Shaking失效问题使用者只引入了组件库中的一个按钮但打包时整个库都被打进去了。排查与解决确保导出方式正确在库的入口文件src/index.ts中避免导出整个对象或使用export * from ...。应该按需导出每个组件。// 推荐按需导出 export { Button } from ./Button; export { Modal } from ./Modal; // 避免导出整个模块对象可能影响 tree shaking import * as ButtonModule from ./Button; export { ButtonModule };设置package.json的sideEffects字段如果你的库包含有副作用的文件如全局样式、polyfill在此字段中声明否则标记为false。{ sideEffects: [**/*.css, **/*.scss] }检查最终打包产物使用rollup-plugin-visualizer或webpack-bundle-analyzer生成依赖分析图直观查看哪些模块被打包。7.3 组件性能优化避免内联函数导致的不必要重渲染在函数组件中将事件处理函数用useCallback包裹将复杂对象或数组用useMemo包裹。const MyComponent ({ list }) { const handleClick useCallback(() { // ... }, []); // 依赖项数组 const processedList useMemo(() list.map(item transform(item)), [list]); return ChildComponent onClick{handleClick} data{processedList} /; };虚拟滚动长列表对于可能渲染大量数据的列表或表格组件集成react-window或react-virtualized只渲染可视区域内的元素。惰性加载非关键组件使用React.lazy和Suspense动态加载某些复杂或非立即需要的组件如富文本编辑器、复杂图表。const HeavyChart React.lazy(() import(./HeavyChart)); function MyPage() { return ( Suspense fallback{divLoading chart.../div} HeavyChart / /Suspense ); }7.4 类型定义文件.d.ts生成问题问题使用者安装库后在 TypeScript 项目中找不到类型提示或者提示错误。解决使用vite-plugin-dts等插件在构建时自动生成.d.ts文件。在package.json中正确设置types字段指向生成的声明文件入口如types: ./dist/types/index.d.ts。确保没有将node_modules或dist目录提交到源码仓库避免路径混乱。类型文件应作为构建产物的一部分发布到 npm。7.5 版本发布后依赖冲突问题发布新版本后下游项目安装时出现react版本冲突如Invalid hook call。解决将 React 设置为 peerDependencies在组件库的package.json中将react和react-dom声明为peerDependencies并指定一个较宽泛的版本范围如16.8.0。这告诉使用者“我的库需要 React但不会自带一份请你自己安装。”{ peerDependencies: { react: 16.8.0, react-dom: 16.8.0 } }在文档中明确说明在安装指南中提醒使用者确保其项目中的 React 版本符合要求。构建一个组件库就像搭建一座城市的基础设施前期规划越周密后期的扩展和维护就越轻松。zcbm135/test1这个项目对我来说最大的价值不在于产出了多少个精美的组件而在于跑通并验证了这一整套工程实践从 Monorepo 管理、开发工具链、组件设计模式、到自动化构建发布。过程中每一个踩过的坑比如 CSS Modules 的类名哈希策略、Tree Shaking 的配置细节、Changesets 的发布流程都变成了宝贵的经验。如果你也在考虑构建自己的组件库我的建议是不要一开始就追求大而全先从一个最基础的按钮开始把这条“生产线”搭建起来然后像搭积木一样一个组件一个组件地添加和完善过程中不断重构和优化你的基础设施。这套体系本身其价值远超过任何一个单独的组件。