第四期:你了解JS的作用域吗?

什么是作用域

从字面理解,就是起作用的区域,称之为作用域,那是什么在起作用呢?

通常来说,一段程序代码中所用到的名字并不总是有效/可用的,而限定这个名字的可用性的代码范围就是这个名字的作用域

说白了,作用域就是画一个圈把指定的一些成员(变量)圈起来,达到与外界隔离的目的。

在JavaScript中,作用域指的就是当前代码的EC(Execution Context、执行上下文)

为什么需要作用域

从平常的开发来看,好像没有作用域也没问题,程序也能执行,为什么需要一个作用域呢?

在程序执行的过程中,操作变量是最常见的操作,而变量提供的这种存储、访问、修改值的操作给程序带来了"状态(state)",大部分的编程范式都是围绕着状态来展开的(函数式编程例外,它的理念就是state less)

试想一下如果没有状态,程序还能那么无所不能吗 没有状态我们只能实现一些简单的操作,而状态是在操作变量的过程中产生的,所以管理变量就很重要了。 作用域就是为了管理变量而定制的一套规则,它解决了变量存在哪里?去哪里获取?怎么隔离的问题。

JavaScript的作用域

JavaScript的作用域分两种:

  1. 全局作用域
  2. 函数作用域

那么作用域是什么时候被定义的呢?

作用域的分类

在程序的领域中,作用域分为两种模型:

  1. 动态作用域
  2. 静态作用域(词法作用域)

动态作用域是指作用域是在程序运行的时候才确定的,如果是函数的作用域,会根据函数被调用的地方来生成作用域。 加入JavaScript使用的是动态作用域的话,那么下面代码的结果会有变化:

js 复制代码
function set(y) {
    console.log(x + y);
}

function init() {
    var x = 8;
    set(1); 
}

var x = 10;
init(); // 9

这就是动态作用域,会根据调用来适配。 是不是觉得有点熟悉?是的,JavaScript中的this的机制跟动态作用域的机制类似的。

静态作用域(词法作用域),大部分的语言都是采用这种规则设计的。JavaScript也是采用词法作用域。

词法作用域(lexical scope)

之前我们已经将作用域定义为一种规则,而所谓动态、静态就是作用域被定义的时机。动态比较好理解,就是在运行的时候动态定义的。那么静态是在什么时候呢?静态就是在编译(编写)的阶段完成的,如果一门语言可以通过阅读源代码就确定一个声明的作用域,那么这门语言使用的就是静态作用域 也叫做词法作用域

我们从编译开始说起,一般来程序的编译分为以下几个阶段

graph TD A[词法分析] --> B[语法分析] B --> C[语义分析] C --> D[生成中间代码] D --> E[生成目标代码]

作用域定义其实就是在词法分析阶段就已经被定义了。使用词法作用域的变量被称为词法变量。在词法分析的阶段会收集变量(包括各种类型的变量),JavaScript在这个阶段会将变量汇集到对应作用域的VO(Variable object 变量对象)。 看看这段代码:

js 复制代码
function Vo() {
  var a = 1;
  var b = function() {};
 
  function c() {}
}

函数对象在创建的时候编译器会为其设置一个[[Scope]]属性,该属性指向函数的执行上下文,执行上下文中就包含了属于该函数的VO。当上面的VO函数在被编译时会生成对应的AO:

js 复制代码
{
    a: undefined,
    b: undefined,
    c: pointer // 指向c的function object
}

可以看到,在函数开始执行之前,执行工具就已经知道函数作用域中的整个变量的组成了(VO同样适用于全局,全局的VO称为全局变量对象(global VO)) 这一变量被提前预知的情况,也被我们称为"提升"。

块级作用域(block scoping)

ES6之前,JavaScript的块级作用域只有函数作用域,ES6新增的letconst让块级作用域开始用起来。 在一个{...}中使用let或者const定义变量,这个变量会劫持这个块级作用域,在这个作用域中不允许重复定义同名的变量,var则没有这个限制。 那么es6是怎么实现这样的块级作用域呢? 实现的方式跟传统的是一致的,不过会有一些特殊的处理,当编译进入一个{...}时,会为这个块创建一个属于它的VO,如果是let和const的定义将其放入VO,var和函数定义提升到函数/全局的VO。

提升

变量或者函数在作用域中都有提升的动作,提升其实就是前面说的变量的收集,编译过程中收集作用域中的定义,并将其汇入到对应的vo中,这个过程就是提升。

看看编译器是怎么工作的:

js 复制代码
var a = 10;

这个变量的定义并赋值的操作会被分成两步: 第一步:声明,在编译阶段完成

js 复制代码
var a;

第二步:赋值,在运行阶段完成

js 复制代码
a = 10;

也就是说提升其实只是对声明的提升,其他的逻辑操作还是留在原地等待执行。 在这一点上,函数看起来会比较特殊,因为函数在第一个步会"值"带上,但其实是一样的,用function关键字定义一个函数,会创建一个函数对象,这个函数对象其实就是我们的声明,它的操作逻辑是在调用函数的时候函数体的执行结果。

