title: JS高级基础
categories: Javascript
tag: JS
date: 2021-11-11 13:16:34
浏览器渲染过程
浏览器内核的 htmlParser 进行解析,
- 浏览器会将 HTML 解析成一个 DOM 树,DOM 树的构建过程是一个深度遍历过程:当前节点的所有子节点都构建好后才会去构建当前节点的下一个兄弟节点。
- 将 CSS 解析成 CSS Rule Tree 。
- 根据 DOM 树和 CSSOM 来构造 Rendering Tree。注意:Rendering Tree 渲染树并不等同于 DOM 树,因为一些像 Header 或 display:none 的东西就没必要放在渲染树中了。
- 有了 Render Tree,浏览器已经能知道网页中有哪些节点、各个节点的 CSS 定义以及他们的从属关系。下一步操作称之为 layout,顾名思义就是计算出每个节点在屏幕中的位置。
- 再下一步就是绘制,即遍历 render 树,并使用 UI 后端层绘制每个节点。
注意:上述这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的 html 都解析完成之后再去构建和布局 render 树。它是解析完一部分内容就显示一部分内容,同时,可能还在通过网络下载其余内容。
阻塞渲染:CSS 与 JavaScript
谈论资源的阻塞时,我们要清楚,现代浏览器总是并行加载资源。例如,当 HTML 解析器(HTML Parser)被脚本阻塞时,解析器虽然会停止构建 DOM,但仍会识别该脚本后面的资源,并进行预加载。
同时,由于下面两点:
- 默认情况下,CSS 被视为阻塞渲染的资源,这意味着浏览器将不会渲染任何已处理的内容,直至 CSSOM 构建完毕。
- JavaScript 不仅可以读取和修改 DOM 属性,还可以读取和修改 CSSOM 属性。
存在阻塞的 CSS 资源时,浏览器会延迟 JavaScript 的执行和 DOM 构建。另外:
- 当浏览器遇到一个 script 标记时,DOM 构建将暂停,直至脚本完成执行。
- JavaScript 可以查询和修改 DOM 与 CSSOM。
- CSSOM 构建时,JavaScript 执行将暂停,直至 CSSOM 就绪。
所以,script 标签的位置很重要。实际使用时,可以遵循下面两个原则:
- CSS 优先:引入顺序上,CSS 资源先于 JavaScript 资源。
- JavaScript 应尽量少影响 DOM 的构建。
浏览器内核和 JS 引擎的关系
这里我们先以 WebKit 为例,WebKit 事实上由两部分组成的:
- WebCore:负责 HTML 解析、布局、渲染等等相关的工作;
- JavaScriptCore:解析、执行 JavaScript 代码;
V8 引擎的原理
我们来看一下官方对 V8 引擎的定义:
- V8 是用 C ++编写的 Google 开源高性能 JavaScript 和 WebAssembly 引擎,它用于 Chrome 和 Node.js 等。
- 它实现 ECMAScript 和 WebAssembly,并在 Windows 7 或更高版本,macOS 10.12+和使用 x64,IA-32, ARM 或 MIPS 处理器的 Linux 系统上运行。
- V8 可以独立运行,也可以嵌入到任何 C ++应用程序中。
V8 引擎本身的源码非常复杂,大概有超过100w 行 C++代码,通过了解它的架构,我们可以知道它是如何对 JavaScript 执行的:
为什么要转换为字节码?
因为我们不知道程序会运行在哪个操作系统上面。可能是mac,也可能是windows。这个时候,我们就需要根据字节码再转换为自己机器可能识别的机器码
Parse 模块会将 JavaScript 代码转换成 AST(抽象语法树),这是因为解释器并不直接认识 JavaScript 代码;
- 如果函数没有被调用,那么是不会被转换成 AST 的;
- Parse 的 V8 官方文档:https://v8.dev/blog/scanner
Ignition 是一个解释器,会将 AST 转换成 ByteCode(字节码)
同时会收集 TurboFan 优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
- 如果函数只调用一次,Ignition 会执行解释执行 ByteCode;
- Ignition 的 V8 官方文档:https://v8.dev/blog/ignition-interpreter
TurboFan 是一个编译器,可以将字节码编译为 CPU 可以直接执行的机器码;
- 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过 TurboFan 转换成优化的机器码,提高代码的执行性能;
- 但是,机器码实际上也会被还原为 ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如 sum 函数原来执行的是 number 类型,后来执行变成了 string 类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
- TurboFan 的 V8 官方文档:https://v8.dev/blog/turbofan-jit
官方解析图
那么我们的 JavaScript 源码是如何被解析(Parse 过程)的呢?
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 执行过程
1 初始化对象
js 引擎会在执行代码之前,会在堆内存中创建一个全局对象:Global Object(GO)
- 该对象 所有的作用域(scope)都可以访问;
- 里面会包含 Date、Array、String、Number、setTimeout、setInterval 等等;
- 其中还有一个 window 属性指向自己;
2 执行上下文栈
js 引擎内部有一个执行上下文栈(Execution Context Stack,简称 ECS),它是用于执行代码的调用栈。
- 那么现在它要执行谁呢?执行的是全局的代码块:
- 全局的代码块为了执行会构建一个 Global Execution Context(GEC);
- GEC 会 被放入到 ECS 中 执行;
GEC 被放入到 ECS 中里面包含两部分内容:
- 第一部分:在代码执行前,在 parser 转成 AST 的过程中,会将全局定义的变量、函数等加入到 GlobalObject 中,但是并不会赋值; 因此都会 undefined
- 这个过程也称之为变量的作用域提升(hoisting)
- 第二部分:在代码执行中,对变量赋值,或者执行其他的函数;
3 GEC 被放入到 ECS 中
4 GEC 开始执行代码
5 遇到函数如何执行
n 在执行的过程中执行到一个函数时,就会根据函数体创建一个函数执行上下文(Functional Execution Context,简称 FEC),并且压入到EC Stack中。
FEC 中包含三部分内容:
- 第一部分:在解析函数成为 AST 树结构时,会创建一个 Activation Object(AO):
- AO 中包含形参、arguments、函数定义和指向函数对象、定义的变量;
- 第二部分:作用域链:由 VO(在函数中就是 AO 对象)和父级 VO 组成,查找时会一层层查找;
- 第三部分:this 绑定的值:这个我们后续会详细解析;
FEC 被放入到 ECS 中
FEC 开始执行代码
例题分析
查看以下代码
var message = 'hello Global'
function foo() {
console.log(message)
}
function bar() {
var message = 'hello Bar'
foo()
}
bar()
这个会打印的是 hello Global
父级作用域跟定义位置有关系。跟调用位置是没有关系的。在编译时就已经确定了作用域。所以打印的是 hello Global
以上指的是 ES5 及以前。
作用域提升面试题
var n = 100
function foo() {
n = 200
}
foo()
console.log(n) //200
function foo() {
console.log(n) //undefined
var n = 200
console.log(n) //200
}
var n = 100
foo()
var n = 100
function foo1() {
console.log(n) //100
}
function foo2() {
var n = 200
console.log(n) //200
foo1()
}
foo2()
console.log(n) //100
//父级作用域跟定义位置有关系。跟调用位置是没有关系的。
var a = 100
function foo() {
console.log(a) //undefined
return
var a = 100 //虽然是在return后面声明的变量,但是编译的时候还是有这个变量的
}
foo()
function foo() {
var a = (b = 100)
/** 相当于
var a = b
b = 10
*/
}
foo()
console.log(b) //100
console.log(a) //a is not undefined
去掉var之后,b就会变为全局变量。(JS红宝书P24)