自定义抛出物体

组件名称:ParabolaFly(抛物线飞行)

#Canvas

组件名称:ParabolaFly(抛物线飞行)

属性命名(props)

属性(Props)

属性名类型默认值必填说明
startObject { x: number, y: number }✅ 是飞行起点坐标(相对于窗口或容器的坐标系)
endObject { x: number, y: number }✅ 是飞行终点坐标
peakHeightNumber100抛物线的最高点高度,数值越大弧线越高
speedNumber600飞行速度,单位像素/秒(或倍率因子)
showTrailBooleantrue是否启用轨迹点绘制
showTrailOption.timeNumber1000单个轨迹点存在的时间(毫秒)
showTrailOption.gapNumber1绘制轨迹点的间隔(数值越大,轨迹越稀疏)
showTrailOption.maxNumNumber10同时存在的最大轨迹点数,超过则移除最旧的点

插槽(Slots)

插槽名类型默认值说明
defaultVNode / Function飞行主体的内容,例如一个图标组件或图片
detailVNode / Function默认圆点(直径 6px,灰色半透明圆)自定义轨迹点的渲染方式,例如小方块、小星星等

Fly.show(options) 参数表

参数名类型必填默认值说明
startObject { x: number, y: number }飞行起点坐标(相对于窗口或容器的坐标)
endObject { x: number, y: number }飞行终点坐标
contentVNode / Function飞行主体内容(通常是一个组件或 DOM 节点),例如 () => h(MyIcon)
peakHeightNumber100抛物线的最高点高度,数值越大弧线越高
speedNumber600飞行速度,单位:像素/秒(或倍率因子)
showTrailBooleantrue是否启用轨迹点绘制
detailVNode / Function默认小圆点自定义轨迹点的渲染方式,例如星形、方块
showTrailOptionObject { time, gap, maxNum }{ time: 1000, gap: 1, maxNum: 10 }轨迹点控制参数
├─ timeNumber1000单个轨迹点的存在时间(毫秒)
├─ gapNumber1绘制轨迹点的间隔(数值越大,轨迹越稀疏)
└─ maxNumNumber10同时存在的最大轨迹点数量,超出会移除最旧的点

传入模板使用方式:

js
用法一:传字符串/HTML
Fly.show({
  start: { x: 100, y: 100 },
  end: { x: 400, y: 400 },
  content: () => h('div', { style: 'width:20px;height:20px;background:red;border-radius:50%;' })
})

用法二:传组件
<!-- MyIcon.vue -->
<template>
  <div class="w-8 h-8 bg-blue-500 rounded-full"></div>
</template>

import MyIcon from './MyIcon.vue'

Fly.show({
  start: { x: 100, y: 100 },
  end: { x: 400, y: 400 },
  content: () => h(MyIcon)   // ✅ slot 用组件
})

用法三:传动态文本
Fly.show({
  start: { x: 50, y: 50 },
  end: { x: 300, y: 200 },
  content: () => '🔥'
})

动画实现层面的关键区别:


1. left/top 改变坐标

  • 原理:不断修改 style.left / style.top 数值。
  • 优点:逻辑直观,直接对应坐标。
  • 缺点
    • 每次修改都会触发 layout(回流) → 性能差,特别是频繁动画时。
    • 可能影响兄弟元素的布局(虽然 absolute/fixed 一般影响较小)。

2. transform: translate(x, y) 改变坐标

  • 原理:元素保持一个“基准位置”,通过 transform: translate() 偏移。
  • 优点
    • 由 GPU 加速,通常只触发 composite(合成层绘制),性能更好,不卡顿。
    • 不会触发回流,不影响其它元素布局。
    • 可以和 scale / rotate 等结合。
  • 缺点
    • 计算逻辑稍微复杂一点(需要基准点 + 偏移量)。
    • left/top 一般需要配合 0,否则坐标基准不直观。

实际推荐方案

现代动画推荐用 transform: translate(x, y),尤其是抛物线飞行这种帧率要求高的效果。

时间计算

image-20251002220720729

垂直方向第一段位移:

translateY(-h)/transition:translateY t1

垂直方向第二段位移:

translateY(h+y)/transition:translateY t2

水平方向位移:

translateX(x)/transition:translateX t1+t2

speed作为分母,因为一般取一个比较大的

具体实现

fly/index.js

js
import { createApp, h } from 'vue'
import ParabolaFly from './ParabolaFly.vue'

function showFly(options) {
  const container = document.createElement('div')
  document.body.appendChild(container)

  const { content,detail, ...rest } = options
  const slots = {}
  if (content) slots.default = () => (typeof content === 'function' ? content() : content)
  if (detail)  slots.detail  = () => (typeof detail  === 'function' ? detail()  : detail)
  const app = createApp({
    render() {
      return h(
        ParabolaFly,
        {
          ...rest,
          onEnd: () => {
            app.unmount()
            document.body.removeChild(container)
          }
        },
        slots
      )
    }
  })

  app.mount(container)
}

export const Fly = {
  install(app) {
    app.config.globalProperties.$fly = showFly
  },
  show: showFly
}

fly/ParabolaFly.vue

vue
<template>
  <div ref="outer" @transitionend.self="handleOuter"
    :style="{ position: 'fixed', left: `${start.x}px`, top: `${start.y}px`, pointerEvents: 'none' }">
    <div ref="inner" @transitionend.self.once="handleInner">
      <slot>
        <div style="width: 50px;height: 50px;background-color: red;border-radius: 10px;"></div>
      </slot>
    </div>
  </div>
