面试题Promise解决回调地狱

693 阅读7分钟

前言

我们知道JavaScript是一门单线程编程语言。这意味着在执行JavaScript代码时,只有一个线程负责处理任务,同一时间只能执行一个任务。这一设计选择主要是为了避免多线程环境下常见的问题,如竞态条件和数据同步问题,尤其是当涉及到操作DOM(文档对象模型)时,这能确保数据的完整性和一致性。

而为了提高代码的响应性和性能,在JavaScript中有一个异步编程的操作。在 JavaScript 的早期,异步编程的主要方法是使用回调函数。然而,随着应用程序的复杂性增加,回调函数嵌套逐渐变得不可维护,形成了“回调地狱”(callback hell)。为了解决这个问题,ES6 引入了 Promise,这是一种更优雅和强大的处理异步操作的方法。

setTimeout

setTimeout 是 JavaScript 中用于实现异步操作的一个函数。它的主要作用是安排一个函数在指定的延迟时间之后执行,从而允许后续代码立即继续执行,而不必等待这个函数完成。这种机制有助于提升代码的执行效率和响应速度。 下面是一个简单的示例,展示如何使用 setTimeout 延迟执行一个函数:

function greet() {
    console.log('Hello, world!');
}

// 将 greet 函数的执行延迟 2000 毫秒(2 秒)
setTimeout(greet, 2000);

console.log('This message will be displayed first.');

在这个示例中,setTimeout 安排 greet 函数在 2000 毫秒(2 秒)之后执行。因此,console.log('This message will be displayed first.') 会先被执行,随后 2 秒后才会输出 Hello, world!

通过这种方式,setTimeout 提供了一种简单而强大的机制来管理异步操作,使得代码执行更加灵活和高效。

回调函数

我们先来个例子

var data = null

function a(){
    setTimeout(function(){
        data='hello'
    },1000)
}

function b(){
    console.log(data);
}
a()
b()

我们的初心是想在打印出a函数里的变量data的内容,结果因为在a函数里添加了一个setTimeout函数,导致b函数比a函数先执行完。那么要想实现我们的初心该怎么办呢?请看下面代码。

var data = null

function a(){
    setTimeout(function(){
        data='hello'
        b()
    },1000)
}

function b(){
    console.log(data);
}
a()

我们直接把b函数的调用放到setTimeout函数里当data的值被修改完后再调用b函数。这个方法就是我们要说的回调函数。

回调函数:是一种在特定事件或条件发生时调用的函数,主要用于异步编程中处理完成后的操作。简单来说,当你需要将一部分代码延迟执行,或者等待某个操作(如读取文件、网络请求、动画结束等)完成后再执行时,就可以使用回调函数。

回调地狱

看完上述回调函数的使用感觉还挺方便的,那再看看下面这段代码呢?

function fetchDataFromDB(callback) {
    console.log("开始从数据库获取数据...");
    setTimeout(() => {
        callback(null, "用户数据");
    }, 2000);
}

function processData(data, callback) {
    console.log(`处理数据: ${data}`);
    setTimeout(() => {
        callback(null, `${data}处理完成`);
    }, 1500);
}

function saveProcessedData(info, callback) {
    console.log(`保存处理后的信息: ${info}`);
    setTimeout(() => {
        callback(null, "保存成功");
    }, 800);
}

function notifySuccess(message, callback) {
    console.log(`通知:${message}`);
    setTimeout(() => {
        callback(null, "通知发送成功");
    }, 500);
}

console.log("程序启动...");

fetchDataFromDB((err, userData) => {
    if (err) return console.error("数据获取错误:", err);
    processData(userData, (err, processedInfo) => {
        if (err) return console.error("数据处理错误:", err);
        saveProcessedData(processedInfo, (err, saveResult) => {
            if (err) return console.error("保存错误:", err);
            notifySuccess(saveResult, (err, notifyStatus) => {
                if (err) return console.error("通知错误:", err);
                console.log(notifyStatus);
            });
        });
    });
});

console.log("主程序继续执行,不等待上述异步操作...");

看到这串代码就不简单了吧。这段代码模拟了一个更复杂的异步操作链,从数据库获取数据开始,到数据处理、保存处理后的数据,最后发送成功通知,每一个步骤都依赖于前一个步骤的结果,并且通过回调函数来控制流程。这正是“回调地狱”的典型特征:

  1. fetchDataFromDB: 模拟从数据库获取数据的异步操作,完成后调用回调函数传递数据。
  2. processData: 接收从数据库获取的数据,处理数据后,通过回调传递处理结果。
  3. saveProcessedData: 保存处理后的数据,并在完成时回调通知。
  4. notifySuccess: 发送成功通知,最后通过回调告知通知发送状态。

