一、浏览器内核
JS代码执行过程:
浏览器内核即为浏览器排版引擎,分为Gecko、Trident、Webkit、Blink,其渲染过程如下
中途遇到JS标签会停止解析HTML,而去加载和执行JavaScript代码(JavaScript引擎执行)
二、JavaScript引擎
JS引擎将 JS代码 → 机器码,常见JS引擎:SpiderMonkey、Chakra、JavaScriptCore、V8
三、浏览器内核和JS引擎的关系
以WebKit为例,WebKit事实上由浏览器内核和JS引擎组成的。
WebKit引擎是一个开源的浏览器引擎,它最初由苹果公司为其Safari浏览器开发而来。WebKit引擎的主要作用是解析HTML、CSS和JavaScript等网页内容,并将其渲染出来,呈现在用户的浏览器中。
WebKit引擎的底层原理是通过解析HTML和CSS文档来创建一个文档树(DOM树)和样式表树(CSSOM树),然后将它们结合起来生成一棵渲染树(Render Tree)。渲染树包含了所有需要在屏幕上显示的内容,每个节点代表了一个渲染对象,包括文本、图像、表单元素等等。渲染树中的每个节点都包含了相应的样式信息,并且按照从根节点开始的顺序进行渲染。
在渲染过程中,WebKit引擎还使用了一些优化技术,例如布局缓存、合并渲染层等,以提高页面渲染的性能和效率。此外,WebKit引擎还支持硬件加速,能够利用计算机的GPU来加速页面的渲染,从而提高页面的响应速度和流畅度。
WebCore:负责HTML解析、布局、渲染等等相关的工作(例如小程序渲染层Webview);JavaScriptCore:解析、执行JavaScript代码(如小程序逻辑层JsCore);
四、V8引擎
(一)基础
定义:
- V8是用C ++编写的Google开源高性能 JavaScript 和 WebAssembly 引擎,它用于Chrome和Node.js等
- 它实现ECMAScript和WebAssembly,并在Windows 7或更高版本,macOS 10.12+和使用x64,IA-32,ARM或MIPS处理器的Linux系统上运行
- V8可以独立运行,也可以嵌入到任何C ++应用程序中
1️⃣ Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码,具体细节如下:
- 词法分析,对词进行切割生成tokens对象数组,type、value
- 语法分析,对词分析生成AST树,该网站 展现JS生成AST树的代码
AST抽象语法树其他出现情形:
TS → JS过程
TS → AST -(适配JS)→ 新AST → generate code → JS
template → AST → createVNode
如果函数没有被调用,那么是不会被转换成AST的;
Parse的V8官方文档:https://v8.dev/blog/scanner
2️⃣ Ignition是一个解释器,会将 AST 转换成 ByteCode(字节码)【例如以上 TS 转 JS 的过程,我们可以将 JS 转化为 字节码 进行V8引擎下一步操作】
转为字节码的原因是为了适应不同的系统,最终字节码 → 汇编代码 → CPU指令
同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
如果函数只调用一次,Ignition会执行解释执行ByteCode;
Ignition的V8官方文档:https://v8.dev/blog/ignition-interpreter
3️⃣ TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码;
如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能;
但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
即通常函数使用相同类型调用效率更高
TurboFan的V8官方文档:https://v8.dev/blog/turbofan-jit
解析:
- Blink将源码交给V8引擎,Stream获取到源码并且进行编码转换;
- Scanner会进行词法分析(lexical analysis),词法分析会将代码转换成tokens;
- 接下来tokens会被转换成AST树,经过Parser和PreParser:
- Parser就是直接将tokens转成AST树架构;
- PreParser称之为预解析,为什么需要预解析呢?
- 这是因为并不是所有的JavaScript代码,在一开始时就会被执行。那么对所有的JavaScript代码进行解析,必然会影响网页的运行效率;
- 所以V8引擎就实现了Lazy Parsing(延迟解析)的方案,它的作用是将不必要的函数进行预解析,也就是只解析暂时需要的内容,而对函数的全量解析是在函数被调用时才会进行;
- 比如我们在一个函数outer内部定义了另外一个函数inner,那么inner函数就会进行预解析;
- 生成AST树后,会被Ignition转成字节码(bytecode),之后的过程就是代码的执行过程(后续会详细分析)。
五、作用域与执行上下文
(一)关于作用域
作用域是指程序定义变量的区域,作用域定义了如何找到对应的变量。在执行代码「运行在作用域」中,获取对变量的访问权限。JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。即⚠️作用域在定义时就确定了。PS:bash属于动态作用域,即作用域是在调用时决定的。
我们看以下案例:
假设JavaScript采用静态作用域,让我们分析下执行过程:
执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。
假设JavaScript采用动态作用域,让我们分析下执行过程:
执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印 2。
因为,JavaScript采用的是静态作用域,所以这个例子的结果应该是1,与运行结果一致。
再看另外的案例:
两段代码各自的执行结果都是“local scope”
因为JavaScript采用的是词法作用域,函数的作用域基于函数创建的位置。而引用《JavaScript权威指南》的回答。
JavaScript 函数的执行用到了作用域链,这个作用域链是在函数定义的时候创建的。嵌套的函数 f() 定义在这个作用域链里,其中的变量 scope 一定是局部变量,不管何时何地执行函数 f(),这种绑定在执行 f() 时依然有效。
(二)执行上下文
1. 顺序执行
我们有个直观的印象 Javascript 是顺序执行,先看一个案例。
但是如果这个是以下代码呢?
打印的结果居然是两个 foo2。
这是因为 JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行。当执行一段代码的时候,会进行一个“准备工作”,那这个“一段一段”中的“段”究竟是怎么划分的呢?
🧐 到底JavaScript引擎遇到一段怎样的代码时才会做“准备工作”呢?
2. 可执行代码
JavaScript 的可执行代码
executable code
的类型有哪些?其实只有只有三种:全局代码、函数代码、eval代码。
举个例子,当执行到一个函数的时候,就会进行准备工作,这里的“准备工作”,让我们用个更专业一点的说法,就叫做"执行上下文
execution context
"。3. 执行上下文栈
JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文
为了模拟执行上下文栈的行为,让我们定义执行上下文栈是一个数组:
栈结构就像是枪的的弹夹先进后出
试想当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用
globalContext
表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前, ECStack 最底部永远有个 globalContext
:我们看以下代码案例🎯:
当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。知道了这样的工作原理,让我们来看看如何处理上面这段代码:
再看另一个案例🎯:
二者不同之处在于:在Case 1中,
checkscope
函数的执行上下文在 f
函数执行完毕后被销毁。而在Case 2中,尽管 checkscope
函数执行完毕后其执行上下文从执行上下文栈中弹出,但由于 f
函数引用了该上下文中的 scope
变量,这个上下文并未被销毁,而是被 f
函数的[[Scope]]属性保留下来。然后,当 f
函数在全局上下文中被调用时,它创建了一个新的执行上下文,并将其推入执行上下文栈,这就是JavaScript的闭包(Closure)机制。六、VO、Scope chain
(一)总述
当 JavaScript 代码执行一段可执行代码(executable code)时,会创建对应的执行上下文(execution context)。
对于每个执行上下文,都有三个重要属性:
- 变量对象(
Variable object
,VO);
- 作用域链(
Scope chain
);
- this;
(二)变量对象
1. 变量对象
变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。可以分为两种类型:「全局上下文下的变量对象」和「函数上下文下的变量对象」。
2. 全局上下文
- 全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。
- 在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。
- 例如,当JavaScript 代码引用 parseInt() 函数时,它引用的是全局对象的 parseInt 属性。全局对象是作用域链的头,还意味着在顶层 JavaScript 代码中声明的所有变量都将成为全局对象的属性。
简单理解:
- 可以通过
this
引用,在客户端 JavaScript 中,全局对象就是Window
对象。
- 全局对象是由 Object 构造函数实例化的一个对象。
- 预定义的属性是否可用
- 作为全局变量的宿主
- 客户端 JavaScript 中,全局对象有 window 属性指向自身
综上,对JS而言,全局上下文中的变量对象就是全局对象。
3. 函数上下文
在函数上下文中,我们用活动对象(
activation object
, AO)来表示变量对象。活动对象和变量对象其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫 activation object(AO),而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。
活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。
arguments
属性值是 Arguments
对象。4. 执行过程
1️⃣ 进入执行上下文
当进入执行上下文时,这时候还没有执行代码,变量对象会包括:
- 函数的所有形参 (如果是函数上下文)
- 由名称和对应值组成的一个变量对象的属性被创建;
- 没有实参,属性值设为 undefined;
- 函数声明
- 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建;
- 如果变量对象已经存在相同名称的属性,则完全替换这个属性;
- 变量声明
- 由名称和对应值(undefined)组成一个变量对象的属性被创建;
- 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性;
2️⃣ 代码案例🎯
在进入执行上下文后,这时候的 AO 如下所示。需要注意因为
c
是函数定义而 d
是变量赋值,所以二者在AO中存在的形式也不相同。以上就是变量对象的创建过程,让我们简洁的总结我们上述所说:
- 全局上下文的变量对象初始化是全局对象;
- 函数上下文的变量对象初始化只包括 Arguments 对象;
- 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值;
- 在代码执行阶段,会再次修改变量对象的属性值;
3️⃣ 其他案例补充:
案例一🎯
解析:输出「ReferenceError: a is not defined」,「1」
这是因为函数中的
a
并没有通过 var
关键字声明,所有不会被存放在 AO 中。第一段执行
console
的时候, AO 的值是:没有
a
的值,然后就会到全局去找,全局也没有,所以会报错。当第二段执行
console
的时候,全局对象已经被赋予了 a
属性,这时候就可以从全局找到 a
的值,所以会打印 1。案例二 🎯
解析:会打印函数,而不是 undefined 。
这是因为在进入执行上下文时,⚠️ 首先会处理函数声明,其次会处理变量声明,如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。
(三)作用域链
1. 作用域链
当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。
2. 函数创建
首先再确认一个前提:函数的作用域在函数定义的时候就决定了。
这是因为函数有一个内部属性
[[scope]]
,当函数创建的时候,就会保存所有父变量对象到其中,我们可以理解 [[scope]]
就是所有父变量对象的层级链,但是注意:[[scope]]
并不代表完整的作用域链!以下举一个例子🎯
函数创建时,各自的[[scope]]为:
3. 函数激活
当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用链的前端。
这时候执行上下文的作用域链,我们命名为 Scope:
至此,作用域链创建完毕。
4. 函数执行过程🎯
结合着上文的变量对象和执行上下文栈,我们来总结一下函数执行上下文中作用域链和变量对象的创建过程🎯
执行过程如下:
- checkscope 函数被创建,保存作用域链到 内部属性[[scope]]
- 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
- checkscope 函数并不立刻执行,开始做准备工作
- 第一步:复制函数[[scope]]属性创建作用域链
- 第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明
- 第三步:将活动对象AO压入 checkscope 作用域链顶端
- 准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值
- 查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
5. 全局代码执行过程🎯
以下是早期ECMA规范里面的执行过程(即不会出现
let
、const
)其中第1-8行是执行的代码
6. 全局代码执行过程(函数)🎯
特殊情况:假设在函数
foo()
中,我们需要获取 name
的值,则获取到的值为 “Alan”,原理如下(见橙色线)7. 全局代码执行过程(函数嵌套)🎯
8. 函数调用函数执行过程🎯
⚠️需要再次强调:函数在编译时其父级作用域已经确定,跟调用位置无关 ,只跟定义位置有关。
9. 环境变量和记录
上面的笔记都是基于早期ECMA的版本规范,所以会出现以下简称:AO / GO;VO→GO(全局时),VO→AO(函数时)
即从
variable Obejct
→ VariableEnvironment
,从 properties of the variable object
→ a Environment Record
,最新的ECMA的版本规范中,对于一些词汇进行了修改。即原来需要用对象记录,新版本只需要记录,没有限定是用对象,例如可以是Map可以是数组等等。通过上面的变化我们可以知道,在最新的ECMA标准中,我们前面的变量对象VO已经有另外一个称呼了变量环境VE。
具体修改以函数调用函数具体过程为例,如下所示:
(四)作用域 Scope 面试题
面试题核心都是源于上面V8引擎的代码执行过程解析
题目一
执行
foo()
,查找变量 n
,在foo的AO中无法找到n,在其parentScope,即GO中找到n,并复制200题目二
foo()
的AO对象中在编译过程中已经确定下来 n
为 undefined
(扫描到 var n
),第一次打印就在函数作用域中发现 n
,而且为undefined,赋值之后为200;题目三
⭐ 重点是函数初始化时已经确定其作用域和父级作用域,所以函数作用域只跟其定义的位置有关,和其使用的位置无关。
题目四
foo()
的AO中检测到定义了变量 a
(语句 var a = 200
),return只是在函数执行时生效;所以打印 a
为 undefined
题目五(特殊情况)
⭐ JS引擎对foo中的 var a = b = 10,处理时会转化成:
var a = 10; b = 10;
- 作者:😈Zabanya
- 链接:https://blog.zabanya.space/article/bb9d4f21-2d77-4cce-ad49-760067db47e1
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处