js基础梳理-如何理解作用域和作用域链?

本文重点是要梳理执行上下文的生命周期中的建立作用域链,在此之前,先回顾下关于作用域的一些知识。

1.什么是作用域(scope)?

在《JavaScritp高级程序设计》中并没有找到确切的关于作用域的定义,只是在“4.2执行环境及作用域”中简单说了下执行环境(execution context)的概念。而执行环境其实就是之前博客:js基础梳理-究竟什么是执行上下文栈(执行栈),执行上下文(可执行代码)?中的执行上下文。

而在《JavaScript权威指南》中,对作用域的描述为:

变量作用域:一个变量的作用域(scope)是程序源代码中定义这个变量的区域

在《你不知道的Javascript·上卷》中对作用域的描述则为:

负责收集并维护由所有生命的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

简单来讲,作用域(scope)就是变量访问规则的有效范围

  • 作用域外,无法引用作用域内的变量;
  • 离开作用域后,作用域的变量的内存空间会被清除,比如执行完函数或者关闭浏览器
  • 作用域与执行上下文是完全不同的两个概念。我曾经也混淆过他们,但是一定要仔细区分。

JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建。

说得很深奥的样子,其实上面这段话重点用函数作用域与函数执行上下文来区分是最好不过的了。函数作用域是在函数声明的时候就已经确定了,而函数执行上下文是在函数调用时创建的。假如一个函数被调用多次,那么它就会创建多个函数执行上下文,但是函数作用域显然不会跟着函数被调用的次数而发生什么变化。

1.1 全局作用域

var foo = 'foo';
console.log(window.foo);   // => 'foo' 

在浏览器环境中声明变量,该变量会默认成为window对象下的属性。

function foo() {
    name = "bar"
}
foo();
console.log(window.name) // bar

在函数中,如果不加 var 声明一个变量,那么这个变量会默认被声明为全局变量,如果是严格模式,则会报错。

全局变量会造成命名污染,如果在多处对同一个全局变量进行操作,那么久会覆盖全局变量的定义。同时全局变量数量过多,非常不方便管理。

这也是为什么jquery要在全局建立\(变量,其余私有方法属性挂在\)下的原因。

1.2 函数作用域

假如在函数中定义一个局部变量,那么该变量只可以在该函数作用域中被访问。

function doSomething () {
    var thing = '吃早餐';
}
console.log(thing); // Uncaught ReferenceError: thing is not defined

嵌套函数作用域:

function outer () {
    var thing = '吃早餐';
    function inner () {
        console.log(thing);
    }
    inner();
}

outer();  // 吃早餐

在外层函数中,嵌套一个内层函数,那么这个内层函数可以向上访问到外层函数中的变量。

既然内层函数可以访问到外层函数的变量,那如果把内层函数return出来会怎样?

function outer () {
    var thing = '吃早餐';

    function inner () {
        console.log(thing);
    }

    return inner;
}

var foo = outer();
foo();  // 吃早餐

前面提到,函数执行完后,函数作用域的变量就会被垃圾回收。而这段代码看出当返回了一个访问了外部函数变量的内部函数,最后外部函数的变量得以保存。

这种当变量存在的函数已经执行结束,但扔可以再次被访问到的方式就是“闭包”。后期会继续对闭包进行梳理。

1.3 块级作用域

很多书上都有一句话,javascript没有块级作用域的概念。所谓块级作用域,就是{}包裹的区域。但是在ES6出来以后,这句话并不那么正确了。因为可以用 let 或者 const 声明一个块级作用域的变量或常量。

比如:

for (let i = 0; i < 10; i++) {
    // ...
}
console.log(i); // Uncaught ReferenceError: i is not defined

发现这个例子就会和函数作用域中的第一个例子一样的错误提示。因为变量i只可以在 for循环的{ }块级作用域中被访问了。

扩散思考:

究竟什么时候该用let?什么时候该用const?

默认使用 const,只有当确实需要改变变量的值的时候才使用let。因为大部分的变量的值在初始化之后不应再改变,而预料之外的变量的修改是很多bug的源头。

1.4 词法作用域

