在图形用户界面编程中(也被称为GUI编程),用单一线程进行GUI控制是通常的做法

如果使用多线程来进行GUI编程,例如,一个线程修改某个UI模块的属性,另一个线程直接删除这个UI模块,那么UI的表现将很难被预测

Javascript运行的环境是单线程的:每一个window或者一个node.js程序只有一个线程

因此,在JS程序正在执行的某个时间,只有特定的某个语句正在执行(此时其它的语句会被阻塞)

我个人很喜欢知乎上面@云澹的回答

运行在浏览器中,是单线程的,每个window一个JS线程,既然是单线程的,在某个特定的时刻只有特定的代码能够被执行,并阻塞其它的代码。而浏览器是事件驱动的(Event driven),浏览器中很多行为是异步(Asynchronized)的,会创建事件并放入执行队列中。javascript引擎是单线程处理它的任务队列。所以当多个事件触发时,会依次放入队列,然后一个一个响应。

@云澹

单线程

Javascript的主线程中,我们有一个堆(heap)栈(stack)的结构:

heap&stack

其中:

  • 堆的结构负责内存的分配
  • 栈的结构负责线程中要执行的内容,被称为访问栈(call stack)

我们可以看看访问栈的实现:

1
2
3
4
5
6
7
8
9
10
11
function foo(a) {
return a*2
}
function bar(b) {
return foo(b)
}
function printDouble(c) {
var sum = bar(c)
console.log(sum)
}
printDouble(100)

在执行的过程中,我们的访问栈发生以下的变化:

  1. 主线程进栈
  2. printDouble进栈
  3. bar进栈
  4. foo进栈
  5. foo出栈
  6. bar出栈
  7. console.log进栈
  8. console.log出栈
  9. printDouble出栈
  10. 主线程出栈

如果我们在foo函数中发生了错误,我们会得到报错信息:

1
2
3
4
5
Uncaught Error: 发生了错误(…)  VM1436:1
foo @ VM14136:1
bar @ VM14205:1
printDouble @ VM14298:1
(anonymous function) @ VM14324:1

我们得到从上到下的结果就是所谓的访问栈

当我们得到了RangeError: maximum call stack size exceeded的报错信息时,就代表我们的访问栈的大小不够了,去检查你的程序中有没有进行无限循环调用的语句吧 : )

同步与异步

因为Javascript是单线程,一个Javascript程序只有一个访问栈,函数语句按照进入的顺序一个接一个地被执行,前一个结束,才轮到后一个开始

然而有的时候,某些函数语句的主要时间消耗并不在CPU上,而是在输入输出设备(包括网络)上面,比如Ajax,这样的任务消耗了很多时间,从而阻塞了后续任务的执行,同时也浪费了CPU的资源

单线程任务同步执行的优势就是清晰易于理解,其缺陷就是经常会出现这种情况

为了解决这个问题,我们把要执行的任务分为两种:同步(synchronous)异步(asynchronous)

  • 同步任务就是一般情况下的语句 —— 只有执行完毕之后才会执行后面的语句
  • 异步任务则是特殊的语句 —— 先执行特定的语句引发异步操作:调用相关接口,然后等待其他的部分的运转,等时机成熟再执行与运转结果相关的事件

比如我们比较一下采用同步和异步的方式来执行Ajax

  • 同步执行时,我们调用Ajax的接口,根据地址发起请求,直到响应返回,才执行接下来的任务
  • 异步执行时,我们调用Ajax的接口请求相应地址,然后执行接下来的任务,当响应返回时,特定的函数语句会被加入到访问栈

更加明显的例子当我们使用node.js时,我们会发现很多函数会有同步异步两个版本,比如readFilereadFileSync

  • 当你使用readFile时,最后一个参数为回调函数,响应返回时会被异步执行
  • 当你使用readFileSync时,下一个语句一定是在readFileSync的文件操作结束返回结果后进行的

异步任务

以下几类任务被当做异步任务:

  • DOM事件监听
  • XMLHttpRequest
  • setTimeout & setInterval

注意,DOM事件监听 – addEventListener函数就是异步过程的发起函数,事件监听器函数就是异步过程的回调函数,事件触发时,表示异步任务完成,会将事件监听器函数封装成一个任务放到任务队列中,等待主线程执行,事件冒泡时,多条任务按照事件冒泡的顺序进入任务队列

onclick,onchange等的handler函数也是异步执行的,同理

