不管是前端老司机,还是前端小白,看到标题中列举的这些概念,想必都是头大。其实你知道么?这些概念背后是有联系的,理清楚他们的关系,你才能准确且牢靠地记住他们。
也只有理清楚这些基本且重要的概念,你才能在前端的道路上越走越远。
好了,让我们开始吧。
执行上下文
执行上下文可以理解为函数运行的环境。每个函数执行时,都会给对应的函数创建这样一个执行环境。
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 foofunction foo() { console.log('function foo') }var foo = 20;复制代码
// 上栗的执行顺序为// 首先将所有函数声明放入变量对象中function foo() { console.log('function foo') }// 其次将所有变量声明放入变量对象中,但是因为foo已经存在同名函数,因此此时会跳过undefined的赋值// var foo = undefined;// 然后开始执行阶段代码的执行console.log(foo); // function foofoo = 20;复制代码
再看一个例子:
// demo01function 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的地址引用 a: undefined}复制代码
未进入执行阶段之前,变量对象中的属性都不能访问!但是进入执行阶段之后,变量对象转变为了活动对象,里面的属性都能被访问了,然后开始进行执行阶段的操作。
变量对象和活动对象其实都是同一个对象,只是处于执行上下文的不同生命周期。不过只有处于函数调用栈栈顶的执行上下文中的变量对象,才会变成活动对象。
// 执行阶段VO -> AO // Active ObjectAO = { arguments: {...}, foo:, 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中变量对象中的值,那么闭包就会产生。
// demo01function 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中相对晦涩的,平时开发过程中,要多思考其原理,这是一个必经的阶段,只要不断加深理解,我们才能真正掌握这些概念,也只有掌握好这些概念,我们才能在前端的道理上越走越远。