動(dòng)畫系統(tǒng)中的狀態(tài)機(jī)設(shè)計(jì):從簡(jiǎn)單過(guò)渡到復(fù)雜編排的架構(gòu)演進(jìn)
動(dòng)畫系統(tǒng)中的狀態(tài)機(jī)設(shè)計(jì)從簡(jiǎn)單過(guò)渡到復(fù)雜編排的架構(gòu)演進(jìn)一、引子if-else 堆砌的動(dòng)畫代碼無(wú)法維護(hù)一個(gè)下拉菜單的動(dòng)畫需求打開(kāi)時(shí)遮罩淡入 → 菜單從上方滑入 → 列表項(xiàng)依次彈出stagger關(guān)閉時(shí)列表項(xiàng)淡出 → 菜單滑出 → 遮罩淡出快速雙擊時(shí)不應(yīng)觸發(fā)兩次動(dòng)畫動(dòng)畫進(jìn)行中切換路由時(shí)立即停止動(dòng)畫用 if-else 寫出來(lái)約 80 行但快速雙擊時(shí)動(dòng)畫堆疊用戶看到兩個(gè)菜單同時(shí)出現(xiàn)路由切換時(shí)動(dòng)畫還在內(nèi)存中運(yùn)行。引入狀態(tài)機(jī)后同樣的邏輯縮減到 30 行且狀態(tài)轉(zhuǎn)換清晰可測(cè)。這個(gè)下拉菜單的 Bug 在我們的組件庫(kù)中存活了三個(gè)版本。第一版用setTimeout控制時(shí)序快速雙擊時(shí)兩個(gè)setTimeout同時(shí)運(yùn)行用戶看到菜單打開(kāi)又打開(kāi)。第二版加了isAnimating布爾值做鎖但路由切換時(shí)鎖沒(méi)釋放下次進(jìn)入頁(yè)面菜單永遠(yuǎn)打不開(kāi)。第三版用狀態(tài)機(jī)重構(gòu)問(wèn)題徹底消失——因?yàn)闋顟B(tài)機(jī)的當(dāng)前狀態(tài)是唯一的不存在兩個(gè)動(dòng)畫同時(shí)爭(zhēng)奪 DOM 控制權(quán)的情況。狀態(tài)機(jī)最優(yōu)雅的地方在于它把什么時(shí)候該做什么從命令式代碼變成了聲明式配置狀態(tài)的合法轉(zhuǎn)換被圖結(jié)構(gòu)約束非法路徑在設(shè)計(jì)時(shí)就被排除。二、狀態(tài)機(jī)在動(dòng)畫系統(tǒng)中的價(jià)值狀態(tài)機(jī)的核心價(jià)值狀態(tài)隔離每個(gè)狀態(tài)的行為是確定的不存在某種中間狀態(tài)轉(zhuǎn)換保護(hù)非法的狀態(tài)轉(zhuǎn)換被自動(dòng)拒絕Closed → Closed 關(guān)閉操作被忽略可測(cè)試性每條狀態(tài)轉(zhuǎn)換路徑可以獨(dú)立測(cè)試狀態(tài)機(jī)的設(shè)計(jì)靈感來(lái)自美院的動(dòng)畫分鏡課。分鏡表上每一格都標(biāo)注了當(dāng)前幀的狀態(tài)和觸發(fā)下一幀的事件——離開(kāi)格子的箭頭只有有限的幾條不可能同時(shí)跳到兩個(gè)格子。前端動(dòng)畫的狀態(tài)機(jī)也是同理菜單不可能同時(shí)處于正在打開(kāi)和正在關(guān)閉兩個(gè)狀態(tài)因?yàn)?DOM 元素的display和opacity只有一個(gè)值。狀態(tài)機(jī)把這個(gè)物理約束變成了代碼約束。三、生產(chǎn)級(jí)代碼/** * 動(dòng)畫狀態(tài)機(jī) * * 用于管理復(fù)雜 UI 動(dòng)畫的生命周期。 * 支持狀態(tài)轉(zhuǎn)換守衛(wèi)、動(dòng)畫中斷處理、超時(shí)回退。 */ // 狀態(tài)定義 type AnimationState idle | entering | entered | exiting | exited; // 事件定義 type AnimationEvent | { type: ENTER } | { type: EXIT } | { type: ANIMATION_COMPLETE } | { type: FORCE_CLOSE }; // 狀態(tài)機(jī)配置 interface StateMachineConfig { initialState: AnimationState; states: RecordAnimationState, { on?: PartialRecordAnimationEvent[type], { target: AnimationState; guard?: (context: any) boolean; action?: (context: any) void; }; entry?: (context: any) void; exit?: (context: any) void; }; } class AnimationStateMachine { private currentState: AnimationState; private config: StateMachineConfig; private context: any; constructor(config: StateMachineConfig, context?: any) { this.config config; this.currentState config.initialState; this.context context || {}; // 執(zhí)行初始狀態(tài)的 entry this.config.states[this.currentState].entry?.(this.context); } /** * 發(fā)送事件觸發(fā)狀態(tài)轉(zhuǎn)換 * * returns 是否成功轉(zhuǎn)換 */ send(event: AnimationEvent): boolean { const currentStateConfig this.config.states[this.currentState]; const transition currentStateConfig.on?.[event.type]; // 當(dāng)前狀態(tài)不處理該事件 → 忽略 if (!transition) { console.warn( 狀態(tài) ${this.currentState} 不支持事件 ${event.type} → 忽略 ); return false; } // 守衛(wèi)檢查 if (transition.guard !transition.guard(this.context)) { console.warn( 事件 ${event.type} 的守衛(wèi)條件未通過(guò) → 忽略 ); return false; } // 退出當(dāng)前狀態(tài) currentStateConfig.exit?.(this.context); // 轉(zhuǎn)換動(dòng)作 transition.action?.(this.context); // 進(jìn)入新?tīng)顟B(tài) const previousState this.currentState; this.currentState transition.target as AnimationState; // 執(zhí)行新?tīng)顟B(tài)的 entry this.config.states[this.currentState].entry?.(this.context); console.log(狀態(tài)轉(zhuǎn)換: ${previousState} → ${this.currentState}); return true; } /** 獲取當(dāng)前狀態(tài) */ getState(): AnimationState { return this.currentState; } /** 是否處于活躍動(dòng)畫中 */ isAnimating(): boolean { return this.currentState entering || this.currentState exiting; } } /** * 下拉菜單動(dòng)畫 狀態(tài)機(jī)配置 */ const dropdownMenuMachine: StateMachineConfig { initialState: exited, states: { exited: { entry: (ctx) { // 隱藏菜單 DOM ctx.menuElement.style.display none; }, on: { ENTER: { target: entering }, }, }, entering: { entry: (ctx) { // 顯示菜單 → 播放入場(chǎng)動(dòng)畫 ctx.menuElement.style.display block; ctx.playEnterAnimation().then(() { ctx.machine.send({ type: ANIMATION_COMPLETE }); }); }, exit: (ctx) { // 被打斷時(shí)立即停止動(dòng)畫 ctx.cancelAnimation(); }, on: { ANIMATION_COMPLETE: { target: entered }, EXIT: { target: exiting }, FORCE_CLOSE: { target: exited, action: (ctx) ctx.forceHide() }, }, }, entered: { on: { EXIT: { target: exiting }, FORCE_CLOSE: { target: exited, action: (ctx) ctx.forceHide() }, }, }, exiting: { entry: (ctx) { ctx.playExitAnimation().then(() { ctx.machine.send({ type: ANIMATION_COMPLETE }); }); }, exit: (ctx) { ctx.cancelAnimation(); }, on: { ANIMATION_COMPLETE: { target: exited }, ENTER: { target: entering }, // 關(guān)閉中又打開(kāi) → 直接切回 FORCE_CLOSE: { target: exited, action: (ctx) ctx.forceHide() }, }, }, }, }; // 使用 const machine new AnimationStateMachine(dropdownMenuMachine, { menuElement: document.getElementById(dropdown)!, playEnterAnimation: () { /* Web Animations API */ }, playExitAnimation: () { /* Web Animations API */ }, cancelAnimation: () { /* 取消活躍動(dòng)畫 */ }, forceHide: () { /* 強(qiáng)制隱藏 */ }, machine: null as any, // 自引用 }); machine.context.machine machine;注意entering狀態(tài)的exit回調(diào)——當(dāng)動(dòng)畫被中斷時(shí)用戶在打開(kāi)過(guò)程中點(diǎn)擊關(guān)閉exit會(huì)調(diào)用ctx.cancelAnimation()立即停止當(dāng)前動(dòng)畫。這比setTimeout方案優(yōu)雅得多setTimeout被中斷后仍然會(huì)執(zhí)行回調(diào)而狀態(tài)機(jī)的exit是同步的、確定性的。FORCE_CLOSE事件是緊急逃生通道用于路由切換或組件卸載時(shí)——它跳過(guò)所有動(dòng)畫過(guò)渡直接跳轉(zhuǎn)到exited狀態(tài)并調(diào)用forceHide()。四、總結(jié)動(dòng)畫狀態(tài)機(jī)解決快速重復(fù)操作導(dǎo)致的動(dòng)畫堆疊和動(dòng)畫生命周期管理兩個(gè)核心問(wèn)題五個(gè)狀態(tài)idle/entering/entered/exiting/exited覆蓋大多數(shù) UI 動(dòng)畫場(chǎng)景快速雙擊時(shí)狀態(tài)機(jī)自動(dòng)處理轉(zhuǎn)換沖突進(jìn)入中觸發(fā)退出 → 跳過(guò)進(jìn)入直接退出狀態(tài)機(jī)使動(dòng)畫邏輯可測(cè)試——每條轉(zhuǎn)換路徑可以獨(dú)立驗(yàn)證FORCE_CLOSE 事件提供緊急逃生通道路由切換/組件卸載時(shí)強(qiáng)制終止從 if-else 到狀態(tài)機(jī)的遷移本質(zhì)上是把隱式的狀態(tài)變成顯式的狀態(tài)。if-else 代碼中狀態(tài)藏在布爾變量和定時(shí)器的組合里——isAnimating !isClosing timeoutId ! null描述的是一個(gè)沒(méi)有名字的中間態(tài)。狀態(tài)機(jī)給每個(gè)中間態(tài)起了名字畫了邊界規(guī)定了誰(shuí)能過(guò)渡到誰(shuí)。美院教動(dòng)畫時(shí)老師說(shuō)每一幀都要有明確的姿態(tài)。狀態(tài)機(jī)就是給代碼動(dòng)畫的每一幀都賦予了明確的姿態(tài)——沒(méi)有模糊的中間態(tài)沒(méi)有不可預(yù)測(cè)的并發(fā)只有確定性的狀態(tài)和確定性的轉(zhuǎn)換。狀態(tài)機(jī)的五個(gè)狀態(tài)對(duì)應(yīng)了動(dòng)畫的五個(gè)生命周期階段idle未觸發(fā)、entering入場(chǎng)中、entered已展示、exiting出場(chǎng)中、exited已隱藏。每個(gè)狀態(tài)的entry和exit回調(diào)是進(jìn)入和離開(kāi)這個(gè)姿態(tài)時(shí)做什么——entering.entry播放入場(chǎng)動(dòng)畫entering.exit取消動(dòng)畫。這種模式可以推廣到所有 UI 動(dòng)畫場(chǎng)景模態(tài)框、抽屜、Toast、Tooltip、Accordion——只要?jiǎng)赢嬘写蜷_(kāi)和關(guān)閉兩個(gè)方向就可以復(fù)用這套狀態(tài)機(jī)模型。

