淺談異步其零

開一個系列的文章,內容是講 JavaScript 中的異步編程。作為開篇,當然是喜聞樂見的入門向介紹文啦。這篇系列文章應該是寫給對異步編程不太了解的人員,其次為了提升我的作文水平。在讀這篇文章之前,你可能需要有這些知識預備:

  • 基本的 HTML 知識
  • 基本的 CSS 知識
  • 了解一些 JS 語法(ES 5)

JavaScript 中的異步編程,想必大多數前端開發者都接觸過,並且體會過它帶來的苦難。我第一次接觸它的時候,搞的暈頭轉向的,就像是這個:

讓一個元素被點擊后發生漸出效果,漸出完成后讓這個元素的值自增 1,最後再漸出出來

好的……幾年前不諳 JS 的我肯定會寫成像下面這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Async 000</title>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
display: flex;
justify-content: center;
align-items: center;
}
#num {
display: inline;
cursor: pointer;
font-size: 16em;
font-family: "Book Antiqua";
border: 0;
background: transparent;
outline: none;
opacity: 1;
transition: opacity 1s;
}
</style>
</head>
<body>
<button id="num">0</button>
<script src="frame.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// frame.js
// 每點擊一次元素 #num 應該要先漸出
// 然後自增 1 后再漸入
var num_ele = document.querySelector('#num')
num_ele.onclick = function () {
num_ele.style.opacity = 0
var text = num_ele.textContent.trim()
var num = parseInt(text)
num_ele.textContent = num + 1
num_ele.style.opacity = 1
}

它原本應該是點擊后隱藏……然後自增了數字 1,最後再顯示出來……但是實際情況並不是這樣[1]。這是怎麼回事?

哦,我想遇到過這種坑的人應該都知道怎麼回事了。問題的痛點在於 onclick 事件里的事情一瞬間就做完了,以至於剛漸出的特效也一下子就被後面的漸入阻止了,所以根本就看不出它有什麼動畫效果。

這種情況,我們要怎麼做?通常來說我們會使用計時器 setTimeout。這就是異步編程。

1
2
3
4
5
6
7
8
9
10
11
12
num_ele.onclick = function () {
num_ele.style.opacity = 0
//延遲 1000 毫秒,也就延遲 1 秒后再執行
setTimeout(function () {
var text = num_ele.textContent.trim()
var num = parseInt(text)
num_ele.textContent = num + 1
num_ele.style.opacity = 1
}, 1000)
}

嗯……這個效果很好很強大了。那麼還有什麼問題嗎?細心一點的朋友已經發現了,連續點擊元素會出現很奇怪的效果。。。那麼該怎麼辦?

問題的痛點在於 onclick 事件被多次觸發導致執行了多次相同的行為(函數)……導致計時器也進行了多次的定時執行,然後表現就是……連續快速點擊多次后數字自己在跳動。

解決這種問題,可以用一個「鎖」變量來阻止多次觸發 onclick 事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 鎖初始化
num_ele._plusLock = false
num_ele.onclick = function () {
// 判斷鎖的值是否為 true
if (num_ele._plusLock) {
return
}
// 給鎖變量賦值 true 以阻止後面再次點擊的問題
num_ele._plusLock = true
num_ele.style.opacity = 0
// 延遲 1000 毫秒,也就延遲 1 秒后再執行
setTimeout(function () {
var text = num_ele.textContent.trim()
var num = parseInt(text)
num_ele.textContent = num + 1
num_ele.style.opacity = 1
// 這邊也要延遲了,
// 要等到動畫完全播放完成后才「解鎖」
setTimeout(function () {
num_ele._plusLock = false
}, 1000)
}, 1000)
}

好的……這個點擊自增的代碼已經很完備了,不會再出現一些詭異的問題……

回到正題

那麼,通過這則案例,我們能知道什麼呢?首先……setTimeout 會造成暫停一段時間后再運行的現象……那麼這種情況呢?

1
2
3
4
5
6
7
8
9
10
function foo(value) {
setTimeout(function () {
value = value * value
}, 500)
return value
}
foo(9) // 9
foo(-1) // -1

函數 foo() 執行后返回了原原本本的數字,setTimeout()設置的異步語句就這麼被「略過」了。那麼,它真的是被略過了嗎?再繼續往下看:

1
2
3
4
5
6
7
8
9
10
11
12
function fooVer2(value) {
setTimeout(function () {
value = value * value
console.log('now, the value is ' + value)
}, 500)
return value
}
fooVer2(-4)
// -4
// (過了一會兒)now, the value is 16

原來並沒有略過,只是 setTimeout() 所指定的函數被暫停了一段時間后才被執行。這恰好符合了 setTimeout() 的名稱之意思——定時

由此我們得知 JavaScript 異步編程至少應該有兩個特點:

  • 不會卡住後續代碼的執行(非阻塞)
  • 即使函數 fooVer2() 執行了 return 語句后,這個函數沒有被「關閉」[2]

以上。


  1. 這得看是什麼樣的瀏覽器了。。。每個瀏覽器表現都可能不同,不過都是異常的特技 ↩︎

  2. 準確地說,應該是作用域沒有被回收,所以 value 變量依舊可用(閉包) ↩︎