2016年我花了几个月的时间学习Node.js异步编程。

最早接触Node.js是因为它很火高并发事件驱动非阻塞I/O听起来很厉害。而且用JavaScript就能写后端对于前端开发者来说很友好不用再学一门新的语言。

于是我兴冲冲地开始学习Node.js。但是学了之后才发现Node.js的异步编程真的太反直觉了和我以前写的PHP、Java完全不一样。

回调地狱、this指向、错误处理、并发控制、事件循环每一个都是坑。我踩了一个又一个的坑从入门到差点放弃。

今天就来聊聊我学习Node.js异步编程的经历以及那些年我踩过的坑。

一、回调函数:入门,就被,回调地狱,劝退了

刚开始学Node.js异步编程学的是回调函数。

Node.js的API大部分都是异步的采用回调函数的方式。比如读文件:

const fs = require('fs');

fs.readFile('file.txt', 'utf8', function(err, data) {
    if (err) {
        console.error('读文件失败:', err);
        return;
    }
    console.log('文件内容:', data);
});

刚开始觉得回调函数也没什么不就是把一个函数传给另一个函数等异步操作完成了再调用这个回调函数,吗?

但是当我需要做多个异步操作的时候问题就来了。

比如我需要先读文件A然后根据文件A的内容读文件B然后根据文件B的内容读文件C然后把三个文件的内容合并写入文件D。

用回调函数写出来就是这样的:

fs.readFile('a.txt', 'utf8', function(err, dataA) {
    if (err) {
        console.error('读文件A失败:', err);
        return;
    }
    fs.readFile('b.txt', 'utf8', function(err, dataB) {
        if (err) {
            console.error('读文件B失败:', err);
            return;
        }
        fs.readFile('c.txt', 'utf8', function(err, dataC) {
            if (err) {
                console.error('读文件C失败:', err);
                return;
            }
            const result = dataA + dataB + dataC;
            fs.writeFile('d.txt', result, 'utf8', function(err) {
                if (err) {
                    console.error('写文件D失败:', err);
                    return;
                }
                console.log('写入成功');
            });
        });
    });
});

看到了吗?这就是传说中的回调地狱(Callback Hell)。

代码一层套一层像金字塔一样往右不断延伸。缩进越来越深代码越来越难读越来越难维护。

而且错误处理也很麻烦。每一层都要写if (err),处理错误很容易漏掉某一层的错误处理导致错误被吞掉了很难调试。

还有控制流也很麻烦。比如我需要循环读多个文件然后等所有文件都读完了再做下一步。用回调函数写起来也很麻烦需要自己写计数器来判断是不是所有文件都读完了。

那时候我真的被回调地狱劝退了觉得Node.js的异步编程太反人类了代码写出来这么丑这么难维护这谁受得了啊。

二、Promise:看到了,希望,但是,也有坑

就在我差点放弃的时候我接触到了Promise。

Promise是ES6(ES2015)引入的一种异步编程的解决方案。它可以把异步操作链式调用避免回调地狱。

用Promise重写上面的例子就是这样的:

const fs = require('fs');

