导读: 对于接触JavaScript这门编程语言没有多久的本菜鸡而言,在相当长的一段时间内,我都完全无法理解这门语言中的异步编程,不明白什么叫异步编程以及为什么需要异步编程。为什么顺序执行程序就不行了呢?非要使用异步回调的方式来去做?经过一段时间的学习和探究,我算是初步了解了其中的道理和内涵。如果你像我一样也是一个JavaScript的小白,希望你看完我写的这篇文章之后,也可以解答你内心的很多困惑,并对JavaScript这门编程语言可以有一个更为深入的了解。

从单线程语言讲起

很长一段时间,我对JavaScript语言的困惑来自于我不清楚什么是单线程编程语言,而这个特性对于JavaScript走向异步编程的方式至关重要。在我们开始去讲单线程编程语言之前,有必须先去了解什么是进程,什么是线程。对这两个概念如果用纯文字讲解略显苍白,这里推荐一个B站Up主对线程和进程讲解的视频。我就是看完这个视频之后才明白进程和线程之间的关系的,相信你看完之后也可以理解。

有了之前的概念基础,下面就来谈谈什么是单线程编程语言。单线程顾名思义就是一个进程里面只会有一个线程。这一个线程有的时候也会被称之为主线程。如果一个进程里面只能有一个线程的话,那它就只能串行的执行程序,就是只能执行完A任务,然后再去执行B任务。反之,如果一个进程里面有多个线程的话,那么它看起来好像是可以并行的执行多个任务。对于单核CPU来说,系统会在多个线程之间快速不停地切换,就可以给你一种错觉好像是多个线程在并行的执行,但这只是一种错觉而已。真正的并行应该是多核才能实现。关于串行、并行和并发这几个概念,引用知乎某大佬一个形象的回答:

你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

并发的关键是你有处理多个任务的能力,不一定要同时。并行的关键是你有同时处理多个任务的能力。所以我认为它们最关键的点就是:是否是『同时』。

多线程就类似于并发这样的一个方式。只有多核才能做到真正的并行。

那么为什么JavaScript会采用这种单线程编程语言的方式呢?可以设想一下,如果JavaScript是一门支持多线程编程语言(比如Java),而多线程实际上是CPU在多个线程之间来回快速切换而已,当JavaScript运行在浏览器中,这时一个线程说要删除一个图片,另外一个线程说要添加一个图片,如果有上百个线程都在争抢CPU的运行时间的话,你的浏览器页面就会有很大的不确定性了,我们并不希望自己的页面加载、更新有很大不确定性。而如果一个进程只有一个线程的话,就不会有上述问题,任务一个接一个顺序执行就可以了。

单线程语言的问题

单线程的确可以不用考虑烦人的哪个任务先执行,哪个任务后执行的问题了,也不需要考虑多线程之间不同线程数据同步的问题。但是线程有他自身的问题。因为单线程是按顺序执行,如果一个任务执行的很慢的话,后面的任务全部都会被阻塞,执行不了。

想象你去超市买东西,如果前面有一个人结账很慢,那你也只能干等着,别的什么事也做不了。对于任务也是同样的道理,对于浏览器而言。我们通常需要各种各样的网络请求,去获取图片,视频等网络资源。如果一个网络请求很慢,比如某一个图片加载很慢,那后面的所有的数据都只能等着前面图片加载完之后才能再去访问网络加载。但我们实际用浏览器访问网页体验好像并不是这样。比如有的时候可能某一个图片加载很长时间也没有加载出来,但这并没有影响后面的图片或者视频等资源的加载。我们是如何做到这一点的呢?答案是使用异步编程(终于讲到异步编程了^ – ^)

想不清楚是因为忘了考虑宿主环境!

我们首先引用一下lynnelv关于同步和异步的定义。


老实说,看完之后我其实是挺疑惑的!JavaScript是一个单线程语言,你比如说你有一个调用栈,就像在超市排队结账一样,按顺序执行程序,这很好理解。但怎么又出现了一个任务队列!单线程还能一边搞调用栈还能一边去做任务队列吗?!,举一个例子:

function updateAsync() {var i = 0;function updateLater() {document.getElementById('output').innerHTML = (i++);if (i < 1000) {setTimeout(updateLater, 0);}}updateLater();
}

