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;
}