函数作用域链和闭包详解

460 阅读8分钟

闭包和函数作用域链紧密相关,所以我打算把他们两个放在一起写

函数的作用域链

  • 函数执行时,每个Execution Context,中都会有一个包含其中变量的对象:global context叫做变量对象(代码执行时,都会存在,不仅仅在函数执行时期);函数局部上下文中的叫做活动对象(只在函数执行期间存在)
  • 调用函数时,首先创建相应的Execution Context(包含变量对象),然后创建函数的活动对象(用做变量对象)并将其推进作用域链的前端,意味着执行了函数的函数execution context里面有两个变量对象,作用域链实际上就是一个包含指针的列表,每个指针分别指向一个变量对象
  • 一个函数A内部定义的函数B会把A函数的活动对象添加到自己的作用域链中
  • 实现闭包后,外部函数的活动对象不能在其执行完毕后销毁,因为内部函数依旧有对它的引用,依旧保留在内存中。

红宝书中的闭包例子

let value1 = 5
let value2 = 10

function compare(a,b){
// 需要查看compare的原型对象才能看到[[scopes]]属性
// 因为compare.prototype.constructor和foo指向同一个函数,所以点开constructor选项。
    console.log(compare.prototype)
    if (a>b){
        return 1
    }else {
        return 0
    }
}

let result = compare(value1,value2)

image.png

  • 解析
  1. 定义compare()函数时,就会为其创建作用域链,预装载全局变量对象,保存在 [[Scope]] 中,也就是图中的Global和Script。 也就是说,即使不调用compare函数,上图中的红圈部分依旧存在(只是少了一个result)
  2. 调用compare函数:首先创造执行上下文(execution context),然后复制函数的 [[Scope]] 创建其作用域链,最后创建函数的活动对象(用作变量对象)并将其推入作用域链的前端。 (这部分过程在上图[[Scope]]中不可见)
  3. 函数执行完毕之后,活动对象就会被销毁,只留下定义函数时保存在 [[Scope]] 全局变量对象,也就是图中的Global和Script。
  • 作用域链实际上就是一个包含指针的列表,每个指针分别指向一个变量对象 image.png


MDN中的闭包案例

function makeAdder(x) {
  return function(y) {
    return x + y;
  };
}

const add5 = makeAdder(5);
const add10 = makeAdder(10);

console.log(add5(2));  // 7
console.log(add10(2)); // 12
  • 从本质上讲,makeAdder 是一个函数工厂 — 它创建了将指定的值和它的参数相加求和的函数
  • add5 和 add10 都是闭包。它们共享相同的函数定义,但是保存了不同的词法环境。在 add5 的环境中,x 为 5。而在 add10 中,x 则为 10。
  • add5 就是匿名函数本身。执行 add5 能正常计算,这不就是 add5 能记住并访问它所在的词法作用域,而且 add5 函数的运行还是在当前词法作用域之外了。
  • 正常来说,当 makeAdder 函数执行完毕之后,其作用域是会被销毁的,然后垃圾回收器会释放那段内存空间。而闭包却很神奇的将 makeAdder 的作用域存活了下来,add5 依然持有该作用域的引用,这个引用就是闭包。

image.png


  • 注释

内部属性 [[Scope]] 包含了一个函数被创建的作用域中对象的集合

如果你不了解 执行上下文、变量对象、活动对象的区别。请移步于此 简书: JS 执行环境(EC),变量对象(VO),活动对象(AO),作用域链(scope chain)



闭包的原理

接下来我们就可以谈 闭包 了

我们先来看《JS高级程序设计》给闭包的定义:引用了 另一个函数作用域中变量函数


  • 为了解释上面这个定义,一起来看下面出于《JS高级程序设计》的例子
function createCompareFun(propertyName){
        return function closure (object1,object2){
            console.log(closure.prototype)
            let name1 = object1[propertyName]
            let name2 = object2[propertyName]

            return name1>name2?1:0
        }
    }

    let compare = createCompareFun(name)
    let result = compare({name:'Nicholas'},{name: 'Matt'})

打开控制台查看 closure.prototype

image.png

在一个函数内部定义的函数会把其包含函数的活动对象添加到自己的作用域链中。

  1. 在 createCompareFun 返回 closure 函数后,
  2. closure 函数先复制 createCompareFun 函数的活动对象和全局变量对象到自己身上,因此 createCompareFun 函数的活动对象就是图中的 Closure
  3. closure 函数创建自己的活动对象并将其推入作用域链的前端。(这部分过程在上图中的[[Scope]]不可见) image.png

闭包的优缺点

优点

你可以在一些语法技巧上经常见到闭包的出现。

  • MDN: “闭包很有用,因为它允许将函数与其所操作的某些数据(环境)关联起来。这显然类似于面向对象编程。在面向对象编程中,对象允许我们将某些数据(对象的属性)与一个或者多个方法相关联。”

  • 同时,闭包也可以把变量存到独立的作用域,作为私有成员存在,避免变量污染全局

