一、异步操作描述
1. 单线程模型
JavaScript 是单线程模型,只在一个线程上运行(主线程运行,其他线程在后台配合)。
-
js 为什么采用单线程?
js 一开始就是单线程,因为多线程会共享资源、也可能修改彼此的运行结果。如果一个线程操作dom节点,另一个线程又删除了这个节点,浏览器需要处理以哪个线程为准等问题。因此为了避免复杂性,JS 诞生起就是单线程。
-
单线程的坏处? 事件循环?
如果一个任务耗时很长,那么后面的任务都必须排队等着,造成网页假死(无响应)。例如因为 IO操作读写外部数据 很慢,等待请求返回结果时,如果对方服务器响应延迟,就会导致脚本的长时间停滞。
如果 CPU 不管 I/O 操作,挂起等待中的任务,先运行排在后面的任务,等待 I/O 操作返回了结果,再把挂起的任务继续执行下去,这种机制就是 事件循环机制(Event Loop)。
2. 同步异步
- 同步任务:在主线程上排队执行的任务,只有前一个执行完毕,才能执行下一个。
- 异步任务:被引擎挂起,不进入主线程、而进入任务队列的任务,只有引擎认为某个异步任务可以执行了,才会进入主线程执行。
二、异步操作的模式
1. 回调函数:f1(callback)
function f1(callback) {
//先执行完 f1 内部的代码
callback(); // 最后执行 f2
}
function f2() {} // 回调函数
f1(f2); // 如果f1是异步,也可以保证先执行f1再执行f2
优点:简单、容易理解和实现。
缺点:各部分之间强耦合,不利于代码维护,当多个回调函数嵌套时还会出现回调地狱问题(程序结构混乱、流程难追踪)。
2. 事件监听
异步任务的执行和代码顺序无关,而是取决于某个事件是否发生。事件发生后,浏览器监听到了这个事件,就会执行对应的监听函数。
// 为了方便理解,以下写法不具有实操性
function f1() {
setTimeout(function() {
//...
f1.trigger('click'); // 执行完成后,立即触发 click 事件
})
}
f1.on('click', f2); // 当 f1 发生click 事件,就执行 f2.
优点:可以绑定多个事件,每个事件可以指定多个回调函数,而且可以去耦合,有利于模块化。
缺点:整个程序变成事件驱动型,运行流程不清晰。
3. 发布/订阅
发布/订阅模式:事件可以理解成信号,如果存在一个 "信号中心",某个任务执行完成后就向中心 发布(publish) 一个信号,其他任务可以向中心 订阅(subscribe) 该信号,从而知道什么时候自己可以执行。
三、定时器
参数函数或代码就是回调函数。
1. setTimeout
某个函数或某段代码在多少毫秒之后执行。它会返回一个整数表示定时器的编号 ,之后可用于取消这个定时器。
setTimeout 还可以传入更多参数,它们会依次传入回调函数中。
let timerId = setTimeout(callback, delay);
setTimeout('console.log(2)', 1000); // 第1个参数是代码,要用 ""
setTimeout(sum, 1000); // 第1个参数是函数,直接传入函数名。
setTimeout(function(a, b) {
console.log(a + b);
}, 1000, 1, 2); // 3
注意:如果回调函数是对象的方法,那么 setTimeout 使得方法内部的 this 指向「全局环境」。 但可以将 obj.fn() 放到 setTimeout 的函数参数中,这样就能正确显示。
var x = 1;
let obj = {
x: 2,
fn: function() {
console.log('123 ' + this.x);
}
};
setTimeout(obj.fn, 1000); // 1,如果var换成let,输出undefiend,因为let定义的全局变量不属于全局对象。
setTimeout(obj.fn(), 1000); // 2,直接调用
setTimeout(function f() { obj.fn(); }, 1000); // 2
2. setInterval
某个函数或某段代码,每隔多少毫秒就会执行一次,无限次的定时执行。其他的用法和 setTimeout 完全一致。
注意:setInterval 指的是 两次“开始执行”之间的间隔,不会考虑任务的消耗,可能会出现紧接着输出多次结果的情况。为了保证两次执行之间有固定的间隔,可以在每次执行结束后,使用 setTimeout 指定下一次执行的具体时间。
var timer = setTimeout(function f() {
console.log(1);
timer = setTimeout(f, 1000);
}, 1000);
// 无限次输出 1
3. clearTimeout、clearInterval
将定时器编号传入clearTimeout、clearInterval函数,就可以取消对应的定时器。
var id1 = setTimeout(f, 1000);
var id2 = setInterval(f, 1000);
clearTimeout(id1);
clearInterval(id2);
4. 定时器的延时误差
由于事件循环机制,定时器的延迟时间实际上指的是 delay 时间后将函数添加到宏任务队列中去,等到主线程执行完后再去检查宏任务队列,按照延迟时间的长短取出执行,因此不能保证指定的任务一定会按照预期时间执行。
所以 setTimeout(f, 0) 也不会立即执行,它只是提前了在任务队列中的顺序。
5. 和 requestAnimationFrame 的区别
requestAnimationFrame 是浏览器用于定时循环操作的一个接口,按帧对网页进行重绘。(html5新增的api,类似setTimeout)
- 不需要设置时间间隔,raf 采用的系统时间间隔(按帧)。
- raf 使用回调函数作参数,这个回调函数会在浏览器重绘之前调用。
四、Promise 对象、Genertor、async/await
-
设计思想:所有的异步操作都返回一个 Promise 实例,然后用Promise 实例的 then 方法指定下一步的回调函数。
-
优点:避免回调地狱。
-
缺点:无法取消Promise;如果没有catch等捕获错误的回调,那么Promise抛出的错误不会反映到外部(不能用 try catch)。
- 优点:可以用 yield 暂停执行,
-
对比
Promise:处理then的链式调用时,将异步代码以同步方式写出来,流程更为清晰。 -
对比
Generator:(1)内置执行器,直接调用即可;(2)await 后面可以跟 Promise对象和原始类型的值,yield 后面只能跟Promise对象;(3)语义更清楚。
Async/Await 让 try/catch 可以同时处理同步和异步错误。(generator也可)
五、事件循环:是独立的
-
首先
script脚本是一个异步任务,先执行 script 脚本。这个script脚本会包含同步和异步任务。同步任务会进入主线程执行,异步任务(分为宏任务、微任务)会添加到任务队列中,任务队列分为宏任务队列和微任务队列。 -
主线程的同步任务执行完成后,会去任务队列读取异步任务,在主线程上执行完所有的微任务。(微任务执行过程中产生的宏任务和微任务都会添加到任务队列中,其中新生成的微任务也会在这一轮被执行情况)
-
微任务执行完毕后,再取一个宏任务到主线程执行。当这个宏任务执行完后然后继续执行微任务队列,以上过程不断重复,即为事件循环。(宏任务执行过程中产生的宏任务和微任务都会添加到任务队列中)
- 宏任务:整个
script代码(也就是第一次执行的同步代码)、setTimeout / setInterval/setImmediate(Node)、I/O。 - 微任务:
Promise.then(回调)(Promise会直接执行)、process.nextTick,async/await(await后面的语句,类似于 then)本质就是Promise、MutationObserver(监听DOM的变化并及时做出响应)。 - 执行顺序:先执行一个宏任务,然后执行所有的微任务,再执行下一个宏任务。
async function fn () {
console.log(1);
}
async function test () {
console.log(2); // 直接执行
await fn(); // 直接执行
// await后面的语句,类似Promise的then,放到微任务中执行。
console.log(3);
}
test();
// 2 1 3
注意: 以上规则均指的是JS线程,而浏览器还有GUI(渲染)引擎,除非 js 脚本设置成 defer 或 async,不然一般情况下两者是互斥的。解析 html 文档时遇到 js 脚本、GUI 引擎会暂停,将执行权交给 JS 引擎。
包含异步 JS 的执行顺序:宏任务(第一次执行脚本中的同步代码) => 微任务 => 页面渲染 => 宏任务...,微任务优先于 DOM 渲染。每轮事件循环之后会执行页面渲染,但不是每次都会执行,要看各方面因素。
六、Web Worker
为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许主线程创建多个Worker线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。
参考文章