【悄咪咪学Node.js】3. callback 回调函数

callback 回调函数

1. 前言

本节课将会引导大家学习了解:

  • 什么是回调函数
  • 回调函数在 Node.js 里的地位
  • 了解回调函数的运行机制
  • 了解回调函数的劣势

学习完本节课程后,应该具有:

  • 辨识哪些是回调函数的能力
  • 使用应用回调函数的 API
  • 通过编写回调函数,控制程序执行流程、提取公共方法的能力

2. 回调函数

2.1 基石

JavaScript 语言将 函数 理解成 对象 ,这使得 函数 能作为 参数 在函数间传递。

这种特性还影响了很多 Node.js 库的 API ,使得部分工具对外开放的异步接口(甚至部分同步接口)都使用回调函数来做流程控制。

2.2 JavaScript 的异步机制

JavaScript 是单线程执行语言且只有一个执行流,具体表现在一次只执行一条指令。

javaScript 复制代码
console.log('start');
console.log('running');
console.log('end');
  • 在第一行,打印start,表示任务开始执行。
  • 在第二行,打印running,表示任务正在执行。
  • 在第三行,打印end,表示任务结束。

结果:

shell 复制代码
start
running
end

但是在实际生产开发中,并不是所有语句执行都特别迅速,如网络 I/O、文件 I/O 等。我们在running加上一个等待时间,使得任务执行时间延长到1秒,来模拟一下这些执行时间较长的操作。

javaScript 复制代码
function runningFn(){
    console.log('running');
}

console.log('start');

setTimeout(runningFn, 1000);

console.log('end');
  • 在第一行,打印start,表示任务开始执行。
  • 在第三至五行,延迟1秒后打印running,表示任务正在执行。
  • 在第三行,打印end,表示任务结束。

结果:

shell 复制代码
start
end
running

结果却发现running在最后打印,这里发生的就是异步,即在setTimeout定时器被定义后,没有停留并等待,而是去执行打印end。然后在 1 秒计时结束后,再转过头来打印running。

我们不难发现,JavaScript 在执行简单、迅速的任务时,是由上至下顺序逐条执行的。而在耗时较长的地方,偏向于 不阻塞 后面的代码执行,这样的好处是减少耗时长的任务造成的假死问题。比如说:

我们使用 Node.js 写出一个大小有数百 M 的文件(txt、excel 等),如果性能稍差的机器可能要耗费十几秒甚至几十秒。如果我们要等写出完成再执行后面的任务,或者等写出完成后再返回给页面,等待的那段时间就很可能会被感觉程序假死了。

那么问题来了: 上面的代码,怎么控制流程使得打印正常呢(按 start,running,end 正常打印)?

2.3 什么是回调函数

回调函数是一个在另一个函数完成执行后,所执行的函数------故此得名"回调"。

回调函数有两点重要的特征:

  • 它通过参数传递的方式传入到另一个函数中
  • 并在某一个节点上被调用
js 复制代码
// 定义回调函数
function callbackFn(content) {
    console.log(content);
}

// 定义主函数,并传入回调函数
function say(words, cb) {
    let content = 'I said "' + words + '".';
    
    cb(content); // 调用回调函数
}

// 调用主函数,传入回调函数
say('Hello world!', callbackFn); 
shell 复制代码
I said "Hello world!".
  • 第二行,定义回调函数
  • 第三行,回调函数负责把 内容 输出到控制台
  • 第七行,定义主函数,接收 未重组内容 和 回调函数
  • 第八行,重新组装内容
  • 第十行,调用回调函数,并将 已重组内容 传入 回调函数 中
  • 第十四行,调用主函数,并传入 未重组内容 ,主函数 say() 会先将 未重组内容 通过 let content = 'I said "' + words + '".'; 重组,再传入到 回调函数 cb() 中。

在这个例子中,callbackFn 符合上述两点特征,故我们称 callbackFn 就是回调函数。

现在我们来回顾一下上面的例子:

javaScript 复制代码
function runningFn(){
    console.log('running');
}

console.log('start');

setTimeout(runningFn, 1000);

console.log('end');

利用回调函数使其按正确顺序打印:

