给程序员看的Javascript攻略(完结)- 异步

3,771 阅读8分钟


原文发表在: holmeshe.me , 本文是汉化重制版。

本系列在 Medium上同步连载。

用ajax胡乱做项目的时候踩过好多坑,然后对JS留下了“非常诡异”的印象。系统学习后,发现这个构建了整个互联网表层的语言其实非常666。这次的学习已经告一段落,本篇也是这个系列的最后一部分。回头看来,把学习记录发出来这个经历挺奇特的,以前是写了给自己看,现在随便搞搞发来掘金就3000+的总阅读,顿时感觉有意义了很多。所以我也想明白了,你看,我就有动力写。

其实没啥新鲜的

简单来讲,异步有两层含义,1)让慢操作不要阻塞;2)非线性触发事件。稍稍讲深一点,在操作系统里,事件也叫中断,这里一次中断可以代表一个网络收包,一次时钟,或者一次鼠标点击,等。那从技术上层面看,一个事件可以中断当前进程,挂起下一条指令,并且“异步地”调用一个预设好的代码块(事件处理函数)。
应用层也一样。

阻塞操作的问题

狭义来说,异步可以解决应用阻塞(一般是I/O)的问题。为啥要聊异步一定要说阻塞呢?那我们从头来看看。每一个带UI的应用(无论是嵌入式的,还是APP,游戏还是一个网页),底下都一个循环在非常快的刷新屏幕,那如果这个循环被阻塞了,比如在这个循环上进行了一次网络请求,UI就卡了,用户也就跑了。而JavaScript就跑在这个循环上。
这次要先做点实验前准备。
首先,下载Moesif Origin & CORS Changer。这个用来让Chrome给我们的跨站请求放行。
然后,我们用Python来实现一个慢服务(API):
from flask import Flask
import time
app = Flask(__name__)
@app.route("/lazysvr")
def recv():
  time.sleep(10)
  return "ok"
if __name__ == "__main__":
  app.run(host='***.***.***.***', threaded=True)
然后我们打开Moesif Origin & CORS Changer,(不然请求直接失败返回了),然后我们跑例子:
<html>
<head>
</head>
<body>
  <button type="button">Click Me!</button> 
  <script>
    var xmlHttp = new XMLHttpRequest();
    xmlHttp.open( "GET", "http://***.***.***.***:5000/lazysvr", false ); // false for synchronous request
    xmlHttp.send( null ); // the thread is suspended here
    alert(xmlHttp.responseText);
  </script>
</body>
</html>
如果我们打开开发者面板,可以很容易观察到,代码会卡在下面这行:
xmlHttp.send( null ); // it is the aforementioned blocking operation
在卡住的这10秒左右,按钮是点不动的,然后浏览器才会跳出弹窗:
ok
并且,Chrome会抱怨:
[Deprecation] Synchronous XMLHttpRequest on the main thread is deprecated because of its detrimental effects to the end user’s experience. For more help, check https://xhr.spec.whatwg.org/.
暂且把这个当作对这个问题的官方描述吧。

来一波异步

广义来讲,以下的都属于异步操作:
1)把慢操作放到其它线程执行;
2)由外部触发的事件;
3)两者的混合。
下面我会举三个例子来说明
第一个🌰,收包

这个例子的代码也可以解决上节的阻塞问题,
打上码:
<html>
<head>
</head>
<body>
  <button type="button">Click Me!</button> 
  <script>
    var xmlHttp = new XMLHttpRequest();
--  xmlHttp.open( "GET", "http://192.241.212.230:5000/lazysvr", false );
++  xmlHttp.open( "GET", "http://192.241.212.230:5000/lazysvr", true ); // 1) change the param to "true" for asynchronous request

++  xmlHttp.onreadystatechange = function() { // 2) add the callback
++    if(xmlHttp.readyState == 4 && xmlHttp.status == 200) {
++      alert(xmlHttp.responseText);
++    }
    }

    xmlHttp.send(); 
--  alert(xmlHttp.responseText);
  </script>
</body>
</html>
在上面的代码里,我们1)把open()的第二个参数变成“true”,这样可以把慢操作负载到其它线程上去;2)注册一个回调函数来监听收报事件。这个回调函数在网络交互完成后会被立即执行。
这次按钮就可以点了,然后
ok
也按照预期弹出。
再来一个,时钟周期

直接先打上码:
setTimeout(callback, 3000);
function callback() {
  alert(‘event triggered’);
}
注意1,JS从一开始就没有同步的sleep()函数;
注意2,和开始说的OS不一样,这个时钟是绝对不会触发进程调度的,正如之前提到,所有的JS代码都是运行在一条线程上。
第三个,点击鼠标

