从编译原理的角度彻底理解什么是闭包(Closure)

4,471 阅读17分钟

「这是我参与2022首次更文挑战的第4天,活动详情查看:2022首次更文挑战

一个由闭包(Closure)引发的思考

在JavaScript这门语言中有一个让初学者容易犯迷糊,但又逃不掉的概念,那就是闭包。说他逃不掉是因为它的产生是这门语言设计理念的附带产物,说它让初学者容易犯迷糊是因为,它的定义过于抽象,很容易让人产生明白了但又没完全明白的感觉。

今天我们就从最基础的概念来讲起,一点一点拨开闭包的外衣,让其露出庐山真面目。

什么是闭包?

我们先看定义, 关于下面的定义先读一下就好,现在不必深究。

我们先看看在计算机科学中怎么解释,这里采用 维基百科中的定义:

In programming languages, a closure, also lexical closure or function closure, is a technique for implementing lexically scoped name binding in a language with first-class functionsOperationally, a closure is a record storing a function together with an environment. The environment is a mapping associating each free variable of the function (variables that are used locally, but defined in an enclosing scope) with the value or reference to which the name was bound when the closure was created. Unlike a plain function, a closure allows the function to access those captured variables through the closure's copies of their values or references, even when the function is invoked outside their scope.

计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。捕捉时对于值的处理可以是值拷贝,也可以是名称引用,这通常由语言设计者决定,也可能由用户自行指定(如C++)。

我们来看一看 MDN 文档关于闭包的解释的解释

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function’s scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

我们再看看 前端红宝书中关于闭包的解释:

Closures are functions that have access to variables from another function’s scope. This is often accomplished by creating a function inside a function.

闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。

最后我们翻一翻小黄书看看它关于闭包的定义:

Closure is observed when a function uses variable(s) from outer scope(s) even while running in a scope where those variable(s) wouldn't be accessible.

当函数在一些 外部(词法作用域中)变量 无法被访问的 作用域中 访问了该(词法)作用域中的变量,就形成了闭包。

对于上面的定义,如果你是一个JavaScript老鸟,可能很轻易的就了解,它在讲什么,然而对于一些JS 新人,可能就有问题了。对于上面提到的什么 词法环境作用域词法作用域 等等本来就理解的不是很清晰,这样组合在一起,更是两眼一抹黑。

不管怎么着,当你读完上面的定义了解到,闭包和Functions & 作用域似乎存在着这下面的关系就好。

closure.png

其实对于闭包的难以理解,不是它本身的定义有多么复杂,而是其前置知识要求的比较多,若我们抛去这些前置知识来谈论这个抽象的概念,当然就难以理解了。

既然如此,那么我们就以一个JS新人的角度来一点一滴的去从前置知识入手去理解闭包。

前置知识

本来我是想一个概念一个概念的去讲解这些前置内容,但这样讲起来仍然是迷雾丛丛,让人不好串起一条明确的知识路线。那么我们就从 JS 的 编译原理来讲起,让整个流程串起来,在整个周期中,需要哪些概念,我们就讲哪些概念。

尽管通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。

为什么有这样的歧义呢? 因 为与其他语言不同, JavaScript 的编译过程不是发生在构建之前的。对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时 间内。

JS引擎在运行过程主要分为两大步骤

  • 编译期(这个时期 JS引擎会请自己的好基友 编译器,来负责语法分析及代码生成等)
  • 执行期(这个主要有JS引擎来完成,在这个时期创建了执行上下文等)

那具体JS引擎在执行过程中的编译期执行期都做了什么呢? 我们一一道来。首先给大家整体来一张图感受一下。一图胜千言,大家先看看图,看自己能理解多少。 image-20220107101808343.png

编译期

编译期主要经历了三个“编译”步骤

  1. 分词/词法分析(Tokenizing/Lexing) 这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代 码块被称为词法单元(token)。
  2. 解析/ 语法分析(Parsing) 将“词法单元流(数组)”转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST) 。
  3. 可执行代码生成 将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息 息相关。

值得一提的是,以上三步过程中我们会确定作用域, 什么是作用域呢?

作用域

作用域是负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限, 控制着变量和函数的可见性与生命周期.

说到作用域,我们来了解几个作用域的概念

