1. 从输入 URL 到页面展示 发生了什么?
总体分为以下过程:
- DNS 域名解析:将域名解析成 IP 地址
- TCP 连接:TCP 三次握手
- 发送 HTTP 请求
- 服务器处理请求并返回 HTTP 报文
- 浏览器解析渲染页面
- 断开连接:TCP 四次挥手
2. 浏览器工作原理
在浏览器中输入查找内容,浏览器是怎样将页面加载出来的?
大致流程如下:
- 首先,用户输入服务器地址,与服务器建立连接
- 服务器返回对应的静态资源(index.html)
- 然后浏览器拿到 index.html 后进行解析
- 当解析时遇到 css 或 js 文件,就向服务器请求并下载对应的 css 和 js 文件
- 最后浏览器对页面进行渲染,执行 js 代码
3. 浏览器渲染过程
-
HTML Parser 将 HTML解析转换成 DOM 树
-
CSS Parser 将 样式表转换成 CSS 规则树
-
合并 DOM 树和 CSS 规则树,生成 render(渲染) 树
-
布局 render 树(Layout)
通过渲染树中渲染对象的信息,计算出每一个渲染对象的位置和尺寸
-
绘制 render 树(painting),进行 Display 展示
注意图中顶部的紫色 DOM 三角形,实际上是 js 对 DOM 的相关操作。
4. 一个强大的 JavaScript 引擎 — V8 引擎
在解析 HTML 的过程中,遇到了 JavaScript 标签,该怎么办呢?
- 会停止解析 HTML ,而去加载和执行 JavaScript 代码
那么,JavaScript 代码由谁来执行呢?
-
JavaScript 引擎
高级的编程语言最终都要转成机器指令来执行的,
所以我们需要 JavaScript 引擎帮助我们将 JavaScript 代码翻译成 CPU 指令来执行
(1)V8 引擎的架构
V8 的底层架构主要有三个核心模块(Parse、Ignition、TurboFan)
1. Parse(解析):
该过程主要是对 JavaScript 源代码进行词法分析和语法分析。
词法分析:对代码中的每一个词每一个符号进行解析,最终生成很多 tokens
例如:对 const name = "curry"
// 首先对const进行解析,因为const为一个关键字,所以类型会被记为一个关键词,值为const
tokens: [
{ type: 'keyword', value: 'const' }
]
// 接着对name进行解析,因为name为一个标识符,所以类型会被记为一个标识符,值为name
tokens: [
{ type: 'keyword', value: 'const' },
{ type: 'identifier', value: 'name' }
]
// 以此类推...
语法分析:在词法分析的基础上,拿到 tokens 中的一个个对象,根据不同类型,再进一步分析具体语法,最终生成 AST 抽象语法树
可以详细查看通过 Parse 转换后的 AST 的工具:astexplorer.net/
2. Ignition
一个解析器,可以将 AST 转换成 ByteCode(字节码)
3. TurboFan
一个编译器,可以将字节码编译为 CPU 认识的机器码
(2)V8 引擎的执行过程
- Blink 内核将 JS 源码交给 V8 引擎
- Stream 获取到 JS 源码进行编码转换
- Scanner 进行词法分析,将代码转换成 tokens
- Parser 和 PreParser
- Parser :直接解析,将 tokens 转成 AST 树
- PreParser:预解析,对不必要的函数进行预解析,也就是只解析暂时需要的内容,而在函数被调用时才进行函数的全量解析
- 生成 AST 树后,会被 Ignition 转成字节码,之后就是代码的执行过程
5. JavaScript 的执行过程
假如要执行如下代码:
var title = "hello"
console.log(num1)
var num1 = 20
var num2 = 30
var result = num1 + num2
console.log(result)
(1)首先,代码被解析,V8 引擎内部会帮助我们创建一个全局对象:Global Object(GO)
-
GO 可以访问所有的作用域
-
里面会包含 Date、Array、String、setTimeout等等(所以我们可以直接 new Date() )
-
GO 还有一个window 属性指向自己(所以window.window.window还是指向 GO自己)
用伪代码表示为:
var globalObject = {
String: 类,
Date: 类
setTimeout: 函数,
...
window: globalObject,
title: undefined,
num1: undefined,
num2: undefined,
result: undefined
}
window指向自己,window.window.window
(2)然后运行代码
-
为了执行代码,v8引擎内部会有一个执行上下文栈(Execution Context Stack,简称 ECStack),也是函数调用栈,它是用于执行代码的调用栈
-
为了全局代码能够正常执行,首先需要创建一个**全局执行上下文 **(Global Execution Context,简称GEC),一般只有一个,在全局代码需要被执行时才会创建
-
然后全局执行上下文会被放入执行上下文栈中执行,包含两个部分:
-
在代码执行前,会将全局定义的变量,函数等加入到 GlobalOject 中,但是并不会赋值(也称为变量的作用域提升)
VO:变量对象
-
开始依次执行代码:
title = "hello" // 赋值
console.log(num1) // undefined, 不会报错
num1= 20 // 赋值
num2......
-
全局代码执行过程—函数
疑问:为什么在函数定义之前也可以执行这个函数?
全局中,函数的执行过程:
首先根据函数体创建一个函数执行上下文,并且压入到执行上下文栈中(EC Stack)
注意:每个函数执行都会在栈中创建函数执行上下文,执行完之后就出栈
在初始化 GO 的时候,函数的 AO 也是会被初始化的
比如说 ,全局中,有function foo() {},一开始初始化GO的时候从上到下执行到 foo
会在内存中开辟一块内存空间,存储函数foo,其中包含:
[[scope]]:parent scope(实际上是函数的上一层作用域)
foo.[[scope]] = [ globalContext.VO ]函数的执行体(代码块)
例如:执行以下代码
var name = "why"
foo(123)
function foo(num) {
console.log(m)
var m = 10
var n = 20
}
补充:以下name打印的是什么
var name = "why"
function foo() {
var name = "foo"
console.log(name)
}
打印的是"foo",console.log(name)执行的时候,会在foo的函数执行上下文中的AO查找name,找到的是"foo"
实际上,查找变量的时候,是沿着作用域链来查找的
作用域链
作用域链由 当前执行上下文的VO(变量对象,在函数中就是 AO 对象)和 parent scope (父级 VO)组成,查找时会一层层查找
看一个例子:
var message = "Hello Global"
function foo() {
console.log(message) // Hello Global
}
function bar() {
var message = "Hello Bar"
foo()
}
bar()
比如说这里,当执行 foo 函数的时候, foo 的 AO 中没有message,就会去它的父级 VO 中查找。一定要注意!当第一次全局代码初始化的时候, foo函数对象会保存在内存中,其中包括 [parent scope]: GO,函数体: 代码块
当foo函数执行的时候,会找到保存的代码块,创建foo函数执行上下文,其中包括三部分:
-
第一部分:在解析函数成为 AST 树结构的时候,会创建一个 AO(Activation)
其中包含形参、arguments、函数定义、指向函数对象或定义的变量
-
第二部分:就是作用域链[AO+VO]
-
第三部分:this 绑定的值
当foo在自己的AO找不到message的时候,去父级 VO 找,这个父级 VO 就是一开始初始化的时候保存的 [parent scope]:GO,所以message为 "Hello Global"
简单描述一下这个过程:
// 全局执行上下文
// 初始化
{VO: GO: {window; message:undefined; foo: 地址1; bar: 地址2;}}
其中
地址1(foo函数对象):{[parent scope]: GO;函数体: 代码块}
地址2(bar函数对象):{[parent scope]: GO;函数体: 代码块}
// 执行代码
{VO: GO: {window; message:"Hello Global"; foo: 地址1; bar: 地址2;}}
// bar()
bar函数执行,创建一个函数执行上下文,
其中包括 {
VO: AO:{message: undefined}
scope chain: AO + parent scope(这里是GO)
this
}
// 然后开始执行 bar的代码
message:"Hello Bar" (赋值)
foo()
foo函数执行,创建一个foo的函数执行上下文
其中包括:{
VO: AO: {}
scope chain: AO + parent scope(这里是GO)
this
}
// 然后开始执行 foo的代码
console.log(message)
发现自己的AO没有message,会向上找,即从自己保存的父级VO中查找,找到GO中的message为 "Hello Global"
JS执行上下文文章推荐:github.com/mqyqingfeng…
写的挺清晰详细的
6. 变量环境和记录
早起的ECMA版本规范:
在最新的ECMA的版本规范中,对于一些词汇进行了修改:
也就是前面的变量对象VO改为变量环境 VE,因为规范不在限制一定要是一个对象了
7. 几道常见的作用域提升面试题:
-
var n = 100 function foo() { n = 200 } foo() console.log(n) // 200 // foo的VO中找不到n,因此向父级VO中找,所以赋值给了全局的n -
function foo() { console.log(n) // undefined var n = 200 console.log(n) // 200 } var n = 100 foo() -
var a = 100 function foo() { console.log(a) // undefined return var a = 200 } foo() -
function foo() { m = 100 } foo() console.log(m) // 100 // 严格来说这是一种语法错误 // js引擎的处理:会把m放到 -
function foo() { var a = b = 10 // => 转成下面的两行代码 // var a = 10 // b = 10 } foo() //console.log(a) // 报错 a is not defined(因为当 foo函数执行完之后,foo的函数执行上下文就会弹出栈(没啦!哪里还会有a呢)) console.log(b) // 10