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

Svelte 5中如何在Action中使用Tween?

在Svelte 5中如何直接响应Tween的当前值(纯JavaScript实现)

Svelte 5已弃用tweened并替换为Tween。tweened是一个store,因此在action中可以订阅其值并做出响应(例如将偏移量转换为CSS语句transform: translate(${Math.round(x)}px, ${Math.round(y)}px))。

但Tween本身似乎不具备响应式,能找到的示例都是通过bind:等方式在Svelte组件中使用,这在action内无法生效。

我知道action已被attachments取代,但不清楚这如何解决监听Tween.current值的核心问题。

如何直接响应Tween的当前值?即不使用bind:或其他Svelte机制,仅用JavaScript实现?

以下是可在全新Svelte 5项目中测试的原始代码:

原始action代码

const { abs, sqrt, round } = Math;
import { tweened } from "svelte/motion";
import { cubicInOut } from "svelte/easing";
import type { Unsubscriber } from "svelte/store";
type vector = [number, number];
type ArrowKey = 'up' | 'left' | 'right' | 'down';
type nullable<T> = T | null;

export const gesturesAction = (
    node: HTMLElement,
    opts: {
        selfOnly?: boolean,
        longPressDelay?: number,
        minSwipeDistance?: number,
        drag?: boolean | 'x' | 'y' | 'xy', // true is free drag, xy locks you to x and y axis
        minDragDistance?: number,
        snapDuration?: number,
    } = {}
) => {

    // fill missing opts properties with defaults
    const SELF_ONLY = opts.selfOnly || false; // if true, event will not trigger on child nodes.
    const LONG_PRESS_DELAY = opts.longPressDelay || 1000;
    const MIN_SWIPE_DISTANCE = opts.minSwipeDistance || 40;
    const DRAG = opts.drag || false;
    const MIN_DRAG_DISTANCE = opts.minDragDistance || 40;
    const SNAP_DURATION = opts.snapDuration || 200; // after pointerup, how long to snap back

    // warn if drag enabled but node has a position that is not draggable
    const cssPosition = window.getComputedStyle(node).position;
    if (!['absolute', 'fixed'].includes(cssPosition)) {
        console.warn(`
        Dragging enabled on element whose css position is ${cssPosition}.
        Change to either absolute or fixed for dragging to work properly.
        Element id: ${node.id}
        Parent id: ${node.parentElement?.id}
        `)
    }

    // wrapper for dispatching custom events, 'detail' optional (see CustomEvent docs)
    const dispatch = <T = unknown>(name: string, detail?: T) => node.dispatchEvent(new CustomEvent(name, { detail }));

    // adds event listeners, returns a cleanup function
    const listen = (type: keyof HTMLElementEventMap, handler: (e: PointerEvent) => unknown) => {
        const fn = SELF_ONLY ? (e: PointerEvent) => { if (e.target === node) { handler(e); } } : handler;
        node.addEventListener(type, fn as EventListener);
        return () => node.removeEventListener(type, fn as EventListener);
    };

    // measure movement
    let start: nullable<vector>;
    let dPos: vector;
    let dEnd: nullable<vector>;
    let direction: nullable<ArrowKey>;
    let distance: number;
    let animationFrameRequested = false; // prevent multiple calls per frame
    let cDragAxis: nullable<'x' | 'y'> = null; // in 'xy' case, stick to one axis per drag
    let tSub: Unsubscriber; // subscription for resetting position after drag
    const onAnimationFrame = (e: PointerEvent) => {
        if (!start) { return; }
        
        dPos = [e.clientX - start[0], e.clientY - start[1]];
        let [dx, dy] = dPos;
        
        if (DRAG) {

            const [adx, ady] = [abs(dx), abs(dy)];

            // ignore both tiny movements and purely diagonal movement
            if ((adx + ady < 4) || adx === ady) { return; }

            direction = (adx > ady) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'up' : 'down');

            // if not free drag, eliminate undesired movement
            if (typeof DRAG === 'string') {
                if (!cDragAxis) {
                    cDragAxis = DRAG === 'xy' ? (adx > ady ? 'x' : 'y') : DRAG;
                }
    
                if (cDragAxis === 'x') { dy = 0; }
                else { dx = 0; }
            }
            
            // actually drag the element
            node.style.transform = `translate(${round(dx)}px, ${round(dy)}px)`;
            distance = sqrt((dx ** 2) + (dy ** 2));
            if (!MIN_DRAG_DISTANCE || distance >= MIN_DRAG_DISTANCE) {
                const detail = { direction, distance, dx, dy };
                dispatch('drag', detail);
                dispatch(`drag${direction}`, detail);
            }
        }
    };
    const getPos = (e: PointerEvent): vector => [e.clientX, e.clientY];
    const toRemove: (() => void)[] = [
        // NOTE do NOT get ride of touchmove! touch-action: none was not enough.
        // NOTE leave preventDefault too! Lost like three hours on this.
        listen('touchmove', e => e.preventDefault()),
        listen('pointerdown', e => {
            start = getPos(e);
            dPos = [0, 0];
            dEnd = null;
        }),
        listen('pointermove', e => {
            if (!animationFrameRequested) {
                animationFrameRequested = true;
                requestAnimationFrame(() => {
                    onAnimationFrame(e);
                    animationFrameRequested = false;
                });
            }
        }),
        listen('pointerup', e => {
            dEnd = dPos;
            if (DRAG) {
                if (!MIN_DRAG_DISTANCE || distance >= MIN_DRAG_DISTANCE) {
                    const [dx, dy] = dPos;
                    dispatch('dragend', { direction, distance, dx, dy })
                }

                // gradually reset node position
                const tween = tweened(
                    cDragAxis === 'x' ? [dPos[0], 0] : [0, dPos[1]],
                    { duration: SNAP_DURATION, easing: cubicInOut }
                );
                tSub = tween.subscribe(([tx,ty]) => {
                    node.style.transform = `translate(${round(tx)}px, ${round(ty)}px)`;
                });
                tween.set([0,0]);
            }
            // NOTE do not zero distance here, it will fuck with swiping.
            start = cDragAxis = direction = null;
            dPos = [0,0];
        }),
        () => tSub?.()
    ];

    // long press
    // NOTE for touch devices, may have to disable context menu on long press, or at least delay it
    let lpTimer: ReturnType<typeof setTimeout>;
    toRemove.push(
        listen('pointerdown', () => lpTimer = setTimeout(() => dispatch('longpress'))),
        listen('pointerup', () => clearTimeout(lpTimer))
    );

    // swipe, swipeleft, swiperight, swipeup, swipedown
    let swipe: 'swipeleft' | 'swiperight' | 'swipeup' | 'swipedown';
    toRemove.push(listen('pointerup', e => {
        if (distance > MIN_SWIPE_DISTANCE && dEnd) {
            if (abs(dEnd[0]) > abs(dEnd[1])) { // if swipe more horizontal than vertical
                swipe = dEnd[0] > 0 ? 'swiperight' : 'swipeleft';
            } else { swipe = dEnd[1] > 0 ? 'swipedown' : 'swipeup'; }
            dispatch(swipe);
            dispatch('swipe', { direction: swipe.slice(5) as ArrowKey });
        }
    }));

    return {
        destroy: () => toRemove.forEach(remove => remove())
    }
};

