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);
});
使用匿名函数写法的优势:
- 不需要将 不通用 的代码提取出来的;如上面 不使用匿名函数 例子代码中的
print()
函数 - 限制 回调函数 的作用域,保证安全、用完即销毁,节省内存资源;如上面 使用匿名函数 例子中的 匿名函数
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
回调函数需要符合 通过参数传递的方式传入到另一个函数中 、并在某一个节点上被调用 两个特点。
-
重点2
回调函数在众多 依赖库 和 基础 API 中被大量使用。
-
重点3
回调函数是一种绝对可用的流程控制方案,使用 匿名函数 写法能更加有效控制方法的作用域,并且增强代码可读性。
-
重点4
回调函数有明显缺陷,实现流程控制时推荐使用更加现代的 Promise 和 async/await。