☰
组件库设计 | React组件库Concis开源探索过程中的一些心路历程
2026/10/7 19:26:41 网站建设 项目流程

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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数。

具体怎么接?我是在组件库的scripts/目录下写了一个文档生成脚本,不侵入组件源码。先拿 Key:进控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在 API Keys 页面创建一个,复制出来。然后配置环境变量:

export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://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_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 看看当前支持的模型列表,换成别的也行。关键是 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/react

packages/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 和 CJS,ESM 给现代打包工具,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 storybook@latest init --type react

然后在packages/concis/src/button/下建button.stories.tsx:

import type { Meta, StoryObj } from '@storybook/react'; import Button from './button'; const meta: Meta<typeof 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 = StoryObj<typeof 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.ts:

import 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 查 Key,404 查 URL,reading choices查响应结构,local proxy failed查环境变量。这四类覆盖了 90% 的问题。

5. 本篇常见错排查:401、local proxy failed、reading choices 对照表

把上面散落的报错集中整理一下,方便你对照。这些都是我在 Concis 文档生成流程里真实遇到的,不是编的。

报错信息触发场景根因解决
401 Unauthorized/Invalid API key首次调用Key 错误、过期、带空格重新复制 Key,trim(),确认控制台里 Key 有效
404 Not Found请求发出但路径不对Base URL 多带/v1或末尾斜杠改成https://taotoken.net/api,不带/v1
local proxy failed本地有代理工具环境变量HTTP_PROXY干扰unset HTTP_PROXY HTTPS_PROXY后重跑
Cannot read properties of undefined (reading 'choices')响应解析模型名错误或响应为空打印完整响应,核对 Model ID
OAuth相关报错用了需要 OAuth 的客户端认证方式不匹配文档脚本用 API Key 方式,不走 OAuth
Model 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_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。如果你的用量不大,按次调用就够了,不用急着上套餐。

接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有各语言的示例代码,Python、Node、curl 都有。API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,可以创建多个 Key 分配给不同脚本,方便追踪用量。

回到组件库本身。Concis 从第一行代码到现在,最大的收获不是写了多少组件,而是想清楚了一件事:开源项目的骨架比功能重要。目录分层决定了项目能长多大,打包配置决定了用户愿不愿意用,文档质量决定了别人能不能上手。这三样做扎实,功能可以慢慢加;这三样做砸了,功能再多也是空中楼阁。

如果你也在做自己的组件库,建议先把骨架搭好,再写第一个组件。骨架对了,后面每一步都顺;骨架错了,写到第 20 个组件就得推倒重来。我踩过的坑,希望你能绕过去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询