</template>

<script setup>
import { computed, onMounted, ref, nextTick, useSlots, createApp } from 'vue';
const slots = useSlots()
const outer = ref(null)
const inner = ref(null)
const destroyed = ref(false)

const emit = defineEmits(['end'])
const props = defineProps({
  /** 起点坐标 { x: number, y: number } */
  start: {
    type: Object,
    required: true
  },
  /** 终点坐标 { x: number, y: number } */
  end: {
    type: Object,
    required: true
  },
  /** 抛物线最高点高度,默认 100 */
  peakHeight: {
    type: Number,
    default: 100
  },
  /** 飞行速度(像素/秒 或一个倍率),默认 600 */
  speed: {
    type: Number,
    default: 30
  },
  /** 是否显示轨迹点 */
  showTrail: {
    type: Boolean,
    default: true
  },
  /** 轨迹点展示时间 ms */
  showTrailOption: {
    type: Object,
    default: {
      time: 1000,
      gap: 5,
      maxNum: 20
    }
  }
})
onMounted(() => {
  // console.log('ParabolaFly props:', props);
  // console.log('t1:', t1.value, 't2:', t2.value, 'totalTime:', totalTime.value);
  requestAnimationFrame(() => {
    // 内层控制竖直
    inner.value.style.transition = `transform ${t1.value}ms ease-out`
    inner.value.style.transform = `translateY(-${props.peakHeight}px)`
    // 外层控制水平
    outer.value.style.transition = `transform ${totalTime.value}ms linear`
    outer.value.style.transform = `translateX(${dx.value}px)`
    requestAnimationFrame(createTrail())
  })
})
function createTrail() {
  let count = props.showTrailOption.gap || 1
  const trailQueue = [] // ✅ 用数组保存轨迹点

  function draw() {
    if (destroyed.value) {
      while (trailQueue.length > 0) {
        const old = trailQueue.shift()
        if (old.__app__) old.__app__.unmount()
        old.remove()
      }
      return
    }

    if (--count <= 0) {
      count = props.showTrailOption.gap || 1

      if (props.showTrail && outer.value && inner.value) {
        const outerRect = outer.value.getBoundingClientRect()
        const innerRect = inner.value.getBoundingClientRect()

        const container = document.createElement('div')
        container.style.position = 'fixed'
        container.style.left = `${outerRect.x + outer.value.offsetWidth / 2}px`
        container.style.top = `${innerRect.y + outer.value.offsetHeight / 2}px`
        container.style.transform = 'translate(-50%, -50%)'
        container.style.pointerEvents = 'none'

        if (!slots.detail || slots.detail().length === 0) {
          // 默认轨迹点
          container.style.width = '6px'
          container.style.height = '6px'
          container.style.background = 'rgba(0,0,0,0.3)'
          container.style.borderRadius = '50%'
          document.body.appendChild(container)
        } else {
          // 自定义轨迹组件
          document.body.appendChild(container)
          const app = createApp({
            render: () => slots.detail()
          })
          app.mount(container)
          container.__app__ = app // ✅ 绑定引用,方便销毁
        }

        // 放入队列
        trailQueue.push(container)

        // 超过最大数量 → 移除最前面的
        const maxNum = props.showTrailOption.maxNum || 10
        if (trailQueue.length > maxNum) {
          const old = trailQueue.shift()
          if (old.__app__) old.__app__.unmount()
          old.remove()
        }

        // 定时删除(可选,不删也行,纯靠队列控制)
        setTimeout(() => {
          if (container.parentNode) {
            if (container.__app__) container.__app__.unmount()
            container.remove()
          }
        }, props.showTrailOption.time || 1000)
      }
    }

    requestAnimationFrame(draw)
  }

  return draw
}


function handleOuter(e) {
  // console.log('水平过渡结束', e);
  destroyed.value = true
  emit('end')
}
function handleInner(e) {
  // console.log("垂直动画结束!!!", e);
  // 内层控制竖直
  inner.value.style.transition = `transform ${t2.value}ms ease-in`
  inner.value.style.transform = `translateY(${props.peakHeight + dy.value}px)`
}
const t1 = computed(() => {
  // console.log(props.peakHeight);
  return Math.sqrt(props.peakHeight) / props.speed * 1000
})
const t2 = computed(() => {
  return Math.sqrt(props.peakHeight + dy.value) / props.speed * 1000
})
const totalTime = computed(() => {
  return (t1.value + t2.value)
})
const dx = computed(() => {
  return props.end.x - props.start.x
})
const dy = computed(() => {
  return props.end.y - props.start.y
})
</script>

main.ts

ts
import { Fly } from './fly'
const app = createApp(App)
app.use(Fly)
app.mount('#app')

App.vue(使用)

css
function handleClick() {
  Fly.show({
    start: { x: 100, y: 200 },
    end: { x: 800, y: 400 },
    peakHeight: 200,
    speed: 30,
    showTrail: true,
    showTrailOption: {
      time: 500,
      gap: 10,
      maxNum: 200
    },
    detail: () => h('div', {
  style: `
    width: 20px;
    height: 20px;
    background-color: green;
    clip-path: polygon(
      50% 0%,
      61% 35%,
      98% 35%,
      68% 57%,
      79% 91%,
      50% 70%,
      21% 91%,
      32% 57%,
      2% 35%,
      39% 35%
    );
  `
})
})
}

最终效果

评论

评论加载中……