回调函数定义

函数一等公民一节,我们详细介绍了函数一等公民的三大特性,这里我们再次强调一下:

  • 函数可以赋值给变量
  • 函数可以作为另一个函数参数进行传递
  • 函数可以从子程序中进行返回

这一小节,我们详细的讲解一下函数**可以作为另一个函数参数进行传递 **这一特性带来的一种函数编程模式 —— 回调函数。

说实话,在我们的日常编程中,如果你是一名前端开发人员,回调函数是很常见的,形如:

  1. // 我们利用 jQuery 给 id 属性值为 btn 的按钮注册一个点击事件
  2. $('#btn').click(function() {
  3. // do something
  4. });
  5. // 或者我们利用 node 的 fs api 读取一个文件
  6. fs.readFile('<目录 url>', function(err, data) {
  7. // => null, <data>
  8. });

像上面的实例代码,我们随处可见!

说了这么多,总结一句话:回调函数就是一个被作为参数传递给另一个函数的函数,这个函数会在接受参数的函数逻辑中被调用,本质上是一种函数编程的模式,因此,使用回调函数也叫做回调模式,常称其为 callback。

回调函数使用场景有很多,比如做异步处理、切面编程、函数式编程等等,在 jQuery 中使用十分广泛。

回调函数原理

上面的内容,我们对回调函数的形式、定义和使用场景大致有了一个基本的认识。那回调函数到底是怎么运行的呢?这里我们好好的来探究一下!

假象有这样一个数组 [1, 2, 3, 4, 5],我们通过遍历的方式将数组中的每个值乘以 2,最后返回新数组,注意后续可能会有不同的需求哟。当然通过 JavaScript 提供的很多工具函数都可以很好的完成,但是我们这里就要自己造一个。

  1. // 定义一个函数,接受需要处理的数组 origin 和一个定义处理逻辑的函数 callback
  2. function myMap(origin, callback) {
  3. if (!Array.isArray(origin)) return [];
  4. const newArray = [];
  5. for (let i = 0, len = origin.length; i < len; i++) {
  6. newArray.push(callback(origin[i], i))
  7. }
  8. // 返回新的数组
  9. return newArray;
  10. }
  11. // 调用测试
  12. myMap([1,2,3], function(item) {
  13. return item * 2
  14. });
  15. // 输出值
  16. [2, 4, 6]

上面的实例代码,我们简单的实现了一个 myMap 函数,功能和现有的 Array 提供的 map 函数类似。接受两个参数,一个是需要处理的数组,另一个是处理逻辑的函数,也就是我们说的回调函数。当我们通过调用测试代码调用函数时,myMap 函数中会遍历需要处理的 origin 数组,然后针对每一个数组元素调用逻辑处理函数 callback,并将每次通过调用 callback 获取的值 push 到新的数组,最后当遍历完所有的数组元素时,返回新的数组。

在这一个完整的逻辑中,逻辑处理函数 callback 针对每次 for 循环调用了,还可以通过 if-else 或 switch 等分支语句控制 callback 的调用。

我们从中可以清晰的看到三点:

  • 1、回调函数的调用和普通函数的调用方式是一样,都是通过函数名加 () 的形式;
  • 2、回调函数的传参和普通函数的传参也无二致,它依然可以在包含它的函数体内通过 arguments 对象获取参数;
  • 3、回调函数会在包含它的函数体内的某个特定时间点被“回调”(就像它的名字一样);

回调函数作用域

回调函数作用域就要从函数作用域说起了。在 ES5 之前,JavaScript 只有全局作用域和函数作用域。

  • 全局作用域指的是当前的窗口 window 作用域。在这里 ECMAScript 提供了很多底层实现的数据类型,方法和语法,构成了 JavaScript 语言体系。在非严格模式下,通过不带任何修饰符和带 var 关键字声明的变量,都会直接挂到全局 window 上。
  1. // 首先做一次验证操作,证明 window 下面没有声明 test 和 testVar 属性
  2. window.test // undefined
  3. window.testVar // undefined
  4. // 通过不添加任何修饰符和带 var 关键字分别声明变量并赋予值
  5. test = 'lane'
  6. var testVar = 'var lane'
  7. // 然后再次输出 window 下面的 test 和 testVar 属性值
  8. window.test // 'lane'
  9. window.testVar // 'var lane'

但在严格模式下就有很大的不同了。不带任何修饰符声明变量就会无效,甚至会提示报错

  1. 'use strict'
  2. myName = 'lane'; // Uncaught ReferenceError: myName is not defined
  • 函数作用域指的是声明一个函数而生成的作用域,居于全局作用域之中的一个单独的作用域。在这个作用域中可以访问外层的全局作用域,对外层作用域中的变量进行访问。

多重回调与回调地狱

在早些年,回调函数盛行的时代,很多异步处理都是通过回调函数进行处理的。比如如果你熟悉 nodejs ,你肯定用过这种方式读取文件:

  1. fs.readFile([目录], 'utf8', function() {
  2. // do something
  3. });

那如果你有多个相互依赖的文件要按照顺序进行读取,也就是说,你只有在读取完上一个文件并获取到文件信息后,然后根据文件里面的信息,去读取下一个文件,依次类推,那你的代码就应该是多重回调的形式,会写成这样了:

  1. fs.readFile([目录], 'utf8', function() {
  2. // do something
  3. fs.readFile([目录], 'utf8', function() {
  4. // do something
  5. fs.readFile([目录], 'utf8', function() {
  6. // do something
  7. // more read file operate
  8. });
  9. });
  10. });

这样一种层层嵌套的回调形式就是我们俗称的回调地狱了。也许有人会问,这样的多重回调写法不是很清晰吗,为什么会被称为回调地狱呢?虽然长得有点丑!

针对这样的疑问,我就自问自答一下吧!

  • 大家听说的地狱肯定都是同一个地狱 —— 有 18 层。在这里,虽然代码我们不可能真正写 18 层的嵌套,但是这样的嵌套 3-4 层就会令人反感了;
  • 因为是层层依赖的关系(否则你不会这样嵌套着写),前一个回调获取的信息,在后一个处理中会被用到,那稍微有一层的逻辑要修改修改,其他层的逻辑是不是也会引起连锁反应,需要修改修改;
  • 其实嵌套一两层确实没什么关系,但是层数多一层,维护的成本就会增一个等级,牵一发而动全身,想想都是很恐怖的事情;
  • 理解成本成了回调地狱的另一个问题。回调函数嵌套越深,理解起来也会越费劲。因为在理解代码的时候,特别是这种嵌套性的代码,必须一行一行去解读,对于嵌套较深的代码,可能会看到后面的忘记了前面的代码逻辑是什么,无形中增加了代码理解的成本;

当然,回调函数也不是一无是处,每种技术模式、技术方案都有其存在的道理,回调函数也是一样的。适时、合理的使用回调函数的技能也会将代码写的很优雅,比如函数优化 —— 防抖和节流,setTimeout、setInterval 定时器的使用等等很多很多场景。最后还请大家使用辩证的眼光看待每一种技术吧!