前言
本文是写作在给团队新人培训之际, 所以其实本文的受众是对 JavaScript 的运行机制不了解或了解起来有困难的小伙伴也就是说, 其实真正的原理和本文阐述的并不完全符合, 就如中学课本和大学课本一样, 大学老师会告诉你高中的一些东西是在某些理想情况下得到的结论, 本文同理
本文的目的是希望大家阅读之后能对 JavaScript 的运行机制有一个比较直观比较快的认识, 但更重要的是自己动手实践, 只有实践才能真正发现问题和得到提升:)
想要理解 JavaScript 的运行机制, 需要分别深刻理解以下几个点:
JavaScript 的单线程机制
任务队列(同步任务和异步任务)
事件和回调函数
定时器
Event Loop(事件循环)
JavaScript 的单线程机制
JavaScript 的一个语言特性 (也是这门语言的核心) 就是单线程什么是单线程呢? 简单地说就是同一时间只能做一件事, 当有多个任务时, 只能按照一个顺序一个完成了再执行下一个
JavaScript 的单线程与它的语言用途是有关的作为一门浏览器脚本语言, JavaScript 的主要用途是完成用户交互操作 DOM 这就决定了它只能是单线程, 否则会导致复杂的同步问题
设想 JavaScript 同时有两个线程, 一个线程需要在某个 DOM 节点上添加内容, 而另一个线程的操作是删除了这个节点, 那么浏览器应该以谁为准呢?
所以为了避免复杂性, JavaScript 从诞生起就是单线程
为了提高 CPU 的利用率, html5 提出 web Worker 标准, 允许 JavaScript 脚本创建多个线程, 但是子线程完全受主线程控制, 且不得操作 DOM 所以这个标准并没有改变 JavaScript 单线程的本质
任务队列
一个接一个地完成任务也就意味着待完成的任务是需要排队的, 那么为什么会需要排队呢?
通常排队有以下两种原因:
任务计算量过大, CPU 处于忙碌状态;
任务所需的东西为准备好所以无法继续执行, 导致 CPU 闲置, 等待输入输出设备(I/O 设备)> 比如有的任务你需要 Ajax 获取到数据才能往下执行
由此 JavaScript 的设计者也意识到, 这时完全可以先运行后面已经就绪的任务来提高运行效率, 也就是把等待中的任务先挂起放到一边, 等得到需要的东西再执行就好比接电话时对方离开了一下, 这时正好有另一个来电, 于是你便把当前通话挂起, 等那个通话结束后, 再连回之前的通话
所以也就出现了同步和异步的概念, 任务也被分成了两种, 一种是同步任务(Synchronous), 另一种是异步任务(Asynchronous)
同步任务: 需要执行的任务在主线程上排队, 一个接一个, 前一个完成了再执行下一个
异步任务: 没有马上被执行但需要执行的任务, 存放在任务队列 (task queue) 中, 任务队列会通知主线程什么时候哪个异步任务可以执行, 然后这个任务就会进入主线程并被执行> 所有的同步执行都可以看作是没有异步任务的异步执行
具体来说, 异步执行如下:
所有同步任务都在主线程上执行, 形成一个执行栈(execution context stack)
也就是所有能被马上执行的任务都在主线程上排好了队, 一个接一个的被执行
主线程之外, 还存在一个任务队列 (task queue) 只要异步任务有了运行结果, 就在任务队列之中放置一个事件
也就是说每个异步任务准备好了就会立一个唯一的 flag, 这个 flag 用来标识对应的异步任务
一旦执行栈中的所有同步任务执行完毕, 系统就会读取任务队列, 看看里面有哪些事件那些对应的异步任务, 就结束等待装袋, 进入执行栈开始被执行
也就是主线程把之前的任务做完了之后, 就会来看任务队列中的 flag, 来把对应的异步任务打包来执行
主线程不断重复以上三步
只要主线程空了, 就会去读取任务队列这个过程会被不断重复, 这就是 JavaScript 的运行机制
事件和回调函数
事件
任务队列是一个事件的队列(也可以理解成是消息的队列),IO 设备完成一项任务, 就会在任务队列中添加一个时间, 表示相关的异步任务可以进入执行栈接着主线程读取任务队列, 查看里面有哪些事件
任务队列中的事件, 除了 IO 设备的事件以外, 还包括一些用户产生的事件 (比如鼠标点击页面滚动等等) 只要指定过回调函数, 这些事件发生时就会进入任务队列, 等待主线程读取
回调函数
所谓回调函数(callback), 就是那些会被主线程挂起来的代码异步任务必须指定回调函数, 当主线程开始执行异步任务, 就是执行对应的回调函数
任务队列是一个先进先出的数据结构, 排在前面的事件, 优先被主线程读取主线程的读取过程基本上是自动的, 只要执行栈一清空, 任务队列上第一位的事件就自动进入主线程但是, 如果包含定时器, 主线程首先要检查一下执行时间, 某些事件只有到了规定的时间, 才能返回主线程
Event Loop
主线程从任务队列中读取事件, 这个过程是循环不断的, 所以整个的运行机制又称为 Event Loop(事件循环)
为了更好地理解 Event Loop, 下面参照 Philip Roberts 的演讲中的一张图
上图中, 主线程在运行时, 产生了 heap(堆)和 stack(栈), 栈中的代码调用各种外部 API, 并在任务队列中加入各种事件 (click,load,done) 当栈中的代码执行完毕, 主线程就会读取任务队列, 并依次执行那些事件所对应的回调函数
执行栈中的代码 (同步任务), 总是在读取任务队列(异步任务) 之前执行
- var req = new XMLHttpRequest();
- req.open('GET', url);
- req.onload = function() {};
- req.onerror = function() {};
- req.send();
上面的代码中的 req.send 方法是 Ajax 操作向服务器发送数据, 它是一个异步任务, 意味着只有当前脚本的所有代码执行完, 系统才会去读取任务队列所以, 它与以下的写法是等价的
- var req = new XMLHttpRequest();
- req.open('GET', url);
- req.send();
- req.onload = function() {};
- req.onerror = function() {};
也就是说, 指定回调函数的部分 (onload 和 onerror), 在 send() 方法的前面或后面是无关紧要的, 因为它们属于执行栈的一部分, 系统总是执行完它们才会去读取任务队列
定时器
除了放置异步任务的事件, 任务队列还可以放置定时事件, 即指定某些代码在多少时间之后执行这叫做定时器 (timer) 功能, 也就是定时执行的代码
SetTimeout()和 setInterval()可以用来注册在指定时间之后单次或重复调用的函数, 它们的内部运行机制完全一样, 区别在于前者指定的代码是一次性执行, 后者会在指定毫秒数的间隔里重复调用:
setInterval(updateClock, 60000); //60 秒调用一次 updateClock()
因为它们都是客户端 JavaScript 中重要的全局函数, 所以定义为 Window 对象的方法
但作为通用函数, 其实不会对窗口做什么事情
Window 对象的 setTImeout()方法用来实现一个函数在指定的毫秒数之后运行所以它接受两个参数, 第一个是回调函数, 第二个是推迟执行的毫秒数 setTimeout()和 setInterval()返回一个值, 这个值可以传递给 clearTimeout()用于取消这个函数的执行
- console.log(1);
- setTimeout(function(){console.log(2);}, 1000);
- console.log(3);
上面代码的执行结果是 1,3,2, 因为 setTimeout()将第二行推迟到 1000 毫秒之后执行
如果将 setTimeout()的第二个参数设为 0, 就表示当前代码执行完 (执行栈清空) 以后, 立即执行 (0 毫秒间隔) 指定的回调函数
- setTimeout(function(){console.log(1);}, 0);
- console.log(2)
上面代码的执行结果总是 2,1, 因为只有在执行完第二行以后, 系统才会执行任务队列中的回调函数
总之, setTimeout(fn,o)的含义是, 指定某个任务在主线程最早可得的空闲时间执行, 也就是尽可能早地执行它在任务队列的尾部添加一个事件, 因此要等到同步任务和任务队列现有的事件都处理完, 才会的到执行
HTML5 标准规定了 setTimeout()的第二个参数的最小值(最短间隔), 不得低于 4 毫秒, 如果低于这个值, 就会自动增加
需要注意的是, setTimeout()只是将事件插入了任务队列, 必须等到当前代码 (执行栈) 执行完, 主线程才会去执行它指定的回调函数要是当前代码耗时很长, 有可能要等很久, 所以并没有办法保证回调函数一定会在 setTimeout()指定的时间执行
由于历史原因, setTimeout()和 setInterval()的第一个参数可以作为字符串传入如果这么做, 那这个字符串会在指定的超时时间或间隔之后进行求值(相当于执行 eval())
来源: http://www.92to.com/bangong/2018/02-07/33290232.html