开发一个函数绘图工具:从需求到单文件落地
站上有个工具:函数绘图室 →。一个单文件、零依赖、开箱即用的函数绘图器。这篇记一下它为什么存在、怎么做出来的。
起点:一个具体的场景
市面上绘图工具不少,为什么自己写?因为需求很具体:给孩子讲函数。这意味着:
- 打开就能用,不注册、不装 App、不看广告
- 小学到高中的函数都要画:从
y = 2x到y = sin(x)/x - 输入方式要像作业本:
^、√、|x|、分段函数{ },而不是程序员的Math.pow - 出错要好好说话,“第 3 个字符少个括号”比一屏红色堆栈有用得多
把这些列完,发现现成工具要么太重(专业数学软件),要么太轻(只能画多项式)。那就自己写。
技术决策:单文件,零依赖
整个工具是一个 index.html,没有构建步骤、没有框架、没有 CDN。这不是为了炫技,是三个务实的好处:
- 分发即复制 —— 丢到任何静态目录就能跑,我的博客
public/tools/下放一份就上线了 - 没有供应链 —— 零
node_modules,十年后打开还能跑 - 强制约束 —— 单文件逼着你把架构想清楚,什么东西该放哪
代价当然有:没有组件化、没有热更新、代码全靠自觉分段注释。但对一个两千行的工具,这个交换是划算的。项目的形态应该匹配它的生命周期——这工具大概会活很久,但不会长很大。
核心:手写表达式解析器
整个工具的心脏是一个手写的 tokenizer + 递归下降解析器,把用户输入编译成 AST,再求值绘图。为什么不直接 eval?
- 安全 ——
eval是把用户输入当代码执行,XSS 敞门 - 体验 —— 解析器能定位错误:“括号不匹配”能标出是第几个字符,
eval只能抛一句 meaningless 的异常 - 能力 —— 分段函数
{x<0: -x, 其他: x}、比较运算、绝对值|x|、自定义函数复用,这些语法全要自己定义,eval给不了
实现上是很教科书的三层:
输入字符串 → tokenize → 递归下降解析 → AST → 闭包求值
tokenize 负责把 − × ÷ ² ³ π 这些数学符号统一成 ASCII;解析层处理运算符优先级(^ 右结合、隐式乘法 2x、2(x+1));求值层把 AST 编译成原生闭包,比每次遍历 AST 快几倍——缩放绘图时每帧要算几千个点,这个优化是值得的。
绘图:Canvas 与”数学坐标系”
Canvas 原生是像素坐标系,y 轴向下。绘图层做的所有事就是一套干净的换算:
屏幕像素 ⇄ 数学坐标
围绕它构建:自适应网格(刻度永远是”好看”的 1-2-5 序列)、悬停读数(鼠标吸附最近的曲线点)、双指缩放与拖拽(移动端同样可用)。
一个容易忽略的细节是不连续函数的处理——tan(x) 和 1/x 在渐近线附近会把两个不相连的分支连成一条竖线。解法很朴素:相邻两点 y 跳变超过阈值就断笔。朴素,但有效。
交互细节:让”解”看得见
工具里我个人最喜欢的功能是「解 · 浏览器」:画两条曲线,交点自动标出来;画一条,与 x 轴的交点(零点)自动标出来。
这把”方程的解”从抽象概念变成了看得见的东西——方程 f(x) = g(x) 的解就是两线相交处的 x 坐标。数学老师讲一节课的事,鼠标悬停一下就懂了。
再加上数值表(列表描点,可导出 CSV)、小学/初中/高中分级示例库,一个”给孩子讲函数”的闭环就齐了。
复盘
- 需求具体,方案才不会跑偏。 “给孩子讲函数”五个字,砍掉了 90% 的功能蔓延
- 单文件不是反工程,是匹配生命周期的工程。 不是所有项目都值得一个脚手架
- 手写解析器没有想象中难。 递归下降 + 运算符优先级,一个周末的量,换来完全可控的错误提示
- 最费时间的不是核心算法,是交互。 缩放手感、悬停吸附、错误定位,这些”体感”占了代码的一半
工具在这里:函数绘图室 →,欢迎拿去给孩子讲一讲什么是函数。