原始组件代码

<script module lang="ts">
    import { gesturesAction } from '$lib/drag.action.svelte';
</script>

<span use:gesturesAction={{ drag: true, minDragDistance: 40 }}>Drag me!</span>

<style>
    span {
        position: absolute;
        text-align: center;
        padding: 4px 4px;
        font-size: 24px;
        background-color: blueviolet;
    }
</style>

解决方案:使用Tween的onUpdate回调

Svelte 5的Tween类提供了onUpdate方法,你可以通过它注册回调函数,在动画每一帧更新时获取当前值,完全不需要依赖Svelte的响应式机制或bind:语法。

修改核心逻辑

替换原action中的tweened相关代码为以下内容:

import { Tween } from 'svelte/motion'; // 替换原tweened导入

// 在pointerup的drag复位逻辑中:
const initialValue = cDragAxis === 'x' ? [dPos[0], 0] : [0, dPos[1]];
const tween = new Tween(initialValue, {
    duration: SNAP_DURATION,
    easing: cubicInOut
});

// 注册更新回调,实时获取当前值
const unsubscribe = tween.onUpdate(([tx, ty]) => {
    node.style.transform = `translate(${round(tx)}px, ${round(ty)}px)`;
});

// 启动动画到目标位置
tween.to([0, 0]);

// 加入清理函数,防止内存泄漏
toRemove.push(() => unsubscribe());

完整修改后的action代码

const { abs, sqrt, round } = Math;
import { Tween } from "svelte/motion";
import { cubicInOut } from "svelte/easing";
type vector = [number, number];
type ArrowKey = 'up' | 'left' | 'right' | 'down';
type nullable<T> = T | null;

