likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 10

callback 回调函数

1. 前言

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

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

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

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

2. 回调函数

2.1 基石

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

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

2.2 JavaScript 的异步机制

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

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

结果:

start
running
end

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

function runningFn(){
    console.log('running');
}

console.log('start');

setTimeout(runningFn, 1000);

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

结果:

start
end
running

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

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

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

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

2.3 什么是回调函数

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

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

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

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

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

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

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

function runningFn(){
    console.log('running');
}

console.log('start');

setTimeout(runningFn, 1000);

console.log('end');

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

function runningFn(cb){
    console.log('running');
    cb();
}

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

console.log('start');

setTimeout(runningFn, 1000, callback);
start
running
end

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

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

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

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

4.1 匿名函数

JavaScript 语言支持 匿名函数

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

function() {
    console.log('我是一个匿名函数')
}

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

如果不使用匿名函数:

function print(item) {
    console.log(item);
}

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

如果使用匿名函数:

[1, 2, 3, 4, 5].forEach(function (item) {
    console.log(item);
});

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

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

4.2 时代背景

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

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

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

// 定义回调函数
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 由上而下逐行解释执行,如果代码变成这样

// 定义主函数,并传入回调函数
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 还没被定义

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

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

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

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

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

// 定义主函数,并传入回调函数
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 如何传入函数

例子回顾:

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

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

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

如果对于 callbackFncallbackFn() 分不清的新手,在编写自己的回调函数时,可能会传入错误的参数,如: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 的值为 undefinedundefined 不是函数,不能执行 undefined(),导致报错 callbackFn(...) is not a function

结果打印:

undefined
Uncaught TypeError: callbackFn(...) is not a function

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

分别打印一下 callbackFncallbackFn()

function callbackFn(content) {
    console.log(content);
}

console.log(callbackFn);
console.log(callbackFn());
ƒ callbackFn(content) {
    console.log(content);
}
undefined

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

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

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

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

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

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

5.2 小结

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

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

6. 常用 API 例子

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

6.1 Array 数组类型

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

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

arr.forEach(readItem);

6.2 fs 文件系统

文件系统中的所有异步接口都支持回调函数

6.3 lodash 工具库

const _ = require('lodash');

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

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

_.each(arr, readItem);

6.4 request 网络请求库

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 回调地狱

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

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

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 异常捕获

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

function callbackFn(content) {
    console.log(content);
}

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

async(callbackFn);

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

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 不合法,实际执行结果:

Uncaught Error: content can not be null or undefined

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

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.catchasync/awaittry/catch 捕获。

8. 小结

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

重点如下:

  1. 重点1

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

  2. 重点2

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

  3. 重点3

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

  4. 重点4

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

转载自:https://juejin.cn/post/7350107540327038985
评论
请登录