随着每个步骤都需要等待前一个异步操作完成,代码向右缩进得越来越深,不仅阅读困难,而且修改和维护成本极高。如果还需添加新的操作或错误处理逻辑,代码的复杂度会成倍增长,这就是为什么这种编程模式被称为“回调地狱”。 所以为了避免我们碰到这种恐怖的地狱回调,就要使用ES6给我们提供的一种方法了——Promise。

Promise

Promise的基本概念

Promise 是一个代表未来将要完成的操作的对象。它可以处于三种状态之一:

  1. Pending(等待) :初始状态,操作尚未完成。
  2. Fulfilled(完成) :操作成功完成。
  3. Rejected(拒绝) :操作失败。

Promise 提供了 .then().catch() 方法,分别用于处理操作成功和失败的情况。Promise 的链式调用可以避免回调地狱的问题。

使用Promise重写回调地狱

下面我们就用Promise来重写上面的地狱回调

function fetchDataFromDB() {
    return new Promise((resolve, reject) => {
        console.log("开始从数据库获取数据...");
        setTimeout(() => {
            resolve("用户数据");
        }, 2000);
    });
}

function processData(data) {
    return new Promise((resolve, reject) => {
        console.log(`处理数据: ${data}`);
        setTimeout(() => {
            resolve(`${data}处理完成`);
        }, 1500);
    });
}

function saveProcessedData(info) {
    return new Promise((resolve, reject) => {
        console.log(`保存处理后的信息: ${info}`);
        setTimeout(() => {
            resolve("保存成功");
        }, 800);
    });
}

function notifySuccess(message) {
    return new Promise((resolve, reject) => {
        console.log(`通知:${message}`);
        setTimeout(() => {
            resolve("通知发送成功");
        }, 500);
    });
}

console.log("程序启动...");

fetchDataFromDB()
    .then(processData)
    .then(saveProcessedData)
    .then(notifySuccess)
    .then(result => console.log(result))
    .catch(error => console.error("发生错误:", error));

console.log("主程序继续执行,不等待上述异步操作...");

Promise的优势

Promise相对于回调函数有以下几个显著的优势:

  1. 避免回调地狱:Promise通过链式调用避免了回调函数的深度嵌套,使代码更易于阅读和维护。
  2. 错误处理更加优雅:Promise通过.catch()方法统一处理错误,而不需要在每个回调函数中进行错误处理。
  3. 更好的控制流程:Promise可以更方便地处理一系列异步操作,使得代码逻辑更加清晰。

Promise.then

Promise.then 方法用于在Promise对象成功执行后处理结果。它接受两个参数:

  1. onFulfilled: 当Promise成功时执行的函数,接收Promise的结果。
  2. onRejected: 当Promise失败时执行的函数,接收Promise的错误。

下面是一个简单的示例,展示如何使用 then 方法处理成功的异步操作:

let promise = new Promise((resolve, reject) => {
    let success = true; // 模拟异步操作的结果
    if (success) {
        resolve("操作成功");
    } else {
        reject("操作失败");
    }
});

promise.then((message) => {
    console.log(message); // 输出 "操作成功"
}).catch((error) => {
    console.error(error);
});

在这个示例中,如果异步操作成功,then 方法中的回调函数会被调用并输出 "操作成功"。如果操作失败,错误会被 catch 方法捕获并输出 "操作失败"。

Promise.catch

Promise.catch 方法用于在Promise对象失败时处理错误。它只接受一个参数:

  1. onRejected: 当Promise失败时执行的函数,接收Promise的错误。

下面是一个示例,展示如何使用 catch 方法处理失败的异步操作:

let promise = new Promise((resolve, reject) => {
    let success = false; // 模拟异步操作的结果
    if (success) {
        resolve("操作成功");
    } else {
        reject("操作失败");
    }
});

promise.then((message) => {
    console.log(message);
}).catch((error) => {
    console.error(error); // 输出 "操作失败"
});

在这个示例中,如果异步操作失败,catch 方法会捕获错误并输出 "操作失败"。如果操作成功,成功的消息会被 then 方法中的回调函数处理。

总结

JavaScript作为一门单线程语言,通过引入异步编程提升了响应性和性能。在早期,回调函数是主要的异步编程方式,但随着复杂度的增加,回调函数逐渐演变成“回调地狱”。ES6引入的Promise提供了一种优雅且强大的方式来处理异步操作,不仅避免了回调地狱,还提供了更好的错误处理和流程控制机制。

通过Promise,开发者可以更轻松地编写和管理异步代码,提高开发效率和代码质量。