MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript中的回调函数与Promise的对比

2023-12-043.8k 阅读

回调函数基础

在JavaScript中,回调函数是一种非常基础且常用的概念。简单来说,回调函数就是作为参数传递给另一个函数,并在该函数内部被调用的函数。

回调函数的用途

  1. 异步操作:JavaScript是单线程语言,为了避免阻塞线程,像读取文件、网络请求等操作都是异步执行的。回调函数就用于在这些异步操作完成后执行相应的逻辑。例如,使用fs模块读取文件:
const fs = require('fs');

fs.readFile('example.txt', 'utf8', function (err, data) {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});

在这个例子中,fs.readFile是一个异步操作,它接收文件名、编码格式以及一个回调函数作为参数。当文件读取操作完成后,无论是成功还是失败,都会调用这个回调函数。如果有错误(err不为null),则在回调函数中处理错误;如果成功,则处理读取到的数据(data)。

  1. 事件处理:在网页开发中,事件处理也是回调函数的常见应用场景。比如,当用户点击一个按钮时,我们希望执行一些操作。可以通过给按钮的click事件绑定一个回调函数来实现:
<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF - 8">
    <title>回调函数示例</title>
</head>

<body>
    <button id="myButton">点击我</button>
    <script>
        const button = document.getElementById('myButton');
        button.addEventListener('click', function () {
            console.log('按钮被点击了');
        });
    </script>
</body>

</html>

这里,addEventListener的第二个参数就是一个回调函数,当按钮被点击时,该回调函数就会被执行。

回调地狱

虽然回调函数在处理异步操作和事件方面非常有用,但当有多个异步操作需要按顺序执行时,就容易出现“回调地狱”的问题。例如,假设有三个异步操作asyncOperation1asyncOperation2asyncOperation3,它们需要依次执行,代码可能会写成这样:

function asyncOperation1(callback) {
    setTimeout(() => {
        console.log('asyncOperation1 完成');
        callback();
    }, 1000);
}

function asyncOperation2(callback) {
    setTimeout(() => {
        console.log('asyncOperation2 完成');
        callback();
    }, 1000);
}

function asyncOperation3(callback) {
    setTimeout(() => {
        console.log('asyncOperation3 完成');
        callback();
    }, 1000);
}

asyncOperation1(() => {
    asyncOperation2(() => {
        asyncOperation3(() => {
            console.log('所有操作完成');
        });
    });
});

随着异步操作数量的增加,代码会变得越来越难以阅读和维护。这种层层嵌套的回调函数结构,就被称为“回调地狱”。它使得代码的逻辑变得复杂,调试也更加困难。

Promise 基础

Promise是JavaScript中用于处理异步操作的一种更现代的方式。它代表了一个异步操作的最终完成(或失败)及其结果值。

Promise的状态

  1. Pending(进行中):初始状态,既不是成功,也不是失败状态。
  2. Fulfilled(已成功):意味着操作成功完成,Promise有一个 resolved 值。
  3. Rejected(已失败):意味着操作失败,Promise有一个 rejection 原因。

一旦Promise从Pending状态转变为FulfilledRejected状态,就不会再改变,这个特性称为“不可变”。

创建Promise

可以使用new Promise()构造函数来创建一个Promise对象。构造函数接收一个执行器函数,执行器函数有两个参数,resolverejectresolve用于将Promise状态从Pending转变为Fulfilledreject用于将Promise状态从Pending转变为Rejected。例如:

const myPromise = new Promise((resolve, reject) => {
    setTimeout(() => {
        const success = true;
        if (success) {
            resolve('操作成功');
        } else {
            reject('操作失败');
        }
    }, 1000);
});

在这个例子中,使用setTimeout模拟一个异步操作,1秒后根据success的值决定是调用resolve还是reject

处理Promise

Promise提供了.then().catch()方法来处理异步操作的结果。.then()方法用于处理Fulfilled状态,.catch()方法用于处理Rejected状态。例如:

myPromise.then((value) => {
    console.log(value); // 输出:操作成功
}).catch((error) => {
    console.error(error);
});

也可以链式调用.then()方法,来处理多个异步操作按顺序执行的情况。比如:

function asyncOperation1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('asyncOperation1 完成');
            resolve('asyncOperation1 的结果');
        }, 1000);
    });
}

function asyncOperation2(result1) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('asyncOperation2 完成,使用 result1:', result1);
            resolve('asyncOperation2 的结果');
        }, 1000);
    });
}

function asyncOperation3(result2) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('asyncOperation3 完成,使用 result2:', result2);
            resolve('asyncOperation3 的结果');
        }, 1000);
    });
}

asyncOperation1()
   .then(asyncOperation2)
   .then(asyncOperation3)
   .then((finalResult) => {
        console.log('所有操作完成,最终结果:', finalResult);
    });

在这个例子中,asyncOperation1完成后,其结果会作为参数传递给asyncOperation2asyncOperation2的结果又会作为参数传递给asyncOperation3。这种链式调用的方式,相比于回调地狱,代码更加清晰和易于维护。

