原生JS Canvas动态绘制函数曲线:从坐标系到高清交互实现
本文详解如何使用原生JavaScript在Canvas中动态绘制函数曲线。内容涵盖坐标系边界计算、网格刻度优化、浮点数处理、save/restore状态管理、鼠标交互辅助线、平移缩放逻辑以及requestAnimationFrame动态绘制。最后解决Canvas高清渲染问题,提供完整无依赖库的实现思路与代码示例。
前言
本文直接切入核心主题。阅读后你将掌握以下效果:一个清晰且流畅的函数曲线展示界面(见下方示例)。

具体实现步骤可拆解为以下几个部分:
- 绘制坐标系
- 绘制多条函数曲线
- 绘制辅助线和坐标点
- 支持平移、缩放
- 支持动态绘制曲线
- 实现高清渲染。接下来将逐一攻克这些难点,你可以参考示例代码(传送门),全程不依赖任何第三方库。
第一步:绘制坐标系
绘制函数图像的前提是建立坐标系,即网格系统。虽然绘制网格的基本思路是画出横向和纵向的线条,但在实际实现中需考虑诸多细节。绘制静态且无刻度的网格相对简单,但加入刻度后复杂度显著增加。以下列举实现过程中可能遇到的关键问题。
1、如何确定 x 轴和 y 轴的边界值
确定边界值是后续所有操作的基础,因为函数绘制依赖于 x 轴的边界范围(作为参数传入)。对于 y 轴,通常为了保持网格为正方形,使其取值范围与 x 轴保持一致。若未传入 x 轴的两个边界值(leftX、rightX),系统将采用默认值 [-canvas宽度的一半/100, canvas宽度的一半/100]。鉴于画布宽高通常为数百像素,除以 100 是合理的简化处理,除以 10 亦可接受。必须强调边界值的重要性,因为后续所有绘制逻辑均基于此。若缺失边界值,绘制工作将难以开展。
2、不是传入多少网格数就是多少网格
网格刻度需进行适当的取整处理,例如 10、5、1、0.5、0.2、0.1 等。这意味着网格数量并非直接等于传入的数值。举例而言,若 x 轴取值范围为 (-5,5),网格数量为 10,则恰好生成 10 个网格,刻度整齐(如 -5, -4, -3...);若网格数为 9,系统仍会生成 10 个网格,刻度保持整齐(-5, -4, -3...),而非生成 (-5, -3.9, -2.8) 这类非整数刻度。因此,网格大小需经过特定算法计算,以下提供一种解法。
/**
* 计算每一个网格的宽高大小,注意并不是 gridCount 是多少就会有多少网格
* 因为我们的坐标刻度通常都是 10、5、1、0.5、0.1 这个样子,大部分都是以 2、5、10 的倍数
* @param len x 或 y 轴代表的长度
* @param gridCount 网格个数
*/
calcGridSize(len: number, gridCount: number): number[] {
let gridWidth = 1;
// 应该保留几位小数,避免浮点数
let fixedCount = 0;
// 事实上,要是图方便的话,你也可以直接用 unitX 来当做网格大小,不过记得要取整
let unitX = len / gridCount;
// 而这里呢,我们需要找到离 unitX 最近的(稍微偏整数的)值
// 首先要找到离 unitX 最近的 10 的整数倍,如 0.01、0.1、1、10、100
while (gridWidth < unitX) {
gridWidth *= 10
}
while (gridWidth / 10 > unitX) {
gridWidth /= 10
fixedCount++;
}
// 看看能不能再划分一次,如(/5 得到 0.02、0.2、20)、(/2 得到 0.05、5、50)
if (gridWidth / 5 > unitX) {
gridWidth /= 5;
fixedCount++;
} else if (gridWidth / 2 > unitX) {
gridWidth /= 2;
fixedCount++;
}
// 因为 x 轴长度和 y 轴的长度是一样的,所以可以这样赋值
return [gridWidth, gridWidth, fixedCount];
}
3、如何让坐标原点位于画布中心
此概念较为抽象,需结合图像理解。我们需要避免网格偏向一侧的情况(注意观察四周网格)。

而是实现网格居中分布的效果(注意观察四周网格,并与上图对比)。

