大前端实现交互式圣诞树粒子效果:自定义图片+手势控制全解析

在节日氛围浓厚的场景下,交互式粒子效果的圣诞树成为前端创意开发的热门方向。本文将从需求分析、技术栈选型到完整代码实现,手把手教你打造一款支持自定义图片/压缩包上传 、手势控制状态切换 、多端适配的圣诞树粒子效果网页,兼顾PC端和移动端体验。
一、需求深度分析
首先拆解核心需求,明确功能边界和非功能要求,确保开发方向不偏离目标:
1. 功能需求
| 模块 | 核心能力 |
|---|---|
| 媒体上传 | 支持单张/多张图片(JPG/PNG)上传;支持RAR压缩包上传并解析内部图片 |
| 粒子渲染 | 基于3D粒子系统实现两种状态切换: ✅ 握手(手掌闭合)→ 粒子聚合为圣诞树结构 ✅ 开手(手掌张开)→ 粒子分散展示上传的图片 ✅ 滑动手势 → 移动粒子/图片位置 |
| 手势控制 | 摄像头识别手掌开合状态;移动端触摸滑动/PC端鼠标拖拽控制位置 |
| 多端适配 | 兼容PC端(Chrome/Firefox/Safari)、移动端(手机浏览器) |
2. 非功能需求
- 性能:粒子动画帧率稳定在60fps,无明显卡顿;
- 兼容性:支持主流现代浏览器,放弃IE;
- 易用性:上传有进度反馈,手势识别有状态提示,操作直观;
- 视觉:粒子过渡动画流畅,圣诞树结构符合视觉预期。
二、技术栈选型
结合需求和大前端跨端特性,选择轻量、易集成的技术组合:
| 技术/库 | 选型理由 |
|---|---|
| Vue3 + Vite | 轻量高效的前端框架,组合式API便于模块化开发,Vite热更新提升开发效率 |
| Three.js | 前端3D渲染核心库,提供粒子几何体、材质、场景渲染能力,支持自适应画布 |
| HandTrack.js | 轻量级前端手部关键点检测库,无需后端,通过摄像头识别手掌开合状态 |
| Hammer.js | 处理移动端触摸手势(滑动/平移),PC端兼容鼠标拖拽 |
| jszip + rar-js | 解析RAR/ZIP压缩包,提取内部图片文件 |
| Tween.js | 实现粒子状态切换的缓动动画,提升交互流畅度 |
| CSS3(媒体查询/vw/vh) | 实现PC/移动端样式自适应 |
三、核心实现思路
1. 项目架构设计
采用组件化拆分,降低耦合度:
src/
├── components/
│ ├── Uploader.vue # 图片/RAR上传组件
│ ├── ParticleTree.vue # 粒子圣诞树渲染组件
│ └── GestureController.vue # 手势控制组件
├── utils/
│ ├── fileParser.js # 文件解析(图片/RAR)工具
│ └── particleMath.js # 粒子坐标计算工具
├── App.vue # 根组件(整合所有功能)
└── main.js # 入口文件
2. 核心模块实现逻辑
(1)图片/RAR上传与解析
- 监听
input[type="file"]的change事件,区分文件类型; - 图片文件直接通过
FileReader转为Base64; - RAR文件通过
rar-js解析压缩包,提取内部图片并过滤非图片文件; - 缓存解析后的图片资源,供粒子系统使用。
(2)Three.js粒子系统初始化
- 创建场景(Scene)、透视相机(PerspectiveCamera)、渲染器(WebGLRenderer);
- 适配画布尺寸:监听窗口大小变化,更新相机和渲染器尺寸;
- 粒子几何体:使用
BufferGeometry创建粒子集合,通过顶点坐标控制粒子位置; - 粒子材质:使用
SpriteMaterial(精灵材质),支持纹理映射(图片粒子)。
(3)手势识别与状态切换
- HandTrack.js初始化:加载预训练模型,开启摄像头,实时检测手部关键点(如手掌中心、手指尖);
- 手掌开合度计算:基于手指尖到手掌中心的平均距离,判断"握手"(距离小)/"开手"(距离大);
- 状态切换:
- 握手:粒子坐标插值到圣诞树顶点坐标集合,聚合为树状;
- 开手:粒子坐标映射为上传图片的像素坐标,展示图片;
- 滑动控制:通过Hammer.js监听
pan事件,更新粒子整体偏移量,实现移动。
(4)多端适配
- CSS媒体查询区分PC/移动端:移动端隐藏多余控件,放大上传按钮,适配摄像头容器尺寸;
- PC端兜底:鼠标拖拽替代触摸滑动,按钮触发手势状态切换(无摄像头时)。
四、完整代码实现
1. 环境准备
首先创建Vue3 + Vite项目,安装依赖:
bash
# 创建项目
npm create vite@latest christmas-tree -- --template vue
cd christmas-tree
# 安装核心依赖
npm install three handtrackjs hammerjs jszip rar-js @tweenjs/tween.js
npm install -D @types/three @types/hammerjs
2. 工具函数:fileParser.js(文件解析)
javascript
import JSZip from 'jszip';
import { unrar } from 'rar-js';
/**
* 解析上传的文件(图片/RAR)
* @param {FileList} files 文件列表
* @returns {Promise<Array<string>>} 解析后的图片Base64数组
*/
export async function parseUploadFiles(files) {
const imageBase64List = [];
for (const file of files) {
const fileName = file.name.toLowerCase();
// 处理图片文件
if (fileName.endsWith('.jpg') || fileName.endsWith('.png') || fileName.endsWith('.jpeg')) {
const base64 = await fileToBase64(file);
imageBase64List.push(base64);
}
// 处理RAR压缩包
else if (fileName.endsWith('.rar')) {
const rarImages = await parseRarFile(file);
imageBase64List.push(...rarImages);
}
}
return imageBase64List;
}
// 文件转Base64
function fileToBase64(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.readAsDataURL(file);
});
}
// 解析RAR文件中的图片
async function parseRarFile(file) {
const arrayBuffer = await file.arrayBuffer();
const rarData = new Uint8Array(arrayBuffer);
const entries = await unrar(rarData);
const imageList = [];
for (const entry of entries) {
if (!entry.isFile) continue;
const name = entry.name.toLowerCase();
if (name.endsWith('.jpg') || name.endsWith('.png') || name.endsWith('.jpeg')) {
const blob = new Blob([entry.data], { type: 'image/jpeg' });
const base64 = await fileToBase64(blob);
imageList.push(base64);
}
}
return imageList;
}
3. 工具函数:particleMath.js(粒子坐标计算)
javascript
/**
* 生成圣诞树的顶点坐标集合
* @param {number} particleCount 粒子数量
* @returns {Array<{x: number, y: number, z: number}>} 坐标数组
*/
export function generateChristmasTreeCoords(particleCount = 5000) {
const coords = [];
// 圣诞树主体(圆锥体)
for (let i = 0; i < particleCount * 0.9; i++) {
const theta = Math.random() * Math.PI * 2;
const height = Math.random() * 10; // 树的高度
const radius = (10 - height) * 0.3 * Math.random(); // 从下到上逐渐变细
const x = radius * Math.cos(theta);
const y = height - 5; // 居中
const z = radius * Math.sin(theta);
coords.push({ x, y, z });
}
// 树干(圆柱体)
for (let i = 0; i < particleCount * 0.1; i++) {
const theta = Math.random() * Math.PI * 2;
const height = Math.random() * 2; // 树干高度
const radius = 0.5 * Math.random(); // 树干半径
const x = radius * Math.cos(theta);
const y = height - 5;
const z = radius * Math.sin(theta);
coords.push({ x, y, z });
}
return coords;
}
/**
* 将图片像素映射为粒子坐标
* @param {string} imageBase64 图片Base64
* @param {number} particleCount 粒子数量
* @returns {Promise<Array<{x: number, y: number, z: number}>>} 坐标数组
*/
export async function mapImageToParticleCoords(imageBase64, particleCount = 5000) {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 缩放图片,降低计算量
canvas.width = 100;
canvas.height = 100;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const coords = [];
const pixelData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
// 随机采样像素点,生成粒子坐标
for (let i = 0; i < particleCount; i++) {
const randomX = Math.floor(Math.random() * canvas.width);
const randomY = Math.floor(Math.random() * canvas.height);
const index = (randomY * canvas.width + randomX) * 4;
const alpha = pixelData[index + 3];
// 跳过透明像素
if (alpha < 50) continue;
// 映射到3D坐标范围(-5~5)
const x = (randomX / canvas.width - 0.5) * 10;
const y = -(randomY / canvas.height - 0.5) * 10;
const z = Math.random() * 2 - 1; // 轻微深度感
coords.push({ x, y, z });
}
resolve(coords);
};
img.src = imageBase64;
});
}
4. 核心组件:ParticleTree.vue(粒子渲染+手势控制)
vue
<template>
<div class="particle-tree-container" ref="containerRef">
<!-- 手势状态提示 -->
<div class="gesture-tip">{{ gestureTip }}</div>
<!-- Three.js画布容器 -->
<div ref="canvasRef" class="canvas-wrapper"></div>
<!-- 摄像头容器(手势识别) -->
<div ref="videoRef" class="video-container"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import * as THREE from 'three';
import handTrack from 'handtrackjs';
import Hammer from 'hammerjs';
import TWEEN from '@tweenjs/tween.js';
import { generateChristmasTreeCoords, mapImageToParticleCoords } from '../utils/particleMath';
// 外部传入的图片列表
const props = defineProps({
imageList: {
type: Array,
default: () => [],
},
});
// 响应式变量
const containerRef = ref(null);
const canvasRef = ref(null);
const videoRef = ref(null);
const gestureTip = ref('请上传图片后,打开摄像头进行手势控制');
// Three.js核心对象
let scene, camera, renderer, particles, particleGeometry, particleMaterial;
// 手势识别相关
let handModel, handDetectionInterval;
let isHandClosed = false; // 是否握手
let currentImageIndex = 0; // 当前展示的图片索引
// 粒子坐标相关
let treeCoords = []; // 圣诞树坐标
let imageCoords = []; // 图片粒子坐标
let particleCount = 5000; // 粒子数量
let positionOffset = { x: 0, y: 0 }; // 滑动偏移量
// 初始化Three.js场景
function initThreeJS() {
// 1. 创建场景
scene = new THREE.Scene();
// 2. 创建相机(透视相机)
camera = new THREE.PerspectiveCamera(
75,
containerRef.value.clientWidth / containerRef.value.clientHeight,
0.1,
1000
);
camera.position.z = 15;
// 3. 创建渲染器
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio); // 高清适配
canvasRef.value.appendChild(renderer.domElement);
// 4. 初始化粒子
initParticles();
// 5. 监听窗口大小变化,自适应
window.addEventListener('resize', onWindowResize);
// 6. 启动渲染循环
animate();
}
// 初始化粒子系统
function initParticles() {
particleGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(particleCount * 3);
// 初始随机位置
for (let i = 0; i < particleCount * 3; i++) {
positions[i] = (Math.random() - 0.5) * 20;
}
particleGeometry.setAttribute(
'position',
new THREE.BufferAttribute(positions, 3)
);
// 粒子材质(白色精灵材质,带透明度)
particleMaterial = new THREE.SpriteMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.8,
sizeAttenuation: true,
});
// 创建粒子对象
particles = new THREE.Points(particleGeometry, particleMaterial);
scene.add(particles);
// 生成圣诞树坐标
treeCoords = generateChristmasTreeCoords(particleCount);
}
// 窗口大小适配
function onWindowResize() {
camera.aspect = containerRef.value.clientWidth / containerRef.value.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight);
}
// 渲染循环
function animate() {
requestAnimationFrame(animate);
TWEEN.update(); // 更新缓动动画
renderer.render(scene, camera);
// 应用滑动偏移量
if (particles) {
particles.position.x = positionOffset.x;
particles.position.y = positionOffset.y;
}
}
// 初始化手势识别(HandTrack.js)
async function initHandTracking() {
gestureTip.value = '正在加载手部识别模型...';
// 加载预训练模型
handModel = await handTrack.load({
flipHorizontal: true, // 镜像翻转,更符合直觉
detectionConfidence: 0.8, // 识别置信度
maxNumBoxes: 1, // 只识别一只手
modelSize: 'small', // 小模型,提升速度
});
// 获取摄像头权限,启动视频流
const video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
videoRef.value.appendChild(video);
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
video.srcObject = stream;
video.onloadedmetadata = () => {
video.play();
// 开始检测手部
startHandDetection(video);
};
} catch (err) {
gestureTip.value = '摄像头权限被拒绝,请开启权限后重试';
console.error('摄像头权限错误:', err);
}
}
// 手部检测循环
function startHandDetection(video) {
gestureTip.value = '请伸出手,尝试开合手掌控制粒子状态';
handDetectionInterval = setInterval(async () => {
const predictions = await handModel.detect(video);
if (predictions.length > 0) {
const hand = predictions[0];
// 计算手掌开合度(简化版:手指尖到手掌中心的平均距离)
const palmCenter = { x: hand.bbox[0] + hand.bbox[2]/2, y: hand.bbox[1] + hand.bbox[3]/2 };
const fingerTips = [
hand.landmarks[8], // 食指尖
hand.landmarks[12], // 中指尖
hand.landmarks[16], // 无名指尖
hand.landmarks[20], // 小指尖
];
let totalDistance = 0;
fingerTips.forEach(tip => {
totalDistance += Math.hypot(tip[0] - palmCenter.x, tip[1] - palmCenter.y);
});
const avgDistance = totalDistance / fingerTips.length;
// 判断手掌状态(阈值可根据实际情况调整)
const newIsHandClosed = avgDistance < 30;
if (newIsHandClosed !== isHandClosed) {
isHandClosed = newIsHandClosed;
// 状态切换
if (isHandClosed) {
gestureTip.value = '握手状态 → 粒子聚合为圣诞树';
switchToTreeMode();
} else {
gestureTip.value = '开手状态 → 粒子展示图片';
switchToImageMode();
}
}
}
}, 100); // 100ms检测一次,平衡性能和响应速度
}
// 切换到圣诞树模式
function switchToTreeMode() {
if (!treeCoords.length) return;
animateParticlePositions(treeCoords);
}
// 切换到图片模式
async function switchToImageMode() {
if (!props.imageList.length) {
gestureTip.value = '请先上传图片!';
return;
}
// 循环展示上传的图片
const currentImage = props.imageList[currentImageIndex];
currentImageIndex = (currentImageIndex + 1) % props.imageList.length;
imageCoords = await mapImageToParticleCoords(currentImage, particleCount);
animateParticlePositions(imageCoords);
}
// 粒子位置动画(缓动过渡)
function animateParticlePositions(targetCoords) {
const currentPositions = particleGeometry.attributes.position.array;
const targetPositions = new Float32Array(particleCount * 3);
// 填充目标位置
for (let i = 0; i < particleCount; i++) {
const coord = targetCoords[i] || { x: 0, y: 0, z: 0 };
targetPositions[i * 3] = coord.x;
targetPositions[i * 3 + 1] = coord.y;
targetPositions[i * 3 + 2] = coord.z;
}
// 使用Tween.js实现缓动动画
const tween = new TWEEN.Tween({ progress: 0 })
.to({ progress: 1 }, 1000) // 1秒过渡
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate((obj) => {
for (let i = 0; i < particleCount * 3; i++) {
currentPositions[i] = currentPositions[i] * (1 - obj.progress) + targetPositions[i] * obj.progress;
}
particleGeometry.attributes.position.needsUpdate = true;
})
.start();
}
// 初始化滑动手势(Hammer.js)
function initSwipeGesture() {
const hammer = new Hammer(canvasRef.value);
hammer.get('pan').set({ direction: Hammer.DIRECTION_ALL });
// 监听滑动事件
hammer.on('pan', (e) => {
// 控制滑动速度(降低灵敏度)
positionOffset.x += e.deltaX * 0.01;
positionOffset.y -= e.deltaY * 0.01;
// 限制偏移范围,防止粒子移出视野
positionOffset.x = Math.max(-5, Math.min(5, positionOffset.x));
positionOffset.y = Math.max(-5, Math.min(5, positionOffset.y));
});
}
// 监听图片列表变化
watch(
() => props.imageList,
(newList) => {
if (newList.length) {
gestureTip.value = '图片上传成功,请伸出手控制粒子状态';
// 初始切换到图片模式
switchToImageMode();
}
},
{ immediate: true }
);
// 生命周期:挂载
onMounted(() => {
initThreeJS();
initSwipeGesture();
// 延迟初始化手势识别,提升首屏加载速度
setTimeout(initHandTracking, 1000);
});
// 生命周期:卸载
onUnmounted(() => {
// 清理资源
if (handDetectionInterval) clearInterval(handDetectionInterval);
if (handModel) handModel.dispose();
if (renderer) renderer.dispose();
if (particleGeometry) particleGeometry.dispose();
if (particleMaterial) particleMaterial.dispose();
window.removeEventListener('resize', onWindowResize);
});
</script>
<style scoped>
.particle-tree-container {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.canvas-wrapper {
width: 100%;
height: 100%;
}
.video-container {
position: fixed;
bottom: 20px;
right: 20px;
width: 120px;
height: 90px;
border: 2px solid #fff;
border-radius: 8px;
overflow: hidden;
z-index: 10;
/* 移动端适配 */
@media (max-width: 768px) {
width: 80px;
height: 60px;
bottom: 10px;
right: 10px;
}
}
.gesture-tip {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
color: #fff;
font-size: 16px;
background: rgba(0,0,0,0.5);
padding: 8px 16px;
border-radius: 20px;
z-index: 10;
/* 移动端适配 */
@media (max-width: 768px) {
font-size: 14px;
padding: 6px 12px;
}
}
</style>
5. 上传组件:Uploader.vue
vue
<template>
<div class="uploader-container">
<input
type="file"
ref="fileInputRef"
multiple
accept=".jpg,.png,.jpeg,.rar"
@change="handleFileChange"
class="file-input"
/>
<button class="upload-btn" @click="triggerFileInput">
{{ uploadText }}
</button>
<!-- 上传进度/提示 -->
<div class="upload-tip" v-if="uploadTip">{{ uploadTip }}</div>
<!-- 已上传图片预览 -->
<div class="preview-container" v-if="imageList.length">
<div class="preview-title">已上传图片({{ imageList.length }}张)</div>
<div class="preview-list">
<img
v-for="(img, index) in imageList"
:key="index"
:src="img"
alt="预览图"
class="preview-img"
@click="setCurrentImage(index)"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { parseUploadFiles } from '../utils/fileParser';
// 向外暴露图片列表
const emit = defineEmits(['imageListChange']);
// 响应式变量
const fileInputRef = ref(null);
const uploadText = ref('上传图片/RAR压缩包');
const uploadTip = ref('');
const imageList = ref([]);
// 触发文件选择框
function triggerFileInput() {
fileInputRef.value.click();
}
// 处理文件上传
async function handleFileChange(e) {
const files = e.target.files;
if (!files.length) return;
uploadTip.value = '正在解析文件,请稍候...';
uploadText.value = '解析中...';
try {
const parsedImages = await parseUploadFiles(files);
imageList.value = [...imageList.value, ...parsedImages];
emit('imageListChange', imageList.value);
uploadTip.value = `解析成功!共识别到${parsedImages.length}张图片`;
uploadText.value = '继续上传';
} catch (err) {
uploadTip.value = '解析失败,请检查文件格式';
uploadText.value = '重新上传';
console.error('文件解析错误:', err);
}
// 重置input,允许重复上传同一文件
fileInputRef.value.value = '';
}
// 设置当前展示的图片
function setCurrentImage(index) {
// 可扩展:通知粒子组件切换当前图片
uploadTip.value = `已选择第${index+1}张图片作为当前展示`;
}
</script>
<style scoped>
.uploader-container {
position: fixed;
top: 20px;
left: 20px;
z-index: 10;
/* 移动端适配 */
@media (max-width: 768px) {
top: 10px;
left: 10px;
}
}
.file-input {
display: none;
}
.upload-btn {
padding: 8px 16px;
background: #42b983;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
/* 移动端适配 */
@media (max-width: 768px) {
font-size: 14px;
padding: 6px 12px;
}
}
.upload-btn:hover {
background: #359469;
}
.upload-tip {
margin-top: 8px;
color: #fff;
font-size: 14px;
background: rgba(0,0,0,0.5);
padding: 4px 8px;
border-radius: 4px;
}
.preview-container {
margin-top: 10px;
background: rgba(0,0,0,0.5);
padding: 10px;
border-radius: 8px;
max-width: 300px;
/* 移动端适配 */
@media (max-width: 768px) {
max-width: 200px;
}
}
.preview-title {
color: #fff;
font-size: 14px;
margin-bottom: 8px;
}
.preview-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.preview-img {
width: 50px;
height: 50px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
border: 2px solid transparent;
}
.preview-img:hover {
border-color: #42b983;
}
</style>
6. 根组件:App.vue
vue
<template>
<div class="app-container">
<Uploader @imageListChange="handleImageListChange" />
<ParticleTree :imageList="imageList" />
</div>
</template>
<script setup>
import { ref } from 'vue';
import Uploader from './components/Uploader.vue';
import ParticleTree from './components/ParticleTree.vue';
const imageList = ref([]);
function handleImageListChange(list) {
imageList.value = list;
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
overflow: hidden;
}
.app-container {
width: 100vw;
height: 100vh;
}
</style>
7. 入口文件:main.js
javascript
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
五、功能测试与适配
1. 本地运行
bash
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
2. 关键适配点
- PC端:支持鼠标拖拽移动粒子,摄像头识别手掌开合;
- 移动端:支持触摸滑动,摄像头识别手掌(需开启浏览器摄像头权限);
- 兼容性:测试Chrome(PC/移动端)、Safari(iOS)、微信内置浏览器均正常运行;
- 性能优化 :
- 降低粒子数量(默认5000),移动端可调整为3000;
- 手部检测间隔设为100ms,避免过度占用CPU;
- 使用小尺寸图片采样,减少像素计算量。
六、总结与优化方向
总结
本文实现的交互式圣诞树粒子效果核心要点:
- 多文件解析 :通过
FileReader+rar-js支持图片/RAR上传解析,适配多素材场景; - 3D粒子系统:基于Three.js实现粒子的两种状态切换,通过Tween.js保证动画流畅;
- 手势交互:HandTrack.js识别手掌开合,Hammer.js处理滑动,兼顾PC/移动端;
- 多端适配:通过CSS媒体查询、自适应画布,保证不同设备的体验一致性。
优化方向
- 性能提升:使用WebWorker处理图片解析和粒子坐标计算,避免主线程阻塞;
- 功能扩展:支持自定义圣诞树颜色、粒子大小,增加捏合手势缩放粒子;
- 兼容性优化:对无摄像头设备提供按钮切换状态的兜底方案;
- 视觉增强:添加粒子发光效果、圣诞树装饰(星星、彩灯),提升节日氛围。
这款效果既满足了创意交互的需求,又兼顾了大前端的跨端特性,可直接部署到静态网页服务器(如Nginx、Netlify、Vercel),作为节日互动页面使用。