js 复制代码
function runningFn(cb){
    console.log('running');
    cb();
}

function callback() {
    console.log('end');
}

console.log('start');

setTimeout(runningFn, 1000, callback);
shell 复制代码
start
running
end

3. 为什么要学习回调函数

学习 回调函数 是为了能使用前人给我们造好的轮子,融入这个庞大的 Node.js 生态之中,减轻我们的开发压力。

在自己编写代码时,虽然推荐使用更加现代的 Promise 来做流程控制,但是多学一种流程控制方法,也给自己编写代码留出更多选择的空间。

4. 为什么回调函数遍地开花

4.1 匿名函数

JavaScript 语言支持 匿名函数。

匿名函数 是指没有定义函数名的函数,比如:

js 复制代码
function() {
    console.log('我是一个匿名函数')
}

而使用 匿名函数 可以使 回调函数 的写法更加优雅。

如果不使用匿名函数:

js 复制代码
function print(item) {
    console.log(item);
}

[1, 2, 3, 4, 5].forEach(print);

如果使用匿名函数:

js 复制代码
[1, 2, 3, 4, 5].forEach(function (item) {
    console.log(item);
});

使用匿名函数写法的优势:

  1. 不需要将 不通用 的代码提取出来的;如上面 不使用匿名函数 例子代码中的 print() 函数
  2. 限制 回调函数 的作用域,保证安全、用完即销毁,节省内存资源;如上面 使用匿名函数 例子中的 匿名函数

4.2 时代背景

回调函数作为 Node.js 初期为数不多的流程控制方法,被广泛使用也是一个在时代环境下没有更多选择的妥协。

5. 回调函数是怎么运作的

我们再看一次上面的代码例子

js 复制代码
// 定义回调函数
function callbackFn(content) {
    console.log(content);
}

// 定义主函数,并传入回调函数
function say(words, cb) {
    let content = 'I said "' + words + '".';
    
    cb(content); // 调用回调函数
}

// 调用主函数,传入回调函数
say('Hello world!', callbackFn); 

5.1 常见问题

5.1.1 提前声明回调函数

由于 JavaScript 由上而下逐行解释执行,如果代码变成这样

js 复制代码
// 定义主函数,并传入回调函数
function say(words, cb) {
    let content = 'I said "' + words + '".';

    cb(content); // 调用回调函数
}

// 调用主函数,传入回调函数
say('Hello world!', callbackFn);

// 以变量的方式定义回调函数
const callbackFn = function (content) {
    console.log(content);
}

当在第九行,执行 say('Hello world!', callbackFn); 时,会由于 callbackFn未定义而报错。

这是由于当以变量的方式定义函数时,在执行到 say() 时,callbackFn 还没被定义

但是提前声明回调函数可能会在后期维护时加大阅读难度,我们可以用刚刚学到的 匿名函数 优化编写结构:

js 复制代码
// 定义主函数,并传入回调函数
function say(words, cb) {
    let content = 'I said "' + words + '".';
    
    cb(content); // 调用回调函数
}

// 调用主函数,传入匿名回调函数
say('Hello world!', function (content) {
    console.log(content);
}); 

Tips:这种写法提升了代码可读性,并且缩小了回调函数的作用域。

但如果我们使用 function 关键字后置定义 callbackFn,却能发现程序能正常运行

js 复制代码
// 定义主函数,并传入回调函数
function say(words, cb) {
    let content = 'I said "' + words + '".';
    
    cb(content); // 调用回调函数
}

// 调用主函数,传入回调函数
say('Hello world!', callbackFn); 

// 定义回调函数
function callbackFn(content) {
    console.log(content);
}

这是因为 JavaScript 在运行初期会将所有 function 关键字标识的函数提前声明。

5.1.2 如何传入函数

例子回顾:

js 复制代码
// 定义回调函数
function callbackFn(content) {
    console.log(content);
}

// 定义主函数,并传入回调函数
function say(words, cb) {
    let content = 'I said "' + words + '".';
    
    cb(content); // 调用回调函数
}

// 调用主函数,传入回调函数
say('Hello world!', callbackFn); 

