JavaScript是一门单线程语言,但是它的异步编程能力非常强大,这也是JavaScript能够处理高并发、实现复杂交互的关键。从最早的回调函数,到Promise,再到async/await,JavaScript异步编程在不断发展,变得越来越优雅、越来越易读。

2016年,Promise已经被写入ES6(ES2015)标准,得到了主流浏览器和Node.js的良好支持,成为JavaScript异步编程的主流方案。但是很多开发者对Promise还不够熟悉,还在使用传统的回调函数,导致代码嵌套过深、难以维护。今天就来详细讲解JavaScript异步编程,从回调到Promise,带大家全面掌握JavaScript异步编程。

同步与异步

1. 同步编程: 同步编程是指代码按照顺序执行,前一行代码执行完后,才会执行下一行代码。如果某一行代码执行时间很长(比如网络请求、文件读取),后面的代码就会被阻塞,等待前面的代码执行完。

// 同步示例
console.log('开始');
const data = readFileSync('file.txt'); // 同步读取文件,会阻塞
console.log(data);
console.log('结束');
// 输出:开始 -> 文件内容 -> 结束

同步编程的优点是代码简单、易读、易调试;缺点是会阻塞,执行效率低,不适合处理耗时操作。

2. 异步编程: 异步编程是指代码不按照顺序执行,耗时操作不会阻塞后面的代码,而是在后台执行,执行完成后通过回调函数、Promise等方式通知主程序。

// 异步示例
console.log('开始');
readFile('file.txt', (err, data) => {
    console.log(data); // 异步读取文件,完成后执行回调
});
console.log('结束');
// 输出:开始 -> 结束 -> 文件内容

异步编程的优点是不会阻塞,执行效率高,适合处理耗时操作(网络请求、文件读取、定时器等);缺点是代码相对复杂,不如同步代码易读。

回调函数

回调函数是JavaScript异步编程最早的方式,也是最基础的方式。回调函数就是把一个函数作为参数传给另一个函数,当异步操作完成后,调用这个函数来处理结果。

1. 回调函数的基本用法

// 定时器回调
setTimeout(() => {
    console.log('1秒后执行');
}, 1000);

// 异步读取文件回调
const fs = require('fs');
fs.readFile('file.txt', 'utf8', (err, data) => {
    if (err) {
        console.error('读取失败:', err);
        return;
    }
    console.log('文件内容:', data);
});

// AJAX请求回调
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/data', true);
xhr.onload = function() {
    if (xhr.status === 200) {
        console.log('请求成功:', xhr.responseText);
    } else {
        console.error('请求失败:', xhr.status);
    }
};
xhr.onerror = function() {
    console.error('网络错误');
};
xhr.send();

2. 回调地狱: 回调函数的缺点是,当有多个异步操作需要依次执行时,会出现回调嵌套,代码层级很深,难以阅读和维护,这就是所谓的"回调地狱"(Callback Hell)。

// 回调地狱示例
fs.readFile('file1.txt', 'utf8', (err, data1) => {
    if (err) {
        console.error('读取file1失败:', err);
        return;
    }
    fs.readFile('file2.txt', 'utf8', (err, data2) => {
        if (err) {
            console.error('读取file2失败:', err);
            return;
        }
        fs.readFile('file3.txt', 'utf8', (err, data3) => {
            if (err) {
                console.error('读取file3失败:', err);
                return;
            }
            console.log('三个文件都读取完成:', data1, data2, data3);
            // 还可以继续嵌套...
        });
    });
});

回调地狱的问题:

  • 代码层级深,难以阅读和理解
  • 错误处理重复,每个回调都要处理错误
  • 难以调试,出问题时很难定位
  • 难以维护和修改,增加或减少步骤都很麻烦

为了解决回调地狱的问题,Promise应运而生。

Promise

Promise是ES6(ES2015)引入的异步编程新方案,它比回调函数更优雅、更易读、更易维护,能够有效解决回调地狱的问题。

1. Promise的基本概念: Promise是一个对象,代表一个异步操作的最终完成(或失败)及其结果值。Promise有三种状态:

  • pending(进行中):初始状态,异步操作正在执行
  • fulfilled(已成功):异步操作成功完成
  • rejected(已失败):异步操作失败

Promise的状态只能从pending变为fulfilled,或从pending变为rejected,一旦状态改变,就不会再变。

2. Promise的基本用法