比如上面代码展示的setTimeout是一个异步执行程序(书上就是这么说的),但是!单线程怎么可能会做到异步呢?假设你去排队结账,而且因为单线程,所以只能有一个结账通道,那么前面人结账再久,你也只能等着。你不能换一个通道去结账。但这个任务队列就是告诉你,我们还有一个结账通道。比如这个setTimeout是异步函数,他的延时计时,不会影响你的主线程执行。可是如果你只有一个线程,要么去执行程序,要么去计时,怎么可能一边计时,一边还执行程序呢?

后来我才明白,原来是这么回事!除了JavaScript本身之外,还有一个宿主环境。

JavaScript本身是不能自己单独执行的,要么在浏览器中,要么是在Node.js中,而上述两个就是宿主环境。JavaScript本身是单线程编程语言这没错,但JavaScript的宿主环境给他提供了额外的并发功能。(也就是上面说的消息队列)

上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

上图和对应的文字内容引用自JavaScript 运行机制详解:再谈Event Loop,图中的WebAPIs就是JavaScript宿主环境所提供的线程。setTimeout会在该线程中执行,并不会影响JavaScript自身的主线程。setTimeout的延时时间结束之后,它就会进入到图中的回调消息队列中,当主线程空闲时,消息队列中的内容就会进入到主线程中去执行。

有了异步编程这样一个东西之后,JavaScript即使是单线程编程,也可以有并发的特性了。将网络请求和I/O操作等耗时的任务都交给异步编程来实现,主线程把控整体流程,这样主线程就不会被耗时任务拖累而发生阻塞。

Ajax

我们之前已经花了很大的篇幅来介绍异步编程和背后的思想。在这一部分我们谈谈对于浏览器而言,最重要的异步编程应用AJAX。

Ajax 即“Asynchronous Javascript And XML”(异步 JavaScript 和 XML),是指一种创建交互式、快速动态网页应用的网页开发技术,无需重新加载整个网页的情况下,能够更新部分网页的技术。

通过在后台与服务器进行少量数据交换,Ajax 可以使网页实现异步更新。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。

因为异步操作往往需要用到callback,如果不太清楚callback的话,可以看一下这篇文章,里面的关于callback的比喻很形象。因为异步操作不是同步任务,把异步请求或者操作发送出去之后就继续执行同步任务了,所以很适合用这种执行完之后“打电话”回调的方式执行任务。

知道callback的原理之后我们继续回到AJAX。下面这段代码是模拟AJAX请求的代码:

function ajax(url, callback) {// 1、创建XMLHttpRequest对象var xmlhttpif (window.XMLHttpRequest) {xmlhttp = new XMLHttpRequest()} else { // 兼容早期浏览器xmlhttp = new ActiveXObject('Microsoft.XMLHTTP')}// 2、发送请求xmlhttp.open('GET', url, true)xmlhttp.send()// 3、服务端响应xmlhttp.onreadystatechange = function () {if (xmlhttp.readyState === 4 && xmlhttp.status === 200) {var obj = JSON.parse(xmlhttp.responseText)// console.log(obj)callback(obj)}}
}

当服务器有响应之后,把响应数据,通过回调函数返回回来。注意之前文章中说的回调函数特点,是程序执行完把响应回调回来。在服务器响应这种异步的情况下,是很适合使用回调函数的。我们执行上述ajax的代码可以如下:

var url = 'https://getman.cn/mock/route/to/demo'
ajax(url, res => {console.log(res)
})

这个URL路径是一个在线测试网站,你可以点击这里查看该网站详情。如上所示,通常情况下我们定义回调函数的时候,是写成箭头函数的形式,在该函数里面具体定义要执行什么样的回调动作。为了便于理解,我把箭头函数改成普通函数的形式,代码如下:

ajax(url, function(res) {console.log(res)
})

最后再强调一下,我们是把一个函数作为参数传递进去了。这个函数就是回调函数。

理解了AJAX和回调函数之后,我们再来看下面一种情况。在实际编程中,我们经常会遇到执行某些请求,然后有了反馈再出发a动作,a动作执行完,有了反馈再触发b动作…。举一个例子,比如客户端请求一段视频内容,这时候服务器不会将整段视频全部传过去,而是切分成很多小段,一段一段传给客户端。客户端得到一段视频之后确认无误,会再次发送请求,服务端就会再传过来下一段内容。而这些操作都是要异步完成,如果我们写成回调函数,大概可能会长这样:

ajax(url, res => {dosomethingajax(url, res => {dosomethingajax(url, res => {dosomething......})})
})