词法作用域,也可以叫做静态作用域。意思是无论函数在哪里调用,词法作用域都只在由函数被声明时所处的位置决定。
既然有静态作用域,那么也有动态作用域。
而动态作用域的作用域则是由函数被调用时执行的位置所决定。

var a = 123;
function fn1 () {
    console.log(a);
}
function fn2 () {
    var a = 456;
    fn1();
}
fn2();   // 123

以上代码,最后输出结果 a 的值,来自于 fn1 声明时所在位置访问到的 a 值 123。
所以JS的作用域是静态作用域,也叫词法作用域。

上面的1.1-1.3可以看做作用域的类型。而这一小节,其实跟上面三小节还是有差别的,并不属于作用域的类型,只是关于作用域的一个补充说明吧。

2. 什么是作用域链(scope chain)

在JS引擎中,通过标识符查找标识符的值,会从当前作用域向上查找,直到作用域找到第一个匹配的标识符位置。就是JS的作用域链。

var a = 1;
function fn1 () {
    var a = 2;
    function fn2 () {
        var a = 3;
        console.log(a);
    }
    fn2 ();
}
fn1(); // 3

console.log(a) 语句中,JS在查找 a变量标识符的值的时候,会从 fn2 内部向外部函数查找变量声明,它发现fn2内部就已经有了a变量,那么它就不会继续查找了。那么最终结果也就会打印3了。

3. 作用域链与执行上下文

在此前的博客:js基础梳理-究竟什么是执行上下文栈(执行栈),执行上下文(可执行代码)?中讲到执行上下文的生命周期:

3.执行上下文的生命周期

3.1 创建阶段

  • 生成变量对象(Variable object, VO)
  • 建立作用域链(Scope chain)
  • 确定this指向

3.2 执行阶段

  • 变量赋值
  • 函数引用
  • 执行其他代码

上面做了那么多铺垫,其实重点是想梳理这一小节。
下面,以一个函数的创建和激活两个时期来讲解作用域链是如何创建及变化的。

3.1函数创建阶段

上文中讲到,函数的作用域在函数定义的时候就决定了。

这是因为函数有一个内部属性[[scope]],当函数创建的时候,就会保存所有父变量对象到其中,但是注意:此时[[scope]]并不代表完整的作用域链,因为在创建阶段,它还没有包括自己的作用域。

举个栗子:

function foo () {
    function bar () {
        ...
    }
}

函数创建时,各自的[[scope]]为:

foo.[[scope]] = [
    globalContext.VO
];

bar.[[scope]] = [
    fooContext.AO,
    globalContext.AO
];

3.2 函数激活阶段

当函数激活时,进入函数上下文,创建VO/AO后,就会将活动对象添加到作用域链的前端。

这时候执行上下文的作用域链,命名为 Scope:

Scope = [AO].concat([[scope]]);

至此,作用域链创建完毕。

3.3 举个栗子

以下面的例子为例,结合之前的变量对象,活动对象和执行上下文栈,总结一下函数执行上下文中作用域链和变量对象的创建过程:

var x = 10;

function foo() {
  var y = 20;

  function bar() {
    var z = 30;
    console.log(x +  y + z);
  }

  bar();
}

foo(); // 60

大家肯定都知道打印结果会是60。但是从第一行代码开始到最后一行代码结束,整个代码的执行上下文栈以及作用域链是怎样变化的呢?

// 第一步:进入全局上下文,此时的执行上下文栈是这样:
ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    }
];

// 第二步:foo函数被创建,此时的执行上下文栈没有变化,但是创建了foo函数的作用域,保存作用域链到内部属性[[scope]]。
ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    }
];
foo.[[scope]] = [
    globalContext.VO
];

// 第三步:foo函数执行,进入foo函数上下文的创建阶段
// 这个阶段它做了三件事:
// 1.复制之前的foo.[[scope]]属性到foo函数上下文下,创建foo函数的作用域链;
// 2. 创建foo函数上下文的变量对象,并初始化变量对象,依次加入形参,函数声明,变量声明
// 3. 把foo函数上下文的变量对象加入到第一步创建的foo函数作用域链的最前面。
// 最终,经过这三个步骤之后,整个执行上下文栈是这样

ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        VO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: undefined
        },
        Scope: [foo.VO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.VO,
    globalContext.VO
];

