You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

点击元素时CSS Transition与Canvas坐标更新不同步问题求助

问题描述

我正在开发一个Web项目,包含用户可点击的元素列表。点击元素时,该元素需变为激活状态,触发200ms的CSS Transition(如transform),同时要同步更新该元素在HTML5 Canvas上的坐标。

当前JS代码逻辑是:点击元素时添加active类触发CSS过渡,随后调用updateItemCoordinates()更新坐标,但坐标更新要么滞后于过渡完成,要么保留旧坐标,导致两者不同步。

尝试过的方案:

  • 使用transitionend事件,但引入了延迟;
  • 用requestAnimationFrame在过渡期间更新坐标,但未实现正确同步。

当前使用的JavaScript代码如下:

// Lines animation
const canvas = document.getElementById('line-canvas');
const ctx = canvas.getContext('2d');
const list = document.querySelector('.ressource-search__parent-terms');
const items = Array.from(list.querySelectorAll('.ressource-search__parent-term'));
const parent = document.querySelector('.ressource-search');

canvas.width = list.offsetWidth;
canvas.height = parent.offsetHeight;

const animationDuration = 200;

function getCanvasCoordinates(element) {
    const rect = element.getBoundingClientRect();
    const canvasRect = canvas.getBoundingClientRect();
    return {
        x: rect.left + rect.width / 2 - canvasRect.left,
        y: rect.top + rect.height / 2 - canvasRect.top
    };
}

let itemCoordinates = items.map(getCanvasCoordinates);

// Click sur l'element
items.forEach((item, key) => {
    item.addEventListener('click', () => {

        items.forEach(otherItem => {
            otherItem.classList.remove('active');
            otherItem.classList.add('not-active');
        });

        item.classList.remove('not-active');
        item.classList.add('active');

        updateItemCoordinates();
    });
})

function updateItemCoordinates() {
    const newCoordinates = items.map(getCanvasCoordinates);
    const startTime = performance.now();

    function animateTransition(timestamp) {
        const elapsed = timestamp - startTime;
        const progress = Math.min(1, elapsed / animationDuration);

        ctx.clearRect(0, 0, canvas.width, canvas.height);

        for (let i = 0; i < items.length - 1; i++) {
            const x1 = itemCoordinates[i].x;
            const y1 = itemCoordinates[i].y;
            const x2 = itemCoordinates[i + 1].x;
            const y2 = itemCoordinates[i + 1].y;

            const newX1 = x1 + (newCoordinates[i].x - x1) * progress;
            const newY1 = y1 + (newCoordinates[i].y - y1) * progress;
            const newX2 = x2 + (newCoordinates[i + 1].x - x2) * progress;
            const newY2 = y2 + (newCoordinates[i + 1].y - y2) * progress;

            drawLine(newX1, newY1, newX2, newY2, 1);
        }

        if (progress < 1) {
            requestAnimationFrame(animateTransition);
        } else {
            itemCoordinates = newCoordinates;
            cancelAnimationFrame(animateTransition);
        }
    }
    requestAnimationFrame(animateTransition);
}

function animateLine(index, startTime) {
    const x1 = itemCoordinates[index].x;
    const y1 = itemCoordinates[index].y;
    const x2 = itemCoordinates[index + 1].x;
    const y2 = itemCoordinates[index + 1].y;

    return function(timestamp) {
        const elapsed = timestamp - startTime;
        const progress = Math.min(1, elapsed / animationDuration);

        ctx.clearRect(0, 0, canvas.width, canvas.height);

        for (let i = 0; i < index; i++) {
            const prevX1 = itemCoordinates[i].x;
            const prevY1 = itemCoordinates[i].y;
            const prevX2 = itemCoordinates[i + 1].x;
            const prevY2 = itemCoordinates[i + 1].y;
            drawLine(prevX1, prevY1, prevX2, prevY2, 1);
        }

        drawLine(x1, y1, x2, y2, progress);

        if (progress < 1) {
            requestAnimationFrame(animateLine(index, startTime));
        } else {
            if (index + 2 < items.length) {
                animateLine(index + 1, performance.now())(performance.now());
            }
        }
    };
}

function drawLine(x1, y1, x2, y2, progress) {
    ctx.beginPath();
    ctx.moveTo(x1, y1);
    ctx.lineTo(x1 + (x2 - x1) * progress, y1 + (y2 - y1) * progress);
    ctx.lineWidth = 1;
    ctx.strokeStyle = "#707070";
    ctx.stroke();
}

function startAnimation() {
    if (items.length > 1) {
        animateLine(0, performance.now())(performance.now());
    }
}

