首先,什麼是打字機效果呢?打字機效果即為文字逐個輸出,實際上就是一種Web動畫。一圖勝千言,諸君請看:
在Web應用中,實現動畫效果的方法比較多,JavaScript 中可以通過定時器 setTimeout 來實現,css3 可以使用 transition 和 animation 來實現,html5 中的 canvas 也可以實現。除此之外,html5 還提供一個專門用於請求動畫的 API,即 requestAnimationFrame(rAF),顧名思義就是 “請求動畫幀”。接下來,我們一起來看看 打字機效果 的幾種實現。為了便於理解,我會盡量使用簡潔的方式進行實現,有興趣的話,你也可以把這些實現改造的更有逼格、更具藝術氣息一點,因為程式設計,本來就是一門藝術。
打字機效果的 N 種實現
實現一:setTimeout()
setTimeout版本的實現很簡單,只需把要展示的文字進行切割,使用定時器不斷向DOM元素裡追加文字即可,同時,使用::after偽元素
在DOM元素後面產生游標閃爍的效果。程式碼和效果圖如下:
<!-- 樣式 -->
<style type="text/css">
/* 設定容器樣式 */
#content {
height: 400px;
padding: 10px;
font-size: 28px;
border-radius: 20px;
background-color: antiquewhite;
}
/* 產生游標閃爍的效果 */
#content::after{
content: '|';
color:darkgray;
animation: blink 1s infinite;
}
@keyframes blink{
from{
opacity: 0;
}
to{
opacity: 1;
}
}
</style>
<body>
<div id='content'></div>
<script>
(function () {
// 獲取容器
const container = document.getElementById('content')
// 把需要展示的全部文字進行切割
const data = '最簡單的打字機效果實現'.split('')
// 需要追加到容器中的文字下標
let index = 0
function writing() {
if (index < data.length) {
// 追加文字
container.innerHTML += data[index ++]
let timer = setTimeout(writing, 200)
console.log(timer) // 這裡會依次列印 1 2 3 4 5 6 7 8 9 10
}
}
writing()
})();
</script>
</body>
setTimeout()方法的返回值是一個唯一的數值(ID),上面的程式碼中,我們也做了setTimeout()返回值的列印,那麼,這個數值有什麼用呢?
如果你想要終止setTimeout()方法的執行,那就必須使用 clearTimeout()方法來終止,而使用這個方法的時候,系統必須知道你到底要終止的是哪一個setTimeout()方法(因為你可能同時呼叫了好幾個 setTimeout()方法),這樣clearTimeout()方法就需要一個引數,這個引數就是setTimeout()方法的返回值(數值),用這個數值來唯一確定結束哪一個setTimeout()方法。
實現二:setInterval()
setInterval實現的打字機效果,其實在MDN window.setInterval 案例三中已經有一個了,而且還實現了播放、暫停以及終止的控制,效果可點選這裡檢視,在此只進行setInterval打字機效果的一個最簡單實現,其實程式碼和前文setTimeout的實現類似,效果也一致。
(function () {
// 獲取容器
const container = document.getElementById('content')
// 把需要展示的全部文字進行切割
const data = '最簡單的打字機效果實現'.split('')
// 需要追加到容器中的文字下標
let index = 0
let timer = null
function writing() {
if (index < data.length) {
// 追加文字
container.innerHTML += data[index ++]
// 沒錯,也可以通過,clearTimeout取消setInterval的執行
// index === 4 && clearTimeout(timer)
} else {
clearInterval(timer)
}
console.log(timer) // 這裡會列印出 1 1 1 1 1 ...
}
// 使用 setInterval 時,結束後不要忘記進行 clearInterval
timer = setInterval(writing, 200)
})();
和setTimeout一樣,setInterval也會返回一個 ID(數字),可以將這個ID傳遞給clearInterval()或者clearTimeout() 以取消定時器的執行。
在此有必要強調一點:定時器指定的時間間隔,表示的是何時將定時器的程式碼新增到訊息佇列,而不是何時執行程式碼。所以真正何時執行程式碼的時間是不能保證的,取決於何時被主執行緒的事件迴圈取到,並執行。
實現三:requestAnimationFrame()
在動畫的實現上,requestAnimationFrame 比起 setTimeout 和 setInterval來無疑更具優勢。我們先看看打字機效果的requestAnimationFrame實現:
(function () {
const container = document.getElementById('content')
const data = '與 setTimeout 相比,requestAnimationFrame 最大的優勢是 由系統來決定回撥函式的執行時機。具體一點講就是,系統每次繪製之前會主動呼叫 requestAnimationFrame 中的回撥函式,如果系統繪製率是 60Hz,那麼回撥函式就每16.7ms 被執行一次,如果繪製頻率是75Hz,那麼這個間隔時間就變成了 1000/75=13.3ms。換句話說就是,requestAnimationFrame 的執行步伐跟著系統的繪製頻率走。它能保證回撥函式在螢幕每一次的繪製間隔中只被執行一次,這樣就不會引起丟幀現象,也不會導致動畫出現卡頓的問題。'.split('')
let index = 0
function writing() {
if (index < data.length) {
container.innerHTML += data[index ++]
requestAnimationFrame(writing)
}
}
writing()
})();
與setTimeout相比,requestAnimationFrame最大的優勢是由系統來決定回撥函式的執行時機。具體一點講,如果螢幕重新整理率是60Hz,那麼回撥函式就每16.7ms被執行一次,如果重新整理率是75Hz,那麼這個時間間隔就變成了1000/75=13.3ms,換句話說就是,requestAnimationFrame的步伐跟著系統的重新整理步伐走。它能保證回撥函式在螢幕每一次的重新整理間隔中只被執行一次,這樣就不會引起丟幀現象,也不會導致動畫出現卡頓的問題。
實現四:CSS3
除了以上三種JS方法之外,其實只用CSS我們也可以實現打字機效果。大概思路是藉助CSS3的@keyframes
來不斷改變包含文字的容器的寬度,超出容器部分的文字隱藏不展示。
<style>
div {
font-size: 20px;
/* 初始寬度為0 */
width: 0;
height: 30px;
border-right: 1px solid darkgray;
/*
Steps(<number_of_steps>,<direction>)
steps接收兩個引數:第一個引數指定動畫分割的段數;第二個引數可選,接受 start和 end兩個值,指定在每個間隔的起點或是終點發生階躍變化,預設為 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 */
}
}
</style>
<body>
<div>
大江東去浪淘盡,千古風流人物
</div>
</body>
以上CSS打字機效果的原理一目瞭然:
- 初始文字是全部在頁面上的,只是容器的寬度為0,設定文字超出部分隱藏,然後不斷改變容器的寬度;
- 設定
border-right
,並在關鍵幀上改變border-color
為transparent
,右邊框就像閃爍的游標了。
實現五: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是一個輕量級的打字動畫庫, 只需要幾行程式碼,就可以在專案中實現炫酷的打字機效果(本文第一張動圖即為Typed.js實現)。原始碼也相對比較簡單,有興趣的話,可以到GitHub進行研讀。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/typed.js@2.0.11"></script>
</head>
<body>
<div id="typed-strings">
<p>Typed.js is a <strong>JavaScript</strong> library.</p>
<p>It <em>types</em> out sentences.</p>
</div>
<span id="typed"></span>
</body>
<script>
var typed = new Typed('#typed', {
stringsElement: '#typed-strings',
typeSpeed: 60
});
</script>
</html>
使用Typed.js,我們也可以很容易的實現對動畫開始、暫停等的控制:
<body>
<input type="text" class="content" name="" style="width: 80%;">
<br>
<br>
<button class="start">開始</button>
<button class="stop">暫停</button>
<button class="toggle">切換</button>
<button class="reset">重置</button>
</body>
<script>
const startBtn = document.querySelector('.start');
const stopBtn = document.querySelector('.stop');
const toggleBtn = document.querySelector('.toggle');
const resetBtn = document.querySelector('.reset');
const typed = new Typed('.content',{
strings: ['雨過白鷺州,留戀銅雀樓,斜陽染幽草,幾度飛紅,搖曳了江上遠帆,回望燈如花,未語人先羞。'],
typeSpeed: 200,
startDelay: 100,
loop: true,
loopCount: Infinity,
bindInputFocusEvents:true
});
startBtn.onclick = function () {
typed.start();
}
stopBtn.onclick = function () {
typed.stop();
}
toggleBtn.onclick = function () {
typed.toggle();
}
resetBtn.onclick = function () {
typed.reset();
}
</script>
參考資料:Typed.js官網 | Typed.js GitHub地址
當然,打字機效果的實現方式,也不僅僅侷限於上面所說的幾種方法,本文的目的,也不在於蒐羅所有打字機效果的實現,如果那樣將毫無意義,接下來,我們將會對CSS3動畫和JS動畫進行一些比較,並對setTimeout、setInterval 和 requestAnimationFrame的一些細節進行總結。
CSS3動畫和JS動畫的比較
關於CSS動畫和JS動畫,有一種說法是CSS動畫比JS流暢,其實這種流暢是有前提的。藉此機會,我們對CSS3動畫和JS動畫進行一個簡單對比。
JS動畫
優點:
- JS動畫控制能力強,可以在動畫播放過程中對動畫進行精細控制,如開始、暫停、終止、取消等;
- JS動畫效果比CSS3動畫豐富,功能涵蓋面廣,比如可以實現曲線運動、衝擊閃爍、視差滾動等CSS難以實現的效果;
- JS動畫大多數情況下沒有相容性問題,而CSS3動畫有相容性問題;
缺點:
- JS在瀏覽器的主執行緒中執行,而主執行緒中還有其它需要執行的JS指令碼、樣式計算、佈局、繪製任務等,對其干擾可能導致執行緒出現阻塞,從而造成丟幀的情況;
- 對於幀速表現不好的低版本瀏覽器,CSS3可以做到自然降級,而JS則需要撰寫額外程式碼;
- JS動畫往往需要頻繁操作DOM的css屬性來實現視覺上的動畫效果,這個時候瀏覽器要不停地執行重繪和重排,這對於效能的消耗是很大的,尤其是在分配給瀏覽器的記憶體沒那麼寬裕的移動端。
CSS3動畫
優點:
部分情況下瀏覽器可以對動畫進行優化(比如專門新建一個圖層用來跑動畫),為什麼說部分情況下呢,因為是有條件的:
- 在Chromium基礎上的瀏覽器中
- 同時CSS動畫不觸發layout或paint,在CSS動畫或JS動畫觸發了paint或layout時,需要main thread進行Layer樹的重計算,這時CSS動畫或JS動畫都會阻塞後續操作。
- 部分效果可以強制使用硬體加速 (通過 GPU 來提高動畫效能)
缺點:
- 程式碼冗長。CSS 實現稍微複雜一點動畫,CSS程式碼可能都會變得非常笨重;
- 執行過程控制較弱。css3動畫只能在某些場景下控制動畫的暫停與繼續,不能在特定的位置新增回撥函式。
main thread(主執行緒)和compositor thread(合成器執行緒)
- 渲染執行緒分為main thread(主執行緒)和compositor thread(合成器執行緒)。主執行緒中維護了一棵Layer樹(LayerTreeHost),管理了TiledLayer,在compositor thread,維護了同樣一顆LayerTreeHostImpl,管理了LayerImpl,這兩棵樹的內容是拷貝關係。因此可以彼此不干擾,當Javascript在main thread操作LayerTreeHost的同時,compositor thread可以用LayerTreeHostImpl做渲染。當Javascript繁忙導致主執行緒卡住時,合成到螢幕的過程也是流暢的。
- 為了實現防假死,滑鼠鍵盤訊息會被首先分發到compositor thread,然後再到main thread。這樣,當main thread繁忙時,compositor thread還是能夠響應一部分訊息,例如,滑鼠滾動時,如果main thread繁忙,compositor thread也會處理滾動訊息,滾動已經被提交的頁面部分(未被提交的部分將被刷白)。
CSS動畫比JS動畫流暢的前提
CSS動畫比較少或者不觸發pain和layout,即重繪和重排時。例如通過改變如下屬性生成的css動畫,這時整個CSS動畫得以在compositor thread完成(而JS動畫則會在main thread執行,然後觸發compositor進行下一步操作):
- backface-visibility:該屬性指定當元素背面朝向觀察者時是否可見(3D,實驗中的功能);
- opacity:設定 div 元素的不透明級別;
- perspective 設定元素檢視,該屬性隻影響 3D 轉換元素;
- perspective-origin:該屬性允許您改變 3D 元素的底部位置;
- transform:該屬性應用於元素的2D或3D轉換。這個屬性允許你將元素旋轉,縮放,移動,傾斜等。
- JS在執行一些昂貴的任務時,main thread繁忙,CSS動畫由於使用了compositor thread可以保持流暢;
- 部分屬效能夠啟動3D加速和GPU硬體加速,例如使用transform的translateZ進行3D變換時;
通過設定
will-change
屬性,瀏覽器就可以提前知道哪些元素的屬性將會改變,提前做好準備。待需要改變元素的時機到來時,就可以立刻實現它們,從而避免卡頓等問題。- 不要將
will-change
應用到太多元素上,如果過度使用的話,可能導致頁面響應緩慢或者消耗非常多的資源。 例如下面的程式碼就是提前告訴渲染引擎 box 元素將要做幾何變換和透明度變換操作,這時候渲染引擎會將該元素單獨實現一幀,等這些變換髮生時,渲染引擎會通過合成執行緒直接去處理變換,這些變換並沒有涉及到主執行緒,這樣就大大提升了渲染的效率。
.box {will-change: transform, opacity;}
- 不要將
setTimeout、setInterval 和 requestAnimationFrame 的一些細節
setTimeout 和 setInterval
- setTimeout 的執行時間並不是確定的。在JavaScript中,setTimeout 任務被放進了非同步佇列中,只有當主執行緒上的任務執行完以後,才會去檢查該佇列裡的任務是否需要開始執行,所以 setTimeout 的實際執行時機一般要比其設定的時間晚一些。
- 重新整理頻率受 螢幕解析度 和 螢幕尺寸 的影響,不同裝置的螢幕繪製頻率可能會不同,而 setTimeout 只能設定一個固定的時間間隔,這個時間不一定和螢幕的重新整理時間相同。
setTimeout 的執行只是在記憶體中對元素屬性進行改變,這個變化必須要等到螢幕下次繪製時才會被更新到螢幕上。如果兩者的步調不一致,就可能會導致中間某一幀的操作被跨越過去,而直接更新下一幀的元素。假設螢幕每隔16.7ms重新整理一次,而setTimeout 每隔10ms設定影像向左移動1px, 就會出現如下繪製過程:
- 第 0 ms:螢幕未繪製,等待中,setTimeout 也未執行,等待中;
- 第 10 ms:螢幕未繪製,等待中,setTimeout 開始執行並設定元素屬性 left=1px;
- 第 16.7 ms:螢幕開始繪製,螢幕上的元素向左移動了 1px, setTimeout 未執行,繼續等待中;
- 第 20 ms:螢幕未繪製,等待中,setTimeout 開始執行並設定 left=2px;
- 第 30 ms:螢幕未繪製,等待中,setTimeout 開始執行並設定 left=3px;
- 第 33.4 ms:螢幕開始繪製,螢幕上的元素向左移動了 3px, setTimeout 未執行,繼續等待中;
- ...
從上面的繪製過程中可以看出,螢幕沒有更新 left=2px 的那一幀畫面,元素直接從left=1px 的位置跳到了 left=3px 的的位置,這就是丟幀現象,這種現象就會引起動畫卡頓。
- setInterval的回撥函式呼叫之間的實際延遲小於程式碼中設定的延遲,因為回撥函式執行所需的時間“消耗”了間隔的一部分,如果回撥函式執行時間長、執行次數多的話,誤差也會越來越大:
// repeat with the interval of 2 seconds
let timerId = setInterval(() => console.log('tick', timerId), 2000);
// after 50 seconds stop
setTimeout(() => {
clearInterval(timerId);
console.log('stop', timerId);
}, 50000);
- 巢狀的setTimeout可以保證固定的延遲:
let timerId = setTimeout(function tick() {
console.log('tick', timerId);
timerId = setTimeout(tick, 2000); // (*)
}, 2000);
requestAnimationFrame
除了上文提到的requestAnimationFrame的優勢外,requestAnimationFrame還有以下兩個優勢:
- CPU節能:使用setTimeout實現的動畫,當頁面被隱藏或最小化時,setTimeout 仍然在後臺執行動畫任務,由於此時頁面處於不可見或不可用狀態,重新整理動畫是沒有意義的,完全是浪費CPU資源。而requestAnimationFrame則完全不同,當頁面處於未啟用的狀態下,該頁面的螢幕重新整理任務也會被系統暫停,因此跟著系統步伐走的requestAnimationFrame也會停止渲染,當頁面被啟用時,動畫就從上次停留的地方繼續執行,有效節省了CPU開銷。
- 函式節流:在高頻率事件(resize,scroll等)中,為了防止在一個重新整理間隔內發生多次函式執行,使用requestAnimationFrame可保證每個重新整理間隔內,函式只被執行一次,這樣既能保證流暢性,也能更好的節省函式執行的開銷。一個重新整理間隔內函式執行多次是沒有意義的,因為顯示器每16.7ms重新整理一次,多次繪製並不會在螢幕上體現出來。
關於最小時間間隔
2011年的標準中是這麼規定的:
- setTimeout:如果當前正在執行的任務是由setTimeout()方法建立的任務,並且時間間隔小於4ms,則將時間間隔增加到4ms;
- setInterval:如果時間間隔小於10ms,則將時間間隔增加到10ms。
- 在最新標準中:如果時間間隔小於0,則將時間間隔設定為0。 如果巢狀級別大於5,並且時間間隔小於4ms,則將時間間隔設定為4ms。
定時器的清除
- 由於clearTimeout()和clearInterval()清除的是同一列表(活動計時器列表)中的條目,因此可以使用這兩種方法清除setTimeout()或 setInterval()建立的計時器。
參考資料
往期高分合集: