javascript 进阶之 - 预编译

引言

在 javascript 的世界里, 恨不能所有的东西都能打印, 都能 debugger 打断点, 一步一步看其执行的逻辑 . 但是:

  1. 有些属性限制了访问, 比如 [[Scopes]] , [[Class]] [[Caller]] … 让你不得而知其到底是个啥, 只能道听途说, 半猜地似懂非懂.
  2. 有些内置方法, 无法用代码查看 , 比如 Object 里面到底都进行了哪些操作.
  3. 有些过程, 无法掌控, 一闪而过, 看不到它究竟做了什么, 比如预编译.

这里姑且根据一些特性, 猜测一下预编译发生了哪些事, 是怎么处理的.

预编译做了什么?

  1. 语法分析, 代码是否有错误, 如有直接退出代码
  2. 变量和函数的声明收集, 变量赋值 undefined , 函数为其引用值
  3. 词法作用域的生成和挂载

也就是说, 不管定义在单个 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 监测到预编译 , 编译器到底做了哪些处理, 只能靠一些既有的现象, 推断出预编译大概做了些什么事情.

  1. 每次 script 加载解析执行或者函数调用的时候, 在执行前, 都会进行预编译.
  2. 预编译会在预编译时, 对预编译作用范围内的所有函数声明, 挂载一个变量保存变量查找的规则 , 是作用域链中不可变的一部分. 也就是 [[Scopes]] 属性.
  3. 作用域链包括函数执行时的局部 AO 和 函数 [[Scopes]] 属性值.

纯属主观臆断 , 权当娱乐.
待学习了编译原理和研究了 V8 源码, 再来把理讲明白, 或者打脸.

猜你喜欢

转载自blog.csdn.net/haokur/article/details/80541281