// 把fs.readFile包装成Promise
function readFilePromise(filename) {
    return new Promise(function(resolve, reject) {
        fs.readFile(filename, 'utf8', function(err, data) {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

// 把fs.writeFile包装成Promise
function writeFilePromise(filename, data) {
    return new Promise(function(resolve, reject) {
        fs.writeFile(filename, data, 'utf8', function(err) {
            if (err) {
                reject(err);
            } else {
                resolve();
            }
        });
    });
}

// 使用Promise
readFilePromise('a.txt')
    .then(function(dataA) {
        return readFilePromise('b.txt');
    })
    .then(function(dataB) {
        return readFilePromise('c.txt');
    })
    .then(function(dataC) {
        // 这里有个问题:dataA和dataB,在这个作用域里,访问不到
        const result = dataA + dataB + dataC; // 报错:dataA is not defined
        return writeFilePromise('d.txt', result);
    })
    .then(function() {
        console.log('写入成功');
    })
    .catch(function(err) {
        console.error('出错了:', err);
    });

看到了吗?用Promise代码变成了链式调用一层一层往下不再往右延伸了。缩进也只有一层代码清晰了很多。

而且错误处理也简单了很多。只需要在链的最后写一个,.catch(),就能捕获链中所有的错误不需要每一层都写错误处理了。

那时候我觉得Promise简直就是救星终于不用再写回调地狱了。

但是用了一段时间之后我发现Promise也有一些,坑。

1. 中间变量的问题

上面的例子就有这个问题。在Promise链中每一个,.then(),都是一个新的作用域。前面的,.then(),返回的结果只能传到下一个,.then(),不能直接传到后面的,.then()。

比如上面的例子dataA是第一个,.then(),的参数但是到了第三个,.then(),就访问不到dataA了。因为第二个,.then(),返回的是dataB所以第三个,.then(),只能拿到dataB拿不到dataA。

这就很麻烦了。如果后面的操作需要用到前面的多个结果怎么办?

有几种解决方法:

  • 把中间结果存在外部变量里。但是这样就破坏了Promise的纯函数特性也容易出问题。
  • 把多个结果打包成一个对象返回。但是这样每个,.then(),都要打包解包很麻烦。
  • 用Promise.all(),并行执行。但是只有当多个操作没有依赖关系的时候才能,用。如果有依赖关系就不能,用。

这些解决方法都不是很优雅都有各自的问题。

2. 错误处理的问题

Promise的错误处理虽然比回调函数简单了但是也有一些,坑。

比如如果你在,.then(),里面写了异步操作但是忘记了return这个Promise那么这个异步操作的错误就不会被后面的,.catch(),捕获会变成未处理的Promise拒绝(Unhandled Promise Rejection)。

比如:

readFilePromise('a.txt')
    .then(function(dataA) {
        // 忘记return了
        readFilePromise('b.txt');
    })
    .then(function(dataB) {
        // dataB是undefined
        console.log(dataB);
    })
    .catch(function(err) {
        // 读文件B的错误,不会,被,这里捕获
        console.error(err);
    });

这个问题很常见特别是新手很容易忘记return导致错误被吞掉了很难调试。

还有如果你在,.catch(),里面又抛出了错误那么这个错误需要再后面再跟一个,.catch(),才能捕获。否则也会变成未处理的Promise拒绝。

3. 并发控制的问题

Promise提供了Promise.all(),Promise.race(),Promise.allSettled(),等方法来处理并发。但是这些方法都比较基础对于复杂的并发控制比如限制并发数分批执行重试等就需要自己实现或者用第三方库比如async.js,bluebird等。

比如我需要读100个文件但是不想同时读100个怕I/O扛不住想限制同时最多读10个。用原生的Promise实现起来就比较麻烦需要自己写并发控制的逻辑。

4. 调试的问题

Promise链调试起来也比较麻烦。因为Promise链是异步的调用栈和同步代码不一样。在调试器里看调用栈会比较乱很难追踪代码的执行流程。

而且Promise的错误堆栈也不太友好有时候错误发生在某个,.then(),里面但是错误堆栈只能看到Promise的内部调用看不到具体是哪一行代码出了问题。

三、async/await:终于,写异步代码,像,写同步代码一样

就在我被Promise的各种坑折磨的时候我接触到了async/await。

async/await是ES2017引入的一种新的异步编程的语法。它是基于Promise的但是语法更简洁更直观让写异步代码像写同步代码一样。

用async/await重写上面的例子就是这样的:

async function processFiles() {
    try {
        const dataA = await readFilePromise('a.txt');
        const dataB = await readFilePromise('b.txt');
        const dataC = await readFilePromise('c.txt');
        const result = dataA + dataB + dataC;
        await writeFilePromise('d.txt', result);
        console.log('写入成功');
    } catch (err) {
        console.error('出错了:', err);
    }
}

processFiles();

看到了吗?用async/await代码变得非常简洁非常直观。就像写同步代码一样一行一行往下执行没有回调地狱没有Promise链的各种.then()。

而且中间变量的问题也解决了。dataA、dataB、dataC都在同一个作用域里可以随便访问不需要担心作用域的问题。

错误处理也很简单用try/catch就能捕获所有的错误和同步代码一样。

那时候我觉得async/await简直就是异步编程的终极解决方案终于不用再被回调地狱和Promise链折磨了。

但是用了一段时间之后我发现async/await也有一些,坑。

1. 忘记await

最常见的坑就是忘记写await。

如果你调用了一个返回Promise的函数但是忘记了写await那么你拿到的不是异步操作的结果而是一个Promise对象。这时候如果你把这个Promise对象当成结果来用就会出问题。

比如:

async function processFiles() {
    const dataA = readFilePromise('a.txt'); // 忘记await了
    console.log(dataA); // 打印的是Promise对象,不是文件内容
    const result = dataA + 'something'; // 结果是"[object Promise]something"
}

这个问题很常见特别是代码比较长比较复杂的时候很容易忘记写await。而且这个问题有时候不会立即报错会导致结果不对很难调试。

2. 串行执行导致性能问题

async/await写起来像同步代码但是它本质上还是异步的。如果你在一个循环里用await那么这些异步操作会串行执行也就是一个执行完了再执行下一个。

如果这些异步操作没有依赖关系可以并行执行那么用await串行执行就会导致性能问题浪费时间。

比如:

// 串行执行,读100个文件,需要,100个文件的时间相加
async function readFilesSerially(filenames) {
    const results = [];
    for (const filename of filenames) {
        const data = await readFilePromise(filename);
        results.push(data);
    }
    return results;
}

// 并行执行,读100个文件,只需要,最慢的那个文件的时间
async function readFilesParallel(filenames) {
    const promises = filenames.map(filename => readFilePromise(filename));
    const results = await Promise.all(promises);
    return results;
}

上面的例子串行执行需要100个文件的读取时间相加;并行执行只需要最慢的那个文件的读取时间。性能差距很大。

所以用async/await的时候一定要注意哪些异步操作可以并行执行哪些必须串行执行。可以并行的尽量用Promise.all(),并行执行提高性能。

3. 并发控制的问题

和Promise一样async/await对于复杂的并发控制比如限制并发数分批执行重试等也需要自己实现或者用第三方库。

比如我需要读100个文件限制同时最多读10个。用async/await实现起来也比较麻烦需要自己写并发控制的逻辑。

4. 错误处理的问题

async/await用try/catch处理错误很直观但是也有一些,坑。

比如如果你在try块里有多个await那么任何一个await抛出错误都会跳到catch块。但是有时候你可能想对不同的await做不同的错误处理这时候用一个大的try/catch就不太方便了。

还有如果你在catch块里又抛出了错误那么这个错误需要在调用这个async函数的地方再用try/catch捕获或者用,.catch(),捕获。否则也会变成未处理的Promise拒绝。

四、事件循环:理解了,它,才,真正,理解了,Node.js异步编程

学了回调函数、Promise、async/await我以为我已经掌握了Node.js异步编程了。但是后来我发现我只是学会了语法并没有真正理解Node.js异步编程的本质。

直到我学习了事件循环(Event Loop),才真正理解了Node.js异步编程的本质。

Node.js是单线程的基于事件驱动的。它的异步编程本质上是通过事件循环来实现的。

事件循环简单来说就是Node.js在一个单线程里不断地循环处理事件。当有异步操作的时候Node.js会把这个异步操作交给底层的线程池或者操作系统去处理然后继续执行后面的代码。当异步操作完成了会把回调函数放到事件队列里。当当前的同步代码执行完了事件循环会从事件队列里取出回调函数执行。

这个过程说起来简单但是理解起来还是有点绕的。特别是宏任务(Macrotask)和微任务(Microtask)的区别执行顺序很容易搞混。

比如下面的代码执行顺序是什么?

console.log('1');

setTimeout(function() {
    console.log('2');
}, 0);

Promise.resolve().then(function() {
    console.log('3');
});

console.log('4');

答案是:1, 4, 3, 2。

为什么?因为setTimeout是宏任务Promise.then(),是微任务。事件循环会先执行当前的同步代码(1, 4),然后执行所有的微任务(3),然后执行下一个宏任务(2)。

这个执行顺序刚开始我总是搞混后来理解了事件循环的机制才真正搞清楚了。

理解了事件循环之后我才真正理解了Node.js异步编程的本质。为什么Node.js是单线程的但是能处理高并发?为什么异步操作不会阻塞主线程?为什么回调函数会在同步代码执行完了才执行?这些问题理解了事件循环就都明白了。

五、各种踩坑经历

在学习和使用Node.js异步编程的过程中我踩了很多坑除了上面说的还有一些其他的坑。

1. this指向的问题

在回调函数里this的指向经常会出问题。

比如:

const obj = {
    name: 'test',
    sayHello: function() {
        setTimeout(function() {
            console.log('Hello, ' + this.name); // this指向的是global,不是obj
        }, 1000);
    }
};

obj.sayHello(); // 打印:Hello, undefined

这个问题是因为回调函数里的this指向的是global对象(在浏览器里是window),不是obj。

解决方法有几种:

  • 用箭头函数。箭头函数没有自己的this它的this是继承自外层作用域的。
  • 用bind(),绑定this。
  • 用一个变量保存this比如const self = this;

2. 闭包的问题

在循环里用回调函数经常会遇到闭包的问题。

比如:

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}

你可能以为会打印0, 1, 2, 3, 4但是实际上会打印5, 5, 5, 5, 5。

因为var声明的变量是函数作用域不是块级作用域。循环结束后i的值是5然后1秒后所有的回调函数才执行这时候访问的i都是5。

解决方法有几种:

  • 用let声明变量。let是块级作用域每次循环都会创建一个新的i。
  • 用立即执行函数(IIFE),创建一个新的作用域保存i的值。

3. 错误处理的问题

Node.js的异步编程错误处理一直是一个大坑。

回调函数的错误是通过回调函数的第一个参数err来传递的。但是很容易忘记处理err导致错误被吞掉了。

Promise的错误是通过,.catch(),来捕获的。但是很容易忘记写,.catch(),导致未处理的Promise拒绝。

async/await的错误是通过try/catch来捕获的。但是也容易忘记写try/catch。

而且Node.js里还有一些其他的错误比如未捕获的异常(uncaughtException),未处理的Promise拒绝(unhandledRejection),这些都需要处理否则可能会导致进程崩溃。

4. 内存泄漏的问题

Node.js的异步编程如果写得不好很容易导致内存泄漏。

比如闭包引用了大的对象导致这些对象无法被垃圾回收。比如事件监听器没有移除导致监听器越来越多。比如定时器没有清除导致定时器一直运行。

这些内存泄漏的问题刚开始可能不明显但是运行时间长了内存会越来越高最后导致进程崩溃。

六、我的反思和总结

学了这么久Node.js异步编程踩了这么多坑我有一些反思和总结。

1. 异步编程不是银弹

异步编程不是银弹不是用了异步性能就一定,好。

异步编程适合I/O密集型的应用比如Web服务器因为I/O操作很耗时用异步能让主线程不被阻塞处理更多的请求。

但是对于CPU密集型的应用异步编程就不太适合了因为CPU密集型的操作会阻塞主线程这时候异步也没用需要用多进程或者多线程来处理。

而且异步编程会增加代码的复杂度增加调试的难度。如果用得不好反而会让代码变得难以维护性能也不一定,好。

所以不要为了异步而异步要根据实际情况选择合适的编程方式。

2. 选择合适的异步编程方式

Node.js的异步编程有多种方式回调函数、Promise、async/await还有第三方库比如async.js,bluebird,co等。

不同的场景适合不同的方式。

  • 简单的异步操作回调函数就够了。
  • 复杂的异步流程Promise更合适。
  • 大多数场景async/await是最好的选择因为语法最简洁最直观。

但是不管用哪种方式都要注意错误处理并发控制性能等问题。

3. 理解本质比学会语法更重要

学异步编程不能只学会语法更要理解本质。

回调函数、Promise、async/await这些都只是语法是表象。真正的本质是事件循环是Node.js的事件驱动非阻塞I/O的机制。

只有理解了事件循环理解了Node.js异步编程的本质才能真正掌握异步编程才能在遇到各种奇怪的问题的时候知道是怎么回事怎么解决。

4. 多实践多踩坑才能真正掌握

异步编程是一门实践的学问只看书看教程是不够的必须多实践多写代码多踩坑才能真正掌握。

我刚开始学异步编程的时候看了很多教程觉得自己懂了但是一写代码就各种出错各种踩坑。后来写的代码多了踩的坑多了才慢慢真正理解了异步编程。

所以不要怕踩坑踩坑是学习的一部分。每踩一个坑你就对异步编程多了一份理解。

七、写在最后

Node.js异步编程从入门到放弃我经历了什么。

从回调地狱到Promise到async/await到事件循环我踩了一个又一个的坑从入门到差点放弃到最后慢慢理解了异步编程的本质。

现在回头看这段学习的经历虽然很痛苦但是也很值得。因为通过这段经历我不仅学会了Node.js异步编程更理解了事件驱动非阻塞I/O的思想这些对于我以后的编程都有很大的帮助。

异步编程不是银弹用好了能提高性能用不好会让代码变得难以维护。所以我们要根据实际情况选择合适的编程方式不要为了异步而异步。

而且学异步编程不能只学会语法更要理解本质。只有理解了本质才能真正掌握异步编程。

最后用一句话结尾:

"异步编程从入门到放弃再到真正理解这是每个Node.js开发者的必经之路。"

愿大家都能在踩坑中成长真正掌握异步编程。