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