ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

组件库设计 | React组件库Concis开源探索过程中的一些心路历程

组件库设计 | React组件库Concis开源探索过程中的一些心路历程 1. 从零到开源Concis 组件库的目录分层与样式隔离取舍做 React 组件库这件事我最初的想法特别朴素手上业务不忙想证明一下自己能独立撑起一个开源项目。Concis 的第一行代码写下去之前我几乎把 antd、element-plus、arco-design 的源码目录翻了个遍想搞清楚一套成熟组件库到底是怎么分层的。如果你现在也在搜「React 组件库从零搭建目录结构」或者「组件库样式隔离方案怎么选」那这篇记录应该能帮你少走几个月弯路。先说结论组件库的骨架不是组件本身而是分层。Concis 早期只有一个src目录所有组件、样式、工具函数全堆在一起写到第 20 个组件时彻底失控——改一个 Button 的样式Table 的边框跟着变想按需加载发现入口文件把所有组件都 import 了一遍。这就是没有分层的代价。我最终采用的目录结构是这样的你可以直接照着建concis/ ├── packages/ │ ├── concis/ # 核心组件库 │ │ ├── src/ │ │ │ ├── button/ │ │ │ │ ├── index.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── interface.ts │ │ │ │ └── style/ │ │ │ │ └── index.less │ │ │ ├── table/ │ │ │ └── index.ts # 统一出口 │ │ ├── package.json │ │ └── tsup.config.ts │ ├── cli/ # 脚手架工具 │ └── docs/ # 文档站点 ├── lerna.json └── package.json这里有几个关键取舍我踩过坑才想明白。第一组件内部再分一层style。早期我把样式写在button.tsx里用 CSS-in-JS好处是隔离彻底坏处是 SSR 场景下样式闪烁、打包体积膨胀。后来改成每个组件独立style/index.less配合 Babel 插件做按需引入体积从 480KB 降到 120KB 左右。样式隔离我最终选了CSS Modules 前缀命名空间而不是 Shadow DOM——Shadow DOM 在 React 里事件穿透和主题变量传递太麻烦组件库要的是可控不是绝对隔离。第二packages分包而不是单包。这是参考 arco-design 学到的。单包结构下cli 工具、文档、组件库混在一起发布时要么全发要么全不发。用 lerna 拆成多包后concis/cli可以独立迭代组件库发版不受文档影响。lerna.json 配置很简单{ packages: [packages/*], version: independent, npmClient: npm, command: { publish: { ignoreChanges: [**/*.md, **/test/**] } } }version: independent让每个包独立版本号这点很重要——组件库发 1.2.0 的时候cli 可能还是 0.3.1没必要强行对齐。第三按需加载的取舍。我试过三种方案全量引入、手动按需、自动按需。全量引入最省事但体积最大手动按需import Button from concis/es/button对用户不友好最终选了ES Module sideEffects 标记配合babel-plugin-import自动转换。在package.json里加一行{ sideEffects: [**/*.css, **/*.less] }这样打包工具能安全 tree-shaking用户写import { Button } from concis也能只打进 Button 的代码。实测下来一个只用 Button 和 Input 的项目打包后组件库部分只有 18KB。分层这件事没有标准答案但有一条原则让每个目录的职责单一到能用一句话说清。packages/concis/src/button就是「Button 组件的实现和样式」不多不少。当你发现某个目录需要解释三句话以上就该拆了。2. 用 TaoToken 统一 Key 与 API 通道给组件库接上 AI 文档生成组件写到 30 个的时候最烦的不是写代码是写文档。每个组件的 props 表格、示例代码、API 说明纯手工维护改一个 prop 要同步改三处。我就想能不能用 AI 辅助生成文档草稿人工再润色。但直接调各家模型 API 有个问题Key 分散、通道不统一、切换模型要改代码。这时候我用 TaoToken 做了一层统一通道。它的定位很简单——一个 Key 走通多个模型对组件库这种需要批量生成文档的场景特别合适。官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 注意 API 地址不带 UTM 参数。具体怎么接我是在组件库的scripts/目录下写了一个文档生成脚本不侵入组件源码。先拿 Key进控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在 API Keys 页面创建一个复制出来。然后配置环境变量export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api脚本里用 OpenAI 兼容的 SDK 调用因为 TaoToken 的 API 是兼容 OpenAI 格式的这点省了很多事// scripts/gen-doc.ts import OpenAI from openai; import fs from fs; import path from path; const client new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL, }); async function generateDoc(componentName: string, sourceCode: string) { const prompt 你是 React 组件库文档专家。根据以下组件源码生成 Markdown 格式的 API 文档。 要求 1. 提取所有 props列出名称、类型、默认值、说明 2. 给出 2 个使用示例 3. 语言简洁不要客套话 组件名${componentName} 源码 ${sourceCode}; const res await client.chat.completions.create({ model: claude-sonnet-4-20250514, messages: [{ role: user, content: prompt }], temperature: 0.3, }); return res.choices[0].message.content; } // 批量处理 const componentsDir path.resolve(packages/concis/src); const components fs.readdirSync(componentsDir).filter((f) fs.statSync(path.join(componentsDir, f)).isDirectory() ); for (const name of components) { const tsxPath path.join(componentsDir, name, ${name}.tsx); if (!fs.existsSync(tsxPath)) continue; const code fs.readFileSync(tsxPath, utf-8); const doc await generateDoc(name, code); fs.writeFileSync( path.join(componentsDir, name, README.md), doc ?? ); console.log(生成 ${name} 文档完成); }这里 Model ID 我填的是claude-sonnet-4-20250514你也可以在模型对话页面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 看看当前支持的模型列表换成别的也行。关键是 Base URL、Key、Model ID 这三件套配齐代码里就不用再关心具体走哪个通道。为什么不在组件库运行时接 AI因为文档生成是构建期的事不该进产物。放在scripts/里npm run gen:doc手动触发生成完人工过一遍再提交。这样既享受了 AI 的效率又不会让组件库带上不必要的依赖。有个细节要注意源码里如果有中文注释prompt 里最好说明「保留原有中文说明」否则模型可能翻译成英文。我第一版生成出来全是英文 API 说明又跑了一遍才改回来。3. 可复制的 tsup 打包配置与 Storybook 验证步骤组件库能不能用一半看代码一半看打包。Concis 早期用 webpack 打包配置写了 200 多行构建一次 40 秒。后来换成 tsup配置压到 30 行构建 8 秒。这一节把完整配置和验证流程给你。先装依赖npm i -D tsup typescript types/reactpackages/concis/tsup.config.ts完整内容import { defineConfig } from tsup; export default defineConfig({ entry: [src/index.ts, src/**/index.tsx], format: [esm, cjs], dts: true, splitting: true, sourcemap: true, clean: true, external: [react, react-dom], esbuildOptions(options) { options.jsx automatic; }, outDir: dist, treeshake: true, minify: false, });逐项说下为什么这么配。entry用 glob 匹配每个组件的index.tsx这样每个组件单独产出一个 chunk配合splitting: true实现真正的按需加载。format同时出 ESM 和 CJSESM 给现代打包工具CJS 兜底老项目。dts: true自动生成类型声明省得手写.d.ts。external把 react 排除掉不然会把 React 打进产物用户项目里就有两份 React 了。package.json里的字段要对应上{ name: concis, version: 1.2.0, main: dist/index.js, module: dist/index.mjs, types: dist/index.d.ts, sideEffects: [**/*.css, **/*.less], files: [dist], scripts: { build: tsup, dev: tsup --watch }, peerDependencies: { react: 17.0.0, react-dom: 17.0.0 } }打包完怎么验证我用 Storybook。装npx storybooklatest init --type react然后在packages/concis/src/button/下建button.stories.tsximport type { Meta, StoryObj } from storybook/react; import Button from ./button; const meta: Metatypeof Button { title: Components/Button, component: Button, tags: [autodocs], argTypes: { type: { control: select, options: [primary, default, danger, text], }, size: { control: select, options: [small, middle, large], }, }, }; export default meta; type Story StoryObjtypeof Button; export const Primary: Story { args: { type: primary, children: 主要按钮, }, }; export const Danger: Story { args: { type: danger, children: 危险操作, }, };跑npm run storybook浏览器打开 6006 端口能看到 Button 的交互式文档。tags: [autodocs]会自动根据 TypeScript 类型生成 props 表格这就是为什么前面 tsup 要开dts: true——类型信息是文档的源头。验证按需加载是否生效用rollup-plugin-visualizer或者直接看 dist 目录ls dist/ # index.js index.mjs button/ table/ input/ ...如果每个组件都有独立目录说明 splitting 生效了。再写个测试项目import { Button } from concis;打包后看产物里有没有 Table 的代码没有就对了。Storybook 还有个好处它本身就是组件库的「活文档」。我后来把 Storybook 的静态产物部署到线上用户可以直接在文档里改 props 看效果比纯文字说明直观得多。这一步做完组件库的骨架就算立起来了——目录分层、打包配置、文档验证三件套齐活。4. 验证请求从一次 401 到成功生成组件文档配置写完不代表能跑通。我第一次跑文档生成脚本直接报 401。这一节把验证流程和真实报错完整走一遍你照着做能少踩坑。先做最小验证别一上来就跑批量脚本。写个test-api.tsimport OpenAI from openai; const client new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL, }); async function main() { const res await client.chat.completions.create({ model: claude-sonnet-4-20250514, messages: [ { role: user, content: 用一句话说明 React 组件库按需加载的原理 }, ], }); console.log(res.choices[0].message.content); } main().catch(console.error);跑之前确认环境变量echo $TAOTOKEN_API_KEY echo $TAOTOKEN_BASE_URL如果输出为空说明没 export 成功。注意TAOTOKEN_BASE_URL应该是https://taotoken.net/api不要带末尾斜杠也不要带/v1——SDK 会自己拼/v1/chat/completions。我第一次就是多写了/v1结果请求变成/v1/v1/chat/completions直接 404。401 的典型报错长这样Error: 401 Unauthorized { error: { message: Invalid API key provided, type: invalid_request_error } }排查顺序第一Key 有没有复制完整前后有没有空格第二Key 是不是在控制台被删了或者过期了第三环境变量有没有被 shell 缓存试试source ~/.zshrc或者重开终端。我那次是复制 Key 时多带了一个换行符trim()一下就好了。401 解决后跑通了会看到类似输出按需加载的核心是让打包工具能够静态分析出哪些模块被引用通过 ES Module 的 import/export 语法和 sideEffects 标记实现未引用代码的 tree-shaking。看到这个说明通道通了。接下来跑批量脚本处理 30 个组件大概 2 分钟。过程中可能遇到reading choices报错TypeError: Cannot read properties of undefined (reading choices)这个通常是响应结构不对原因可能是模型名写错了API 返回了错误对象而不是正常响应或者网络中断返回了空。加个防御const res await client.chat.completions.create({...}); if (!res?.choices?.[0]?.message?.content) { console.error(响应异常:, JSON.stringify(res)); return null; }还有一种情况是local proxy failed这个一般是你本地配了什么代理工具导致的。组件库脚本走的是标准 HTTPS不需要任何额外代理把HTTP_PROXY、HTTPS_PROXY环境变量清掉再跑unset HTTP_PROXY HTTPS_PROXY生成完的文档长这样以 Button 为例## Button 按钮 ### 何时使用 标记一个操作用户点击后触发相应行为。 ### API | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | type | primary \| default \| danger \| text | default | 按钮类型 | | size | small \| middle \| large | middle | 按钮尺寸 | | disabled | boolean | false | 是否禁用 | | onClick | (e: MouseEvent) void | - | 点击回调 | ### 示例 ...人工过一遍把模型编造的 prop 删掉补上漏掉的提交。这套流程跑顺之后每加一个新组件文档草稿自动生成我只需要花 5 分钟校对比从零写快太多。验证这一步的核心是先最小化再批量化。一个请求跑通再跑三十个。报错信息别慌401 查 Key404 查 URLreading choices查响应结构local proxy failed查环境变量。这四类覆盖了 90% 的问题。5. 本篇常见错排查401、local proxy failed、reading choices 对照表把上面散落的报错集中整理一下方便你对照。这些都是我在 Concis 文档生成流程里真实遇到的不是编的。报错信息触发场景根因解决401 Unauthorized/Invalid API key首次调用Key 错误、过期、带空格重新复制 Keytrim()确认控制台里 Key 有效404 Not Found请求发出但路径不对Base URL 多带/v1或末尾斜杠改成https://taotoken.net/api不带/v1local proxy failed本地有代理工具环境变量HTTP_PROXY干扰unset HTTP_PROXY HTTPS_PROXY后重跑Cannot read properties of undefined (reading choices)响应解析模型名错误或响应为空打印完整响应核对 Model IDOAuth相关报错用了需要 OAuth 的客户端认证方式不匹配文档脚本用 API Key 方式不走 OAuthModel not found模型名拼写错误Model ID 不在支持列表去模型对话页核对当前可用模型名重点说下local proxy failed。这个报错很迷惑因为你的代码里根本没写代理。原因是某些开发环境会全局设置HTTP_PROXY环境变量Node 的 fetch 会读取它。组件库的文档脚本是纯服务端请求不需要任何代理层直接清掉就行。检查方法env | grep -i proxy有输出就 unset 掉。这个坑我卡了半小时最后发现是之前配别的工具留下的环境变量。再说reading choices。这个报错的本质是res是 undefined 或者结构不对。加一行日志就能定位const res await client.chat.completions.create({...}); console.log(原始响应:, JSON.stringify(res, null, 2));如果打印出来是{error: {...}}说明请求本身失败了只是 SDK 没抛异常。这时候看 error.message 就知道具体原因。如果是null说明网络层出了问题检查 Base URL 能不能 ping 通curl -I https://taotoken.net/api返回 200 或 401 都说明网络通返回超时就检查网络环境。关于 OAuth如果你用的是 Claude Code 这类客户端它可能默认走 OAuth 流程。但我们的文档脚本是标准 API 调用用 API Key 就够了。Claude Code 接入的话配置里要写全三件套——Base URL、Key、Model ID{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-你的key, ANTHROPIC_MODEL: claude-sonnet-4-20250514 } }注意 Claude Code 用的是ANTHROPIC_前缀不是OPENAI_别搞混。Cline 的 MCP 配置类似在 settings 里填 Base URL 和 Key。Codex 的话看auth.json把 base_url 和 api_key 填对。排查这件事有个通用心法报错先看 HTTP 状态码再看响应体最后看代码。401 是认证404 是路径500 是服务端reading xxx是解析。按这个顺序大部分问题五分钟内能定位。6. 组件库骨架跑通之后把 AI 通道沉淀成长期能力走到这一步你应该已经有一套能跑的组件库骨架了lerna 分包、tsup 打包、Storybook 验证、AI 辅助文档生成。但我想说的是最后这块 AI 通道的价值不止于生成文档。组件库维护到后期最耗精力的是三件事文档同步、类型补全、issue 分类。文档同步刚才解决了。类型补全可以用同样的通道让模型根据组件源码生成.d.ts草稿人工校对。issue 分类可以写个脚本把 GitHub issue 拉下来让模型打标签。这些都属于「构建期/维护期」的 AI 辅助不影响组件库运行时。如果你打算长期维护这个组件库建议把 AI 通道做成一个独立的packages/ai-tools包里面封装好客户端和常用 prompt 模板。这样以后加新功能不用每次重写调用代码。核心就是那三件套——Base URL、Key、Model ID封装一次到处复用。对于需要长期跑批量任务的场景比如一次性给 100 个组件生成文档、或者持续做代码审查可以考虑 Coding Plan 这类长期方案比按次调用更划算。入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。如果你的用量不大按次调用就够了不用急着上套餐。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有各语言的示例代码Python、Node、curl 都有。API Keys 管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 可以创建多个 Key 分配给不同脚本方便追踪用量。回到组件库本身。Concis 从第一行代码到现在最大的收获不是写了多少组件而是想清楚了一件事开源项目的骨架比功能重要。目录分层决定了项目能长多大打包配置决定了用户愿不愿意用文档质量决定了别人能不能上手。这三样做扎实功能可以慢慢加这三样做砸了功能再多也是空中楼阁。如果你也在做自己的组件库建议先把骨架搭好再写第一个组件。骨架对了后面每一步都顺骨架错了写到第 20 个组件就得推倒重来。我踩过的坑希望你能绕过去。
返回列表