從打字機效果的N種實現(xiàn)看JS定時器機制和前端動畫


打字機效果即為文字逐個輸出,實際上就是Web動畫。
在Web應(yīng)用中,實現(xiàn)動畫效果的方法比較多,JavaScript 中可以通過定時器 setTimeout 來實現(xiàn),css3 可以使用 transition 和 animation 來實現(xiàn),html5 中的 canvas 也可以實現(xiàn)。
除此之外,html5 還提供一個專門用于請求動畫的 API,即 requestAnimationFrame(rAF),顧名思義就是 “請求動畫幀”。
接下來,我們一起來看看 打字機效果 的幾種實現(xiàn)。為了便于理解,我會盡量使用簡潔的方式進行實現(xiàn),有興趣的話,你也可以把這些實現(xiàn)改造的更有逼格、更具藝術(shù)氣息一點,因為編程,本來就是一門藝術(shù)。
打字機效果的 N 種實現(xiàn)
實現(xiàn)一:setTimeout()
/* 設(shè)置容器樣式 */#content {height: 400px;padding: 10px;font-size: 28px;border-radius: 20px;background-color: antiquewhite;}/* 產(chǎn)生光標(biāo)閃爍的效果 */#content::after{content: '|';color:darkgray;animation: blink 1s infinite;}@keyframes blink{from{opacity: 0;}to{opacity: 1;}}

