优化聊天室特效加载与移动端性能

This commit is contained in:
2026-04-25 03:34:19 +08:00
parent 128b52d0aa
commit e3cba255f9
6 changed files with 158 additions and 25 deletions
+1 -12
View File
@@ -1,18 +1,7 @@
/** /**
* 文件功能:聊天室全屏特效 Vite 入口 * 文件功能:聊天室全屏特效 Vite 入口
* *
* 按原 public script 顺序加载所有全屏特效模块,让生产环境由 Vite 统一压缩、加 hash 并减少请求数 * 首屏仅加载特效管理器,具体特效模块和音效模块由管理器在首次播放时按需加载
*/ */
import "./effects/effect-sounds.js";
import "./effects/effect-manager.js"; import "./effects/effect-manager.js";
import "./effects/fireworks.js";
import "./effects/rain.js";
import "./effects/lightning.js";
import "./effects/snow.js";
import "./effects/sakura.js";
import "./effects/meteors.js";
import "./effects/gold-rain.js";
import "./effects/hearts.js";
import "./effects/confetti.js";
import "./effects/fireflies.js";
+5 -2
View File
@@ -92,7 +92,10 @@ const ConfettiEffect = (() => {
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
const DURATION = 7800; const DURATION = 7800;
let pieces = Array.from({ length: 90 }, () => new Piece(w, h)); const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640;
const initialPieceCount = isMobile ? 56 : 90;
const spawnPieceCount = isMobile ? 6 : 10;
let pieces = Array.from({ length: initialPieceCount }, () => new Piece(w, h));
const startTime = performance.now(); const startTime = performance.now();
let lastSpawnAt = startTime; let lastSpawnAt = startTime;
let animId = null; let animId = null;
@@ -129,7 +132,7 @@ const ConfettiEffect = (() => {
}); });
if (now - startTime < DURATION * 0.9 && now - lastSpawnAt >= 120) { if (now - startTime < DURATION * 0.9 && now - lastSpawnAt >= 120) {
pieces.push(...Array.from({ length: 10 }, () => new Piece(w, h))); pieces.push(...Array.from({ length: spawnPieceCount }, () => new Piece(w, h)));
lastSpawnAt = now; lastSpawnAt = now;
} }
+140 -6
View File
@@ -7,6 +7,24 @@
*/ */
const EffectManager = (() => { const EffectManager = (() => {
// 音效模块只在首次播放特效时加载,缓存 Promise 避免重复 import
let _soundModulePromise = null;
// 各特效模块加载器,使用静态路径让 Vite 拆分为可按需加载的 chunk
const EFFECT_MODULES = {
fireworks: { key: "fireworks", load: () => import("./fireworks.js") },
"wedding-fireworks": { key: "fireworks", load: () => import("./fireworks.js") },
rain: { key: "rain", load: () => import("./rain.js") },
lightning: { key: "lightning", load: () => import("./lightning.js") },
snow: { key: "snow", load: () => import("./snow.js") },
sakura: { key: "sakura", load: () => import("./sakura.js") },
meteors: { key: "meteors", load: () => import("./meteors.js") },
"gold-rain": { key: "gold-rain", load: () => import("./gold-rain.js") },
hearts: { key: "hearts", load: () => import("./hearts.js") },
confetti: { key: "confetti", load: () => import("./confetti.js") },
fireflies: { key: "fireflies", load: () => import("./fireflies.js") },
};
// 特效模块 Promise 缓存,同类型重复触发时复用同一次加载
const _effectModulePromises = new Map();
// 当前正在播放的特效名称(防止同时播放两个特效) // 当前正在播放的特效名称(防止同时播放两个特效)
let _current = null; let _current = null;
// 当前特效返回的取消函数,用于手动停止时真正停掉内部 RAF / 定时器 // 当前特效返回的取消函数,用于手动停止时真正停掉内部 RAF / 定时器
@@ -17,6 +35,8 @@ const EffectManager = (() => {
const _queue = []; const _queue = [];
// 队列最多保留 3 个待播特效,避免高频触发后播放过期动画 // 队列最多保留 3 个待播特效,避免高频触发后播放过期动画
const MAX_QUEUE_LENGTH = 3; const MAX_QUEUE_LENGTH = 3;
// 普通特效排队超过 10 秒后丢弃,避免播放过期动画
const QUEUED_EFFECT_TTL = 10000;
// 当前特效播放批次,用于忽略手动停止后的旧回调 // 当前特效播放批次,用于忽略手动停止后的旧回调
let _playToken = 0; let _playToken = 0;
// 是否已经绑定本轮点击停止监听 // 是否已经绑定本轮点击停止监听
@@ -154,6 +174,16 @@ const EffectManager = (() => {
stop(); stop();
} }
/**
* 页面进入后台时停止特效,避免隐藏标签页继续运行 Canvas 和音效。
*/
function _handleVisibilityChange() {
if (document.hidden) {
stop();
_queue.length = 0;
}
}
/** /**
* 特效结束后清理 Canvas,重置状态,并停止音效。 * 特效结束后清理 Canvas,重置状态,并停止音效。
* *
@@ -186,8 +216,8 @@ const EffectManager = (() => {
window.EffectSounds.stop(); window.EffectSounds.stop();
} }
if (playNext && _queue.length > 0) { if (playNext) {
const nextType = _queue.shift(); const nextType = _dequeueNextType();
if (nextType) { if (nextType) {
play(nextType); play(nextType);
} }
@@ -200,17 +230,84 @@ const EffectManager = (() => {
* @param {string} type 待播放特效类型 * @param {string} type 待播放特效类型
*/ */
function _enqueue(type) { function _enqueue(type) {
const existingIndex = _queue.indexOf(type); const existingIndex = _queue.findIndex((item) => item.type === type);
if (existingIndex !== -1) { if (existingIndex !== -1) {
_queue.splice(existingIndex, 1); _queue.splice(existingIndex, 1);
} }
_queue.push(type); _queue.push({
type,
queuedAt: Date.now(),
keepUntilPlayed: type === "wedding-fireworks",
});
while (_queue.length > MAX_QUEUE_LENGTH) { while (_queue.length > MAX_QUEUE_LENGTH) {
_queue.shift(); _queue.shift();
} }
} }
/**
* 取出下一个仍然有效的排队特效。
*
* @returns {string|null}
*/
function _dequeueNextType() {
const now = Date.now();
while (_queue.length > 0) {
const next = _queue.shift();
if (next.keepUntilPlayed || now - next.queuedAt <= QUEUED_EFFECT_TTL) {
return next.type;
}
}
return null;
}
/**
* 确保音效模块已经加载。
*
* @returns {Promise<void>}
*/
function _loadSoundModule() {
if (!_soundModulePromise) {
_soundModulePromise = import("./effect-sounds.js");
}
return _soundModulePromise;
}
/**
* 确保指定特效模块已经加载。
*
* @param {string} type 特效类型
* @returns {Promise<void>}
*/
function _loadEffectModule(type) {
const moduleConfig = EFFECT_MODULES[type];
if (!moduleConfig) {
return Promise.reject(new Error(`未知特效类型:${type}`));
}
if (!_effectModulePromises.has(moduleConfig.key)) {
_effectModulePromises.set(moduleConfig.key, moduleConfig.load());
}
return _effectModulePromises.get(moduleConfig.key);
}
/**
* 播放前加载对应特效模块和音效模块。
*
* @param {string} type 特效类型
* @returns {Promise<void>}
*/
function _loadModulesFor(type) {
return Promise.all([
_loadEffectModule(type),
_loadSoundModule(),
]).then(() => {});
}
/** /**
* 记录具体特效返回的取消句柄。 * 记录具体特效返回的取消句柄。
* *
@@ -246,16 +343,51 @@ const EffectManager = (() => {
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies * @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
*/ */
function play(type) { function play(type) {
if (document.hidden) {
return;
}
if (!EFFECT_MODULES[type]) {
console.warn(`[EffectManager] 未知特效类型:${type}`);
return;
}
// 防重入:同时只允许一个特效 // 防重入:同时只允许一个特效
if (_current) { if (_current) {
console.log(`[EffectManager] 特效 ${_current} 正在播放,加入队列 ${type}`);
_enqueue(type); _enqueue(type);
return; return;
} }
const canvas = _getCanvas(); _play(type);
}
/**
* 加载模块后播放指定特效。
*
* @param {string} type 特效类型
*/
async function _play(type) {
_current = type; _current = type;
const token = _playToken; const token = _playToken;
try {
await _loadModulesFor(type);
} catch (error) {
console.error(`[EffectManager] 加载特效模块失败:${type}`, error);
_cleanup({ token });
return;
}
if (token !== _playToken || _current !== type) {
return;
}
if (document.hidden) {
_cleanup({ playNext: false, token });
return;
}
const canvas = _getCanvas();
const finishCurrent = () => _cleanup({ token }); const finishCurrent = () => _cleanup({ token });
_bindClickStop(); _bindClickStop();
@@ -328,6 +460,8 @@ const EffectManager = (() => {
_cleanup({ playNext: false, cancelCurrent: true }); _cleanup({ playNext: false, cancelCurrent: true });
} }
document.addEventListener("visibilitychange", _handleVisibilityChange);
return { play, stop }; return { play, stop };
})(); })();
+3 -1
View File
@@ -102,7 +102,9 @@ const GoldRainEffect = (() => {
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
const DURATION = 8600; const DURATION = 8600;
const COIN_COUNT = Math.min(58, Math.max(28, Math.floor(w / 28))); const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640;
const densityScale = isMobile ? 0.72 : 1;
const COIN_COUNT = Math.round(Math.min(58, Math.max(28, Math.floor(w / 28))) * densityScale);
const coins = Array.from({ length: COIN_COUNT }, () => new Coin(w, h)); const coins = Array.from({ length: COIN_COUNT }, () => new Coin(w, h));
const startTime = performance.now(); const startTime = performance.now();
let animId = null; let animId = null;
+2 -1
View File
@@ -66,7 +66,8 @@ const RainEffect = (() => {
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
const DURATION = 8000; const DURATION = 8000;
const DROP_COUNT = 200; // 增加雨滴数量(原 180 const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640;
const DROP_COUNT = isMobile ? 130 : 200; // 移动端降低雨线数量,避免满屏线段拖慢滚动和输入。
const drops = Array.from({ length: DROP_COUNT }, () => { const drops = Array.from({ length: DROP_COUNT }, () => {
const d = new Drop(w, h); const d = new Drop(w, h);
+7 -3
View File
@@ -161,18 +161,22 @@ const SnowEffect = (() => {
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
const DURATION = 10000; const DURATION = 10000;
const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640;
const densityScale = isMobile ? 0.68 : 1;
const backFlakeCount = Math.round(Math.min(120, Math.max(70, Math.floor(w / 18))) * densityScale);
const frontFlakeCount = Math.round(Math.min(64, Math.max(34, Math.floor(w / 42))) * densityScale);
const flakes = [ const flakes = [
...Array.from( ...Array.from(
{ length: Math.min(120, Math.max(70, Math.floor(w / 18))) }, { length: backFlakeCount },
() => new Flake(w, h, "back"), () => new Flake(w, h, "back"),
), ),
...Array.from( ...Array.from(
{ length: Math.min(64, Math.max(34, Math.floor(w / 42))) }, { length: frontFlakeCount },
() => new Flake(w, h, "front"), () => new Flake(w, h, "front"),
), ),
]; ];
const breezeBands = Array.from({ length: 2 }, () => ({ const breezeBands = Array.from({ length: isMobile ? 1 : 2 }, () => ({
x: Math.random() * w, x: Math.random() * w,
y: Math.random() * h, y: Math.random() * h,
radius: 180 + Math.random() * 140, radius: 180 + Math.random() * 140,