前置知识:

为什么JavaScript是单线程的

其实,JavaScript的单线程,与它的用途是有很大关系,我们都知道,JavaScript作为浏览器的脚本语言,主要用来实现与用户的交互,利用JavaScript,我们可以实现对DOM的各种各样的操作,如果JavaScript是多线程的话,一个线程在一个DOM节点中增加内容,另一个线程要删除这个DOM节点,那么这个DOM节点究竟是要增加内容还是删除呢?这会带来很复杂的同步问题,因此,JavaScript是单线程的

单线程模型

所谓单线程,是指在JS引擎中负责解释和执行JavaScript代码的线程只有一个
在同一个时间只能做一件事,单线程意味着,如果在同个时间有多个任务的话,这些任务就需要进行排队,前一个任务执行完,才会执行下一个任务,举例:

  1. // 同步代码
  2. function fun1() {
  3. console.log(1);
  4. }
  5. function fun2() {
  6. console.log(2);
  7. }
  8. fun1();
  9. fun2();
  10. // 输出
  11. 1
  12. 2

因为代码是从上到下依次执行,执行完fun1(),才继续执行fun2(),但是如果fun1()中的代码执行的是读取文件或者ajax操作,文件的读取和数据的获取都需要一定时间,难道我们需要完全等到fun1()执行完才能继续执行fun2()么?为了解决这个问题,后面我们会介绍同步和异步的概念

程序里面所有的任务,可以分成两类:同步任务(synchronous)和异步任务(asynchronous)
即任务的执行模式分成两种:同步(Synchronous)和异步(Asynchronous)

同步 & 异步

同步:

同步任务是指在主线程上排队执行的任务,只有前一个任务执行完毕,才能继续执行下一个任务
当我们打开网站时,网站的渲染过程,比如元素的渲染,其实就是一个同步任务

异步:

异步任务是指不进入主线程,而进入任务队列的任务,只有任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程
常见的异步程序:如 定时器、网络请求

js的同步和异步问题通常是指ajax的回调,如果是同步调用,程序在发出ajax调用后就会暂停,直到远程服务器产生回应后才会继续运行。而如果是异步调用,程序发出ajax调用后不会暂停,而是立即执行后面的代码,服务器返回信息后会自动触发回调函数进行处理。相比较而言,异步调用的性能最佳,程序不会出现卡顿的现象,而同步调用则通常用于需要立即获得结果并实时处理的情况。

异步机制

那么,JavaScript中的异步是怎么实现的呢?那要需要说下回调和事件循环这两个概念

回调:
首先要先说下任务队列,我们在前面也介绍了,异步任务是不会进入主线程,而是会先进入任务队列,任务队列其实是一个先进先出的数据结构,也是一个事件队列,比如说文件读取操作,因为这是一个异步任务,因此该任务会被添加到任务队列中,等到IO完成后,就会在任务队列中添加一个事件,表示异步任务完成啦,可以进入执行栈啦~但是这时候呀,主线程不一定有空,当主线程处理完其它任务有空时,才会读取任务队列,读取里面有哪些事件,排在前面的事件会被优先进行处理,如果该任务指定了回调函数,那么主线程在处理该事件时,就会执行回调函数中的代码,也就是执行异步任务啦

事件循环:
单线程从从任务队列中读取任务是不断循环的,每次栈被清空后,都会在任务队列中读取新的任务,如果没有任务,就会等到,直到有新的任务,这就叫做任务循环,因为每个任务都是由一个事件触发的,因此也叫作事件循环

总的来说,JavaScript的异步机制包括以下几个步骤

  1. 所有同步任务都在主线程上执行,行成一个执行栈
  2. 主线程之外,还存在一个任务队列,只要异步任务有了结果,就会在任务队列中放置一个事件
  3. 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,看看里面还有哪些事件,那些对应的异步任 务,于是结束等待状态,进入执行栈,开始执行
  4. 主线程不断的重复上面的第三步

