Skip to content

Latest commit

 

History

History
240 lines (189 loc) · 10.2 KB

作用域、作用域链与闭包.md

File metadata and controls

240 lines (189 loc) · 10.2 KB

作用域、作用域链、闭包

作用域和作用域链

作用域和作用域链,简单来说只要记住以下几个点:

  • 作用域最大的用处是隔离变量,不同作用域下同名变量不会冲突。
  • 作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行。
  • 作用域链用于标识符解析。标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直至找到标识符为止(如果找不到标识符,通常会导致错误发生)。

从编译原理的角度来讲,JavaScript 引擎会在代码执行前对其进行编译,在这个过程中,像 var a = 2 这样的声明会被分解为两个独立的步骤:

  1. 首先,var a 在其作用域中声明新变量。这会在最开始的阶段,也就是代码执行前进行。

  2. 接下来,a = 2 会查询(LHS 查询)变量 a 并对其进行赋值,查询是沿着作用域链进行的。

词法作用域、函数作用域、块作用域

上面大致介绍了作用域和作用域链的用途,下面对其进行更深入的分析。

词法作用域

简单来说,词法作用域就是定义在词法阶段的作用域(大部分编译器都有词法分析这一阶段),即词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的。 在《你不知道的 JavaScript》中举了这么一个例子:

function foo (a) {
  var b = a * 2

  function bar (c) {
    console.log(a, b, c)
  }

  bar(b * 3)
}

foo(2) // 2, 4, 12

这个例子中有三个逐级嵌套的作用域,可以将它们想象成几个逐级包含的气泡。

❶ 包含着整个全局作用域,其中只有一个标识符:foo

❷ 包含着 foo 所创建的作用域,其中有三个标识符:a、bar 和 b

❸ 包含着 bar 所创建的作用域,其中只有一个标识符:c

显然,这些气泡的排列顺序是在书写代码时决定的,那么什么会产生一个新的气泡?只有函数会生成新的气泡吗?

对于 JavaScript 而言,每声明一个函数确实会为其自身创建一个气泡,但是需要注意的是除了函数之外其他结构也能创建作用域气泡,比如 with、try / catch、以及 let 声明对应的块作用域。

函数作用域

函数作用域的含义是:属于这个函数的全部变量都可以在整个函数的范围内使用及复用。

函数声明与函数表达式:函数声明是不可以省略函数名的,而函数表达式可以

function foo (a) {
  console.log('function')
}

setTimeout(function () {
  console.log('I waited 1 second')
}, 1000)

另外,需要注意的是立即执行函数表达式(IIFE - Immediately Invoked Function Expression)的作用域问题:

var a = 2; // 加分号...否则编译报错

(function foo () {
  var a = 3
  console.log(a) // 3
  console.log(foo) // [Function: foo]
})()

console.log(a) // 2
console.log(foo) // ReferenceError: foo is not defined

由于函数被包含在一对()括号内部,因此成为了一个函数表达式,通过在末尾加上另外一个()可以立即执行这个函数。这种形式让 foo 变量名隐藏在自身中而不会非必要地污染外部作用域。

块作用域

除 JavaScript 外的很多编程语言都支持块作用域,块作用域的用处就是让变量的声明距离其使用的地方尽可能的近,通常是将变量绑定到所在的 {...} 中。

var foo = true

if (foo) {
  var bar = foo * 2
}

console.log(bar) // 2

bar 变量仅在 if 声明的上下文中使用,因此如果能将它声明在 if 块内部将会是一个非常有意义的事情,ES6 的 let 关键字就很好地解决了这个问题。

var foo = true 

if (foo) {
  let bar = foo * 2
}

console.log(bar) // ReferenceError: bar is not defined

另一个常见的问题就是 for 循环:

for (var i = 0; i < 10; i++) {
  
}
console.log(i) // 10

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

闭包

函数 A 返回了一个函数 B,并且函数 B 中使用了函数 A 的变量,就形成了闭包。

理解闭包的关键在于:内部函数的作用域链中包含外部函数的作用域(本身作用域的上一级)

《JavaScript高级程序设计》的这个例子十分清晰地解释了闭包

