「JavaScript基础」手把手教你理解作用域链[作用域,作用域链,数据存取性能优化,闭包]

1,078 阅读10分钟

作用域


作用域这个概念可以说贯穿了整个javascript知识体系的始终,例如确定变量是否可以被访问,当前this的值等等,而要理解性能和作用域的关系,首先要理解作用域的工作原理.

作用域链


每一个函数都是一个对象(Function对象的实例),所以他们都拥有可以访问的属性,和一系列不能通过代码访问的内部属性.其中一个内部属性就是[[scope]]

感谢chrome浏览器的强大功能,我们可以在控制台看到当前函数的[[scope]]

我们先来给一个代码的例子,然后借助控制台来观察其作用域的变化


let color = 'blue'

debugger

function changeColor() {
    
    let anotherColor = 'red'

    debugger

    function swapColor() {
        
        let tempColor = anotherColor

        anotherColor = color

        color = tempColor

        debugger

    }

    debugger

    swapColor()

    debugger


}

changeColor()

  • step1.初始化作用域

Global就是最外层的全局变量,也就是window,代码中定义的changeColor函数也会在里面.

script是该js执行时创建的,color变量存放在这里.

我们可以展开Global中的[[scope]]看一下.发现和外部是完全一致的.这是因为我们的changeColor函数是定义在最外层window上的.

  • step2.执行changeColor()

debuggerlet anotherColor = 'red'之后

此时已经开始执行changeColor

首先我们看下call stack这个功能

Call Stack 函数调用栈

CallStack是一个栈结构,栈的特点是LIFO(后入先出),出栈入栈只会在一端(也就是栈顶)进行。

CallStack是用来处理函数调用与返回的。每次调用一个函数,Javascript运行时会生成一个新的调用结构压入CallStack。而函数调用结束返回时,JavaScript运行时会将栈顶的调用结构弹出。由于栈的LIFO特性,每次弹出的必然是最新调用的那个函数的结构。

可以看到changeColor已经被入放入栈中,我们可以简单理解为,当一个函数被执行,就放入这个栈,当它运行结束时,就会被弹出

然后我们看一下scope

此时我们应该分成两部分来看,下面红框的scriptGloble其实是在定义时候的[[scope]]就展示了.这部分是在函数被创建的时候就定义好的,在函数被执行时,它将会优先被推入栈中.

执行上下文

执行此函数会创建一个称为执行上下文的内部对象,执行上下文定义了一个函数执行的环境.需要注意的是,多次调用同一个函数将会导致创建不同的执行栈,自然他们的执行上下文也就不同.当函数执行完毕的时候,栈被推出,执行上下文就会被销毁.

看上图,Local部分就是运行时被创建的活动对象,在函数执行时被推入了scope这个作用域栈的顶部.

因为,swapColor()是在changeColor()执行时被创建(这一点我们可以在上图中看到).由此我们可以推断,swapColor()[[scope]]作用域链就是changeColor()执行时候的scope(tips),我们可以展开看一下

tips: 一个有趣的现象,function 定义的函数和 const let var定义的函数在scope中的表现并不相同.假如一个直接用function定义的函数不会被执行(js会先扫描一边看这个函数有没有调用),那它就不会储存在scope中.而const,let,var申明的函数无论有没有被调用都会被储存在scope中

可以看到对应关系是非常的明确的.和changeColor()执行时的scope一致

  • step3. 执行swapColor

根据代码,当运行到swapColor()时,它的执行过程被放入call stack函数调用栈,同时创建了一个活动对象,并且推入了作用域链的最上层,所以他的作用域链应该是local(活动对象) + 创建时的[[scope]]

总结

作用域决定了当前环境下变量的值,而多个作用域的串联组成了作用域链.

作用域链是调用时的local(即活动对象) + 定义时的[[scope]],变量的值在作用域链完成式就已经决定.

函数执行流程:

1.js引擎扫描决定作用域的初始化内容

2.初始化,如果是函数则会有拥有[[scope]]属性,用来包含一个函数被创建时的作用域链条

3.执行该函数,创建一个进程推入call stack

4.复制[[scope]]推入当前作用域scope,创建活动对象推入scope顶部

5.执行完毕,call stack弹出该进程

6.垃圾回收

数据存储存取优化


说明一点,由于js的特性,变量的取值在函数被执行的时候才能确认(比如说万恶的this指向问题).所以当活动对象被创建并推入作用域顶部后,js引擎才会沿着作用域链去查找,这里,就涉及了数据存储相关的性能优化问题.

众所周知,所有的计算机行为都是有消耗的.大多数情况下,从一个字面量和一个局部变量中存取数据的性能差异是微不足道的,而访问数组元素和对象成员的开销则要更大一点.至于具体要大多少,主要看浏览器.

在现代的高性能浏览器中,基本都是对数组和对象的数据存取做过优化的.而一个变量被获取值的过程有一个专业的名词——标识符解析(Identifier Resolution Performance)

标识符解析

如前文所讲,任意的计算机操作都会产生性能开销.在执行环境的作用域链中,一个标识符所在的位置越深,它的读写速度也就越慢.因此,函数读写局部变量总是最快的.而读写全局变量一般而言都是最慢的(但有些javaScript引擎会优化它).主要的原因在于全局变量处于作用域链的最末端,因此他也是最远的.

