Web前端之Html的canvas实现粒子连线动画与深度解析、粒子连线动画的设计思路与实现细节、时间步积分与性能优化全解析、动态点连线器、DPR适配


效果图

canvas1


canvas2


canvas3


canvas4


canvas5


代码

const id = `idSpecial_${Date.now()}`
// 动态创建 <canvas>,后续插入页面
const canvas = document.createElement('canvas');
// 随机数函数:返回 [min, max] 的浮点数
const getRandom = (min, max) => Math.random() * (max - min) + min;
// 画布、上下文、舞台尺寸、实例变量(初始化为空)
let cvs = undefined;
let ctx = undefined;
let stage = undefined;
let GraphExample = undefined;

// 设置 canvas 的样式类(需自行在 CSS 中定义)
canvas.className = 'p_a zi_n1 l_0 t_0 w_100_ h_100_';
// 设置 id
canvas.id = id;
// 设置一个特殊的 key 值(推荐用 dataset 来存放自定义属性)
canvas.dataset.key = 'keySpecial01';
// 将 canvas 加入页面
document.body.appendChild(canvas);
// 获取页面中的第一个 canvas(也可以直接用上面创建的 canvas)
cvs = document.getElementById(id);
// 获取 2D 绘图上下文
ctx = cvs.getContext('2d');
// 舞台尺寸(逻辑像素,未处理高分屏 DPR)
stage = { w: window.innerWidth, h: window.innerHeight };

/**
 * resizeCanvas()
 * - 根据 devicePixelRatio (dpr) 设置 canvas 的实际像素尺寸(物理像素),同时保持 CSS 尺寸为逻辑像素
 * - 通过 ctx.setTransform(dpr, 0, 0, dpr, 0, 0) 将绘图坐标系缩放回逻辑像素
 *   这样后续所有绘制坐标都以逻辑像素为单位(更方便且与布局一致)
 * - 这样可保证在高 DPI 屏幕上画面清晰而坐标计算简单
 */
function resizeCanvas() {
    const dpr = window.devicePixelRatio || 1;

    // 保持 CSS 显示尺寸(逻辑像素)
    cvs.style.width = window.innerWidth + 'px';
    cvs.style.height = window.innerHeight + 'px';
    // canvas 实际像素按 dpr 放大(避免模糊)
    // Math.max 防止 width/height 变成 0 导致错误
    cvs.width = Math.max(1, Math.floor(window.innerWidth * dpr));
    cvs.height = Math.max(1, Math.floor(window.innerHeight * dpr));
    // 将上下文缩放回逻辑像素坐标系
    // 之后对 ctx 的所有绘制与坐标都以逻辑像素计算(无需每次除以 dpr)
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    // 更新 stage(这是逻辑像素尺寸,用于碰撞和位置限制)
    stage.w = window.innerWidth;
    stage.h = window.innerHeight;
}

/**
 * Point 类代表场景中的一个点(小球)
 * - this.r: 半径(以逻辑像素计)
 * - this.x, this.y: 当前位置(逻辑像素)
 * - this.xSpeed, this.ySpeed: 速度,单位 px / s(逻辑像素每秒)
 * - this.lastDrawTime: 上一次渲染的时间戳(performance.now() 毫秒)
 */
class Point {
    constructor() {
        // 半径,合适可调整(影响碰撞边界与视觉大小)
        this.r = 6;
        // 使用 stage.w/stage.h 初始化点的位置,确保点在可见范围内
        this.x = getRandom(0, stage.w - this.r / 2);
        this.y = getRandom(0, stage.h - this.r / 2);
        // 速度以 px/s(浮点)表示,取值范围 [-50, 50]
        // 这个范围决定点的移动快慢,越大移动越快
        this.xSpeed = getRandom(-50, 50);
        this.ySpeed = getRandom(-50, 50);
        // 初始化为当前时间,避免第一次渲染时大跳跃
        this.lastDrawTime = performance.now();
    }