// 第四步:foo函数执行,进入foo函数上下文的执行阶段。
// 这个阶段又做了以下2件事:
// 1. 把foo执行上下文的变量对象VO改成了活动对象AO,并且修改AO中变量的值
// 2. 发现创建了一个 bar函数,就保存了bar函数的所有父变量对象到bar函数的[[scope]]属性上。

ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: 20
        },
        Scope: [foo.AO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.AO,
    globalContext.VO
];

bar.[[scope]] = [
    foo.AO,
    globalContext.VO
];

// 第五步,bar函数执行,进入bar函数上下文的创建阶段
// 与第三步类似,也做了三件事,只不过主体变成了bar
// 1.复制之前的bar.[[scope]]属性到bar函数上下文下,创建foo函数的作用域链;
// 2. 创建bar函数上下文的变量对象,并初始化变量对象,依次加入形参,函数声明,变量声明
// 3. 把bar函数上下文的变量对象加入到第一步创建的bar函数作用域链的最前面。
// 最终,经过这三个步骤之后,整个执行上下文栈是这样

ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: 20
        },
        Scope: [foo.AO, globalContext.VO]
    },
    <bar>functionContext: {
        VO: {
            arguments: {
                length: 0
            },
            z: undefined
        },
        Scope: [bar.VO, foo.AO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.AO,
    globalContext.VO
];

bar.[[scope]] = [
    bar.VO,
    foo.AO,
    globalContext.VO
];

// 第六步:bar函数执行,进入bar函数上下文的执行阶段
// 与第四步类似。不过此时bar函数里面不会再创建新的函数上下文了
// 1. 把bar执行上下文的变量对象VO改成了活动对象AO,并且修改AO中变量的值
ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: 20
        },
        Scope: [foo.AO, globalContext.VO]
    },
    <bar>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            z: 30
        },
        Scope: [bar.AO, foo.AO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.AO,
    globalContext.VO
];

bar.[[scope]] = [
    bar.AO,
    foo.AO,
    globalContext.VO
];

// 第七步:执行bar函数中的console.log(x + y +z),查找x,y,z三个标识符

- "x"
-- <bar>functionContext.AO   // 没找到,继续到foo.AO中找
-- <foo>functionContext.AO   // 还没找到,再往globalContext.VO中找
-- globalContext.VO     // 找到了,值为 10

- "y"
-- <bar>functionContext.AO   // 没找到,继续到foo.AO中找
-- <foo>functionContext.AO   // 找到了,值为20

-- "z"
-- <bar>functionContext.AO   // 找到了,值为 30

打印结果: 60。

// 第八步:bar函数执行完毕,将其从执行上下文栈中弹出,foo函数执行完毕,将其从执行上下文栈中弹出。最终,执行上下文栈,只剩下globalContext

ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    }
]

感觉其实可以简化理解一下,把第三四步,第五六步分别分成一个步骤。

打算每周定一个小主题,可能是基础知识巩固,也可能是自己学习新知识的记录。在下一篇博文中,将对this指向问题进行梳理。如果你也感兴趣,也可以去搜集下相关资料,到时候大家共同学习探讨一下。

原文地址:https://www.cnblogs.com/hezhi/p/10090151.html

时间: 2024-08-01 22:39:59

js基础梳理-如何理解作用域和作用域链?的相关文章

JS基础知识回顾:变量、作用域和内存问题

ECMAScript变量可能包含两种不同数据类型的值:基本类型值和引用类型值. 基本类型值指的是简单的数据段,而引用类型值指的是那些可能由多个值构成的对象. 引用类型的值是保存在内存中的对象,与其他语言不同,JavaScript不允许直接访问内存中的位置,也就是说不能直接操作对象的内存空间. 在操作对象时,实际上是在操作对象的引用而不是实际的对象. 在很多语言中,字符串以对象的形式来表示,因此被认为是引用类型的,ECMAScript放弃了这一传统. 定义基本类型值和引用类型值的方式是类似的:创建

js基础梳理-究竟什么是执行上下文栈(执行栈),执行上下文(可执行代码)?

