Skip to content

预编译、作用域与执行上下文 #28

Open
@TieMuZhen

Description

@TieMuZhen

JavaScript三部曲

  • 语法分析
  • 预编译
  • 解释执行

语法分析是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。并检查你的代码有没有什么低级的语法错误,如果有,引擎会停止执行并抛出异常。

解释执行顾名思义便是执行代码了;

预编译简单理解就是在内存中开辟一些空间,存放一些变量与函数 ;

在学习预编译前先学习下执行上下文作用域

一、执行上下文

JavaScript解释阶段便会确定作用域规则,因此作用域在函数定义时就已经确定了,而不是在函数调用时确定,但是执行上下文是函数执行之前创建的 ,执行上下文是代码运行时的上下文环境。

什么是执行上下文栈?

js引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文,处于活动状态的执行上下文环境只有一个。

执行上下文的类型

执行上下文总共有三种类型:

  • 全局执行上下文: 这是默认的、最基础的执行上下文。不在任何函数中的代码都位于全局执行上下文中。它做了两件事:
    • 创建一个全局对象,在浏览器中这个全局对象就是 window 对象。
    • 将 this 指针指向这个全局对象。一个程序中只能存在一个全局执行上下文。
GO global object 全局上下文
1、找变量
2、找函数声明
3、执行
  • 函数执行上下文: 每次调用函数时,都会为该函数创建一个新的执行上下文。在函数上下文中,我们用活动对象(activation object, AO)来表示变量对象。只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫AO,而只有活动对象上的各种属性才能被访问。
AO activation object 活跃对象 函数上下文
1、找形参和变量声明
2、实参值赋给形参
3、找函数声明,赋值
4、执行
  • Eval 函数执行上下文: 运行在 eval 函数中的代码也获得了自己的执行上下文,但由于 Javascript 开发人员不常用 eval 函数,所以在这里不再讨论。

执行上下文的生命周期

执行上下文的生命周期包括三个阶段:创建阶段执行阶段回收阶段,本文重点介绍创建阶段。

1. 创建阶段
当函数被调用,但未执行任何其内部代码之前,会做以下三件事:

  • 创建变量对象(Variable Object,VO):首先初始化函数的参数arguments提升函数声明和变量声明函数提升要比变量提升的优先级要高一些,且不会被变量声明覆盖,但是会被变量赋值之后覆盖。
  • 创建作用域链(Scope Chain):在执行期上下文的创建阶段,作用域链是在变量对象之后创建的。作用域链本身包含变量对象。作用域链用于解析变量。
  • 确定this指向。

在一段 JS 脚本执行之前,要先解析代码(所以说 JS 是解释执行的脚本语言),解析的时候会先创建一个全局执行上下文环境,先把代码中即将执行的变量、函数声明都拿出来。变量先暂时赋值为 undefined,函数则先声明好可使用。这一步做完了,然后再开始正式执行程序。

另外,一个函数在执行之前,也会创建一个函数执行上下文环境,跟全局上下文差不多,不过函数执行上下文中会多出 this arguments 和函数的参数

2. 执行阶段
执行变量赋值、代码执行

3. 回收阶段
执行上下文出栈等待虚拟机回收执行上下文

二、作用域

作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。

因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。

经典的一道面试题

var a = 1
function out(){
    var a = 2
    inner()
}
function inner(){
    console.log(a)
}
out()  // 1

三、预编译

1、预编译在什么时候发生

预编译分为全局预编译函数预编译:全局预编译发生在页面加载完成时执行,而函数预编译发生在函数执行的前一刻。

2、全局预编译的步骤

  1. 创建GO(Global Object,全局执行期上下文,在浏览器中为window)对象;
  2. 寻找var变量声明,并赋值为undefined;
  3. 寻找function函数声明,并赋值为函数体;
  4. 执行代码。

3、函数预编译的步骤

  1. 创建AO对象,函数执行上下文。
  2. 寻找函数的形参和变量声明,将变量和形参名作为AO对象的属性名,值设定为undefined.
  3. 将形参和实参相统一,即更改形参后的undefined为具体的形参值。
  4. 寻找函数中的函数声明,将函数名作为AO属性名,值为函数体。

4、示例分析

先来区分理解一下这2个概念: 变量声明var …函数声明function(){}函数提升要比变量提升的优先级要高一些,且不会被变量声明覆盖,但是会被变量赋值之后覆盖。

<script>
    var a = 1;
    console.log(a);
    function test(a) {
        console.log(a);
        var a = 123;
        console.log(a);
        function a() {}
        console.log(a);
        var b = function() {}
        console.log(b);
        function d() {}
    }
    var c = function (){
        console.log("I at C function");
    }
    console.log(c);
    test(2);
