作用域
作用域 是定义变量的区域,规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。
作用域是确定在何处以及如何查找变量的一套规则。JavaScrip 采用词法作用域(lexical scoping),也就是静态作用域。
词法作用域(静态作用域)和动态作用域的区别
词法作用域:因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。函数的作用域基于函数创建的位置。
动态作用域:而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。
执行上下文
JavaScript 中有三种执行上下文
- 全局执行上下文 :这是默认或者说基础的上下文,任何不在函数内部的代码都在全局上下文中。它会执行两件事:创建一个全局的
window
对象(浏览器的情况下),并且设置this
的值等于这个全局对象。一个程序中只会有一个全局执行上下文。 - 函数执行上下文: 每当一个函数被调用时, 都会为该函数创建一个新的执行上下文。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序(将在后文讨论)执行一系列步骤。
- Eval 函数执行上下文: — 执行在
eval
函数内部的代码也会有它属于自己的执行上下文
执行上下文栈
JavaScript 引擎不是一行行执行的,而是一段段执行的。当执行一段代码的时候,会进行一个 “准备工作”,比如变量提升(var 声明的变量)或函数提升(函数声明,非函数表达式),这个“准备工作”就是构建执行上下文。JavaScript 引擎创建执行上下文栈(ECS)来管理执行上下文。
var foo = function () {
console.log('foo1');
}
foo(); // foo1
var foo = function () {
console.log('foo2');
}
foo(); // foo2
// 原因:存在 `var foo` 变量提升,函数表达式不会提升,实质代码如下:
var foo;
foo = function () {
console.log('foo1');
}
foo(); // foo1
foo = function () {
console.log('foo2');
}
foo(); // foo2
function foo() {
console.log('foo1');
}
foo(); // foo2
function foo() {
console.log('foo2');
}
foo(); // foo2
// 原因:存在函数提升,函数声明可以提升,实质代码如下:
var foo;
foo = function () {
console.log('foo1');
}
foo = function () {
console.log('foo2');
}
foo(); // foo2
foo(); // foo2
关于提升,函数创建有函数声明和函数表达式之分,函数表达式是不会被提升的, 函数声明存在变量提升。
// 函数表达式不会提升
foo(); // TypeError: undefined is not a function
var foo = function() {
console.log('foo');
}
// 函数声明可以提升
foo(); // foo
function foo() {
console.log('foo');
}
JS 引擎在解释代码时,最先遇到全局代码,所以初始化的时候首先就会向执行上下文压入全局执行上下文(globalContext),并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前,ECStrack 底部永远有一个 globalContext。
当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。(堆栈处理)
- 创建阶段,执行上下文会创建变量对象(VO),建立作用域链(scope chain),以及确定 this 的指向
- 代码执行阶段,创建完成后,才会开始执行代码,这个过程就是变量赋值(AO),函数引用,以及执行其他代码
拓展:变量提升
当游览器开辟出供代码执行的栈内存后,代码并没有自上而下立即执行,而是继续做了一些事情,把当前作用域中所有带 var / function 关键词的进行提前声明和定义 => 变量提升机制
- var:带var的只是提前声明(declare)“var a;”如果只声明没有赋值,默认值是undefined
- function:带function的不仅声明,而且还定义了(defined) “a=13” 定义其实就是赋值,准确来说就是让变量个某个值进行关联
举个栗子
console.log(a); // undefined
var a = 12;
var b = a;
b = 13;
console.log(a); // 12
console.log(sum(10,20)); // 30
function sum(n, m) {
return n + m;
}
/*
函数表达式方式,由于使用VAR来创建SUM,变量提升阶段只会声明变量,不会赋值,所以此时函数在前面执行,函数是没有值的,不能执行(真实项目中这种方式最常用,因为它操作严谨)
*/
console.log(sum); // undefined
// sum(10, 20); // TypeError: sum is not a function
var sum = function (n, m) {
return n + m;
};
console.log(sum(10, 20)); // 30
变量提升中关于判断条件的处理
1.变量
全局作用域(栈内存)
1、变量提升
不管条件是否成立都要进行变量提升
var a; // => 创建一个全局变量a window.a
2、代码执行
console.log(a); // undefined
if (!("a" in window)) { // true
var a = 13;
}
console.log(a); // undefined
2.函数
全局作用域(栈内存)
1、变量提升
但是函数有特殊性,在老版本游览器中,确实不论条件是否成立,函数也是提前声明或者定义的,但是新版本游览器中,为了兼容ES6严谨的语法规范,条件中的函数在变量提升阶段只能提前声明,不能提前定义
function fn;
2、代码执行
console.log(fn()); // undefined
fn(); // Uncaught TypeError: fn is not a function
if (!("a" in window)) {
// 条件成立,进来后的第一件事就是给FN赋值,然后再代码执行
fn(); // haha
function fn() {
console.log("haha");
}
}
fn();
带var和不带var的区别
在全局作用域下的区别
不带var的:相当于给全局对象window设置了一个属性 a
a=13;
带var的:是在全局作用域下声明了一个变量b(全局变量),但是在全局下声明的变量也同样相当于给window增加了一个对应的属性(只有全局作用域具备这个特点)
/*
带var和不带var的区别
*/
a = 13;
console.log(a); // => window.a
var b = 14;
console.log(b); // 14 // => 创建变量b & 给window创建了属性b
console.log(window.b); // 14
JavaScript 执行一段可执行代码时,会创建对应的执行上下文,那么每个执行上下文中都有哪些内容?
执行上下文 3 个重要的属性:
- 变量对象(Variable Object,VO)
- 作用域链(Scope Chain)
- this
变量对象
变量对象 是与执行上下文相关的 数据作用域,存储了上下文中定义的 变量 和 函数声明。
变量对象有全局上下文中变量对象和函数上下文下的变量对象之分。
全局上下文中变量对象
全局上下文的变量对象就是全局对象(GO),比如 web 浏览器中,全局对象就有 window 属性指向自身,window 对象。全局对象是由 Object 构造函数实例化的一个对象。预定义一大堆函数和属性。作为全局变量的宿主。
全局上下文的变量对象就是全局对象,web 浏览器是 window、self 或者 frames ,node 中是 global, Web Workers 中是 self,不同环境下的统一标准的全局变量是 globalThis。
在松散模式下,可以在函数中返回 this 来获取全局对象,但是在严格模式和模块环境下,this 会返回 undefined。
函数上下文的变量对象
函数上下文中用 活动对象(Activation Object) 表示变量对象。
活动对象在进入函数上下文时被创建,通过函数的 arguments 属性初始化。分析一个函数上下文的 AO 对象很重要。
活动对象和变量对象其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,才叫 activation object。
活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性值是 Arguments 对象。即,调用函数时,会为其创建一个 Arguments 对象,并自动初始化局部变量 arguments,指代该 Arguments 对象。所有作为参数传入的值都会成为 Arguments 对象的数组元素。
执行过程
进入执行上下文时,还没有执行代码。进入执行上下文时,初始化的规则如下,从上到下就是一种顺序,变量对象会包括:
- 函数的所有形参 (如果是函数上下文)
(1)由名称和对应值组成的一个变量对象的属性被创建
(2)没有实参,属性值设为 undefined
- 函数声明
(1)由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
(2)如果变量对象已经存在相同名称的属性,则完全替换这个属性
- 变量声明
(1)由名称和对应值(undefined)组成一个变量对象的属性被创建
(2)如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性
console.log(foo); // undefined
function foo(){
console.log("foo");
}
var foo = 1;
举个栗子
function foo(a) {
var b = 2;
function c() {}
var d = function() {};
b = 3;
}
foo(1);
在进入执行上下文后,这时候的 AO 是:
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
c: reference to function c(){},
d: undefined
}
当代码执行完后,这时候的 AO 是:
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 3,
c: reference to function c(){},
d: reference to FunctionExpression "d"
}
VO 与 AO
未进入执行阶段之前,变量对象(VO)中的属性都不能访问!但是进入执行阶段之后,变量对象(VO)转变为了活动对象(AO),里面的属性都能被访问了,然后开始进行执行阶段的操作。它们其实都是同一个对象,只是处于执行上下文的不同生命周期。
变量对象的创建
变量对象的创建,依次经历了以下几个过程:
- 建立 arguments 对象。检查当前上下文中的参数,建立该对象下的属性与属性值(全局环境下没有这步)。
- 检查当前上下文的函数声明,也就是使用 function 关键字声明的函数。在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果函数名的属性已经存在,那么该属性将会被新的引用所覆盖。
- 检查当前上下文中的变量声明,每找到一个变量声明,就在变量对象中以变量名建立一个属性,属性值 undefined。如果该变量名的属性已经存在,为了防止同名的函数被修改为 undefined,则会直接跳过,原属性值不会被修改
总结:
- 全局上下文的变量对象初始化是 全局对象。
- 函数上下文的变量对象初始化 只包括 Arguments 对象。
- 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值。
- 在代码执行阶段,会 再次修改变量对象的属性值。
- 在进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。
- AO = VO + function parameters + arguments
- AO 实际上是包含了 VO 的。因为除了 VO 之外,AO 还包含函数的 parameters,以及 arguments 这个特殊对象。也就是说 AO 的确是在进入到执行阶段的时候被激活,但是激活的除了 VO 之外,还包括函数执行时传入的参数和 arguments 这个特殊对象。
- 同一作用域下,函数提升比变量提升得更靠前。
- 一个执行上下文的生命周期可以分为两个阶段:
创建阶段:在这个阶段中,执行上下文会分别创建变量对象,建立作用域链,以及确定 this 的指向。
代码执行阶段:创建完成之后,就会开始执行代码,这个时候,会完成变量赋值,函数引用,以及执行其他代码。
10. 进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。 即,函数提升优先级高于变量提升,且不会被同名变量声明时覆盖,但是会被同名变量赋值后覆盖。
作用域链
在变量对象中,当查找变量的时候,对象中会先从当前执行上下文中的变量对象中查找变量(当前作用域),如果没有找到,就会从父级(词法层面的父级:书写位置)执行上下文中的变量对象中查找变量(父级作用域),一直到全局上下文的变量对象中,也就是全局对象中(全局作用域)。这样由多个执行上下文的变量对象构成的链叫做 作用域链。
作用域链(Scope Chain)是一个非常重要的概念,它决定了如何查找变量,即确定了在何处查找变量的顺序。作用域链是在执行上下文(execution context)被创建时构建的,它由一系列可访问的作用域组成,这些作用域按照特定的顺序链接在一起。 JavaScript 引擎会从当前作用域开始查找该变量,如果当前作用域中没有找到,就会沿着作用域链向上查找,直到全局作用域。如果在全局作用域中仍然没有找到该变量,就会返回 undefined。
函数的作用域在函数定义时就确定了。
以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的:
函数创建时的作用域
函数的作用域在函数定义的时候就决定了。因为函数有一个内部属性 [[scope]]
,当函数创建的时候,就会保存所有父变量对象到其中, [[scope]]
就是所有父变量对象的层级链,但是注意:[[scope]]
并不代表完整的作用域链!
函数激活后的作用域
函数激活,进入函数上下文,创建 VO
、AO
对象后,就会将 AO
添加到作用域链的前端。这时候执行上下文的作用域链,我们命名为 Scope
:Scope = [AO].concat([[Scope]])
,至此,作用域链创建完毕。
举个栗子
1. checkscope 函数被创建,保存作用域链到 内部属性 [[scope]]
checkscope.[[scope]] = [
globalContext.VO
];
2. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
ECStack = [
checkscopeContext,
globalContext
];
3. checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数 [[scope]]
属性创建作用域链
checkscopeContext = {
Scope: checkscope.[[scope]],
}
4. 第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: checkscope.[[scope]],
}
5. 第三步:将活动对象压入 checkscope 作用域链顶端
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: [AO, [[Scope]]]
}
6. 准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: 'local scope'
},
Scope: [AO, [[Scope]]]
}
7. 查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
ECStack = [
globalContext
];
作用域链查找机制:关键在于如何查找上级作用域:
- 从函数创建开始,作用域就已经指定好了
- 当前函数实在哪个作用域(N)下创建的,那么函数执行形成的作用域(M)的上级作用域就是N,“和函数在哪执行没有关系,只和创建的地方有关系”
总结:
- checkscope 函数创建的时候,保存的是根据词法所生成的作用域链,不是其完整的作用域链。Checkscope 函数执行的时候,会复制(引用类型的复制)这个作用域链,作为自己作用域链的初始化,然后根据环境生成变量对象,然后将这个变量对象,添加到这个复制的作用域链,这才完整的构建了自己的作用域链。至于为什么会有两个作用域链,是因为在函数创建的时候并不能确定最终的作用域的样子,为什么会采用复制的方式而不是直接修改呢?应该是因为函数会被调用很多次吧。ES5 开始,已经修改了通过变量对象升为活动对象的机制了。引入了词法环境和变量环境。
- 作用域链的作用是保证执行环境里有权访问的变量和函数是有序的,作用域链的变量只能向上访问,变量访问到 window 对象即被终止,作用域链向下访问变量是不被允许的。
- 当你定义(书写)一个函数的时候(并未调用),js 引擎也能根据你函数书写的位置,函数嵌套的位置,给你生成一个
[[scope]]
,作为该函数的属性存在(这个属性属于函数的)。即使函数不调用,所以说基于词法作用域(静态作用域)。然后进入函数执行阶段,生成执行上下文,执行上下文你可以宏观的看成一个对象,(包含 vo, scope, this),此时,执行上下文里的 scope 和之前属于函数的那个[[scope]]
不是同一个,执行上下文里的 scope,是在之前函数的[[scope]]
的基础上,又新增一个当前的 AO 对象构成的。函数定义时候的[[scope]]
和函数执行时候的 scope,前者作为函数的属性,后者作为函数执行上下文的属性。 - 从 v8 角度看函数的作用域:这是 v8 编译的结果,v8 有惰性编译,在开始编译时遇到函数会保存其为函数对象,在编译顶层代码为 ast 与字节码再去执行。这是为了优化执行速度,因为大部分函数在执行顶层代码时是不会执行的。但是有个问题,函数的执行上下文栈中的变量都是存在与栈中的,而 js 的函数有闭包性质。如果外层的函数执行结束它的作用域也会被销毁,那栈中的变量同时也被销毁。这就有了预解析。 顺序就是
预解析 -> 解析 -> 编译
执行。 预解析也只查看函数的语法与是否引用外部变量。
this
this 的几种调用场景
- 作为对象调用时,指向该对象
obj.b();
// 指向 obj - 作为函数调用,
var b = obj.b; b();
// 指向全局 window - 作为构造函数调用
var b = new Fun();
// this 指向当前实例对象 - 作为 call 与 apply 调用
obj.b.apply(object, []);
// this 指向当前的 object
this 指向
- 全局作用域里的 this 是 window,严格模式下是 undefined,window.fn() 把 window. 省略了;
- 函数的 this,看执行主体前有没有点,有点,前面是谁,函数里的 this 就是谁,没有点,函数的 this 就是 window,严格模式下是 undefined
- 自执行函数里的 this 是 window,严格模式下是 undefined
- 回调函数里的 this 一般情况下是 window
- 箭头函数没有 this,在箭头函数中使用的 this,是上级作用域或作用域链中找到的 this,直到找到全局的 window。
- 构造函数里的 this 是当前实例
- 实例原型上的公有方法里的 this 一般是当前实例
- 给元素绑定事件行为事件里的 this 就是当前被绑定的元素本身
call / apply / bind
- 原型上提供的三个公有属性方法
- 每一个函数都可以调用这个方法执行
- 这些方法都是用来改变函数中的 this 指向
实例.方法():都是找到原型上的内置方法,让内置方法先执行(只不过执行的时候做了一些事情会对实例产生改变,而这也是这些内置方法的作用),内置方法中的 this 一般都是当前操作的实例
call 方法
语法:函数 .call([context],[params1],...)
函数基于原型链找到 Function.prototype.call 这个办法,并且把它执行,在 call 方法执行的时候完成了一些功能
- 让当前函数执行
- 把函数中的 this 指向改为第一个传递给 call 的实参
- 把传递给 call 其余的实参当作参数信息传递给当前函数
如果执行 call 一个参数都没有传递或者传递 null / undefined,非严格模式下是让函数中的 this 指向 window,严格模式下指向的是 undefined。
Tips:
- 一次 call 是让左边函数执行(this 是传递的参数)
- 多个 call 是让最后传参的函数执行(this 是 window/undefined)
基于原生JS实现内置call的方法
/* 基于原生JS实现内置call的方法 */
(function () {
function myCall(context) {
// this 要执行的函数
context = context || window;
let args = [],
result;
for (let i = 1; i < arguments.length; i++) {
args.push(arguments[i]);
}
context.$fn = this;
result = context.$fn(...args);
delete context.$fn;
return result;
}
Function.prototype.myCall = myCall;
})();
function sum(a, b) {
console.log(this);
return a + b;
}
let result = sum.myCall(newObj, 10, 20);
console.log(result);
apply 方法
和 call 方法一样,都是把函数执行,并且改变里面的 this 关键字的,唯一的区别就是传递给函数参数的方式不同
- call 是一个个传参
- appy 是按照数组传参
bind 方法
和 call / apply 一样,也是用来改变函数中的 this 关键字的,只不过基于 bind 改变 this,当前方法并没有被执行,类似于预先改变 this
let obj1 = { name: "obj" };
function fn2() {
console.log(this.name);
}
document.body.onclick = fn2; // => 当事件触发,fn的this:body
document.body.onclick = fn2.call(obj1); // 这里立即执行了函数 => 基于call/apply这样的处理,不是把fn绑定给事件,而是把fn执行后的结果绑定给事件
document.body.onclick = function () {
// this:body
fn2.call(obj1);
};
document.body.onclick = fn.bind(obj1); // => bind的好处是:通过bind方法只是预先把fn中的this修改为obj,此时fn并没有立即执行,当点击事件触发才会执行fn(call/apply都是改变this的同时立即把方法执行) => 在IE6-8中不支持bind方法,预先做啥事情的思想被称为“柯里化函数”
参考文献:
彻底搞懂作用域、执行上下文、词法环境相信很多小伙伴在初学JavaScript的时候会经常对作用域,执行上下文,词法环境等 - 掘金