    /**
     * draw(now)
     * - 接收当前时间戳 now(应为 performance.now())
     * - 计算时间差 duration(秒),根据速度计算位移(速度单位 px/s)
     * - 做边界检测并实现反弹(简单的速度取反)
     * - 绘制圆点并更新 lastDrawTime
     */
    draw(now = new Date()) {
        // 将毫秒转换为秒,便于乘以速度(px/s)
        const duration = (now - this.lastDrawTime) / 1000;

        if (duration > 0) {
            // 基于时间片移动,保证帧率波动时运动仍看起来匀滑(时间步积分)
            let x = this.x + this.xSpeed * duration;
            let y = this.y + this.ySpeed * duration;

            // 边界检测与反弹:
            // 这里把边界定义为 [0, stage.w - r/2] 和 [0, stage.h - r/2]
            // 当超出边界时,将位置夹回边界并翻转速度分量(弹回效果)
            if (x > stage.w - this.r / 2) {
                x = stage.w - this.r / 2;
                this.xSpeed = -this.xSpeed;
            } else if (x < 0) {
                x = 0;
                this.xSpeed = -this.xSpeed;
            }
            if (y > stage.h - this.r / 2) {
                y = stage.h - this.r / 2;
                this.ySpeed = -this.ySpeed;
            } else if (y < 0) {
                y = 0;
                this.ySpeed = -this.ySpeed;
            }
            // 更新位置
            this.x = x;
            this.y = y;
        }
        // 绘制点(白色实心圆)
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
        ctx.fillStyle = 'rgb(255, 255, 255)';
        ctx.fill();
        // 更新绘制时间基准
        this.lastDrawTime = now;
    }
}

/**
 * Graph 类管理所有点与动画循环
 * - pointNumber: 点的个数
 * - maxDis: 两点连线的最大距离(超过则不画线)
 */
class Graph {
    constructor(pointNumber = 30, maxDis = 500) {
        // 初始化点数组
        this.points = new Array(pointNumber).fill(0).map(() => new Point());
        this.maxDis = maxDis;
        this.rafId = null;
        // 将 this._tick 绑定到实例,确保 requestAnimationFrame 回调中的 this 正确
        this._tick = this._tick.bind(this);
    }

    /**
     * _tick(now)
     * - requestAnimationFrame 的回调函数(now 为 DOMHighResTimeStamp)
     * - 这里再次调用 requestAnimationFrame 可以避免丢帧并持续循环
     * - 注意:把 requestAnimationFrame 放在函数开头或结尾都可以,但需要管理 rafId
     */
    _tick(now) {
        // 先注册下一帧,这样即使 _drawFrame 抛错也不会丢失 rafId 控制(但错误需捕获)
        this.rafId = requestAnimationFrame(this._tick);
        // 绘制当前帧
        this._drawFrame(now);
    }

    /**
     * start()
     * - 启动动画循环
     * - 如果已经有 rafId(正在运行)则直接返回,避免重复启动
     * - 重置每个点的 lastDrawTime 为当前时间,避免 resume 时出现大量位移(“跳帧”)
     */
    start() {
        // 已经在运行
        if (this.rafId) return;

        const now = performance.now();

        this.points.forEach(p => p.lastDrawTime = now);
        this.rafId = requestAnimationFrame(this._tick);
    }

    /**
     * stop()
     * - 取消动画循环
     */
    stop() {
        if (this.rafId) {
            cancelAnimationFrame(this.rafId);
            this.rafId = null;
        }
    }

    /**
     * _drawFrame(now)
     * - 清空画布(逻辑像素范围)
     * - 遍历所有点:先绘制点,再与后面的点计算距离决定是否绘线
     * - 为性能考虑:只计算 j > i 的组合,避免重复计算(对称性)
     */
    _drawFrame(now) {
        // 清除当前帧(以逻辑像素为单位)
        ctx.clearRect(0, 0, stage.w, stage.h);

        const pts = this.points;

        for (let i = 0; i < pts.length; i++) {
            const p1 = pts[i];

            // 更新并绘制点
            p1.draw(now);

            // 与后续点配对(避免重复)
            for (let j = i + 1; j < pts.length; j++) {
                const p2 = pts[j];
                const dx = p1.x - p2.x;
                const dy = p1.y - p2.y;
                const d = Math.sqrt(dx * dx + dy * dy);

                // 超过 maxDis 不绘制连线
                if (d > this.maxDis) continue;

                // 绘制连线,透明度与距离成反比(越近越明显)
                ctx.beginPath();
                ctx.moveTo(p1.x, p1.y);
                ctx.lineTo(p2.x, p2.y);
                ctx.closePath();
                // alpha = 1 - d / maxDis,让线条随距离淡出
                ctx.strokeStyle = `rgba(200, 200, 200, ${1 - d / this.maxDis})`;
                ctx.stroke();
            }
        }
    }

    /**
     * destroy()
     * - 停止动画并清理资源(清空画布与点数组)
     * - 在卸载或切换场景时调用,避免内存泄漏
     */
    destroy() {
        this.stop();
        ctx.clearRect(0, 0, stage.w, stage.h);
        // 可选:释放数组引用,帮助垃圾回收
        this.points.length = 0;
    }
}

/**
 * 初始化与事件绑定
 */
function init() {
    // 首次调整画布尺寸(考虑 dpr)
    resizeCanvas();

    // 创建 Graph 并启动动画
    GraphExample = new Graph();
    GraphExample.start();

    /**
     * 页面可见性变化处理:
     * - 当页面切换到后台(document.hidden 为 true)时停止动画,节省 CPU/GPU
     * - 切回前台时重启动画并重置时间基准,避免跳帧
     */
    document.addEventListener('visibilitychange', () => {
        if (document.hidden) {
            GraphExample && GraphExample.stop();
        } else {
            // GraphExample.start 中会重置各点的 lastDrawTime
            GraphExample && GraphExample.start();
        }
    });

    /**
     * 窗口大小变化处理:
     * - 重新设置 canvas 大小并修正点位置,防止点超出新尺寸
     * - 这里采用简单策略:把超出边界的点夹回边界
     * - 可进一步改进为缩放所有点坐标或者生成新点
     */
    window.addEventListener('resize', () => {
        resizeCanvas();
        GraphExample && GraphExample.points.forEach(p => {
            p.x = Math.min(Math.max(p.x, 0), stage.w - p.r / 2);
            p.y = Math.min(Math.max(p.y, 0), stage.h - p.r / 2);
        });
    });

    /**
     * 页面刷新/关闭前清理
     * - 调用 destroy 停止动画并清空画布
     * - 注意:某些浏览器在 beforeunload 中限制异步操作,这里仅做同步清理
     */
    window.addEventListener('beforeunload', () => {
        GraphExample && GraphExample.destroy();
    });
}

// 启动
init();

解析一

目标与整体思路

目标
在全屏 <canvas> 上生成若干“点”(粒子),按各自速度匀速移动,碰到边界反弹;任意两点在距离阈值内画连线,距离越近线越亮;窗口尺寸、页面可见性变化时要自适应与节能;并处理高 DPI(Retina)清晰度问题。


整体思路分三层
1、画布层:动态创建 canvas,适配 DPR,管理逻辑像素与物理像素;
2、粒子层(Point):基于时间步的物理更新 + 绘制;
3、调度层(Graph):requestAnimationFrame 循环、配对计算与事件绑定(resize / visibility / unload)。


知识点 & 技术点速览

