重点:异步编程

178 阅读8分钟

一、异步操作描述

1. 单线程模型

JavaScript 是单线程模型,只在一个线程上运行(主线程运行,其他线程在后台配合)。

  1. js 为什么采用单线程?

    js 一开始就是单线程,因为多线程会共享资源、也可能修改彼此的运行结果。如果一个线程操作dom节点,另一个线程又删除了这个节点,浏览器需要处理以哪个线程为准等问题。因此为了避免复杂性,JS 诞生起就是单线程。

  2. 单线程的坏处? 事件循环?

    如果一个任务耗时很长,那么后面的任务都必须排队等着,造成网页假死(无响应)。例如因为 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)

  1. 不需要设置时间间隔,raf 采用的系统时间间隔(按帧)。
  2. raf 使用回调函数作参数,这个回调函数会在浏览器重绘之前调用。

四、Promise 对象、Genertor、async/await

Promise对象详解

  • 设计思想:所有的异步操作都返回一个 Promise 实例,然后用Promise 实例的 then 方法指定下一步的回调函数。

  • 优点:避免回调地狱。

  • 缺点:无法取消Promise;如果没有catch等捕获错误的回调,那么Promise抛出的错误不会反映到外部(不能用 try catch)。

Generator 详解

  • 优点:可以用 yield 暂停执行,

async/await 详解(Generator语法糖)

  • 对比 Promise:处理then的链式调用时,将异步代码以同步方式写出来,流程更为清晰。

  • 对比 Generator:(1)内置执行器,直接调用即可;(2)await 后面可以跟 Promise对象和原始类型的值,yield 后面只能跟Promise对象;(3)语义更清楚。

Async/Await 让 try/catch 可以同时处理同步和异步错误。(generator也可)

五、事件循环:是独立的

  1. 首先script脚本是一个异步任务,先执行 script 脚本。这个script脚本会包含同步和异步任务。同步任务会进入主线程执行,异步任务(分为宏任务、微任务)会添加到任务队列中,任务队列分为宏任务队列和微任务队列

  2. 主线程的同步任务执行完成后,会去任务队列读取异步任务,在主线程上执行完所有的微任务。(微任务执行过程中产生的宏任务和微任务都会添加到任务队列中,其中新生成的微任务也会在这一轮被执行情况

  3. 微任务执行完毕后,再取一个宏任务到主线程执行。当这个宏任务执行完后然后继续执行微任务队列,以上过程不断重复,即为事件循环。(宏任务执行过程中产生的宏任务和微任务都会添加到任务队列中)

  • 宏任务:整个script代码(也就是第一次执行的同步代码)、setTimeout / setInterval/setImmediate(Node)I/O
  • 微任务Promise.then(回调)(Promise会直接执行)、process.nextTickasync/awaitawait后面的语句,类似于 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 单线程的本质。

参考文章

事件循环