整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

JavaScript运行机制

JavaScript运行机制

、JavaScript是单线程

为什么是单线:因为JavaScript主要用于DOM操作和用户交互,如果说是多线程,一个线程要在某个DOM节点上添加元素,另一个线程是要删除这个节点。这样就存在一个问题,到底是以哪个线程为主呢?为了避免复杂性,设计者一开始就把它设定为单线程。

?二、任务队列

因为是单线程,所以JavaScript执行任务就需要一个一个的来,这样就造成大量任务排队执行,效率很低,为了解决这个问题就提到了同步任务+异步任务。

同步任务:就是主线程执行的任务。

异步任务:异步任务是不进入主线程,而是进入“任务队列”中。

JavaScript运行机制:就是主线程执行的是同步任务,主线程执行完毕后执行任务队列中的事件(任务队列中放置的是异步任务)

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)

三、异步任务有哪些

定时器、网络请求(http)、Promise、I/o、UI渲染

所周知,JavaScript 是一门单线程语言,虽然在 html5 中提出了 Web-Worker ,但这并未改变 JavaScript 是单线程这一核心。可看HTML规范中的这段话:

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.

为了协调事件、用户交互、脚本、UI 渲染和网络处理等行为,用户引擎必须使用 event loops。Event Loop 包含两类:一类是基于 Browsing Context ,一种是基于 Worker ,二者是独立运行的。 下面本文用一个例子,着重讲解下基于 Browsing Context 的事件循环机制。

来看下面这段 JavaScript 代码:

 
setTimeout(function() {
 console.log('setTimeout');
}, 0);//前端全栈交流学习圈:866109386
//帮助1-3年前端人员,突破技术,提升思维 
Promise.resolve().then(function() {
 console.log('promise1');
}).then(function() {
 console.log('promise2');
});
 
console.log('script end');

先猜测一下这段代码的输出顺序是什么,再去浏览器控制台输入一下,看看实际输出的顺序和你猜测出的顺序是否一致,如果一致,那就说明,你对 JavaScript 的事件循环机制还是有一定了解的,继续往下看可以巩固下你的知识;而如果实际输出的顺序和你的猜测不一致,那么本文下面的部分会为你答疑解惑。

任务队列

所有的任务可以分为同步任务和异步任务,同步任务,顾名思义,就是立即执行的任务,同步任务一般会直接进入到主线程中执行;而异步任务,就是异步执行的任务,比如ajax网络请求,setTimeout 定时函数等都属于异步任务,异步任务会通过任务队列( Event Queue )的机制来进行协调。具体的可以用下面的图来大致说明一下:



同步和异步任务分别进入不同的执行环境,同步的进入主线程,即主执行栈,异步的进入 Event Queue 。主线程内的任务执行完毕为空,会去 Event Queue 读取对应的任务,推入主线程执行。 上述过程的不断重复就是我们说的 Event Loop (事件循环)。

在事件循环中,每进行一次循环操作称为tick,通过阅读规范可知,每一次 tick 的任务处理模型是比较复杂的,其关键的步骤可以总结如下:

  • 在此次 tick 中选择最先进入队列的任务( oldest task ),如果有则执行(一次)
  • 检查是否存在 Microtasks ,如果存在则不停地执行,直至清空Microtask Queue
  • 更新 render

主线程重复执行上述步骤

可以用一张图来说明下流程:



这里相信有人会想问,什么是 microtasks ?规范中规定,task分为两大类, 分别是 Macro Task (宏任务)和 Micro Task(微任务), 并且每个宏任务结束后, 都要清空所有的微任务,这里的 Macro Task也是我们常说的 task ,有些文章并没有对其做区分,后面文章中所提及的task皆看做宏任务( macro task)。

(macro)task 主要包含:script( 整体代码)、setTimeout、setInterval、I/O、UI 交互事件、setImmediate(Node.js 环境)

microtask主要包含:Promise、MutaionObserver、process.nextTick(Node.js 环境)

setTimeout/Promise 等API便是任务源,而进入任务队列的是由他们指定的具体执行任务。来自不同任务源的任务会进入到不同的任务队列。其中 setTimeout 与 setInterval 是同源的。

