1. 为什么我决定把"写代码"这件事交出去
去年有段时间我同时在维护三个内部工具的后台面板,每个面板的布局逻辑都差不多——顶部一排指标卡、中间几个图表、底部一张明细表。但每次产品提新需求,比如"把转化率卡片挪到第二行""加一个按渠道筛选的下拉框",我就得重新翻一遍那几个组件的props定义,改完还要担心有没有影响到别的页面。这种重复劳动做多了,人会产生一种很强烈的念头:能不能让AI去写这些"体力活"代码,我只负责告诉它"这个面板长什么样、数据从哪来、点击之后干什么"。
这个念头就是这套轻量级Dashboard的起点。它的核心思路说起来很简单:把"逻辑"和"代码"彻底分开。逻辑指的是面板的结构描述、数据源配置、交互行为定义,这些由人来定;代码指的是具体的组件渲染、状态管理、事件绑定,这些交给AI生成。人负责"要什么",AI负责"怎么写"。
我给它起了个名字叫可插拔Dashboard,因为整个系统是围绕"插件"这个概念搭起来的。一个插件就是一个独立的功能单元,比如"指标卡插件""折线图插件""筛选器插件",每个插件有自己的配置schema和渲染逻辑。你想加一个新功能,不用改主框架,写个新插件注册进去就行。而插件的代码,大部分可以让AI根据你给的配置描述直接生成。
这套东西适合谁呢?我觉得有三类人用起来会比较顺手。第一类是像我这样需要频繁搭建内部工具的前端或全栈开发者,能省下大量重复的布局和联调时间。第二类是对前端不算特别精通、但需要快速出可视化面板的数据或运营同学,只要会写配置就能搭出能用的面板。第三类是想研究"AI辅助开发"落地方式的同学,这套架构本身就是一个很好的实验场,你能清楚看到哪些环节适合交给AI、哪些必须人来把控。
接下来我会把这套系统的设计思路、插件机制、AI生成代码的边界、以及实际踩过的坑,一层层拆开讲。不是那种"三步搞定"的爽文,而是把我真实做下来觉得值得分享的东西都摆出来。
2. 可插拔架构的骨架:插件协议怎么定才不别扭
2.1 从"一个面板"到"一堆插件"的思维转变
最开始我是按传统方式写的:一个Dashboard组件,里面硬编码了指标卡区域、图表区域、表格区域。写着写着就发现,每次加新区域都要动这个组件,组件越来越臃肿,最后变成一个两千多行的怪物。后来我换了个思路——Dashboard本身不应该知道任何具体业务,它只负责一件事:根据一份配置,把对应的插件渲染到对应的位置。
这个转变的关键在于定义清楚"插件协议"。所谓协议,就是每个插件必须遵守的一套约定:你叫什么名字、你需要哪些配置参数、你怎么把自己渲染出来、你对外暴露哪些能力。只要遵守这套约定,任何插件都能被Dashboard识别和加载。
我最终定下来的插件协议大概长这样(用TypeScript描述,但思路是通用的):
interface DashboardPlugin { // 插件唯一标识,比如 'metric-card'、'line-chart' type: string; // 插件显示名称,用于配置面板里展示 displayName: string; // 配置schema,描述这个插件需要哪些参数 configSchema: PluginConfigSchema; // 渲染函数,接收配置和数据,返回组件 render: (props: PluginRenderProps) => React.ReactNode; // 可选:数据获取逻辑 fetchData?: (config: any, context: PluginContext) => Promise<any>; }这里有几个设计决策值得展开说。为什么用type字符串而不是直接传组件引用?因为配置是要被序列化存储的(存数据库或localStorage),组件引用没法序列化。用字符串做标识,加载时再通过注册表映射到具体组件,这样配置就能自由地存和读。
为什么把configSchema单独抽出来?这是为了让AI能"看懂"插件需要什么。后面讲AI生成代码时你会看到,schema是AI理解插件能力的关键输入。schema我用的是一种简化的JSON Schema变体,描述每个字段的类型、默认值、是否必填、可选项等。
fetchData为什么是可选的?因为有些插件是纯展示的,数据由父级传入;有些插件需要自己拉数据。把数据获取也纳入插件协议,是为了让插件真正自包含——一个插件从"要数据"到"渲染出来"的完整链路都在自己内部,主框架不用操心。
2.2 插件注册表:让框架认识所有插件
有了协议,还需要一个地方把所有插件登记在册,这就是插件注册表。它的实现很朴素,就是一个Map:
class PluginRegistry { private plugins = new Map<string, DashboardPlugin>(); register(plugin: DashboardPlugin) { if (this.plugins.has(plugin.type)) { console.warn(`插件 ${plugin.type} 已存在,将被覆盖`); } this.plugins.set(plugin.type, plugin); } get(type: string): DashboardPlugin | undefined { return this.plugins.get(type); } getAll(): DashboardPlugin[] { return Array.from(this.plugins.values()); } }看起来简单,但这里有个我踩过的坑:插件的注册时机。一开始我在主入口文件里手动import所有插件然后逐个register,结果插件一多,入口文件变成了一堆import语句的堆砌,而且新增插件必须改入口文件,违背了"可插拔"的初衷。
后来我改成用构建工具的自动导入能力(比如Vite的import.meta.glob),扫描指定目录下所有符合命名规范的文件,自动注册。这样新增插件只需要在目录里加一个文件,框架自动就能发现它。这个改动让"加插件"这件事从"改三处代码"变成了"加一个文件",体验完全不一样。
提示:自动扫描虽然方便,但要约定好文件命名和导出格式,否则容易出现"文件被扫到了但导出不符合协议"的静默失败。我的做法是扫描后对每个模块做一次协议校验,不符合的打印明确错误并跳过,而不是让它悄悄坏掉。
2.3 布局引擎:配置驱动的排列逻辑
插件有了,接下来要解决"它们摆在哪"。我没有用现成的拖拽布局库,而是自己写了一个基于栅格(grid)的轻量布局引擎。原因有两个:一是拖拽库通常很重,而我需要的只是"按配置排列";二是自己写的布局逻辑更容易和AI生成的配置对接。
布局配置大概长这样:
{ "layout": [ { "type": "metric-card", "span": 6, "config": { "title": "今日活跃", "dataKey": "activeUsers" } }, { "type": "metric-card", "span": 6, "config": { "title": "转化率", "dataKey": "conversionRate" } }, { "type": "line-chart", "span": 12, "config": { "title": "趋势", "dataKey": "trend" } } ] }span表示这个插件占多少列,总列数我设的是12(和主流栅格系统一致,方便心算)。布局引擎遍历这个数组,按span累加,满12就换行。这个逻辑简单到几乎不会出错,但足够覆盖绝大多数面板场景。
为什么不用flex或者CSS Grid直接写?因为配置是数据,数据需要被程序处理。用span这种数字化的描述,布局引擎可以精确计算每个插件的位置,也方便后续做响应式(比如小屏时把span统一降级)。如果直接写CSS,配置就变成了字符串,AI生成和程序校验都会变难。
3. 让AI写代码:哪些能交出去,哪些必须自己攥着
3.1 AI生成代码的三种模式
在实际用下来,我发现AI生成Dashboard相关代码可以分成三种模式,复杂度递增,可靠性递减。
第一种是生成配置,不是生成代码。这是最稳的。比如你说"我要一个面板,顶部四个指标卡显示用户数、订单数、转化率、客单价,下面一个折线图显示近30天趋势",AI直接输出一段符合我布局schema的JSON。这种模式下AI做的是"自然语言到结构化配置"的翻译,不涉及真正的代码逻辑,出错概率极低,而且生成的配置可以直接被布局引擎消费。
第二种是生成插件骨架。当你要加一个全新的插件类型,比如"漏斗图插件",可以让AI根据插件协议生成一个骨架文件:导出一个符合DashboardPlugin接口的对象,render函数里放一个占位实现,configSchema按你描述填好。这种模式下AI做的是"模板填充",因为协议是固定的,AI只要往里套就行,可靠性也很高。
第三种是生成插件的具体渲染逻辑。比如让AI实现"漏斗图"的render函数,里面涉及SVG绘制、数据映射、动画等。这种模式最灵活但也最容易出问题,因为AI可能用错API、算错坐标、或者写出性能很差的实现。我的经验是:这种代码AI可以写初稿,但必须人来review和调试,不能直接上生产。
3.2 给AI的输入怎么组织才高效
用AI生成代码,输入的质量直接决定输出的质量。我摸索出一套"三段式"的输入结构,效果比随便丢一句话好很多。
第一段是角色和约束。明确告诉AI它在什么上下文里工作,比如"你正在为一个基于React的Dashboard框架编写插件,插件必须符合以下接口定义",然后把接口贴上去。这一步是给AI划定边界,避免它自由发挥写出不符合协议的东西。
第二段是具体需求。用结构化的方式描述你要什么,包括插件的type、displayName、需要哪些配置字段、每个字段的类型和含义、渲染出来大概是什么样。这里越具体越好,比如"折线图的x轴是日期,y轴是数值,支持多条线,每条线有独立的颜色"就比"画个折线图"强得多。
第三段是参考示例。给AI一个已经写好的同类插件作为参考,让它模仿风格和结构。这一步对保持代码一致性特别有用,因为AI会倾向于模仿你给的示例的写法,而不是自己发明一套。
我实际用下来,三段式输入相比一句话输入,生成代码的可用率大概能从三成提到七成以上。剩下的三成问题主要集中在边界情况处理和数据格式假设上,这些恰恰是需要人来兜底的。
3.3 AI生成代码的验收清单
AI写完代码,不能直接信。我给自己定了一份验收清单,每次都要过一遍:
- 接口符合性:导出的对象是否严格符合插件协议?type、render、configSchema这些必填项有没有缺?
- 配置字段一致性:render函数里用到的配置字段,是否都在configSchema里声明了?有没有用到没声明的字段?
- 数据边界:数据为空、数据格式不符合预期、数据量特别大时,代码会不会崩?
- 副作用:有没有在render里做不该做的事,比如直接改props、发起请求、操作DOM?
- 性能:有没有明显的性能问题,比如在render里做重计算、没有做memo、循环里创建大量对象?
这份清单看起来啰嗦,但每一条我都真的遇到过对应的翻车案例。比如有一次AI生成的表格插件,在数据为空时直接访问了data[0].name,页面白屏。这种问题人一眼能看出来,但AI不会主动帮你防。
注意:AI生成的代码里经常出现"假设数据一定存在且格式正确"的写法。这不是AI的错,而是它没有你系统的上下文。所以数据校验和兜底逻辑,必须由人来补,不能指望AI。
4. 数据层设计:插件怎么拿到它要的数据
4.1 数据源抽象:别让插件直接碰接口
插件要渲染,就得有数据。最直接的做法是让每个插件自己调接口,但这会带来一堆问题:接口地址散落在各个插件里、鉴权逻辑重复、缓存没法统一管理、测试时没法mock。所以我加了一层数据源抽象。
数据源的核心是一个DataProvider概念,它负责根据一个"数据标识"返回数据。插件不关心数据从哪来,只关心"我要activeUsers这个数据"。数据标识到实际接口的映射,由数据源配置统一管理:
interface DataSourceConfig { key: string; // 数据标识,如 'activeUsers' type: 'api' | 'static' | 'computed'; // type为api时 url?: string; method?: 'GET' | 'POST'; params?: Record<string, any>; // type为static时 value?: any; // type为computed时 expression?: string; }这样插件里写的就是context.getData('activeUsers'),至于这个数据是来自接口、静态值还是计算出来的,插件完全不用管。这个抽象带来的好处在联调阶段特别明显:后端接口还没好时,我把数据源type改成static,填个假数据,前端就能先跑起来。
4.2 数据获取的时机与缓存
数据什么时候拉?这个问题比想象中复杂。如果每个插件渲染时都自己拉一次,同一个数据被多个插件用到时会重复请求。如果统一在Dashboard初始化时全拉一遍,又可能拉了一堆当前不可见插件的数据,浪费。
我的方案是按需获取加缓存。数据源维护一个缓存Map,key是数据标识。插件请求数据时,先查缓存,有就直接返回,没有才真正去拉,拉回来存缓存。同时提供一个失效机制,可以手动清某个key的缓存,或者配置TTL自动过期。
class DataProvider { private cache = new Map<string, { value: any; timestamp: number }>(); private ttl = 60000; // 默认60秒 async getData(key: string): Promise<any> { const cached = this.cache.get(key); if (cached && Date.now() - cached.timestamp < this.ttl) { return cached.value; } const config = this.configs.get(key); const value = await this.fetch(config); this.cache.set(key, { value, timestamp: Date.now() }); return value; } }这里有个细节:并发请求的合并。如果两个插件几乎同时请求同一个还没缓存的数据,会触发两次请求。解决办法是在DataProvider里维护一个"进行中的请求"Map,同一个key的请求如果已经在飞,就复用那个Promise,而不是再发一次。这个优化在插件多的时候效果很明显,能把首屏请求数降下来不少。
4.3 数据格式的约定与转换
插件拿到的数据格式,最好有个统一约定,否则每个插件都要写一堆防御性代码。我的约定是:数据源返回的永远是"原始数据",格式转换由插件自己负责。比如接口返回的是{ list: [...], total: 100 },数据源原样返回,折线图插件自己去取list并映射成它需要的点数组。
为什么不在数据源层做转换?因为不同插件对同一份数据的期望格式可能不同。指标卡只要一个数字,折线图要一个数组,表格要一个对象数组。如果数据源层强行统一,反而会限制插件的灵活性。把转换放在插件里,每个插件对自己的数据格式负责,职责更清晰。
当然,为了避免每个插件都写重复的转换逻辑,我抽了一些常用的转换工具函数,比如pickNumber(data, path)、toSeries(data, xField, yField),插件按需引用。这些工具函数也是AI生成代码时很好的"积木",告诉AI"用这些现成函数处理数据",能减少它自己造轮子出错的机会。
5. 配置面板:让人能改,也让AI能改
5.1 配置面板为什么必须存在
一套配置驱动的系统,如果没有可视化配置面板,那配置就只能手写JSON,体验很差。而且配置面板不只是给人用的——它还是AI生成配置的"校验场"。AI生成的配置丢进面板,面板根据configSchema渲染出表单,如果某个字段AI填错了类型,表单会立刻暴露问题。
配置面板的实现思路是:根据插件的configSchema动态渲染表单。schema里说某个字段是string,就渲染输入框;说是enum,就渲染下拉框;说是number,就渲染数字输入。这样新增插件时,配置面板不用改代码,自动就能支持新插件的配置。
function renderField(field: ConfigField, value: any, onChange: (v: any) => void) { switch (field.type) { case 'string': return <input value={value} onChange={e => onChange(e.target.value)} />; case 'number': return <input type="number" value={value} onChange={e => onChange(Number(e.target.value))} />; case 'enum': return ( <select value={value} onChange={e => onChange(e.target.value)}> {field.options.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)} </select> ); // ... } }5.2 schema设计里的几个坑
configSchema看着简单,但设计不好会带来很多麻烦。我踩过的坑主要有这几个。
坑一:字段嵌套太深。一开始我允许schema无限嵌套,结果配置面板渲染逻辑变得很复杂,AI生成配置时也容易搞错层级。后来我限制最多两层嵌套,超过的拆成多个插件或者用扁平化的字段名。
坑二:默认值和必填的语义不清。有的字段有默认值但又是必填,这本身就矛盾。我后来明确:必填字段不能有默认值,有默认值的字段一定不是必填。这样配置面板的逻辑就清晰了——必填的没填就标红,有默认值的自动填充。
坑三:字段之间的依赖关系没表达。比如"数据源类型"选了api才需要填url,选了static才需要填value。这种依赖关系如果不在schema里表达,配置面板就得写死判断逻辑。我在schema里加了个visibleWhen字段,描述"当某个字段等于某个值时我才显示",配置面板据此动态显隐。
{ "key": "url", "type": "string", "label": "接口地址", "visibleWhen": { "field": "sourceType", "equals": "api" } }这个visibleWhen机制后来被证明特别有用,因为AI生成配置时,如果不知道字段间的依赖,很容易填一堆互相矛盾的字段。有了visibleWhen,配置面板能主动引导,减少无效配置。
5.3 配置的版本管理与迁移
配置是要存起来的,存起来就会面临"结构变了怎么办"的问题。比如某个插件早期有个字段叫dataKey,后来改名叫dataField,那老配置读进来就找不到数据了。
我的做法是给配置加一个版本号,每次schema有破坏性变更就升版本,同时写一个迁移函数把老版本配置转成新版本。迁移函数可以是一串,从v1到v2、v2到v3依次执行,最终得到当前版本。
const migrations: Record<number, (config: any) => any> = { 1: (config) => ({ ...config, dataField: config.dataKey, dataKey: undefined }), 2: (config) => ({ ...config, span: config.span || 6 }), }; function migrate(config: any, fromVersion: number, toVersion: number) { let result = config; for (let v = fromVersion; v < toVersion; v++) { result = migrations[v](result); } return result; }这套迁移机制在项目迭代了几个月后救了我好几次。没有它的话,每次改schema都得手动去数据库里改老配置,或者干脆放弃兼容,那对用户来说是很糟糕的体验。
6. 实测中那些让人头疼的边界情况
6.1 插件加载失败不能拖垮整个面板
插件是独立单元,但它们在同一个页面里渲染。如果某个插件的render函数抛异常,默认情况下整个React树会崩,整个面板白屏。这显然不能接受——一个插件坏了,其他插件应该照常工作。
解决办法是用错误边界(Error Boundary)把每个插件包起来。React提供了错误边界机制,我写了一个通用的PluginErrorBoundary组件,任何插件渲染出错时,它捕获错误并渲染一个降级UI(比如一个提示"该模块加载失败"的占位块),而不是让整个页面崩掉。
class PluginErrorBoundary extends React.Component { state = { hasError: false, error: null }; static getDerivedStateFromError(error: Error) { return { hasError: true, error }; } componentDidCatch(error: Error, info: React.ErrorInfo) { // 上报错误,方便排查 reportError(error, info); } render() { if (this.state.hasError) { return <div className="plugin-error">该模块加载失败</div>; } return this.props.children; } }这个改动看起来小,但实际价值很大。因为插件是AI生成的,出错概率比人手写的要高,有了错误边界,一个插件的bug不会影响整个面板的可用性,用户体验好很多。
6.2 数据为空、数据格式不对、数据量过大
这三种情况是插件渲染时最常见的翻车点,我逐个说下处理方式。
数据为空:插件必须能优雅处理空数据。指标卡显示"--"而不是"NaN",折线图显示"暂无数据"而不是空白画布,表格显示空状态提示。这个逻辑我要求每个插件都必须实现,并且在验收清单里明确检查。
数据格式不对:接口返回的数据结构和插件期望的不一致,这在联调阶段很常见。我的做法是在插件的数据处理函数里加类型校验,不符合预期时走降级逻辑并打印警告,而不是直接崩。比如期望数组但拿到null,就当成空数组处理。
数据量过大:表格插件如果一次渲染一万行,页面会卡死。解决办法是分页或虚拟滚动。我选了分页,因为实现简单且对AI友好——AI生成表格插件时,只要告诉它"数据超过100条时分页",它基本能写对。虚拟滚动虽然体验更好,但实现复杂,AI容易写错,我暂时没让AI碰这块。
6.3 插件之间的隐式耦合
可插拔架构最怕的就是插件之间偷偷产生依赖。比如A插件改了某个全局状态,B插件依赖这个状态,表面上两个插件独立,实际上耦合在一起。这种隐式耦合在插件少的时候不明显,插件一多就会变成维护噩梦。
我的应对策略是限制插件能访问的上下文。插件只能通过PluginContext访问数据源和有限的全局状态,不能直接访问window、不能直接改其他插件的状态。PluginContext提供的能力是白名单式的,想加新能力必须显式在框架层加,而不是插件自己想办法绕过去。
interface PluginContext { getData: (key: string) => Promise<any>; setGlobalState: (key: string, value: any) => void; getGlobalState: (key: string) => any; // 就这些,没有别的 }这个约束一开始让写插件有点束手束脚,但长期看是值得的。它强制插件保持独立,也让AI生成插件代码时有个明确的边界——AI知道只能用context上的这几个方法,不会去乱用全局变量。
7. 这套东西跑起来之后,我的几点真实体会
用这套架构做了几个面板之后,有几个感受挺深的,分享出来给想尝试类似思路的朋友。
第一,AI最擅长的是"填空",不是"设计"。你把接口、schema、示例都给好,让AI往里填实现,它做得又快又好。但你要是让AI从零设计一个插件该有哪些字段、该怎么组织,它给出的方案往往很平庸,甚至自相矛盾。所以人的价值在于"定框架、定协议、定边界",AI的价值在于"在框架内高效产出"。
第二,配置驱动不是银弹,它有自己的适用边界。对于结构相对固定、变化主要在"有哪些模块、每个模块什么参数"的场景,配置驱动非常合适。但如果面板的交互逻辑特别复杂、模块之间有大量联动,硬套配置驱动反而会把配置搞得比代码还复杂。这种时候老老实实写代码可能更划算。
第三,给AI的约束越明确,产出越稳定。我后来把插件协议、常用工具函数、数据格式约定整理成一份"插件开发规范",每次让AI生成代码时都把这份规范作为上下文传进去。有了这份规范,AI生成的代码风格统一、接口正确率明显提升。这份规范本身也是随着踩坑不断补充的,现在已经有十几条"必须"和"禁止"。
第四,错误处理和降级逻辑必须人来写。AI生成的代码在"正常路径"上通常没问题,但边界情况处理得很差。而Dashboard这种多插件共存的场景,恰恰是边界情况最多的地方。所以我的做法是:AI写主逻辑,人补错误处理和降级,两者结合。
最后说个具体的技巧。如果你也想让AI帮你生成插件代码,建议先从最简单的插件类型开始,比如纯展示的指标卡。把这类插件的协议、示例、规范都打磨好,让AI生成几个跑通,建立起信心和流程,再逐步挑战更复杂的图表类插件。一上来就让AI写复杂图表,大概率会被各种细节问题劝退。循序渐进,这套东西的威力才能真正发挥出来。