function createComparisonFunction(propertyName) {
  return function(object1, object2) {
    var value1 = object1[propertyName]
    var value2 = object2[propertyName]
 
    if (value1 < value2) {
      return -1
    } else if (value1 > value2) {
      return 1
    } else {
      return 0
    }
  }
}
// 创建函数
var compareNames = createComparisonFunction('name')
// 调用函数
var result = compareNames({name: 'Nicholas'}, {name: 'Greg'})
// 解除对匿名函数的引用(以便释放内存)
compareNames = null

这个图涉及到了执行环境变量对象活动对象作用域链 等概念,同时这张图也用于解释闭包

执行环境(execution context):执行环境定义了变量或函数有权访问的其他数据

  • 全局执行环境是最外围的一个执行环境,在Web浏览器中被认为是 window 对象,因此所有全局变量和函数都是作为 window 对象的属性和方法创建的
  • 每个函数都有自己的执行环境
  • 当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。 而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。ECMAScript 程序中的执行流正是由这个方便的机制控制着。

变量对象(variable object):每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。

活动对象(activation object):执行环境为函数,则其活动对象为变量对象

作用域链(scope chain):当代码在一个环境中执行时,会创建变量对象的一个作用域链。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。

冴羽大大对这几个概念做了深入的分析:点击跳转


理解闭包的关键在于:内部函数的作用域链中包含(本身作用域的上一级)外部函数的作用域,再观察下上面的图,就是说内部函数对外部函数的活动对象有引用,所以即使外部函数出栈了,其活动对象任然驻留在内存中。

因此,闭包不能滥用,其会导致内存泄漏,影响网页的性能。闭包使用完之后,要立即释放资源,将引用变量指向 null,这等同于通知垃圾回收例程将其清除。

闭包中的 this

在闭包中使用 this 对象也可能会导致一些问题。我们知道,this 对象是在运行时基于函数的执行环境绑定的:在全局函数中,this 等于 window,而当函数被作为某个对象的方法调用时,this 等于那个对象。不过,匿名函数的执行环境具有全局性(见 this 的绑定规则部分),因此其 this 对象通常指向 window。下面来看一个例子

var name = "The Window";
var object = {
 name : "My Object",
 getNameFunc : function(){
 return function(){
   return this.name;
   };
 }
};
console.log(object.getNameFunc()()); //"The Window"(在非严格模式下) 
// node.js环境下是undefined

按照之前的对闭包的解释会有如下一个问题,为什么匿名函数没有取得其包含作用域(或外部作用域)的 this 对象呢?

每个函数在被调用时都会自动取得两个特殊变量:this 和 arguments。内部函数在搜索这两个变量时,只会搜索到其活动对象为止,因此永远不可能直接访问外部函数中的这两个变量。不过,把外部作用域中的 this 对象保存在一个闭包能够访问到的变量里,就可以让闭包访问该对象了,如下所示。

var name = "The Window";
var object = {
  name: "My Object",
  getNameFunc: function() {
    var that = this;
    return function() {
      return that.name;
    };
  }
};
console.log(object.getNameFunc()()) // My Object

闭包的用途

一句话回顾下闭包的定义:函数 A 返回了一个函数 B,并且函数 B 中使用了函数 A 的变量,就形成了闭包。其大致有以下用途:

  1. 可以读取函数内部的变量。
  2. 可以使变量的值长期保存在内存中
  3. 可以用来实现JS模块。
  4. 模仿块级作用域
  5. 实现私有变量
// 匿名闭包:IIFE
var Module = (function () {
  var _private = 'safe now'
  var foo = function () {
    console.log(_private)
  }
  return {
    foo: foo
  }
})()

Module.foo() // safe now
console.log(Module._private) // undefined

IIFE

什么样的代码才能称为 JS 模块呢? 具有特定功能的 JS 文件,将所有的数据和功能都封装在一个函数内部(私有的),只向外暴露一个包含 n 个方法的对象或函数,模块的使用者,只需要通过模块暴露的对象调用方法来实现对应的功能。 另外函数节流与防抖也用到了闭包,可以参看 JS-Coding 章节。

例题

function outer() {
  var num=0               // 内部变量
  return function add() { // 通过 return 返回 add 函数,就可以在 outer 函数外访问了
    num++                 // 内部函数有引用,作为 add 函数的一部分了
    console.log(num)
  }
}
var func1 = outer()
func1() // 实际上是调用 add 函数, 输出1
func1() // 输出2 因为 outer 函数内部的私有作用域会一直被占用
var func2 = outer()
func2() // 输出1  每次重新引用函数的时候,闭包是全新的。
func2() // 输出2