setTimeout()方法的返回值是一個唯一的數(shù)值(ID),上面的代碼中,我們也做了setTimeout()返回值的打印,那么,這個數(shù)值有什么用呢?
如果你想要終止setTimeout()方法的執(zhí)行,那就必須使用 clearTimeout()方法來終止,而使用這個方法的時候,系統(tǒng)必須知道你到底要終止的是哪一個setTimeout()方法(因為你可能同時調(diào)用了好幾個 setTimeout()方法),這樣clearTimeout()方法就需要一個參數(shù),這個參數(shù)就是setTimeout()方法的返回值(數(shù)值),用這個數(shù)值來唯一確定結(jié)束哪一個setTimeout()方法。
實現(xiàn)二:setInterval()
setInterval實現(xiàn)的打字機效果,其實在MDN window.setInterval 案例三中已經(jīng)有一個了,而且還實現(xiàn)了播放、暫停以及終止的控制,效果可點擊這里查看,在此只進行setInterval打字機效果的一個最簡單實現(xiàn),其實代碼和前文setTimeout的實現(xiàn)類似,效果也一致。
(function () {// 獲取容器const container = document.getElementById('content')// 把需要展示的全部文字進行切割const data = '最簡單的打字機效果實現(xiàn)'.split('')// 需要追加到容器中的文字下標(biāo)let index = 0let timer = nullfunction writing() {if (index < data.length) {// 追加文字container.innerHTML += data[index ++]// 沒錯,也可以通過,clearTimeout取消setInterval的執(zhí)行// index === 4 && clearTimeout(timer)} else {clearInterval(timer)}console.log(timer) // 這里會打印出 1 1 1 1 1 ...}// 使用 setInterval 時,結(jié)束后不要忘記進行 clearIntervaltimer = setInterval(writing, 200)})();
和setTimeout一樣,setInterval也會返回一個 ID(數(shù)字),可以將這個ID傳遞給clearInterval()或者clearTimeout() 以取消定時器的執(zhí)行。
在此有必要強調(diào)一點:定時器指定的時間間隔,表示的是何時將定時器的代碼添加到消息隊列,而不是何時執(zhí)行代碼。所以真正何時執(zhí)行代碼的時間是不能保證的,取決于何時被主線程的事件循環(huán)取到,并執(zhí)行。
實現(xiàn)三:requestAnimationFrame()
在動畫的實現(xiàn)上,requestAnimationFrame 比起 setTimeout 和 setInterval來無疑更具優(yōu)勢。我們先看看打字機效果的requestAnimationFrame實現(xiàn):
(function () {const container = document.getElementById('content')const data = '與 setTimeout 相比,requestAnimationFrame 最大的優(yōu)勢是 由系統(tǒng)來決定回調(diào)函數(shù)的執(zhí)行時機。具體一點講就是,系統(tǒng)每次繪制之前會主動調(diào)用 requestAnimationFrame 中的回調(diào)函數(shù),如果系統(tǒng)繪制率是 60Hz,那么回調(diào)函數(shù)就每16.7ms 被執(zhí)行一次,如果繪制頻率是75Hz,那么這個間隔時間就變成了 1000/75=13.3ms。換句話說就是,requestAnimationFrame 的執(zhí)行步伐跟著系統(tǒng)的繪制頻率走。它能保證回調(diào)函數(shù)在屏幕每一次的繪制間隔中只被執(zhí)行一次,這樣就不會引起丟幀現(xiàn)象,也不會導(dǎo)致動畫出現(xiàn)卡頓的問題。'.split('')let index = 0function writing() {if (index < data.length) {container.innerHTML += data[index ++]requestAnimationFrame(writing)}}writing()})();

與setTimeout相比,requestAnimationFrame最大的優(yōu)勢是由系統(tǒng)來決定回調(diào)函數(shù)的執(zhí)行時機。具體一點講,如果屏幕刷新率是60Hz,那么回調(diào)函數(shù)就每16.7ms被執(zhí)行一次,如果刷新率是75Hz,那么這個時間間隔就變成了1000/75=13.3ms,換句話說就是,requestAnimationFrame的步伐跟著系統(tǒng)的刷新步伐走。
它能保證回調(diào)函數(shù)在屏幕每一次的刷新間隔中只被執(zhí)行一次,這樣就不會引起丟幀現(xiàn)象,也不會導(dǎo)致動畫出現(xiàn)卡頓的問題。
實現(xiàn)四:CSS3
除了以上三種js方法之外,其實只用CSS我們也可以實現(xiàn)打字機效果。大概思路是借助CSS3的@keyframes來不斷改變包含文字的容器的寬度,超出容器部分的文字隱藏不展示。
div {font-size: 20px;/* 初始寬度為0 */width: 0;height: 30px;border-right: 1px solid darkgray;/*Steps(, ) steps接收兩個參數(shù):第一個參數(shù)指定動畫分割的段數(shù);第二個參數(shù)可選,接受 start和 end兩個值,指定在每個間隔的起點或是終點發(fā)生階躍變化,默認(rèn)為 end。*/animation: write 4s steps(14) forwards,blink 0.5s steps(1) infinite;overflow: hidden;}@keyframes write {0% {width: 0;}100% {width: 280px;}}@keyframes blink {50% {/* transparent是全透明黑色(black)的速記法,即一個類似rgba(0,0,0,0)這樣的值。*/border-color: transparent; /* #00000000 */}}大江東去浪淘盡,千古風(fēng)流人物

以上CSS打字機效果的原理一目了然:
初始文字是全部在頁面上的,只是容器的寬度為0,設(shè)置文字超出部分隱藏,然后不斷改變?nèi)萜鞯膶挾龋?/span>
設(shè)置border-right,并在關(guān)鍵幀上改變 border-color 為transparent,右邊框就像閃爍的光標(biāo)了。
實現(xiàn)五:Typed.js
Typed.js is a library that types. Enter in any string, and watch it type at the speed you've set, backspace what it's typed, and begin a new sentence for however many strings you've set.
Typed.js是一個輕量級的打字動畫庫, 只需要幾行代碼,就可以在項目中實現(xiàn)炫酷的打字機效果(本文第一張動圖即為Typed.js實現(xiàn))。源碼也相對比較簡單,有興趣的話,可以到GitHub進行研讀。
Document Typed.js is a JavaScript library.
It types out sentences.


參考資料:Typed.js官網(wǎng)?|?Typed.js GitHub地址
當(dāng)然,打字機效果的實現(xiàn)方式,也不僅僅局限于上面所說的幾種方法,本文的目的,也不在于搜羅所有打字機效果的實現(xiàn),如果那樣將毫無意義,接下來,我們將會對CSS3動畫和JS動畫進行一些比較,并對setTimeout、setInterval 和 requestAnimationFrame的一些細(xì)節(jié)進行總結(jié)。
CSS3動畫和JS動畫的比較
關(guān)于CSS動畫和JS動畫,有一種說法是CSS動畫比JS流暢,其實這種流暢是有前提的。借此機會,我們對CSS3動畫和JS動畫進行一個簡單對比。
JS動畫
優(yōu)點:
JS動畫控制能力強,可以在動畫播放過程中對動畫進行精細(xì)控制,如開始、暫停、終止、取消等;
JS動畫效果比CSS3動畫豐富,功能涵蓋面廣,比如可以實現(xiàn)曲線運動、沖擊閃爍、視差滾動等CSS難以實現(xiàn)的效果;
JS動畫大多數(shù)情況下沒有兼容性問題,而CSS3動畫有兼容性問題;
缺點:
JS在瀏覽器的主線程中運行,而主線程中還有其它需要運行的JS腳本、樣式計算、布局、繪制任務(wù)等,對其干擾可能導(dǎo)致線程出現(xiàn)阻塞,從而造成丟幀的情況;
對于幀速表現(xiàn)不好的低版本瀏覽器,CSS3可以做到自然降級,而JS則需要撰寫額外代碼;
JS動畫往往需要頻繁操作DOM的css屬性來實現(xiàn)視覺上的動畫效果,這個時候瀏覽器要不停地執(zhí)行重繪和重排,這對于性能的消耗是很大的,尤其是在分配給瀏覽器的內(nèi)存沒那么寬裕的移動端。
CSS3動畫
優(yōu)點:
部分情況下瀏覽器可以對動畫進行優(yōu)化(比如專門新建一個圖層用來跑動畫),為什么說部分情況下呢,因為是有條件的:
在Chromium基礎(chǔ)上的瀏覽器中
同時CSS動畫不觸發(fā)layout或paint,在CSS動畫或JS動畫觸發(fā)了paint或layout時,需要main thread進行Layer樹的重計算,這時CSS動畫或JS動畫都會阻塞后續(xù)操作。
部分效果可以強制使用硬件加速 (通過 GPU 來提高動畫性能)
缺點:
代碼冗長。CSS 實現(xiàn)稍微復(fù)雜一點動畫,CSS代碼可能都會變得非常笨重;
運行過程控制較弱。css3動畫只能在某些場景下控制動畫的暫停與繼續(xù),不能在特定的位置添加回調(diào)函數(shù)。
main thread(主線程)和compositor thread(合成器線程)
渲染線程分為main thread(主線程)和compositor thread(合成器線程)。主線程中維護了一棵Layer樹(LayerTreeHost),管理了TiledLayer,在compositor thread,維護了同樣一顆LayerTreeHostImpl,管理了LayerImpl,這兩棵樹的內(nèi)容是拷貝關(guān)系。因此可以彼此不干擾,當(dāng)Javascript在main thread操作LayerTreeHost的同時,compositor thread可以用LayerTreeHostImpl做渲染。當(dāng)Javascript繁忙導(dǎo)致主線程卡住時,合成到屏幕的過程也是流暢的。
為了實現(xiàn)防假死,鼠標(biāo)鍵盤消息會被首先分發(fā)到compositor thread,然后再到main thread。這樣,當(dāng)main thread繁忙時,compositor thread還是能夠響應(yīng)一部分消息,例如,鼠標(biāo)滾動時,如果main thread繁忙,compositor thread也會處理滾動消息,滾動已經(jīng)被提交的頁面部分(未被提交的部分將被刷白)。
CSS動畫比JS動畫流暢的前提
CSS動畫比較少或者不觸發(fā)pain和layout,即重繪和重排時。例如通過改變?nèi)缦聦傩陨傻腸ss動畫,這時整個CSS動畫得以在compositor thread完成(而JS動畫則會在main thread執(zhí)行,然后觸發(fā)compositor進行下一步操作):
backface-visibility:該屬性指定當(dāng)元素背面朝向觀察者時是否可見(3D,實驗中的功能);
opacity:設(shè)置 div 元素的不透明級別;
perspective 設(shè)置元素視圖,該屬性只影響 3D 轉(zhuǎn)換元素;
perspective-origin:該屬性允許您改變 3D 元素的底部位置;
transform:該屬性應(yīng)用于元素的2D或3D轉(zhuǎn)換。這個屬性允許你將元素旋轉(zhuǎn),縮放,移動,傾斜等。
JS在執(zhí)行一些昂貴的任務(wù)時,main thread繁忙,CSS動畫由于使用了compositor thread可以保持流暢;
部分屬性能夠啟動3D加速和GPU硬件加速,例如使用transform的translateZ進行3D變換時;
通過設(shè)置 will-change 屬性,瀏覽器就可以提前知道哪些元素的屬性將會改變,提前做好準(zhǔn)備。待需要改變元素的時機到來時,就可以立刻實現(xiàn)它們,從而避免卡頓等問題。
不要將 will-change 應(yīng)用到太多元素上,如果過度使用的話,可能導(dǎo)致頁面響應(yīng)緩慢或者消耗非常多的資源。
例如下面的代碼就是提前告訴渲染引擎 box 元素將要做幾何變換和透明度變換操作,這時候渲染引擎會將該元素單獨實現(xiàn)一幀,等這些變換發(fā)生時,渲染引擎會通過合成線程直接去處理變換,這些變換并沒有涉及到主線程,這樣就大大提升了渲染的效率。
.box {will-change: transform, opacity;}
setTimeout、setInterval 和 requestAnimationFrame 的一些細(xì)節(jié)
setTimeout 和 setInterval
setTimeout 的執(zhí)行時間并不是確定的。在JavaScript中,setTimeout 任務(wù)被放進了異步隊列中,只有當(dāng)主線程上的任務(wù)執(zhí)行完以后,才會去檢查該隊列里的任務(wù)是否需要開始執(zhí)行,所以 setTimeout 的實際執(zhí)行時機一般要比其設(shè)定的時間晚一些。
刷新頻率受 屏幕分辨率 和 屏幕尺寸 的影響,不同設(shè)備的屏幕繪制頻率可能會不同,而 setTimeout 只能設(shè)置一個固定的時間間隔,這個時間不一定和屏幕的刷新時間相同。
setTimeout 的執(zhí)行只是在內(nèi)存中對元素屬性進行改變,這個變化必須要等到屏幕下次繪制時才會被更新到屏幕上。如果兩者的步調(diào)不一致,就可能會導(dǎo)致中間某一幀的操作被跨越過去,而直接更新下一幀的元素。假設(shè)屏幕每隔16.7ms刷新一次,而setTimeout 每隔10ms設(shè)置圖像向左移動1px, 就會出現(xiàn)如下繪制過程:
第 0 ms:屏幕未繪制,等待中,setTimeout 也未執(zhí)行,等待中;
第 10 ms:屏幕未繪制,等待中,setTimeout 開始執(zhí)行并設(shè)置元素屬性 left=1px;
第 16.7 ms:屏幕開始繪制,屏幕上的元素向左移動了 1px, setTimeout 未執(zhí)行,繼續(xù)等待中;
第 20 ms:屏幕未繪制,等待中,setTimeout 開始執(zhí)行并設(shè)置 left=2px;
第 30 ms:屏幕未繪制,等待中,setTimeout 開始執(zhí)行并設(shè)置 left=3px;
第 33.4 ms:屏幕開始繪制,屏幕上的元素向左移動了 3px, setTimeout 未執(zhí)行,繼續(xù)等待中;
...
從上面的繪制過程中可以看出,屏幕沒有更新 left=2px 的那一幀畫面,元素直接從left=1px 的位置跳到了 left=3px 的的位置,這就是丟幀現(xiàn)象,這種現(xiàn)象就會引起動畫卡頓。
setInterval的回調(diào)函數(shù)調(diào)用之間的實際延遲小于代碼中設(shè)置的延遲,因為回調(diào)函數(shù)執(zhí)行所需的時間“消耗”了間隔的一部分,如果回調(diào)函數(shù)執(zhí)行時間長、執(zhí)行次數(shù)多的話,誤差也會越來越大:
// repeat with the interval of 2 secondslet timerId = setInterval(() => console.log('tick', timerId), 2000);// after 50 seconds stopsetTimeout(() => {clearInterval(timerId);console.log('stop', timerId);}, 50000);