// 创建Promise
const promise = new Promise((resolve, reject) => {
    // 异步操作
    setTimeout(() => {
        const success = true; // 模拟异步操作结果
        if (success) {
            resolve('操作成功'); // 成功,调用resolve,状态变为fulfilled
        } else {
            reject('操作失败'); // 失败,调用reject,状态变为rejected
        }
    }, 1000);
});

// 使用Promise
promise
    .then(result => {
        console.log('成功:', result); // 操作成功时执行
    })
    .catch(error => {
        console.error('失败:', error); // 操作失败时执行
    });

3. Promise的链式调用: Promise的then方法返回一个新的Promise,所以可以链式调用,有效解决回调地狱的问题。

// Promise链式调用示例
readFilePromise('file1.txt')
    .then(data1 => {
        console.log('file1读取成功:', data1);
        return readFilePromise('file2.txt'); // 返回新的Promise
    })
    .then(data2 => {
        console.log('file2读取成功:', data2);
        return readFilePromise('file3.txt'); // 返回新的Promise
    })
    .then(data3 => {
        console.log('file3读取成功:', data3);
        console.log('三个文件都读取完成');
    })
    .catch(error => {
        console.error('读取失败:', error); // 任何一步失败都会被捕获
    });

Promise链式调用的优点:

  • 代码扁平化,没有嵌套,易读易维护
  • 错误处理统一,一个catch就能捕获所有错误
  • 易于调试,出问题时容易定位
  • 易于维护和修改,增加或减少步骤都很方便

4. Promise的常用方法

  • Promise.resolve():返回一个成功的Promise
Promise.resolve('成功').then(result => console.log(result)); // 成功
  • Promise.reject():返回一个失败的Promise
Promise.reject('失败').catch(error => console.error(error)); // 失败
  • Promise.all():接收一个Promise数组,等待所有Promise都成功后,返回一个包含所有结果的数组;如果有一个失败,就立即返回失败
Promise.all([
    readFilePromise('file1.txt'),
    readFilePromise('file2.txt'),
    readFilePromise('file3.txt')
])
    .then(results => {
        console.log('所有文件都读取成功:', results); // results是一个数组,包含三个文件的内容
    })
    .catch(error => {
        console.error('读取失败:', error); // 任何一个文件读取失败都会被捕获
    });
  • Promise.race():接收一个Promise数组,返回第一个完成的Promise的结果(不管成功还是失败)
Promise.race([
    readFilePromise('file1.txt'),
    readFilePromise('file2.txt')
])
    .then(result => {
        console.log('第一个读取成功:', result); // 哪个文件先读取完成,就返回哪个的结果
    })
    .catch(error => {
        console.error('读取失败:', error); // 第一个失败的Promise会被捕获
    });
  • Promise.allSettled():ES2020引入,接收一个Promise数组,等待所有Promise都完成后,返回一个包含所有结果的数组(不管成功还是失败)
Promise.allSettled([
    readFilePromise('file1.txt'),
    readFilePromise('file2.txt')
])
    .then(results => {
        results.forEach(result => {
            if (result.status === 'fulfilled') {
                console.log('成功:', result.value);
            } else {
                console.error('失败:', result.reason);
            }
        });
    });
  • Promise.any():ES2021引入,接收一个Promise数组,返回第一个成功的Promise的结果;如果所有Promise都失败,就返回失败
Promise.any([
    readFilePromise('file1.txt'),
    readFilePromise('file2.txt')
])
    .then(result => {
        console.log('第一个成功:', result);
    })
    .catch(error => {
        console.error('全部失败:', error);
    });

5. Promise的错误处理: Promise的错误可以通过catch方法捕获,也可以通过then方法的第二个参数捕获。推荐使用catch方法,因为它能捕获链式调用中所有的错误。

// 推荐使用catch
promise
    .then(result => {
        console.log('成功:', result);
    })
    .catch(error => {
        console.error('失败:', error);
    });

// 不推荐使用then的第二个参数
promise
    .then(
        result => {
            console.log('成功:', result);
        },
        error => {
            console.error('失败:', error);
        }
    );

注意:如果Promise内部抛出错误,但是没有catch,会导致未捕获的Promise拒绝(Unhandled Promise Rejection),在Node.js中可能会导致进程崩溃。所以一定要记得catch错误。

6. 回调函数转Promise: 很多旧的API都是基于回调函数的,可以用Promise包装一下,变成Promise风格的API。