理解这一点后,可以从中心向上下、左右绘制网格,但为简化计算,仍采用从左到右的绘制方式。结合代码与图像说明会更清晰。
drawGrid() {
const { width, height, leftX, rightX, leftY, rightY, xLen, yLen, gridCount, ctx2d } = this;
ctx2d?.save();
// 注意这里我们是将网格数作为配置项,而不是网格的宽高大小
const [gridWidth, gridHeight, fixedCount] = this.calcGridSize(xLen, gridCount);
// 由于计算会产生浮点数偏差,所以要控制下小数点后面的数字个数
this.fixedCount = fixedCount;
// 从左到右绘制竖线
for (let i = Math.floor(leftX / gridWidth); i * gridWidth < rightX; i++) {
// 绘制像素点 / 整个画布宽度 = 实际 x 值 / 实际表示的 x 轴长度
const x = (i * gridWidth - leftX) / xLen * width;
// i = 0 就说明是 y 轴,颜色加深
const color = i ? '#ddd' : '#000';
this.drawLine(x, 0, x, height, color)
this.fillText(String(this.formatNum(i * gridWidth, this.fixedCount)), x, height, this.fontSize, TextAlign.Center);
}
// 绘制横线也是和上面一样的方法,就是要注意画布的 y 轴向下,需要用 height 减一下,或者用 scale(1, -1);
for (let j = Math.floor(leftY / gridHeight); j * gridHeight < rightY; j++) {
let y = (j * gridWidth - leftY) / yLen * height;
y = height - y;
const color = j ? '#ddd' : '#000';
this.drawLine(0, y, width, y, color);
this.fillText(String(this.formatNum(j * gridHeight, this.fixedCount)), 0, y, this.fontSize, TextAlign.Middle);
}
ctx2d?.restore();
}
// 保留 fixedCount 位小数,整数不补零
formatNum(num: number, fixedCount: number): number {
return parseFloat(num.toFixed(fixedCount));
}