提升的问题

letconst有个区别于var的特点:在定义前访问会报错

js 复制代码
{
    console.log(a);
    console.log(b); 
    var a = 2;
    let b = 3;
}

上面的代码会抛出错误:ReferenceError: Cannot access 'b' before initialization 这个现象被称为临时性死区(TDZ Temporal Dead Zone)

很多文章对这个现象的解释是letconst没有提升的操作,事实是这样吗?

首先临时性死区是因为"访问了定义但是未初始化的变量 "导致的。如果没有提升那就不应该是这个错误,应该是访问了未定义的变量才对。所以letconst是有提升的,但是在提升的时候并没有像var一样给一个默认值undefined

js 复制代码
var val = 15;
function demo(){
  console.log(val);
  let val = 5;
}
demo();

这段代码执行报错了??如果没有提升的话,应该是输出15,而不是报错。

Q:为什么会有变量提升?let和const又没有提升?

作用域链(Scope Chain)

前面说了作用域在js中其实可以等同于当前的执行上下文,执行上下文有两个重要的属性:

  1. 变量对象
  2. 作用域链

变量对象保存当前作用域的变量。当我们调用变量的时候,会去变量对象中查找。那么当出现下面这种情况时,执行器要怎么做呢?

js 复制代码
var a = 2;

function demo() {
  console.log(a);
}

demo(); // 2

demo的变量对象中并没有a这变量,但是console.log依然可以输出a的值,这就是作用域链的作用了。

作用域链是标示变量对象的链,可以把它看做一个指针栈,每个元素都指向一个变量对象,从栈顶到栈底依次是当前执行环境的变量对象,上一层环境的变量对象,...,最后一个指向全局环境的变量对象。 通过作用域链,执行模型就知道什么时候该使用哪个作用域的变量,去哪里能找到这个变量。

js 复制代码
var a = 1;

function scope1() {
    var b = 2;
    console.log(a);
    
    function scope2() {
        var b = 3;
        console.log(b);
    }
    
    scope2();
}

scope1(); // 1 3

按照之前的说法,函数在被编写的时候就已经确定了它的作用域了,函数对象那么它的作用域链其实也是可以确定的,在函数对象生成的时候回给它注入[[scope]]属性,该属性指向函数的词法环境(就是作用域或者执行上下文),一般认为它是指向当前函数的作用域链,但是不管是指向词法环境还是作用域链效果都是差不多的。

闭包

坑爹玩意来了,闭包在js的世界里面一直是一个很神奇的东西,即使是从事前端工作多年的人也很难说清楚它。

看看这段代码

js 复制代码
for (var i = 0; i < 10; i++) { 
    setTimeout(() => {
         console.log(i);
    }, i*100);
}

这段代码会输出10个10

因为for是同步的,而setTimeout是延迟执行的,对于setTimeout来说callback中引用的都是同一个i,所以setTimeout输出的i其实是++了10次的i,所以是10个10。

如果要实现0-9的输出,就要用这么写:

js 复制代码
for (var i = 0; i < 10; i++) { 
    (function(i){
        setTimeout(function() {
             console.log(i);
        }, i*100);
    })(i);
}

我们使用一个匿名函数,将i作为参数传给这个函数,在函数内部执行setTimeout这样就可以了。 这是为什么?? 因为使用了闭包 所以闭包是什么鬼?? 先看看上面例子运行时整体的关系图: 第一次循环的时候:

第二次循环的时候:

可以看到在第二次循环时,虽然第一次循环已经结束,第一个匿名函数已经执行完成,按理他的作用域应该被回收,但是因为其内的setTimeout回调仍引用这匿名函数的i参数,所以其作用域不会被回收。如果浏览器的GC(垃圾回收机制)使用的引用计数 算法的话,会导致i的引用数不会被清0,这块内存就不会被回收,这也是闭包会引起内存溢出问题的原因。

三个阶段

理解了闭包大概是怎么形成之后,我们来看看闭包到底是什么东西。这里分享下我自身的经验,我对闭包的理解大致分为三个阶段:

一、函数中返回的函数 我已经不记得是在那篇文章里面看到的这个概念,这个是对闭包的描述:闭包就是函数中返回的另一个函数,额,这个说法非常笼统,这并不是闭包的概念,只是对闭包最常见的定义方式的描述。

js 复制代码
function a() {
    var b = 10;

    return function() {
        console.log(b);
    };
}

这种理解是基本是新手阶段的看法,因为经常看到的闭包都是类似上面示例的代码,加上一些概念的误导,很容易就认为这就是闭包的概念(在我这些年的面试官生涯中,不少面试者都是这么回答的)。但是其实下面的方式也可以实现闭包:

js 复制代码
function callback() {
    console.log(name);
}

function run(func) {
    var name = 'sk';
    func();
}

run(callback); // sk

还有很多其他的方式可以实现闭包。

二、作用域的延伸 在了解了闭包的多种实现形式后,闭包给我的感觉就像是将某个作用域中变量延伸到其他作用域中一样(这个作用域不是全局作用域哈),这样理解也是贴近闭包最基础的定义:有权访问另一个函数作用域的函数

