引子
前几天学校的交流群里面讨论 JavaScript 回调函数, 有个同学提出了一个观点: 回调函数就是异步执行的!
看到这个观点, 我想了想我使用回调函数的场景, 还真都是异步的, 一时竟觉得他说得很有道理.
当然, 这句话本身, 当然是 错的 , 在 JavaScript 中函数作为一等公民, 可以在任何地方定义, 在函数内或函数外, 可以作为函数的参数和返回值, 基于这个基本事实, 就可以写出高阶函数.
接受或者返回一个函数的函数称为高阶函数
常用的内置高阶函数例如 Array 对象的 forEach,map 等, 函数组合 https://github.com/mqyqingfeng/Blog/issues/45 , 函数柯里化 https://github.com/mqyqingfeng/Blog/issues/42 , 回调模式都属于高阶函数. 实际上, JavaScript 是有能力进行函数式编程的(FED 关于函数式编程的文章 http://taobaofed.org/blog/2017/03/16/javascript-functional-programing/), 这里就不深入讨论了.
有点扯远了, 回到刚刚那个同学观点, 和明显, 用 forEach 遍历一下数组, 并不是异步的, 那么为什么提到回调函数的时候, 很容易和异步联系起来呢? 这个问题引起了我的思考, 回调和异步到底有什么样的渊源呢?
单线程的 JavaScript
说起异步, 就要先说说 JavaScript 运行机制. 我们知道, JavaScript 是单线程执行的, 意味着同一个时间点, 只有一个任务在运行. 单线程就意味着, 所有任务需要排队, 前一个任务结束, 才会执行后一个任务. 如果前一个任务耗时很长, 后一个任务就不得不一直等着.
从诞生起, JavaScript 就是单线程, 这已经成了这门语言的核心特征, 将来也不会改变.
为什么需要异步?
单线程的好处是实现起来比较简单, 执行环境相对单纯; 坏处是只要有一个任务耗时很长, 后面的任务都必须排队等着, 会拖延整个程序的执行. 常见的浏览器无响应(假死), 往往就是因为某一段 Javascript 代码长时间运行(比如死循环), 导致整个页面卡在这个地方, 其他任务无法执行.
为了解决这个问题, Javascript 语言将任务的执行模式分成两种: 同步 (Synchronous) 和异步(Asynchronous).
关于异步处理, 现在已经有了许多好的解决方案. 如果想全面了解如何处理异步,@肖鸡已经写了一篇非常全面的文章 JS 中异步的解决方案.
JavaScript 执行机制
在谈异步之前, 先来说说 JavaScript 的执行机制, 看下面这段代码
- function foo () {
- return foo();
- }
- foo();
- // Uncaught RangeError: Maximum call stack size exceeded
这代码里面抛出了一个错误, 意思是超过最大调用堆栈大小, 那么这个 call stack 是什么呢?
call stack: 执行 JavaScript 的主线程分为 heap 和 stack,stack 是一个执行环境上下文.
stack https://zh.wikipedia.org/wiki/堆栈 是一种数据结构, 数据先入后出, 后入先出. 执行 JavaScript 的 call stack, 也是如此.
从上图的例子可以看出调用栈的变化
main(js 文件可以视作一个 main 函数) -> printSquare(内部调用了 square, 因此需要把 square 推入栈中) -> square(内部调用了 multiply, 推入栈中) -> multiply
此时所依赖的函数都在栈中, 那么可以执行了, 执行顺序和栈是一致的, 后入先出(执行), 所以顺序为
multiply -> square -> printSquare.
call stack 也是有最大限制的, 可以使用下面的代码测试一下浏览器的最大 call stack size
- var i = 0;
- function inc() {
- i++;
- inc();
- }
- inc();
- //VM202:2 Uncaught RangeError: Maximum call stack size exceeded
- i // 15720
理解 JavaScript 的函数调用方式, 对于理解递归, 高阶函数, 异步函数等都是非常有帮助的.
以递归为例, 递归函数不断调用自身, 那么就会不断向 call stack 中推入函数, 直到达到递归条件(此时函数不再调用自身), 然后再按后进先出的原则依次执行 stack 中的函数.
异步的实现
异步的实现我分为三部分来理解: webApi, 任务队列, event loop
webApi
先来列举一下 JavaScript 中的异步任务, 现在先限定在浏览器中, 可以得出以下结果:
dom 事件
定时器 setTimeout,setInterval
XMLHttpRequest
可以发现, 这些都是都是浏览器的一些 api, 也就是 webApi. 其实异步的实现是浏览器来处理的, 主线程并不用管异步时如何实现的.
事实上, 浏览器是多进程的, 所以可以开多个线程来处理异步行为, 并在任务完成时同步到任务队列
任务队列
看下面这段代码, setTimeout 指定的函数 0ms 后输出, 但是最后才执行
- console.log(1);
- setTimeout(() => {console.log('after 0ms')} ,0);
- console.log(2);
- console.log(3);
- // 1
- // 2
- // 3
- // after 0ms
因为 setTimeout 的函数经过 webApi,0ms 后定时器执行并将回调函数放到 task queue, 当 call stack 中的代码执行完毕时, 主进程不断查看 task queue 中的任务, 如果有任务就取出并放到 call stack 中执行.
setTimeout 的定时是不准确的, 因为当前 call stack 执行任务时, 定时器的回调就会一直在 task queue 中等待
对于其他的异步 api, 如 dom 事件, ajax 请求等, 都是同样的原理, 当异步事件执行完毕, 就会把相应的回调函数放到 task queue 中.
task queue 中的任务需要反复轮询, 查看是否有任务已完成, 这个轮询就是 event loop
Event loop
event loop 经常用类似如下的方式来实现
- while (queue.waitForMessage()) {
- queue.processNextMessage();
- }
如果当前没有任何消息 queue.waitForMessage 会等待同步消息到达.
异步和回调的关系
说到现在, 异步和回调的关系已经很明确了.
异步: 通过 webApi 创建异步任务. 任务完成时, 如果有指定了回调函数, 将回调函数放入 task queue 中; 如果没有指定回调函数, 这个事件就被丢弃.
回调函数: 定义了异步任务完成时所要执行的操作, 包括事件和定时器所指定的异步任务.
避免同步阻塞的代码
像深度循环, 同步的 ajax 请求等任务会非常耗时, 主线程有代码执行时, task queue 中的代码就会一直处于等待状态, 此时浏览器无法进行任何交互和操作, 页面就相当于挂掉了.
nodeJS 中的异步
node 作为一个事件驱动, 无阻塞 IO 的运行时, 也是使用了事件循环. 但是 node 和浏览器的实现有很多不同, 下次可以再水一篇 node 文章(逃
来源: http://www.open-open.com/lib/view/open1514205841359.html