01、动态创建元素与自定义数据属性:document.createElement(‘canvas’) + canvas.dataset.key。
02、唯一 ID 生成:const id = `idSpecial_${Date.now()}
03、Canvas 2D 基础:getContext(‘2d’)、clearRect、arc、fill、moveTo/lineTo/stroke。
04、高 DPI 适配(Retina):逻辑像素 vs 物理像素、devicePixelRatio、ctx.setTransform(dpr,0,0,dpr,0,0)。
05、帧率无关的运动:使用真实时间增量(Δt,秒)更新位置,避免高/低帧率导致运动不一致。
06、requestAnimationFrame:动画主循环,回调 now 为高精度时间戳(DOMHighResTimeStamp)。
07、边界反弹:越界夹取 + 速度分量取反。
08、粒子连接:O(n²) 配对、距离阈值过滤、基于距离的 alpha 渐隐。
09、生命周期管理:start/stop/destroy,避免多次启动与内存泄漏。
10、页面可见性节能:visibilitychange 时暂停 / 恢复。
11、尺寸变化自适应:resize 时重设画布、修正粒子位置。
12、卸载清理:beforeunload 同步清理资源。


代码结构与运行流程

初始化 init()
1、resizeCanvas():根据 DPR 设置 cvs.width/height 与 CSS 尺寸,ctx.setTransform(…) 把坐标系缩放回“逻辑像素”。
2、GraphExample = new Graph() 并 start():进入 rAF 动画循环。
3、绑定 visibilitychange / resize / beforeunload 事件。


Graph.start() → rAF 循环
1、记录每个点的 lastDrawTime = performance.now(),避免恢复时“跳帧”。
2、requestAnimationFrame(this._tick) 进入 _drawFrame(now)。


每帧 _drawFrame(now)
1、clearRect(0,0,stage.w,stage.h) 清屏(逻辑像素单位)。
2、遍历点:p.draw(now) 更新位置并画圆。
3、双重循环配对(j>i):距离检测,命中则画线(alpha = 1 - d / maxDis)。


Point.draw(now)
1、duration = (now - lastDrawTime)/1000(秒)得到Δt。
2、x += xSpeedΔt; y += ySpeedΔt,做边界检测与反弹。
3、ctx.arc(…); ctx.fill() 画点,更新 lastDrawTime = now。


关键细节与背后原理

高 DPI 适配为何要 setTransform
1、问题:Retina 下若只设 CSS 尺寸,像素密度高导致内容模糊。
2、做法:把 物理尺寸 设为 逻辑尺寸 * dpr,然后用 ctx.setTransform(dpr,0,0,dpr,0,0) 把“绘图坐标系”缩回逻辑像素。
3、好处:后续所有坐标、尺寸按“布局同款”的逻辑像素算,代码更简单且更清晰。


帧率无关运动(时间步积分)
1、思路:每帧用真实经过时间 Δt(单位秒)更新位置。这样 30fps/144fps 下动画速度一致。
2、一致性:Graph._tick(now) 传入的 now 与 Point.lastDrawTime 都用高精度“页面相对时间”(performance.now() 系列),单位一致才不会飘。
小提示:在代码里 Point.draw(now = new Date()) 的默认值是 Date(相对 Unix 纪元),而 rAF 的 now 是相对页面加载时间的高精度时间戳。由于 Graph 总是传入 now,默认值不会触发,但最佳实践是把默认值也改成 performance.now(),避免将来单独调用时时间系不一致。


O(n²) 配对的成本与优化
1、当前每帧做 n*(n-1)/2 次距离计算,n=30 时成本可接受。
2、粒子数增加时的优化思路:
2.1、网格/栅格分桶(Uniform Grid):仅与相邻格子内粒子配对。
2.2、四叉树 / KD-Tree:空间索引减少无效配对。
2.3、距离平方:若只需比较大小,可用 dxdx + dydy 与 maxDis*maxDis 比较,省掉 sqrt(本例需要 d 计算 alpha,可替换为近似映射,见文末改进)。


事件与资源管理
1、visibilitychange:隐藏时 stop(),恢复时 start() 并重置 lastDrawTime,避免长时间后台后回到前台“瞬移”。
2、resize:重新计算 DPR 与画布尺寸,同时将粒子位置夹回新边界,避免越界。
3、beforeunload: destroy() 清理,降低内存泄漏风险。


可扩展玩法(思路清单)

1、交互力场:鼠标/触摸点作为“引力/斥力”源,粒子受力运动。
2、动态密度:根据屏幕面积动态设定粒子数:count ≈ area / k。
3、颜色与宽度映射:将 alpha 扩展为颜色渐变、lineWidth 随距离变化。
4、性能提升:
4.1、批量绘制:把同一帧的多条线合并到一次 beginPath/stroke(见改进片段)。
4.2、OffscreenCanvas + Worker:把绘制丢给 Worker,主线程更顺畅(现代浏览器)。
5、降噪/抗抖:resize 事件可做 requestAnimationFrame-based throttle 或 debounce,避免频繁重排。


常见坑与规避

1、CSS 尺寸 ≠ 属性尺寸:只能改变 CSS 宽高会导致模糊;必须同时设置 cvs.width/height 为物理像素。
2、时间戳混用:不要把 Date.now() 与 performance.now() 混在同一条时间差计算里。
3、DPR 变化:浏览器缩放或把窗口拖到不同 DPI 屏幕,devicePixelRatio 会变;你在 resize 中调用 resizeCanvas(),可覆盖大多数情形。
4、重复启动动画:Graph.start() 里有 if (this.rafId) return;,能避免多次 requestAnimationFrame 叠加(👍)。


针对性小改进(可直接用)

时间戳一致化(防止将来单独调用 draw 时踩坑)

class Point {
  // ...
  draw(now = performance.now()) { // 改为 performance.now()
    const duration = (now - this.lastDrawTime) / 1000;
    // ...
  }
}

线段批量绘制(减少多次 stroke)

_drawFrame(now) {
  ctx.clearRect(0, 0, stage.w, stage.h);

  const pts = this.points;
  // 先画点
  for (let i = 0; i < pts.length; i++) pts[i].draw(now);

  // 批量路径
  ctx.beginPath();
  for (let i = 0; i < pts.length; i++) {
    const p1 = pts[i];
    for (let j = i + 1; j < pts.length; j++) {
      const p2 = pts[j];
      const dx = p1.x - p2.x, dy = p1.y - p2.y;
      const d2 = dx*dx + dy*dy;
      const max2 = this.maxDis * this.maxDis;
      if (d2 > max2) continue;

      // 可用近似把 alpha 映射到 d2:alpha ≈ 1 - d2/max2(非线性不同,但便宜)
      const alpha = 1 - d2 / max2;
      ctx.moveTo(p1.x, p1.y);
      ctx.lineTo(p2.x, p2.y);
      // 按 alpha 分组更高效,这里为简洁用一次 strokeStyle 覆盖:
      ctx.strokeStyle = `rgba(200, 200, 200, ${alpha})`;
      ctx.stroke();
      ctx.beginPath(); // 重新开始以便不同 alpha 生效
    }
  }
}

注:若想一次 stroke() 完成,需要将不同透明度的线分桶(比如按 0.1 为步长分 10 桶),每桶设置一次 strokeStyle 再统一描边,能显著减少 stroke() 次数与状态切换。


resize 轻抖(在动画场景下足够)

let resizeQueued = false;
window.addEventListener('resize', () => {
  if (!resizeQueued) {
    resizeQueued = true;
    requestAnimationFrame(() => {
      resizeQueued = false;
      resizeCanvas();
      GraphExample && GraphExample.points.forEach(p => {
        p.x = Math.min(Math.max(p.x, 0), stage.w - p.r / 2);
        p.y = Math.min(Math.max(p.y, 0), stage.h - p.r / 2);
      });
    });
  }
});

测试清单(上线前自检)

Retina 与非 Retina 下是否同样清晰?
切到后台 10 秒再回来,粒子是否平滑继续(不瞬移)?
快速缩放窗口、全屏/退出时是否仍清晰且不卡顿?
粒子数量翻倍(如 60)仍能稳定 60fps 吗?
长时间(>30 分钟)运行内存是否平稳(无泄漏)?


总结

1、这套实现覆盖了「Canvas 动效」的核心工程要点:DPR 适配、时间步积分、rAF 调度、O(n²) 代价管理、事件/资源的生命周期。在此基础上,可以按产品需求增加交互力场、渐变颜色、性能空间索引或 OffscreenCanvas。
2、先把“时间戳一致化 + 线段批量绘制 + resize 轻抖”三件小事落地,就能在保证画质的同时,把性能和稳定性再向前推一大步。


解析二

项目功能简介

页面加载后自动生成多个点,点会随机运动。
当两个点之间的距离小于某个阈值时,绘制一条半透明的连线,距离越近线越清晰。
支持高 DPI 屏幕的清晰渲染。
动态适配浏览器窗口大小变化。
页面切换到后台时自动暂停动画,节省性能。
页面关闭时清理资源,避免内存泄漏。


涉及的前端知识点

DOM 操作
1、document.createElement(‘canvas’) 动态创建 Canvas。
2、canvas.dataset.key 设置自定义属性。
3、document.body.appendChild(canvas) 插入页面。


Canvas API
1、canvas.getContext(‘2d’) 获取绘制上下文。
2、ctx.arc(x, y, r, 0, 2 * Math.PI) 绘制圆形点。
3、ctx.lineTo(x, y) + ctx.stroke() 绘制连线。
4、ctx.clearRect(0, 0, w, h) 清空画布。


高 DPI 适配
1、使用 devicePixelRatio (dpr) 区分逻辑像素和物理像素。
2、ctx.setTransform(dpr, 0, 0, dpr, 0, 0) 将绘图坐标系缩放回逻辑像素。


动画与性能优化
1、requestAnimationFrame 控制动画帧率。
2、通过时间差计算点的移动,保证在不同刷新率下运动流畅。
3、document.visibilitychange 监听页面可见性,后台时暂停动画。


面向对象编程 (OOP)
1、Point 类:定义点的属性与运动逻辑。
2、Graph 类:管理所有点与动画循环。


事件监听与生命周期管理
1、resize → 画布自适应窗口变化。
2、beforeunload → 页面关闭前清理动画。


实现逻辑

动态创建 Canvas

const id = `idSpecial_${Date.now()}`;
const canvas = document.createElement('canvas');

canvas.id = id;
canvas.className = 'p_a zi_n1 l_0 t_0 w_100_ h_100_';
canvas.dataset.key = 'keySpecial01';
document.body.appendChild(canvas);

1、id 使用 Date.now() 生成唯一值,避免冲突。
2、使用 dataset.key 存放自定义标识(推荐代替 setAttribute)。
3、设置样式类保证画布全屏覆盖。


画布尺寸与高分屏适配

function resizeCanvas() {
    const dpr = window.devicePixelRatio || 1;

    cvs.style.width = window.innerWidth + 'px';
    cvs.style.height = window.innerHeight + 'px';

    cvs.width = Math.max(1, Math.floor(window.innerWidth * dpr));
    cvs.height = Math.max(1, Math.floor(window.innerHeight * dpr));

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    stage.w = window.innerWidth;
    stage.h = window.innerHeight;
}

关键点:逻辑像素与物理像素分离。
逻辑像素:浏览器 window.innerWidth。
物理像素:Canvas 实际像素 = 逻辑像素 × dpr。
目的:避免高分屏下画面模糊。


Point 类:定义点与运动

class Point {
    constructor() {
        this.r = 6;
        this.x = getRandom(0, stage.w - this.r / 2);
        this.y = getRandom(0, stage.h - this.r / 2);
        this.xSpeed = getRandom(-50, 50);
        this.ySpeed = getRandom(-50, 50);
        this.lastDrawTime = performance.now();
    }

    draw(now) {
        const duration = (now - this.lastDrawTime) / 1000;
        if (duration > 0) {
            this.x += this.xSpeed * duration;
            this.y += this.ySpeed * duration;
            // 边界检测 & 反弹
            if (this.x > stage.w - this.r / 2 || this.x < 0) this.xSpeed = -this.xSpeed;
            if (this.y > stage.h - this.r / 2 || this.y < 0) this.ySpeed = -this.ySpeed;
        }
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
        ctx.fillStyle = 'rgb(255,255,255)';
        ctx.fill();
        this.lastDrawTime = now;
    }
}

每个点有位置、速度、半径。
基于「时间片」计算位移:位移 = 速度 × 时间差。
这样即使帧率不稳定,点的运动依然匀滑。
触碰边界时反弹(取反速度)。


Graph 类:管理动画与连线

class Graph {
    constructor(pointNumber = 30, maxDis = 500) {
        this.points = new Array(pointNumber).fill(0).map(() => new Point());
        this.maxDis = maxDis;
        this.rafId = null;
        this._tick = this._tick.bind(this);
    }

    _tick(now) {
        this.rafId = requestAnimationFrame(this._tick);
        this._drawFrame(now);
    }

    _drawFrame(now) {
        ctx.clearRect(0, 0, stage.w, stage.h);
        const pts = this.points;

        for (let i = 0; i < pts.length; i++) {
            const p1 = pts[i];
            p1.draw(now);

            for (let j = i + 1; j < pts.length; j++) {
                const p2 = pts[j];
                const dx = p1.x - p2.x;
                const dy = p1.y - p2.y;
                const d = Math.sqrt(dx * dx + dy * dy);
                if (d <= this.maxDis) {
                    ctx.beginPath();
                    ctx.moveTo(p1.x, p1.y);
                    ctx.lineTo(p2.x, p2.y);
                    ctx.strokeStyle = `rgba(200,200,200, ${1 - d / this.maxDis})`;
                    ctx.stroke();
                }
            }
        }
    }

    start() {
        if (this.rafId) return;
        const now = performance.now();
        this.points.forEach(p => p.lastDrawTime = now);
        this.rafId = requestAnimationFrame(this._tick);
    }

    stop() {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
    }

    destroy() {
        this.stop();
        ctx.clearRect(0, 0, stage.w, stage.h);
        this.points.length = 0;
    }
}

遍历点数组,绘制每个点。
遍历点对 (i, j),判断距离是否小于阈值。
小于阈值 → 绘制连线,线条透明度与距离成反比。


生命周期管理

function init() {
    resizeCanvas();
    GraphExample = new Graph();
    GraphExample.start();

    document.addEventListener('visibilitychange', () => {
        if (document.hidden) GraphExample.stop();
        else GraphExample.start();
    });

    window.addEventListener('resize', () => {
        resizeCanvas();
        GraphExample.points.forEach(p => {
            p.x = Math.min(Math.max(p.x, 0), stage.w - p.r / 2);
            p.y = Math.min(Math.max(p.y, 0), stage.h - p.r / 2);
        });
    });

    window.addEventListener('beforeunload', () => {
        GraphExample.destroy();
    });
}

页面切换后台:暂停动画 → 节省性能。
窗口变化:重设画布大小,并调整点位置。
页面关闭:调用 destroy 停止动画、释放资源。


总结与拓展

技术亮点
1、高 DPI 适配:使用 dpr 保证清晰度。
2、时间片位移:基于时间差计算移动量,保证流畅性。
3、透明度控制:线条透明度与距离成反比,视觉自然。
4、性能优化:requestAnimationFrame、暂停后台动画、避免重复计算 (i, j) 和 (j, i)。
5、OOP 设计:Point 管理单个点,Graph 管理全局动画与点集合。


可扩展方向
1、支持 鼠标交互:点跟随鼠标移动。
2、增加 粒子颜色渐变。
3、使用 WebGL 或 three.js 实现更复杂的 3D 效果。
4、点的数量、速度、连线距离支持 动态调节。


解析三

概要

这是一个基于 HTML5 Canvas 的动效示例:在全屏画布上生成若干“点”(小球),点按速度移动并在一定距离内画连线。
实现关键包括:高 DPI(devicePixelRatio)支持、基于时间步(time-based)的位置积分、requestAnimationFrame 动画循环、O(n²) 连线判断的性能考量,以及页面生命周期(可见性、resize、卸载)管理。


核心知识点(概念 / 必备理论)

1、Canvas 渲染坐标与 devicePixelRatio(DPR):物理像素与逻辑像素的区别;如何用 canvas.width/height 配合 CSS 宽高与 ctx.setTransform 保持绘图在逻辑像素单位上同时保证清晰度。
2、高精度时间:performance.now() 与 Date.now() 的区别(精度与基准),为什么用于动画时间差计算。
3、时间步积分(time-based movement):使用 deltaTime(秒)乘速度(px/s)计算位移,保证帧率波动时运动速度一致。
4、requestAnimationFrame(rAF)循环:相对于 setTimeout/setInterval,rAF 与浏览器刷新周期同步、具备省电优势。
5、边界检测与反弹:位置限制、速度取反做简易弹性碰撞。
6、连线算法与视觉衰减:两点间距计算(Euclidean distance),根据距离计算线透明度(例如 alpha = 1 - d / maxDis)。
7、性能复杂度:点对连线为 O(n²) 组合;当点数增大时需考虑空间划分或近邻搜索优化(网格 / 四叉树 / kd-tree /近邻搜索)。
8、资源与生命周期管理:停止 rAF、移除事件监听、防止内存泄漏(destroy / beforeunload),页面隐藏时暂停渲染节省资源。


代码结构与逻辑详解(逐步拆解)

1、初始化与画布尺寸管理(resizeCanvas())

目标
在高 DPI 屏幕上保持画面清晰,同时让坐标以逻辑像素为单位,便于与页面布局对应。


实现要点
1、读取 window.devicePixelRatio(dpr)。
2、将 canvas 的 CSS 尺寸设置为逻辑像素(style.width = innerWidth + ‘px’),同时将 canvas.width = innerWidth * dpr 设置为物理像素。
3、使用 ctx.setTransform(dpr, 0, 0, dpr, 0, 0) 把绘图坐标系缩放回逻辑像素,以后所有坐标都用逻辑像素单位(无需每次除以 dpr)。
4、更新 stage(逻辑像素宽高)供碰撞与位置计算使用。


注意点 / 推荐
1、使用 Math.floor/Math.max 防止 0 或小数导致异常。
2、当页面缩放或 DPI 变化(例如浏览器缩放、显示器切换)时要重新调用 resizeCanvas()。
3、若需更精细的像素对齐可考虑 ctx.translate(0.5,0.5) 等技巧以减少线条模糊。


2、点(Point)类:状态与渲染(Point)

职责
1、保存点的半径 r、位置 x,y、速度 xSpeed,ySpeed(单位 px/s)、lastDrawTime(性能计时基准)。
2、在 draw(now) 中计算 delta = (now - lastDrawTime) / 1000,用 x += xSpeed * delta 更新位置。
3、做边界检测(如果超出边界夹回并把对应速度分量取反,产生弹回效果)。
4、绘制圆(arc + fill)并更新 lastDrawTime = now。


关键实现细节
1、时间基准统一:lastDrawTime 使用 performance.now() 初始化,draw 的 now 也应使用 performance.now()(不要混用 Date 与 performance,否则基准不同可能导致负或异常的 delta)。在你的代码中 draw(now = new Date()) 会生成 Date 对象,建议改为 now = performance.now()。
2、速度单位:速度以 px/s 表示(方便与 delta 相乘)。如果速度很大或 delta 很大应考虑步长限制(例如 clamp(delta, 0, 0.05))避免跳帧导致穿透。


3、Graph 管理类(Graph)

职责
1、管理点数组 this.points、最大连线距离 maxDis、以及动画 id rafId。
2、启动/停止动画:start() 将所有点的 lastDrawTime 重置为当前 performance.now(),避免 resume 时出现跳跃;stop() 取消 rAF。
3、rAF 回调绑定:this._tick = this._tick.bind(this),并在 _tick(now) 中先注册下一帧 requestAnimationFrame(this._tick)(保证 rafId 始终存在),随后调用 _drawFrame(now) 做真正绘制。
4、_drawFrame(now) 中:ctx.clearRect(0,0,stage.w,stage.h) 清屏,然后遍历点数组 for i:先 p1.draw(now),再与后续 j>i 的点计算距离 d = sqrt(dxdx + dydy),如果 d <= maxDis 则画线并根据 1 - d/maxDis 设置 alpha。


性能与实现技巧
1、只计算 j > i 避免重复;
2、如果 points.length 大(>200),O(n²) 会成为瓶颈,建议使用空间分区(网格 / 四叉树)或限制连线范围(降低 maxDis);
3、使用 ctx.beginPath() 组合绘制多条线可以减小绘制次数,但要注意不同线条 alpha 的场景;若 alpha 不同,需分批绘制同 alpha 的线段以减少 stroke() 次数。


4、页面生命周期管理

1、可见性(visibilitychange):document.hidden 为 true 时调用 GraphExample.stop(),回到前台时 start() 并重置时间基准以避免大位移。
2、窗口缩放(resize):调用 resizeCanvas() 并把超出边界的点 clamp 回去(p.x = clamp(p.x, 0, stage.w - p.r/2))。可选优化:按比例缩放所有点坐标以保留视觉相对位置。
3、卸载(beforeunload):调用 destroy() 停止动画并清理资源。


常见问题、潜在 bug 与改进建议

1、时间基准不一致:lastDrawTime = performance.now(),但 draw(now = new Date()) 的默认值会混用 Date 与 performance,这会导致 delta 的基准不一致。应统一使用 performance.now()。
修复:draw(now = performance.now())。


2、点越界处理:使用 r/2 作为边界偏移看似合理,但更直观的是把边界定义为 [r, stage.w - r]。确保半径的解释一致。


3、性能问题:当点数量增加,连线的 O(n²) 会明显拖慢。常见优化:
3.1、空间哈希格子(grid):把点放入网格单元,只与邻近单元的点做距离检查(常用且实现简单)。
3.2、四叉树(quadtree):适合大量随机分布点的近邻查询。
3.3、限制绘制密度:按距离分段、对靠近的点绘制更粗/更亮的线,远处不绘制。


4、抗锯齿/像素对齐:线条和圆在某些 DPI/浏览器组合下会略模糊,可尝试微调坐标(+0.5 像素)或使用 willReadFrequently、WebGL 提升性能。


5、动画跳帧:当浏览器切换标签页或系统负载高时,恢复时 delta 会非常大。解决方法:
5.1、在 start() 时把所有点的 lastDrawTime 设为 performance.now()(你的代码有做这点)。
5.2、在每帧计算时对 delta 做上限 if (duration > 0.1) duration = 0.1,避免一次跳过过远。


6、移动与碰撞物理改进:当前只做速度取反的简单弹回,若需要更真实的物理效果可以加入质量、摩擦、能量损失系数、点与点碰撞检测等。


7、内存/事件泄露:确保在多次创建/销毁场景时取消事件监听、停止 rAF 并清空数组引用(points.length = 0; GraphExample = null;)。


可扩展方向(功能与 UX 提升)

1、鼠标交互:鼠标靠近时增加点密度或高亮连线;拖拽生成新点;点击添加/移除点。
触控优化:支持触屏多点交互,考虑 pointer 事件兼容。
2、GPU 加速:用 WebGL / regl / three.js 将连线与点的绘制交给 GPU,尤其当点数很大时能显著提升性能。
3、粒子属性扩展:颜色、渐变、大小随距离或速度改变、trail(轨迹)效果。
4、视觉合成:使用 globalCompositeOperation(如 lighter)做叠加发光效果。
5、节点分组与力导向布局:把点视为网络节点并使用力学仿真(spring)形成簇状结构。
6、节流/帧率控制:允许用户设置目标 FPS(比如 30FPS)以节能或适配低端设备。


调试与测试要点(清单)

1、检查 resizeCanvas() 在不同 DPI、浏览器缩放下的表现(Chrome/Firefox/Safari)。
2、确保 performance.now() 在所有绘制路径中被一致使用。
3、在低帧率下测试恢复(切后台再切回)是否发生跳帧。
4、用 devtools 的 FPS / CPU profiler 检测 O(n²) 对性能的影响。
5、在移动设备上测试触控事件和内存占用。
6、验证 destroy() 是否完全释放引用,避免内存增长。


精简实现建议(代码层面小改动)

1、把 Point.draw 默认参数改为 draw(now = performance.now())(统一时间基准)。
2、在 resize 事件里对 devicePixelRatio 变化做检测(用户改变缩放或显示输出切换时重新设置 canvas)。
3、给 Graph 加入 setPointNumber(n) 或 addPoint/removePoint 的接口,便于运行时调整粒子数。
4、对 _drawFrame 做批量绘制优化:先把所有点画好,再一次性处理连线的 beginPath()/stroke() 策略以减少绘制开销(注意 alpha 不同需要分组)。


总结

这套实现把常见的 Canvas 动画关键点都涵盖了:高 DPI 适配、时间步运动、rAF 循环与生命周期管理。关键的改进方向在于统一时间基准、优化 O(n²) 连线检查以及在移动/高点数场景下做性能工程(网格/四叉树、GPU、帧率限制等)。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值