分析示例代码

千言万语,不如就着例子讲来的清楚。下面我们可以按照规范,一步步执行解析下上面的例子,先贴一下例子代码(免得你往上翻)。

console.log('script start');
 
setTimeout(function() {
 console.log('setTimeout');
}, 0);//前端全栈交流学习圈:866109386
 //帮助1-3年前端人员,提升技术,突破思维
Promise.resolve().then(function() {
 console.log('promise1');
}).then(function() {
 console.log('promise2');
});
 
console.log('script end');

整体 script 作为第一个宏任务进入主线程,遇到 console.log,输出 script start

遇到 setTimeout,其回调函数被分发到宏任务 Event Queue 中

遇到 Promise,其 then函数被分到到微任务 Event Queue 中,记为 then1,之后又遇到了 then 函数,将其分到微任务 Event Queue 中,记为 then2

遇到 console.log,输出 script end

至此,Event Queue 中存在三个任务,如下表:

  • 执行微任务,首先执行then1,输出 promise1, 然后执行 then2,输出 promise2,这样就清空了所有微任务
  • 执行 setTimeout 任务,输出 setTimeout 至此,输出的顺序是:script start, script end, promise1, promise2, setTimeout
  • so,你猜对了吗?

看看你掌握了没

再来一个题目,来做个练习:

console.log('script start');
 
setTimeout(function() {
 console.log('timeout1');
}, 10);
 
new Promise(resolve=> {
 console.log('promise1');
 resolve();
 setTimeout(()=> console.log('timeout2'), 10);
}).then(function() {
 console.log('then1')
})
 
console.log('script end');

这个题目就稍微有点复杂了,我们再分析下:

首先,事件循环从宏任务 (macrotask) 队列开始,最初始,宏任务队列中,只有一个 scrip t(整体代码)任务;当遇到任务源 (task source) 时,则会先分发任务到对应的任务队列中去。所以,就和上面例子类似,首先遇到了console.log,输出 script start; 接着往下走,遇到 setTimeout 任务源,将其分发到任务队列中去,记为 timeout1; 接着遇到 promise,new promise 中的代码立即执行,输出 promise1, 然后执行 resolve ,遇到 setTimeout ,将其分发到任务队列中去,记为 timemout2, 将其 then 分发到微任务队列中去,记为 then1; 接着遇到 console.log 代码,直接输出 script end 接着检查微任务队列,发现有个 then1 微任务,执行,输出then1 再检查微任务队列,发现已经清空,则开始检查宏任务队列,执行 timeout1,输出 timeout1; 接着执行 timeout2,输出 timeout2 至此,所有的都队列都已清空,执行完毕。其输出的顺序依次是:script start, promise1, script end, then1, timeout1, timeout2

用流程图看更清晰:



总结

有个小 tip:从规范来看,microtask 优先于 task 执行,所以如果有需要优先执行的逻辑,放入microtask 队列会比 task 更早的被执行。

最后的最后,记住,JavaScript 是一门单线程语言,异步操作都是放到事件循环队列里面,等待主执行栈来执行的,并没有专门的异步执行线程。。

对前端的技术,架构技术感兴趣的同学关注我的头条号,并在后台私信发送关键字:“前端”即可获取免费的架构师学习资料

知识体系已整理好,欢迎免费领取。还有面试视频分享可以免费获取。关注我,可以获得没有的架构经验哦!!

  • 变量提升
  • 调用栈
  • 作用域链
  • 闭包
  • this

变量提升

实际上变量和函数声明在代码里的位置是不会变的,而且是在编译阶段被 JavaScript 引擎放入内存中,一段 JavaScript 代码在执行之前需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段。大致流程为:JavaScript 代码片段 ——> 编译阶段 ——> 执行阶段—>

编译阶段,每段执行代码会分为两部分,第一部分为变量提升部分的代码,第二部分为执行部分的代码。经过编译后,生成执行上下文(Execution context)和 可执行代码

执行上下文 是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入函数的执行上下文,从而确定该函数执行期间用到的如 this、变量、对象以及函数等。

