javascript 进阶之 - 预编译
引言#
在 javascript 的世界里, 恨不能所有的东西都能打印, 都能 debugger 打断点, 一步一步看其执行的逻辑 . 但是:
- 有些属性限制了访问, 比如
[[Scopes]]
,[[Class]]
[[Caller]]
… 让你不得而知其到底是个啥, 只能道听途说, 半猜地似懂非懂. - 有些内置方法, 无法用代码查看 , 比如
Object
里面到底都进行了哪些操作. - 有些过程, 无法掌控, 一闪而过, 看不到它究竟做了什么, 比如预编译.
这里姑且根据一些特性, 猜测
一下预编译发生了哪些事, 是怎么处理的.
预编译做了什么?#
- 语法分析, 代码是否有错误, 如有直接退出代码
- 变量和函数的声明收集, 变量赋值 undefined , 函数为其引用值
- 词法作用域的生成和挂载
也就是说, 不管定义在单个 js
何处的变量和函数都会优先挂载, 于是代码在书写顺序上, 可以先赋值 , 再声明变量 ; 先调用 ,再声明函数.
a = 10;
var a ;
var b = 10;
var test = 'xx'
test();
function test(){
console.log(b)
}
依预编译的规律, 编译器 重排
的代码可能是这样的
// 先从上到下 , 收集变量声明
var a ;
var b ;
var test;
// 再收集函数声明
function test(){
}
// ------ 以上为预编译 ------
// 以下开始执行
b = 10
test = 'xx'
test(); // Uncaught TypeError: test is not a function
以上, 虽然预编译 , 可以使得在声明前( 代码顺序的前后 )进行赋值 , 调用, 但代码规范却最好不要这样写, 还是要规范书写顺序, 先声明后再赋值调用等.
所以预编译的目的, 并不是为了让开发者能天马行空地不管顺序乱写代码, 而可能是代码执行的内在逻辑:
即, 解决了变量如何查找的问题
就是从其作用域链上查找. 所以在执行前要生成作用域链, 那就先把所有的变量声明和函数声明都收集, 并挂载在对应的作用域链的位置. 特殊的是, 挂载顺序, 是先是变量声明, 再是函数声明, 也就是函数的声明是可以覆盖变量的声明, 而变量声明其返回值是undefined , 而函数声明返回值是函数的引用.
为什么函数声明可以覆盖变量声明, 而且函数声明是有值的?
也就是说, 在函数声明的时候, 先在内存里会创建一个函数对象, 然后返回一个引用地址.函数声明的变量名拿到这个引用值.
那么为什么 var a = 1, a 还是 undefined , 而 function test() {} , test 却是实际值?
var a = 1
是声明加赋值语句 , 预编译只管声明, 也就是 var a ; 而 function test() {}
只有声明体 , function test()
可以理解为 , function
为声明关键词, test(){}
是堆内存中有一个 test
为名的对象, 而声明这个对象是一个函数.
预编译在什么时候进行?#
有一种说法是在
js
脚本加载之后, 执行之前进行了预编译, 也就是说预编译至始至终只进行一次 ! ? 这显然是站不住脚的.
看以下情景
var a = 10;
function test(){
var b = 20;
}
函数
test
中也有声明局部变量b
, 那么要不要对它预编译? 很显然是不用, 因为test
函数可能永远都不会执行, 预编译了就造成了浪费, 预编译在保证程序的正常运行的前提下要尽可能地快完成.
怎么证明? 借用 const
特性
debugger
console.log(1)
const c = 1
const c = 2
可以看出如果重复定义 const
, 那么预编译就不会通过, console.log(1)
也未执行.
还可以得出, debugger
进入断点, 是在预编译之后.
再看:
const c = 1
console.log(1)
function test(){
const c = 2 ;
const c = 3 ;
}
在
test
方法未执行, 程序是正常运行的, 所以也就是, 首次预编译, 忽略了函数体内部. 在执行到函数的时候, 函数体内才进行一次预编译.
const c = 1
function test(){
console.log(1)
debugger
const c = 2 ;
const c = 3 ;
}
test();
同样, 既没有打印
1
, 也没有进断点, 而是直接抛出错误Uncaught SyntaxError: Identifier 'c' has already been declared
’
所以预编译发生在每一次执行前.
执行包括:
1. 加载脚本成功后或者解析到 script
标签时, 全局的执行
2. 调用执行某一个方法的时候
函数的预编译#
函数体执行前的预编译有点特殊, 应该其中有参数参与.
var a = 20;
function test(a){
debugger
var a = 10
function a(){
}
}
test(function b(){})
当形参 , 局部变量, 局部函数同名一起出现的时候, 最后预编译后 a 是谁?
可以看出函数体内的函数声明, 是放在最后的. 那么 局部变量和参数谁先谁后?
var a = 20;
function test(a){
debugger
var a = 10
var b = 20
}
test(2);
可以看出, 预编译时, 参数是在局部变量之后.
于是 , 预编译大致是:
function test(a){
var a ;
params { a : 2 };
function a(){
}
// ------ 以上预编译 ------
a = 10
}
test(2)
函数体内预编译顺序:
var
变量声明 ->params
参数声明 ->function
函数声明
预编译时的作用域#
在全局代码执行时或函数执行之前 , 会收集变量声明和函数声明, 这些收集的声明, 合称变量对象( VO ) .
- 变量声明 , 赋值为 undefined
- 函数声明, 赋值为函数对象的引用对象
在创建函数的同时 , 还会对
[[Scopes]]
属性赋初值.
这个初值, 为其外层的VO 对象
+[[Scopes]]
全局中, 可以认为其[[Scopes]]
为空数组. 全局为最外层, 没有上一层.
var a = 10;
function test(){
console.log(a);
var b = 20;
function inner(){
console.log(b)
}
}
test();
以上 , 预编译时 . 全局生成 VO 大概像这样
global [[Scopes]] = []
global VO {
a: undefined,
test:{
name:'test',
[[Caller]]:...,
[[Scopes]]: [ global VO , ...global [[Scopes]] ],
...
},
...
}
当预编译完成 , 开始逐步执行, VO 被激活 => AO, 执行到 test
, 进入函数 , 进入再一次的小范围预编译.
test [[Scopes]] = [ global VO ]
Test VO {
b : undefined,
inner:{
name:'inner',
[[Caller]]:...,
[[Scopes]]: [ Test VO , ...test [[Scopes]] ],
...
}
}
代码执行时, 查找变量或者函数的规则总是 , 先检查本作用域的 VO/AO 是否存在, 不存在则根据 [[Scopes]]
上查找. 到 globao VO/AO 终止.
总结#
有种越解释越乱越复杂的感觉 , 因为没亲自动手实现过 js 解释器, 也无法debugger 监测到预编译 , 编译器到底做了哪些处理, 只能靠一些既有的现象, 推断出预编译大概做了些什么事情.
- 每次 script 加载解析执行或者函数调用的时候, 在执行前, 都会进行预编译.
- 预编译会在预编译时, 对预编译作用范围内的所有函数声明, 挂载一个变量保存变量查找的规则 , 是作用域链中不可变的一部分. 也就是
[[Scopes]]
属性. - 作用域链包括函数执行时的局部
AO
和 函数[[Scopes]]
属性值.
纯属主观臆断 , 权当娱乐.
待学习了编译原理和研究了 V8 源码, 再来把理讲明白, 或者打脸.
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· C#/.NET/.NET Core技术前沿周刊 | 第 29 期(2025年3.1-3.9)
· 从HTTP原因短语缺失研究HTTP/2和HTTP/3的设计差异