当前位置: 首页 > news >正文

深入 JavaScript 运行原理

最近半年都在用 AI 辅助编程, 基本就没咋去写过代码了, 愈高度依赖 AI, 愈加得知道语言的原理, 才能游刃有余.

js 这门语言, 真的是学会只要几天, 掌握需要好几年.

回顾这些年的工作, 我主要是和数据相关, 偏后端为主的, 对于 R, Python, Java, Scala, Go, Sql, Rust, Js 都有用过, Js 对于我的数据工作来说, 非常重要, 因为所有的数据分析结果都要用图表可视化方式呈现, 不论是酷炫大屏还是复杂的多维表格, 还是用的 BI 工具, 都是需要了解 js 的. 因此想要相对深入学习一下 js 这门语言.

同时也感悟到学习任何技能的通用方法:

  • 见多才能识广, 量变引发质变; 动手实践 -> 即时反馈 -> 多做项目 -> 大量重复
  • 原理学习, 不断提炼和总结; 从实践操作 -> 概念理解 -> 文字输出 -> 语言输出

特此说明一下, 所有的学习笔记内容都是来自 b 站博主 coderwhy 大佬的公开内容整理哈

js 重要阶段

js 是一门高级的编程语言, 前期主要运行在浏览器中, 是一个单线程的脚本语言, 寄生在浏览器客户端软件的线程中,

最初只是做一下简单的事情, 如表单验证等, 后来才随着互联网不断发展起来的. 大致有这么关键阶段

  • 1995年, 网页客户端表单验证 , 用户交互 (简单场景)
  • 2005年, 异步编程, Ajax, 实现异步数据交互 (打通前后端)
  • 2015年, 简称 Es6, 引入了
  • 模块化, 面向对象, 等现代语言特性 (工程化)
  • 当下与未来: 服务端编程, Node.js, 强类型 Ts, 现代框架 React/Vue, WebAssembly 等

js 语言难点

首先是 作用域: 包含作用域提升, 作用域链, 块级作用域, AO, GO, VO 等概念;

其实是 函数闭包: 包含闭包的理解, 内存泄露, 函数中的 this 指向等;

然后是 面向对象: 理解函数本身也是一个对象, js 如何实现继承, 原型, 原型链等理解;

然后是 异步编程: 深入Promise 的运行过程, 基于 Promise A+ 规范手写实现 Promise;

然后是 语言特性: Es6+, let/const, 箭头函数, Esmodule, 新数据结构 Set, Map 等;

最后是 理解应用: 浏览器事件循环, 微任务/宏任务, 内存管理, 防抖节流, 框架原理等, 如 vue 响应式实现等;

浏览器工作原理

也是大致了解即可, 像我们熟悉的 Chrome, 本身也是一个用 C++ 写的程序, 细节上可以去 github 看一些开源版本的源码, 当然我们在应用阶段是不太需要的.

js 代码在浏览器的执行过程

  • 服务器 (静态资源)
  • 浏览器下载并解析静态文件, html 的, css 的, js 的代码
  • 浏览器执行相关代码 (识别 -> 下载 -> 解析 -> 执行)

浏览器内核

不同浏览器通常有不同的内核, 如 Firefox 用的是 Gecko, 早期的 IE 用的是 Trident, Chrome, Edge 用的是 Webkit 和 Blink 等等.

我们说的浏览器内核, 其实更多是浏览器的排版引擎:

  • 排版引擎 layout engine
  • 页面渲染引擎 rendering engine

浏览器渲染过程

html -> html Parser -> DOM Tree - > 和下面 css, js 并行的哈

css -> css Parser -> Style Rules -> Attachment -> Render Tree / layout -> Painting -> Display

js -> js引擎 -> DOM Tree -> ...

注意上面这个过程, JS 是可以控制 dom 和 css 的, 而 js 的执行又依赖于 js引擎, 因此, js 引擎非常关键.

js 引擎

js 是一门高级编程语言, 计算机的 cpu 并不认识, 最终都是需要通过一个 编译/转换 为机器语言.

