JavaScript 异步编程

Author: Guang

异步编程在编程领域一直是重要的课题,对于前端程序来说,界面始终要保持与用户的交互性,用户的相应与程序的任务之间存在很多消息传递、方法调用、数据加载,异步机制在起到了很大的作用。从JavaScript最初发展开始,实现异步的形式也一直在变,从最初的回到函数和事件监听,到ECMAScript 6引入Promise,再到后来的借用生成器(Generator)和目前最广泛使用的async/await函数修饰符,异步的方式逐步完善,代码风格愈发简约。

不可或缺的异步

JavaScript是单线程应用程序,我们大部分时候编写的代码是同步调用的,每次调用都会等待执行结果,拿到当前调用结果后进入下一步执行。当一个调用是轻量的时候不会出现问题,计算机指令“静默”地运行在计算机内核上。但当一些耗时地任务也在程序中同步地运行在程序中,就会造成JavaScript程序线程一直被占用,造成“卡顿”。诸如我们需要计算一个比较大地斐波那契数,在同步调用状态下就会长时间占用程序线程,使页面无法及时响应用户交互或者更新界面渲染。

// 斐波那契函数
// 当前求斐波那契数的时间复杂度 O(2^n)
function fibonacci(n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

fibonacci(15);

当我们在程序中调用了诸如求斐波那契数的耗时任务时,JavaScript线程被占用,页面无法及时响应用户交互,造成页面假死。这种情景下就需要异步方法来解除程序主线程的阻塞。

对于异步的定义,维基百科上有如下描述:

同步可以理解为:发出一个调用时,在没有得到结果之前,该调用就不返回;一旦调用返回,就得到返回值。换句话说,就是由调用者主动等待这个调用的结果。而异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知或通过回调函数,让调用者能响应结果。

JavaScript程序通过异步方法实现,将一些耗时任务交给程序运行环境去执行,不等待任务的运行结果,运行环境执行完毕之后将结果“通知”到程序线程。通过这种方式,JavaScript程序解除程序线程的占用问题,避免前端页面进入“假死”状态。在前端开发中,比较耗时的操作,都基于异步方法进行,以下是一些常见的情景:

  • 网络IO操作:包括网络静态资源请求、网络接口请求,
  • 本地IO操作:本地文件读取
  • 浏览器接口调用:包括getUserMedia()访问用户的媒体设备、navigator.geolocation获取用户的地理位置等
  • IndexedDB读写

在JavaScript语言的发展历程中,异步编程的方式也在不断演进完善,下面我们逐一讨论。

使用回调函数

在JavaScript中,函数作为“一等公民”,可以作为参数进行传递,这让JavaScript天然支持回调函数。开发人员可以很方便的定义一个函数,将这个函数延迟到需要执行它的时候去执行。例如当我们获取服务器数据的时候,常常可以使用回调函数来接收得到的数据并进行处理。

// 模拟异步操作的函数
function fetchData(callback) {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', 'https://api.example.com/data', true);

    xhr.onload = function() {
        if (xhr.status === 200) {
            callback(null, xhr.responseText);
        } else {
            callback(xhr);
        }
    };

    // 请求失败的回调
    xhr.onerror = function() {
        callback(Error('请求出错'));
    };
}

// 定义操作数据的方法
function handleData(error, data) {
    if(error) {
        console.error(error);
        return;
    }
    console.log('获取的数据:', data);
}

// 使用回调函数处理异步结果
fetchData(handleData);

代码中我们向https://api.example.com/data请求服务端数据。获取服务端数据的时长是耗时长并且不可知的,JavaScript程序使用浏览器提供的API(XMLHTTPRequest)创建请求并发送。将拿回数据的任务交给浏览器(运行环境),就不再等待数据结果,JavaScript程序线程解除了阻塞,可以正常相应用户交互或者进行渲染更新。当浏览器拿到后端数据后,通过执行已注册的回调函数将数据传递给JavaScript应用程序。如此,一次完整的异步的网络数据请求完成。

事件监听(发布/订阅模式的实现)

浏览器页面元素的事件监听也是异步实现的,我们在元素上注册事件函数,浏览器在捕获对应的用户交互行为时执行这些函数。这也是基于回调函数的发布/订阅模式的实现。

// 发布者
const button = document.querySelector('submit');

// 订阅者
button.addEventListener('click', () => {
    console.log('Button clicked!');
});

评价

使用回调函数能够完成异步编程工作。当JavaScript程序规模变大,回调函数带来的问题也很明显。主要体现在以下几个方面:

  1. 回调函数传递链很容易变得很长,形成很深的嵌套结构,增加了项目的维护难度,我们称之为“回调地狱”;
  2. 回调函数的this值的不确定性,极容易引入逻辑错误;
  3. 难以使用try...catch处理程序错误;
  4. 无法处理函数返回值

使用Promise

为了摆脱回调异步函数引入的问题,JavaScript社区探索了Promise方案,Promise主要是解决回调函数的回调地狱问题,并不是完全取代回调机制,实际上,Promise是对回调函数进行妥善安排的一种方案。Promise方案最终在2015年于ECMAScript 6中标准化,成为原生JavaScript的一部分。本质上,Promise是一个具备then()方法的的thenable对象,它可以表示异步操作最终的完成(或失败)的状态以及其结果值,我们可以在它上面绑定回调函数,这样我们就不需要在一开始把回调函数作为参数传入这个函数了。

一个Promise对象一共有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。其中fulfilled状态和rejected状态又称为settled状态。一个Promise对象总会从创建之初的pending状态进入到fulfilled状态或者rejected状态。也可以说一个Promise对象总会被settled。一个Promise对象一旦被settled,其状态便不会再改变,并且对象中保存的数据可以反复读取。

1. 创建Promise对象

在JavaScript中,Promise被声明为一个class,我们可以按照构造函数去创建它。

const promise = new Promise((resolve, reject) => {
    // 使用 setTimeout() 模拟耗时任务
    setTimeout(() => {
        if(success) { // 伪代码,如果成功
            resolve('success');
        } else { // 失败分支
            reject(Error('failed'));
        }
    }, 2000);
});

promise.then((message) => {
    // handleSuccess(message);
    console.log(message);
}, (error) => {
    // handleError(error);
    console.error(error);
});

Promise构造函数接受一个executor回调函数作为参数去初始化当前构造的Promise对象。executor回调函数定义了两个形参:resolvereject。在executor回调函数中,JavaScript程序调用复杂任务,然后退出。当复杂任务执行完毕后,会通过resolvereject的调用通知任务调用结果,任务执行成功时使用resolve调用,任务失败或者程序报错时使用reject调用。promise对象支持使用方法then接收两个参数onfulfilledonrejected,用于注册复杂服务处理结果的回调函数。executor中调用resolve会唤起onfulfilled的调用,调用reject会唤起onrejected的调用。这便是Promise最简单的使用。

2. Thenable对象

Promise是一个具备then()方法的的thenable对象。Promises/A+ 规范规定,Promise对象不能直接保存thenable对象作为其最终结果值,而是会递归展开(unwrap)thenable对象,直到遇到非thenable值。Promise对象的最终状态和结果值总是取决于then()方法中注册的回调的执行状态。为了理解thenable对象,我们先看Promise.resolve()Promise.reject()方法。通过静态方法Promise.resolve()Promise.reject()我们可以快速地创建一个Promise对象,并且它的状态就是分别是fulfilled和rejected的,Promise对象的结果值就是调用两个静态方法时传入的参数。

Promise.resolve(30).then(value => {
    console.log(value); // 输出:30
});
Promise.reject(33).catch(error => {
    console.error(error); // 输出:33
});

如果Promise.resolve()Promise.reject()接收的参数是一个thenable对象时,它们创建的Promise对象并不会将thenable对象作为结果值保存,JavaScript会执行thenable对象中的then()方法,then()方法的执行结果决定了最终的Promise对象的状态和结果值。请看下面示例代码:

const thenable = {
    then: (resolve, reject) => {
        reject(33);
    }
};

const promise = Promise.resolve(thenable);
promise.catch(error => {
    console.error(error); // 输出 33,promise 的状态并没有由 Promise.resolve() 的调用决定,而是由 thenable 对象中 then 方法的执行结果确定。
});

Promise起源于社区,在成为正式标准前,许多库都使用了thenable对象,如果Promise对象可以将thenable对象作为最终的结果值,那么就会出现Promise对象状态不一致的问题或者出现无限等待的问题。为了避免这些问题,规范对thenable对象的行为做了如此规定。

3. 链式调用

Promise支持链式调用,这个分为两个部分。首先是Promise对象包含catch()方法,catch(onrejected)注册拒绝回调函数等价于then(onfulfilled,onrejected)注册。下面代码和上面创建Promise的代码是等价的。

const promise = new Promise((resolve, reject) => {
    // 使用 setTimeout() 模拟耗时任务
    setTimeout(() => {
        if(success) { // 伪代码,如果成功
            resolve('success');
        } else { // 失败分支
            reject(Error('failed'))
        }
    }, 2000);
});

promise.then((message) => {
    // handleSuccess(message);
    console.log(message);
}).catch((error) => {
    // handleError(error);
    console.error(error);
});

其次,Promise对象的thencatch方法返回值也是Promise对象,仍然可以继续调用thencatch方法。同时,两个方法的返回值会沿着调用链传递。当thencatch方法中的回调正常返回时,会进入两个方法创建的Promisethen方法中,当thencatch方法中的回调报错时,错误信息会被两个法创建的Promisecatch方法捕获。请看如下代码示例:

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(30); // 将当前 Promise 对象状态置为 resolved
    }, 20);
});