js 复制代码
function func1() {
    var a = 2;
}

我们要在外面访问函数func1中的a变量,有很多种办法:

js 复制代码
function func1() {
    var  a = 2;

    return function func2() {
        console.log(a);
    }
}

var func3 = func1();

func3();

或者

js 复制代码
var func3;
function func1() {
    var  a = 2;

    function func2() {
        console.log(a);
    }

    func3 = func2;
}

func3();

不管是哪种做法,其实都是将函数func2当成一个值类型在传递,而最终func2是在其定义的词法作用域的范围外执行的。所以说闭包是对作用域的一种延伸的方法,就是让一些作用域的成员可以在作用域外的其他地方被使用。

三、特殊的实体 我们把视角放大一些来看:闭包并不是JavaScript独有的,其他语言也存在闭包:PHP、Python、Ruby、Lua等等。是词法闭包(Lexical Closure)的简称。对于闭包的描述,可以分为两种:

  • 一种是认为闭包就是符合特定条件的函数:在自身作用域中引用了自由变量的函数 自由变量: 既不是函数的参数也不是函数的局部变量的变量(函数作用域外的变量)
  • 另一种则认为闭包是函数和其相关的环境(创建和引用)组合而成的实体

明显第二种说法更贴切,第一种说法把闭包和函数对等,明显不对,函数是固定的一个代码库,每次都是执行同一实例。而闭包虽然形式上与函数类似,但是其实每次运行闭包都会产生不同的实例,不同的引用环境与相同的函数会产生不同的实例。

都是闭包

有个经典的说法:"JavaScript所有的函数都是闭包" ,怎么理解这句话呢? 看看V8是怎么实现闭包的: 在我查找[[scope]]属性的时,在Chrome上发现可以查看到另一个隐藏的属性[[Scopes]]。这是一个数组,保存的应该是当前函数需要用到的其他作用域的变量和对象的集合。

js 复制代码
var a = 1;

function scope1() {
    var b = 2;
    console.log(a);
    
    function scope2() {
        console.log(b);
    }

	scope2();
	console.dir(scope2);
}

scope1();

可以看到scope2的[[Scopes]]属性如下:

有两个元素: Closure (scope1)(闭包):{b:2} Global全局对象

在看另一段代码

js 复制代码
var a = 1;

function scope1() {
    var b = 2;
    console.log(a);
    
    function scope2() {
        var b = 3;
        console.log(b);
    }

	scope2();
	console.dir(scope2);
}

scope1();

可以看到scope2的[[Scopes]]属性如下:

没有scope1

再加点东西:

js 复制代码
var a = 1;

function scope1() {
    var b = 2;
    var c = 3;
    var d = 4;
    var e = 5;
    console.log(a);
    
    function scope2() {
        var b = 3;
        console.log(b * c + e);
    }

	scope2();
	console.dir(scope2);
}

scope1();

scope2的[[Scopes]]属性如下:

scope1{c: 3, e: 5}

明显这个属性是存储了对应函数引用到的其他作用域的变量。是针对闭包来实现的,而且所有函数都默认引用了Global:

js 复制代码
var a = 1;

function scope1() {}

console.dir(scope1);

所以"所有函数都是闭包"这句话在这个角度来看是成立的。

总结

本文是对作用域基础知识的一些个人总结,作用域是理解代码执行过程中的关键概念,它对于编写高效、可维护的代码至关重要。通过了解块级作用域、作用域链、闭包等内容,可以帮助我们更好地理解JavaScript的工作原理,提高代码质量和效率。

相关推荐
fishmemory7sec6 分钟前
Electron 主进程与渲染进程、预加载preload.js
前端·javascript·electron
fishmemory7sec9 分钟前
Electron 使⽤ electron-builder 打包应用
前端·javascript·electron
豆豆1 小时前
为什么用PageAdmin CMS建设网站?
服务器·开发语言·前端·php·软件构建
JUNAI_Strive_ving1 小时前
番茄小说逆向爬取
javascript·python
看到请催我学习2 小时前
如何实现两个标签页之间的通信
javascript·css·typescript·node.js·html5
twins35202 小时前
解决Vue应用中遇到路由刷新后出现 404 错误
前端·javascript·vue.js
qiyi.sky2 小时前
JavaWeb——Vue组件库Element(3/6):常见组件:Dialog对话框、Form表单(介绍、使用、实际效果)
前端·javascript·vue.js
煸橙干儿~~2 小时前
分析JS Crash(进程崩溃)
java·前端·javascript
哪 吒2 小时前
华为OD机试 - 几何平均值最大子数(Python/JS/C/C++ 2024 E卷 200分)
javascript·python·华为od
安冬的码畜日常2 小时前
【D3.js in Action 3 精译_027】3.4 让 D3 数据适应屏幕(下)—— D3 分段比例尺的用法
前端·javascript·信息可视化·数据可视化·d3.js·d3比例尺·分段比例尺