效果組件進(jìn)階:從定時(shí)器到幀動(dòng)畫的性能優(yōu)化)
最早在項(xiàng)目里落地“打字機(jī)效果組件”我的第一版實(shí)現(xiàn)很簡單setInterval每隔幾十毫秒截一次字符串往模板里塞。演示的時(shí)候確實(shí)唬人可等文案一長問題全暴露出來——用戶切個(gè)后臺(tái)標(biāo)簽頁再回來整段文字“嗖”地一下跳到結(jié)尾幾百字的長段落打出來一頓一頓的連鼠標(biāo)滾輪都感覺被拖累。后來我抽了一晚上把組件推翻重寫核心調(diào)度從定時(shí)器換成了requestAnimationFrame再加上字素拆分、變速曲線、段落暫停、指令式控制這些細(xì)節(jié)才做出真正稱得上“絲滑”的Vue3 打字機(jī)效果組件。這篇屬于進(jìn)階內(nèi)容基礎(chǔ)寫法我不再展開重點(diǎn)講清重寫過程中的方案取舍、關(guān)鍵代碼和實(shí)際踩坑。適合已經(jīng)在用 Vue3、做過簡單打字效果但想讓動(dòng)畫節(jié)奏、性能和細(xì)節(jié)控制都更上一個(gè)臺(tái)階的開發(fā)朋友。1. 先想清楚進(jìn)階版比基礎(chǔ)版到底“進(jìn)”在哪做任何組件之前先別急著寫代碼。把舊方案為什么不好用想透了新方案才不會(huì)走回頭路。1.1 setTimeout 實(shí)現(xiàn)打字機(jī)的三個(gè)硬傷第一版組件用定時(shí)器跑起來很順是因?yàn)槲陌付?、?chǎng)景演示足夠。但真正放到生產(chǎn)環(huán)境后三個(gè)問題會(huì)依次浮出來。問題一定時(shí)器在后臺(tái)會(huì)被節(jié)流切回前臺(tái)直接跳結(jié)尾。瀏覽器為了省電對(duì)后臺(tái)頁面的setTimeout會(huì)做節(jié)流處理節(jié)流間隔大時(shí)可以到 1 秒以上。用戶切走標(biāo)簽頁定時(shí)器回調(diào)全被積壓再切回來的時(shí)候積壓的回調(diào)會(huì)在極短時(shí)間內(nèi)連續(xù)觸發(fā)表現(xiàn)就是文字“啪”一下打完。我用一段 600 字的文案實(shí)測(cè)過切后臺(tái) 5 秒再切回來動(dòng)畫基本是瞬移完成毫無懸念。問題二逐字符截取字符串復(fù)雜度是 O(n2)。舊方案里每一幀都重新執(zhí)行text.slice(0, index)字符串越長一次性拷貝的代價(jià)越大。文案 300 字以內(nèi)尚可一旦出現(xiàn)幾千字的演講稿、小說章節(jié)打字到中后段時(shí)每次 slice 都涉及大段內(nèi)存復(fù)制肉眼可見掉幀。問題三控制能力和節(jié)奏表達(dá)幾乎為零?;A(chǔ)版只有一個(gè)方向從頭打到尾。想暫停、繼續(xù)、重置都要另寫邏輯想讓一萬字文案分段落展示、段落之間留停頓、模仿真人打字時(shí)快時(shí)慢的節(jié)奏基礎(chǔ)版壓根做不到。1.2 進(jìn)階版要解決的四個(gè)核心課題把上面三個(gè)痛點(diǎn)拆開進(jìn)階版的目標(biāo)就很清晰了。穩(wěn)定性切后臺(tái)、突然卡頓都不能讓整體進(jìn)度失真。要么自動(dòng)暫停要么恢復(fù)后從斷點(diǎn)繼續(xù)。性能文本再長也要保持 60 幀的流暢體驗(yàn)不能隨著字符串變長而線性劣化。節(jié)奏感勻速打字像機(jī)器人在念稿真正“打字機(jī)”的感覺應(yīng)該是有起速、有沖刺、有收尾的類似人在鍵盤上敲擊??刂屏M件要對(duì)外提供start、pause、resume、reset等指令式 API多段文本之間要支持停頓和輪播。1.3 為什么最終選擇 requestAnimationFrame關(guān)于動(dòng)畫調(diào)度業(yè)界早有共識(shí)凡是跟畫面變化掛鉤的優(yōu)先選requestAnimationFrame而不是setInterval或setTimeout。RAF 最大的優(yōu)勢(shì)是它由瀏覽器在每一次重繪之前調(diào)用回調(diào)參數(shù)自帶高精度時(shí)間戳。屏幕是 60Hz它就每秒觸發(fā)約 60 次屏幕是 120Hz它會(huì)跟著提高頻率CPU 忙不過來導(dǎo)致掉幀時(shí)瀏覽器會(huì)自動(dòng)跳過中間幀而不是像定時(shí)器那樣在卡頓結(jié)束后把積壓的回調(diào)一股腦補(bǔ)跑。我用一個(gè)更生活化的類比來理解.setInterval像定鬧鐘鬧鐘響的時(shí)候渲染線程不一定有空.requestAnimationFrame像跟渲染引擎打同一套拍子引擎每次邁腿你都踩著點(diǎn)落腳。因?yàn)?RAF 天然和屏幕刷新同步打字動(dòng)畫就不會(huì)出現(xiàn)“兩幀相隔時(shí)間忽長忽短”的抖動(dòng)感。順帶還拿到一個(gè)福利頁面切到后臺(tái)RAF 自動(dòng)停止切回來繼續(xù)跑完全不需要自己寫節(jié)流邏輯。2. 核心細(xì)節(jié)解析從文本到逐幀渲染決定了用 RAF 之后接下來要處理的是三個(gè)細(xì)節(jié)層面文本怎么拆、時(shí)間怎么算、光標(biāo)怎么做。這三點(diǎn)直接決定組件用起來“像不像那么回事”。2.1 不能直接 slice 文本字素拆分才是正解字符串處理是最容易被忽略的坑。JavaScript 的字符串索引基于 UTF-16 碼元一個(gè) emoji、一個(gè)生僻字、一個(gè)帶聲調(diào)的字母組合都可能占用多個(gè)碼元。如果直接按length或者slice(from, to)截取就會(huì)把完整字符從中間劈開輕則顯示亂碼重則出現(xiàn)“半個(gè) emoji”。我試過用一個(gè)家庭 emoji???做測(cè)試結(jié)果差異非常明顯const family ???; console.log(family.length); // 11純碼元數(shù)量 console.log(Array.from(family).length); // 7按碼點(diǎn)拆ZWJ 序列被拆開 console.log([...new Intl.Segmenter(undefined, { granularity: grapheme }).segment(family)].length); // 1按用戶感知的字素拆Intl.Segmenter是原生按“用戶感知字符”做分詞的 API支持絕大多數(shù)現(xiàn)代瀏覽器。它能把 ZWJ 序列、組合變音符號(hào)、旗幟這類復(fù)雜字符正確識(shí)別為一個(gè)整體這正是打字機(jī)逐字顯示需要的粒度。用它的結(jié)果作為渲染單元每個(gè)“字”在視覺上都是完整且獨(dú)立的。當(dāng)然老瀏覽器不一定支持Intl.Segmenter我保留了Array.from作為降級(jí)方案兼容性從 IE 時(shí)代到最新版都能兜底function splitText(raw: string): string[] { const Ctor (Intl as any).Segmenter; if (Ctor) { const segmenter new Ctor(undefined, { granularity: grapheme }); return Array.from(segmenter.segment(raw), (item: any) item.segment); } return Array.from(raw); }2.2 幀間隔怎么算從 delta 到變速曲線有了文本單元下一步就是時(shí)間模型。這里我選擇“每幀累計(jì) delta”的方式而不是“從開始到現(xiàn)在總時(shí)長”的方式。核心思想很簡單記錄上一幀時(shí)間戳每一幀進(jìn)來先算差值再把差值累加到已運(yùn)行時(shí)間。這樣暫停處理起來非常干凈——暫停就是停止累加恢復(fù)后第一幀重新記錄時(shí)間戳進(jìn)度從暫停點(diǎn)繼續(xù)走不用去修正什么“已過時(shí)間”。let prevTimestamp 0; let runMs 0; function tick(now: number) { if (prevTimestamp 0) { prevTimestamp now; } const delta Math.min(0.1, (now - prevTimestamp) / 1000); prevTimestamp now; runMs delta * 1000; // 后續(xù)處理... }要注意Math.min(0.1, ...)這個(gè) clamp 很關(guān)鍵。它防止用戶從休眠狀態(tài)喚醒電腦或調(diào)試器卡了十幾秒之后瀏覽器突然補(bǔ)一個(gè)超大 delta讓動(dòng)畫一幀內(nèi)跳過去一百個(gè)字。RAF 正常情況下每幀間隔不會(huì)超過 100msclamp 后既不影響正常速度又兜住了極端情況。再來說打字節(jié)奏。如果不做處理速度就是勻速的從頭到尾每個(gè)字符間隔完全相同機(jī)械感很重。我引入了一個(gè)easeInOutCubic曲線把“時(shí)間進(jìn)度”映射成“顯示進(jìn)度”function easeInOutCubic(t: number): number { return t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2; }舉一個(gè)具體的例子。假設(shè)某段文案 100 字打字速度是 50 字/秒總時(shí)長約 2 秒。在時(shí)間走到 0.5 秒時(shí)時(shí)間進(jìn)度是 0.25經(jīng)過曲線變換后顯示進(jìn)度大約只有 0.062也就是只顯示 6 個(gè)字時(shí)間走到 1 秒時(shí)顯示進(jìn)度 0.5顯示 50 個(gè)字時(shí)間走到 1.5 秒時(shí)顯示進(jìn)度約 0.94已經(jīng)顯示 94 個(gè)字。這樣打出來的效果就是開頭幾個(gè)字“慢熱”出來中間段勻速?zèng)_刺接近結(jié)尾時(shí)逐步收住。人工打字的感覺一下子就有了。2.3 光標(biāo)跟隨與閃爍能交給 CSS 的絕不用 JS光標(biāo)的實(shí)現(xiàn)同樣有講究。我見過很多實(shí)現(xiàn)會(huì)在 JavaScript 里再開一個(gè)定時(shí)器去控制光標(biāo)顯示、隱藏這既浪費(fèi)資源又容易造成閃爍頻率不穩(wěn)。正確做法是純 CSS 動(dòng)畫只在顯示節(jié)點(diǎn)后面放一個(gè)span用steps做無過渡閃爍.tw-caret { display: inline-block; width: 2px; height: 1em; margin-left: 2px; background: currentColor; vertical-align: text-bottom; animation: tw-blink 0.8s steps(2, start) infinite; } keyframes tw-blink { to { visibility: hidden; } }解釋一下steps(2, start)它讓動(dòng)畫在“可見/隱藏”兩個(gè)狀態(tài)之間切換中間不插入過渡幀。打字機(jī)的光標(biāo)就是這種干脆利落的閃現(xiàn)而不是漸隱漸現(xiàn)。使用visibility而不是opacity是因?yàn)関isibility: hidden時(shí)元素不參與繪制性能上也更有優(yōu)勢(shì)。3. 實(shí)操過程與核心代碼實(shí)現(xiàn)概念講清楚了直接上一版可用的完整實(shí)現(xiàn)。這個(gè)組件我封裝成一個(gè)單文件 SFC用script setup langts組織邏輯。3.1 組件的基礎(chǔ)結(jié)構(gòu)與 props 設(shè)計(jì)先看模板部分結(jié)構(gòu)非常簡單本質(zhì)就三個(gè)節(jié)點(diǎn)容器、文本區(qū)、光標(biāo)區(qū)。template div refrootRef classtw-root :class{ tw-root--active: isRunning } span reftextRef classtw-text/span span v-ifcursor classtw-caret aria-hiddentrue/span /div /templateprops 和 emits 的設(shè)計(jì)如下表參數(shù)類型默認(rèn)值說明textstring | string[]必填單段文案或多段文案speednumber60打字速度單位字符/秒easebooleantrue是否啟用變速曲線pausenumber700多段文案之間的停頓單位毫秒cursorbooleantrue是否顯示光標(biāo)startDelaynumber0調(diào)用 start 后的延遲啟動(dòng)時(shí)間單位毫秒事件方面只暴露三個(gè)start開始播放、update更新已顯示字符數(shù)、end全部播放完成。3.2 文本預(yù)解析與狀態(tài)管理組件的核心狀態(tài)如下每個(gè)變量都有明確分工let rafId 0; let pauseTimer 0; let prevTimestamp 0; let runMs 0; let segmentIndex 0; let renderedInSegment 0;rafId保存當(dāng)前 RAF 回調(diào) id用于cancelAnimationFrame。pauseTimer保存段落停頓的setTimeoutid用于清理。prevTimestamp上一幀時(shí)間戳用于計(jì)算 delta。runMs當(dāng)前段落已累計(jì)運(yùn)行時(shí)間。segmentIndex當(dāng)前正在播放第幾段。renderedInSegment當(dāng)前段內(nèi)已經(jīng)顯示了幾個(gè)字。預(yù)處理階段把傳入的text統(tǒng)一處理成string[][]外層是段落列表內(nèi)層是該段落的字素?cái)?shù)組const segments computed(() { const list Array.isArray(props.text) ? props.text : [props.text]; return list.filter((item) item item.length 0).map(splitText); });3.3 調(diào)度循環(huán)RAF 怎么接管定時(shí)器核心循環(huán)函數(shù)tick是組件的心臟。它負(fù)責(zé)計(jì)算本幀應(yīng)新增的字?jǐn)?shù)、追加文本、處理段落切換和結(jié)束邏輯。function tick(now: number) { if (!isRunning.value) return; if (prevTimestamp 0) { prevTimestamp now; } const delta Math.min(0.1, (now - prevTimestamp) / 1000); prevTimestamp now; const currentSegment segments.value[segmentIndex]; if (!currentSegment) { finish(); return; } runMs delta * 1000; const segmentDuration (currentSegment.length / props.speed) * 1000; const rawProgress Math.min(1, runMs / segmentDuration); const progress props.ease ? easeInOutCubic(rawProgress) : rawProgress; const targetCount Math.min( currentSegment.length, Math.floor(progress * currentSegment.length) ); if (targetCount renderedInSegment) { textRef.value!.textContent currentSegment .slice(renderedInSegment, targetCount) .join(); renderedInSegment targetCount; const doneBefore segments.value .slice(0, segmentIndex) .reduce((sum, item) sum item.length, 0); emit(update, doneBefore renderedInSegment); } if (targetCount currentSegment.length) { rafId requestAnimationFrame(tick); return; } // 當(dāng)前段打完了切下一段或收尾 if (segmentIndex segments.value.length - 1) { segmentIndex 1; renderedInSegment 0; runMs 0; prevTimestamp 0; pauseTimer window.setTimeout(() { if (isRunning.value) { prevTimestamp 0; rafId requestAnimationFrame(tick); } }, props.pause); } else { finish(); } }幾個(gè)細(xì)節(jié)值得展開為什么用textContent 而不是textContent slice()前者是不斷增加文本已顯示部分不會(huì)被重新拷貝后者每次都要把整個(gè)字符串重新拼接、賦值字符串越長越慢。實(shí)測(cè) 3000 字長文案兩種寫法性能差距非常明顯。為什么段落停頓用setTimeout而不是 RAF 里等待RAF 不應(yīng)該空轉(zhuǎn)沒有渲染任務(wù)時(shí)就不該占用回調(diào)隊(duì)列。段落間停頓本質(zhì)是“什么都不做”的等待交給setTimeout最干凈。但要記得在組件卸載時(shí)清掉這個(gè)定時(shí)器。為什么重新開始新段之前要把prevTimestamp 0因?yàn)橥nD期間 RAF 不執(zhí)行下一段開始時(shí)上一幀時(shí)間戳已經(jīng)過期。清零后新段第一幀會(huì)重新記錄時(shí)間戳避免把停頓時(shí)間也算進(jìn)動(dòng)畫進(jìn)度里。3.4 指令式 APIstart / pause / resume / reset組件對(duì)外的四個(gè)方法語義要明確start從頭開始播放。pause暫停停在當(dāng)前位置。resume從暫停位置繼續(xù)。reset清空文本和進(jìn)度回到初始狀態(tài)。它們通過defineExpose暴露給父組件function start() { if (isFinished.value || renderedInSegment 0) { reset(); } isRunning.value true; isFinished.value false; emit(start); if (props.startDelay 0) { pauseTimer window.setTimeout(() { if (isRunning.value) { prevTimestamp 0; rafId requestAnimationFrame(tick); } }, props.startDelay); } else { prevTimestamp 0; rafId requestAnimationFrame(tick); } } function pause() { isRunning.value false; cancelAnimationFrame(rafId); clearTimeout(pauseTimer); } function resume() { if (isRunning.value || isFinished.value) return; isRunning.value true; prevTimestamp 0; rafId requestAnimationFrame(tick); } function reset() { pause(); segmentIndex 0; renderedInSegment 0; runMs 0; prevTimestamp 0; isFinished.value false; if (textRef.value) { textRef.value.textContent ; } } defineExpose({ start, pause, resume, reset, isRunning, isFinished });這里有一個(gè)平時(shí)文檔里不常提的設(shè)計(jì)細(xì)節(jié)resume里特意加了isFinished.value判斷。動(dòng)畫已經(jīng)結(jié)束后再調(diào)用resume如果不加判斷會(huì)讓組件回到一個(gè)“文本完整顯示但狀態(tài)是播放中”的中間態(tài)光標(biāo)會(huì)一直亮著容易造成視覺困惑。這種邊界狀態(tài)我踩過一次現(xiàn)在寫進(jìn)組件里當(dāng)成默認(rèn)行為。3.5 組件銷毀與文本變化時(shí)的兜底邏輯watch監(jiān)聽text變化變化后重置組件。組件卸載時(shí)清理 RAF 和定時(shí)器watch(() props.text, () { reset(); }); onBeforeUnmount(() { cancelAnimationFrame(rafId); clearTimeout(pauseTimer); });需要注意這里的清理順序先清除定時(shí)器再取消 RAF防止組件卸載后還有異步回調(diào)嘗試操作已銷毀的 DOM。4. 常見問題與排查技巧實(shí)錄最后這部分我把實(shí)際使用中遇到的坑和排查思路整理出來做成一份可直接對(duì)照的問題速查表。4.1 問題速查表現(xiàn)象可能原因解決方案切回頁面后動(dòng)畫直接跳到結(jié)尾setTimeout被瀏覽器后臺(tái)節(jié)流改用 RAF delta 循環(huán)末尾多出半個(gè) emoji 或亂碼直接用slice切字符串用Intl.Segmenter按字素拆分動(dòng)畫停在最后一兩個(gè)字不顯示進(jìn)度計(jì)算用floor導(dǎo)致最后一位差一幀當(dāng)前段targetCount len時(shí)直接補(bǔ)齊完整文本組件被keep-alive緩存后定時(shí)器殘留卸載/失活時(shí)未清理在onDeactivated暫停、onBeforeUnmount清理恢復(fù)暫停后第一幀沖得太快暫停前時(shí)間戳過期delta 計(jì)算出異常值resume時(shí)把prevTimestamp置 04.2 keep-alive 與 SSR 場(chǎng)景下的兼容處理如果你的組件會(huì)出現(xiàn)在被keep-alive包裹的動(dòng)態(tài)頁面里建議補(bǔ)上生命周期處理onActivated(() { if (!isFinished.value) { resume(); } }); onDeactivated(() { pause(); });這樣組件從緩存重新激活時(shí)動(dòng)畫從暫停位置繼續(xù)走而不是因?yàn)闉g覽器后臺(tái)調(diào)度導(dǎo)致進(jìn)度錯(cuò)亂。SSR 場(chǎng)景的注意點(diǎn)則是requestAnimationFrame、performance.now都是瀏覽器環(huán)境才有的 API組件邏輯必須放在onMounted之后執(zhí)行。如果項(xiàng)目需要服務(wù)端渲染組件不要自動(dòng)播放由父組件掛載完成后再調(diào)用start()或者加一個(gè)autoPlayprop在onMounted里調(diào)用start()。4.3 大文本性能優(yōu)化追加模式是關(guān)鍵中的關(guān)鍵再強(qiáng)調(diào)一次這個(gè)點(diǎn)因?yàn)樗菀妆蝗撕雎?。錯(cuò)誤的寫法是把已顯示文本全量塞回節(jié)點(diǎn)// 錯(cuò)誤示例O(n2) textEl.textContent wholeText.slice(0, index);正確的寫法是只追加新增部分// 正確示例O(新增量) textEl.textContent segment.slice(from, to).join();前者越到后面越慢后者無論文本多長每一幀的代價(jià)只跟本幀要新增的字符數(shù)相關(guān)。我拿一段 5000 字的文本做過對(duì)比全量重設(shè)方案在中后段已經(jīng)掉到十幾幀追加方案全程穩(wěn)定滿幀。還有一個(gè)優(yōu)化點(diǎn)是觸發(fā)時(shí)機(jī)targetCount renderedInSegment時(shí)才寫textContent。曲線起始階段進(jìn)度很慢可能連續(xù)好幾幀的目標(biāo)字符數(shù)都沒變化這時(shí)應(yīng)該直接跳過 DOM 操作只調(diào)requestAnimationFrame等下一幀。4.4 換行、中英文混排與原生 xss 安全樣式上的三個(gè)細(xì)節(jié)直接影響了組件在不同場(chǎng)景的觀感.tw-root { display: inline-flex; align-items: baseline; white-space: pre-wrap; } .tw-text { white-space: pre-wrap; word-break: break-word; }pre-wrap會(huì)保留文本中的換行和連續(xù)空格同時(shí)允許文本到達(dá)容器邊緣時(shí)自然換行word-break配合中文長文案可以在任意字符處斷行避免一長串英文字母把布局撐破。光標(biāo)用inline-flex align-items: baseline垂直對(duì)齊能讓光標(biāo)穩(wěn)定落在文本基線上中英文混排時(shí)不會(huì)上下跳動(dòng)。另外特別提醒打字機(jī)組件不要用v-html渲染文本。逐字顯示時(shí)v-html會(huì)把、之類的字符當(dāng)成標(biāo)簽解析既容易產(chǎn)生意外換行又存在 XSS 風(fēng)險(xiǎn)。用textContent展示是一勞永逸的解法——哪怕文案里帶script標(biāo)簽它也只是被當(dāng)作普通文本打出來。讓我再補(bǔ)充一個(gè)光標(biāo)顏色的細(xì)節(jié)光標(biāo)沒有設(shè)置獨(dú)立顏色而是用background: currentColor自動(dòng)繼承父級(jí)文字顏色。這樣在不同主題色下都不需要額外改樣式換膚時(shí)組件自己跟得上。這套組件我在好幾個(gè)真實(shí)項(xiàng)目里跑過官網(wǎng)首屏 slogan 輪播、活動(dòng)落地頁的多段引導(dǎo)文案、后臺(tái)的模擬數(shù)據(jù)演示頁用的都是同一個(gè)組件。給我留下最深印象的體會(huì)是打字機(jī)效果看著簡單真正的難點(diǎn)不在“能不能打出來”而在“每一步是否都穩(wěn)得住”。把時(shí)間基準(zhǔn)交還給瀏覽器把展示文本交給textContent把節(jié)奏微調(diào)交給變速曲線這個(gè)組件基本就告別了之前那種土味打字機(jī)的觀感。后續(xù)我還在考慮繼續(xù)擴(kuò)展給每個(gè)字加打字音效、配合語音朗讀高亮當(dāng)前字符、從任意暫停點(diǎn)繼續(xù)播放這類周邊能力方向也都是建立在現(xiàn)有調(diào)度框架之上擴(kuò)展起來比較省事。如果你現(xiàn)在正被手頭打字機(jī)組件的卡頓和跳段困擾可以把這些思路直接搬到自己的工程里相信會(huì)有立竿見影的效果。