很可能会嵌套非常多的层数,从可读性来讲,非常的差,这种代码结构称之为回调地狱。对于这种回调地狱的写法问题,我们可以通过promise来解决。

Promise

ES6中新增一个引用类型称之为Promise,可以通过new操作符来实例化

我们来举一个简单例子:

let p = new Promise(() => {setTimeout(() => {console.log('wait one second')}, 1000)
})

但需要特别注意的,特别是对于初学者而言,我上面虽然写的setTimeout是异步执行,如果去执行代码的话,也的确会停留1s之后再去输出。但这并不表示new Promise里面的所有代码都会异步执行。我们下面举一个例子:

let p = new Promise(() => {console.log(1)
})
console.log(2)

如果你打印输出的话,会发现,先输出的1,后输出的2。显然,执行console.log(2)是同步任务,如果Promise是异步的话,应该先打印2后打印1。因此,需要特别注意的是,Promise里面的代码是同步任务,是立刻执行的。可能这时候你有点糊涂了,我了解同步代码同步执行,异步代码异步执行(比如setTimeout)。那Promise有什么用呢?又怎么实现异步操作?别急,关于这一点,我们后面就会谈到。

对于Promise而言,它最重要的特点在于其具有状态管理的功能。它一共有三种状态:

  • pending
  • resolved
  • rejected

Promise的一个特点就是它只能从pending状态到resolved状态,或者从pending状态到rejected状态。不能反过来。或者它也可能一直处于pending状态,当我们new一个Promise的时候,它就是pending状态。

那这个状态有什么用?举一个简单例子:

let p = new Promise((resolve, reject) => {console.log('进入Promise')resolve('成功')// 因为promise状态是不可逆的,所以reject实际是执行不了的reject('失败')
}).then(res => {console.log(res)
}, err => {console.log(err)
})

你可以执行一下上述代码,看看会输出什么。

我们在new Promise的时候传入两个参数,分别是resolve和reject(需要注意的是这两个参数都是函数)。调用resolve()会把状态切换成resolved,调用reject()会把状态切换成rejected(同时会抛出一个错误)。就像我们前面说的,状态是不可逆的。所以上述代码执行完resolve()之后,reject()是不会被执行的。

在上面的代码中我们还看到.then方法,Promise.prototype.then()是为Promise实例添加处理程序的主要方法。执行上述代码我们也可以看到,resolve()或者reject()所传递的参数会进入到then中进行对应的处理。

通过上面的例子,我们就可以大致勾勒出Promise的应用场景。比如客户端通过Promise发送一个异步请求给服务器。服务器如果响应成功,就在resolve所对应的then中去执行对应代码,如果响应失败,就进入到reject,在reject对应的then中去执行对应代码。

另外,关于请求失败的代码,除了用then的第二个参数捕获之外,还可以写成catch的形式:

let p = new Promise((resolve, reject) => {console.log('进入Promise')resolve('成功')reject('失败')
}).then(res => {console.log(res)
}).catch(err => {console.log(err)
})

以上,关于Promise最为基础的部分介绍的已经足够多了,下面我们尝试用Promise来调用一下AJAX请求。

let p = new Promise((resolve, reject) => {console.log('客户端向服务端发送请求')ajax(url, res => {console.log('第一次获得的响应结果是', res)resolve('成功')})
}).then(resolveRes => {console.log('请求结果:', resolveRes)return new Promise((resolve, reject) => {ajax(url, res => {console.log('第二次获得的响应结果是', res)resolve('成功')})    })
}).then(resolveRes => {console.log('请求结果:', resolveRes)return new Promise((resolve, reject) => {ajax(url, res => {console.log('第三次获得的响应结果是', res)resolve('成功')})    })
})

上述代码模拟的是比如客户端多次向服务器发送视频请求的功能代码。看起来一定程度上减轻了回调地狱这种糟糕写法,将嵌套结构变成了链式结构。但看起来好像非常糟糕。一方面是大量重复代码,非常冗余,另一方面,从理解层面也没有改善多少。关于第二点,后面我们会介绍一种新的语法结构:asyncawait。不过现在我们还是继续回到Promise中。

上面的代码,需要特别强调的一点是,每次我们new一个Promise,都会加一个return,为什么要写return呢?

