前阵子我在做一个在线数学函数画图工具支持2D和3D模式简单说就是用户在网页里输入一个数学表达式比如sin(x)*cos(2*x)或者z exp(-x^2-y^2)浏览器就能直接把它渲染成可缩放、可拖拽、可旋转的曲线或曲面。这个工具的核心价值是把“函数表达式”这种抽象的数学语言变成肉眼可以直接观察的视觉对象。对老师来说可以做课堂演示对学生来说可以验证自己对函数图像的理解对做前端的朋友来说更是一个值得拆解的典型项目它同时涉及字符串解析、数值计算、图形渲染、交互优化、工程部署难度梯度非常友好。这篇文章我会从需求定位、解析方案、2D绘制、3D绘制、性能优化、问题排查六个维度把整个项目从零到一的实现思路和踩坑过程都交代一遍。1. 项目定位与核心设计思路1.1 这个工具到底要解决什么问题在动工之前我先把需求收敛了一下。市面上的计算器软件能画图但要么需要安装要么只支持2D要么交互不顺手。我做这个工具的目标很明确打开浏览器就能用不做任何安装动作同时把2D和3D能力都打包进去。它的核心使用路径是这样的用户输入一个以x为自变量的函数2D模式或者以x, y为自变量的函数3D模式工具先解析这段字符串把它变成计算机可以重复执行的对象然后在指定区间内采样最后把采样结果绘制成图形。整个过程必须在一两秒内完成用户拖拽、缩放时还要保持流畅。我列了一份最小功能集作为第一版的边界支持常见数学函数三角函数、对数、指数、幂、绝对值、开方、取整。支持自定义颜色曲线/曲面支持同时绘制多条2D曲线。2D支持拖拽平移和滚轮缩放3D支持鼠标拖拽旋转、缩放。输入非法表达式时必须给用户友好提示而不是页面直接白屏。部署为纯前端静态服务不需要后端参与计算。1.2 技术选型为什么不是 Matplotlib也不是纯手写 SVG选型阶段我对比了好几条路。最自然的想法是后端用 Python 的 Matplotlib 直接出 PNG 图返回。这个方案其实最先被我否决了。原因有三Matplotlib 生成的图比较重渲染风格偏学术和在线交互工具的气质不搭每画一次图就要一次后端请求和进程开销在并发和高频拖拽场景下扛不住而且 Matplotlib 输出的静态图片完全没法做缩放和旋转交互性几乎为零。另一个想法是前端用 SVG 手写曲线我也可以做到但 SVG 的节点数量一旦上去DOM 树会非常臃肿拖拽和缩放时的重绘代价太大。2D 绘图最终选了 Canvas3D 绘图则根据定制程度选了 three.js 作为底层渲染引擎。我这里做了一张对照表方便大家理解我为什么这么选方案交互能力性能定制自由度学习成本后端 Matplotlib 输出图片差差低低前端 SVG中等中中低前端 Canvas 2D好高高中Plotly.js 3D好中中低three.js 3D好高高高值得一提的是Plotly.js 的 3D surface 上手极快而且内置了坐标轴和配色如果只是想快速做个原型它比我最终方案更合适。我最后选择 three.js是因为我希望后期加入自定义光照、任务化等高阶定制能力时不用推翻重写。2. 最关键的一环函数表达式解析与求值2.1 字符串输入为什么不能直接“画”很多人第一次做这类工具时会忽略一个事实用户在输入框里敲的是字符串它本身只是一串字符不具备任何数学含义。你必须先把它解析成有结构的对象才能对它反复求值。解析数学表达式本质上是做三件事词法分析、语法分析、语义求值。词法分析负责把字符串切成一个个 token比如sin、(、x、、1.5语法分析负责把这些 token 按照数学运算优先级组成一棵语法树语义求值负责在这棵语法树上代入自变量的值算出最终结果。安全问题是这里的红线。在任何情况下都不能直接对用户输入执行全局eval。用户一旦输入类似x; while(true){}的字符串页面会被直接卡死如果环境里恰有可利用的接口还可能造成更严重的事故。所以解析器的核心原则是白名单制只接受数字、自变量x/y、运算符和内置函数名其他字符一概视为非法。2.2 自研解析器的最小实现如果需要完全掌控行为可以自己实现一个最简解析器。我第一版写的就是这种方式核心思路是调度场算法把中缀表达式转成后缀表达式逆波兰式然后用一个栈求值。举例来说表达式sin(x) * cos(2*x)会被分解成这样的 token 流sin ( x ) * cos ( 2 * x )接着按运算符优先级压栈、弹栈最终转换成后缀表达式x sin 2 x * cos *求值时从左到右扫描遇到数值和变量就压栈遇到函数和运算符就从栈里弹出操作数计算结果再压回去。这个过程非常简单我自己用一个数组当栈就实现了。不过自研解析器只适合作为学习用途真实项目里我后面换成了 math.js因为它已经处理了大量边界情况比如^的右结合性、隐式乘法2x是否合法、复数支持、多字符函数名识别等等这些细节自己从零实现很容易漏。2.3 用 math.js 接管求值时的两个坑math.js 本身是个功能完整的数学库用起来很简单。先parse再compile得到可执行函数最后调用时带上{x: 1.5}这样的作用域变量就行。但有两个坑我必须提醒一下。第一math.js 默认把log当作自然对数还是常用对数不同版本、不同配置下行为不一致。这是一个让用户一脸懵的经典问题输入log(100)有人期望得到2有人期望得到4.605...。我的做法是在配置里显式设置math.log的底数并在界面提示“使用log10表示常用对数、ln表示自然对数”避免歧义。第二math.js 允许的语法比用户习惯的更宽松但也可能更严格。比如2sin(x)这种隐式乘法在新版本里默认能解析可sin x不带括号就不一定行。我的方案是在输入框下面放一个语法小贴士把常见写法列出示例用户照抄就行。与其跟解析器搞对抗不如降低学习成本。3. 2D 绘图从网格到曲线的完整细节3.1 坐标系变换与自适网格间距2D 绘图的本质是把数学坐标系里的点映射到 Canvas 像素坐标。假设当前可视区间的 x 范围是[xMin, xMax]y 范围是[yMin, yMax]画布宽高是width和height那映射公式就是screenX (x - xMin) / (xMax - xMin) * width screenY height - (y - yMin) / (yMax - yMin) * height这里有个隐藏问题网格线间距绝不能写死。如果可视范围变成跨度几十万的大区间网格间距还设成 1那网格线会密成一片直接糊掉反过来如果缩放到小区间间距设成 10 又会什么都没有。我采用的是“优秀刻度”算法取当前跨度除以目标网格线数量比如 8 到 10 条然后把它向下对齐到最接近的1 / 2 / 5 × 10^n这个数列上。举一个具体例子如果 x 跨度是 37目标约 10 条网格线那么初步间距是 3.7向下对齐到 2.5再细分到 2。间距是 2网格线在-10, -8, -6...10处绘制视觉上既不太密也不太疏而且刻度值都是好读的数。3.2 断点、渐近线和采样步长三个隐藏的坑2D 绘制的最大坑不是分辨率而是不连续函数。最典型的是tan(x)在π/2 kπ处有竖直渐近线。如果你用均匀步长采样并直接把这些点连成折线就会看到一条竖线从图形底部直穿顶部完全没法看。处理思路是检测“跳跃”。我保存上一个采样点和当前采样点的 y 值如果两者差的绝对值超过了某个阈值比如画面高度的十倍就判断此处不连续把之前的路径收尾从当前点重新开始新路径。阈值选择不能太敏感否则1/x在 x 接近 0 时的正常陡峭变化也会被切断也不能太迟钝否则渐近线还是会出现。还有一类问题是局部极值被遗漏。比如sin(100 * x)频率极高如果采样间隔太大相邻两个采样点之间可能已经振荡了好几圈绘制出来是一条完全失真的低频扭曲曲线。处理办法是采样点数量与可视区间的宽度联动可视区间越宽需要的采样点越多但不能无限增加否则性能撑不住。我做了个折中目标采样点达到一定上限后改用自适应细分在相邻采样点的 y 值变化超过阈值时在两点的中间再补采一个点最多补两层。3.3 交互操作与重绘调优2D 交互包括鼠标拖拽平移、滚轮缩放、悬停显示坐标值。拖拽平移的本质很简单记录鼠标按下的起始位置移动时把位移量换算成数学坐标的偏移再更新可视区间并重绘。这里的性能优化有个小技巧解析一次表达式只需一次函数对象一旦编译好平移和缩放时直接重新采样就行完全不用重新解析。所以我把整个流程分成两层编译层和执行层。编译层处理输入字符串变成函数对象用户每次输入结束触发一次执行层在拖拽和缩放时频繁触发只做采样和绘制不碰字符串。重绘本身也要控制节奏。滚动条事件触发频率极高远高于屏幕刷新率如果每次事件都做全套重绘CPU 会白忙活。我用了一个简单的节流函数配合requestAnimationFrame确保一次屏幕刷新周期最多做一次重绘。对于需要频繁重绘的拖拽场景还额外降低了辅助元素的绘制频率比如网格线没有变化时可以跳过不画只重绘曲线本身。4. 3D 绘图从 z 矩阵到可旋转的曲面4.1 快速方案用 Plotly.js 搭一个能用的 surface如果你想要一个投入产出比最高的 3D 方案我强烈建议先考虑 Plotly.js。它实现曲面图只需要提供三个数据x 数组、y 数组、以及一个二维 z 矩阵其中z[i][j]表示(x[i], y[j])处的函数值。写出来大概是这个样子# 后端生成 z 矩阵 xs [-2 4*i/40 for i in range(41)] ys [-2 4*j/40 for j in range(41)] zs [[math.sin(math.sqrt(x*x y*y)) for x in xs] for y in ys]前端用Plotly.newPlot传入type: surface即可。它会自动生成坐标轴、颜色条、旋转缩放交互甚至鼠标悬停时还能读取具体数值对原型阶段来说体验是非常完备的。但 Plotly.js 的问题也在那里包体很大首屏渲染较慢颜色配色是系统默认的与工具自身的视觉风格不统一而且对底层的网格精度、光照、自定义着色器几乎没有控制权。所以它适合做验证不适合做长期项目。4.2 定制方案用 three.js 生成参数曲面网格three.js 的实现思路和 Plotly 完全不一样。它没有一个SurfaceGeometry可以直接用你需要手动构建几何体。做法是把 x-y 平面切分成N x N的网格对每个顶点计算(x, y, f(x, y))然后把这些顶点按顺序组成三角形面片。代码骨架长这样const segments 120; const geometry new THREE.BufferGeometry(); const vertices new Float32Array((segments 1) * (segments 1) * 3); for (let i 0; i segments; i) { for (let j 0; j segments; j) { const u i / segments; const v j / segments; const x (u - 0.5) * range; const y (v - 0.5) * range; const z fn(x, y); const idx (i * (segments 1) j) * 3; vertices[idx] x; vertices[idx 1] y; vertices[idx 2] z; } } geometry.setAttribute(position, new THREE.BufferAttribute(vertices, 3)); geometry.setIndex(buildTriangles(segments));法线的计算是这里最有意思的部分。最简单的做法是调用geometry.computeVertexNormals()three.js 会自动平均相邻面的法线效果尚可。但当曲面变化剧烈时顶点数不够会看到明显的棱角。要更平滑的效果可以用解析方法来如果z f(x, y)把曲面看作隐式曲面φ z - f(x, y) 0它的梯度(-∂f/∂x, -∂f/∂y, 1)就是法向量方向解析算出两个偏导数后填充法线缓冲光照质量会明显提升。4.3 配色和光照为什么默认色带会误导3D 曲面里颜色往往代表 z 值大小但它太容易误导人了。系统默认的从蓝到黄配色里蓝色给人的感觉是“冷”的黄色是“暖”的观众会下意识把蓝色当作低处、黄色当作高处但如果函数本身在低处有尖锐变化或者你想强调零点附近的细节这种三度色带是完全不够用的。我对配色策略做了两个改进。第一默认改用“蓝到白到红”的双色渐变把 z0 设为白色正负两边用冷色和暖色区分这样函数正负分布能一眼看清。第二把颜色映射范围从线性改成基于分位数拉伸如果 z 值分布特别集中比如大部分都在 -1 到 1 之间少数超出到 100线性映射会让大部分区域颜色都挤在中间同一色阶里失去区分度。分位数映射能把颜色差异使用在大多数点所在的区间但它的代价是颜色与 z 值不再是线性对应关系图例标注必须额外说明。光照方面也不能忽略。纯MeshPhongMaterial在没有光源的情况下看起来是一团黑的至少需要加一个DirectionalLight和一个AmbientLight。我的经验是环境光不要超过 0.4否则材质受光面和非受光面缺少对比模型的立体感会被压扁。5. 性能优化与工程部署5.1 采样计算放到 Web Worker别卡 UI 线程性能问题在这个项目里比想象中来得更早。函数表达式本身计算不慢慢的是极端情况下的非线性复杂度。比如用户输入一个包含多重积分模拟、自定义函数嵌套的表达式采样一次可能要几百万次浮点运算直接在 UI 线程里跑拖拽时页面会掉帧严重时会直接触发浏览器“页面无响应”的崩溃提示。我的解决方案是把编译和采样整体搬进 Web Worker。Worker 里能接收字符串、能执行同样的 math.js 编译和求值但结果不能直接传对象必须序列化成Float32Array。这里有一个很容易踩的坑在 Vue 或者 React 里Float32Array会被结构化克隆成普通数组再转回来会有额外开销。正确的做法是把 worker 返回的Float32Array.buffer作为transferable对象直接转移过来数据不用复制性能开销几乎为零。5.2 输入防抖与按需加载输入框事件是高频率触发的。用户敲字还没停下来你的编译流程就已经跑了十几次这既浪费算力也让页面表现很卡。实现很简单等用户停止输入 300 毫秒后再触发编译和重绘。这个 300 毫秒不是随手定的它低于用户能感知的明显停顿又足够避免大多数打字过程中的重复触发。按需加载则是针对 3D 模块的。three.js 库本身就超过几百 KB如果用户只是打开 2D 模式也要把它下载下来白白浪费时间。我用动态import()把 3D 相关代码拆成一个独立的 chunk只有在用户第一次切换到 3D 标签时才加载。同一思路也用在 Plotly.js 上切过去才加载哪怕加载过程多花一点时间也比首屏多 1 秒强。5.3 静态资源部署细节既然这个工具没有后端部署基本就是一套静态资源。但静态资源也有讲究。所有 JS、CSS 文件都要开 gzip 压缩three.js 这类大库压缩前后体积差异非常明显。同时要在响应头里加长缓存时间并且文件名带上内容哈希这样既能让老用户不重复下载也不会在发布新版本时让浏览器拿到旧文件。我额外做了一件事把首页的初始 JS 包控制在能够在小水管网络下快速打开的程度做法就是把 2D 和 3D 的代码与框架代码彻底拆开核心逻辑单独打包前还要做一次依赖体积体检看到哪个重依赖大到不合理的就换掉。# nginx 静态资源配置示例 location /assets/ { gzip on; gzip_types application/javascript text/css image/svgxml; expires 30d; add_header Cache-Control public, immutable; }6. 常见问题与排查技巧6.1 问题速查表我在开发和内测阶段收集了一堆用户反馈整理出了一张问题速查表你可以对照自己的场景直接用现象可能原因排查方向输入x^2报错用户把^当成了 JavaScript 中的按位异或或解析器默认不认^在输入提示中标注^表示幂math.js 中确认是否启用^作为幂运算曲线部分区域空白函数定义域限制例如sqrt(x-1)在 x1 时无定义采样时跳过 NaN 即可同时把定义域边界提示给用户出现了竖直长线采样点横跨渐近线把正负无穷的两端连在一起检测相邻采样点 y 值跳变超过阈值就断开路径3D 曲面有大量三角形裂缝函数返回 NaN导致部分顶点为空或被丢弃生成几何体时跳过无效点并保持索引结构完整拖拽旋转时明显卡顿顶点数量过大且没有放入 Worker 计算降低默认网格精度或把采样移到 Worker移动端无法旋转 3D 图CSS 的 touch 事件被浏览器默认行为拦截容器上加touch-action: none并在touchstart调用preventDefault页面长时间白屏用户输入了死循环表达式或超长表达式设置编译超时时间或废弃超时任务输入字符数上限6.2 三个踩过的坑逐一说透第一个坑是log函数的底数混淆。math.js 里log(x)默认是自然对数但大量用户是从中文互联网搜索过来的习惯用lg表示常用对数。这个混淆会导致用户怎么看怎么觉得函数图像不对。我最后采用的处理方式是在输入框右侧放一个实时计算预览不管用户输入什么表达式都先计算它在 x1 处的值并显示出来用户一眼就能发现自己的写法是否符合预期。第二个坑是异步 worker 里的 math.js 初始化。math.js 在主线程初始化过之后直接把它扔进 worker 是拿不到对象的你必须把整个表达式字符串传过去在 worker 里重新import、重新parse。这个细节调试起来很隐蔽因为主线程一切正常worker 返回的总是一片空白。排查了一天我才意识到是两个线程各自环境隔离的问题。第三个坑是 3D 曲面 z 值范围过大时的颜色糊化。默认颜色映射线性拉伸时如果峰值集中在很小一块区域整张曲面大部分是中灰色完全看不出结构起伏。我后来把颜色映射改成按正态分布的比例拉伸又加了鼠标悬停时的数值提示人和视觉这才协调起来。这个经验也解释了为什么明明“能画出来”的图用户仍然觉得不好看——问题不在数据在颜色怎么编码数据。6.3 如何提升实际使用体验工具类项目做到最后拼的不只是功能更是体验细节。我后来加了一些“不算功能但很提升好感”的小设计多函数同屏时图例不仅显示颜色点击图例还能临时隐藏对应曲线3D 模式下提供几个预设观察角度按钮一键切换到顶视图、侧视图、标准视图还有每当绘制成功就在 URL 的 query 参数里写入当前函数表达式这样用户可以把带表达式的链接直接粘贴给同事对方打开就是同一个图。这些小功能的实现成本都不高但对“在线工具”这个场景非常有效——它们让页面从“一个能算画的页面”变成“一个可以分享和协作的工作台”。我个人在实际操作中最深的体会是这类项目里解析环节的稳定性决定了用户留存渲染环节的细节决定了好不好看而把你自己的工作流理顺才是决定能不能长期维护下去的关键。如果以后继续扩展我打算把隐式函数f(x, y) 0形式和参数方程曲线也加进来那样能覆盖的数学场景会更完整。最后再分享一个实用小技巧给 Canvas 设置devicePixelRatio缩放然后让绘制坐标系与物理像素对齐曲线边缘的锯齿会明显减少这比任何抗锯齿滤镜都管用。