异步编程/异步操作的方式

背景:异步程序无法即刻获取到返回值,无法使用return,因为return是同步的,那么通过什么手段拿到异步程序的返回值?传入一个函数(回调),当异步任务完成时调用这个函数(即回 调 )

举例:我想喝奶茶,发布一个喝奶茶的任务,但是奶茶不能直接喝到,需要等它做出来后才能喝到,即喝奶茶这个任务是异步任务

  1. //获取奶茶的方法
  2. function getTea(fn){
  3. setTimeout(()=>{
  4. fn('奶茶')
  5. },500)
  6. }
  7. //调用获取奶茶的方法
  8. getTea(function(data){
  9. console.log(data)
  10. })
  1. //获取奶茶的方法
  2. function getTea(fn){
  3. setTimeout(()=>{
  4. fn('奶茶')
  5. },500)
  6. }
  7. //获取火锅的方法
  8. function getHotpot(fn){
  9. setTimeout(()=>{
  10. fn('火锅')
  11. },1000)
  12. }
  13. //调用获取火锅再获取奶茶的方法
  14. //默认按时间来讲应该是先获取到奶茶再获取火锅,但按指定顺序获取的话,需要在回调中嵌套回调
  15. getTea(function(data){
  16. console.log(data)
  17. getHotpot(function(data){ //多重嵌套容易出现回调地狱
  18. console.log(data)
  19. })
  20. })

总结逻辑:为了获取异步任务的数据,需要使用回调,使用回调的时候如果需要按指定顺序返回数据,需要嵌套回调,嵌套回调则容易出现回调地狱

回调

回调函数是异步操作最基本的方法

  1. function 摇骰子(){
  2. setTimeout(()=>{
  3. return parseInt(Math.random()*6) + 1
  4. },1000)
  5. }

注意在这个代码里,
摇骰子()没有写return ,即 return undefined,
箭头函数里有return, 返回真正的结果,所以这是一个异步函数

  1. const n = 摇骰子()
  2. console.log(n)// n是Undefined

异步函数的结果只能在异步函数中获取,无法在同步函数中获取,故n是underfined
那么我们应该怎么去拿到setTimeout的结果呢?
可以用回调

  1. function callback(x) {
  2. console.log(x)
  3. }
  4. 摇骰子(callback){
  5. setTimeout(()=>{
  6. callback(parseInt(Math.random()*6) + 1)
  7. },1000)
  8. }

我们可以把callback函数简化成箭头函数

  1. 摇骰子(x=>{
  2. console.log(x)
  3. }) //最保障的写法:剪头函数,写明参数
  4. // 由于这里函数的形参和实参个数是一样的,所以还可以省略为
  5. 摇骰子(console.log) //log函数没有被我们调用,而是被“别人”调用 → 回调

但是如果参数个数不一样,就不能这么简化,
举例:(著名面试题)

  1. const array = ['1', '2', '3'].map(parseInt)
  2. 得到的结果是 [1, NaN, NaN]

这里错误的原因就是因为 忽略了Map函数和parseInt函数参数不同

  1. const array = ['1', '2', '3'].map(parseInt)
  2. 这种写法等价于
  3. ['1', '2', '3'].map((currentValue, index, array) => {
  4. parseInt(currentValue, index, array)
  5. // parseInt('1', 0) 把'1'换成0进制,那就是1
  6. // parseInt('2', 1) 把'2'换成1进制,1进制里没有2,所以是NaN
  7. // parseInt('3', 2) 把'3'换成2进制,2进制里没有3,所以是NaN
  8. })

最保障的写法还是使用箭头函,标明参数
image.png

总结:
异步任务不能在同步函数中拿到结果,
于是我们传一个回调给异步任务,
异步任务完成时调用回调,
调用时把回调函数的结果作为异步函数的参数

回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度)合(coupling),使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任务只能指定一个回调函数。