嵌套的setTimeout可以保證固定的延遲:
let timerId = setTimeout(function tick() {console.log('tick', timerId);timerId = setTimeout(tick, 2000); // (*)}, 2000);

requestAnimationFrame
除了上文提到的requestAnimationFrame的優(yōu)勢外,requestAnimationFrame還有以下兩個優(yōu)勢:
CPU節(jié)能:使用setTimeout實現(xiàn)的動畫,當(dāng)頁面被隱藏或最小化時,setTimeout 仍然在后臺執(zhí)行動畫任務(wù),由于此時頁面處于不可見或不可用狀態(tài),刷新動畫是沒有意義的,完全是浪費CPU資源。
而requestAnimationFrame則完全不同,當(dāng)頁面處于未激活的狀態(tài)下,該頁面的屏幕刷新任務(wù)也會被系統(tǒng)暫停,因此跟著系統(tǒng)步伐走的requestAnimationFrame也會停止渲染,當(dāng)頁面被激活時,動畫就從上次停留的地方繼續(xù)執(zhí)行,有效節(jié)省了CPU開銷。
函數(shù)節(jié)流:在高頻率事件(resize,scroll等)中,為了防止在一個刷新間隔內(nèi)發(fā)生多次函數(shù)執(zhí)行,使用requestAnimationFrame可保證每個刷新間隔內(nèi),函數(shù)只被執(zhí)行一次,這樣既能保證流暢性,也能更好的節(jié)省函數(shù)執(zhí)行的開銷。
一個刷新間隔內(nèi)函數(shù)執(zhí)行多次是沒有意義的,因為顯示器每16.7ms刷新一次,多次繪制并不會在屏幕上體現(xiàn)出來。
關(guān)于最小時間間隔
2011年的標(biāo)準(zhǔn)中是這么規(guī)定的:
setTimeout:如果當(dāng)前正在運行的任務(wù)是由setTimeout()方法創(chuàng)建的任務(wù),并且時間間隔小于4ms,則將時間間隔增加到4ms;
setInterval:如果時間間隔小于10ms,則將時間間隔增加到10ms。
在最新標(biāo)準(zhǔn)中:如果時間間隔小于0,則將時間間隔設(shè)置為0。如果嵌套級別大于5,并且時間間隔小于4ms,則將時間間隔設(shè)置為4ms。
定時器的清除
由于clearTimeout()和clearInterval()清除的是同一列表(活動計時器列表)中的條目,因此可以使用這兩種方法清除setTimeout()或 setInterval()創(chuàng)建的計時器。