缺点

在上述的第二个例子中,当 createCompareFun 执行完毕以后,其活动对象并没有销毁且一直留在内存中,因为 closure 依旧保留着其引用,直到 closure 被销毁以后才得以销毁

我们可以对第二个例子进行优化

let compare = createCompareFun(name)
let result = compare({name:'Nicholas'},{name: 'Matt'})
compare = null

解除对函数的引用后,垃圾回收机制会将其占用内存释放,作用域链也会被销毁(除了全局作用域以外)


闭包中的this

我们先来看两个例子

const name = "The Window"
const object = {
    name:'My Object',
    getNameFun: function (){
        return function (){
            return this.name
        }
    }
}
let a = object.getNameFun()()
console.log(a) // The Window
const name = "The Window";
const object = {
    name: 'My Object',
    getNameFun: function () {
        let that = this  // 不同之处
        return function () {
            return that.name
        }
    }
};
let a = object.getNameFun()()
console.log(a) // My Object
  • 第一个例子的getNameFun的this指向的是window对象;第二个例子的getNameFun的this指向的是object实例对象

  • 实际上,内部函数不能直接访问到外部函数的this。需要访问包含作用域中的this,则同样需要将其引用先保存到闭包能访问的另一个变量中。

image.png


闭包的应用

其实你经常可以在高阶函数中见到闭包的存在

MDN中的例子:JS 代码都是基于事件的 — 为响应事件而执行的函数。

在页面上添加一些可以调整字号的按钮

//JS
function makeSizer(size) {
  return function() {
    document.body.style.fontSize = size + 'px';
  };
}

var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);

//HTML
<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a>

document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;



防抖函数

function debounce(fn,wait){
    let timer;
    return function (){
        let context = this
        let arg = [...arguments]

        if (timer){
            clearTimeout(timer)
        } 

        timer = setTimeout(()=>{
            fn.apply(context,arg)
        },wait)
    }
}

单例设计模式

class SocketService {
    ws = null // 和服务端连接的socket对象
    connected = false // 标识是否连接成功
    connect = () => {
    } // 定义连接服务器的方法
    send = () => {
    } // 定义发送数据的方法
}

function getInstance() {
    let instance = null;
    return function () {
        if (instance) {
            instance = new SocketService()
        }
        return instance
    }
}
var getSocket = getInstance()
const socket = getSocket()

使用闭包遍历取索引值(古老的问题)

// 利用for循环创建了4个执行函数
// 每个立即执行函数成为一个闭包
// i在每次迭代的时候,都产生一个私有的作用域,在这个私有的作用域中保存当前i的值

for (var i = 0; i < 10; i++) {
    setTimeout(function(){console.log(i)},0) //10个10
}
 
for (var i = 0; i < 10; i++) {
    (function(j){
        setTimeout(function(){console.log(j)},0) // 0 - 9
    })(i)
}

用函数定义模块,我们将操作函数暴露给外部,而细节隐藏在模块内部(个人觉得用类实现比较好)

function module() {
    const arr = []

    function pushNum(val) {
        if (typeof val == 'number') {
            arr.push(val)
        }
    }

    function get(index) {
        return index < arr.length ? arr[index] : null
    }
    return {
        pushNumExport: pushNum,
        getExport: get
    }
}

let module1 = module()
module1.pushNumExport(15)
console.log(module1.getExport(0)) // 15

使用闭包作为特权方法访问私有变量

编程语言中,比如 Java,是支持将方法声明为私有的,即它们只能被同一个类中的其它方法所调用。

JS没有私有成员的概念,所有对象属性都公有的。但是有私有变量,任何定义在函数或块中的变量都可以认为是私有的,私有变量包括函数参数、局部变量、以及函数内部定义的其他函数。

特权方法——能够访问私有变量的公有方法。 把所有私有变量和私有函数都定义在构造函数中,再创建一个能够访问这些私有成员的特权方法,因为定义在构造函数中的特权方法其实是一个闭包。通过构造函数创建实例后,只能通过特权方法来访问了,否则没有办法直接访问私有变量和函数。

const Counter = (function() {
//私有变量
  const privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
 //三个私有方法
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }
})();

console.log(Counter.value()); // 0
Counter.increment();
Counter.increment();
console.log(Counter.value()); // 2
Counter.decrement();
console.log(Counter.value()); // 1

在之前的示例中,每个闭包都有它自己的词法环境;而上面的代码只创建了一个词法环境,为三个函数所共享:Counter.increment,Counter.decrement 和 Counter.value。

该共享环境创建于一个立即执行的匿名函数体内。这个环境中包含两个私有项:名为 privateCounter 的变量和名为 changeBy 的函数。这两项都无法在这个匿名函数外部直接访问。必须通过匿名函数返回的三个公共函数访问。

这三个公共函数是共享同一个环境的闭包。