export const gesturesAction = (
    node: HTMLElement,
    opts: {
        selfOnly?: boolean,
        longPressDelay?: number,
        minSwipeDistance?: number,
        drag?: boolean | 'x' | 'y' | 'xy',
        minDragDistance?: number,
        snapDuration?: number,
    } = {}
) => {

    const SELF_ONLY = opts.selfOnly || false;
    const LONG_PRESS_DELAY = opts.longPressDelay || 1000;
    const MIN_SWIPE_DISTANCE = opts.minSwipeDistance || 40;
    const DRAG = opts.drag || false;
    const MIN_DRAG_DISTANCE = opts.minDragDistance || 40;
    const SNAP_DURATION = opts.snapDuration || 200;

    const cssPosition = window.getComputedStyle(node).position;
    if (!['absolute', 'fixed'].includes(cssPosition)) {
        console.warn(`
        Dragging enabled on element whose css position is ${cssPosition}.
        Change to either absolute or fixed for dragging to work properly.
        Element id: ${node.id}
        Parent id: ${node.parentElement?.id}
        `)
    }

    const dispatch = <T = unknown>(name: string, detail?: T) => node.dispatchEvent(new CustomEvent(name, { detail }));

    const listen = (type: keyof HTMLElementEventMap, handler: (e: PointerEvent) => unknown) => {
        const fn = SELF_ONLY ? (e: PointerEvent) => { if (e.target === node) { handler(e); } } : handler;
        node.addEventListener(type, fn as EventListener);
        return () => node.removeEventListener(type, fn as EventListener);
    };

    let start: nullable<vector>;
    let dPos: vector = [0, 0];
    let dEnd: nullable<vector>;
    let direction: nullable<ArrowKey>;
    let distance = 0;
    let animationFrameRequested = false;
    let cDragAxis: nullable<'x' | 'y'> = null;
    let tweenUnsubscribe: (() => void) | undefined;

    const onAnimationFrame = (e: PointerEvent) => {
        if (!start) { return; }
        
        dPos = [e.clientX - start[0], e.clientY - start[1]];
        let [dx, dy] = dPos;
        
        if (DRAG) {
            const [adx, ady] = [abs(dx), abs(dy)];
            if ((adx + ady < 4) || adx === ady) { return; }

            direction = (adx > ady) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');

            if (typeof DRAG === 'string') {
                if (!cDragAxis) {
                    cDragAxis = DRAG === 'xy' ? (adx > ady ? 'x' : 'y') : DRAG;
                }
    
                if (cDragAxis === 'x') { dy = 0; }
                else { dx = 0; }
            }
            
            node.style.transform = `translate(${round(dx)}px, ${round(dy)}px)`;
            distance = sqrt((dx ** 2) + (dy ** 2));
            if (!MIN_DRAG_DISTANCE || distance >= MIN_DRAG_DISTANCE) {
                const detail = { direction, distance, dx, dy };
                dispatch('drag', detail);
                dispatch(`drag${direction}`, detail);
            }
        }
    };

    const getPos = (e: PointerEvent): vector => [e.clientX, e.clientY];
    const toRemove: (() => void)[] = [
        listen('touchmove', e => e.preventDefault()),
        listen('pointerdown', e => {
            start = getPos(e);
            dPos = [0, 0];
            dEnd = null;
            distance = 0;
        }),
        listen('pointermove', e => {
            if (!animationFrameRequested) {
                animationFrameRequested = true;
                requestAnimationFrame(() => {
                    onAnimationFrame(e);
                    animationFrameRequested = false;
                });
            }
        }),
        listen('pointerup', e => {
            dEnd = dPos;
            if (DRAG) {
                if (!MIN_DRAG_DISTANCE || distance >= MIN_DRAG_DISTANCE) {
                    const [dx, dy] = dPos;
                    dispatch('dragend', { direction, distance, dx, dy })
                }

                // 使用Tween实现复位动画
                const initialValue = cDragAxis === 'x' ? [dPos[0], 0] : [0, dPos[1]];
                const tween = new Tween(initialValue, {
                    duration: SNAP_DURATION,
                    easing: cubicInOut
                });

                tweenUnsubscribe = tween.onUpdate(([tx, ty]) => {
                    node.style.transform = `translate(${round(tx)}px, ${round(ty)}px)`;
                });

                tween.to([0, 0]);
            }
            start = cDragAxis = direction = null;
            dPos = [0,0];
        }),
        () => tweenUnsubscribe?.()
    ];

    let lpTimer: ReturnType<typeof setTimeout>;
    toRemove.push(
        listen('pointerdown', () => lpTimer = setTimeout(() => dispatch('longpress'))),
        listen('pointerup', () => clearTimeout(lpTimer))
    );

    let swipe: 'swipeleft' | 'swiperight' | 'swipeup' | 'swipedown';
    toRemove.push(listen('pointerup', e => {
        if (distance > MIN_SWIPE_DISTANCE && dEnd) {
            if (abs(dEnd[0]) > abs(dEnd[1])) {
                swipe = dEnd[0] > 0 ? 'swiperight' : 'swipeleft';
            } else { swipe = dEnd[1] > 0 ? 'swipedown' : 'swipeup'; }
            dispatch(swipe);
            dispatch('swipe', { direction: swipe.slice(5) as ArrowKey });
        }
    }));

    return {
        destroy: () => toRemove.forEach(remove => remove())
    }
};

组件代码无需修改,保持原样即可。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.13 00:16:31