比如在第二次请求的那个对应的Promise中不写return的话,意味着就不会有返回值。后面的then执行,相当于对战原有的(就是最开始的那个)Promise对象的then继续再执行then(返回的会是一个空的promise)。只有return 之后,才意味着对新new出来的Promise再执行then。因此在书写代码的时候一定要记得加return,否则如果发生多层Promise嵌套的时候很有可能出现逻辑错误。

下面我们把上面代码中重复部分进行抽离,精简一下代码可以写成这样:

function getPromise(url) {return new Promise((resolve, reject) => {ajax(url, res => {console.log('获得的响应结果是', res)})resolve('成功')})
}getPromise(url).then(resolveRes => {console.log('请求结果:', resolveRes)return getPromise(url)
}).then(resolveRes => {console.log('请求结果:', resolveRes)return getPromise(url)
}).then(resolveRes => {console.log('请求结果:', resolveRes)return getPromise(url)
})

经过抽离之后,代码看起来简洁很多。同样的,我们还是要再强调一下这个return问题。明明定义getPromise函数的时候已经写了return了,为什么后面再调用的时候还要再加return?因为函数定义部分可以看到,调用函数之后返回的相当于是一个new Promise,你可以对照之前写的比较复杂的代码,这个new Promise还需要再返回一下才行。

需要注意的一点是,在上面的例子中,then是平级的,也就是说,即使前一层请求失败,也不会影响后一层的then执行。如果你想对不同的请求失败做统一的处理,可以这样写代码:

getPromise(url).then(resolveRes => {console.log('请求结果:', resolveRes)return getPromise(url)
}).then(resolveRes => {console.log('请求结果:', resolveRes)return getPromise(url)
}).then(resolveRes => {console.log('请求结果:', resolveRes)return getPromise(url)
}).catch(err => {console.log(err)
})

这样的话,只要有一个请求失败,就会直接触发catch,别的then都不会被执行了。

此外,Promise还有一些静态方法,我这里就不演示了。具体可以看MDN的Promise文档。

async / await

除了使用Promise之外,我们还可以使用async/await来实现异步编程。

async关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上。此外需要注意的是,如同我们在介绍Promise中强调的一样,虽然async关键字可以让函数具有异步特征,但里面的同步代码还是会同步执行。只有遇到异步执行的函数时才会异步执行。

async的函数在执行后都会自动返回一个Promise对象,所以,我们也可以在async函数后面接then来做处理。不过我们还有一种更好的方式是使用await关键字。await可以获取后面Promise对象成功状态传递出来的参数。

我们下面举一个具体例子,还是调用之前在讲ajax部分定义的函数。

function getPromise(url) {return new Promise((resolve, reject) => {ajax(url, res => {console.log('获得的响应结果是', res)})resolve('成功')})
}// 改造后
function getPromise(url) {return new Promise((resolve, reject) => {ajax(url, res => {resolve(res)})})
}

我们把之前定义的getPromise函数略微改造一下。然后还是对之前的客户端向服务端多次请求数据的功能使用async/await用代码改造一下:

async function getData(){const res1 = await getPromise(url)console.log(res1)const res2 = await getPromise(url)console.log(res2)const res3 = await getPromise(url)console.log(res3)
}
getData()

通过await等待,我们会在每次得到数据之后再发送下一次请求。这样异步编程看起来就像同步代码了,使代码变得更容易理解。

因为async返回的是Promise,所以,对于失败的请求,我们还是可以通过then的第二个参数或者catch来捕获:

getData().catch(err => {console.log(err)
})

参考资料

[1] JavaScript异步编程
[2] 说一说javascript的异步编程
[3] 线程进程 |两个简单例子告诉你什么是进程和线程 | 进程线程原来如此简单
[4] 并发与并行的区别是什么?
[5] 深入理解js事件循环机制(浏览器篇)
[6] JavaScript 运行机制详解:再谈Event Loop
[7] 《JavaScript高级程序设计(第4版)》
[8] 《深入浅出Nodejs》
[9] ajax
[10] Callback(回调)是什么?