我们写的所有 js 代码, 不论是给浏览器或 Node 执行, 最后都会 转换 为机器语言才能被执行, 这个转换的工具, 就是 js引擎.

类比其他高级语言也是一样的, 比如 Python, 它会在运行中将代码转为为 C 语言代码, C 再一步步转为机器码给 cpu 执行 (我猜这就是大家说它的的原因之一吧, 动态翻译, 动态转换, 还有锁); 如 Java 语言会现将代码变为为 字节码 运行在 jvm 虚拟机上, 从而实现跨平台, 但内存占用高; 对于新兴的语言像 Go 和 Rust, 都采用了直接将代码编译为二进制的可执行文件, 然后运行. 由此, 对于 js 引擎的理解也是差不多的.

常见的 js 引擎有 js 作者开发的 SpiderMonkey, 微软开发的 Chakra, 苹果的 JavaScriptCore, 谷歌开发的 V8 等. 其中最为出名的就是 V8 引擎, 几乎统治了整个市场.

浏览器内核 与 js引擎 的关系

以 webkit 内核为例, 它主要是由两部分组成的:

  • WebCore: 负责 html 解析, 布局, 渲染等操作
  • JavaScriptCore: 解析, 执行 js 代码等操作

我们在微信小程序中编写的 js 代码, 就是被 JsCore 解析执行的, 小程序也是将代码分为两部分, 渲染层 (Webview / UIWebView) 和逻辑层 (Jscore)

V8 引擎

它是 Google 用 C++ 编写的开源高性能 Js 和 WebAssembly 引擎, 用于 Chrome 和 Node.js等.

实现了 ECMAScript 标准, 在 windows7+, MacOs 10+, ARM, MIPS 处理器的 Linux 系统中都能运行, V8 可以独立运行, 也可以嵌入到任何 C++ 应用程序中. 它底层的架构大致是这样的:

Js源代码 -> Parser -> AST抽象语法树 -> Ignition / TurboFan -> byteCode / MachineCode -> 运行

Parser 主要做 词法分析 tokens 和语法分析, 然后再生成抽象语法树 AST , 涉及编译原理的知识

Ignition / TurboFan 是将 AST 转为字节码和机器码, 是为适配环境 win, mac, linux, nodejs 等

这里的 TurbFan 有点像一个热函数, 或者用来 "蒸馏" 字节码, 提高编译效率, 但如果传递的类型不一样的话, v8 会进行 Deoptimization 优化, 即返回, 重新生成字节, 就会造成性能损失了, 所以如果我们能实现保证输入, 输出的类型是事先确定的, 则编译执行效率会更高, 这不就是 TypeScript 嘛.

V8 引擎架构

再总结一遍吧, 这个 v8 是真的有点复杂, 本身超过 100w+ 的 C++ 代码, 挑重点模块小结:

  • Parser 是 转换器, 会将 js 代码转为 AST 抽象语法树, 若函数未被调用则不转, 关键词是 scanner, tokens

  • Ignition 是 解释器, 会将 AST 转为字节码 byteCode; 若函数仅调用一次, 则会解释并执行 byteCode

  • TurboFan 是 编译器, 若函数被多次调用则标记 热点函数 编译机器码提高性能, 类型变化则退回, 转为 byteCode

我一直是很讨厌写类型, 尤其讨厌 java , 后来学习了 scala 和 go 之后, 才渐渐感受到 静态语言, 类型系统 的强大之处, 安全, 高性能, 现在至少是不反感了, 至少从理论上 Ts 代码运行效率应该是比 js 要高的.

V8 引擎解析

具体可以参考 v8 引擎官网: https://v8.dev/, 这里是大致了解为主

Blink内核 -> Stream -> Scanner / tokens -> PreParser/ Parser 预解析 -> AST -> byteCode

这里重点了解这个 预解析, 如下面这段代码, 这个 inner 函数在 js 引擎中是不用去解析的, 因为都没有被调用过

function outer() {functon inner() {}// 几遍内部调用了, 一开始也不用解析 AST 的inner()
}// 调用
outer()

