前言
「为什么这行先跑到没有先执行?」
setTimeout
就是等几秒就会执行addEventListener
就是要等事件触发fetch
就是要等后端回应
解释「同步」与「非同步」
在游戏里,客人点菜了之后,对应的食材原料就会送出来。
假设我们以这个番茄沙拉当例子:
在领完食材原料之后,我们会有青菜、番茄需要处理。
但你不需要等到青菜切完才能处理番茄。
而是在收到食材的同时,负责青菜的朋友就去处理青菜,负责番茄的朋友就去处理番茄。
可能青菜先处理好,也可能番茄先处理好,但不要紧,等到青菜、番茄这些食材都弄好了,最后再一起装盘、出餐。
像这样处理事件的流程不会被「卡住」,就是非同步(Asynchronous) 的概念。
那么「同步」(Synchronous) 的概念又是什么呢?
假设边缘人如我,只能自己一人玩 Overcooked,在领完食材原料之后,一样会有青菜、番茄需要处理。
因为只有一个厨师,所以要嘛先处理青菜、要嘛先处理番茄,必须先弄完一项之后再去处理另一项,整个流程会被前一个步骤卡住。
像这样「先完成 A 才能做 B、C、D ... 」的运作方式我们就会把它称作「同步」(Synchronous) 。
所以回到一开始所说的,「同步」光看字面上就可能把它想成是「所有动作同时进行」,但事实上比较像是「一步一步来处理」的意思。而「非同步」则是,我不用等待 A 做完才做 B、C,而是这三个事情可以同时发送出去。(当然回传结果的顺序也不一定就是)
下面来点硬核的......
Javascript Runtime
首先需要提到的是 Javascript Runtime,中文大概可以翻成 Javascript 的「执行环境」吧,比如 Chrome、Firefox、node,每个 runtime 提供的 API 都不同,所以不是所有地方都有 window
物件,setTimeout
之类的 Web API。
Runtime 会随着环境而不同,但有两个机制,是属于 Javascript 的机制,因此任何地方都一样:Call Stack (存放指令)、Memory Heap (存放资料)
另外还有两个名词也先介绍一下:
- Callback Queue:用来存放从 Web api 过来,准备要进入 Call Stack 的指令
- Event Loop:会不断监看 Call Stack,如果空了就会把 Callback Queue 的指令放到 Call Stack 执行
程式码在背景的处理顺序
对于一段程式码,Javascript engine 底层会依序做这些事:
- 把 JS 的指令一行一行放到 Call Stack,并且执行
- 途中如果遇到不属于 JS 自身 (如: Web API) 的指令,因为 JS 看不懂,会交由 Web API 处理
- Web API 处理后的 (如:
setTimeout
秒数数完) 程式码会放到 Callback Queue - Event Loop 不断地监看 Call Stack 是否空了,如果空了就会把 Callback Queue 的指令放到 Call Stack 执行
$.on('button', 'click', function onClick() {
setTimeout(function timer() {
console.log('clicked');
}, 2000);
});
console.log("Hi!");
setTimeout(function timeout() {
console.log("Click the button!");
}, 5000);
console.log("Welcome");
范例
以下程式码为例,用到$.on
(类似addEventListener
) 跟 setTimeout
这种非同步的程式码,但中间也夹杂了一些console
,在 background 会怎么运作呢?
- 把
$.on()
放到 Call Stack - 把
$.on()
交由 Web API 处理 (因为不是原生 JS) - Web API 开始等待按钮点击事件
- 把
console.log("Hi!")
放到 Call Stack,执行 - 把
setTimeout()
放到 Call Stack - 把
setTimeout()
交由 Web API 处理 (因为不是原生 JS) - Web API 开始等待 5 秒
- 把
console.log("Welcome")
放到 Call Stack,执行
至此,Call Stack 已净空,Event Loop 会把 Callback Queue 里面的指令搬到 Call Stack 执行
- (过了 5 秒)
- Web API 内的
setTimeout()
的 callback function 被搬到 Callback Queue - Event Loop 把
setTimeout()
的 callback function 搬到 Call Stack,执行
至此,Call Stack 再度净空
- (过了 5 秒)
- Web API 内的
setTimeout()
的 callback function 被搬到 Callback Queue - Event Loop 把
setTimeout()
的 callback function 搬到 Call Stack,执行
至此,Call Stack 再度净空
- (使用者点击了按钮)
- Web API 内的
$.on()
的 callback function 被搬到 Callback Queue - Event Loop 把
$.on()
的 callback function 搬到 Call Stack,执行 - 把
setTimeout()
交由 Web API 处理 (因为不是原生 JS) - Web API 开始等待 2 秒
至此,Call Stack 再度净空
- (过了 2 秒)
- Web API 内的
setTimeout()
的 callback function 被搬到 Callback Queue - Event Loop 把
setTimeout()
的 callback function 搬到 Call Stack,执行
至此,Call Stack 再度净空
如果我提早点击按钮?
上述的流程是比较顺的正向流程,但真实情境下,哪会在那边等 5 秒才按按钮啊,如果我们提早点击按钮,会发生什么事?
你会发现,因为你点击,Callback Queue 很早就有指令了,但那个指令只能乖乖排队,等程式码跑完最后一行程式,才会轮到它,因为 Event Loop 要等 Call Stack 的指令都跑完,才会放 Callback Queue 的人进来。
setTimeout 0 秒算是同步还非同步?
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
这问题真的很妙,「我等了你 0 秒,请问我算是有等你吗?」,仿佛成了哲学思考问题 XD
如果 setTimeout
只用一句很简单的「等了几秒就执行」来概括,就会在这个范例被卡住,因为这个范例一秒都不用等,那是不是就会立刻执行呢?
大家可以试着自己想想上述的程式码会印出什么,其实核心问题跟上一题提早点击是一样的。
最后答案是:
1
3
2
没错,只要是非同步 (如 Web Api) 的程式码,就一定要进 callback queue 蹲着,不管有多快进去,都一定要等 call stack 的程式码都跑完,才有可能轮到它。
对于新手比较容易理解的会是这样:「要等同步都执行完,才会轮到非同步」。
视觉化的 Playground
这个网站是我极力推荐的地方,我的抽象思维不太好,没办法在脑袋中把同步跟非同步搅在一起,可以透过这个 playground,把你写的 code 实际在 background 执行起来,连 background 在做的事情都清楚显示给你看,特别适合像我一样的视觉化动物 (?)。
可以看到当你将上面的程式贴上去,按下 Save and Run,程式并不会咻一声就跑完,而是用大约每一秒 2 个指令的速度,把这行程式码是被放到 Call Stack 还是 Callback Queue,清楚显示在画面上,可以很清楚知道电脑现在正在处理哪个指令。如果还是嫌太快,作者也准备了 Pause 按钮,按照自己的步调调整。
前面两题关于「提早点击」与「setTimeout 0 秒」的问题,你都可以在这个 playground 找到解答。
结语
如果你很有耐心,把上述一大串都看完,就会知道「非同步」诞生的原因,真的不是凭空诞生的,而是因为就像上面的 Web Api 那样,对于 Javascript 以外的指令,透过背景运作的机制来完成,就是所谓的「非同步」。
别走,非同步还没讲完.....(被打),下期会讲非同步的三大境界,来测测你真的懂非同步吗?