promise.then((value) => {
    console.log(value); // 输出:30,promise 被 resolve 了
    return value + 1; // 返回当前值加1,创建了新的 fulfilled 状态的 Promise 对象
}).then((value) => {
    console.log(value); // 输出:31,上游 Promise 对象 onfulfilled 正常返回,
    throw value + 3; // 创建了新的 rejected 状态的 Promise 对象
}).then((value) => {
    console.log(value); // 被忽略, 上游 Promise 对象状态是 rejected
    return value + 1;
}).catch((error) => {
    console.error(error); // 输出:34,捕获上游 Promise 对象抛出的错误,错误值是 34
    return error + 1; // 返回 error 值加1,创建了新的 fulfilled 状态的 Promise 对象
}).then((value) => {
    console.log(value); // 输出:35,上游 Promise 对象 onfulfilled 正常返回,
    return value + 1; // 返回当前值加1,创建了新的 fulfilled 状态的 Promise 对象
});

4. 编排异步任务处理顺序

因为对thenable对象的处理机制,开发人员可以在Promise调用链上插入promise对象,安排异步任务的执行顺序。示例代码如下:

const promise1 = new Promise((resolve, reject) => {
    console.log('create promise1');
    setTimeout(() => {
        console.log('resolve promise1 with 30');
        resolve(30);
    }, 20);
});