此外,刻度需绘制在边缘而非轴线上,原因是在轻微移动或放大画布时,坐标轴可能移出视线,导致刻度不可见。因此,将刻度预先置于边缘更为稳妥。
4、刻度总是会有浮点数
刻度和坐标点的计算涉及加减乘除,不可避免地会遇到前端常见的浮点数精度问题,因此需进行处理,如保留指定小数位数。处理浮点数运算主要有两种思路:
- 转换为整数进行计算(先将数字乘以 10 的 n 次方,最后再除以 10 的 n 次方)。
- 转换为字符串进行计算(类似小学数学竖式计算,如乘法中分别与个位、十位、百位...相乘后相加)。
第二步:画函数曲线
绘制函数曲线的思路直观:以直代曲,通过连接多个点形成曲线。首先通过 y = fn(x)(fn 示例如 sin(x)、1 / x)计算函数坐标点,然后将这些坐标点转换为画布上的像素点,最后进行绘制。需注意以下细节:
- 点的数量越多,曲线越平滑。可通过参数 steps 控制,默认值为 1。
- 计算出的点无需保存,因为移动或缩放操作会改变所有数值。
- 在绘制每条函数曲线前务必调用 beginPath,否则会影响下一条曲线的样式(如颜色、线宽)。
- 对于画布外的点无需绘制,直接使用 moveTo 即可(而非使用 continue 跳过)。
/** 绘制函数曲线,就是用一段段直线连起来 */
drawFn() {
const { width, height, leftX, leftY, xLen, yLen, steps, ctx2d } = this;
if (!ctx2d) return;
ctx2d.save();
this.fnList.forEach(fn => {
ctx2d.strokeStyle = (fn as any).color;
ctx2d.beginPath();
for (let i = 0; i < width; i += steps) {
// 小小的公式推导:像素点 / 画布宽 = x / 实际表示的 x 轴长度
const x = i / width * xLen + leftX;
let y = fn(x);
if (isNaN(y)) continue;
// 换算到具体绘制点
y = height - (y - leftY) / yLen * height;
// 在画布之外是不用绘制的所以用 moveTo 即可
if (i === 0 || y > height || y < 0) {
ctx2d.moveTo(i, y);
} else {
ctx2d.lineTo(i, y);
}
}
ctx2d.stroke();
});
ctx2d.restore();
}
需特别强调在绘制前后务必调用 save 和 restore,且这两个方法必须配对使用。Canvas 基于状态管理,若设置画笔为红色而不使用 save 和 restore,该红色设置将影响后续所有操作。因此,建议养成如下习惯(尤其在涉及平移、旋转和缩放时,否则可能导致状态混乱)。
// 初始状态
ctx2d.save();
// 在这里设置各种状态1
ctx2d.save();
// 在这里设置各种状态2
ctx2d.restore();
// 这里是状态1
ctx2d.restore();
// 这里是初始状态
之所以可以嵌套使用,是因为 Canvas 使用栈(仅支持 push 和 pop 的数组)来管理状态,结构类似 [初始状态集合、状态1集合、状态2集合]。save 将当前状态追加至数组末尾,restore 则取出数组最后一项作为当前状态。集合可理解为一个包含 strokeStyle、fillStyle、lineWidth 等属性的大对象。
第三步:绘制辅助线和交点坐标
此步骤较为简单。首先获取鼠标相对于画布的坐标,然后将其换算为曲线的 x 值,代入每个函数计算 y 值。若 y 为有效值,则在点 [x, y] 处绘制圆形。注意对所有数值进行格式化。
drawSubLine(canvasPos: Point) {
const ctx2d: CanvasRenderingContext2D | null = this.ctx2d;
if (!ctx2d) return;
const { width, height } = this;
const { x, y } = canvasPos;
// 先重新绘制网格和函数曲线
this.draw();
// 绘制辅助线
ctx2d.save();
this.drawLine(x, 0, x, height, '#999',true);
this.drawLine(0, y, width, y, '#999', true);
ctx2d.restore();
// 绘制鼠标点
const centerRectLen: number = 8;
this.strokeRect(x - centerRectLen / 2, y - centerRectLen / 2, centerRectLen, centerRectLen);
const actualPos = this.canvasPosToFnVal(canvasPos);
// 绘制曲线和辅助线的交点坐标
this.handleCrosspoint(actualPos.x);
}
handleCrosspoint(x: number) {
const pointList: Point[] = this.checkCrosspoint(x);
pointList.forEach(point => {
const { x, y } = this.fnValToCanvasPos(point);
this.fillCircle(x, y, 4, 'red');
this.fillText(`[${this.formatNum(point.x, this.fixedCount + 1)}, ${this.formatNum(point.y, this.fixedCount + 1)}]`, x, y, this.fontSize);
});
}
绘制辅助线时通常会重新绘制网格和曲线,此过程可优化。通过分层处理,由于鼠标移动仅改变辅助线和坐标点,可将网格和函数曲线置于下层,辅助线和坐标点置于上层。每次绘制时只需重绘上层即可。
第四步:平移
首先需添加事件监听。
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this), false);
// 也可以把 mouseup 和 mousemove 这两个监听写在 mousedown 的回调中
document.addEventListener('mouseup', this.handleMouseUp.bind(this), false);
document.addEventListener('mousemove', this.handleMouseMove.bind(this), false);
平移是众多操作中相对简单的,其核心结果是改变 x 轴和 y 轴的边界值。因此,只需重新计算边界值并调用绘制函数即可。通过 startPos 和 endPos 计算平移距离,实现方式如下。
// 返回距离画布左上角的点
viewportToCanvasPosition(e: MouseEvent): Point {
const { clientX, clientY } = e;
const { top, left } = this.canvas.getBoundingClientRect();
const x = clientX - top;
const y = clientY - left;
return new Point(x, y);
}
// 记录鼠标按下的点
handleMouseDown(e: MouseEvent) {
const canvasPos: Point = this.viewportToCanvasPosition(e);
this.state.startPos = canvasPos;
}
// 计算平移距离,更新 leftX 和 rightX 的值,然后重新绘制
handleMouseMove(e: MouseEvent) {
const canvasPos: Point = this.viewportToCanvasPosition(e);
if (!this.state.startPos) return;
document.body.style.cursor = 'move';
this.state.endPos = canvasPos;
const { width, height, xLen, yLen, state: { startPos, endPos } } = this;
// 算出平移距离
const dx = (endPos.x - startPos.x) / width * xLen;
const dy = (endPos.y - startPos.y) / height * yLen;
// 更新边界值
this.leftX -= dx;
this.rightX -= dx;
this.leftY += dy;
this.rightY += dy;
this.xLen = this.rightX - this.leftX;
this.yLen = this.rightY - this.leftY;
this.draw();
this.state.startPos = canvasPos;
}
// 还原拖拽前的状态
handleMouseUp(e: MouseEvent) {
this.state.startPos = null;
this.state.endPos = null;
document.body.style.cursor = 'auto';
}
需注意,若向右平移,上述代码中的 dx 应为正值,但刻度值会减小,因此计算时应使用减号。其他方向同理。
第五步:缩放
首先需监听滚轮事件。
this.canvas.addEventListener('mousewheel', this.handleMouseWheel.bind(this), false);
缩放的本质同样是改变边界值,但推导过程比平移稍复杂,建议自行绘图以理清逻辑。与平移不同,缩放应以当前点为中心进行,且涉及一边增加一边减少。例如,在画布中心放大时,leftX 值应增加,rightX 值应减少。此外,缩放需设置限制,无限大或无限小均无实际意义。以下提供代码和图像示例。
handleMouseWheel(e: Event) {
e.preventDefault();
const event: WheelEvent = e as WheelEvent;
const canvasPos: Point = this.viewportToCanvasPosition(event);
const { deltaY } = event;
const { leftX, rightX, leftY, rightY } = this;
const scale: number = deltaY > 0 ? 1.1 : 0.9;
if (this.isInvalidVal(scale)) return;
const { x, y } = this.canvasPosToFnVal(canvasPos);
// 注意缩放和平移不一样,轴的左右两边一边是加一边是减
this.leftX = x - (x - leftX) * scale;
this.rightX = x + (rightX - x) * scale;
this.leftY = y - (y - leftY) * scale;
this.rightY = y + (rightY - y) * scale;
this.xLen = this.rightX - this.leftX;
this.yLen = this.rightY - this.leftY;
this.draw();
}
/** 缩放过大过小都没啥意义,所以设置下边界值 */
isInvalidVal(ratio: number): boolean {
const { xLen, yLen, MIN_DELTA, MAX_DELTA } = this;
if (ratio > 1 && (xLen > MAX_DELTA || yLen > MAX_DELTA)) return true;
if (ratio < 1 && (xLen < MIN_DELTA || yLen < MIN_DELTA)) return true;
// 上面的判断为什么不直接 (xLen > MAX_DELTA || yLen > MAX_DELTA || xLen < MIN_DELTA || yLen < MIN_DELTA)这样判断呢?
// 因为如果这样判断你会发现缩放到最大和最小的时候,再继续操作都是无效的。
return false;
}
canvasPosToFnVal(canvasPos: Point): Point {
const { width, height, leftX, leftY, xLen, yLen } = this;
const x = leftX + canvasPos.x / width * xLen;
const y = leftY + canvasPos.y / height * yLen;
return new Point(x, y);
}

