ホームページ > 記事 > ウェブフロントエンド > JavaScript 非同期プログラミングの詳細な紹介 (例付き)
この記事では、PHP シナジーの実装について詳しく説明します (コード付き)。必要な方は参考にしていただければ幸いです。
まえがき
この記事を書く本来の目的は、より深く理解したい場合に次のとおりです。 JS と非同期プログラミングは、乗り越えなければならないハードルです。ここには多くの広範な内容が含まれるため、JS を初めて使用する場合は、 当時はまだこの概念を十分に理解できていないかもしれませんが、今でもこのハードルを越えるために、知識を集めて理解するつもりです。私がすでに習得した知識を活用する勇気を、できる限り伝えてください。 JS での非同期プログラミング。私が説明した概念や用語の一部に誤りがある場合、読者は問題を私に指摘するよう求められ、私は直ちに変更を修正します。
同期と非同期
それがブラウザ側であるかサーバー側であるかはわかっています。 (ノード) 側では、JS の実行はシングルスレッドで実行されます。このスレッドでは、例としてブラウザーでの JS 実行スレッドを取り上げます。 エンジンは実行コンテキスト スタックを作成し、後入れ先出しに従って実行コンテキスト スタック内の一連のタスクと同様に、コードが実行コンテキスト (グローバル、関数、評価) として使用されます ( LIFO ) メソッドが順次実行されます。同期の最大の特徴は、この時点でJSなどの後続のタスクの実行をブロックすることです。 このとき、大量の計算が実行されるため、スレッドがブロックされ、ページのレンダリングと読み込みに一貫性がなくなります (ブラウザ側のイベント ループ内)。 実行スタック内の各タスクが実行されると、イベント キュー内のタスクがチェックされ、キュー内のタスクが空になるまで実行されます。イベント キュー内のタスクは、マイクロ キューとマクロ キューに分割されます。マイクロキュー マクロキュー内のタスクは、実行が完了し、ブラウザの GUI スレッドはページ レンダリング (UI レンダリング) を実行します。これが、実行スタックで多数の計算が実行されるとページのレンダリングがブロックされる理由を説明します。
非同期は、同期とは対照的に、非同期操作の完了後に実行されるタスクとして理解でき、通常はコールバック関数またはとして実装されます。 Promise のフォームはイベント キューに入れられ、イベント ループ (イベント ループ) このメカニズムは、非同期操作がポーリングされるたびに完了したかどうかをチェックし、完了した場合は、イベント キュー内の実行ルールに従って、対応するタスクが順番に実行されます。非同期タスクが同期タスクのように完全にブロックされないのは、まさにイベント ループ機構のおかげです。 JS実行スレッド。
非同期操作には通常、ネットワーク リクエスト、ファイル読み取り、データベース処理が含まれます。
非同期タスクには通常、setTimout / setInterval、Promise、requestAnimationFrame (ブラウザーに固有)、setImmediate (Node に固有)、process が含まれます。 .nextTick (Node 専用) など...
注: ブラウザ側とノード側のイベント ループ この記事の焦点はイベント ループではなく、JS であるため、以下の 2 つの図は、さまざまな環境でのイベント ループの動作メカニズムを簡単に示しています。 非同期プログラミングはこれに基づいているため、必要な読者を助けるために、対応する読書リンクを以下に示します。
ブラウザ側
##ノード側
閲覧リンクJavaScriptの詳しい説明ブラウザイベントループの仕組み
const foo = function (x, y, cb) { setTimeout(() => { cb(x + y) }, 2000) } // 使用 thunk 函数,有点函数柯里化的味道,在最后处理 callback。 const thunkify = function (fn) { return function () { let args = Array.from(arguments) return function (cb) { fn.apply(null, [...args, cb]) } } } let fooThunkory = thunkify(foo) let fooThunk1 = fooThunkory(2, 8) let fooThunk2 = fooThunkory(4, 16) fooThunk1((sum) => { console.log(sum) // 10 }) fooThunk2((sum) => { console.log(sum) // 20 })約束ES6 のリリース前、非同期プログラミングの主力であるコールバック関数は、コールバック地獄、コード実行の追跡の難しさなど、さまざまな理由で批判されてきました。コードが非常に複雑になり、保守や更新ができなくなったことが、以前のジレンマを大きく変えました。早速、コードを実際に読んでその魅力を事前に感じてから、Promise で重要だと思われる点をいくつかまとめていきます。
const foo = function () { let args = [...arguments] let cb = args.pop() setTimeout(() => { cb(...args) }, 2000) } const promisify = function (fn) { return function () { let args = [...arguments] return function (cb) { return new Promise((resolve, reject) => { fn.apply(null, [...args, resolve, reject, cb]) }) } } } const callback = function (x, y, isAdd, resolve, reject) { if (isAdd) { resolve(x + y) } else { reject('Add is not allowed.') } } let promisory = promisify(foo) let p1 = promisory(4, 16, false) let p2 = promisory(2, 8, true) p1(callback) .then((sum) => { console.log(sum) }, (err) => { console.error(err) // Add is not allowed. }) .finally(() => { console.log('Triggered once the promise is settled.') }) p2(callback) .then((sum) => { console.log(sum) // 10 return 'evil ' }) .then((unknown) => { throw new Error(unknown) }) .catch((err) => { console.error(err) // Error: evil })
ポイント 1: 制御の逆転防止 (懸念の分離)
什么是反控制反转呢?要理解它我们应该先弄清楚控制反转的含义,来看一段伪代码。
const request = require('request') // 某购物系统获取用户必要信息后执行收费操作 const purchase = function (url) { request(url, (err, response, data) => { if (err) return console.error(err) if (response.statusCode === 200) { chargeUser(data) } }) } purchase('https://cosmos-alien.com/api/getUserInfo')
显然在这里 request 模块属于第三方库是不能够完全信任的,假如某一天该模块出了 bug , 原本只会向目标 url 发送一次请求却变成了多次,相应的我们的 chargeUser 函数也就是收费操作就会被执行多次,最终导致用户被多次收费,这样的结果完全就是噩梦!然而这就是控制反转,即把自己的代码交给第三方掌控,因此是不可完全信任的。
那么反控制反转现在我们可以猜测它的含义应该就是将控制权交还到我们自己写的代码中,而要实现这点通常我们会引入一个第三方协商机制,在 Promise 之前我们会通过事件监听的形式来解决这类问题。现在我们将代码更改如下:
const request = require('request') const events = require('events') const listener = new events.EventEmitter() listener.on('charge', (data) => { chargeUser(data) }) const purchase = function (url) { request(url, (err, response, data) => { if (err) return console.error(err) if (response.statusCode === 200) { listener.emit('charge', data) } }) } purchase('https://cosmos-alien.com/api/getUserInfo')
更改代码之后我们会发现控制反转的恢复其实是更好的实现了关注点分离,我们不用去关心 purchase 函数内部具体发生了什么,只需要知道它在什么时候完成,之后我们的关注点就从 purchase 函数转移到了 listener 对象上。我们可以把 listener 对象提供给代码中多个独立的部分,在 purchase 函数完成后,它们同样也能收到通知并进行下一步的操作。以下是维基百科上关于关注点分离的一部分介绍。
关注点分离的价值在于简化计算机程序的开发和维护。当关注点分开时,各部分可以重复使用,以及独立开发和更新。具有特殊价值的是能够稍后改进或修改一段代码,而无需知道其他部分的细节必须对这些部分进行相应的更改。一一 维基百科
显然在 Promise 中 new Promise() 返回的对象就是关注点分离中分离出来的那个关注对象。
要点二:不可变性 ( 值得信任 )
细心的读者可能会发现,要点一中基于事件监听的反控制反转仍然没有解决最重要的信任问题,收费操作仍旧可以因为第三方 API 的多次调用而被触发且执行多次。幸运的是现在我们拥有 Promise 这样强大的机制,才得以让我们从信任危机中解脱出来。所谓不可变性就是:
Promise 只能被决议一次,如果代码中试图多次调用 resolve(..) 或者 reject(..) ,Promise 只会接受第一次决议,决议后就是外部不可变的值,因此任何通过 then(..) 注册的回调只会被调用一次。
现在要点一中的示例代码就可以最终更改为:
const request = require('request') const purchase = function (url) { return new Promise((resolve, reject) => { request(url, (err, response, data) => { if (err) reject(err) if (response.statusCode === 200) { resolve(data) } }) }) } purchase('https://cosmos-alien.com/api/getUserInfo') .then((data) => { chargeUser(data) }) .catch((err) => { console.error(err) })
要点三:错误处理及一些细节
还记得最开始讲 Promise 时的那一段代码吗?我们把打印结果的那部分代码再次拿出来看看。
p1(callback) .then((sum) => { console.log(sum) }, (err) => { console.error(err) // Add is not allowed. }) .finally(() => { console.log('Triggered once the promise is settled.') }) p2(callback) .then((sum) => { console.log(sum) // 10 return 'evil ' }) .then((unknown) => { throw new Error(unknown) }) .catch((err) => { console.error(err) // Error: evil })
首先我们说下 then(..) ,它的第一个参数作为函数接收 promise 对象中 resolve(..) 的值,第二个参数则作为错误处理函数处理在 Promise 中可能发生的错误。
而在 Promise 中有两种错误可能会出现,一种是显式 reject(..) 抛出的错误,另一种则是代码自身有错误会被 Promise 捕捉,通过 then(..) 中的错误处理函数我们可以接收到它前面 promise 对象中出现的错误,而如果在 then(..) 接收 resolve(..) 值的函数中也出现错误,该错误则会被下一个 then(..) 的错误处理函数所接收 ( 有两个前提,第一是要写出这个 then(..) 否则该错误最终会在全局抛出,第二个则是要确保前一个 then(..) 在它的 Promise 决议后调用的是第一个参数即接收 resolve(..) 值的函数而不是错误处理函数 )。
一些值得注意的细节:
catch(..) 相当于 then(..) 中的错误处理函数 ,只是省略了第一个参数。
finally(..) 在 Promise 一旦决议后 ( 无论是 resolve 还是 reject ) 都会被执行。
then(..) 、catch(..) 、finally(..) 都是异步调用,作为 Event Loop 里事件队列中的微队列任务执行。
generator
generator 也叫做生成器,它是 ES6 中引入的一种新的函数类型,在函数内部它可以多次启动和暂停,从而形成阻塞同步的代码。下面我将先讲述它的基本用法然后是它在异步编程中的使用最后会简单探究一下它的工作原理。
生成器基本用法
let a = 2 const foo = function *(x, y) { let b = (yield x) + a let c = (yield y) + b console.log(a + b + c) } let it = foo(6, 8) let x = it.next().value a++ let y = it.next(x * 5).value a++ it.next(x + y) // 84
从上面的代码我们可以看到与普通的函数不同,生成器函数执行后返回的是一个迭代器对象,用来控制生成器的暂停和启动。在常见的设计模式中就有一种模式叫做迭代器模式,它指的是提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。迭代器对象 it 包含一个 next(..) 方法且在调用之后返回一个 { done: .. , value: .. } 对象,现在我们先来自己实现一个简单的迭代器。
const iterator = function (obj) { let current = -1 return { [Symbol.iterator]() { return this }, next() { current++ return { done: current <p>可以看到我们自己实现的迭代器不仅能够手动进行迭代,还能被 for..of 自动迭代展开,这是因为在 ES6 中只要对象具有 Symbol.iterator 属性且该属性返回的是一个迭代器对象,就能够被 for..of 所消费。</p><p>回头来看最开始的那个 generator 示例代码中生成器产生的迭代器对象 it ,似乎它比普通的迭代器有着更强大的功能,其实就是与 yield 表达式紧密相连的消息双向传递。现在我先来总结一下自己认为在生成器中十分重要的点,然后再来分析下那段示例代码的完整执行过程。</p><p>每次调用 it.next() 后生成器函数内的代码就会启动执行且返回一个 { done: .. , value: .. } 对象,一旦遇到 yield 表达式就会暂停执行,如果此时 yield 表达式后面跟有值例如 yield val,那么这个 val 就会被传入返回对象中键名 value 对应的键值,当再次调用 it.next() 时 yield 的暂停效果就会被取消,如果此时的 next 为形如 it.next(val) 的调用,yield 表达式就会被 val 所替换。这就是生成器内部与迭代器对象外部之间的消息双向传递。</p><p>弄清了生成器中重要的特性后要理解开头的那段代码就不难了,首先执行第一个 it.next().value ,遇到第一个 yield 后生成器暂停执行,此时变量 x 接受到的值为 6。在全局环境下执行 a++ 后再次执行 it.next(x * 5).value 生成器继续执行且传入值 30,因此变量 b 的值就为 33,当遇到第二个 yield 后生成器又暂停执行,并且将值 8 传出给变量 y 。再次执行 a++ ,然后执行 it.next(x + y) 恢复生成器执行并传入值 14,此时变量 c 的值就为 47,最终计算 a + b + c 便可得到值 84。</p><p><strong>在异步编程中使用生成器</strong></p><p>既然现在我们已经知道了生成器内部拥有能够多次启动和暂停代码执行的强大能力,那么将它用于异步编程中也便是理所当然的事情了。先来看一个异步迭代生成器的例子。</p><pre class="brush:php;toolbar:false">const request = require('request') const foo = function () { request('https://cosmos-alien.com/some.url', (err, response, data) => { if (err) it.throw(err) if (response.statusCode === 200) { it.next(data) } }) } const main = function *() { try { let result = yield foo() console.log(result) } catch (err) { console.error(err) } } let it = main() it.next()
这个例子的逻辑很简单,调用 it.next() 后生成器启动,遇到 yield 时生成器暂停运行,但此时 foo 函数已经执行即网络请求已经发出,等到有响应结果时如果出错则调用 it.throw(err) 将错误抛回生成器内部由 try..catch 同步捕获,否则将返回的 data 作为传回生成器的值在恢复执行的同时将 data 赋值给变量 result ,最后打印 result 得到我们想要的结果。
在 ES6 中最完美的世界就是生成器 ( 看似同步的异步代码 ) 和 Promise ( 可信任可组合 ) 的结合,因此我们现在再来看一个由生成器 + Promise 实现异步操作的例子。
const axios = require('axios') const foo = function () { return axios({ method: 'GET', url: 'https://cosmos-alien.com/some.url' }) } const main = function *() { try { let result = yield foo() console.log(result) } catch (err) { console.error(err) } } let it = main() let p = it.next().value p.then((data) => { it.next(data) }, (err) => { it.throw(err) })
这个例子跟前面异步迭代生成器的例子几乎是差不多的,唯一不同的就是 yield 传递出去的是一个 promise 对象,之后我们在 then(..) 中来恢复执行生成器里下一步的操作或是抛出一个错误。
生成器工作原理
在讲了那么多关于 generator 生成器的使用后,相信读者也跟我一样想知道生成器究竟是如何实现能够控制函数内部代码的暂停和启动,从而形成阻塞同步的效果。
我们先来简单了解下有限状态机 ( FSM ) 这个概念,维基百科上给出的解释是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型。简单的来说,它有三个主要特征:
状态总数 ( state ) 是有限的
任一时刻,只处在一种状态之中
某种条件下,会从一种状态转变 ( transition ) 到另一种状态
其实生成器就是通过暂停自己的作用域 / 状态来实现它的魔法的,下面我们就以上文的生成器 + Promise 的例子为基础,用有限状态机的方式来阐述生成器的基本工作原理。
let stateRequest = { done: false, transition(message) { this.state = this.stateResult console.log(message) // state 1 return foo() } } let stateResult = { done: true, transition(data) { // state 2 let result = data console.log(result) } } let stateError = { transition(err) { // state 3 console.error(err) } } let it = { init() { this.stateRequest = Object.create(stateRequest) this.stateResult = Object.create(stateResult) this.stateError = Object.create(stateError) this.state = this.stateRequest }, next(data) { if (this.state.done) { return { done: true, value: undefined } } else { return { done: this.state.done, value: this.state.transition.call(this, data) } } }, throw(err) { return { done: true, value: this.stateError.transition(err) } } } it.init() it.next('The request begins !')
在这里我使用了行为委托模式和状态模式实现了一个简单的有限状态机,而它却展现了生成器中核心部分的工作原理,下面我们来逐步分析它是如何运行的。
首先这里我们自己创建的 it 对象就相当于生成器函数执行后返回的迭代器对象,我们把上文生成器 + Promise 示例中的 main 函数代码分为了三个状态并将跟该状态有关的行为封装到了 stateRequest 、stateResult 、stateError 三个对象中。然后我们再调用 init(..) 将 it 对象上的行为委托到这三个对象上并初始化当前的状态对象。在准备工作完成后调用 next(..) 启动生成器,这个时候我们就进入了状态一,即执行 foo 函数发出网络请求。在 foo 函数内部当得到请求响应数据后就执行 it.next(data) 触发状态机内部的状态改变,此时执行状态二内部的代码即打印网络请求返回的结果。如果网络请求中出现错误就会执行 it.throw(err) ,这个时候的状态就会转换到状态三即错误处理状态。
在这里我们似乎忽略了一个很重要的地方,就是生成器是如何做到将其内部的代码分为多个状态的,当然我们知道这肯定是 yield 表达式的功劳,但是其内部又是怎么实现的呢?由于本人能力还不够,而且还有很多东西来不及去学习和了解,因此暂时无法解决这个问题,但我还是愿意把这个问题提出来,如果读者确实有兴趣能够通过查阅资料找到答案或者已经知道它的原理还是可以分享出来,毕竟经历这样刨根问底的过程还是满有趣的。
async / await
终于讲到最后一个异步语法了,作为压轴的身份出场,据说 async / await 是 JS 异步编程中的终极解决方案。话不多说,先直接上代码看看它的基本用法,然后我们再来探讨一下它的实现原理。
const foo = function (time) { return new Promise((resolve, reject) => { setTimeout(() => { resolve(time + 200) }, time) }) } const step1 = time => foo(time) const step2 = time => foo(time) const step3 = time => foo(time) const main = async function () { try { console.time('run') let time1 = 200 let time2 = await step1(time1) let time3 = await step2(time2) await step3(time3) console.log(`All steps took ${time1 + time2 + time3} ms.`) console.timeEnd('run') } catch(err) { console.error(err) } } main() // All steps took 1200 ms. // run: 1222.87939453125ms
我们可以看到 async 函数跟生成器函数极为相似,只是将之前的 * 变成了 async ,yield 变成了 await 。其实它就是一个能够自动执行的 generator 函数,我们不用再通过手动执行 it.next(..) 来控制生成器函数的暂停与启动。
await 帮我们做到了在同步阻塞代码的同时还能够监听 Promise 对象的决议,一旦 promise 决议,原本暂停执行的 async 函数就会恢复执行。这个时候如果决议是 resolve ,那么返回的结果就是 resolve 出来的值。如果决议是 reject ,我们就必须用 try..catch 来捕获这个错误,因为它相当于执行了 it.throw(err) 。
下面直接给出一种主流的 async / await 语法版本的实现代码:
const runner = function (gen) { return new Promise((resolve, reject) => { var it = gen() const step = function (execute) { try { var next = execute() } catch (err) { reject(err) } if (next.done) return resolve(next.value) Promise.resolve(next.value) .then(val => step(() => it.next(val))) .catch(err => step(() => it.throw(err))) } step(() => it.next()) }) } async function fn() { // ... } // 等同于 function fn() { const gen = function *() { // ... } runner(gen) }
从上面的代码我们可以看出 async 函数执行后返回的是一个 Promise 对象,然后使用递归的方法去自动执行生成器函数的暂停与启动。如果调用 it.next().value 传出来的是一个 promise ,则用 Promise.resolve() 方法将其异步展开,当这个 promise 决议时就可以重新启动执行生成器函数或者抛出一个错误被 try..catch 所捕获并最终在 async 函数返回的 Promise 对象的错误处理函数中处理。
关于 async / await 的执行顺序
下面给出一道关于 async / await 执行顺序的经典面试题,网上给出的解释给我感觉似乎很含糊。在这里我们结合上文所讲的 generator 函数运行机制和 async / await 实现原理来具体阐述下为什么执行顺序是这样的。
async function async1(){ console.log('async1 start') await async2() console.log('async1 end') } async function async2(){ console.log('async2') } console.log('script start') setTimeout(() => { console.log('setTimeout') }) async1() new Promise((resolve) => { console.log('promise1') resolve() }) .then(() => { console.log('promise2') }) console.log('script end')
将这段代码放在浏览器中运行,最终的结果这样的:
script start async1 start async2 promise1 script end promise2 async1 end setTimeout
其实最主要的地方还是要分清在执行栈中同步执行的任务与事件队列中异步执行的任务。首先我们执行同步任务,打印 script start ,调用函数 async1 ,在我们遇到 await 表达式后就会暂停函数 async1 的执行。因为在这里它相当于 yield async2() ,根据上文的 async / await 原理实现代码可以看出,当自动调用 it.next() 时遇到第一个 yield 后会暂停执行,但此时函数 async2 已经执行。上文还提到过 async 函数在执行完后会返回一个 Promise 对象,故此时 it.next().value 的值就是一个 promise 。接下来要讲的就是重点啦 !!!
我们用 Promise.resolve() 去异步地展开一个 promise ,因此第一个放入事件队列中的微队列任务其实就是这个 promise 。之后我们再继续运行执行栈中剩下的同步任务,此时打印出 promise1 和 script end ,同时第二个异步任务被加入到事件队列中的微队列。同步的任务执行完了,现在来执行异步任务,首先将微队列中第一个放入的那个 promise 拿到执行栈中去执行,这个时候之前 Promise.resolve() 后面注册的回调任务才会作为第三个任务加入到事件队列中的微队列里去。然后我们执行微队列中的第二个任务,打印 promise2,再执行第三个任务即调用 step(() => it.next(val)) 恢复 async 函数的执行,打印 async1 end 。最后,因为微队列总是抢占式的在宏队列之前插入执行,故只有当微队列中没有了任务以后,宏队列中的任务才会开始执行,故最终打印出 setTimeout 。
常见异步模式
在软件开发中有着设计模式这一专业术语,通俗一点来讲设计模式其实就是在某种场合下针对某个问题的一种解决方案。在 JS 异步编程的世界里,很多时候我们也会遇到因为是异步操作而出现的特定问题,而针对这些问题所提出的解决方案 ( 逻辑代码 ) 就是异步编程的核心,似乎在这里它跟设计模式的概念很相像,所以我把它叫做异步模式。下面我将介绍几种常见的异步模式在实际场景下的应用。
并发交互模式
当我们在同时执行多个异步任务时,这些任务返回响应结果的时间往往是不确定的,因而会产生以下两种常见的需求:
多个异步任务同时执行,等待所有任务都返回结果后才开始进行下一步的操作。
多个异步任务同时执行,只返回最先完成异步操作的那个任务的结果然后再进行下一步的操作。
场景一:
同时读取多个含有英文文章的 txt 文件内容,计算其中单词 of 的个数。
等待所有文件中的 of 个数计算完毕,再计算输出总的 of 数。
直接输出第一个计算完 of 的个数。
const fs = require('fs') const path = require('path') const addAll = (result) => console.log(result.reduce((prev, cur) => prev + cur)) let dir = path.join(__dirname, 'files') fs.readdir(dir, (err, files) => { if (err) return console.error(err) let promises = files.map((file) => { return new Promise((resolve, reject) => { let fileDir = path.join(dir, file) fs.readFile(fileDir, { encoding: 'utf-8' }, (err, data) => { if (err) reject(err) let count = 0 data.split(' ').map(word => word === 'of' ? count++ : null) resolve(count) }) }) }) Promise.all(promises).then(result => addAll(result)).catch(err => console.error(err)) Promise.race(promises).then(result => console.log(result)).catch(err => console.error(err)) })
并发控制模式
有时候我们会遇到大量异步任务并发执行而且还要处理返回数据的情况,即使拥有事件循环 ( Event Loop ) 机制,在并发量过高的情况下程序仍然会崩溃,所以这个时候就应该考虑并发控制。
场景二:
利用 Node.js 实现图片爬虫,控制爬取时的并发量。一是防止 IP 被封掉 ,二是防止并发请求量过高使程序崩溃。
const fs = require('fs') const path = require('path') const request = require('request') const cheerio = require('cheerio') const target = `http://www.zimuxia.cn/${encodeURIComponent('我们的作品')}` const isError = (err, res) => (err || res.statusCode !== 200) ? true : false const getImgUrls = function (pages) { return new Promise((resolve) => { let limit = 8, number = 0, imgUrls = [] const recursive = async function () { pages = pages - limit limit = pages >= 0 ? limit : (pages + limit) let arr = [] for (let i = 1; i { request(target + `?set=${number++}`, (err, res, data) => { if (isError(err, res)) return console.log('Request failed.') let $ = cheerio.load(data) $('.pg-page-wrapper img').each((i, el) => { let imgUrl = $(el).attr('data-cfsrc') imgUrls.push(imgUrl) resolve() }) }) }) ) } await Promise.all(arr) if (limit === 8) return recursive() resolve(imgUrls) } recursive() }) } const downloadImages = function (imgUrls) { console.log('\n Start to download images. \n') let limit = 5 const recursive = async function () { limit = imgUrls.length - limit >= 0 ? limit : imgUrls.length let arr = imgUrls.splice(0, limit) let promises = arr.map((url) => { return new Promise((resolve) => { let imgName = url.split('/').pop() let imgPath = path.join(__dirname, `images/${imgName}`) request(url) .pipe(fs.createWriteStream(imgPath)) .on('close', () => { console.log(`${imgName} has been saved.`) resolve() }) }) }) await Promise.all(promises) if (imgUrls.length) return recursive() console.log('\n All images have been downloaded.') } recursive() } request({ url: target, method: 'GET' }, (err, res, data) => { if (isError(err, res)) return console.log('Request failed.') let $ = cheerio.load(data) let pageNum = $('.pg-pagination li').length console.log('Start to get image urls...') getImgUrls(pageNum) .then((result) => { console.log(`Finish getting image urls and the number of them is ${result.length}.`) downloadImages(result) }) })
发布 / 订阅模式
我们假定,存在一个"信号中心",当某个任务执行完成,就向信号中心"发布" ( publish ) 一个信号,其他任务可以向信号中心"订阅" ( subscribe ) 这个信号,从而知道什么时候自己可以开始执行,当然我们还可以取消订阅这个信号。
我们先来实现一个简单的发布订阅对象:
class Listener { constructor() { this.eventList = {} } on(event, fn) { if (!this.eventList[event]) this.eventList[event] = [] if (fn.name) { let obj = {} obj[fn.name] = fn fn = obj } this.eventList[event].push(fn) } remove(event, fn) { if (!fn) return console.error('Choose a named function to remove!') this.eventList[event].map((item, index) => { if (typeof item === 'object' && item[fn.name]) { this.eventList[event].splice(index, 1) } }) } emit(event, data) { this.eventList[event].map((fn) => { if (typeof fn === 'object') { Object.values(fn).map((f) => f.call(null, data)) } else { fn.call(null, data) } }) } } let listener = new Listener() function foo(data) { console.log('Hello ' + data) } listener.on('click', (data) => console.log(data)) listener.on('click', foo) listener.emit('click', 'RetroAstro') // Hello // Hello RetroAstro listener.remove('click', foo) listener.emit('click', 'Barry Allen') // Barry Allen
场景三:
监听 watch 文件夹,当里面的文件有改动时自动压缩该文件并保存到 done 文件夹中。
// gzip.js const fs = require('fs') const path = require('path') const zlib = require('zlib') const gzipFile = function (file) { let dir = path.join(__dirname, 'watch') fs.readdir(dir, (err, files) => { if (err) console.error(err) files.map((filename) => { let watchFile = path.join(dir, filename) fs.stat(watchFile, (err, stats) => { if (err) console.error(err) if (stats.isFile() && file === filename) { let doneFile = path.join(__dirname, `done/${file}.gz`) fs.createReadStream(watchFile) .pipe(zlib.createGzip()) .pipe(fs.createWriteStream(doneFile)) } }) }) }) } module.exports = { gzipFile: gzipFile }
开始监听 watch 文件夹
// watch.js const fs = require('fs') const path = require('path') const { gzipFile } = require('./gzip') const { Listener } = require('./listener') let listener = new Listener() listener.on('gzip', (data) => gzipFile(data)) let dir = path.join(__dirname, 'watch') let wait = true fs.watch(dir, (event, filename) => { if (filename && event === 'change' && wait) { wait = false setTimeout(() => wait = true, 100) listener.emit('gzip', filename) } })
结语
对于 JavaScript 异步编程在这里我就讲这么多了,当然还有很多东西自己没有了解和学习到,因此在本篇文章中没有涉及。最后还是给出上面三个场景代码的 GitHub 地址 ,总之在前端学习的路上还得继续加油嘞 。
以上がJavaScript 非同期プログラミングの詳細な紹介 (例付き)の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。