初识作用域与作用域链
作用域是针对标识符(不管是变量 函数 以及其他标识符)而言的,任何一个标识符都有有效范围,也就是当前作用域及内部作用域。“外不能访里”
函数执行时才会形成作用域,每次执行都会形成新的作用域。
作用域链是函数执行时内部代码寻找标识符的方式, 从当前作用域找到外部作用域, 一直找到全局作用域。“里可以访外”
作用域链对标识符有遮蔽作用,八个字:从里到外 找到即止。
函数被定义时(注意:转赋值不是定义),它的作用域链就已经确定了。
先从语法规则角度来记忆,后面可以从内存角度来理解
作用域类型与声明提升
作用域类型
分别是全局作用域 函数作用域 以及 块级作用域
-
全局作用域,就是最外层的这个作用域。我们可以假设,我们的代码全部都在被注入到一个main的函数中执行,这个main函数的作用域就是全局作用域。
- 函数作用域,就是我们函数执行形成的函数自己的一个作用域
- 块级作用域,就是一对花括号,与let和const密切相关,见下一节。
声明提升
js代码在真正执行前会有一个预编译的过程,这是JS编译器在后台发生的行为,在预编译过程中 会有一个声明提升的行为。
其一是var声明的变量,var a = 100 其实可以分为两个语句,
console.log('123')
var a = 100
// var a;
// a = 100
声明提升会把var a 提升到当前作用域的顶端,当代码真正执行时 var a 就会在作用域顶端执行,
而 a = 100 依然在原位,在代码真正被执行到该行代码时才会被赋值100。
另外,在全局作用域中,var
声明的变量会被挂载到 window
对象上,这是 ES5 的设计。
其二是函数声明定义的函数,函数声明定义的函数也会被声明提升,所以函数声明定义的函数可以在函数定义代码前被调用,如下所示就是允许的。
abc()
function abc(){
console.log('abc执行了')
}
注意:声明提升行为 只会提升到当前作用域顶端
编程提醒: 不管是变量还是函数,一定要养成先声明后使用的好习惯。不声明变量直接使用
会导致该变量跨作用域定义到全局作用域中,会造成全局作用域中的标识符污染。
认识let与const
使用let和const声明的标识符会在预编译过程中被写入临时性死区(TDZ),而在代码执行到声明部分时,才会从临时性死区中移除。后续代码使用let和const声明的变量才不会报错。
let 和const的使用要点
共同点:
- let、const 两者会被块级作用域封闭,也就是说,在块级作用域中用let 和const声明的标识符是外部无法访问到。
- let、const 声明的变量一定要在声明后面才能正常访问和使用,不然会报错
不同点:
- const 声明时就一定要赋值,不然会报错;const 声明的变量,赋值后不能更改标识符指向的内存空间地址。所以,const声明的标识符一般被称为常量。
- let可以先声明,需要赋值的时候再赋值,未赋值前变量值是undefined。
另外,
块级作用域
对上一节做补充。
块级作用域只对let 和 const 关键字声明的标识符有封闭作用 对函数声明方式定义的函数和var关键字声明的变量是没有封闭作用的。
只有离let或者const这两个最近的一层花括号 才会形成块级作用域。
{
let a = 100
function test(){
console.log('test')
}
console.log(a,'我在块级作用域中')
}
console.log(a)
console.log(test)
再聊作用域与作用域链
作用域其实也是通过内存中的对象记录数据的方式来实现的(可以根据后面的VO与执行环境来理解)。
作用域链在函数定义时,就已经生成了。函数定义时会有一个内部属性[[Scopes]] 指向一个类似数组的对象,相关的作用域信息对象都会保存在这个数组中。
实验
实验中只有函数定义、没有函数执行,然后通过打印对象方法,可以查看函数的各个属性。
const a = 100;
{
const b = 2000;
const obj = {
abc() {
console.log(b);
}
}
console.log(obj);
}
其中,
Global → 全局对象,也就是window对象,位于作用域链最顶层(浏览器)
Script → 用户脚本作用域数据,也就是全局作用域(.js文件)
Block → 块级作用域(一对花括号)
注意:后二者才是作用域,window对象不是。
补充:很多内部的类型只能被JS编译器调用或者说内部代码执行使用,我们是无法访问和使用的,比如像作用域中的Script 类型 Global类型 Block类型。
小问题:作用域类型一节中提到的函数作用域,在这个实验中怎么没有?下一小节揭晓。
代码执行的背后
实验
代码如下:
let a = 100;
let b = 2000;
function test() {
const innerA = 6;
console.log(a, innerA);
}
test();
console.log("全部执行完成");
流程如下:
- 在 上述代码执行前,js编译器会先做一些事情。它会先生成一个global对象,也就是window对象,然后定义一个类main函数,在定义时其[[Scopes]]就会指向一个类数组Scopes,该类数组中会添加Global(windows)对象。
- 代码执行后(相当于执行main函数),它首先会在内存中产生一个variable object(变量对象、活动对象)和一个excursion context(执行环境对象、执行上下文对象)。
- excursion context会添加variable object与scopes类数组的引用到内部的属性上,然后将excursion contenxt放入执行环境栈中。接着variable object中会产生引用,指向a,b变量,还有test函数。而当test函数被定义时,同样,该函数[[Scopes]]也会指向一个新的类数组Scopes,并将父级函数(也就是main函数)的Scopes中的元素都放入新的类数组中,这里元素是Global(windows)对象。最后然后将父级函数的variable object放进新的类数组Scopes中。
- 当test函数被执行时,该函数也会产生自己的一个variable object和一个excursion context。同样,excursion context会添加variable object与scopes类数组到内部的属性上,然后将excursion contenxt放入执行环境栈中。接着variable object中会产生引用,指向innerA变量。
- 当函数执行完后,首先执行环境栈中,test函数的执行环境的引用就被弹出,执行环境对象不再被应用后就会从内存中删除,然后vo2也没有再被引用,也会被删除。
- 最后对于console.log()函数,它也是先从VO1找,找不到再去Scopes中找,最后在Gobal对象中找到。
1. 这个Scopes中的元素一旦确定下来,其作用域链也就确定了,也就是所谓的函数被定义(申明)后,作用域链就确定了。
2. 只有一个函数被执行后,其执行环境和variable object才会生成,该函数才知道能访问哪些标识符(variable object中的,Scopes数组中的),而对于其子函数,则刚刚被定义出来,都不知道有哪些标识符,自然无法访问。也就是所谓的一个函数被执行了才能确定其作用域。
闭包
实验
代码如下:
let a = 100
let b = 2000
function test(){
let innerA = 6
let innerB = 88
function go(){
console.log(innerA)
}
}
·
test()
console.log('全部执行完成')
1.在test函数执行后, 未执行到go函数前,同上一小节所示,内存结构如下:
为什么是“在test函数执行后, 未执行到go函数前”而不是“go函数被定义前”,应为之前的说法并严谨,严谨说法是函数的作用域链在其父级函数执行到该函数声明时确定。
关键流程:
当父函数(如
test
)被调用时,会创建其执行上下文,生成活动对象(AO)。在父函数 AO 创建阶段,内部函数(如
go
)的标识符被注册,但此时go
的作用域链尚未完全确定。当执行到
function go() { ... }
的代码时,go
函数被实例化,此时其作用域链被正式确定。
2.在test函数执行后, 执行完go函数后,闭包通过作用域链直接引用父函数的 VO。但是不同的是,如果子函数(闭包)被外部引用,父函数的活动对象会一直保留,直到闭包不再被使用。
每次父函数调用都会生成新的 VO,闭包的作用域链独立绑定到各自的 VO。
闭包的形成条件
闭包是指 内部函数引用了父函数作用域中的变量,且该内部函数被外部作用域长期引用。此时,父函数的 VO 会被保留,以确保内部函数在后续调用时仍能访问这些变量。
核心机制
-
作用域链:内部函数的作用域链包含父函数的 VO。
-
引用保留:只要内部函数存在,父函数的 VO 就不会被垃圾回收。
闭包中的值是动态引用的
如果父函数的活动对象中的变量后续被修改,闭包访问的是最终值(而非定义时的值)。
function parent() {
let parentVar = 10;
function child() {
console.log(parentVar);
}
parentVar = 20; // 修改父函数变量
return child;
}
const closure = parent();
closure(); // 输出 20(访问的是修改后的值)
循环中的闭包陷阱
由于“闭包中的值是动态引用”的特性,可能造成循环中闭包问题
function createClosures() {
let functions = [];
for (var i = 0; i < 3; i++) {
functions.push(function() { console.log(i); });
}
return functions;
}
const closures = createClosures();
closures[0](); // 输出 3
closures[1](); // 输出 3
closures[2](); // 输出 3
-
var
的函数作用域:
var
声明的变量i
属于整个函数的作用域(即createClosures
函数的作用域),而非循环的块级作用域。 -
共享同一个变量:
所有闭包函数都引用同一个变量i
。当循环结束时,i
的值已递增到3
。 -
动态引用:
闭包捕获的是变量的引用,而非值的快照。因此,所有闭包在执行时访问的是最终的i
值。
let
的块级作用域实现变量隔离,修改后的代码如下
function createClosures() {
let functions = [];
for (let i = 0; i < 3; i++) { // 使用 let
functions.push(function() { console.log(i); });
}
return functions;
}
const closures = createClosures();
closures[0](); // 输出 0
closures[1](); // 输出 1
closures[2](); // 输出 2
-
let
的块级作用域:
let
声明的变量i
属于每次循环迭代的块级作用域(即for
循环的{}
内部)。 -
每次迭代生成新的变量副本:
JavaScript 引擎在每次迭代时为i
创建一个新的绑定,闭包函数捕获的是当前迭代的i
值。 -
独立引用:
每个闭包函数引用各自迭代中的i
,因此保留不同的值。
手动释放闭包
若闭包长期存在(如绑定到全局变量),父函数的活动对象会持续占用内存。
let closure = parent();
closure(); // 输出 20
closure = null; // 解除引用,父函数的活动对象可被垃圾回收
闭包的意义与用法
闭包的意义在于返回函数或者包含函数的对象,然后通过函数或者对象的方法来实现对闭包对象数据的操作。该闭包对象成为只有该函数或者该函数方法能访问的对象,形成了一个私有的空间。
在实验中就是,即使test函数结束了,后面调用go函数时,仍然能引用到innerA。
私有空间有利于标识符的管理,避免和全局作用域中的标识符冲突。
这里我们就可以看出来,所谓的函数作用域就是闭包
this关键字
每当函数执行时,就会有this这个关键字可供使用,可以理解为是一个特殊的标识符。
1. 全局上下文
在全局作用域中(非严格模式),this
指向全局对象:
-
浏览器环境:
window
-
Node.js 环境:
global
console.log(this); // 浏览器中输出: Window {...}
2. 函数调用
普通函数调用
-
非严格模式:
this
指向全局对象。
function showThis() {
console.log(this);
}
showThis(); // 浏览器中输出: Window {...}
严格模式('use strict'
):this
为 undefined
。
function showThis() {
console.log(this);
}
showThis(); // 浏览器中输出: Window {...}
箭头函数调用
箭头函数没有自己的 this
,它继承外层作用域、非箭头函数的 this
。
const obj = {
value: 42,
getValue: () => {
console.log(this.value); // 外层是全局,输出: undefined
}
};
obj.getValue();
3. 对象方法调用
当函数作为对象的方法调用时,this
指向调用该方法的对象。
const person = {
name: "Alice",
greet() {
console.log(this.name); // 输出: Alice
},
};
person.greet();
特别的,当调用静态方法时,this
的指向是一个特殊的情况。静态方法不属于类的任何实例,而是直接属于类本身。所以在静态方法中,this
指向的是定义该静态方法的类本身,而不是类的实例。
4. 构造函数调用
使用 new
关键字调用构造函数时,this
指向新创建的实例对象。
function Person(name) {
this.name = name;
}
const alice = new Person("Alice");
console.log(alice.name); // 输出: Alice
5.事件处理函数中的this
事件处理函数(非箭头函数)中的this是指向绑定元素本身,可以通过this 来访问元素。
对于箭头函数式的事件处理函数而言,this会指向上级作用域。
代码如下:
document.getElementById('myButton').addEventListener('click', () => {
this.style.color = 'red'; // 这里this不会指向myButton元素
});
这段代码会抛出一个错误,因为 this
没有 style
属性。在箭头函数中,this
将指向全局对象(在肺炎个模式下,浏览器中是 window
对象)。
6.显式绑定——神奇的bind apply call
这三个方法都是用来改变某个函数执行时的this指向。
call() 和 apply()都是立即执行函数,指定this和参数
call()
方法调用一个函数,并将this
绑定到提供的值。call()
立即执行函数,并且可以向函数传递参数, 参数数量不限。var changeColor = function() { this.style.color = 'red'; }; changeColor.call(button); // 立即改变按钮颜色
apply()
方法调用一个函数,并将this
绑定到提供的值。apply()
立即执行函数,并且可以传递参数数组。
bind()返回一个绑定了this的新函数。
bind()
方法创建一个新的函数,当被调用时,它的this
被绑定到提供的值。与apply()
和call()
不同,bind()
不立即执行函数,而是返回一个新函数,这个新函数可以稍后被调用。
var button = document.getElementById('myButton');
var changeColor = function() {
this.style.color = 'red';
};
var boundChangeColor = changeColor.bind(button);
boundChangeColor(); // 改变按钮颜色
为什么需要这几个关键词呢?
this.shapesMoveFunc = this.playScene.allInstances.shapes[randomIndex].oneStep.bind(this.playScene.allInstances.shapes[randomIndex]);
this.gameApp.ticker.add(this.shapesMoveFunc);
这是使用pixi.js的两行代码,我需要往ticker.add()中传入回调函数,这个回调函数在一个类中。
那这里为什么需要bind()?
因为正常情况下, A.c(), c函数肯定知道this是谁,可是当你仅仅只是将A.c当作回调函数传进ticker.add(),那c函数中的this就会是和ticker相关实例绑定,而不是原来的A。所以需要bind()函数重新将c函数绑定回A实例,这样可以确保方法内部能够正确访问该实例的属性和方法。
还有就是比如说我想实现将函数参数转换为数组形式,比如“args.slice()”,但是args是类数组,其原型链上并没有slice方法,我们可以如下处理:
// 示例:转换 arguments 对象为数组
function example() {
const args = Array.prototype.slice.call(arguments);
console.log(args); // 输出真正的数组
}
example(1, 2, 3); // 输出 [1, 2, 3]
通过 call()
方法将 slice
的上下文(this
)指向类数组对象并且立即执行函数,从而让类数组对象“借用”数组的 slice
方法 。
当然现代ES6,可以用如下实现方式:
const args = Array.from(arguments);
const divArray = Array.from(document.querySelectorAll("div"));
const args = [...arguments];
const divArray = [...document.querySelectorAll("div")];
7. 回调函数中的 this
回调函数的 this
取决于调用方式,可能丢失绑定(常见陷阱)。
const obj = {
value: 10,
getValue() {
setTimeout(function() {
console.log(this.value); // 输出: undefined(this指向全局)
}, 100);
}
};
obj.getValue();
// 解决方法:使用箭头函数或 bind
setTimeout(() => {
console.log(this.value); // 输出: 10(箭头函数继承外层 this)
}, 100);