如果对于 callbackFn、callbackFn() 分不清的新手,在编写自己的回调函数时,可能会传入错误的参数,如:say('Hello world!', callbackFn());

要一劳永逸地解决这个问题,就需要清楚理解这个时候 应该传入什么 、而我们 传入了什么。

如果我们在 第十四行 执行的是 say('Hello world!', callbackFn());,这里传入的是 callbackFn 函数的返回值。

执行流程如下:

  • 第十四行,say() 函数第二位传值为 callbackFn 的执行返回值,callbackFn 被立即执行。
  • 第三行,callbackFn 被调用时没有传入参数,content 为默认值 undefined,控制台打印出 undefined。
  • 第四行,callbackFn 函数执行完成,没有返回值。
  • 由于 callbackFn 函数没有 return 任何内容,该函数的返回值为 undefined。
  • 第十四行,实际调用结果为 say('Hello world!', undefined);。
  • 第十行,由于变量 cb 的值为 undefined。undefined 不是函数,不能执行 undefined(),导致报错 callbackFn(...) is not a function。

结果打印:

shell 复制代码
undefined
Uncaught TypeError: callbackFn(...) is not a function

我们能明确知道,cb 必须是一个函数。

分别打印一下 callbackFn、callbackFn()

js 复制代码
function callbackFn(content) {
    console.log(content);
}

console.log(callbackFn);
console.log(callbackFn());
shell 复制代码
ƒ callbackFn(content) {
    console.log(content);
}
undefined

由此可见,callbackFn 的值是方法本身;callbackFn() 的值是callbackFn 函数的返回值 undefined。

下面给出一个例子,如果能看懂这个例子,并成功预测出返回值,那就能判断出是否理解这个点:

js 复制代码
// 定义回调函数生成器
function callbackGenerator() {
    // 返回回调函数
    return function(content) {
        console.log(content);
    }
}

// 定义主函数,并传入回调函数
function say(words, cb) {
    let content = 'I said "' + words + '".';
    
    cb(content); // 调用回调函数
}

// 调用主函数,传入回调函数
say('Hello world!', callbackGenerator()); 
shell 复制代码
I said "Hello world!".

callbackGenerator 的函数返回值才是 cb 所真正需要的回调函数。

5.2 小结

回调函数必须是一个函数。

回调函数的运作逻辑,就是用尽一切方法,将实际所需要 回调函数 传递给 主函数 。并让 主函数 控制 回调函数 实际被调用的 时间点 和 传参。

6. 常用 API 例子

在这里,举例说明一下 回调函数 的广泛使用,这里出现的只是冰山一角中的冰山一角。

6.1 Array 数组类型

js 复制代码
let arr = [1, 2, 3, 4, 5];

function readItem(item, idx) {
    console.log('第' + idx + '个元素是:' + item);
}

arr.forEach(readItem);

6.2 fs 文件系统

复制代码
文件系统中的所有异步接口都支持回调函数
js 复制代码
const fs = require('fs'),
      path = './juejin.txt';
      
function readFile(err, content) {
    if (err) {
        console.error(err);
    } else {
        console.log(content);
    }
}

fs.readFile(path, 'utf-8', readFile);

6.3 lodash 工具库

js 复制代码
const _ = require('lodash');

let arr = [1, 2, 3, 4, 5];

function readItem(item, idx) {
    console.log('第' + idx + '个元素是:' + item);
}

_.each(arr, readItem);

6.4 request 网络请求库

js 复制代码
const request = require('request');

function callbackFn (error, response, body) {
    console.error('error:', error);
    console.log('statusCode:', response && response.statusCode);
    console.log('body:', body);
}

request('http://www.google.com', callbackFn);

Tips: 由此可见,回调函数在依赖库、甚至是基础的 API 中都被广泛使用。

7. 缺点

7.1 回调地狱

回调函数虽然好用,但如果无限制地嵌套下去就会出现回调地狱。

我们先来认识一下什么是回调地狱:

js 复制代码
task1(function(result1) {
    // todo
    let sum = result1;
    ...
    
    task2(function(result2) {
        // todo
        sum += result2;
        ...
        
        task3(function(result3) {
            // todo
            sum += result3;
            ...
            
            task4(function(result4) {
                // todo
                sum += result4;
                ...
            })
        })
    })
})

