执行上下文、变量对象、作用域链、this,看这篇就够了!

不管是前端老司机,还是前端小白,看到标题中列举的这些概念,想必都是头大。其实你知道么?这些概念背后是有联系的,理清楚他们的关系,你才能准确且牢靠地记住他们。

也只有理清楚这些基本且重要的概念,你才能在前端的道路上越走越远。

好了,让我们开始吧。

执行上下文

执行上下文可以理解为函数运行的环境。每个函数执行时,都会给对应的函数创建这样一个执行环境。

JS运行环境大概包括三种情况:全局环境、函数环境、eval环境(不推荐使用,所以不讨论)。

一个JS程序中,必定会产生多个执行上下文,JS引擎会以栈的方式处理它们,这个栈,我们称之为函数调用栈。栈底永远都是全局上下文,栈顶就是当前正在执行的上下文。

由于栈是先进后出的结构,我们不难推出以下四点:

  • 只有栈顶的上下文处于执行中,其他上下文需要等待
  • 全局上下文只有唯一的一个,它在浏览器关闭时出栈
  • 函数的执行上下文的个数没有限制
  • 每次某个函数被调用,就会有个新的执行上下文为其创建。

当然,光知道这些还是不够,我们还必须了解执行上下文的生命周期。

执行上下文的生命周期

当调用一个函数时,一个新的执行上下文就会被创建。而一个执行上下文的生命周期可以分为两个阶段。

创建阶段

在这个阶段中,执行上下文会分别创建变量对象,建立作用域链,以及确定this的指向。

代码执行阶段

创建完成之后,就会开始执行代码,这个时候,会完成变量赋值,函数引用,以及执行其他代码。

至此,我们终于知道执行上下文跟变量对象、作用域链及this的关系。

接下来我们重点介绍这三个概念。

变量对象

当一个函数被调用时,执行上下文就创建了,执行上下文包含了函数所有声明的变量和函数,保存这些变量跟函数的对象,我们称之为变量对象。

变量对象的创建,依次经历了以下几个过程。

  • 建立arguments对象。检查当前上下文中的参数,建立该对象下的属性与属性值。
  • 检查当前上下文的函数声明,也就是使用function关键字声明的函数。在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果函数名的属性已经存在,那么该属性将会被新的引用所覆盖。
  • 检查当前上下文中的变量声明,每找到一个变量声明,就在变量对象中以变量名建立一个属性,属性值为undefined。如果该变量名的属性已经存在,为了防止同名的函数被修改为undefined,则会直接跳过,原属性值不会被修改。
    举个反例,很多人对以下代码存在疑问,既然变量声明的foo遇到函数声明的foo会跳过,可是为什么最后foo的输出结果仍然是被覆盖了?
function foo() { console.log('function foo') }
var foo = 20;

console.log(foo); // 20
复制代码

这是因为上面的三条规则仅仅适用于变量对象的创建过程。也就是执行上下文的创建过程。而foo = 20是在执行上下文的执行过程中运行的,输出结果自然会是20。对比下例。

console.log(foo); // function foo
function foo() { console.log('function foo') }
var foo = 20;
复制代码
// 上栗的执行顺序为

// 首先将所有函数声明放入变量对象中
function foo() { console.log('function foo') }

// 其次将所有变量声明放入变量对象中,但是因为foo已经存在同名函数,因此此时会跳过undefined的赋值
// var foo = undefined;

// 然后开始执行阶段代码的执行
console.log(foo); // function foo
foo = 20;
复制代码

再看一个例子:

// demo01
function test() {
    console.log(a);
    console.log(foo());

    var a = 1;
    function foo() {
        return 2;
    }
}

test();
复制代码

我们直接从test()的执行上下文开始理解。全局作用域中运行test()时,test()的执行上下文开始创建。为了便于理解,我们用如下的形式来表示

// 创建过程
testEC = {
    // 变量对象
    VO: {},
    scopeChain: {}
}

// 因为本文暂时不详细解释作用域链,所以把变量对象专门提出来说明

// VO 为 Variable Object的缩写,即变量对象
VO = {
    arguments: {...},  //注:在浏览器的展示中,函数的参数可能并不是放在arguments对象中,这里为了方便理解,我做了这样的处理
    foo: <foo reference>  // 表示foo的地址引用
    a: undefined
}

复制代码

未进入执行阶段之前,变量对象中的属性都不能访问!但是进入执行阶段之后,变量对象转变为了活动对象,里面的属性都能被访问了,然后开始进行执行阶段的操作。

变量对象和活动对象其实都是同一个对象,只是处于执行上下文的不同生命周期。不过只有处于函数调用栈栈顶的执行上下文中的变量对象,才会变成活动对象。

// 执行阶段
VO ->  AO   // Active Object
AO = {
    arguments: {...},
    foo: <foo reference>,
    a: 1,
    this: Window
}
复制代码

因此,上面的例子demo1,执行顺序就变成了这样

function test() {
    function foo() {
        return 2;
    }
    var a;
    console.log(a);
    console.log(foo());
    a = 1;
}

test();
复制代码

作用域链与闭包

变量对象讲完了,接着是作用域链,这里就不得不先提下作用域。

作用域

作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。

JavaScript中只有全局作用域与函数作用域。言外之意是:javascript除了全局作用域之外,只有函数可以创建的作用域

JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段代码执行阶段

