1. 为什么我决定把Dashboard的代码交给AI来写
1.1 一个让我彻底改变想法的下午
去年有段时间我同时在维护三个内部工具的后台面板,一个是给运营看的数据概览,一个是给客服用的工单统计,还有一个是给运维盯的接口健康度监控。三个面板长得不一样,但底层逻辑几乎一模一样:拉数据、做聚合、渲染图表、加筛选条件、处理加载和错误状态。我那时候的状态就是,每加一个新指标,就要在三个项目里各改一遍,改到最后自己都分不清哪个文件对应哪个面板。
真正让我下决心的是某个周五下午,产品临时要加一个"近7天新增用户趋势"的小卡片。按理说这就是一个接口加一个折线图的事,但我硬是花了两个多小时,因为三个面板的图表封装各不相同,一个用ECharts,一个用Recharts,还有一个是手写的SVG。我当时就在想,如果这些重复的、模式化的代码能自动生成,我只负责定义"这个卡片要展示什么数据、用什么图表、放在哪个位置",那该省多少事。
后来我尝试了一个思路:把Dashboard拆成"逻辑层"和"渲染层",逻辑层由我来定义配置,渲染层的代码交给AI去写。实测下来,这套方式让我加一个新面板的时间从半天压缩到了二十分钟左右。这篇文章就是把这套实践完整地拆开讲一遍,包括我踩过的坑、AI生成代码时哪些地方必须人工兜底、以及怎么保证生成出来的东西是可维护的而不是一堆一次性垃圾。
1.2 这套方案到底适合谁
先说清楚适用边界。这套"AI写代码、人定逻辑"的轻量级Dashboard方案,最适合以下几类场景:一是内部工具类面板,用户量不大但对迭代速度要求高;二是需要快速验证的数据看板,可能做完给老板看一眼就废弃了;三是个人项目或者小团队项目,没有专职前端,后端同学自己顺手就把面板搭了。
不太适合的场景也要说清楚:面向C端的高并发、强交互、对性能和体验有极致要求的Dashboard,还是老老实实手写。AI生成的代码在边界情况处理、无障碍访问、极端数据量下的性能表现上,目前还达不到精细打磨的水平。我个人的判断标准是,如果这个面板的代码生命周期超过一年,且会持续迭代,那核心架构还是要人来定,AI只负责填充重复部分。
1.3 核心思路一句话概括
整套方案的核心就一句话:人定义"数据契约"和"布局契约",AI根据契约生成渲染代码,运行时通过插件机制动态加载。数据契约描述的是"这个卡片需要什么数据、数据长什么样",布局契约描述的是"这个卡片放在网格的哪个位置、占多大"。渲染代码是这两者的函数,而AI最擅长的恰恰就是根据明确的输入输出写函数。
这个思路的好处在于,它把"创造性决策"和"机械性编码"彻底分开了。你不需要让AI去猜你想要什么,你只需要把契约写清楚,剩下的交给它。下面我从整体设计开始,一层层往下拆。
2. 整体架构设计与方案选型
2.1 为什么是"可插拔"而不是"大一统"
我见过很多Dashboard框架,走的是"大一统"路线:提供一个超级配置对象,把所有能想到的图表类型、筛选器、布局方式都塞进去,然后通过一个巨大的switch-case来渲染。这种方案的问题在于,配置对象会随着需求膨胀而变得不可维护,而且每加一种新图表就要改核心渲染逻辑,牵一发动全身。
我选择"可插拔"路线,是因为它天然契合AI生成代码的工作方式。每个卡片是一个独立的插件,有自己的数据契约、渲染逻辑和样式。核心框架只负责三件事:管理插件的注册与加载、提供数据获取的统一入口、处理布局和生命周期。这样AI生成的代码被隔离在单个插件内部,即使某个插件写得有问题,也不会污染整个系统。
具体来说,一个插件就是一个目录,里面至少包含一个配置文件(描述契约)和一个渲染组件。框架启动时扫描插件目录,读取配置,按需加载渲染组件。这个设计让我可以在不重启服务的情况下新增或替换卡片,对于内部工具来说这个特性非常实用。
2.2 技术栈的取舍:轻量优先
技术选型上我踩过不少坑,最后定下来的组合是:Vite + 原生ES模块 + 一个极简的响应式库。为什么不用React或者Vue?不是它们不好,而是对于"AI生成代码"这个场景,框架的抽象层反而会增加AI出错的概率。AI对原生DOM操作和标准Web API的理解是最扎实的,一旦引入框架特有的语法糖、生命周期钩子、响应式陷阱,生成代码的可用率会明显下降。
我实测过让AI用React写一个带筛选和分页的表格组件,十次里有三次会因为useEffect依赖数组写错导致无限循环。换成原生JS加一个轻量响应式库之后,同样的需求十次里基本九次能直接跑。这个差异在批量生成卡片的时候会被放大,所以技术栈越"标准"越好。
当然,如果你的团队已经重度使用某个框架,也不必为了这套方案强行切换。折中方案是让AI生成框架无关的纯逻辑代码(数据处理、格式化、聚合),渲染部分由人来写。这样既享受了AI的效率,又避开了框架特有的坑。
2.3 数据契约的设计原则
数据契约是整个方案的地基,设计得好不好直接决定了AI生成代码的质量。我的原则是:契约要足够具体,让AI不需要做任何猜测;同时要足够简洁,让人写起来不累。
一个典型的数据契约包含这几个字段:数据源标识(从哪个接口拉)、字段定义(每个字段的名字、类型、单位)、聚合方式(如果需要前端聚合)、刷新策略(轮询间隔或手动刷新)。我特意没有把"展示形式"放进数据契约,因为展示形式属于布局契约的范畴,两者分离才能让同一个数据被不同卡片复用。
这里有个经验:字段定义里一定要写清楚单位。我遇到过AI把"金额"字段当成"元"来格式化,结果接口返回的是"分",展示出来差了100倍。后来我在契约里强制要求每个数值字段标注单位,这个问题就再没出现过。
2.4 布局契约与网格系统
布局契约我用的是最朴素的12列网格,每个卡片声明自己占几列、在第几行。为什么是12列?因为12能被2、3、4、6整除,组合方式最多,是业界验证过的经典选择。AI对这个网格系统的理解也很到位,你告诉它"占4列",它生成的CSS基本不会出错。
布局契约里还有一个容易被忽略的字段:最小宽度和最小高度。这个字段的作用是告诉框架,当屏幕变窄时这个卡片应该怎么响应。我一开始没加这个,结果在移动端预览时图表全挤成一团。加上之后,框架会在容器宽度不足时自动把卡片堆叠成单列,体验好很多。
3. 核心细节解析与实操要点
3.1 插件目录结构怎么定
插件目录结构我改过三版,最后定下来的是这样:
plugins/ user-trend/ manifest.json // 数据契约 + 布局契约 render.js // 渲染逻辑(AI生成) style.css // 样式(AI生成) order-stats/ manifest.json render.js style.cssmanifest.json是唯一必须人工编写的文件,render.js和style.css都可以由AI根据manifest生成。这个结构的好处是,一个插件就是一个自包含的单元,复制粘贴就能复用,删除目录就等于卸载插件,非常干净。
注意:manifest.json的字段命名一定要统一,我建议用全小写加连字符,比如
>const cache = new Map(); async function fetchData(sourceId, params = {}) { const key = `${sourceId}:${JSON.stringify(params)}`; if (cache.has(key)) { const { data, expireAt } = cache.get(key); if (Date.now() < expireAt) return data; } const source = sources[sourceId]; if (!source) throw new Error(`未知数据源: ${sourceId}`); const data = await source.fetcher(params); cache.set(key, { data, expireAt: Date.now() + source.ttl }); return data; }这段代码里有个细节值得说:缓存key包含了params的序列化结果。这意味着同一个数据源用不同参数请求会被视为不同的缓存条目。我一开始没加这个,结果带筛选条件的卡片总是显示上一次的筛选结果,排查了半天才发现是缓存key没区分参数。
3.3 AI生成渲染代码的提示词模板
这是整套方案里最核心的部分。我经过大量试验,总结出了一个提示词模板,用这个模板生成的代码可用率能到八成以上。模板的结构是:角色设定 + 契约描述 + 输出要求 + 约束条件。
角色设定部分告诉AI"你是一个前端工程师,擅长用原生JS和标准Web API写代码"。契约描述部分把manifest.json的内容原样贴进去。输出要求部分明确说"只输出render.js的内容,不要输出解释,不要用任何框架"。约束条件部分列出几条硬性规则,比如"不要使用innerHTML拼接用户数据"、"所有异步操作必须处理错误"。
这里有个关键技巧:在提示词里给出一个完整的示例。我会把已经写好的一个插件的render.js作为示例贴进去,让AI照着这个风格写。实测下来,有示例的情况下生成代码的风格一致性会好很多,不会出现这个插件用class那个插件用函数的情况。
3.4 样式隔离的三种方案对比
AI生成的CSS很容易出现类名冲突,因为不同插件可能都用了
.card、.title这种通用类名。我试过三种隔离方案,各有优劣:
方案 实现方式 优点 缺点 前缀约定 每个插件类名加插件名前缀 简单直观 依赖AI遵守约定,偶尔会忘 Shadow DOM 每个插件挂载到shadow root 彻底隔离 样式调试麻烦,部分库不兼容 CSS Modules 构建时自动加哈希 自动化程度高 需要构建工具支持 最后我选了前缀约定加构建时校验的组合。构建时用一个脚本扫描所有插件的CSS,如果发现没有前缀的类名就报错。这样既保持了简单,又有自动化兜底。AI在提示词里被告知"所有类名必须以插件名加连字符开头",配合构建校验,基本不会出问题。
4. 实操过程与核心环节实现
4.1 从零搭建框架骨架
框架骨架的代码量其实很小,核心就是一个注册表和两个生命周期钩子。我把它写在一个不到200行的
core.js里。启动流程是这样的:扫描plugins目录拿到所有manifest,校验契约格式,按布局契约计算网格位置,然后逐个加载render.js并挂载。这里有个实现细节:加载render.js我用的是动态import,这样每个插件是独立的模块,天然支持按需加载和热更新。Vite对这个模式支持得很好,开发时改一个插件的代码,只有那个插件会重新加载,其他插件不受影响。
async function loadPlugin(manifest) { const module = await import(`./plugins/${manifest.id}/render.js`); const container = createContainer(manifest.layout); const data = await fetchData(manifest.dataSource, manifest.defaultParams); module.render(container, data, manifest); return { container, module }; }这段代码里
createContainer负责根据布局契约创建DOM容器并设置网格样式。我特意把容器创建和渲染分开,这样渲染函数只需要关心内容,不用管自己在哪里。4.2 用AI生成第一个插件
拿"近7天新增用户趋势"这个卡片举例。我先手写manifest.json:
{ "id": "user-trend", "title": "近7天新增用户趋势", "dataSource": "userStats", "fields": [ { "name": "date", "type": "string", "label": "日期" }, { "name": "count", "type": "number", "label": "新增用户数", "unit": "人" } ], "layout": { "colSpan": 6, "rowSpan": 1, "minWidth": 320 }, "refresh": { "interval": 300000 } }然后把这个文件内容加上提示词模板丢给AI。AI返回的render.js大概长这样:一个render函数,接收容器、数据和manifest,内部创建一个canvas或者svg,用数据画出折线图。我检查了一遍,发现它用了原生Canvas API,没有引入任何外部库,这正是我想要的。
这里有个经验:第一次生成后不要急着用,先让AI自己review一遍。我会追加一句"请检查你的代码是否有内存泄漏、是否处理了空数据、是否在容器尺寸变化时重绘"。AI通常能自己发现一两个问题并修正。这个"生成-自检"的流程比直接生成再人工改要高效。
4.3 数据聚合与格式化的处理
有些卡片需要前端做聚合,比如"按地区统计订单量"。这种逻辑我倾向于让AI生成纯函数,然后单独测试。因为聚合逻辑是纯计算,没有副作用,测试起来很方便。我会在manifest里声明聚合方式,AI根据声明生成对应的聚合函数。
格式化也是类似。日期格式化、数字千分位、百分比保留几位小数,这些都有标准做法,AI生成的质量很稳定。但有个坑要注意:时区处理。我遇到过AI用
toLocaleDateString格式化日期,结果在不同时区的机器上显示不一致。后来我在契约里强制要求所有日期字段用ISO格式传输,展示时统一用UTC+8转换,这个问题才解决。4.4 热更新与调试体验
开发体验这块我下了不少功夫,因为如果每次改卡片都要重启服务,那这套方案的效率优势就没了。Vite的HMR天然支持动态import的模块热更新,我只需要在插件模块里加一段
import.meta.hot.accept的处理,就能实现改render.js后只刷新那个卡片。调试方面,我在框架层加了一个"调试模式",开启后每个卡片会显示自己的数据契约、加载耗时、渲染耗时。这个功能帮我定位过好几次性能问题,比如某个卡片渲染慢是因为在render函数里做了大量DOM操作,改成先构建DocumentFragment再一次性插入就好了。
提示:调试模式下还可以加一个"重新生成"按钮,点击后把当前manifest发给AI重新生成render.js并热替换。这个功能在快速迭代阶段特别好用,相当于把AI变成了一个实时的代码生成器。
5. 常见问题与排查技巧实录
5.1 AI生成代码的典型问题速查
在大量使用AI生成插件代码的过程中,我总结出几类高频问题,整理成表格方便对照排查:
问题现象 根本原因 解决方式 图表不显示 容器高度为0 在布局契约里强制设置minHeight 数据更新后界面不变 没有清理旧DOM render函数开头先清空容器 内存持续增长 事件监听未移除 返回cleanup函数,框架在卸载时调用 数字显示异常 单位未标注 契约里强制每个数值字段带unit 移动端错位 未处理响应式 布局契约加minWidth,框架自动堆叠 首次加载闪烁 数据未到先渲染 框架层统一处理loading状态 这张表里的每一条都是我实际踩过的坑。特别是"内存持续增长"那条,我一开始没在意,直到某个面板开了半天之后浏览器卡死才发现。后来我在框架层强制要求render函数返回一个cleanup函数,卸载时调用,问题才根治。
5.2 契约写错导致的连锁反应
契约是整个系统的输入,契约写错会导致AI生成完全错误的代码。最常见的契约错误有三种:字段类型写错(把数字写成字符串)、数据源标识拼错、布局参数超出网格范围。我在框架层加了契约校验,启动时如果发现契约不合法就直接报错并指出具体位置,而不是让错误悄悄传递到渲染阶段。
校验规则我列一下:字段类型必须是string、number、boolean、date之一;数据源标识必须在已注册的数据源列表里;colSpan必须在1到12之间;rowSpan必须是正整数。这几条规则拦住了我八成的契约错误。
5.3 性能问题的排查思路
Dashboard的性能问题通常出在三个地方:数据请求太多、渲染太重、重绘太频繁。我的排查顺序是先用调试模式看每个卡片的加载和渲染耗时,定位到具体卡片后再深入。
数据请求太多的情况,通常是多个卡片请求了同一个接口但参数不同。这时候可以考虑在数据源层做请求合并,把多个小请求合并成一个大请求。渲染太重的情况,一般是DOM节点太多,可以考虑用Canvas替代DOM,或者做虚拟滚动。重绘太频繁的情况,通常是轮询间隔太短,我一般把默认轮询间隔设在5分钟以上,除非有实时性要求。
5.4 什么时候该放弃AI生成
不是所有卡片都适合AI生成。我的判断标准是:如果这个卡片的逻辑复杂度超过"数据处理加标准图表"的范畴,比如涉及复杂的交互状态机、自定义的手势操作、精细的动画编排,那就老老实实手写。AI在这些场景下生成的代码往往需要大量修改,改的时间比自己写还长。
另外一个判断标准是复用性。如果一个卡片会被多个面板复用,那它的代码值得人工打磨,因为一次投入多次收益。如果是一次性的卡片,AI生成完全够用。我现在的做法是,先用AI生成一版跑起来,如果发现这个卡片确实重要且会长期存在,再安排时间人工重构。
6. 这套方案跑了一年之后的真实体会
6.1 效率提升到底有多少
说个具体的数字。这套方案上线前,我平均每周花在Dashboard维护上的时间大概是6到8小时。上线后,同样的维护量降到了2小时左右。新增一个标准卡片的时间从平均2小时降到了20分钟,其中15分钟是写契约和调提示词,5分钟是检查和微调。
但这个效率提升有个前提:契约设计要到位。我前两个月因为契约设计不合理,返工了不少次。后来把契约模板固化下来,效率才真正起来。所以如果你要尝试这套方案,建议先花时间把契约设计好,磨刀不误砍柴工。
6.2 哪些地方AI还是靠不住
必须诚实地说,AI在以下几个方面还是靠不住:一是复杂的错误处理,它倾向于只处理happy path;二是无障碍访问,生成的代码基本没有aria属性;三是极端数据的边界处理,比如空数组、超大数值、超长文本。这些地方我都在框架层做了统一兜底,而不是指望AI在每个插件里都处理好。
还有一个靠不住的地方是代码风格的一致性。即使给了示例,不同批次生成的代码还是会有细微差异。我的做法是在构建流程里加一个lint步骤,用统一的规则格式化所有生成的代码,这样至少保证风格一致。
6.3 后续可以怎么扩展
这套框架目前只支持展示型卡片,后续我想加的是交互型卡片,比如可以点击筛选、可以下钻查看明细。交互型卡片的契约会更复杂,需要声明交互事件和状态管理方式。我初步的想法是把交互也抽象成契约的一部分,AI根据契约生成事件处理函数。
另一个扩展方向是卡片之间的联动。比如点击一个卡片的某个数据点,其他卡片跟着筛选。这个需要框架层提供事件总线,插件通过总线通信。这个特性我还在设计中,核心难点是如何让AI理解联动的语义,可能需要更结构化的契约描述。
最后分享一个小技巧:如果你也在用类似的方式,建议把每次AI生成的提示词和结果都存档。我存了大概两百多个案例,后来发现这些案例本身就是最好的提示词素材库。遇到新需求时,先翻翻有没有类似的案例,把那个案例的提示词改一改,比从零写提示词效率高得多。这个习惯让我在后期几乎不用再调试提示词,直接套用改参数就行。