最近有朋友问我:AI 到底能不能写图形库?他看我最近一直在折腾一个内部数据可视化的项目,还以为我是全靠 AI 在写代码。我说你这话得分两半回答,要问"能不能用 AI 写出图形库的大头",答案是可以,而且快得离谱;要问"把整个图形库丢给 AI 就完事",那答案是我差点被剩下那 10% 坑出心理阴影。
事情是这样的,我用 AI 辅助开发了一个纯前端的轻量图形库,主要做折线图、柱状图、散点图、饼图这类基础图表,跑在 Canvas 2D 上,不依赖任何第三方图表框架。整个过程中 AI 大概帮我写完了 90% 的代码量,从绘图原语到坐标变换再到动画框架,速度确实让我这种写惯了业务代码的人有点恍惚。但真正上线前的那几天,我几乎全耗在剩下那 10% 上:高分屏适配、数据边界防御、事件命中检测、动画资源释放、文字布局错位……每一个都像埋在代码里的雷,AI 根本不会主动替你想。今天就把整个过程掰开揉碎讲一遍,给所有想用 AI 写图形库、或者已经在坑沿上试探的朋友做个参考。
1. 项目概述与整体思路
1.1 这个图形库到底要做什么
先交代背景。我们内部有个运营看板,之前一直用的是开源图表库,但需求越来越偏门:要自定义交互动效、要跟内部设计规范完全一致、要砍掉我们用不到的一大半功能让首包体积下来。换个成熟图表库当然也行,但改起来更费劲,不如自己写一个够用的轻量库,只保留折线图、柱状图、散点图、饼图四种基本图表,加上缩放、tooltip、图例交互就够了。
这个定位很重要,千万别上来就想做一个"小 ECharts"。图形库这个东西,功能范围一旦放大,复杂度是指数级上升的。我跟 AI 协作的前提,就是先把这个范围死死锁住:不碰 3D、不做 WebGL、不做大数据量的热力图、不做拖拽编辑。这样 AI 写出来的代码才会可靠,后面你自己补坑的时候也不会绝望。
技术上选的是 Canvas 2D。原因很简单:SVG 在处理大量节点时 DOM 开销太大,WebGL 对我们这种基础图表又杀鸡用牛刀。Canvas 2D 在中等数据量下性能足够,API 也直白,最关键的是 AI 对 Canvas 2D 的掌握程度非常高,训练语料里这方面的代码太丰富了,所以它写出来的前几版代码基本靠谱。
1.2 为什么敢让 AI 来写核心代码
我承认一开始是有点冒险的。图形库和普通业务页面不一样,它涉及大量数学计算、坐标变换、状态管理,还有 Canvas 上下文这种"一旦搞错就全是黑屏"的东西。但我的判断是:正因为 AI 见过海量的绘图代码,它在这类任务上的表现反而比写复杂业务逻辑要稳。
实际操作下来确实如此。AI 生成的绘图原语、比例尺计算、基础动画循环,拿过来稍作修改就能跑。我甚至让 AI 先写了几十个针对数据映射的单元测试,它的输出正确率高到让我怀疑是不是从哪个开源项目里背下来的。
但我也很清楚它的问题在哪:AI 写代码是"按模式匹配",不是"按语义理解"。它能写出ctx.beginPath()加ctx.stroke(),但它不会意识到你的图表在 2 倍屏上会发虚;它能写出requestAnimationFrame动画,但它不关心组件卸载后这个循环还在不在跑。这些属于"从没出过 bug 就不会想到"的经验问题,恰好是 AI 最缺的部分。
所以我的策略是:AI 负责 90% 的"从无到有",我负责 10% 的"从对到稳"。这也直接引出了整篇文章的核心——那 10% 到底是什么,怎么补。
1.3 架构设计:一开始就把分工想清楚
动笔之前,我花了一个晚上把整个库分成三层:数据层负责接收和清洗原始数据;计算层负责把业务数值映射成画布坐标;渲染层负责真正调用 Canvas API 画出来。交互单独拎出来挂在渲染层后面,不跟核心绘图逻辑混在一起。
这个分层对 AI 协作来说无比重要。我只需要把每一层的接口定义好,AI 就能在接口约束内生成实现,不会越界乱写。而且一旦哪一层出了问题,定位范围非常小,我能精确到"是计算层的问题"然后把那一小段代码单独丢给 AI 修,而不是把整个库甩给它让它瞎猜。
接口定义我用了非常死板的 JSDoc 风格,但效果出奇好。AI 看到这种严密类型说明,生成的代码完成度明显更高,少了很多"它自己发明接口"的情况。这一点在后面我会详细展开。
2. 技术方案与实现路径
2.1 数据层:先把脏数据挡在门外
图形库的第一个坑其实不在绘图,在数据。真实业务数据永远不会像教程里的数组那么干净,可能出现空值、单个数据点、所有值相等、负值、非数字、极大极小值混在一起。如果数据层不做清洗,后面计算层和渲染层怎么补都补不干净。
我给 AI 定的数据层接口是:输入任意数组,输出统一规格的序列数据结构,包含name、data、color,其中data是已经过滤掉非法值的数组,同时记录下哪些位置是空值,供折线图断线使用。AI 写出来的清洗函数中规中矩,但有一个隐藏 bug:它默认数据都是数值型,遇到字符串数字会直接返回 NaN。我后来加了类型转换和严格校验,这才算把门守住。
这个环节的经验是:数据层的校验规则必须由人来定,不能交给 AI 自由发挥。AI 只会按它见过的最常见场景写,但真实数据的丑陋程度远超它的想象。你可以让 AI 实现你定好的规则,但规则本身一定要来自你对自己业务的了解。
2.2 计算层:坐标变换是图形库的心脏
计算层是整个库的命门,也是最容易出数学坑的地方。核心是把业务数据域(比如销售额 0 到 1000 万)映射到画布像素域(比如 x 从 60 到 540)。这个映射关系叫比例尺,我用一个简单的类来实现。
class LinearScale { constructor(domain, range) { this.d0 = domain[0]; this.d1 = domain[1]; this.r0 = range[0]; this.r1 = range[1]; const span = this.d1 - this.d0; this.k = span === 0 ? 0 : (this.r1 - this.r0) / span; this.mid = (this.r0 + this.r1) / 2; } map(v) { if (this.k === 0) return this.mid; return this.r0 + (v - this.d0) * this.k; } }这里我特意让 AI 处理了span === 0的情况,也就是数据最大值和最小值相等的时候。这是 AI 最容易忽略的边界条件,如果不处理,映射结果全是 NaN,图表直接白屏。类似的边界还有:空数组、单点数据、全部是负值、包含Infinity。我在定义接口时把这些情况一条条列成注释,AI 大部分能接住,但接不住的我后面还得自己补。
坐标变换设计好之后,折线图的路径生成就很简单了:遍历数据点,每个点做一次scale.map()得到画布坐标,然后连起来。柱状图也类似,只是把 x 坐标换成柱子的中心位置,再根据柱宽算出左右边界。这段代码 AI 跑得飞快,几乎没返工。
2.3 渲染层:Canvas 绘制的核心套路
渲染层是我觉得 AI 最出彩的部分。它生成的绘制函数结构非常标准:先save(),然后设置样式,绘制,最后restore()。这个习惯对 Canvas 开发至关重要,因为 Canvas 上下文是个全局状态机,不恢复现场的话,上一个图形的样式会污染下一个。
function drawLine(ctx, points, style) { ctx.save(); ctx.beginPath(); ctx.strokeStyle = style.stroke; ctx.lineWidth = style.lineWidth; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; points.forEach((p, i) => { if (i === 0) { ctx.moveTo(p.x, p.y); } else { ctx.lineTo(p.x, p.y); } }); ctx.stroke(); ctx.restore(); }还有个细节是折线图的空值断线。如果数据中间有个null,折线不应该硬生生连过去,而应该断开。AI 第一版没处理这个,我让它改成遇到空值就beginPath()重新开始一段新路径,这才符合真实图表的预期。这段代码很小,但属于"图表面向用户的基本素养",AI 不会自觉想到。
我把渲染层的公共代码抽了几个工具函数:绘制网格线、绘制坐标轴文字、绘制图例。坐标轴文字这里有个暗坑:Canvas 的fillText是按文字基线对齐的,想让文字居中必须用textBaseline = 'middle'配合measureText计算宽度。AI 生成的文字绘制经常偏上或偏左,肉眼看着不明显,但截图对比就很扎眼。
2.4 交互层:命中检测与坐标换算
图表不只需要画,还需要响应用户操作。鼠标移到数据点上时展示 tooltip,点击柱子时触发回调,这些都需要做命中检测。我选择的是最简单的思路:在事件监听里遍历所有图元,计算鼠标位置与图元的距离,小于阈值就算命中。
这里第一道坎是坐标换算。Canvas 的getBoundingClientRect()返回的是元素在视口中的位置,而clientX/clientY是鼠标相对视口的位置,两者相减才能得到相对画布左上角的坐标。这个换算 AI 会写,但它经常漏掉一个关键点:页面发生滚动时 rect 会变,必须每次事件触发时实时获取,不能缓存。我第一次测试就是缓存了 rect,结果页面一滚动 tooltip 就错位。
canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; hitTest(x, y); });命中检测本身也分场景。散点图好办,算圆心距离就行;柱状图好办,判断是否在矩形内;折线图最麻烦,因为用户往往点的不是数据点本身,而是折线上任意一段。所以我用了一个点到线段的距离函数,阈值设为 8 像素,这样鼠标靠近折线附近就能命中。
function pointSegDist(px, py, x1, y1, x2, y2) { const dx = x2 - x1; const dy = y2 - y1; const l2 = dx * dx + dy * dy; if (l2 === 0) return Math.hypot(px - x1, py - y1); let t = ((px - x1) * dx + (py - y1) * dy) / l2; t = Math.max(0, Math.min(1, t)); const cx = x1 + t * dx; const cy = y1 + t * dy; return Math.hypot(px - cx, py - cy); }这段代码算法不难,但它的意义在后面排查时体现出来了。没有它,折线图就只能点中端点,体验极差。AI 一开始给的是点到直线距离的版本,没有 clamp 到线段范围内,导致点在折线延长线上也会命中,那是一种非常诡异的手感。
3. 实操过程:AI 写出的那 90% 是怎么来的
3.1 我给 AI 的"需求文档"式提示词
说句实话,AI 写代码的上限,很大程度上取决于你怎么描述需求。我不是写那种"帮我画个折线图"的模糊指令,而是把每个模块拆成一个独立的提示词任务,附上接口定义和几个关键约束。
举个例子,我给比例尺模块的提示词大概是这样的结构:模块目标、输入输出格式、必须处理的边界情况、参考实现风格。AI 看到这种提示词,基本能一次写出可运行的代码。我后来总结出一套适合自己的模式:先给骨架(接口签名),再给血肉(具体需求),最后给边界清单。边界清单尤其重要,AI 不会主动想到的坑,你把它列进去它就能处理。
这一步省下的时间非常可观。手动实现一个坐标比例尺带边界防御,大概要二十分钟,AI 十秒出稿,我花两分钟检查,效率差距是碾压级的。
3.2 AI 写单测,我来补业务断言
很多人写 AI 辅助代码时忽略测试,我觉得这是最亏的。图形库这类纯计算密集的模块,恰恰是最适合写单元测试的,因为输入输出高度可预测。我让 AI 先把LinearScale、pointSegDist、数据清洗函数这些纯函数测试写出来,测试用例我来补业务相关的部分。
这里有个小技巧:AI 生成测试用例时,通常会写"常规情况",比如输入 0 到 100 映射到 0 到 500。这种当然能过,但不代表代码没问题。我要求自己在测试里补上这些:全相等数据、单点数据、负数数据、包含null的数据、极大值极小值。这些边界用例才是图形库稳定性的基本盘。
实测下来,AI 写的代码在常规用例上几乎全过,但在边界用例上暴露了不少问题,这正好印证了前面说的:AI 的强项是模式覆盖,弱项是对"异常世界"的想象力。
3.3 动画系统:AI 的框架是对的,细节是空的
图表库一般需要入场动画,比如柱状图从底部长出来、折线图从左到右画过去。动画框架无非是requestAnimationFrame循环里逐步推进进度值,然后每帧重新渲染。这个框架 AI 写得没毛病,但它有一个致命遗漏:没有提供销毁接口。
组件卸载了、图表换数据了,动画循环还在跑,每帧还在继续重绘。在一两个图表时看不出问题,到十几个图表同时挂载卸载时,CPU 占用直接拉满,页面明显卡顿。这个 bug 不是 AI 写不出来,而是它压根不知道你的图表会被反复创建销毁。
start(progress) { if (this._rafId !== null) return; this._startT = performance.now(); const tick = (t) => { const p = Math.min((t - this._startT) / this._duration, 1); progress(this._easing(p)); if (p >= 1) { this._rafId = null; return; } this._rafId = requestAnimationFrame(tick); }; this._rafId = requestAnimationFrame(tick); } destroy() { if (this._rafId !== null) { cancelAnimationFrame(this._rafId); this._rafId = null; } }我在所有图表实例上都挂了一个destroy()方法,统一处理:取消动画、移除事件监听、断开 ResizeObserver、释放引用。这不只是动画的问题,是整个组件生命周期的兜底。AI 帮你写了出生,你要负责写死亡。
3.4 性能优化:能省则省的渲染策略
图形库最容易被人诟病的就是性能。数据量一上来,每帧把全部图元重绘一遍,Canvas 再快也扛不住。我的方案是分级处理:静态图表的背景网格和坐标轴只在初始化时画一次,数据层有变化时只重绘数据区域;动画进行中才全量重绘,动画结束就切回静态模式。
AI 对这个优化方案的理解很快,但实现起来有个共享状态的问题:它把"当前是否处于动画中"这个标记放在模块全局,而多个图表实例共享同一份状态,导致一个图表在动画时,另一个静态图表的背景也被强制重绘了。这属于典型的"AI 写代码不考虑实例隔离",我花了一晚上把所有全局状态收归到每个实例内部,才算彻底解决。
4. 让我差点崩溃的那 10%
4.1 高分屏模糊:最经典的 AI 盲区
第一天上浏览器实测,我就发现图表文字边缘发虚,线条粗细不均,截图放大后像蒙了一层纱。原因很明确:没有处理 devicePixelRatio。现代笔记本大多是 2 倍屏,物理像素是 CSS 像素的两倍,Canvas 默认按 CSS 像素绘图,等于用一个低分辨率画布拉伸到高清屏上,不糊才怪。
解决办法是让 Canvas 的位图尺寸等于 CSS 尺寸乘以 DPR,然后通过setTransform把绘图坐标缩放到 CSS 像素坐标系。
function fitCanvas(canvas, cssWidth, cssHeight) { const dpr = window.devicePixelRatio || 1; canvas.width = Math.round(cssWidth * dpr); canvas.height = Math.round(cssHeight * dpr); canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }这个代码看起来简单,但放到整个库里牵扯面很大:所有坐标计算基于 CSS 像素,所有绘制通过setTransform放大,事件命中坐标用的是getBoundingClientRect返回的 CSS 像素,所以完全不受影响。我让 AI 排查这个 bug 时,它给的第一个建议是"提高 canvas 的分辨率",完全不提 DPR 这个词。最后还是我自己定位并改完的。经验:涉及物理像素和逻辑像素的问题,AI 目前很难主动识别,这是第一类必踩的坑。
4.2 数据边界的 NaN 陷阱
图形库上线后收到的第一个线上 bug,是某个报表的销售额字段连续一个月都是同一个值。按理说数据正常,但图表渲染出来一片空白,控制台全是NaN。根因就是我前面说的:比例尺在d1 - d0 === 0时返回中点的逻辑没生效,因为 AI 在处理"数据经过某种转换后再喂给比例尺"的路径时,中间层把数值变成了NaN。
这个 bug 排查起来非常恶心。表面现象是比例尺这边输出了NaN,但实际源头在数据清洗的排序逻辑里——AI 的排序函数把包含null的数组默认按undefined排到最后,然后求最大值最小值时没过滤掉undefined,于是一切归零。
我后来定了一个铁律:所有入口数据进计算层之前,必须经过一道统一的sanitize函数,把null、undefined、NaN、Infinity全部处理掉。这个函数我人肉写死,不让 AI 自己发挥。事实证明这是整个项目最值的投资。
4.3 命中检测的精度与性能平衡
折线图 tooltip 命中调试是另一个差点让我放弃的瞬间。一开始点的反馈断断续续:鼠标明明在折线上方,tooltip 不出现;鼠标离折线老远,tooltip 却弹出来了。后来发现两个问题叠加:一是点线段距离函数的阈值设置不合理,8 像素在 2 倍屏下实际是 4 物理像素,手感偏紧;二是命中检测遍历了所有数据点,数据量到几千时,每次鼠标移动都要算几千次距离,肉眼可见卡顿。
解决方案有两步。阈值改成按 DPR 归一化,保证不同屏幕下手感一致;性能方面加了一个简单的空间索引,先把数据点按 x 坐标排序,鼠标移动时先用二分查找圈定 x 附近的候选点,再对候选点做精确距离计算。这样几千个点的命中检测只要算几十次,手感直接起飞。
这段优化 AI 帮了一部分,但空间索引的思路完全是我提出的。AI 能写二分查找,但它不会主动想到"鼠标命中检测需要性能优化"。这是第二类必踩的坑:AI 默认数据量是小规模,你要在提示词里显式告诉它大数据场景的预期。
4.4 动画与事件监听的资源泄漏
前面提到了动画循环泄漏,实际线上环境更复杂。我们的看板有标签页切换,每次切走再切回来,图表组件都会重新创建。如果没有正确的销毁机制,每切换一次就多一个永远跑不完的requestAnimationFrame循环,十几个标签来回切几轮,页面直接变成幻灯片。
类似的泄漏还有事件监听。我把mousemove监听直接绑在 canvas 上,销毁时只移除图表实例的引用,忘了removeEventListener,导致旧 canvas 虽然从 DOM 里摘了,但监听函数还挂在上面,鼠标一动就报错。这种问题 AI 完全不会预警,它写代码时不会想"这个组件以后会被销毁吗"。
我的改进方案是把事件绑定和销毁统一封装成一个bindEvents/unbindEvents对,所有图表实例强制走这个通道,不允许手动散装监听。
4.5 文字布局和颜色插值的暗坑
最后补两个小坑,虽然小,但特别影响观感。
第一个是文字布局。坐标轴刻度标签稍长一点就会跟轴线重叠。AI 生成的绘制逻辑用的是fillText加固定偏移,没有考虑文字宽度。我改成用measureText量出实际宽度,再根据刻度位置动态计算对齐方向,这才避免了"刻度数字互相打架"的丑态。
第二个是颜色插值。Pie 图 hover 高亮时需要一个过渡色,我尝试让 AI 写一个十六进制颜色插值函数。它写出来的版本没错,但只处理了 6 位 hex,遇到 3 位 hex 或带透明度的rgba就直接返回NaN色值。后来我统一把所有颜色先转成 RGB 分量,再做线性插值,输出rgb()字符串,兼容性直接拉满。
function parseHex(hex) { let h = hex.replace('#', ''); if (h.length === 3) { h = h.split('').map((c) => c + c).join(''); } const n = parseInt(h, 16); return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; } function lerpColor(c1, c2, t) { const a = parseHex(c1); const b = parseHex(c2); const r = Math.round(a.r + (b.r - a.r) * t); const g = Math.round(a.g + (b.g - a.g) * t); const bl = Math.round(a.b + (b.b - a.b) * t); return `rgb(${r},${g},${bl})`; }5. 常见问题与排查技巧实录
5.1 问题速查表
把这段时间踩过的坑整理成一张表,遇到类似症状可以直接对照。
| 症状 | 根因 | 解决方案 |
|---|---|---|
| 图表文字发虚、线条粗细不均 | 未处理高分屏 DPR | fitCanvas统一缩放 |
| 渲染结果全空白且控制台报 NaN | 数据含空值且清洗不彻底 | 入口处强制sanitize |
| 鼠标点击无法命中图元 | 事件坐标未换算或 rect 缓存失效 | 每次事件实时getBoundingClientRect |
| 折线点中延长线也能触发 tooltip | 点到直线而非线段 | 距离函数做 clamp |
| 页面切换后 CPU 飙高、卡顿 | 动画循环和事件监听未释放 | 统一destroy()接口 |
| 图表在容器缩放后变形 | 未监听容器尺寸变化 | ResizeObserver重设画布 |
| 柱状图边缘溢出绘图区 | 未做裁剪 | save/clip/restore |
| tooltip 跟随有半格偏移 | 未考虑 DPR 或 CSS 像素换算 | 统一以 CSS 像素为坐标基准 |
| 饼图 hover 颜色异常 | 十六进制颜色解析未兼容 3 位 | 统一转 RGB 分量插值 |
| 文字跟坐标轴重叠 | 未测量文字实际宽度 | measureText动态计算偏移 |
这张表不是理论推导,全是实测排障的真实记录。每个问题背后至少花掉我半天到一天的时间,这也是我说"剩下 10% 差点把我坑死"的直接原因。
5.2 让 AI 补救代码的独门技巧
踩坑踩多了,我总结出一套让 AI 配合"补后 10%"的方法,分享给有类似场景的朋友。
第一,把边界条件做成 checklist 贴进提示词。每次让 AI 改某个函数,我都会在末尾追加一行:"请特别注意数据全相等、空数组、单点数据、负值、非数字值这五种边界情况。"这行字看着简单,但能显著降低 AI 回归出新 bug 的概率。
第二,先给测试,再让 AI 改实现。遇到 bug 时,我先把失败的测试用例和断言贴给 AI,让它根据测试反推修复方案。这比直接问"你帮我看看哪错了"有效得多,因为 AI 在对着明确目标时表现更好。
第三,把 AI 当结对编程的 junior,而不是自动补全器。让它先讲思路再写代码,或者在提示词里加一句"先解释你打算怎么修,再输出代码"。这样我能提前发现它思路不对,不用等代码写完再返工。
第四,人工 review 一份"雷区清单"。这个清单包含:有没有处理 DPR、有没有释放动画帧、有没有移除事件监听、有没有做数据边界防御。每次 AI 交稿,我逐条对照检查,漏一条就打回重改。这个过程虽然繁琐,但确实保证了最后的质量。
6. 踩过坑之后的几点体会
说回开头那个问题——AI 能写图形库吗?我的答案变了:能,但它写的是"从零到差不多能用"的那 90%,剩下那 10% 需要的是对渲染引擎的理解、对真实数据丑恶面的认知、对组件生命周期的敬畏,这些恰恰是 AI 目前最薄弱的地方。你让 AI 独立完成整个图形库,就像让一个背熟菜谱的人独立开饭店,菜谱他能背,但食材变质、燃气不稳、客人过敏这些事,菜谱上不会写。
我个人在实际操作中的体会是,AI 辅助写图形库的正确姿势不是"甩手掌柜",而是"带着框架的管理者"。框架、边界、生命周期、性能预期这些设计层面的东西必须由人来定,AI 负责在框架内高效填充实现。你给它的约束越清楚,它产出的质量越接近可用;反过来,你越指望它自己发现问题,它越会在你最信任它的地方给你埋雷。
最后再分享一个小技巧,算是我这段时间最值钱的一条经验:给 AI 生成的每个纯函数都配上边界测试,测试用例里故意写一些"正常人不会给的输入",比如只有一个点的折线图、全是相同数值的柱状图、包含Infinity的散点数据。这些测试不是为了好看,而是为了让 AI 的盲区暴露在可控的测试环境里,而不是暴露在生产环境的线上报错里。毕竟图形库这种项目,出一次线上事故的代价,抵得上你写一百个测试用例的时间。