window.addEventListener('resize', () => {
    itemCoordinates = items.map(getCanvasCoordinates);
    startAnimation();
});

setTimeout(() => {
    startAnimation();
}, 50);
解决方案

核心问题在于你提前计算了newCoordinates,但此时CSS过渡尚未开始,元素位置没有变化,导致Canvas动画没有对应到实际的元素移动。正确的做法是在动画过程中实时获取元素的当前位置,而非预计算目标坐标。

修改后的updateItemCoordinates函数及点击事件逻辑如下:

// 修改点击事件逻辑
items.forEach((item, key) => {
    item.addEventListener('click', () => {
        items.forEach(otherItem => {
            otherItem.classList.remove('active');
            otherItem.classList.add('not-active');
        });
        item.classList.remove('not-active');
        item.classList.add('active');
        
        // 启动同步动画,无需提前计算新坐标
        syncCanvasWithTransition();
    });
})

function syncCanvasWithTransition() {
    const startTime = performance.now();
    // 记录过渡开始时的初始坐标
    const startCoordinates = items.map(getCanvasCoordinates);

    function animateFrame(timestamp) {
        const elapsed = timestamp - startTime;
        const progress = Math.min(1, elapsed / animationDuration);
        
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        
        // 实时获取当前元素的坐标(CSS过渡中的实时位置)
        const currentCoordinates = items.map(getCanvasCoordinates);
        
        // 绘制所有连线,使用当前实时坐标
        for (let i = 0; i < items.length - 1; i++) {
            drawLine(currentCoordinates[i].x, currentCoordinates[i].y, 
                     currentCoordinates[i+1].x, currentCoordinates[i+1].y, 1);
        }
        
        if (progress < 1) {
            requestAnimationFrame(animateFrame);
        } else {
            // 动画结束后更新基准坐标
            itemCoordinates = currentCoordinates;
        }
    }
    
    requestAnimationFrame(animateFrame);
}

关键改进点:

  • 实时获取坐标:在每一动画帧中调用getCanvasCoordinates获取元素的当前位置,确保Canvas绘制的位置与CSS过渡中的元素位置完全同步;
  • 取消预计算目标坐标:不再提前计算newCoordinates,而是直接使用元素的实时位置,避免因过渡未开始导致坐标无变化的问题;
  • 动画结束更新基准:过渡完成后,将当前坐标更新为基准坐标,保证后续操作的准确性。

额外优化建议:

如果担心getBoundingClientRect的性能,可以在动画开始时记录元素的初始位置和目标位置(通过计算CSS变换后的最终位置),然后用progress值插值计算中间位置,减少DOM查询次数:

// 计算目标坐标的示例(假设active类的transform是translateY(-10px))
function getTargetCoordinates(element) {
    const rect = element.getBoundingClientRect();
    const canvasRect = canvas.getBoundingClientRect();
    // 根据active类的transform计算目标位置
    const targetY = rect.top + rect.height / 2 - canvasRect.top - 10;
    return {
        x: rect.left + rect.width / 2 - canvasRect.left,
        y: targetY
    };
}

// 在syncCanvasWithTransition中使用插值计算
function syncCanvasWithTransition() {
    const startTime = performance.now();
    const startCoordinates = items.map(getCanvasCoordinates);
    const targetCoordinates = items.map(getTargetCoordinates);

    function animateFrame(timestamp) {
        const elapsed = timestamp - startTime;
        const progress = Math.min(1, elapsed / animationDuration);
        
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        
        for (let i = 0; i < items.length - 1; i++) {
            // 插值计算当前坐标
            const currentX = startCoordinates[i].x + (targetCoordinates[i].x - startCoordinates[i].x) * progress;
            const currentY = startCoordinates[i].y + (targetCoordinates[i].y - startCoordinates[i].y) * progress;
            const nextX = startCoordinates[i+1].x + (targetCoordinates[i+1].x - startCoordinates[i+1].x) * progress;
            const nextY = startCoordinates[i+1].y + (targetCoordinates[i+1].y - startCoordinates[i+1].y) * progress;
            
            drawLine(currentX, currentY, nextX, nextY, 1);
        }
        
        if (progress < 1) {
            requestAnimationFrame(animateFrame);
        } else {
            itemCoordinates = targetCoordinates;
        }
    }
    
    requestAnimationFrame(animateFrame);
}

这种方式适合已知变换规则的场景,性能更优,但需要硬编码或动态获取CSS变换值。


内容的提问来源于stack exchange,提问作者Kilian

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.08 13:03:16