日常在群里讨论一些概念性的问题,比如变量提升,作用域和闭包相关问题的时候,经常会听一些大佬们给别人解释的时候说执行上下文,调用上下文巴拉巴拉,总有点似懂非懂,不明觉厉的感觉.今天,就对这两个概念梳理一下,加深对js基础核心的理解. 1. 执行上下文(execution context)与可执行代码(execution code) 1.1 首先说一下,可执行代码的类型有哪些: 全局代码:例如加载外部的js文件或者本地标签内的代码.全局代码不包括 function 体内的代码 函数代码:functi

js基础梳理-究竟什么是变量对象,什么是活动对象?

首先,回顾下上篇博文中js基础梳理-究竟什么是执行上下文栈(执行栈),执行上下文(可执行代码)?的执行上下文的生命周期: 3.执行上下文的生命周期 3.1 创建阶段 生成变量对象(Variable object, VO) 建立作用域链(Scope chain) 确定this指向 3.2 执行阶段 变量赋值 函数引用 执行其他代码 1.什么是变量对象(Variable Object) 在写程序的时候会定义很多变量和函数,那js解析器是如何找到这些变量和函数的? 变量对象是与执行上下文对应的概念,在

通过原型链解析js函数一些难以理解的的作用域问题

基本原理 js函数在执行时,系统会创建一个隐式的属性scope,scope中存储的是函数的作用域链. 通过对这个scope的分析,就能解释JavaScript中许多难以理解的问题: 例1: function demo(){} demo(); scope属性是在函数执行时创建,如果这个函数是一个全局函数,他的scope里会保存一个Global object和一个activation object. global object保存的是全局的信息,而activition object保存的是函数内部的

js基础梳理-内存空间

我估计有很多像我这样非计算机专业的人进入到前端之后,总是在写业务代码,思考什么什么效果如何实现,导致很多基础概念型的东西都理解得并不太清楚.经常一碰到群里讨论的些笔试题什么的,总觉得自己像是一个假前端似的,似懂非懂,就算会做也不能清楚的表述为什么会是那样的结果.总是自己安慰自己,反正正常项目很少这么写代码.不知道也没关系,但是时间久了,发现不深刻系统的理解这些东西,工作中经常碰到一些莫名其妙的坑,或者有时候觉得看别人插件的代码,别人究竟是如何一步步实现的? 慢慢的就有了越来越强的想法把这些基础知

JS 基础1: 理解number 数据类型 和隐式类型转换

1, Javascript only has one type of numberic data, named "number". You can see this reflected in the behavior of the typeof operator, which classifies intergers and floating-point numbers alike simply as numbers: typeof 10; // "number"

js基础--javascript基础概念之变量与作用域

js基础--javascript基础概念之变量.作用域 javascript按照ECMA-262 的定义,变量与其他语言变量有所不同.js变量时松散的,不需要事先定义变量类型的.这使得他只是一个保存特定值的一个名称.变量与其数据类型可以在脚本的生命周期内改变. 还有明白几点: JavaScript的变量作用域是基于其特有的作用域链的,JavaScript没有块级作用域. 基本类型和引用类型的值 ECMAScript 的变量有两种不同的数据类型:分别是 基本数据类型值 和 引用类型值 : 基本数据

理解js中的作用域,作用域链以及闭包

作用域变量作用域的类型:全局变量和局部变量全局作用域对于最外层函数定义的变量拥有全局作用域,即对任何内部函数来说,都是可以访问的 <script> var outerVar = "outer"; function fn(){ console.log(outerVar); } fn();//result:outer </script> 局部作用域和全局用域相反,局部作用域一般只在固定的代码片段内可访问到,对于函数外部是无法访问的 <script> fu

js基础之--变量 作用域和内存问题

基本类型:Undefind Null Boolean Number String 引用类型: 对象 在操作对象时,实际上实在操作对象的引用而不是实际的对象.为此,引用类型的值是按引用访问的. 从一个变量向另一个变量赋值引用类型的值,同样也会将存储在对象中的值赋值一份放到为新变量分配的空间中.不同的是,这个值的副本实际上是一个指针,而这个指针指向存储在堆中的一个对象 <script> var arr = {name:"jiayu"} doSomeThing(arr); con