然而在高性能浏览器中,例如chrome和safari4,他们都针对访问跨作用域标识符做了优化,很大程度上减少了这种性能损失.然而在其他浏览器中,性能损失会达到令人震惊的陡峭曲线.

综上所述,在没有优化javaScript引擎的浏览器中,建议尽可能使用局部变量,一个好的经验法则是:如果某个跨作用域的值在函数中被引用一次以上,那么就将其存储到局部变量中.

想知道chromeV8引擎如何实现属性访问的优化的可以看这里

JavaScript 引擎基础:Shapes 和 Inline Caches

顺便推荐大家看一下李兵 老师的 《图解 Google V8》

标识符解析流程:

  • 先搜索自身的活动对象,如果存在则返回,如果不存在将继续搜索函数a的活动对象, 依次查找,直到找到为止。

  • 如果函数存在prototype原型对象,则在查找完自身的活动对象后先查找自身的原型 对象,再继续查找。这就是Javascript中的变量查找机制。

  • 如果整个作用域链上都无法找到,则返回undefined。

闭包


闭包的基本原理

闭包,javaScript最强大的特性.它允许函数访问局部作用于外的数据.

结合一个例子,直观的了解闭包的强大之处.

function func(a, b, length) {
    let sum = length
    debugger
    return function func1() {
        let sum2 = a
        debugger
        return function func2() {
            let sum3 = b
            debugger
            return sum = a + b + length + sum2 + sum3
        }
    }
}


var step1 = func(1, 2, 5)
var step2 = step1()
var setp3 = step2()

闭包强大之处可见一般.当运行到func2内部的时候,它依然可以访问到外层甚至最外层的变量.但是这样就有了一个问题,当我们查看call stack的时候,它的确遵循着规律,在funcfunc1执行完毕后分别将其推出了栈,那么此时,fun2又是如何拿到原本在外部的变量的呢?

前文也提到过,一个标识符解析会循着作用域链向上寻找这个标识符,所以这个标识符的值在执行的那一刻就决定了.而遵循着这个原理,闭包为何能拿到外部变量的原因也就呼之欲出了——**尽管外部的函数已经执行完成,并且被从函数执行栈(call stack)中推出了,但函数依然在自己的作用域链([[scope]])中保留了对外部变量的引用,这是函数被定义时就决定的,和函数的执行与否没有关系.**如图所示:

func2func1运行到return时被定义,我们可以在其[[scope]]属性中发现它保存了funcfunc1中声明的变量,而这部分将在函数执行时被推入scope中,如图所示:

这就是闭包的核心原理.

内存泄漏与垃圾回收

由于闭包的关系,作用域链的存在使得变量时时刻刻都处于被引用的状态,这就使得js引擎认为这个变量一直是有用的.从而他就不会被从内存中释放.而控制内存释放的机制被称为垃圾回收机制.

垃圾回收

了解js垃圾回收点这里

内存泄漏

了解内存泄漏点这里

实战面试题

  • 原题
var result = [];
var a = 3;
var total = 0;
function foo(a) {
    for (var i = 0; i < 3; i++) {
        result[i] = function () {
            total += i * a;
            console.log(total);
        }
    }
}
foo(1);
result[0]();
result[1]();
result[2]();

最后的输出是?

答案是:


result[0](); // 3
result[1](); // 6
result[2](); // 9

如果回答对了,那么你对作用域,作用域链和变量提升已经有了一定的认知,下面来逐步分解一下整个过程.

首先,我们要将for循环做一个拆解,由于var关键字的特性,变量i会存在变量提升

function foo(a) {
	var i = 0;
    // let i = 0
    for (; i < 3; i++) {
        result[i] = function () {
            total += i * a;
            console.log(total);
        }
    }
}

所以其实i变量是存在foo函数的活动对象中被推入作用域下顶部,而不是存在于for循环的块级作用域中,scope如图:

所以每当for循环中对i进行++操作,变化的是foo函数活动对象的i变量.当result数组初始化完成的时候,i的值已经为3了.

于此同时result数组中的函数本身并没有i变量,他只能沿着作用域链向上寻找,直到找到foo函数的作用域下.

那么3,6,9的答案也显而易见了.变量afoo(1)的时候作为实参传入,位于foo函数的作用域下,所以值为1,而i为3,total在依次递增后呈现3,6,9

  • 变化一
var result = [];
var a = 3;
var total = 0;
function foo(a) {
	// var 改为 let
    for (let i = 0; i < 3; i++) {
        result[i] = function () {
            total += i * a;
            console.log(total);
        }
    }
}
foo(1);
result[0]();
result[1]();
result[2]();

答案是:


result[0](); // 0
result[1](); // 1
result[2](); // 3

如果答对了,那么说明你对变量提升和块级作用域的概念已经比较熟悉.

let关键字定义的变量不存在变量提升,它只能在定义它的块级作用域里生效.而每次for循环会有自身独立的块级作用域,平行的块级作用域中定义的变量不会互相影响.

我们来看一下实际中的作用域的样子

最后result数组的样子

可以发现[[scope]]中保存了实时的变量i的块级作用域.

最后的0,1,3也就非常好理解了.