编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。

执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建。

理解这点很重要,我们面试过程中,经常会被问到“自由变量”的取值问题。

什么是“自由变量”?先看个例子:

var x = 10;
function fn() {
    var b = 20;
    console.log(x+b); // x在这里就是一个自由变量
}
复制代码

取x的值时,需要到另一个作用域中取,x就被称作“自由变量”。

“自由变量”的取值,难倒一片的人,不信,看看下面这个例子:

var x = 10;
function fn() {
    console.log(x);
}
function show(f){
    var x = 20;
    (function () {
        f(); // 这里输出什么???
    })();
}
show(fn);
复制代码

你的第一反应是不是20?答案是10!!

其实这个问题很简单,自由变量要到创建这个函数的那个作用域中取值——是“创建”,而不是“调用”

为什么呢?因为作用域是在代码编译过程就确定下来的,然后就不会改变,这就是所谓的“静态作用域”。

本例中,在fn函数取自由变量x的值时,要到哪个作用域中取?——要到创建fn函数的那个作用域中取——无论fn函数将在哪里调用。fn明显是在全局环境下创建的,x明显就是10。

作用域链

上面的例子,只是跨一个作用域去寻找。

如果跨了一步,还没找到呢?——接着跨!——一直跨到全局作用域为止。要是在全局作用域中都没有找到,那就是真的没有了。

这个一步一步“跨”的路线,我们称之为——作用域链。

我们拿文字总结一下取自由变量时的这个“作用域链”过程:(假设a是自由量)

第一步,现在当前作用域查找a,如果有则获取并结束。如果没有则继续;

第二步,如果当前作用域是全局作用域,则证明a未定义,结束;否则继续;

第三步,(不是全局作用域,那就是函数作用域)将创建该函数的作用域作为当前作用域;

第四步,跳转到第一步。

闭包

闭包是一种特殊的对象。

它由两部分组成。执行上下文(代号A),以及在该执行上下文中创建的函数(代号B)。

当B执行时,如果访问了A中变量对象中的值,那么闭包就会产生。

// demo01
function foo() {
    var a = 20;
    var b = 30;

    function bar() {
        return a + b;
    }

    return bar;
}

var bar = foo();
bar();
复制代码

上面的例子,首先有执行上下文foo,在foo中定义了函数bar,而通过对外返回bar的方式让bar得以执行。当bar执行时,访问了foo内部的变量a,b。因此这个时候闭包产生。

闭包的应用场景

除了面试,在实践中,闭包有两个非常重要的应用场景。分别是模块化与柯里化。

this

this或许是最让初学者头疼的概念了吧。this难就难在指向上。

请记住:this的指向,是在函数被调用的时候确定的,在函数执行过程中,this一旦被确定,就不可更改了

我们来看看几种情况:

全局对象中的this

全局环境中的this,指向它本身。

函数中的this

在一个函数上下文中,this由调用者提供,由调用函数的方式来决定。如果调用者函数,被某一个对象所拥有,那么该函数在调用时,内部的this指向该对象。如果函数独立调用,那么该函数内部的this,则指向undefined。但是在非严格模式中,当this指向undefined时,它会被自动指向全局对象。切记,函数执行过程中,this一旦被确定,就不可更改。

'use strict';
var a = 20;
function foo () {
    var a = 1;
    var obj = {
        a: 10,
        c: this.a + 20,
        fn: function () {
            return this.a;
        }
    }
    return obj.c;

}
console.log(foo());    // ?
console.log(window.foo());  // ?
复制代码

执行foo()时,函数独立调用,所以this指向undefined(因为是严格模式),所以执行this.a时报错。

执行window.foo()时,this.a = 20,结果为40.

function foo() {
    console.log(this.a)
}

function active(fn) {
    fn(); // 真实调用者,为独立调用
}

var a = 20;
var obj = {
    a: 10,
    getA: foo
}

active(obj.getA); // 20
复制代码

使用call,apply显示指定this

call与applay

构造函数与原型方法上的this

function Person(name, age) {

    // 这里的this指向了谁?
    this.name = name;
    this.age = age;   
}

Person.prototype.getName = function() {

    // 这里的this又指向了谁?
    return this.name;
}

// 上面的2个this,是同一个吗,他们是否指向了原型对象?

var p1 = new Person('Nick', 20);
p1.getName();
复制代码

this,是在函数调用过程中确定,因此,搞明白new的过程中到底发生了什么就变得十分重要。

通过new操作符调用构造函数,会经历以下4个阶段。

  • 创建一个新的对象;
  • 将构造函数的this指向这个新对象;
  • 指向构造函数的代码,为这个对象添加属性,方法等;
  • 返回新对象。

因此,当new操作符调用构造函数时,this其实指向的是这个新创建的对象,最后又将新的对象返回出来,被实例对象p1接收。因此,我们可以说,这个时候,构造函数的this,指向了新的实例对象,p1。

而原型方法上的this就好理解多了,根据上边对函数中this的定义,p1.getName()中的getName为调用者,他被p1所拥有,因此getName中的this,也是指向了p1。

写在最后

本文提到的概念,都是JavaScript中相对晦涩的,平时开发过程中,要多思考其原理,这是一个必经的阶段,只要不断加深理解,我们才能真正掌握这些概念,也只有掌握好这些概念,我们才能在前端的道理上越走越远。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值