V8 的执行细节

  • Blink 将 js 源码传给 V8 引擎, Stream 获取源码并进行编码转换
  • Scanner 进行词法分析 (lexical analysic), 将代码转为 tokens
  • Parser 将 tokens 转为抽象语法树 AST, 也包含 PreParser

PreParser 预解析的目的是为了提高性能

  • 并非一开始所有的 js 代码都需要被执行, 这样会影响网页运行效率
  • V8 实现了 Lazy Parsing 延迟解析的方案, 将不必要的函数进行预解析 (定义, 位置, 作用域等); 对 函数全量解析是在 函数被调用 时才会进行

因此, 很多函数, 变量的作用域其实在定义时就已经被预解析了, 而与此相对的 this 则是在 运行时动态指向的

var name = 'youge'var num1 = 10
var num2 = 20
var ret = num1 + num2 // 1. 代码先会被解析, v8 会创建一个全局对象 GO
// 2. 运行代码前, 生成执行上下文栈 ECS (函数调用栈)
// 3. 执行全局代码, 创建 全局执行上下文栈 GES
// 4. GES 中有 VO (变量对象) 和 执行体(undefined->value); 当为全局时, VO = GO// 伪代码
var globalObj {String: "类",Date: '类',Window: globalObj,name: undefined,num1: undefined,num2: undefined,ret:  undefined}

同时这也引出了预解析的过程

console.log(num1) // undefined
num1 = 100

注意这里是不会报错的, 输出 undefiend, 因为 num1 已经被 预解析 了定义, 没有解析值而已, 或者换个词说叫 变量提升. 或者 作用域提升 , 但这种写法在其他编程语言是不允许的, 有点奇怪.

Js 代码执行过程

这部分的理解对学习 闭包 非常关键, 搞不清楚就废了.

初始化全局对象

js 引擎在执行代码前, 会现在 堆内存 中创建一个全局对象: Global Object , 简称 GO, 该对象所有的作用域 (scope) 都可以被访问, 里面包含 Date, Array, String, Number, SetTimeout... 等, 还有 window 属性指向了自己.

执行上下文栈 ECS

对于全局的代码块执行, 需要用到这个 Execution Context Stack, 简称 ESC, 此时的代码块会构建一个 Global Execution Context 简称 GEC, 这个全局执行上下文, 会被放在 执行上下文栈中去运行, 包含两部分:

  • 执行前 Parser -> AST, 将全局定义的变量, 函数等添加到 GO, 但不赋值 (undefined), 即 变量作用域提升
  • 执行时, 对变量进行赋值, 或者执行其他的函数
// 全局代码执行过程
cosole.log(name) // undefined
var name = 'youge'

这里的 name 变量, 在解析阶段会放到 GO 中, 赋值为 undefined, 在运行阶段 console.log(name) 时, 会从 ECS 中去找到 name, 再给它进行赋值为 'youge' .

但如果直接不定义就执行的话, 那就直接报错了.

console.log(name) 
// ReferenceError: name is not defined

函数执行上下文

函数是 js 里面的一等公民, 很特殊的东西. 会根据函数体创建一个 Function Execution Context 简称 FEC.

foo()  // 这里调用在解析时候不会执行的// 这里解析发现定义了一个函数, 会生成一个函数执行上下文 FEC
functon foo() {console.log('foo func')
}

和 java 是类似的, 方法会创建在堆内存中, 栈内存值存储函数的内存地址. js 的函数执行上下文包含 3 部分:

  • 执行时-解析, Parser -> AST, 创建 Activation Object, 简称 AO对象, 包含定义, 形参, arguments, 函数执行体等
  • 执行时-解析, 作用域链, 由 VO (函数中就是 AO 对象) 和 父级VO 组成作用域 [[scope]]: Parent Scope
  • 执行时-赋值, this 动态绑定指向

简单讲就是, 函数的定义, (执行之前) 就会在堆内存中开辟一块空间, 返回返回一个地址, 比如 0x100, 这个值存在 GO 里面, 且还存储了函数对象的父级作用域, 方便查找.

foo(123) // 解析阶段不执行function foo(num) {console.log(a)  // 能找到, 值为 undefiendvar a = 10var b = 20console.log('foo')
}