相關(guān)新聞

2026年Java面試高頻考點(diǎn)與備戰(zhàn)策略

2026年Java面試高頻考點(diǎn)與備戰(zhàn)策略

1. 2026年Java面試全景解析最近整理了一份2026年最新的大廠Java面試題庫(kù),涵蓋了1200道高頻考點(diǎn)。這份資料特別適合準(zhǔn)備"金三銀四"跳槽季的開(kāi)發(fā)者,從Java基礎(chǔ)到分布式架構(gòu),從算法到系統(tǒng)設(shè)計(jì),基本覆蓋了所有技術(shù)棧的考察點(diǎn)…

2026/7/29 16:57:25 閱讀更多
大模型 Token 平臺(tái)怎么選?2026 年四類主流平臺(tái)深度對(duì)比

大模型 Token 平臺(tái)怎么選?2026 年四類主流平臺(tái)深度對(duì)比

大模型 Token 平臺(tái),是指以 API 形式提供大語(yǔ)言模型推理調(diào)用、按 Token 消耗計(jì)費(fèi)的服務(wù)基礎(chǔ)設(shè)施。對(duì)于開(kāi)發(fā)者和企業(yè)而言,選對(duì)平臺(tái)意味著穩(wěn)定的訪問(wèn)、可控的成本和足夠靈活的模型切換能力。2026 年市場(chǎng)上主流平臺(tái)已按定位分化為四個(gè)清晰的類別,…

