浏览器中的JavaScript执行机制
一段JS代码在执行之前需要被JS引擎编译,编译阶段完成之后,才会进入执行阶段。
一、编译阶段
Input:JS代码
Handle:
- 创建执行上下文
2. 压入调用栈中
3. 除声明以外的代码编译为字节码
Output:执行上下文和可执行代码
二、执行阶段
JS引擎开始执行可执行代码,按照顺序一行一行的执行。
三、执行上下文(Execution Context)
所谓的执行上下文,就是JS代码执行时的运⾏环境。
该环境包含了执行期间用到的变量环境、词法环境和this等。
什么情况下会创建执行上下文?
- 当执⾏全局JS代码的时候,会编译全局代码并创建全局执⾏上下⽂,并且在整个⻚⾯的⽣存周期内,全局执⾏上下⽂只有⼀份。
- 当调⽤⼀个函数的时候,函数体内的代码会被编译,并创建函数执⾏上下⽂,⼀般情况下,函数执⾏结束后,创建的函数执⾏上下⽂会被销毁。
- 当使⽤eval函数的时候,eval的代码也会被编译,并创建执⾏上下⽂。
1 变量环境和变量提升(Hoisting)
所谓的变量提升,就是指在JS代码执行过程中,JS引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会被设置默认值undefined。
(1) 什么是声明?什么是赋值?
对于变量:var myname = ‘极客时间’
- 声明:var myname = undefined
- 赋值:myname = ‘极客时间’
对于函数:function foo() { console.log(‘foo’) }
- 声明:function foo() { console.log(‘foo’) }
对于匿名函数:var bar = function () { console.log(‘bar’) }
- 声明:var bar = undefined
- 赋值:bar = function () { console.log(‘bar’) }
(2) 变量提升的内容存储在哪里?
在执行上下文中存在一个变量环境的对象,该对象中保存了变量提升的内容。
2 词法环境和作用域
(1) 作用域
作用域:指在程序中定义变量的区域,该位置决定了变量的⽣命周期。
通俗地理解,作⽤域就是变量与函数的可访问范围,即作⽤域控制着变量和函数的可⻅性和⽣命周期。
ES6之前,ES只有两种作用域,全局作用域和函数作用域。
- 全局作用域:在代码中的任何地⽅都能访问,其⽣命周期伴随着⻚⾯的⽣命周期。
- 函数作用域:在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执⾏结束之后,函数内部定义的变量会被销毁。
相较其他语言而言,它们都普遍支持块级作用域。
- 块级作用域:即一对
{}
包裹的一段代码,如{},if(1){},while(1){},for(let i = 0;i<10;i++){},function foo(){}
,在块内部定义的变量在块外是访问不到的,并且块中代码执行完成之后,块中定义的变量会被销毁。
(2) 变量提升带来的问题
- 变量容易在不被察觉的情况下被覆盖掉
- 本应销毁的变量没有被销毁,造成内存泄漏
(3) ES6是如何解决变量提升带来的缺陷?
通过let
和const
关键字以及执行上下文中的词法环境栈来支持块级作用域。
(4) ES6是如何做到既要支持变量提升,又要支持块级作用域的?
分析以下代码执行过程:
1 | function foo() { |
第⼀步,编译并创建函数执⾏上下⽂
- var 声明的变量,在编译阶段全都被存放到变量环境⾥⾯
- 通过let声明的变量,在编译阶段会被存放到词法环境栈中,也就是创建变量并提升,但不初始化,所以在声明之前使用会报错,在声明之前使用的这段区域被称之为暂时性死区
- 在函数内部的块作⽤域中,通过let声明的变量并没有被存放到词法环境中
第⼆步,继续执⾏代码。
变量环境中a的值被设置成了1,词法环境中b 的值被设置成了2。
在词法环境内部,维护了⼀个⼩型栈结构,栈底是函数最外层的变量,进⼊⼀个块作⽤域后,就会把该块作⽤域内部的变量压到栈顶;当块作⽤域执⾏完成之后,该作⽤域的信息就会从词法环境栈的栈顶弹出。
在词法环境和变量环境中查找变量的方式:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给JavaScript引擎,如果没有查找到,那么继续在变量环境中查找。
当作⽤域块执⾏结束之后,其内部定义的变量就会从词法环境的栈顶弹出。
3 this
this:每个执行上下文中都有个this,所以this和执行上下文是绑定的。
然而,执行上下文分为3种,所以this大致可分为全局执行上下文中的this,函数执行上下文中的this,eval中的this。(this没有作⽤域的限制)
(1) 不同情况下的this
全局执行上下文中的this:指向window对象。
函数中的this:
-
默认情况下:调用一个函数,其this是指向window对象
-
手动设置this的情况:
-
函数的方法:call, apply, bind
-
对象调用方法:对象调⽤其内部的⽅法,该⽅法的this指向对象本⾝
-
构造函数:指向实例对象
-
(2) this设计的缺陷
-
嵌套函数中的this不会从外层函数中继承:
1
2
3
4
5
6
7
8
9
10
11var myObj = {
name : "极客时间",
showThis: function(){
console.log(this)
function bar(){
console.log(this) // window
}
bar()
}
}
myObj.showThis()- 可以声明⼀个变量self⽤来保存this(本质上是把this体系转换为了作⽤域的体系)
- 使⽤ES6中的箭头函数(箭头函数不会创建自己的执行上下文,像是一个块作用域,所以箭头函数中的this取决于它的外部函数)
-
普通函数中的this默认指向全局对象window:
严格模式下,默认执⾏⼀个函数,其函数的执⾏上下⽂中的this值是undefined
四、调用栈(Call Stack)
所谓的调用栈,也叫执行上下文栈,就是JS引擎用来存储并管理执行上下文的一种数据结构,也是JS引擎追踪函数执行的一个机制。
查看调用栈信息:
- console.trace()
- 利⽤浏览器查看调⽤栈(Call Stack)的信息
调用栈是有大小的,如果压入的执行期上下文超过一定数目就会导致栈溢出。
解决方法:
- 将递归形式改造成其他形式
- 利用定时器把当前任务拆成很多小任务。
分析以下代码的执行过程:
1 | var a = 2 |
第⼀步,创建全局执行上下⽂,并压⼊调用栈。接着JS引擎便开始执⾏全局代码,⾸先执⾏a=2的赋值操作,执⾏该语句会将全局执行上下⽂中的变量环境对象中的a的值设置为2。
第二步,调用addAll函数。当调⽤该函数时,JavaScript引擎会编译该函数,并为其创建⼀个函数执⾏上下⽂,并压⼊栈中。之后开始执⾏阶段,先执⾏d=10的赋值操作,将addAll函数执⾏上下⽂中的d由undefined变成了10。
第三步,当执⾏到add函数调⽤语句时,同样会为其创建函数执⾏上下⽂,并将其压⼊调⽤栈,当add函数返回时,该函数的执⾏上下⽂就会从栈顶弹出,并将result的值设置为add函数的返回值,也就是9。紧接着addAll函数执⾏最后⼀个相加操作后并返回,addAll的执⾏上下⽂也会从栈顶部弹出,此时调⽤栈中就只 剩下全局上下⽂了。
五、词法作用域、作用域链、外部引用以及闭包
1 词法作用域
词法作用域:指作⽤域是由代码中函数声明的位置来决定的,所以词法作⽤域是静态的作⽤域,通过它就能够预测代码在执⾏过程中如何查找标识符。
词法作用域决定了作用域链。同时词法作⽤域是代码阶段就决定好的,和函数是怎么调⽤的没有关系。
整个词法作⽤域链的顺序是:foo函数作⽤域—>bar函数作⽤域—>main函数作⽤域—>全局作⽤域
2 作用域链(Scope)和外部引用(outer)
作用域链就是变量查找的作用域链条,它是由词法作用域来决定的
在每个执⾏上下⽂的变量环境中,都包含了⼀个外部引⽤(outer),⽤来指向外部的执⾏上下⽂(外部的执行上下文由词法作用域决定)。
当⼀段代码使⽤了⼀个变量时,JavaScript引擎⾸先会在“当前的执⾏上下⽂”中查找该变量,如果在当前的变量环境中没有查找到,那么JavaScript引擎会继续在outer所指向的执⾏上下⽂中查找。
变量查找链条:
- 当前执行上下文中沿着词法环境的栈自顶向下查找
- 当前执行上下文中变量环境中查找
- outer所指向的执行上下文中查找,重复1,2,3,直至找到或找不到为止
1 | function bar() { |
1 | function bar() { |
3 闭包(closure)
闭包:在JavaScript中,根据词法作⽤域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调⽤⼀个外部函数返回⼀个内部函数后,即使该外部函数已经执⾏结束了,但是内部函数引⽤的外部函数的变量依然保存在内存中(堆空间中),我们就把这些变量的集合称为闭包。
闭包产生的核心步骤:
- 预扫描内部函数
- 把内部函数引用的外部函数的变量保存到堆中
1 | function foo() { |
当执⾏到foo函数内部的return innerBar代码时调⽤栈的情况:
根据词法作用域的规则,内部函数getName和setName总是可以访问他们的外部函数foo中的变量,所以当return innerBar执行并返回给全局变量bar时,虽然foo函数已经执行结束了,但是getName和setName函数依然可以使用foo函数中的变量myName和test1。
当foo函数执行完时,调用栈的状态:
当foo函数执行完成后,其上下文也从调用栈中弹出,由于setName和getName方法中使用了foo函数中的变量myName和test1,所以这两个变量会被保存在一块名为foo(closure)的内存中,同时只能有setName和getName访问。
当执⾏到bar.setName⽅法中的myName = "极客邦"这句代码时,JavaScript引擎会沿着“当前执⾏上下⽂‒>foo函数闭包‒>全局执⾏上下⽂”的顺序查找变量myName:
闭包使用不当会导致内存泄漏,闭包是如何被回收的:
- 引用闭包的函数是一个全局变量,那闭包会一直存在直到页面关闭
- 如果引⽤闭包的函数是个局部变量,等函数销毁后,在下次JS引擎执⾏垃圾回收时,判断闭包这块内容如果已经不再被使⽤了,那么JS引擎的垃圾回收器会回收这块内存
参考链接
- 李兵《浏览器工作原理与实践》:https://time.geekbang.org/column/intro/100033601