写给初学者的JavaScript异步编程和背后思想相关推荐

  1. 网页javascript加载不出_写给初学者的JavaScript异步编程和背后思想

    导读:对于接触JavaScript这门编程语言没有多久的本菜鸡而言,在相当长的一段时间内,我都完全无法理解这门语言中的异步编程,不明白什么叫异步编程以及为什么需要异步编程.为什么顺序执行程序就不行了呢 ...

  2. 【学习笔记】Part1·JavaScript·深度剖析-函数式编程与 JS 异步编程、手写 Promise(二、JavaScript 异步编程)

    [学习笔记]Part1·JavaScript·深度剖析-函数式编程与 JS 异步编程.手写 Promise(课前准备) [学习笔记]Part1·JavaScript·深度剖析-函数式编程与 JS 异步 ...

  3. Javascript异步编程之一异步原理

    本系列的例子主要针对node.js环境,但浏览器端的原理应该也是类似的. 本人也是Javascript新手,把自己这段时间学习积累的要点总结下来,希望可以对同样在学习Javascript/node.j ...

  4. JavaScript异步编程原理

    众所周知,JavaScript 的执行环境是单线程的,所谓的单线程就是一次只能完成一个任务,其任务的调度方式就是排队,这就和火车站洗手间门口的等待一样,前面的那个人没有搞定,你就只能站在后面排队等着. ...

  5. JavaScript异步编程

    JavaScript异步编程 一.概述 1.单线程模型 2.同步任务和异步任务 3.任务队列和事件循环 4.异步操作的模式 回调函数 事件监听 发布/订阅 5.异步操作的流程控制 串行执行 并行执行 ...

  6. JavaScript异步编程的原理

    1 想说的话 JavaScript异步编程是前端面试中一个重要内容.为了加深我对此部分内容的印象,故写此博客,主要介绍JS异步编程的运行原理. pre 栈队列 2 正文 本文主要是阐述下面这张图的内容 ...

  7. [书籍精读]《JavaScript异步编程》精读笔记分享

    写在前面 书籍介绍:本书讲述基本的异步处理技巧,包括PubSub.事件模式.Promises等,通过这些技巧,可以更好的应对大型Web应用程序的复杂性,交互快速响应的代码.理解了JavaScript的 ...

  8. JavaScript 异步编程--Generator函数、async、await

    JavaScript 异步编程–Generator函数 Generator(生成器)是ES6标准引入的新的数据类型,其最大的特点就是可以交出函数的执行的控制权,即:通过yield关键字标明需要暂停的语 ...

  9. JavaScript异步编程【下】 -- Generator、Async/await

    文章内容输出来源:拉勾教育 大前端高薪训练营 前言 在JavaScript异步编程[上]和 JavaScript异步编程[中]中,我们已经讲到了处理异步编程的两种方法:回调函数 和 Promise. ...

最新文章

  1. golang python rpc_grpc - 使用 golang 带你从头撸一套 RPC 服务(一)
  2. Windows或者linux深度学习环境搭建
  3. [转]独立窗口打开多个Excel文件
  4. php获取本月工作日,ThinkPHP中获取指定日期后工作日的具体日期方法
  5. python绘图函数返回_python – Matplotlib返回一个绘图对象
  6. chrome 无法抓屏的问题
  7. c++模板类被继承时他的成员不能被子类看到
  8. 自学python-自学Python要学多久可以学会?老男孩Python培训
  9. ad怎么查接线_电视机维修|电视打开后黑屏怎么回事?电视打开后黑屏解决方法【图文详解】...
  10. dump文件_centos7 磁盘与文件系统管理
  11. python爬虫和Java爬虫哪个更好
  12. kepware mysql_Kepware实现向数据库实时写入数据
  13. mysql 口令_怎么样为用户设定口令(MYSQL)_MySQL
  14. 四个步骤告诉你如何进行渠道效果监测
  15. 亲密关系科学(03)夫妻相处的智慧
  16. 【TCP-IP详解卷1-协议】第一章 概述
  17. un7.2:IDEA中实现登录功能
  18. python 将变量保存到文件里
  19. html2canvas html截图
  20. 算法手撕代码26~35

热门文章

  1. C# 生成随机字符串
  2. 《编写高质量代码:改善c程序代码的125个建议》——建议4-1:整数转换为新类型时必须做范围检查...
  3. 折衷的方式实现php与ruby共享session实现单点登录
  4. Python协程:从yield/send到async/await
  5. MySQL · 引擎特性 · InnoDB COUNT(*) 优化(?)
  6. 11g RAC不能启动ohasd进程
  7. Json解析异常处理方式(JSONException: Value of type java.lang.String cannot be converted to JSONObject)...
  8. 分享自制的C#和VB Code互转工具
  9. Cache一致性与2种基本写策略(1)
  10. Oracle内部错误:ORA-00600:[4097]一例