异步任务伴随着回调函数 —— 在任务时机成熟时执行的函数,回调函数是异步调用,也就是所谓事件驱动的

任务队列

Javascript线程中有一个任务队列:保存着等待执行的异步任务的队列

观察一下所谓的事件队列的模型

模型

我们举个关于setTimeout的例子:

1
2
3
4
setTimeout(function(){
console.log('Timeout!')
})
console.log('end!')
  1. 主线程进栈
  2. setTimeout进栈
  3. 调用setTimeoutAPI,设置一个定时器,5秒钟之后回调函数加入任务队列
  4. setTimeout出栈
  5. console.log('end!')进栈
  6. console.log('end!')出栈
  7. 主线程出栈
  8. 事件轮询,任务队列中的匿名函数进栈
  9. console.log('Timeout!')进栈
  10. console.log('Timeout!')出栈
  11. 匿名函数出栈

每当访问栈空的时候,栈会执行事件轮询操作,从任务队列里面读取任务并执行

所以,异步过程的回调函数,一定不在当前这一轮事件循环中执行

单线程和异步

所以像setTimeout这样的异步任务不一定会准确执行

我们先看一下回答中的代码段:

1
2
3
4
5
6
7
8
9
10
function foo() {
console.log("first")
setTimeout(function(){
console.log('second')
},5)
}
for (var i = 0; i < 1000000; i++) {
foo()
}
// 执行结果为:first*1000000, second*1000000

按照我们正常的思维,我们执行1000000次foo函数,每执行一次都会设置一个五毫秒的定时器,然后console.log"second",但是我们发现控制台在几秒内一直在重复弹出"first"(这段时间远大于五毫秒),并没有second在这段时间弹出,直到所有first全部弹出完毕

现在我们能理解这个问题了,只有访问栈里面的任务全部推出之后,任务队列里面的回调函数才会开始执行,所以在first没有完成输出之前,异步调用的second不会执行

我们同时可以理解foo()setTimeout(foo,0)的具体区别了

在React出现的一点点问题

React中,setState函数是异步执行的,如果你在Component的方法里面先调用setState再调用this.state,你会发现后面this.state得到的值与前面setState设置的值不一样,得到的仍然是setState之前的值

看完前面的分析,我想这并不那么难以理解了:setState会进入任务队列,直到访问栈被清空,才会执行事件轮询,从而执行任务队列中的任务

我在React的开发中遇到过如下的问题:

这个问题讲述了同步/异步调用setState的区别

这个jsbin上面存在相关的实例:

Setting value代表renderState里面的内容
Current value代表render时,用getDOMNode得到的DOM里面的属性

注意:这是一个[Controlled Components](https://facebook.github.io/react/docs/forms.html#controlled-components),会忽视用户对`input.value`在`DOM`上的改变,但`e.target.value`的值是用户在`input`上改变后的值

我把 @FakeRainBrigand 的答案 以及自己的理解,进行了结合,得到了下面的过程

同步调用:

  1. 输入Xinput标签里面
  2. input.value不变,触发onchange事件
  3. 执行事件处理函数onchange
  4. 执行setState({value: 'HelXlo'})
  5. 进行虚拟DOMDiff操作,识别出input.value应该是'HelXlo',将DOM操作加入任务队列
  6. onchange任务结束,将render加入任务队列
  7. 执行事件轮询,依次执行DOM操作与render
  • DOM操作将input.value改为当前state的值为'HelXlo'
  • render,此时state里面valueDOM里面input.value都是'HelXlo'

异步调用:

  1. 输入Xinput标签里面
  2. input.value不变,触发onchange事件
  3. 执行事件处理函数onchange,将setState的定时器加入任务队列
  4. onchange任务结束,将render加入任务队列
  5. 进行事件轮询,执行了任务队列中的定时器,执行setState
  6. 执行setState({value: 'HelXlo'})
  7. 进行虚拟DOMDiff操作,识别出input.value应该是’Hello’,将DOM操作加入任务队列`
  8. render,此时state里面value'HelXlo',但DOMinput.value'Hello'
  9. 执行任务队列后面的任务,进行DOM操作,改变input.value'HelXlo'(此时DOM更新令光标被置于input末尾)
有一点小小的 **魔法**: `React`会在`onchange`这种`事件处理函数`后异步调用`render`方法(而`DOM`操作也是异步执行的,别忘了)

通过各种猜想和实验,我得到了上面的结果

我不确认自己的想法是对的,所以这篇博客先放在这里,等我对源码有了更深的理解在回来证实和修改