在 ECS 调用栈中分析一下这个 函数执行上下文 FEC:

解析阶段 VO 在全局作用域 GO 中, 定义了一个函数 foo, 所以 此时的 VO 就是 AO:

globalVO = {foo: <function refrence> // eg: OX100
}

**上下文执行-解析阶段 AO ** : 函数 foo 会在解析阶段被提升并赋值为函数定义.

AO: {num: 123,     // 参数赋值a: undefiend, // 变量提升b: undefiend, // 变量提升
}

此阶段不会执行 a = 10 也不会执行 console.log

上下文执行阶段 AO:

AO: {num: 123,     a: 10, // 赋值b: 20, // 赋值
}

执行阶段 js 会从上往下执行代码:

console.log(a); // 输出: undefined
var a = 10;     // 赋值:a = 10
var b = 20;     // 赋值:b = 20
console.log('foo');

注意点:

  • Console.log(a) 能找到 undefined 是因为 AO 提升了, 那 Console.log(k) 这就报错了
  • 执行阶段, 能找到 a 变量的值, 还涉及一个 函数的父级作用域, 即 函数在定义解析阶段就已确定, 和调用无关!
  • 相反, 和函数调用强相关的事 this 指向, 它是一个运行时动态确定的
  • 函数执行过程, 调用时, FEC 先入ESC栈 调用之后就会 出栈 ECS, 清空 GO, 然后被 js引擎 自动内存回收

如果在 GO 中再反复进行调用, 这会重复上述过程: 创建 FEC -> 入栈 -> AO -> 执行 -> 出栈 -> GC

foo(123);
foo(456);
...

js 作用域

继续来改造一下代码, 来看普通函数执行的情况:

// 全局代码执行过程-函数
var name = 'youge'foo(123) // 解析阶段不执行function foo(num) {console.log(a)  // 能找到, 值为 undefiendvar a = 10var b = 20var name = 'foo'console.log(name) // 这里输出的是 foo 
}

这里的 name 会输出为 foo 而不是 youge, 它会先从 AO 里面去找,

那如果, 没有 var name = 'foo' 时, 则会找到 外面的 name 值, 'youge' . 这看着是很直观, 但原理可能就不那么清晰了.

学过 Python 的兄弟都清楚, 变量查找遵循一个 LEGB 原则, 即局部作用域 -> 外嵌套函数域 -> 全局变量 -> 内置

那 js 也是有类似设计吗? 当然是有的, 这个就是 js 的作用域链.

从函数执行上下文 FEC 来说, 此时的 VO (变量作用域) 包含了 两部分:

  • AO: 函数定义, 参数, 执行体等
  • Scope Chian: VO + Parent Scope (父级作用域), 函数在编译阶段就已确定!.

由此对上面的代码来说, 函数 foo 的父级作用域, 就是全局, 而变量查找顺序就是沿着自己父级作用域一层层往上找.

如果是有多层函数嵌套, 不就是形成了一个 chain 嘛, 同时就设计到另外一个核心概念 闭包 , 呼之欲出啦.

继续来看, 函数嵌套的情况:

// 全局代码执行过程-函数嵌套
var name = 'youge'
foo(123) // 执行时, 创建 AOfunction foo(num) {console.log(a)var a = 10var b = 20// 嵌套函数只会预编译function bar() {console.log(name)}bar()
}

分析这个 ECS 执行上下文过程, 也是分为解析编译阶段 和 执行阶段.

GO 全局对象:

{name: undefiend,foo: 0x101
}

上下文-解析AO: Activation Objet:

// foo 函数会被编译, 假设地址是 ox101, 会存给 GO
{num: undefiend,a: undefiend,b: undefiend,// 嵌套函数 bar 不会被全编译, 仅预编bar: 0x102
}

**上下文解析-bar **函数的内存空间: 0x102

[[scope]]: parent scope // 父级是 foo 
函数执行体代码等

