从零构建数学函数自动作图工具 — 技术路线与实战心得
从零构建数学函数自动作图工具 — 技术路线与实战心得
作者:Eddygit
日期:2026-09-02
项目:math-function-grapher
一、为什么做这个工具
在学习和教学中,经常需要快速可视化一个数学函数的图像。现有的工具要么太重(GeoGebra、Desmos 需要联网),要么太丑( Wolfram Alpha 界面复杂),要么不够灵活。
于是我想做一个纯前端、零依赖、开箱即用的小工具:输入函数表达式,立刻看到曲线。
二、技术路线
2.1 技术选型
| 维度 | 选择 | 理由 |
|---|---|---|
| 渲染方式 | Canvas 2D | 性能优于 SVG,适合大量点绘制 |
| 框架 | 无框架 | 零依赖,一个 HTML 文件即可运行 |
| 表达式解析 | Function 构造器 |
比 eval 安全,比手写解析器简单 |
| 样式 | 原生 CSS | 不引入 Tailwind 等框架,保持极简 |
| 字体 | 系统字体 | 无需加载外部字体 |
2.2 架构设计
整个工具分为三个核心类:
┌─────────────────────────────────────────┐
│ App(控制器) │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ MathParser │ │ GraphRenderer │ │
│ │ (表达式解析) │ │ (Canvas 渲染) │ │
│ └─────────────┘ └──────────────────┘ │
└─────────────────────────────────────────┘
MathParser 负责将用户输入的数学表达式(如 sin(x) + x^2)编译为 JavaScript 可执行函数。
GraphRenderer 负责在 Canvas 上绘制坐标系、网格、刻度、函数曲线,并处理交互事件。
App 是顶层控制器,管理函数列表、UI 事件绑定、预设函数等。
2.3 表达式解析策略
这是整个工具的核心难点。我没有选择手写递归下降解析器,而是用了一个"取巧"的方案:
// 用户输入: "2*sin(x) + x^2"
// 1. 替换 ^ 为 **
// 2. 替换函数名: sin → Math.sin, cos → Math.cos, ...
// 3. 替换常量: pi → Math.PI, e → Math.E
// 4. 编译: new Function('x', `return (${code});`)
为什么不用 eval? eval 会访问当前作用域的所有变量,安全风险更大。Function 构造器创建的函数只能访问全局作用域,加上 "use strict" 进一步限制。
为什么不用 math.js 等库? 因为目标是零依赖。对于常见数学函数,Math 对象已经够用了。
三、实战经验
3.1 渐近线处理
这是开发中遇到的最有意思的问题。tan(x) 在 π/2 附近会趋向无穷,如果直接连线,会在画布上画一条竖直的线。
解决方案:检测相邻两点的 Y 坐标差,如果超过画布高度的 80%,就断开曲线:
if (prevPy !== null && drawing && Math.abs(py - prevPy) > this.height * 0.8) {
drawing = false; // 断开
}
这个阈值是经验值,不是数学上严格的渐近线检测,但在实际使用中效果很好。
3.2 自适应网格
缩放级别变化时,网格间距需要自适应。如果固定间距,缩放到很大时网格太密,缩放到很小时网格太疏。
解决方案:预定义一组间距值 [0.001, 0.002, 0.005, 0.01, ..., 1000],根据当前缩放级别选择最小像素间距对应的数学间距:
getGridStep() {
const minPixelSpacing = 40;
const mathSpacing = minPixelSpacing / this.scale;
const steps = [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100];
for (const s of steps) if (s >= mathSpacing) return s;
return 1000;
}
3.3 高分辨率屏幕适配
在 Retina 屏幕上,如果不处理 devicePixelRatio,Canvas 会模糊:
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
3.4 缩放以鼠标位置为中心
这是交互体验的关键。缩放时,鼠标指向的数学坐标应该保持不变:
// 记算鼠标位置对应的数学坐标
const mathX = this.pixelToMathX(mx);
const mathY = this.pixelToMathY(my);
// 缩放
this.scale *= factor;
// 调整偏移量,使鼠标位置的数学坐标不变
this.offsetX = mathX - (mx - this.width / 2) / this.scale;
this.offsetY = mathY + (my - this.height / 2) / this.scale;
四、存在问题与优化空间
4.1 当前局限
| 问题 | 严重程度 | 说明 |
|---|---|---|
| 安全性有限 | 中 | Function 构造器仍可执行任意代码,理论上可注入恶意表达式。生产环境应考虑用 math.js 或手写解析器 |
| 不支持隐函数 | 低 | 只能绘制 y = f(x) 形式的函数,不支持 x² + y² = 1 等隐函数 |
| 不支持参数方程 | 低 | 不支持 x = cos(t), y = sin(t) 形式的参数方程 |
| 精度问题 | 低 | 在极端缩放级别下,浮点精度可能导致曲线不光滑 |
| 无导数/积分 | 低 | 目前只支持绘制函数图像,不支持求导、积分等高级功能 |
4.2 优化方向
- 引入 Web Worker:将函数求值放在 Web Worker 中,避免大量计算时阻塞 UI
- 支持隐函数:使用 marching squares 算法绘制隐函数曲线
- 添加导数/积分:数值微分 + 数值积分,在曲线上标注关键点
- 表达式高亮:输入框支持语法高亮,提升编辑体验
- 导出功能:支持导出 PNG/SVG,方便分享和嵌入文档
- 函数拟合:给定数据点,自动拟合函数表达式
- 动画模式:支持参数动画,如
sin(a*x)中 a 随时间变化
4.3 架构优化
如果未来功能增多,可以考虑:
- 引入模块化(ES Modules 或打包工具)
- 使用 TypeScript 增强类型安全
- 引入 math.js 替代手写解析,支持更复杂的表达式
- 使用 WebGL 替代 Canvas 2D,提升大规模绘制性能
五、技术心得
5.1 “取巧"不等于"取陋”
用 Function 构造器做表达式解析是一个"取巧"方案,但它确实解决了问题。在工具类项目中,先解决用户需求,再追求技术完美。如果一开始就手写解析器,可能到现在还没完成。
5.2 Canvas 的坐标转换是核心
所有交互功能(缩放、平移、鼠标追踪)都依赖于数学坐标和像素坐标之间的转换。把这两个转换函数写对,其他功能就是水到渠成:
mathToPixelX(x) { return width/2 + (x - offsetX) * scale; }
pixelToMathX(px) { return offsetX + (px - width/2) / scale; }
5.3 视觉效果影响使用体验
加了 shadowBlur 发光效果后,曲线看起来"高级"了很多。技术工具也可以有美感,暗色主题 + 发光曲线的组合让这个工具看起来不像一个"作业项目"。
5.4 零依赖的价值
整个项目只有 3 个文件,不需要 npm install,不需要构建工具,双击 HTML 就能用。这种"开箱即用"的体验在工具类项目中非常重要。
六、总结
这个项目虽然小,但涵盖了前端开发的几个核心能力:
- DOM 操作与事件处理(交互逻辑)
- Canvas 绘图(渲染管线)
- 数学建模(坐标转换、渐近线检测)
- UI 设计(暗色主题、响应式布局)
最大的收获是:好的工具不是功能最多的,而是把核心功能做到极致的。输入函数 → 看到曲线,这个闭环做到了,工具就有价值了。
本文为原创技术博客,转载请注明出处。
- 点赞
- 收藏
- 关注作者
评论(0)