</script>

分析过程如下:

  1. 页面产生便创建了GO全局对象(Global Object)(也就是window对象);
  2. 第一个脚本文件加载;
  3. 脚本加载完毕后,分析语法是否合法;
  4. 开始预编译 查找变量声明,作为GO属性,值赋予undefined; 查找函数声明,作为GO属性,值赋予函数体;

预编译

//抽象描述
GO/window = {
    a: undefined,
    c: undefined,
    test: function(a) {
        console.log(a);
        var a = 123;
        console.log(a);
        function a() {}
        console.log(a);
        var b = function() {}
        console.log(b);
        function d() {}
    }
}

解释执行代码(直到执行调用函数test(2)语句)

//抽象描述
GO/window = {
    a: 1,
    c: function (){
        console.log("I at C function");
    }
    test: function(a) {
        console.log(a);
        var a = 123;
        console.log(a);
        function a() {}
        console.log(a);
        var b = function() {}
        console.log(b);
        function d() {}
    }
}

执行函数test()之前,发生预编译

  1. 创建AO活动对象(Active Object);
  2. 查找形参和变量声明,值赋予undefined;
  3. 实参值赋给形参;
  4. 查找函数声明,值赋予函数体;

预编译之前面1、2两小步如下:

//抽象描述
AO = {
    a:undefined,
    b:undefined,
}

预编译之第3步如下:

//抽象描述
AO = {
    a:2,
    b:undefined,
}

预编译之第4步如下:

//抽象描述
AO = {
    a:function a() {},
    b:undefined
    d:function d() {}
}

执行test()函数时如下过程变化:

//抽象描述
AO = {
    a:function a() {},
    b:undefined
    d:function d() {}
}
--->
AO = {
    a:123,
    b:undefined
    d:function d() {}
}
--->
AO = {
    a:123,
    b:function() {}
    d:function d() {}
}

执行结果:

注意:

预编译阶段发生变量声明和函数声明,没有初始化行为(赋值),匿名函数不参与预编译 ; 只有在解释执行阶段才会进行变量初始化 ;


经典面试题

// 求输出是什么
function Foo() {
    getName = function() {console.log(1)};
    return this;
}
Foo.getName = function() {console.log(2)};
Foo.prototype.getName = function() {console.log(3)};
var getName = function() {console.log(4)};
function getName() {console.log(5)};

Foo.getName();
getName();
Foo().getName();
getName();
new Foo.getName();
new Foo().getName();
new new Foo().getName();

1、 首先预编译阶段,变量声明与函数声明提升至其对应作用域的最顶端。

因此上面的代码编译后如下(函数声明的优先级先于变量声明):

GO = {
    Foo: function() {
        getName = function(){ console.log(1);};
        return this;
    },
    getName: function() { console.log(1)} // getName又被改写了,之前这里是打印4
}

预编译完成后进入执行阶段
2、 Foo.getName();直接调用Foo上getName方法,输出2

3、 getName();输出4,getName被重新赋值了

4、 Foo().getName();执行Foo(),window的getName被重新赋值,返回this;浏览器环境中,非严格模式,this 指向 window,this.getName();输出为1.

如果是严格模式this指向undefined,此处会抛出错误。

如果是node环境中,this指向globalnode的全局变量并不挂在global上,因为global.getName对应的是undefined,不是一个function,会抛出错误。
5、 getName();已经抛错的自然走不动这一步了;继续浏览器非严格模式;window.getName被重新赋过值,此时再调用,输出的是1

6、 new Foo.getName();考察运算符优先级的知识,new 无参数列表,对应的优先级是18;成员访问操作符 . , 对应的优先级是 19。因此相当于是new (Foo.getName)();new操作符会执行构造函数中的方法,因此此处输出为 2.

7、 new Foo().getName();new 带参数列表,对应的优先级是19,和成员访问操作符.优先级相同。同级运算符,按照从左到右的顺序依次计算。new Foo()先初始化 Foo 的实例化对象,实例上没有getName方法,因此需要原型上去找,即找到了Foo.prototype.getName,输出3

8、 new new Foo().getName(); new 带参数列表,优先级19,因此相当于是new (new Foo()).getName();先初始化 Foo 的实例化对象,然后将其原型上的 getName 函数作为构造函数再次 new ,输出3

因此最终结果如下:

Foo.getName(); //2
getName();//4
Foo().getName();//1
getName();//1
new Foo.getName();//2
new Foo().getName();//3
new new Foo().getName();//3

参考文献

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions