Skip to content

DOM 發射器 util

可以發射任何網頁內容!੭ ˙∀˙ )੭

0

技術關鍵字

名稱 描述
物理模擬模擬真實世界物理現象,如重力、碰撞、速度等物理效果
Canvas 2D API基礎的 2D 繪圖 API,可以高效繪製比 DOM 更複雜的圖形
Canvas Shader使用 GLSL 開發,直接在 GPU 上執行,比 Canvas 2D API 更快,但也更難
Memoization藉由快取 function 計算結果,提高性能,避免重複計算
DOM to Image將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現

使用範例

基本用法

放入 slot 的內容可以做為粒子發射,若 DOM 發生變化,粒子圖片也會自動更新。( •̀ ω •́ )✧

(點擊卡片可以切換樣式)

電量!電量你振作一點啊!
Σ(ˊДˋ;)
2023/10/01
已購買,小孩愛吃
(ゝ∀・)b
2022/12/06
不管你嗑了甚麼,都不要給我
(´ー`)
2024/04/03
請不要讓我老闆看到你這鬼東西
(。-`ω´-)
2025/07/11
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4">
    <util-dom-emitter
      ref="emitterRef"
      :slot-list="keys(slots)"
      class="grid justify-items-center gap-2 md:grid-cols-2"
      slot-wrapper-class="  w-2/3 md:w-full"
      canvas-z-index="9999"
    >
      <template
        v-for="(slot, name) in slots"
        #[name]
        :key="name"
      >
        <div
          class="grid grid-cols-3 grid-rows-2 h-full cursor-pointer gap-2 rounded p-2 text-sm"
          :class="slot.type"
          @click="nextType(slot)"
        >
          <div class="row-span-2 flex-center">
            <img
              :src="slot.avatar"
              class="avatar row-span-2 aspect-square shrink-0 object-cover"
              @load="refreshDomImage()"
            >
          </div>

          <span
            class="col-span-2 row-span-1"
            v-html="slot.value"
          />

          <div class="col-span-2 row-span-1 flex">
            <span
              v-for="n in slot.rate"
              :key="n"
              class="rate-star"
            />

            <span
              class="flex-1 self-end text-right text-xs text-gray-300"
              v-text="slot.date"
            />
          </div>
        </div>
      </template>
    </util-dom-emitter>

    <div class="flex gap-2">
      <base-input
        v-model="emitInterval"
        type="range"
        :label="`發射週期 : ${emitInterval}`"
        :min="1"
        :max="1000"
        class="flex-1 border rounded-lg p-4"
      />

      <base-checkbox
        v-model="enableEmit"
        label="開始發射"
        class="border rounded-lg p-4"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
import { debounce } from 'lodash-es'
import { nanoid } from 'nanoid'
import { keys } from 'remeda'
import { reactive, ref, useTemplateRef } from 'vue'
import BaseCheckbox from '../../base-checkbox.vue'
import BaseInput from '../../base-input.vue'
// import UtilDomEmitter from '../util-dom-emitter-2d.vue'
import UtilDomEmitter from '../util-dom-emitter.vue'

const typeList = ['t1', 't2', 't3', 't4'] as const

interface SlotDatum {
  avatar: string;
  rate: number;
  value: string;
  type: typeof typeList[number];
  date: string;
}

const defaultSlotDatum: SlotDatum = {
  avatar: '/logo.webp',
  rate: 0,
  value: '',
  type: 't1',
  date: '',
}

const emitterRef = useTemplateRef('emitterRef')
const slots = reactive({
  [nanoid()]: {
    ...defaultSlotDatum,
    rate: 1,
    value: '電量!電量你振作一點啊!<br>Σ(ˊДˋ;)',
    date: '2023/10/01',
  },
  [nanoid()]: {
    ...defaultSlotDatum,
    avatar: '/low/profile.webp',
    rate: 5,
    value: '已購買,小孩愛吃<br>(ゝ∀・)b',
    date: '2022/12/06',
  },
  [nanoid()]: {
    ...defaultSlotDatum,
    avatar: '/low//profile-2.webp',
    rate: 4,
    value: '不管你嗑了甚麼,都不要給我<br>(´ー`)',
    date: '2024/04/03',
  },
  [nanoid()]: {
    ...defaultSlotDatum,
    avatar: '/low//profile-3.webp',
    rate: 3,
    value: '請不要讓我老闆看到你這鬼東西<br>(。-`ω´-)',
    date: '2025/07/11',
  },
})

function nextType(data: SlotDatum) {
  const index = typeList.indexOf(data.type) + 1
  data.type = typeList.at(index % typeList.length) ?? 't1'
}

/** 偵測不到圖片載入變化,手動更新 DOM 圖片 */
const refreshDomImage = debounce(() => {
  emitterRef.value?.refreshDomImage()
}, 100)

const enableEmit = ref(false)
const emitInterval = ref(500)
useIntervalFn(() => {
  if (!enableEmit.value) {
    return
  }

  emitterRef.value?.emit({
    quantity: 4,
    x: [0, window.innerWidth],
    y: window.innerHeight + 80,
    vx: [-50, 50],
    vy: [-300, -800],
    a: 0,
    va: [-0.3, 0.3],
  })
}, emitInterval)
</script>

<style scoped lang="sass">
.t1
  background: #FDFDFD
  border: 1px solid #DDD
  .avatar
    background: #c2eeff
    border-radius: 50%
  .rate-star
    &::before
      content: '★'
      color: #f59e0b
.t2
  border-radius: 0px
  background-color: #0d9488
  border: 1px solid #0d9488
  color: white
  .avatar
    background: #99f6e4
    clip-path: polygon(30% 0%, 70% 0%, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0% 70%, 0% 30%)
  .rate-star
    &::before
      content: '🍕'
      color: #0d9488

.t3
  border-radius: 1rem
  background-color: #fef2f2
  border: 1px solid #fecaca
  color: #b91c1c
  .avatar
    border-radius: 1rem
  .rate-star
    &::before
      content: '❤️'
      color: #b91c1c
.t4
  border-radius: 999px
  padding-right: 2rem
  background-color: #fdf4ff
  border: 4px solid #f5d0fe
  color: #a855f7
  .avatar
    border-radius: 999px
    background: #f5d0fe
  .rate-star
    &::before
      content: '🔥'
      color: #a855f7
</style>

原理

取得 slot 中的 DOM 並轉換成圖片,接著將圖片作為粒子,進行物理模擬。

粒子資料與圖片都有了,最後就是在 Canvas 中繪製粒子即可。(・∀・)9

後記

第一個使用 Shader 開發的元件登場,放個紀念煙火。✧⁑。٩(ˊᗜˋ*)و✧⁕。

一開始使用 Canvas 2D API 開發,但粒子數量一多,性能太過慘烈,所以決定改用 Shader 試試看。

說是這麼說,Shader 真的是有夠天書...(›´ω`‹ ),只好請來強大的 AI 幫我基於原本的 2D 版本,轉成 Shader 並詳細解釋。∠( ᐛ 」∠)_

所有的模型都試了一輪(Sonnet 4、GPT 4.1、GPT 4o、Gemini 2.5 Pro、GPT o3),任一個模型的程式碼都沒有辦法直接運行,都會有各種小錯誤,需要手動修正。

最後只有 GPT o3 的程式碼可以實際運行,其他模型的程式碼即使調整到沒有任何錯誤,也無法正常運行。( ˙꒳​˙)

真心的覺得 AI 是個很好的老師,不然像以往那樣慢慢搜尋、看教學,半個毛線都沒生出來,熱情已經先消磨殆盡了。( ˘・з・)

現在可以更快速地驗證想法、實作有趣的點子,但也一定程度的降低了某些專業門檻。

只能說我們迎來了一個最好的時代,也是最壞的時代。(´・ω・`)

2D API vs Shader

我有保留 Canvas 2D API 版本,大家可以和目前 Shader 版本的程式碼比較看看。

可以看到 Shader 版本遠比 2D 版本複雜 N 倍以上,但是性能也提升了 N 倍以上,真的相當有趣。੭ ˙ᗜ˙ )੭

原始碼

API

Props

interface Props {
  slotList: SlotList;
  gravity?: number;
  slotWrapperClass?: string;
  canvasZIndex?: number | string;
}

Methods

/** 粒子發射參數
 *
 * [number, number] 表示此範圍隨機值
 */
interface ParticleParams {
  quantity: number;
  x: number | [number, number];
  y: number | [number, number];
  vx: number | [number, number];
  vy: number | [number, number];
  /** 角度 */
  a: number | [number, number];
  /** 角速度 */
  va: number | [number, number];
}

/** 發射特定 slot,或者所有 slot 平均 */
type EmitParams = Record<keyof SlotList, ParticleParams> | ParticleParams

interface Expose {
  emit: (params: EmitParams) => void;
  /** 若粒子圖片與 DOM 有落差,可以手動更新 */
  refreshDomImage: () => void;
}

Slots

type Slots = {
  [K in SlotList[number]]: () => unknown;
}

v0.44.1