词法作用域/ 静态作用域

词法作用域(lexical scope):词法作用域也被称为静态作用域((static scope)。使用词法作用域定义的函数中遇到既不是形参也不是函数内部定义的局部变量的变量时,去函数定义时的环境中查询。

JS采用的就是词法作用域。

动态作用域

动态作用域(dynamic scope):动态作用域的函数中遇到既不是形参也不是函数内部定义的局部变量的变量时,到函数调用时的环境中查询。

JS中没有动态作用域,在JS中 this的表现却和动态作用域有惊人的相似之处,大家可以用理解this的指向来理解动态作用域,如果对动态作用域和this都不太理解,请留言,有需要我会专门再写一篇专题。

两者最显著的区别

词法作用域是因为用的environment是定义时环境,只有唯一一个;而动态作用域用的environment是运行时环境,有N个,我们不能确定。

块级作用域

块级作用域:在 ES5 只有全局作用域和函数作用域,没有块级作用域。使用let声明的变量只能在块级作用域里访问,有“暂时性死区”的特性(也就是说声明前不可用)。块作用域由 { } 包括,if语句和for语句里面的{ }也属于块作用域。

JS的作用域机制

JavaScript 采用词法作用域 (lexical scoping), 也就是静态作用域。 作用域链会保存在函数的内部属性 [[Scope]] 上. 内部属性供 JavaScript 引擎使用, 开发者是访问不到这个属性的.

注意:JS中会有欺骗词法作用域的方法( eval 和 with)这些方法在词法分析器处理过后依然可以修改作用域,但是这种机制可能有点难以理解,也不是我们今天要说的重点,有机会我以后写文章再说

执行期

执行期主要由JS引擎来执行,主要可以分为以下几个步骤

1. 创建执行上下文

执行上下文用以描述代码执行时所处的环境。它定义了代码语句对变量或函数的访问权。在JS中表现为一个关联的内部(variable objects)变量对象。

所有的JS代码都在对应的执行上下文中运行。根据调用位置不同可以分为

  • 全局执行上下文
  • 函数执行上下文
  • eval执行上下文

我们以一个代码中函数的调用来举例子:

在函数被调用之前,函数的执行上下文会被创建,在创建过程主要做了如下几件事:

  • 创建内部变量对象
  • 创建作用域链。
  • 确定 this 指向。
什么是作用域链?

我们先举个例子来理解在JS的作用域链: 我们创建了一个函数A,函数里面又创建了一个函数B,此时就存在三个作用域: 全局作用域、A作用域、B作用域。全局作用域包含了A作用域A作用域包含了B作用域。 当B在查找变量的时候会先从自身的作用域区查找,找不到再到上一级A的作用域查找,如果还没找到就到全局作用域查找,这样就形成了作用域链。

2. 执行代码:执行上下文创建完成后,处于内部的代码会被引擎逐句执行。

在这个过程中主要包括了

  • 变量的赋值
  • 函数的引用和执行
  • ......

3. 垃圾回收

在这里不做过多赘述,我们只需要知道代码执行完后,引擎会根据具体的算法策略自动回收就好。 而引擎的回收策略一般由

  • 引用计数
  • 标记清除

至此 我们已经把 JS引擎的编译和运行过程大致了解了一遍。当然具体的过程远远要比我们上面讲的复杂的多,但是作为一个前端同学,先了解这么多足以,毕竟我们今天重点是闭包, 若有兴趣,以后可以读书慢慢深挖。

再看什么是闭包

由于知道了什么是词法作用域,我们现在才能体会到刚刚的闭包定义的真正含义了。我们来简单的分析一下。拿 MDN文档中的解释来分析

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

现在我们来看看这句话,由于JS采用的是 词法作用域,所以其作用域链的引用是在其定义的时候确定下来的,和函数在哪执行关系不大,所以,这个闭包就顺利成章的产生了。所以,闭包就是这是JS这门语言在由词法作用域构建和执行过程中引用现象的总结定义,并没有什么特别的。

闭包的使用场景

看到自己写 闭包的作用这个小标题,我笑了。这就好比一个人在写吃饭的作用一样。首先,我们看了上面的前置分析应该了解到闭包无处不在, 它就偷偷潜伏在你写的代码里,藏在JS这门语言的骨髓里。 说闭包的好处也基本上是再说JS这门语言的好处;说闭包的使用场景,也等同于在分析JS这门语言的优点和最佳实践。

我对闭包的使用场景做了以下总结。

1. 实现柯里化函数、偏函数

首先如果你没听说过什么是柯里化偏函数,不要被这些稀奇古怪的名字吓到,这些只是简单的概念而已。

柯里化(currying)

将接受n个参数的一个函数转化成接受1个参数的n个函数。

这个直译的名字实在是太不好记了,如果改成扁平化颗粒化,我觉得更让人容易理解。

举个例子

const curry = function(fn){
    return function curryFn(...args){
        if(args.length<fn.length){
            return function(...newArgs){
                return curryFn(...args,...newArgs)
            }
        }else{
            return fn(...args)
        }
    }
}

let add = (a,b,c)=>a+b+c
// 柯里化
add = curry(add)

console.log(add(1)(2)(3)) // 输出 6

偏函数(Partial function)

将接受n个参数的单个函数任意固定a个参数,返回接受剩余n-a个参数的函数。

举个例子

function getPerson(name,age){
  return function (height) {
    return `${name} 今年${age}岁啦,身高${height}cm`;
  }
}

let person = getPerson('Bamboo','18');
person('178');     //"Bamboo 今年18岁啦,身高178cm"

柯里化和偏函数本质没什么区别,只是约束不同 能够实现这类操作的核心点在于JS使用静态的词法作用域。

2. 即时执行函数(IIFF)

即时函数(Immediate Functions)就是函数在定义后可以被立即调用的函数。

函数前加运算符的作用就是将匿名函数或函数声明转换为函数表达式。

即时执行函数的使用场景

  1. IIFE最常见的功能,就是隔离作用域,在ES6之前JS原生也没有块级作用域的概念,所以需要函数作用域来模拟。
  // IIFF
const MyLibrary = (function (global) {
  const myData = '***';
  function feature1(params) {
    console.log(params, global);
  }
  function feature2(params) {
    console.log(params);
  }
  function ReturnFunction(params) {
    // do something;
    return {
      myData,
      feature1,
    };
  }
  ReturnFunction.myData = myData;
  ReturnFunction.feature2 = feature2;
  return ReturnFunction;
})(typeof window !== 'undefined' ? window : this);

  1. 惰性函数 比如DOM事件添加中,为了兼容现代浏览器和IE浏览器,我们需要对浏览器环境进行一次判断。

  2. 模拟块级作用域

function foo(){
  var result = [];
  for(var i = 0;i<10;i++){
    result[i] = function(){
      console.log(i)
    }
  }
  return result;
}
var result = foo();
result[0](); // 10
result[1](); // 10



function foo(){
  var result = [];
  for(var i = 0;i<10;i++){
    (function(i){
      result[i] = function(){
        console.log(i)
      }
    })(i)
  }
  return result;
}
var result = foo();
result[0](); // 0
result[1](); // 1


  1. 实现单例模块封装
// 单例需求 singleton
const MyLibrary3 = (function (global) {
  const myData = '***';
  let instance;
  function feature1(params) {
    console.log(params, global);
  }
  function feature2(params) {
    console.log(params);
  }
  function ReturnFunction(params) {
    // do something;
    return {
      myData,
      feature1,
    };
  }
  ReturnFunction.myData = myData;
  ReturnFunction.feature2 = feature2;
  return function () {
    if (instance) {
      return instance;
    }
    instance = new ReturnFunction();
    return instance;
  };
})(typeof window !== 'undefined' ? window : this);

我们在来简单分析一下由IIFF和经典闭包应用而成Webpack的原理

webpack整体是一个自执行的函数,利用了闭包的特性,保证了内部变量私有化, 同时也不会对全局变量造成污染。 同时我们在这个自执行函数中设置一个对象来模拟并保存模块中exports的内容。同时声明一个函数来模拟 require方法。 每一个模块的执行也都放到一个自执行函数中。

下面代码我们只看IIFF即可,如果有不明白的地方不必深究,若对这部分代码有兴趣,也可以留言,我们做一期详细分析文章。



;(function (modules) {
  // 01 定义对象用于将来缓存被加载过的对象
  let installedModules = {}

  // 02 定义一个 __webpack_require__ 方法替换 import require 加载操作
  function __webpack_require__(moduleId) {
    // 2-1 判断当前缓存中是否存在要被加载的模块内容,如果存在直接返回
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports
    }

    // 2-2 如果当前缓存中不存在则需要我们自己定义{} 执行被导入的模块内容加载
    let module = (installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    })

    // 2-3 调用当前 moduleId 对应的函数,然后完成内容的加载
    modules[moduleId].call(
      modules.exports,
      module,
      module.exports,
      __webpack_require__
    )

    // 2-4 当上述方法调用完成后,我们就可以修改 l 的值用于表示当前模块内容已经加载完成了
    module.l = true

    // 2-5 加载工作完成之后,要将拿回来的内容返回至调用的地方
    return module.exports
  }

  // 03 定义 m 属性 用于保存 modules
  __webpack_require__.m = modules

  // 04 定义 c 属性用于保存 cache
  __webpack_require__.c = installedModules

  // 05 定义 o 方法用于判断对象上是否存在指定的属性
  __webpack_require__.o = function (object, property) {
    return Object.prototype.hasOwnProperty(object, property)
  }

  // 06 定义 d 方法用于在对象的身上添加指定的属性,同时给该属性提供一个 getter
  __webpack_require__.d = function (exports, name, getter) {
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, { enumerable: true, get: getter })
    }
  }

  // 07 定义 r 方法用于标识当前模块是 es6 类型
  __webpack_require__.r = function (exports) {
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
    }
    Object.defineProperty(exports, '__esModule', { value: true })
  }

  // 08 定义 n 方法用于设置具体的 getter
  __webpack_require__.n = function (module) {
    let getter =
      module && module.__esModule
        ? function getDefault() {
            return module['default']
          }
        : function getModuleExports() {
            return module
          }
    __webpack_require__.d(getter, 'a', getter)
    return getter
  }

  // 11 定义 t 方法,用于加载指定 value 的模块内容,之后对内容进行处理再返回
  __webpack_require__.t = function (value, mode) {
    // 01 加载 value 对应的模块内容 ( value 一般就是模块 id)
    // 加载之后的内容又重新赋值给value变量
    if (mode & 1) {
      value = __webpack_require__(value)
    }

    if (mode & 8) {
      // 加载了可以直接返回使用的内容
      return value
    }

    if (mode & 4 && typeof value === 'object' && value && value.__esModule) {
      return value
    }

    // 如果 8 和 4 都没有成立则需要自定义 ns 来通过 default 属性返回内容
    let ns = Object.create(null)

    __webpack_require__.r(ns)

    Object.defineProperty(ns, 'default', { enumerable: true, value: value })

    if (mode & 2 && typeof value !== 'string') {
      for (var key in value) {
        __webpack_require__.d(
          ns,
          key,
          function (key) {
            return value[key]
          }.bind(null, key)
        )
      }
    }
  }

  // 09 定义 p 属性,用于保存资源访问路径
  __webpack_require__.p = ''

  // 10 调用 __webpack_require__ 方法执行模块导入与加载操作
  return __webpack_require__((__webpack_require__.s = './src/index.js'))
})({
  './src/c.js': function (module, __webpack_exports__, __webpack_require__) {
    'use strict'
    __webpack_require__.r(__webpack_exports__)
    __webpack_require__.d(__webpack_exports__, 'age', function () {
      return age
    })
    __webpack_exports__['default'] = 'aaaaaa'
    const age = 90
  },

  './src/index.js': function (
    module,
    __webpack_exports__,
    __webpack_require__
  ) {
    'use strict'
    __webpack_require__.r(__webpack_exports__)
    var _c_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
      /*! ./c.js */ './src/c.js'
    )
    console.log('index.js')
    console.log(
      _c_js__WEBPACK_IMPORTED_MODULE_0__['default'],
      '----',
      _c_js__WEBPACK_IMPORTED_MODULE_0__['age']
    )
  }
})

总结

最后我们再来回顾一下闭包的(MDN)定义: 一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

现在你是不是对这句话有了更深一层的了解呢?