**上下文具体执行阶段 (代码从上往下: **

var name = 'youge' -> GO
foo(123) -> AO

GO:

name = 'youge' // 赋值

AO-foo 函数:

{num: 123, // 函数实参a: 10,    // 赋值b: 20, 
}

注意此时 foo 的 AO 是不会被回收的, 因为还没有执行完, 然后进入到了嵌套函数 Bar, 又会创建一个 AO:

AO: bar 函数:

{} // 此时是空对象

然后来执行 bar 函数体里面的代码:

console.log(name)

它先会找自己的 AO-bar, 发现是没有的,

然后就会沿着父级作用域找上层, 即 AO-foo 中, 发现还是没有,

继续沿着父级作用域找上层, 即 GO 中, 发现 此时的 name: 'youge' 找到啦, 则就输出了 youge. 当执行完后, bar 函数的 FEC 从 ESC 中出栈, 内存回收, 然后 foo 也会进行出栈, 内存回收 (因为没有引用或者闭包了), 然后等到 ESC 栈被清空时则说明程序结束了, 自动被 GC 啦.

继续来看, 函数调用函数的情况:

var msg = 'hello, GO'function foo() {console.log(msg)
}function bar() {var msg = 'hello, bar'foo()
}bar()

先说答案, 会输出 hello, GO, 因为 foo 函数的父级作用域是 GO, 就找到了, 这和函数调用位置无关, 在编译阶段就已经确定了.

也是首先在编译阶段:

GO:

{window,msg: undefined,foo: 0x101, // 会在堆内存开辟空间bar: 0x102: // 会在对内存开辟空间
}

foo 函数在堆内存表现: 0x101:

[parent scope]]: -> GO
函数体: 代码块

bar 函数在堆内存表现: 0x102:

[parent scope]]: -> GO
函数体: 代码块

然后是执行阶段 (从上到下, 函数体不执行, 因为已编译啦):

msg = 'hello, GO' // 赋值
bar()             // 函数调用

GO:

{window,msg: 'hello, GO,foo: 0x101, // 会在堆内存开辟空间bar: 0x102: // 会在堆内存开辟空间
}

AO-bar

{msg: "hello, bar" // 赋值操作
}

继续往下执行 foo() 啦, 然后就遇到:

console.log(message)

就开始从自己的 VO 开始查找, 注意这里的 VO = AO + [[parent-scope]].

一找发现 AO 里面没有 msg, 就会沿着父级作用域, 这里是 GO, 找, 呀发现有 hello, GO, 然后就输出啦.

变量环境和记录

我们上面所有分析的内容包括 GO, VO, AO 等都是早期 ECMA 版本, 即 es5 以前的规范, 即它大致说的是,

**"每个执行上下文都会被关联到一个变量对象 (VO), 在源代码中的变量和函数声明会被作为属性添加到 VO 中" **

但是, 后面的 ECMA 的版本规范中, 就对一些词汇进行了修改哦, 大意是:

"每个执行上下文都会关联到一个变量环境中(VE), 在执行代码中变量和函数的声明会作为环境记录 (Environment Record) 添加变量环境中"

对于函数来说, 参数也会被作为环境记录添加到变量环境中. 注意上面两个说法的区别点:

  • 作为变量属性 properties 添加到变量对象 object VS 作为环境记录 bindings in 变量环境

变量环境补充 (问的 AI)

在 js 中, 变量环境是 执行上下文 的重要组成部分, 用于记录当前作用域中声明的变量和函数, 可简单理解为 当前作用域的 "变量仓库".

js 在执行一段代码前, 会先创建一个执行上下文 Execution Context:

  • Variable Environment (变量环境): 用于保存变量和函数声明
  • Lexical Environment (词法环境): 用于处理 let / const 等块级作用域变量 hoisting
  • This Binding (this 绑定): 用于保存函数中 this 的指向值

即每个函数调用, 全局代码执行都会创建一个执行上下文; 每个上下文都会有一个 变量环境VE; 在代码执行前, JS 引擎会解析代码中的变量和函数声明; 会将这些声明作为 环境记录 ER 添加到变量环境中; 这样就构成了变量提升和作用域的基本机制.

补充-作用域练习题

Case 01:

