函数图像绘制工具这个方向,我从第一版做到现在这个第二版,中间踩的坑比画出来的曲线还曲折。第一版的时候我图省事,直接把表达式往画布上一扔,结果用户输入一个sin(x)/x,程序当场表演了一个“零除崩溃”;后来又遇到tan(x)在渐近线附近画出几条横跨屏幕的竖线,看着像心电图。这一版我花了大概三周时间重构,核心目标就三个:表达式解析要稳、采样策略要聪明、交互体验要顺手。适合谁看?如果你也在用 AI 辅助开发小工具,或者想自己动手做一个能画函数图像的东西,这篇基本能让你少走我走过的弯路。下面我按设计思路、核心实现、实操过程和问题排查四块展开,中间会穿插大量我实际调试时的参数和代码片段。
1. 整体设计思路与方案选型拆解
1.1 为什么不用现成的绘图库直接画
很多人第一反应是找个图表库,把 x 数组和 y 数组喂进去就完事。我第一版就是这么干的,用的是常见的 Canvas 绘图接口,结果发现两个致命问题。第一,图表库默认把数据点当离散点连折线,遇到1/x这种在 0 附近剧烈变化的函数,采样点不够密就会画出一条斜线穿过原点,完全失真。第二,图表库的坐标轴是给统计数据设计的,刻度是“好看的数字”,而函数图像需要的是数学意义上的坐标系,原点和单位长度必须严格对应。
所以这一版我决定自己控制采样和坐标映射,绘图库只负责最后的像素绘制。这个决策背后的逻辑很简单:函数图像的本质是“连续映射的离散近似”,采样策略决定了图像质量的上限,而这一步必须由我掌控。AI 在这个过程中帮我生成了表达式解析器的骨架,但采样逻辑是我一行行调出来的,因为 AI 给的默认方案是等距采样 1000 个点,实测下来对sin(1/x)这种函数完全不够用。
1.2 表达式解析:自己写还是借力
表达式解析是整个工具的地基。用户输入的是字符串,比如x^2 + 2*x - 1或者sin(x)*exp(-x),程序得把它变成可计算的函数。这里有三个选择:一是用eval直接执行,二是引入成熟的数学表达式解析库,三是自己写一个递归下降解析器。
eval我第一时间就排除了,安全性和可控性都太差,用户输入一个奇怪的字符串就可能出问题。成熟库当然省事,但我想要的是能拿到抽象语法树(AST),因为后面做采样优化时需要知道表达式里有没有除法、有没有三角函数,这些信息在纯求值接口里拿不到。所以最终方案是:让 AI 帮我生成一个递归下降解析器的框架,支持加减乘除、幂运算、括号、常见函数(sin、cos、tan、log、exp、sqrt、abs),然后我自己补上 AST 节点定义和求值逻辑。
这个选择的好处在后面体现得很明显。比如我在 AST 里标记了“包含除法”的节点,采样时就会在分母可能为零的位置加密采样点,这就是后面要讲的自适应采样的基础。
1.3 采样策略的核心权衡
采样这件事,本质是在“精度”和“性能”之间找平衡。等距采样最简单,1000 个点铺满屏幕宽度,每个像素大约 1 到 2 个点,对平缓函数够用。但函数图像有个特点:剧烈变化往往集中在很小的区间。tan(x)在π/2附近从正无穷跳到负无穷,等距采样要么漏掉这个跳变,要么在跳变处画出错误的连线。
我最终采用的是两级采样:先做一遍粗粒度的等距采样(比如 500 个点),然后检测相邻点之间的斜率变化,如果某个区间的斜率突变超过阈值,就在该区间内递归加密,直到区间宽度小于一个像素对应的 x 范围。这个思路借鉴了数值积分里的自适应步长,实测下来对绝大多数函数都能在 2000 个点以内画出平滑曲线。
提示:递归加密一定要设深度上限,否则遇到
sin(1/x)在 0 附近这种无限振荡的函数,程序会一直递归下去。我设的上限是 8 层,超过就停止加密,接受一定的不精确。
2. 核心细节解析与实操要点
2.1 表达式解析器的关键实现
解析器的核心是一个tokenize函数加一个递归下降的parse函数。tokenize把输入字符串切成数字、变量、运算符、函数名、括号这几类 token。这里有个容易忽略的细节:隐式乘法。用户经常写2x而不是2*x,或者sin(x)cos(x),解析器得能识别这种写法。我的处理方式是在 tokenize 之后做一遍预处理,在两个“可以相乘”的 token 之间插入乘号,判断规则是:前一个 token 是数字、变量或右括号,后一个 token 是数字、变量、函数名或左括号,就插入乘号。
递归下降解析按优先级分层:最外层是加减,往里是乘除,再往里是幂(注意幂是右结合的,2^3^2等于2^(3^2)),最内层是括号和函数调用。每个层级对应一个函数,返回 AST 节点。AST 节点我用一个简单的对象表示,带type字段和eval(x)方法,这样求值时直接递归调用就行。
// AST 节点示例:二元运算 function BinaryOp(op, left, right) { return { type: 'binary', op, left, right, eval(x) { const l = this.left.eval(x); const r = this.right.eval(x); switch (this.op) { case '+': return l + r; case '-': return l - r; case '*': return l * r; case '/': return l / r; case '^': return Math.pow(l, r); } } }; }这里有个实操心得:求值时要处理 NaN 和 Infinity。Math.log(-1)返回 NaN,1/0返回 Infinity,这些值传到绘图阶段会导致坐标计算出错。我的做法是在求值后统一检查,非有限值就标记为“无效点”,绘图时跳过这些点,而不是硬画。
2.2 自适应采样的参数怎么定
自适应采样的核心参数有三个:初始采样点数、斜率突变阈值、递归深度上限。这三个参数不是拍脑袋定的,我做了几轮对比测试。
初始采样点数我定在 500。屏幕宽度按 1000 像素算,500 个点意味着每 2 个像素一个采样点,对平缓函数已经足够。如果定得太高,比如 2000,那自适应加密的收益就不明显了,因为初始采样已经很密。
斜率突变阈值这个参数比较微妙。我用的是相邻两段线段的斜率差,归一化到屏幕坐标系之后,阈值设在 0.5 左右。意思是如果两段线段的斜率差超过 0.5(大约 26 度),就认为这里可能有剧烈变化,需要加密。这个值调小会导致过度加密、性能下降,调大会漏掉一些细节。我实测下来 0.5 是个不错的平衡点。
递归深度上限设 8 层。每加密一层,区间宽度减半,8 层之后区间宽度是初始的 1/256,对于 500 点的初始采样,最小区间大约对应 0.008 个像素,已经完全够用。再深就是浪费算力。
| 参数 | 取值 | 作用 | 调整建议 |
|---|---|---|---|
| 初始采样点数 | 500 | 粗粒度覆盖整个可视区间 | 屏幕宽时适当增加,但不超过 1000 |
| 斜率突变阈值 | 0.5 | 判断是否需要加密 | 函数振荡剧烈时调小到 0.3 |
| 递归深度上限 | 8 | 防止无限递归 | 一般不用改,除非处理极端函数 |
2.3 坐标映射与断点处理
坐标映射看着简单,其实有个坑:y 轴范围是动态的。用户画x^2和画sin(x),y 的取值范围差了几个数量级。我的做法是先采样一遍,拿到所有有效 y 值的最小值和最大值,然后按这个范围留 10% 的边距来定 y 轴。但这里有个问题:如果函数有渐近线,比如1/x,y 值会趋近无穷大,直接取最大最小值会把整个图像压扁。
解决办法是用分位数裁剪。把所有 y 值排序,取 2% 和 98% 分位数的值作为显示范围,超出范围的点直接裁掉不画。这样1/x的图像会显示中间那段,两端的无穷大被裁掉,视觉上反而更合理。这个技巧是我从数据可视化里搬过来的,用在函数图像上效果出奇地好。
断点处理是另一个重点。tan(x)在π/2处不连续,采样点如果正好跨过这个点,前后两个点的 y 值一个是很大的正数、一个是很大的负数,直接连线就会画出一条竖线。我的处理方式是:如果相邻两点的 y 值符号相反且绝对值都很大(超过显示范围的 2 倍),就判定为断点,不连线。这个判断规则简单但有效,实测能处理绝大多数不连续函数。
3. 实操过程与核心环节实现
3.1 从输入到图像的完整流程
整个流程分五步:解析表达式、确定可视区间、采样、坐标映射、绘制。我按顺序说,每步都附上关键代码和参数。
第一步解析表达式,前面讲过,得到 AST 之后包一层compile函数,返回一个f(x)函数供后续调用。这里加一个缓存:同一个表达式字符串只解析一次,结果存起来。用户拖动缩放时表达式不变,没必要重复解析。
第二步确定可视区间。默认 x 范围是[-10, 10],y 范围动态计算。用户可以通过拖拽和滚轮缩放来改变区间,每次区间变化都触发重新采样和绘制。
第三步采样,用前面说的两级策略。这里有个实现细节:采样是在数学坐标系里做的,不是在屏幕像素里做的。也就是说,我按 x 的数学值等距取点,而不是按像素取点。这样做的好处是缩放时采样逻辑不用改,只需要改 x 的范围。
function sample(f, xMin, xMax, initialCount) { const points = []; const step = (xMax - xMin) / initialCount; for (let i = 0; i <= initialCount; i++) { const x = xMin + i * step; points.push({ x, y: f(x) }); } // 自适应加密 const refined = []; for (let i = 0; i < points.length - 1; i++) { refined.push(points[i]); refineSegment(f, points[i], points[i + 1], 0, refined); } refined.push(points[points.length - 1]); return refined; }refineSegment递归检查斜率突变,需要加密就取中点再分两段递归。这里注意要把加密后的点按顺序插入,不能打乱顺序,否则连线会乱。
第四步坐标映射,把数学坐标转成屏幕像素。x 方向线性映射,y 方向要注意屏幕 y 轴是向下的,所以映射时要翻转。公式是screenY = height - (y - yMin) / (yMax - yMin) * height。
第五步绘制,遍历采样点,有效的点连线,无效点或断点处断开。绘制时用beginPath和moveTo/lineTo,遇到断点就moveTo重新开始一条路径。
3.2 交互功能的实现要点
缩放和拖拽是提升体验的关键。缩放我绑定在滚轮事件上,以鼠标位置为中心缩放,这样用户感觉是“放大我指的地方”。实现上就是根据滚轮方向调整 x 和 y 的范围,同时保持鼠标对应的数学坐标不变。
拖拽绑定在鼠标按下和移动事件上,记录按下时的数学坐标和当前的像素坐标,移动时反算出新的范围。这里有个细节:拖拽时要节流,不能每次mousemove都重新采样绘制,否则会卡。我用requestAnimationFrame做节流,每帧最多重绘一次。
还有一个实用功能是鼠标悬停显示坐标。鼠标移动时,把像素坐标反算成数学坐标,显示在角落。这个功能实现简单但用户反馈很好,尤其是需要读特定点数值的时候。
3.3 性能优化的实际记录
第一版做完之后我测了一下,画sin(x)这种简单函数,从输入到出图大约 30 毫秒,感觉还行。但画sin(1/x)的时候,因为自适应加密触发得厉害,采样点数飙到 8000 多,耗时到了 200 毫秒,拖动时明显卡顿。
优化分两步。第一步是限制总采样点数,设一个上限比如 5000,超过就停止加密,接受精度损失。第二步是把采样和绘制分离到不同的帧,采样用setTimeout分批做,每批处理一部分区间,做完一批就requestAnimationFrame绘制一次中间结果。这样用户能看到图像逐步“长出来”,而不是干等。
实测优化后,sin(1/x)的首次绘制时间降到 80 毫秒左右,拖动时的帧率稳定在 30 帧以上,体验可以接受。
注意:分批采样时要注意批次之间的衔接,我一开始没处理好,导致图像中间出现一条缝隙,后来发现是批次边界处的点没连上,补了一个“重叠一个点”的逻辑才解决。
4. 常见问题与排查技巧实录
4.1 图像画出来是空白或只有一条直线
这是最常见的问题,原因通常有三个。第一,表达式解析失败但没报错,f(x)返回的全是 NaN。排查方法是单独调用f(1)看返回值,如果是 NaN 就检查表达式里有没有非法字符或者函数名拼错。第二,y 轴范围计算错误,所有点都被裁掉了。排查方法是打印采样点的 y 值范围,看是不是异常。第三,坐标映射公式写反了,导致所有点映射到屏幕外。这个只能靠检查代码。
我遇到过一次特别隐蔽的:用户输入x^2,但解析器把^当成了异或运算符,结果算出来全是 0。后来我把^明确映射到Math.pow才解决。所以运算符映射表一定要仔细核对。
4.2 渐近线附近出现竖线
前面提过,这是断点没处理好。除了符号判断,我还加了一条规则:如果相邻两点的 y 值都超出显示范围,且一个在范围上方一个在下方,也判定为断点。这两条规则组合起来,tan(x)、1/x、log(x)的渐近线都能正确处理。
但有一种情况这两条规则都覆盖不了:函数在渐近线附近振荡,比如sin(1/x)在 0 附近。这种函数的图像本质上就无法精确绘制,我的处理是接受它,加密到深度上限就停,画出来的效果是一团密集的线,虽然不完美但至少不误导。
4.3 缩放后图像变模糊或锯齿严重
这是采样点数没跟上缩放级别导致的。放大之后,同样的采样点数覆盖的数学区间变小了,但如果采样点数不变,每个像素对应的采样点就变少了。解决办法是采样点数随缩放级别动态调整,放大时增加初始采样点数。我的规则是初始采样点数等于屏幕宽度除以 2,再乘以一个缩放系数,缩放系数随放大倍数增长,但设上限防止性能爆炸。
| 问题现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 图像空白 | 解析失败或范围错误 | 打印 f(1) 和 y 范围 | 检查表达式和映射公式 |
| 渐近线竖线 | 断点未识别 | 检查相邻点 y 值符号 | 加断点判断规则 |
| 缩放后模糊 | 采样点数不足 | 对比缩放前后点数 | 动态调整采样点数 |
| 拖动卡顿 | 重绘过于频繁 | 看帧率 | 用 requestAnimationFrame 节流 |
| 隐式乘法失效 | tokenize 预处理缺失 | 测试2x输入 | 补插乘号逻辑 |
4.4 几个我踩过的坑和对应技巧
第一个坑是浮点数精度。0.1 + 0.2不等于0.3,这在判断断点时会导致误判。我的做法是比较时加一个容差,比如Math.abs(a - b) < 1e-10就算相等。
第二个坑是函数名大小写。用户可能输入Sin(x)或SIN(x),解析器要统一转小写再匹配。这个细节 AI 生成的代码里经常漏掉,得自己补。
第三个坑是空表达式和纯数字。用户输入空字符串或者只输入一个数字5,程序不能崩。我的处理是空表达式直接返回空图像,纯数字当成常数函数处理,画一条水平线。
第四个坑是移动端触摸事件。桌面端的滚轮和鼠标事件在手机上不生效,得额外绑定touchstart、touchmove、touchend。双指缩放的计算比滚轮复杂,我用的是两指距离的变化比例来调整缩放级别,实测下来体验尚可。
最后分享一个调试技巧:我在开发时加了一个隐藏的“调试模式”,按特定键切换,开启后会在控制台打印采样点数、耗时、y 范围这些信息。这个功能帮我定位了好几个性能问题,建议你也加上,比盲猜高效得多。这个工具后续还可以扩展支持参数方程和极坐标,解析器的 AST 结构已经预留了扩展空间,加新函数类型不用改核心逻辑。