<html>
<head>
</head>
<body>
  <button type=”button” onclick=”callback()”>Click Me!</button> 
  <script>
    function callback() {
      alert(‘event triggered’);
    }
  </script>
</body>
</html>
在上面的三个例子中,我们都给特定的事件(由非主循环触发)注册了回调函数。在第一个例子里,我们还把一个慢操作负载到了其它线程来解决卡死的问题。所有这些操作都可以用一个词来概括,异步!

新的fetch()接口

在第一个🌰中,我用回调来举例是因为比较直观。其实更好的办法是用fetch()来进行网络请求。这个函数会返回一个Promise对象,再用这个对象调用then()函数的话:

1. 异步操作的代码就可以变成线性(更像同步)了;

2. 回调地狱的问题可以得到解决了;

3. 所有的相关异常,可以在一个代码块里处理了:

<html>
<head>
</head>
<body>
  <button type=”button” onclick=”callback()”>Click Me!</button>
   <script>
    fetch(‘http://***.***.***.***:5000/lazysvr')
    .then((response) => {
      return response.text();
    }).then((text) => {
       alert(text);
    }).catch(function(error) {
      console.log(‘error: ‘ + error.message);
    });
 </script>
</body>
</html>

运行结果和第一个🌰一样,我还是留了按钮给你试UI有没有卡。

底层机制,多线程+事件循环

JS不是单线程吗?

答案是,即是也不是。什么意思?

var i;
for (i = 0; i < 1000; i++) {
  var xmlHttp = new XMLHttpRequest();
  xmlHttp.open( "GET", "http://***.***.***.***:5000/lazysvr", true );
  xmlHttp.onreadystatechange = function() {
     if (xmlHttp.readyState == 4 && xmlHttp.status == 200) {
        alert(xmlHttp.responseText);
    }
  } // end of the callback
  xmlHttp.send( null );
}

假设浏览器的pid是666(巧了,我做这个测试的时候还真是),我们用一小段脚本(环境是Mac)本来观察线程状态:

#!/bin/bash
while true; do ps -M 666; sleep 1; done

初始值(我把无关的列和行都干掉了):

USER     PID ... STIME    UTIME
holmes   666 ... 0:00.42  0:01.47
 ......
         666     0:00.20  0:00.64

结束的时候:

USER     PID ... STIME    UTIME
holmes   666 ... 0:00.50  0:01.88
 ......
         666     0:00.37  0:01.28

除了主线程,还有一条非常活跃的线程,我估摸着这条是用来监听网络的(多路复用)套接字。

所以JS代码确实是运行在一条线程里。但是如果从应用程序的角度来看,它其实是多线程。用同样的办法测一下Node吧。

“粗”暴的事件循环

上文提到,操作系统的中断是以指令为粒度的,但是这个传说中的事件循环,粒度就有点大了:

var i;
for (i = 0; i < 3; i++) {
  alert(i);
}
setTimeout(callback, 0);
function callback() {
  alert(‘event triggered’);
}

我们都知道结果是:

1
2
3
event triggered

简单来说呢,虽然我们注册了一个定时事件,并且指定它立即执行,但是JS引擎还是在运行时忠实的把本次循环跑完,才会去理刚刚注册的那个事件。

这个代表一般事件中断是以指令周期为单位,而JS是以循环周期为单位的。

有点尴尬了,这么大粒度的事件处理会不会导致UI响应时间长呢?我觉得其实不会。即使在以指令周期为单位的事件响应里,用户的操作还是需要在本次"循环周期"结束放到主线程来,然后反映到UI。因为一切UI更新都要在主线程。所以,这个极其简化的单线程设计本身并不会对UI性能造成影响。你觉得呢?

迟到的总结

这个系列中,我覆盖了在JS里被细化的 "等于" 操作符和 "null" 值被简化的 字符串,数组,对象和字典。然后我在这篇这篇里深入到prototype这一层进一步讨论了一下对象。最重要的是,我三次提及了this的坑:

第一次

第二次

第三次

说明真的很重要。

最后就是本篇了,用我理解的角度聊了一下异步。

如果你还记得的话,这个系列是我为新工作(临时)学JS准备的。以现在上手程度来看,我觉得这个底子打的还不错,希望对你也一样。但是这个文章并不全面,所以我准备了如下的附加阅读:

JavaScript types

Closure

The debug technique 我用来调试的方法

这篇很有趣,我第一次读到,希望有机会能翻译 interesting topic

Another place to understand “this

More about event loop

A good blog, 最重要的事,

常来掘金看篇。

最后要承认第一段的结构是模仿乔帮主在第一次苹果(iPhone1)发布会的经典段式。(写这篇文章的时候,实在被最新的发布会感动了一把)。如果没看过去找找吧。

感谢阅读,后会有期!