var n = 100
function foo() {n = 200
}
foo()console.log(n)

输出的是 200.

因为 n 没有 AO, 就去父级找找到了 n, 因为找之前执行了 foo() 它会改变 全局变量 n 为 200.

Cae 02:

function foo() {console.log(n)var n = 200console.log(n)
}var n = 100 
foo()

会输出 undefined 和 200.

全局上下文创建, 编译阶段: GO: { n: undefiend, foo: 0x101};

调用时函数执行上下文 - 创建阶段:

​ AO: { n: undefined }, 注意, foo 函数在执行创建阶段, n 是有定义的, 提升为 undefined

执行阶段:

GO.n = 100
console.log(n)  // 输出 undefined(使用的是函数作用域的 n)
var n = 200     // 给 AO.n 赋值为 200
console.log(n)  // 输出 200

此时的 GO: { n: 100, foo: 0x101}; 而此时的 AO-foo: { n: 200 }

在第一行 打印 n 的时候, 会先找自己的 AO, 此时能找到的, 值为 undefined; 在第二次打印 n 时, foo 函数中的 AO 中的 n 已经变成了 200, 所以打印 200.

Case 3:

var n = 100function foo1() {console.log(n)
}function foo2() {var n = 200console.log(n)  foo1() 
}foo2()
console.log(n) 

会输出: 200, 100, 100

编译阶段: GO: {n: undefined, foo1: 0x101, foo2: 0x102}

执行上下文阶段 - 编译:

  • AO-foo1:
  • AO-foo2:

执行阶段:

GO.n = 100
foo2.AO.n = 200; 
cosole.log(n); // foo2的 AO 中有n:200cosole.log(n); // foo1的AO中无n,会往父级GO中找到100console.log(n); // 全局GO中有 n:100

GO: {n: 100, foo1: 0x101, foo2: 0x102}

AO-foo1: { }; [[parent-scope]] -> GO.n = 100

AO-foo2: { n: 200 }

Case 4:

var a = 100function foo() {console.log(a)return var a = 100
}foo()

输出是 undefined

这里的 foo 函数里面的 return 是执行阶段的, 不影响编译阶段的 foo.AO: { a: undefiend }; 因此在执行阶段查找 a 是能够在 foo 函数里面找到的.

Case 5:

function foo() {var a = b = 100
}foo()console.log(a)
console.log(b)

会报错: ReferenceError: a is not defined

补充这个在 js 函数中的这行 a = b = 100 会被转换为: var a = 10; b = 10

注意哦: 这里的 b 就是直接产生了, 哎呦我去!

编译: GO: { foo: 0x101 }

FEC- 解析: foo.AO: { a: undefined}

FEC - 执行: foo.AO: { a: 10 }; GO.b = 10

继续, GO 中执行 console.log(a) 会报错; console.log(b) 会找到 10.

至此, 关于深入理解 js 运行原理就差不多啦!

http://www.njgz.com.cn/news/773.html

相关文章:

  • Combinatorics 第二弹
  • DAY23
  • ES 多租户隔离技术
  • P2601 [ZJOI2009] 对称的正方形
  • 7/25
  • JAVA注解处理
  • day4
  • 30天总结-第二十七天
  • AWS WAF全新控制台体验:简化安全配置与实时威胁监控
  • [题解]GDOI2014
  • 从代码堆砌到工程思维:读《构建之法》的蜕变思考
  • 初遇框架
  • 2025/7/27 模拟赛总结
  • 扣子Coze智能体万字教程:从入门到精通,一文掌握AI工作流搭建
  • STM32简介 - LI,Yi
  • 数学相关学习笔记
  • 第二天
  • 【图论】总结 10:无向图的必经边与必经点
  • 准备去北京
  • 英国拟立法限制iOS与Android垄断地位,强制开放移动生态
  • vmware虚拟机安装
  • 通过Python交互式控制台理解Conv1d
  • 多Agent协作入门:群聊编排模式
  • nreset
  • 7月27号
  • 4
  • rapidocr v3.3.0发布了
  • 15. 三数之和
  • 20250727
  • JTAG和SWD的简单了解