第六步:动态绘制曲线
既然已掌握函数曲线绘制,动态绘制便不再困难。可使用 requestAnimationFrame、setTimeout 或 setInterval 分段绘制。绘制速度可通过调整时间参数或每次绘制的段数来控制。需注意以下几点:
- 动画期间,整个画布应处于不可操作状态。
- 曲线绘制顺序为一条绘制完毕后再绘制下一条,而非从左到右同时绘制所有曲线(当然,具体实现方式可灵活调整)。
- 部分曲线左侧起始部分可能无图像,导致需等待一段时间后才开始绘制。因此,应跳过无需绘制的点。
第七步:模糊到高清
最终你会发现曲线可能存在模糊现象,这在整个 Canvas 中是常见问题。以上为 JS 前端使用 Canvas 动态绘制函数曲线的示例详解,更多关于 JS Canvas 绘制函数曲线的资料请关注本站其他相关文章!
怎么用 Promise 封装 JavaScript sleep 函数实现异步延时
2026-11-05 17:49:14
什么时候该用JS构造器:与工厂模式、字面量的选择对比
2026-11-05 16:18:22
推荐几款轻量级JS照片墙开源库及对比
2026-11-05 15:56:19
JavaScript递归写法有哪些常见陷阱及避免方法
2026-11-05 14:53:38
JS数组浅拷贝与深拷贝怎么选?场景与方法总结
2026-11-05 14:41:02