promise1.then(value => {
    console.log('promise1 value: ' + value);
    const promise2 = new Promise((resolve, reject) => {
        console.log('create promise2');
        setTimeout(() => {
            console.log('resolve promise2 with `value + 5`');
            resolve(value + 5);
        }, 20);
    });
    return promise2;
}).then(value => {
    console.log('final value: ' + value);
});

代码运行会按顺序输出以下内容

resolve promise1 with 30
promise1 value: 30
create promise2
resolve promise2 with `value + 5`
final value: 35

5. 限制性的错误捕获

Promise中,只能通过对象的catch()方法捕获错误,无法像同步代码那样使用try...catch语句捕获错误。如果我们遗漏了catch()方法的调用,可能发生的错误会中断程序的运行。

根据浏览器的事件循环模型,Promise实现异步的方式是编排微任务的执行,每一个微任务都是一个单独的执行单元。而try...catch语句是同步的,会在一个任务中完成,一个执行单元的结束,其中的try...catch语句也会执行完毕。所以Promise采用了catch()方法的机制保证发生的错误被捕获。从语法形式上看,这给开发语言带来了不一致,并且增加了遗漏错误的可能。有关事件循环模型,我们单独再讨论。

评价

Promise的链式调用机制一定程度上解决了多层嵌套的回调函数带来的混乱,作为一个对象,结果值一旦确定便永久保存在对象中,增强了复用能力。但同时,Promise的错误处理机制增加了遗漏错误处理的可能。

借用生成器(Generator)

生成器(Generator)也是发源于JavaScript社区,它本是为迭代器的使用而生。当人们看到它暂停并保存程序运行状态以待恢复的特性时,便想到了将它运用到异步编程。

Generator对象由生成器函数创建,function*声明会创建一个绑定到给定名称生成器函数。生成器函数可以退出,并在稍后重新进入,其上下文(变量绑定)会在重新进入时保存。Generator对象符合迭代器协议,当迭代器的next()方法被调用时,生成器函数的主体会被执行,直到遇到一个yield表达式,该表达式暂停函数的执行,并将表达式值返回给迭代器。每次迭代器调用next()方法都会使生成器函数寻找下一个yield表达式并从yield表达式处获得返回值,直到生成器函数执行完毕。同时,next()方法可以向生成器函数传递参数,该参数就会被当作上一个yield表达式的返回值。以下代码是Generator的简单使用示例:

function* gen() {
    const a = yield 111;
    console.log('inner a: ' + a);
    const b = yield 222;
    console.log('inner b: ' + b);
    const c = yield 333;
    console.log('inner c: ' + c);
}
const t = gen();
const a = t.next(1);
console.log('outer a: ' + a.value);
const b = t.next(2);
console.log('outer b: ' + b.value);
const c = t.next(3);
console.log('outer c: ' + c.value);

代码对生成器函数的调用呈现交替式的,程序的运行在调用方和生成器函数之间交替进行,运行代码会按顺序输出以下内容:

outer a: 111
inner a: 2
outer b: 222
inner b: 3
outer c: 333

人们可以利用生成器函数和调用方可以交替执行的特性,进行异步编程开发。请看下面一个异步读取文件的代码示例:

function readFileAsync(fileName) {
  return new Promise((resolve, reject) => {
    // 模拟异步读取
    setTimeout(() => {
      console.log(fileName + '读完了');
      resolve(fileName);
    }, 50);
  })
}

function* readFiles() {
  yield readFileAsync('first');
  yield readFileAsync('second');
  yield readFileAsync('third');
}
const g = readFiles()
const result = g.next()
result.value
  .then(() => {
    g.next()
  })
  .then(() => {
    g.next()
  });

运行代码,输出结果如下:

first读完了
second读完了
third读完了

生成器在从生成器函数取结果的过程中,仍然没有摆脱Promise.then()的链式调用。此时可以加入一个co库中的自执行器,或者自己实现一个类似的自执行器。代码如下:

function myCo(gen) {
    const generator = gen();
    return new Promise((resolve, reject) => {
        function next(data) {
            try {
                const result = generator.next(data);
                const { value, done } = result;
                if (done === true) {
                    resolve(value);
                } else if (done === false && value instanceof Promise) {
                    value.then(function (val) {
                        next(val);
                    });
                }
            } catch (error) {
                return reject(error);
            }
        }
        next();
    });
}

function readFileAsync(fileName) {
  return new Promise((resolve, reject) => {
    // 模拟异步读取
    setTimeout(() => {
      console.log(fileName + '读完了');
      resolve(fileName);
    }, 50);
  })
}

function* readFiles() {
  yield readFileAsync('first');
  yield readFileAsync('second');
  yield readFileAsync('third');
}

myCo(readFiles);

自执行器具备普遍性,适用于所有生成器函数。

评价

借用生成器(Generator)进行异步编程是基于Promise的,但是它消除了Promise中冗长的链式调用。额外的代价是需要引入一个自执行器。目前,人们很少再去使用借用生成器(Generator)进行异步编程,它最大的贡献是启发了async/await修饰符的实现。async/await修饰符是一种更为简洁的异步编程模式。

使用async/await修饰符

async/await在ECMAScript 2016中得到了提案,并在ECMAScript 2017中被标准化。async function声明创建一个绑定到给定名称的新异步函数。函数体内允许使用await关键字,这使得我们可以更简洁地编写基于Promise的异步代码,并且避免了显式地配置Promise链的需要。

在编程中使用async/await,可以近似的认为是生成器Generator + 自执行器(比如 co) + Promise对象的封装。async声明函数后会保证函数返回一个Promise对象,使用await修饰符调用一个Promise对象会等待Promise对象到完成(settled)状态,并且取出Promise对象的最终结果值。也正是因为如此,await可以修饰一个async声明的函数的调用,也可以修饰一个返回了Promise对象的函数的调用或者直接修饰一个Promise对象(或其它thenable对象)对其进行等待并取出结果值。

