浏览器主要构成
用户接口(包含像地址栏、前进/后退按钮、菜单栏等)
浏览器引擎 (协调 UI 和渲染引擎工作)
渲染引擎 (展示用户请求的内容)
网络 (http、https等网络请求)
javascript 解释器 (解析和执行 javascript 代码)
UI 后台 (生成基础小部件、窗口等)
本地数据存储 (像 cookies、localstorage、IndexedDB、webSQL、文件系统)
在web浏览器环境中,所有的javascript代码都需要托管和运行
在web浏览器中,任何一段javascript代码被执行,在其背后都发生了很多事。这篇文章旨在向大家介绍在web浏览器中每一段javascript代码的执行背后都发生了哪些事情。
在我们深入介绍之前,希望大家了解这些基本概念:
解析器(Parser):解析器或语法解析器是一个可以一行行读取代码的程序。它理代码怎样才能符合解编程语言定义的语法规范,并理解代码期望实现的功能
javascript引擎:它是一个可以接收javascript源码并将其编译为CPU可以理解的二进制机器码的计算机程序。javascript引擎一般由web浏览器代理商开发,每个主流浏览器都有自己javascript引擎。比如: chrome浏览器是V8引擎,firefox浏览器是SpiderMonkey引擎,IE浏览器是Chakra引擎
函数声明:一些分配了名称的函数
函数表达式: 一般应用于匿名函数场景
javascript代码是如何被执行的
我们都知道,浏览器是不理解我们在应用程序中编写的javscript高级语言代码的,它需要被转译为浏览器和计算机理解的机器码(低级语言:离底层比较近,不需要编译可以直接执行。效率高但是不易理解;高级语言:是经过多层封装的语言,无法直接执行,需要编译后才能执行。效率低但是易于理解)
当浏览器读取HTML文档时,如果遇到遇到javascript代码,比如<script>标签或者包含像onClick等javascript代码的属性。浏览器会将其发送给javascript引擎处理
浏览器中的javascript引擎收到消息后会创建一个特殊的专门用于处理javascipt代码的转换和执行的环境。这个环境就是我们所熟知的执行上下文
这个执行上下文包含当前正在运行的,以及帮助运行所需要的所有信息
在执行上下文运行期间:一些特定的代码会被解析器解析,变量和函数会被存储在内存中,生成在机器上可以直接执行的字节码,然后执行
在Javascript中执行上下文一般分为两种类型:
全局执行上下文(GEC)
函数执行上下文(FEC)
全局执行上下文(GEC)
无论何时,Javascript引擎接收到一个js脚本文件,都会首先创建一个默认的全局执行上下文(GEC),全局执行上下文是基础或默认的执行上下文,没有在函数中执行的都会在全局上下文中执行
函数执行上下文(FEC)
一个函数无论何时被调用,Javascript引擎都会创建一个函数执行上下文(FEC)。在全局执行上下文(GEC)中评估和执行函数中的代码。既然每一个函数被调用时都会生成一个函数执行上下文(FEC),那么在脚本运行期间就可能存在多个函数执行上下文(FEC)
执行上下文是如何被创建的呢?
执行上下文(GEC|FEC)的创建分为两个阶段
1. 创建阶段
2. 执行阶段
执行上下文-创建阶段
在创建阶段,执行上下文首先与执行上下文对象(ECO)关联。执行上下文对象(ECO)存储了很多在代码执行期间执行上下文需要用到的重要数据
执行上下文创建阶段也分为3个阶段。在此期间,定义和设置执行上下文对象(ECO)的属性,这3个阶段是:
1. 变量对象(VO)的创建
2. 作用域链的创建
3. 上下文this的设置
执行上下文创建阶段-变量对象的创建
变量对象(VO)是一个在上下文中创建的类对象的容器。在上下文中它存储了变量和函数声明
在全局上下文(GEC)中,每一个以var声明的变量,其名称都会被作为一个属性添加变量对象(VO)上且值为undefined。同样的,每一个函数声明,其名称也会被作为一个属性添加变量对象(VO)上,但值是可运行的函数体. 无论是以var声明的变量、还是函数声明,其属性都会被存储在内存中,因而在变量对象内部(VO),即使在代码运行之前,他们也都是可以被访问到的
在函数执行上下文(FEC)中,并不会构建一个变量对象(VO),而是创建一个类数组的名为argument的对象,它包含函数执行需要的所有参数
在执行代码之前将变量和函数声明存储在内存中的过程称为提升
执行上下文创建阶段-作用域的创建
在变量对象创建阶段后,进入到作用域链创建阶段
在Javascript中,作用域是一种决定一段代码如何访问其他部分代码的机制。作用域回答了:一段代码在哪里可以被访问到,哪里不能访问,哪些可以访问它,哪些不可以
每一个函数执行上下文都会创建自己的作用域,通过作用域可以上下文中的变量和函数声明
当一个函数A被定义在另一个函数B中,内部函数A可以访问包裹它的外部函数B内的信息,这种行为称为词法作用域 ,然而外部函数B却无法访问内部函数A内的信息。在Javascript中的这种现象进而引出了一个叫”闭包“的现象。即使外部函数B已经执行完毕,内部函数A也总是能够访问外部函数B的作用域,例如:
- 图中的右侧是全局作用域,当
.js脚本被加载后它就会被创建,在整个脚本中的所有函数都可以访问到它 - 图中用红框包裹的是
first函数的作用域,它定义了变量b='Hello!'以及second函数
3. 图中用绿框包裹的是
second函数作用域,它有一个console.log语句来打印变量a,b,c
现在除了变量c外,变量a和b都没有在函数second中定义。然而,由于词法作用域的关系,函数second是可以访问自己内部的作用域以及它的父级作用域
在代码运行时,JS引擎无法在second函数作用域中发现变量b。因此,JS引擎会一直向上查找,首先查找的是first函数并发现了变量b='Hello!'。然后回到second函数处理了变量b值问题,接着以同样的方式处理了变量a值问题。JS引擎会通过作用域链从second作用域一直循环向上查找它的父级作用域直到全局作用域。
JS引擎通过这种遍历当前函数执行上下文的作用域来查找变量和函数调用的想法称为作用域链
只有当JS引擎通过作用域链无法查找到变量时,才会停止查找并抛出错误。然而这种查找这并不能反向工作,比如全局作用域无法访问内部函数的变量,除非函数执行后返回出来
作用域链的工作原理就像一个单面镜。你能在里面看到外面的人,但是在外面的人却无法看到里面的人。
这就是上图中红色箭头朝上的原因,因为它是单向的
执行上下文创建阶段-上下文this的设置
接下来并且是最后一个阶段时设置对象的上下文this的值
Javascript中的this是指向其执行上下文所属的作用域的。一旦作用域链被创建,那么它的this指向也会被JS引擎初始化
全局上下文中的this
在全局上下文中GEC(任何函数和对象之外),this指向的是全局对象,web浏览器中的是window。因此,函数声明和以var关键字声明的被初始化的变量,最终都会挂载到 window对象上,比如
var occupation = "Frontend Developer";
function addOne(x) {
console.log(x + 1)
}
它完全等同于
window.occupation = "Frontend Developer";
window.addOne = (x) => {
console.log(x + 1)
};
由于函数和变量都会被挂载到window对象上,因而下面的一段代码在浏览器中总是返回true
函数上下文中的this
在函数执行上下文中,它没有创建this对象,但它能访问它所被定义的环境。在web浏览器中定义它的环境是window对象,它全局执行上下文(GEC)中被定义
在对象内部,
this关键字不会指向全局执行上下文GEC,而是指向它自身。在一个对象内部this的指向类似于:theObject.thePropertyOrMethodDefinedInIt;
思考下面的样例:
控制台打印的是“Victor will rule the world!”,而不是打印“I will rule the world!”。因为在这个样例中,函数中的
this关键字指向的是定义它的对象而不是全局对象。当this关键字被设置后,那么执行上下文中的所有属性也已经被定义了。在执行上下文创建阶段结束之前,JS引擎将进入执行阶段
执行上下文执行阶段
最后,在执行上下文创建阶段结束后来到执行阶段,这也是实际代码执行的开始。知道现在,变量对象VO持有的变量的值依然为undefined,如果代码在此时运行将会返回错误,因为我们无法用undefined变量值工作。在此阶段,JS引擎会再次读取当前执行上下文中的代码,然后更新VO持有变量的值。然后代码会被解析器解析转换为可执行的字节码,最后执行。
Javascript执行栈
”执行栈“即我们所熟知的“调用栈”,在脚本执行的生命周期内会保持追踪所有执行上下文的创建。Javascript是一门单线程语言,意味着在同一时间内它只能执行一个任务。因此,当其他的行为、功能、事件等发生时,每一个事件对应的执行上下文会被创建。但由于Javascript天然的单线程特性,所以将会创建一个可以存放当前所有执行上下文的栈,也就是我们知道的“执行栈”。
当脚本在web浏览器中被加载后,全局执行上下文将作为默认的执行上下文被创建,JS引擎在其中开始执行代码并将全局执行上下放入执行栈的底部。
然后JS引擎在代码中搜索函数调用,每一个函数调用也会创建一个函数执行上下文,然后将函数执行上下文放入执行栈的顶部。
在执行栈顶部的执行上下文将会自动成为当前活跃的执行上下文,即总是会被JS引擎执行的上下文。一旦当前活跃的执行上下文执行完了,JS引擎会将其从当前执行栈的顶部移除,然后下一个执行上下文自动成为活跃的执行上下文,然后如此循环下去。
为了便于理解执行栈的工作过程,思考下面的样例:
首先,脚本被加载进JS引擎中。
然后,JS引擎创建全局执行上下文并将其放入执行栈的底部。
name变量是在全局执行上下文中定义的,所以它被存放在全局变量对象VO上,同样的方式处理first、second、third函数。不要疑惑为啥函数依然还是在全局执行上下文中。记住:全局执行上下文仅适用于除了在函数中定义的变量和函数代码。因为它们不是在任何函数内部定义的,而是在全局执行上下文中声明的。
当JS引擎遇到first函数调用,就会创建一个新的函数执行上下文,然后将其放在执行栈的顶部。在first函数调用期间,它的函数执行上下文成为活跃执行上下文(即JS引擎首先执行的上下文)。在first函数中,变量a="Hi!";被存储在它的函数执行上下文中,而不是全局执行上下文。
紧接着,second函数在first函数中被调用,由于Javascript单线程的原因first函数执行会暂停,直到second函数执行完毕后才会继续执行。然后JS引擎为second函数创建了一个新的函数执行上下文并且将其放在执行栈的顶部,second函数成为当前活跃的执行上下文,变量b="Hey!";被存储在它的函数执行上下文中,然后third函数在second函数中被调用了。然后JS引擎为third函数创建一个新的函数执行上下文并将其放在执行栈的顶部
在函数
third中变量c="Hello!"被存储在其函数执行上下文中,以及在控制台打印"Hello! Victor"信息。third函数执行完它的所有任务后返回,它的执行上下文也被从执行栈中移除。然后回到second函数继续执行,在控制台打印"Hey! Victor"信息。second函数也完成的它的全部任务后返回,它的执行上下文也被从执行栈中移除。当first函数也完成任务后返回,它也被从执行栈中移除。然后执行权又再次回到了全局执行上下文手中。最后当脚本的所有任务都被执行完后,JS引擎会从当前执行栈移除全局执行上下文。
全局执行上下文 VS 函数执行上下文
| 全局执行上下文 | 函数执行上下文 |
|---|---|
创建一个全局变量对象VO存储函数和变量声明 | 没有创建一个全局变量对象VO,而是创建一个argument对象,存储所有传入到函数的参数 |
创建this对象,用于存储全局作用域下的所有变量和函数 | 不会创建this对象,但是依然有权访问定义它的环境下的所有代码,这个环境一般是window对象 |
| 无法访问在函数上下文中定义的代码 | 由于作用域,依然有权访问(在定义它的上下文中或者父级作用域)所有的变量和函数 |
| 为在全局作用域定义的变量和函数设置内存空间 | 仅为在函数中定义的变量和函数设置内存空间 |