回调函数与Promise的对比

  1. 代码结构与可读性
    • 回调函数:在处理多个异步操作时,容易出现回调地狱,代码层层嵌套,逻辑复杂,可读性差。例如前面提到的多个异步操作依次执行的回调函数示例,随着操作的增加,代码会变得难以理解。
    • Promise:通过链式调用.then()方法,能够更清晰地表达异步操作的顺序和依赖关系,代码结构更加扁平,可读性大大提高。如上述Promise链式调用处理多个异步操作的示例,逻辑一目了然。
  2. 错误处理
    • 回调函数:在回调函数中,错误通常通过传递一个错误参数来处理。例如在fs.readFile的回调函数中,第一个参数err就是错误对象。但当有多个回调函数嵌套时,错误处理可能会变得混乱,每个回调函数都需要单独处理错误。
    • Promise:Promise提供了统一的.catch()方法来处理整个Promise链中的错误。只要Promise链中的任何一个Promise被reject,就会被.catch()捕获,使得错误处理更加集中和简洁。例如:
function asyncOperationWithError() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('操作出错');
        }, 1000);
    });
}

asyncOperationWithError()
   .then(() => {
        console.log('这不会被执行');
    })
   .catch((error) => {
        console.error(error); // 输出:操作出错
    });
  1. 可复用性
    • 回调函数:回调函数本身的可复用性较差,因为它通常是为特定的异步操作编写的,并且与调用它的上下文紧密相关。例如,fs.readFile的回调函数是针对文件读取操作,很难直接复用在其他异步操作上。
    • Promise:Promise对象的可复用性更强。一个Promise可以被多个.then().catch()方法处理,不同的代码部分可以基于同一个Promise进行不同的后续操作。例如,一个网络请求的Promise可以在不同的模块中被处理,用于不同的业务逻辑。
  2. 异步操作的组合
    • 回调函数:组合多个回调函数实现复杂的异步操作组合比较困难。例如,要并行执行多个异步操作并在所有操作完成后执行一个回调,使用回调函数实现起来会比较繁琐。
    • Promise:Promise提供了一些静态方法来方便地组合异步操作。比如Promise.all()方法可以并行执行多个Promise,并在所有Promise都resolve后返回一个新的Promise。示例如下:
const promise1 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise1 完成');
    }, 1000);
});

const promise2 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('Promise2 完成');
    }, 1500);
});

Promise.all([promise1, promise2])
   .then((results) => {
        console.log(results); // 输出:['Promise1 完成', 'Promise2 完成']
    });

Promise.race()方法则会返回最先resolvereject的Promise的结果,适用于只需要第一个完成的异步操作的场景。 5. 执行时机 - 回调函数:回调函数在异步操作完成后立即执行,它的执行时机取决于异步操作何时结束。例如setTimeout设置的回调函数,在指定的时间间隔后就会执行。 - Promise:Promise的then回调函数会在当前调用栈清空后执行,也就是在微任务队列中执行。这意味着它的执行时机相对回调函数会更“滞后”一些,但这种特性有助于避免阻塞主线程。例如:

console.log('开始');

new Promise((resolve) => {
    console.log('Promise 构造函数内');
    resolve();
}).then(() => {
    console.log('Promise then 回调');
});

console.log('结束');
// 输出顺序:开始,Promise 构造函数内,结束,Promise then 回调

在这个例子中,Promise构造函数内的代码会立即执行,而then回调函数会在当前调用栈中的其他同步代码(如console.log('结束'))执行完后才执行。

  1. 对异步操作的控制
    • 回调函数:回调函数对异步操作的控制相对有限。一旦异步操作开始,很难对其进行暂停、取消等操作。例如fs.readFile一旦开始读取文件,很难中途取消这个操作。
    • Promise:虽然Promise本身没有直接提供暂停和取消异步操作的方法,但通过一些库(如abortcontroller等)可以实现对Promise的取消控制。并且,Promise可以通过链式调用,方便地对异步操作进行各种组合和控制,比如在某个条件下跳过某个异步操作等。

总结二者的适用场景

  1. 回调函数适用场景
    • 简单异步操作:当异步操作比较简单,并且不存在复杂的依赖关系时,回调函数是一个简洁的选择。例如简单的事件处理,像按钮点击事件绑定回调函数,这种情况下使用回调函数代码量少,逻辑清晰。
    • 与旧代码集成:在一些老项目中,可能已经大量使用了回调函数风格的代码。为了保持代码风格的一致性,在新增的简单异步操作中继续使用回调函数也是合理的。
  2. Promise适用场景
    • 复杂异步流程:当涉及多个异步操作,并且需要按顺序执行、并行执行或有复杂的依赖关系时,Promise的链式调用和组合方法(如Promise.allPromise.race)能更好地管理异步流程,使代码更易读和维护。例如在一个电商应用中,可能需要先获取用户信息,然后根据用户信息获取用户的订单列表,再根据订单列表获取订单详情,这些异步操作使用Promise来处理会更加清晰。
    • 错误处理要求高:如果对错误处理的统一性和集中性有较高要求,Promise的.catch()方法可以方便地捕获整个Promise链中的错误,相比回调函数更有优势。在一些关键业务逻辑的异步操作中,这种统一的错误处理机制可以确保错误不会被遗漏。

通过对回调函数和Promise的深入对比,可以看出它们各有优缺点。在实际开发中,需要根据具体的业务场景和需求,选择合适的方式来处理异步操作,以提高代码的质量和可维护性。无论是回调函数还是Promise,都是JavaScript处理异步编程的重要工具,掌握它们的特性和使用方法对于开发高效、可靠的JavaScript应用至关重要。