作用域与作用域链

初识作用域与作用域链

作用域是针对标识符(不管是变量 函数 以及其他标识符)而言的,任何一个标识符都有有效范围,也就是当前作用域及内部作用域。“外不能访里

函数执行时才会形成作用域,每次执行都会形成新的作用域。

作用域链是函数执行时内部代码寻找标识符的方式, 从当前作用域找到外部作用域, 一直找到全局作用域。“里可以访外

作用域链对标识符有遮蔽作用,八个字:从里到外 找到即止。

函数被定义时(注意:转赋值不是定义),它的作用域链就已经确定了。

先从语法规则角度来记忆,后面可以从内存角度来理解

作用域类型与声明提升

作用域类型

分别是全局作用域 函数作用域 以及 块级作用域

  1. 全局作用域,就是最外层的这个作用域。我们可以假设,我们的代码全部都在被注入到一个main的函数中执行,这个main函数的作用域就是全局作用域。

  2. 函数作用域,就是我们函数执行形成的函数自己的一个作用域
  3. 块级作用域,就是一对花括号,与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的使用要点

共同点:

  1. let、const 两者会被块级作用域封闭,也就是说,在块级作用域中用let 和const声明的标识符是外部无法访问到。
  2. let、const 声明的变量一定要在声明后面才能正常访问和使用,不然会报错

不同点:

  1.  const 声明时就一定要赋值,不然会报错;const 声明的变量,赋值后不能更改标识符指向的内存空间地址。所以,const声明的标识符一般被称为常量。
  2. 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("全部执行完成");

流程如下:

  1. 在 上述代码执行前,js编译器会先做一些事情。它会先生成一个global对象,也就是window对象,然后定义一个类main函数,在定义时其[[Scopes]]就会指向一个类数组Scopes,该类数组中会添加Global(windows)对象。
  2. 代码执行后(相当于执行main函数),它首先会在内存中产生一个variable object(变量对象、活动对象)和一个excursion context(执行环境对象、执行上下文对象)。
  3. excursion context会添加variable object与scopes类数组的引用到内部的属性上,然后将excursion contenxt放入执行环境栈中。接着variable object中会产生引用,指向a,b变量,还有test函数。而当test函数被定义时,同样,该函数[[Scopes]]也会指向一个新的类数组Scopes,并将父级函数(也就是main函数)的Scopes中的元素都放入新的类数组中,这里元素是Global(windows)对象。最后然后将父级函数的variable object放进新的类数组Scopes中。
  4. 当test函数被执行时,该函数也会产生自己的一个variable object和一个excursion context。同样,excursion context会添加variable object与scopes类数组到内部的属性上,然后将excursion contenxt放入执行环境栈中。接着variable object中会产生引用,指向innerA变量。
  5. 当函数执行完后,首先执行环境栈中,test函数的执行环境的引用就被弹出,执行环境对象不再被应用后就会从内存中删除,然后vo2也没有再被引用,也会被删除。
  6. 最后对于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函数被定义前”,应为之前的说法并严谨,严谨说法是函数的作用域链在其父级函数执行到该函数声明时确定。

关键流程

  1. 当父函数(如 test被调用时,会创建其执行上下文,生成活动对象(AO)。

  2. 在父函数 AO 创建阶段,内部函数(如 go)的标识符被注册,但此时 go 的作用域链尚未完全确定。

  3. 当执行到 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
  1. var 的函数作用域
    var 声明的变量 i 属于整个函数的作用域(即 createClosures 函数的作用域),而非循环的块级作用域。

  2. 共享同一个变量
    所有闭包函数都引用同一个变量 i。当循环结束时,i 的值已递增到 3

  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
  1. let 的块级作用域
    let 声明的变量 i 属于每次循环迭代的块级作用域(即 for 循环的 {} 内部)。

  2. 每次迭代生成新的变量副本
    JavaScript 引擎在每次迭代时为 i 创建一个新的绑定,闭包函数捕获的是当前迭代的 i 值。

  3. 独立引用
    每个闭包函数引用各自迭代中的 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);

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值