2026/7/29 16:47:24 閱讀更多
STP協(xié)議深度解析:從原理到實(shí)戰(zhàn),構(gòu)建穩(wěn)定二層網(wǎng)絡(luò)

STP協(xié)議深度解析:從原理到實(shí)戰(zhàn),構(gòu)建穩(wěn)定二層網(wǎng)絡(luò)

1. 項(xiàng)目概述:為什么STP是網(wǎng)絡(luò)工程師的“必修課”?如果你剛接觸網(wǎng)絡(luò),或者正在準(zhǔn)備網(wǎng)工認(rèn)證,聽(tīng)到“STP”這個(gè)詞可能會(huì)有點(diǎn)懵。但只要你開(kāi)始接觸交換機(jī),開(kāi)始搭建一個(gè)超過(guò)兩臺(tái)設(shè)備的網(wǎng)絡(luò),STP(生成樹(shù)…

2026/7/29 16:47:24 閱讀更多
單片機(jī)畢業(yè)設(shè)計(jì)-基于 STM32F103 的多時(shí)段定時(shí)投喂設(shè)備設(shè)計(jì) 基于嵌入式單片機(jī)的智能喂食器控制系統(tǒng)研究(011401)

單片機(jī)畢業(yè)設(shè)計(jì)-基于 STM32F103 的多時(shí)段定時(shí)投喂設(shè)備設(shè)計(jì) 基于嵌入式單片機(jī)的智能喂食器控制系統(tǒng)研究(011401)

博主介紹:??碼農(nóng)一枚 ,專注于大學(xué)生項(xiàng)目實(shí)戰(zhàn)開(kāi)發(fā)、講解和畢業(yè)🚢文撰寫修改等。全棧領(lǐng)域優(yōu)質(zhì)創(chuàng)作者,博客之星、掘金/華為云/阿里云/InfoQ等平臺(tái)優(yōu)質(zhì)作者、專注于Java、小程序技術(shù)領(lǐng)域和畢業(yè)項(xiàng)目實(shí)戰(zhàn) ??技術(shù)范圍:&am…

2026/7/29 18:08:11 閱讀更多
C++ 工控通用:32 位 DINT 四種字節(jié)序一鍵轉(zhuǎn)換函數(shù)

C++ 工控通用:32 位 DINT 四種字節(jié)序一鍵轉(zhuǎn)換函數(shù)

C 工控通用:32 位 DINT 四種字節(jié)序一鍵轉(zhuǎn)換函數(shù)(ABCD/CDAB/BADC/DCBA)前言做 Modbus TCP/RTU 工控上位機(jī)開(kāi)發(fā),最頭疼的問(wèn)題之一就是32 位數(shù)據(jù)字節(jié)序不統(tǒng)一。 PLC 的 DINT(32 位有符號(hào)整數(shù))、REAL&#xff0…

2026/7/29 18:08:11 閱讀更多
一鍵永久保存你的QQ空間記憶:GetQzonehistory完整備份指南

一鍵永久保存你的QQ空間記憶:GetQzonehistory完整備份指南

一鍵永久保存你的QQ空間記憶:GetQzonehistory完整備份指南 【免費(fèi)下載鏈接】GetQzonehistory 獲取QQ空間發(fā)布的歷史說(shuō)說(shuō) 項(xiàng)目地址: https://gitcode.com/GitHub_Trending/ge/GetQzonehistory 你是否還記得那些年在QQ空間留下的青春足跡?那些深夜的…

2026/7/29 18:08:11 閱讀更多
面試官大笑:“一個(gè)任務(wù)拆給 5 個(gè) Subagent 并行跑,不比 1 個(gè)快 5 倍?“我搖頭:“快不了,還可能更慢“

面試官大笑:“一個(gè)任務(wù)拆給 5 個(gè) Subagent 并行跑,不比 1 個(gè)快 5 倍?“我搖頭:“快不了,還可能更慢“

前兩個(gè)月,我在重構(gòu) AlgoMooc 網(wǎng)站過(guò)程中,發(fā)現(xiàn)一個(gè)問(wèn)題:在 Claude Code 里把一個(gè)任務(wù)拆給 5 個(gè) Subagent 并行跑,結(jié)果可能比 1 個(gè) agent 從頭干到尾還慢? 大多數(shù)人的第一反應(yīng)是反過(guò)來(lái)的:活是并行干的&#…

2026/7/29 0:15:24 閱讀更多
# 鴻蒙 HarmonyOS 應(yīng)用開(kāi)發(fā)實(shí)戰(zhàn)(第25期)|骰子(Dice Roller)— Unicode 符號(hào)與動(dòng)畫渲染精講

# 鴻蒙 HarmonyOS 應(yīng)用開(kāi)發(fā)實(shí)戰(zhàn)(第25期)|骰子(Dice Roller)— Unicode 符號(hào)與動(dòng)畫渲染精講

一、應(yīng)用概述 骰子(Dice Roller) 是一款經(jīng)典的休閑娛樂(lè)應(yīng)用,模擬了真實(shí)擲骰子的過(guò)程。應(yīng)用投擲兩個(gè)骰子(六面標(biāo)準(zhǔn)骰),使用 Unicode 骰面符號(hào)直觀展示每個(gè)骰子的點(diǎn)數(shù),并伴有快速滾動(dòng)的動(dòng)畫效果?!?/p>

2026/7/29 0:15:24 閱讀更多