上面读取文件的异步任务,我们可以使用async/await修饰符进行如下实现:

function readFileAsync(fileName) {
  return new Promise((resolve, reject) => {
    // 模拟异步读取
    setTimeout(() => {
      console.log(fileName + '读完了');
      resolve(fileName);
    }, 50);
  })
}

async function readFiles() {
    await readFileAsync('first');
    await readFileAsync('second');
    await readFileAsync('third');
}

readFiles();

同时,若该await修饰调用的Promise被拒绝(rejected),await表达式会把拒绝的原因(reason)抛出。当前函数(await修饰调用的函数)会出现在抛出的错误的栈追踪(stack trace)。这意味着可以使用try...catch语句捕获抛出的错误。示例代码如下:

function readFileAsync(fileName) {
  return new Promise((resolve, reject) => {
    // 模拟异步读取
    setTimeout(() => {
      console.log(`reading: ` + fileName);
      reject(Error('read file-' + fileName + ' failed.'));
    }, 50);
  })
}

try {
    await readFileAsync('first');
} catch(err) {
    console.log('caught error: ' + err);
}

运行代码,输出结果如下:

reading: first
caught error: Error: read file-first failed.

需要注意的是,await只能在async函数或者模块顶层作用域中使用。

评价

使用async/await,我们可以写出近似于同步代码风格的异步编程实现,代码的语义更清晰、简洁。

使用Worker

JavaScript异步编程在之所以如此重要,很大的一个原因是JavaScript是单线程运行的,程序线程长时间被占用就会造成页面“假死”。目前,HTML5推出了Web Worker API,在浏览器中,它可以单独申请一个运行线程,用于处理复杂费时的任务,减少JavaScript程序线程的占用。它可以通过消息机制与JavaScript主线程进行通信,完成任务的定义、运行和状态同步。使用Web Worker也属于异步编程范畴,我们在这里简单介绍它的使用,将来有机会我们在HTML模块中再详细探讨它。

Web Worker给了前端开发人员在不同线程中运行某些任务的能力,对于多线程编程,线程间的资源竞争、状态同步一直是复杂的问题,为了简化这些问题,Web Worker做了如下规定:JavaScript主线程和Web Worker线程间不共享变量,两者分别执行在各自的执行栈上。双方都使用postMessage()方法发送各自的消息,使用onmessage事件处理函数来响应消息(消息被包含在message事件的data属性中)。这意味着Web Worker不能访问DOM(窗口、文档、页面元素等等),也不能访问window对象或使用window对象的默认方法和属性。Web Worker可以使用XMLHttpRequest(尽管responseXMLchannel属性总是为空)或fetch(没有这些限制)执行I/O,也可以使用WebSockets或者IndexedDB。

要创建一个Worker,只须调用new Worker(URL)构造函数,函数参数URL为指定的脚本。我们可以将文章开始的求斐波那契数的方法放到Worker中进行执行,代码示例如下。 创建一个fibonacci.js文件,提供运算斐波那契数的功能:

// 监听主线程中的消息。
// 如果消息中的 command 是 "fibonacci",则调用 `fibonacci()`
addEventListener("message", (message) => {
  if (message.data.command === "get-fibonacci") {
    getFibonacci(Number.parseInt(message.data.quota, 10));
  }
});

// 计算斐波那契函数
function fibonacci(n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

function getFibonacci(n) {
    const result = fibonacci(n);
    // 完成后给主线程发送一条包含我们生成的质数数量的消息消息。
    postMessage(result);
}

在JavaScript主程序代码中创建Worker,并提供发起计算斐波那契数command的方法,以及注册对应的监听结果回调。

// 使用 "fibonacci.js" 创建一个 worker
const worker = new Worker("./generate.js");
// 使用 worker 发起 get-fibonacci 请求,并注册运算成功后的回调。
function getFibonacciByWorker(n, onFinished) {
    worker.postMessage({
        command: "get-fibonacci",
        quota: n,
    });
    worker.addEventListener("message", (message) => {
        const fibonacci = Number.parseInt(message.data, 10);
        onFinished(fibonacci);
    });
}

如此,我们便将获取斐波那契数的耗时工作交给了Worker,在另外的线程中完成它,当前线程可正常处理主程序逻辑。

发表留言

历史留言

--空--