// 手动包装
function readFilePromise(path, encoding) {
    return new Promise((resolve, reject) => {
        fs.readFile(path, encoding, (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

// 使用util.promisify(Node.js)
const util = require('util');
const readFilePromise = util.promisify(fs.readFile);

async/await

async/await是ES2017引入的异步编程新语法,它是基于Promise的语法糖,让异步代码看起来像同步代码一样,更加易读、易写、易维护。2016年时,async/await还没有正式写入标准,但是已经可以通过Babel等转译工具使用,Node.js 7.6+也原生支持了。

1. async函数: 在函数前面加上async关键字,就变成了async函数。async函数的返回值是一个Promise,如果函数内部return了一个值,这个值会被Promise.resolve包装;如果函数内部抛出错误,会被Promise.reject包装。

async function getData() {
    return '数据'; // 相当于return Promise.resolve('数据')
}

getData().then(data => console.log(data)); // 数据

2. await表达式: 在async函数内部,可以使用await关键字等待一个Promise完成。await会暂停async函数的执行,等待Promise完成后,恢复执行,并返回Promise的结果。

async function readFiles() {
    try {
        const data1 = await readFilePromise('file1.txt');
        console.log('file1读取成功:', data1);
        const data2 = await readFilePromise('file2.txt');
        console.log('file2读取成功:', data2);
        const data3 = await readFilePromise('file3.txt');
        console.log('file3读取成功:', data3);
        console.log('三个文件都读取完成');
    } catch (error) {
        console.error('读取失败:', error);
    }
}

readFiles();

async/await的优点:

  • 代码像同步代码一样,非常易读、易写
  • 错误处理用try/catch,和同步代码一样
  • 调试方便,可以像同步代码一样打断点
  • 可以使用所有同步代码的语法(如for循环、if判断等)

3. async/await的错误处理: async/await的错误可以用try/catch捕获,和同步代码一样。

async function getData() {
    try {
        const data = await fetchData();
        console.log('成功:', data);
    } catch (error) {
        console.error('失败:', error);
    }
}

如果不想用try/catch,也可以在await后面加catch:

async function getData() {
    const data = await fetchData().catch(error => {
        console.error('失败:', error);
        return null;
    });
    if (data) {
        console.log('成功:', data);
    }
}

4. 并行执行: 如果多个异步操作没有依赖关系,可以用Promise.all()并行执行,提高效率。

async function readFiles() {
    try {
        // 并行执行三个异步操作
        const [data1, data2, data3] = await Promise.all([
            readFilePromise('file1.txt'),
            readFilePromise('file2.txt'),
            readFilePromise('file3.txt')
        ]);
        console.log('三个文件都读取成功:', data1, data2, data3);
    } catch (error) {
        console.error('读取失败:', error);
    }
}

注意:如果用await依次执行,是串行的,总时间是三个操作时间之和;如果用Promise.all并行执行,总时间是最长的那个操作的时间,效率更高。

异步编程最佳实践

  1. 优先使用async/await:async/await是目前最优雅、最易读的异步编程方式,优先使用。如果环境不支持,可以用Babel转译。
  2. 其次使用Promise:如果不能用async/await,就用Promise,避免回调地狱。
  3. 避免回调地狱:不要使用深层嵌套的回调函数,用Promise或async/await替代。
  4. 总是处理错误:异步操作一定要处理错误,Promise要catch,async/await要用try/catch,避免未捕获的错误。
  5. 并行执行无依赖的操作:如果多个异步操作没有依赖关系,用Promise.all并行执行,提高效率。
  6. 避免阻塞事件循环:JavaScript是单线程的,不要在主线程执行耗时的同步操作,避免阻塞事件循环。
  7. 使用Promise包装旧API:如果使用的是基于回调的旧API,可以用Promise包装一下,变成Promise风格,方便使用。
  8. 注意Promise的状态:Promise的状态一旦改变就不会再变,不要在已经resolve或reject后再调用resolve或reject。

总结

JavaScript异步编程从回调函数发展到Promise,再到async/await,变得越来越优雅、越来越易读、越来越易维护。2016年,Promise已经成为主流,async/await也开始普及,掌握这些异步编程技术,是每个JavaScript开发者的必备技能。

本文介绍了同步与异步的概念、回调函数的用法和回调地狱的问题、Promise的基本概念和用法(链式调用、常用方法、错误处理、回调转Promise)、async/await的基本用法和错误处理、并行执行,以及异步编程的最佳实践。

掌握JavaScript异步编程,需要多练习、多实践,在实际项目中不断积累经验。希望这篇指南能帮助大家全面掌握JavaScript异步编程,写出更优雅、更高效、更易维护的JavaScript代码。

JavaScript异步编程还在不断发展,未来可能会有更优雅的异步编程方式出现。作为前端开发者,要持续学习,跟上技术的发展,掌握最新的异步编程技术。