执行上下文由 变量环境(Variable Environment) 和 **词法环境(Lexical Environment)**对象 组成,变量环境保存了代码中变量提升的内容,包括 var 定义和 function 定义的变量。而词法环境保存 let 和 const 定义块级作用域的变量。

块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了

变量查找过程:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。

变量声明提升补充:

  • var的创建和初始化被提升,赋值不会被提升。
  • let的创建被提升,初始化和赋值不会被提升。
  • function的创建、初始化和赋值均会被提升。

调用栈

调用栈是用来管理函数调用关系的一种数据结构。在函数调用的时候,JavaScript 引擎会创建函数执行上下文,而全局代码下又有一个全局执行上下文,这些执行上下文会使用一种叫的数据结果来管理。

所以 JavaScript 的调用栈,其实就是 执行上下文栈 。举例代码执行,入栈如图所示:

var a=2
function add(b,c){
  return b+c
}
function addAll(b,c){
var d=10
result=add(b,c)
return  a+result+d
}
addAll(3,6)

调用栈既然是一种数据结构,所以是存在大小的,超出了栈大小就会出现栈溢出报错,比如斐波那契数列,执行10000次,超过了最大栈调用大小(Maximum call stack size exceeded)。

function Fibonacci2 (n , ac1=1 , ac2=1) {
  if( n <=1 ) {return ac2};

  return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}
Fibonacci2(10000) // Maximum call stack size exceeded

该函数是递归的,虽然只有一种函数调用,但是还是会一直创建执行上下文压入调用栈中,导致超过最大调用栈大小报错,可以通过 Chrome 调式看到 Call Stack 的情况


总结:

  • 每调用一个函数,JavaScript 引擎会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码。
  • 如果在一个函数 A 中调用了另外一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶。
  • 当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈。
  • 当分配的调用栈空间被占满时,会引发“堆栈溢出”问题。

所以,斐波那契数列函数优化的手段就是使用循环来减少函数调用,从而减少函数执行上下文的创建压入栈的情况,就可以解决栈溢出的报错了。(递归尾部优化无法解决问题,Chrome浏览器还是栈溢出),使用蹦床函数来解决:

function runStack (n) {
  if (n===0) return 100;
  return runStack.bind(null, n- 2); // 返回自身的一个版本
}
// 蹦床函数,避免递归
function trampoline(f) {
  while (f && f instanceof Function) {
    f=f();
  }
  return f;
}
trampoline(runStack(1000000))

可以看到,调用栈中一直是保持3个执行上下文而已,多余的都及时的pop掉了。

作用域链

每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部的引用称为 outer

当一段代码使用一个变量是,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,如果找不到就会继续在 outer 所指向的执行上下文中查找。我们把这个查找的链条就称为作用域链

词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。词法作用域是代码阶段决定好的,和函数是怎么调用的没有关系。

块级作用域中的变量查找

  • 从当前执行上下文的词法环境,自顶向下查找(栈中的内存块),然后再从当前执行向下文中的变量环境中查找;
  • 查找不到,则继续在outer指向的执行上下文继续依次先从词法环境,再到变量环境查找。

闭包

有词法作用域的规则可以知道,内部函数总是可以访问他们的外部函数中的变量,当外部函数执行完毕后,pop stack了,遗留下了外部环境形成的闭包 Closure 环境,该环境内存中还保存着那些可以访问的变量,类似一个专属背包,除了内部函数访问,气氛方式无法访问该专属背包,我们就包这个背包称为外部函数的闭包(那些内部函数引用外部函数的变量依然保存在内存中,我们把这些变量的集合称为闭包)。

闭包是怎么回收的

如果引用闭包的函数是一个全局变量,那么闭包会一直存在知道页面关闭;如果这个闭包以后不再使用的话,就会造成内存泄漏。

如果引用闭包的函数是一个局部变量,等函数销毁后,下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块的内存。

使用闭包的原则:如果闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存有比较大的话,那就尽量让它成为一个局部变量。

this

let a={ name: 'this解释' }
function foo() {
  console.log(this.name)
}
foo.bind(a)() //=> 'this解释''




参考资源:《浏览器的工作原理与实践》极客时间-李兵