JS三座大山——原型和原型链,作用域和闭包加上这次要总结的 异步和单线程。
同步和异步
同步和异步有什么区别呢?
- 同步会阻塞代码的执行,而异步不会阻塞。
// 同步
console.log(100);
alert(200);
console.log(300);
// 只要alert的弹框还存在,代码就停留在这里,不会往下执行,也就是不会打印300
// 异步
console.log(100);
setTimeout(function () {
console.log(200);
}, 1000);
console.log(300);
// 而setTimeout不会阻塞代码,会接着往下执行
-
JS会将所有任务分成两类:同步任务和异步任务
-
同步任务:在主线程上执行的任务,只有前一个任务执行完成,才会执行下一个任务。
-
异步任务:不进入主线程,而进入“任务队列”。当主线程上的任务执行完,JS才会去执行“任务队列”的任务。
对于
setTimeout(fn, 200)
,当到200ms时,fn
会被放入“任务队列”,而任务队列必须等到主线程已有的代码执行完后才会执行fn
。所以当程序执行到setTimeout(fn, 200)
这一行代码时,时间就开始计算,但是fn
实际执行并不一定是在200ms后,可能是在更久的时间后(取决于主线程的同步代码的执行时间)。
-
-
发出调用,立即得到结果是为同步。发出调用,但无法立即得到结果,需要额外的操作才能得到预期的结果是为异步。 同步就是调用之后一直等待,直到返回结果。异步则是调用之后,不能直接拿到结果,通过一系列的手段才最终拿到结果(调用之后,拿到结果中间的时间可以介入其他任务)。
同步
如果在函数返回结果的时候,调用者能够拿到预期的结果(就是函数计算的结果),那么这个函数就是同步的。
同步就是调用之后一直等待,直到返回结果。
如果函数是同步的,即使调用函数执行任务比较耗时,也会一致等待直到得到执行结果。
function wait(){
var time = (new Date()).getTime(); // 获取当前的unix时间戳
while((new Date()).getTime() - time > 5000){}
console.log('5秒过去了');
}
wait();
console.log('慢死了');
异步
如果在函数返回的时候,调用者还不能购得到预期结果,而是将来通过一定的手段得到(例如回调函数),这就是异步。
一个异步过程通常是这样的:主线程发起一个异步请求,异步任务接收请求并告知主线程已收到(异步函数返回);主线程可以继续执行后面的代码,同时异步操作开始执行;执行完成后通知主线程,主线程收到通知后,执行一定的动作(调用回调函数)。因此,一个异步过程包括两个要素:注册函数和回调函数,其中注册函数用来发起异步过程,回调函数用来处理结果。
什么时候用到异步呢?
- 在可能发生等待的情况
- 等待过程不能想
alert
一样阻塞代码的执行 - 所有的“等待的情况”都需要异步
前端使用异步的场景
- 定时任务:如setTimeout,setInterval
- 网络请求:如ajax请求,动态
<img>
加载 - 事件请求
- IO操作:如readFile,readdir
异步有哪些优缺点
- 优点:异步的代码可以大大提升系统的容量上限,因为充分利用了空闲的CPU时间。
- 缺点:反直觉(回调流程控制问题复杂,所以出现promise,async/await这些东西)。
- 嵌套层次深的,难以维护
- 无法正常使用
return
和throw
- 无法正常检索堆栈信息
- 多个回调之间难以建立联系
单线程和多线程
浏览器
一个浏览器通常由以下几个常驻的线程:
- 渲染引擎线程,负责页面的渲染
- js引擎线程,负责js的解析和执行
- 定时触发器线程,处理setInterval和setTimeout
- 事件触发线程,处理DOM事件
- 异步http请求线程,处理http请求
要注意的是渲染引擎和js引擎线程是不能同时进行的。渲染线程在执行任务的时候,js引擎线程会被挂起。因为若是在渲染页面的时候,js处理了DOM,浏览器就不知道该听谁的了
JS引擎
通常讲到浏览器的时候,我们会说到两个引擎:渲染引擎和 JS 引擎。
1、渲染引擎: Chrome/Safari/Opera
用的是 Webkit
引擎,IE
用的是 Trdent
引擎,FireFox
用的是 Gecko
引擎。不同的引擎对同一个样式的实现不一致,就导致浏览器的兼容性问题。
2、JS引擎: js 引擎可以说是 js 虚拟机,负责解析 js 代码的解析和执行。不同浏览器的 js 引擎也各不相同,Chrome
用的是 V8
,FireFox
用的是 SpiderMonkey
,Safari
用的是 JavaScriptCore
,IE
用的是 Chakra
。通常有以下步骤:
- 词法解析:将源代码分解位有意义的分词
- 语法分析:用语法分析器将分词解析成语法树
- 代码生成:生成机器能运行的代码
- 代码执行
之所以说 js 是单线程就是因为浏览器运行时只开启一个 js 解释器,原因是若有两个线程操作 DOM,浏览器就会懵逼…。虽然 JavaScript 就是单线程的,但是浏览器不是单线程的。因为一些 I/O 操作,定时器计时和事件监听是有其他线程来完成的。
单线程
JavaScript的单线程(是这门语言的核心),与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途就是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
HTML5中提出web worker标准,允许JavaScript脚本创建多个线程,但是子进程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
-
JS代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序,还依靠任务队列(task queue)(或者说事件队列)来调控异步任务。
和排队类似。哪个异步操作完成的早,就排在前面。不论异步操作何时开始执行,只要异步操作执行完成,就可以到任务队列中排队。这样,主线程在空闲的时候,就可以从任务队列中获取任务并执行。
任务队列中放的任务具体是什么东西?任务的具体结构当然跟具体的实现有关。但是为了简单起见,可以认为:任务就是注册异步任务时添加的回调函数。
-
任务队列分为macro-task(宏任务)与micro-task(微任务),在最新标准中,它们被分别称为task与jobs。
- macro-task大概包括:
script(整体代码),setTimeout,setInterval,I/O,UI rendering,setImmediate
- micro-task大概包括:
Promise,MutationObserver(html5新特性),Object.observe(已废弃),process.nextTick
- 除了
script
整体代码(可以理解为待执行的所有代码),micro-task
任务的优先级高于macro-task
的任务优先级。
任务队列存在多个,同一任务队列内,按队列顺序被主线程取走;不同任务队列之间,存在着优先级,优先级高的优先获取(如用户I/O)
- macro-task大概包括:
一个很重要的概念:watcher(观察者)
在使用事件驱动的系统中,必然会有非常非常多的事件。如果事件都产生,都要主循环去处理,必然会导致主线程繁忙。那对应的应用层的代码而言,肯定有很多不关心的事件(比如只关心点击事件,不关心定时器事件)。这会导致一定浪费。
- 事实上,不是所有的事件都放置在一个队列里。
- 不同的事件,放置在不同的队列。
- 当我们没有使用定时器时,则完成不关心定时器事件这个队列。
- 当我们调用定时器时,首先会设置一个定时器
watcher
。事件循环的过程中,会去调用该watcher
,检查它的事件队列上是否产生事件(比对时间的方式)。 - 当我们进行
磁盘 IO
的时候,则首先设置一个io watcher
,磁盘 IO
完成后,会在该io watcher
的事件队列上添加一个事件。事件循环的过程中从该watcher
上处理事件。处理完已有的事件后,处理下一个watcher
。 - 检查完所有的
watcher
后,进入下一轮检查。 - 对某类事件不关心时,则没有相关的
watcher
事件循环(Event-loop)—— 实现异步的一种机制
实现异步的方法包括 event-loop,轮询,事件等。
event-loop
是JavaScript
的Runtime
的一种执行机制(宿主环境来实现的)。
首先需要搞懂什么是JavaScript 执行引擎
和JavaScript 执行环境
。通常说的引擎指的是虚拟机,对于Node
来说是V8
、对Chrome
来说是V8
,对Safari
来说是JavaScript Core
,对Firefox
来说是SpiderMonkey
。JavaScript 的执行环境有很多,上面说的各种浏览器、Node、Ringo 等。前者是Engine
,后者是Runtime
。
对于 Engine 来说,他们要实现的是 ECMAScript 标准。 对于什么是 event-loop,他们没兴趣也不关心。
主线程运行的时候,产生堆和栈,栈中的代码调用各种外部API,异步操作执行完成后,就在任务队列中排队。只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些异步任务所对应的回调函数。
一个宿主环境 只能有一个事件循环(Event loop
),而一个事件循环可以多个任务队列(Task queue
),每个任务都有一个任务源(Task source
)。相同任务源的任务,只能放到一个任务队列中。不同任务源的任务,可以放到不同任务队列中。然后 js引擎
做的事就是不断的去读取这些队列里面的任务来执行。
事件驱动的实现过程主要靠事件循环完成。 进程启动后就进入主循环。主循环的过程就是不停的从事件队列里读取事件。如果事件有关联的 handle(也就是注册的 callback),就执行 handle。一个事件并不一定有 callback。
详细步骤如下:
1、所有同步任务都在主线程上执行,形成一个执行栈。
2、主线程之外,还存在一个"任务队列"。只要异步操作执行完成,就在任务队列之中放置一个事件。
3、一旦执行栈中的所有同步任务执行完毕,系统就会按次序读取任务队列中的事件进入执行栈,开始执行。(也就是先放到队列里的事件先执行。)
4、主线程不断重复上面的第三步。
循环
从代码执行顺序的角度来看,程序最开始是按代码顺序执行代码的,遇到同步任务,立刻执行;遇到异步任务,则只是调用异步函数发起异步请求。此时,异步任务开始执行异步操作,执行完成后到任务队列中排队。程序按照代码顺序执行完毕后,查询任务队列中是否有等待的任务。如果有,则按照次序从任务队列中把任务放到执行栈中执行。执行完毕后,再从任务队列中获取任务,再执行,不断重复。
// demo1
console.log(1);
console.log(2);
setTimeout(function(){
console.log(3);
setTimeout(function(){
console.log(6);
})
},400)
setTimeout(function(){
console.log(4);
setTimeout(function(){
console.log(7);
})
},100)
console.log(5)
// 1 2 5 4 7 3 6
注意:同步代码先执行,执行是在栈中执行的,然后微任务会先执行,再执行宏任务。
问题: 如果在执行宏任务的过程中又发现了回调中有微任务,会把这个微任务提前到所有宏任务之前,等到这个微任务完成后再继续执行宏任务吗?
找个例子去搞搞看
console.log(1);
setTimeout(function(){
console.log(2);
Promise.resolve(1).then(function(){
console.log('promise1')
})
})
setTimeout(function(){
console.log(3)
Promise.resolve(1).then(function(){
console.log('promise2')
})
})
setTimeout(function(){
console.log(4)
Promise.resolve(1).then(function(){
console.log('promise3')
})
})
// 浏览器中 先走微任务
1
2
promise1
3
promise2
4
promise3
一个例子是创建WebQQ的QQ好友列表。列表中通常会有成百上千个好友,如果一个好友用一个节点来表示,在页面中渲染这个列表的时候,可能要一次性往页面中创建成百上千个节点。在短时间内往页面中大量添加DOM节点显然也会让浏览器吃不消,看到的结果往往就是浏览器的卡顿甚至假死。
var ary = [];
for ( var i = 1; i <= 1000; i++ ){
ary.push( i ); // 假设 ary 装载了 1000 个好友的数据
};
var renderFriendList = function( data ){
for ( var i = 0, l = data.length; i < l; i++ ){
var div = document.createElement( 'div' );
div.innerHTML = i;
document.body.appendChild( div );
}
};
renderFriendList( ary );
这个问题的解决方案之一是数组分块技术,下面的timeChunk函数让创建节点的工作分批进行,比如把1秒钟创建1000个节点,改为每隔200毫秒创建8个节点。
function chunk(array,process,context){
setTimeout(function(){
//取出下一个条目并处理
var item = array.shift();
process.call(context,item);
//若还有条目,再设置另一个定时器
if(array.length > 0){
setTimeout(arguments.callee,100);
}
},100);
}
var data = [1,2,3,4,5,6,7,8,9,0];
function printValue(item){
var div = document.getElementById('myDiv');
div.innerHTML += item + '<br>';
}
chunk(data.concat(),printValue);
setTimeout
现在HTML5规定setTimeout的最小间隔时间是4ms,也就是说0实际上也会别默认设置为最小值4ms。
JavaScript是单线程执行的,也就是无法同时执行多段代码,当某一段代码正在执行的时候,所有后续的任务都必须等待,形成一个队列。一旦当前任务执行完毕,再从队列中取出下一个任务。这也常被称为“阻塞式运行”。
假如当前JavaScript进程正在执行一段很耗时的代码,此时发生了一次鼠标点击,那么事件处理程序就被阻塞,用户也无法立即看到反馈,事件处理程序会被放入任务队列,直到前面的代码结束以后才会开始执行。
如果代码中设定了一个setTimeout
,那么浏览器便会在合适的时间,将代码插入任务队列。如果这个时间设为0,就代表立即插入队列,但不是立即执行,仍然要等待前面代码执行完毕。所以setTimeout
并不能保证执行的时间,是否及时执行取决于JavaScript线程是拥挤还是空闲。
setTimeout的回调函数的延时是相对于执行setTimeout这段代码这一刻。
下面这段代码可以证明这个观点
// 延时
function sleep(n) {
var start = new Date().getTime();
while(true) {
if (new Date().getTime() - start >= n) {
break;
}
}
}
setTimeout(() => {
console.log(100);
}, 500);
setTimeout(() => {
console.log(200);
}, 1000);
sleep(1000);
大家可以试一试,刷新后,控制台会同时打印出100 200
。如果将500和1000延时分别修改为1000和2000的话,控制台就会先打印100
,再打印200
。想必这足以证明setTimeout中回调函数的延时是相对于setTimeout代码执行那一刻的。