回调地狱 就是 多层回调嵌套。而这样的多层回调嵌套的复杂流程控制在实际开发中不可避免。

回调地狱 会使开发难度和维护难度大大提升,这是由于多层嵌套后的回调函数可读性大大降低。

7.2 异常捕获

因为 回调函数 常用在对 异步 做流程控制中,所以实际上代码更像:

js 复制代码
function callbackFn(content) {
    console.log(content);
}

function async(cb) {
    let content = 'Hello world!';
    setTimeout(cb, 1000, content);
}

async(callbackFn);

在实际生产中,应该尽量捕获任何可能出现的异常,所以代码修改为:

js 复制代码
let content = 'Hello world!';

function callbackFn(content) {
    if (content) {
        console.log(content);
    } else {
        throw new Error('content can not be null or undefined');
    }
}

function async(cb, content) {
    setTimeout(cb, 1000, content);
}

try {
    async(callbackFn, content);
} catch(err) {
    console.warn(err);
}

期望一旦 content 未定义或没有赋值的情况下,抛出异常并捕获打印。

但是这段代码执行不符合预期。

如果 content 不合法,实际执行结果:

shell 复制代码
Uncaught Error: content can not be null or undefined

这表明该异常并没有被捕获 ,这使得 回调函数 的异常处理需要做到 每个回调函数 之中。 代码如下:

js 复制代码
let content = 'Hello world!';

function callbackFn(content) {
    try {
        if (content) {
            console.log(content);
        } else {
            throw new Error('content can not be null or undefined');
        }
    } catch(err) {
        console.warn(err);
    }
}

function async(cb, content) {
    setTimeout(cb, 1000, content);
}

async(callbackFn, content);

一旦 回调函数 多起来,甚至陷入 回调地狱,这种异常处理将会非常复杂,且难以维护。

7.3 解决方法

回调地狱 可通过使用 Promise 的.then链 或 async/await 语法糖避免。

异常捕获 可通过使用 Promise 的.catch 或 async/await 加 try/catch 捕获。

8. 小结

本节课程我们主要学习了 什么是回调函数 、如何辨识回调函数 、回调函数的缺点 ,也知道了 回调函数被普遍使用的原因 以及其在实际开发中的 重要的地位。

重点如下:

  1. 重点1

    回调函数需要符合 通过参数传递的方式传入到另一个函数中 、并在某一个节点上被调用 两个特点。

  2. 重点2

    回调函数在众多 依赖库 和 基础 API 中被大量使用。

  3. 重点3

    回调函数是一种绝对可用的流程控制方案,使用 匿名函数 写法能更加有效控制方法的作用域,并且增强代码可读性。

  4. 重点4

    回调函数有明显缺陷,实现流程控制时推荐使用更加现代的 Promise 和 async/await。

相关推荐
Dumbness9 小时前
不发 npm 也能 npx:用 github: 协议分发一个零依赖 CLI
node.js
Patrick_Wilson14 小时前
npm 的 bin 如何接到 npm run 与 npx
npm·node.js·前端工程化
Patrick_Wilson15 小时前
import 之后发生了什么:package.json、解析与安装产物
npm·node.js·前端工程化
Patrick_Wilson15 小时前
npm 里 SemVer 和 dist-tag 如何决定装哪一版
npm·node.js·前端工程化
Dumbness1 天前
Node 22 下 node --test test/ 报 MODULE_NOT_FOUND:内置测试运行器的文件匹配规则
node.js·测试
Amumu121381 天前
Node学习: 网络基础概念 + HTTP模块
网络·node.js
sumitrmardy1 天前
写了一个邮箱验证 API 之后,我才知道的几个 SMTP 冷知识
node.js
zttbee1 天前
npm install卡顿
前端·npm·node.js
无责任此方_修行中1 天前
如何不用括号调用 JS 的函数?
前端·javascript·node.js
无责任此方_修行中2 天前
Deno 的八年:一场没逃出 Node 引力的运行时起义
前端·javascript·node.js