JavaScript学习(4) - 聊聊闭包那些事

552 阅读8分钟

《JavaScript高级程序设计(第三版)》学习笔记


关键词:递归、闭包、闭包的作用域、闭包存在的三个问题、闭包的应用、ES6的补充知识

本章节学习路线:

  1. 闭包closure:
    1. 闭包的本质:函数嵌套函数,内部函数可以访问外部函数的变量和参数(与作用域有关系)
    2. 闭包的应用:模仿块级作用域、私有变量的访问
    3. 闭包的问题(与解决方案):
      • 引用对象的问题 - 访问外部变量的最终值
      • this指针问题 - 具有全局性,所以内部闭包的this指向的是全局window
      • 内存泄露的问题 - IE的坑
  2. 补充知识:ES6对闭包的填坑大法:箭头函数
    1. 模拟块级作用域
    2. 修复this指向
  3. 联动学习:递归函数(自己调用自己)

1. 回顾知识点

1.1 函数声明与函数表达式

可在JavaScript学习(3) - 聊聊原型链- 1. 变量 3.6 Function章节中了解更为详细的内容

  1. 函数声明:解析器会率先读取函数声明,添加到执行环境中,故调用时,代码可写在函数声明之前
// 1. 函数声明 - 语法:
function funName (arg0, arg1, arg2) {
  // 函数体
}

// 函数声明提升 - 调用语句可以在函数声明之前,因为执行前代码会先读取函数声明
sayHi()
function sayHi() {
  alert('Hi')
}
  1. 函数表达式:解析器无法对函数声明进行提升,率先添加到执行环境,故调用时,必须写在表达式之后
// 函数表达式 - 语法 - 将一个匿名函数赋值给一个变量
// 匿名函数:function没有命名
var funName = function (arg0, arg1, arg2) {
  // 函数体
}

// 函数表达式不能进行函数声明提升,调用要在表达式后面
var sayHi = function () { 
  alert('Hi')
}
sayHi()
  1. 注意事项:指针引用、没有重载
// 注意事项
// 备注1:指针引用
var sum2 = sum1 // sum1和sum2均指向同一个function内容

// 备注2:用指针解释function没有重载
var add1 = function (num) { return num + 100 }
add1 = function(num) { return num + 200 } //创建第二个函数的时候,实际上覆盖了第一个引用,故没有重载

1.2 递归函数

  1. 常规阶乘中,会传入本函数名称进行递归调用,实现自己调用自己
// 经典递归样例 - 阶乘
function factorial (num) {
  if (num <= 1) {
    return 1
  }
  else {
    return num * factorial(num - 1) // 通过传入本函数名,从而进行递归调用
  }
}
  1. 问题:对原函数名称内容进行重写赋值后,会影响递归效果
// 当将上面的function作为函数表达式引用给另一个变量antoherFactorial,使anotherFactorial也指向同一个function
// 再将原有factorial变量赋值为null时,factorial不再是function类型
// 导致anotherFactorial调用时,内部无法执行factorial(),从而报错
var anotherFactorial = factorial
factorial = null           // 对原函数进行了覆盖
alert(anotherFactorial(4)) // error! - 相当于num*null,返回false
  1. 常规解决方法:arguments.callee()
// 使用callee解决上述问题
function factorial(num) {
  if (num <= 1) {
    return 1
  }
  else {
    // 使用arguments.callee来代替函数名factorial
    // 避免对原函数名变量的覆盖修改从而影响整个function的功能
    return num * arguments.callee(num-1)
  }
}
  1. 新的问题:严格模式中不可使用callee(解决方案:命名函数表达式作为传参)
// !!!严格模式中禁止使用callee
// 不使用callee的解决方法:命名函数表达式作为传参
// 创建一个命名为fun()的命名函数表达式,再将其赋值给变量factorial
// 无论如何赋值,函数名fun()依然有效
var factorial = ( function fun (num) { 
  if (num <= 1) {
    return 1
  }
  else {
    return num * fun(num - 1)
  }
})()

2. 闭包 closure

2.1 概念与样例

  1. 核心概念:有权访问另一个函数作用域中的变量的函数
  2. 常见的创建方式:在一个函数内部创建另一个函数(函数嵌套函数)
  3. 注意:闭包的名称只是和英文翻译有问题,和package没有任何关系!!!
  4. 样例:以比较两个对象的值的function为例
function createComparisonFunction (propertyName) {
  
  // 内部return一个匿名函数,该函数可以访问外部作用域变量,所以是闭包
  return function (object1, object2) {
   	// 匿名函数内部,可以获取上层函数createComparisonFunction()作用域中的变量:propertyName
    var value1 = object1[propertyName]
    var value2 = object2[propertyName]
    
    if (value1 < value2) return -1
    else if (value1 > value2) return 1
    else return 0
  }
}

// 调用方式:
// 1. 获取闭包函数
var compare = createComparisonFunction('name')
// 2. 执行闭包函数
var result = compare(compareObj1, compareObj2)
// 3. 解除对匿名函数的引用(以便释放内存)
compare = null

分析上述作用域链:(堆栈思想)

  1. createComparisonFunction()

    • [ 作用域链0 ] - createComparisonFunction()局部活动对象,包含argumentspropertyName
    • [ 作用域链1 ] - 全局变量对象,包含createComparisonFunctionresult
  2. 内部匿名函数:

    • [ 作用域链0 ] - 自身闭包函数的活动对象,包含argumentsobject1object2
    • [ 作用域链1 ]- createComparisonFunction()局部活动对象,包含argumentspropertyName
    • [ 作用域链2 ] - 全局变量对象,包含createComparisonFunctionresult

2.2 闭包的问题

2.2.1 第一个问题:引用变量与作用域

闭包中保存的是上一个作用域的整个变量对象,所以只能取得外层函数中任何变量的最终值 1. 直观的样例:for循环中有闭包

function outer() {
  var result = new Array()
  for (var i = 0; i < 10; i++) {
    result[i] = function () {
      // 每个闭包return的i,是获取于外层函数的变量i,但是存入的是i的最终值,也就是循环执行结束后的10
      return i 
    }
  }
  return result
}

// 调用执行
var outerFun = outer()
for (var n = 0; n < 10; n++) {
  console.log(outerFun[n]()) // 10, 10, 10, 10, 10, 10, 10, 10, 10, 10
}

/*
 * function outer()解析:
 * 1. 每个函数都返回10
 * 2. 每个闭包函数的作用域链,都保存着outer()的活动对象,所以都引用的是同一个变量i
 * 3. outer()返回后,变量i的值为10, 此时每个闭包函数都引用保存变量i的同一个变量对象,所以每个闭包内的i都是10
 *
 * 另一种解释方法:
 * 1. result[]通过循环存入的是十个一模一样的匿名function,且没有执行返回过结果
 * 2. 十个function都是闭包,访问的是上层的作用域的活动对象
 * 3. 调用执行的时候,会将result中十个function都执行一遍,获得的i是原本的for循环结束后的最终值
 */

如何改善上面的问题:通过再创建另一个闭包函数,使闭包符合预期

本质:result中不能存入function,而是存入function立即执行的结果的值

function outer() {
  var result = new Array()
  for (var i = 0; i < 10; i++) {
    // 关键:定义一个匿名函数,立即执行匿名函数,并将结果传递给数组;
    // 匿名函数参数为num,即为最终的函数需要返回的值
    // 函数是按值传递,所以传入的复制给了参数num
    // result数组中每个函数都有自己的num变量的副本,而不是直接指向同一个变量i的结果
    result[i] = function (num) {
      // 函数内部,创建并返回一个访问num的闭包
      return function() { 
        return num 
      }
    }(i) // 调用每个匿名函数的时候,传入了变量i,并立即执行传回数组
    
  }
  return result
}

2.2.2 第二个问题:匿名函数的this对象

匿名函数的执行环境具有全局性,所以this对象通常指向的是window

var name = "The window" // 全局对象name

var object = {
  name: 'inner object', // object对象属性
  
  getName: function() { // object中直接使用闭包,其中的this指向的是全局对象,而非局部对象
    return function() {
      return this.name
    }
  },
  
  getName2: function() {
    var that = this     // 将object的this在此进行转存,方便闭包函数可以访问,则转存后的this是局部对象
    return function() {
      return that.name
    }
  }
}

alert(object.getName()) // 'The Window' (非严格模式下)- 因为object中的闭包函数依然指向的是全局window
alert(object.getName2())// 'inner object' - 因为function内部对object的this对象进行了转存,保证闭包中访问的是局部this

2.2.3 第三个问题: 内存泄漏的问题

本质上是IE的bug:

由于IE9之前的版本对JScript对象和COM对象使用不同的垃圾回收机制,

所以导致IE无法回收已经使用完的闭包内的引用变量

function foo () {
  var element = document.getElementById('someElement') 
  
  // 只要匿名函数存在,element引用数至少为1
  element.onclick = function () {
    alert(element.id) // 闭包内,一直执行的是外层引用的element,闭包执行完后,element的引用数无法减少,导致无法释放销毁
  }
}

解决方案:通过手动释放对象,解除引用,使IE可以进行回收

function foo () {
  var element = document.getElementById('someElement') 
  var id = element.id // 将element的id的副本保存在一个变量中
  
  element.onclick = function() {
    alert(id) // 闭包中直接引用的是副本id
  }
  
  element = null // 由于闭包中活动对象包含了element,所以在执行完闭包后,应该设置为null,以解除对element的引用
}

2.3 闭包的应用

2.3.1 模仿块级作用域

JavaScript中没有类C语言中的块级作用域,但是可以用匿名函数模仿块级作用域

2.3.1.1 JavaScript没有块级作用域(回顾)

// 样例:
// 在outputNum()函数中,创建了一个outputNum的执行环境,其中包含活动对象三个:count、i、result
// 在for循环执行结束后,变量和result并不会被销毁,而是依然可以在函数outputNum()内部随处访问
// 备注:类C语言中的块级作用域 - 可以在for循环执行结束后将i和result销毁
function outputNum(count) {
  for(var i = 0; i < count; i++) {
    var result = i
  }
  console.log(i, result) // for循环执行结束后依然可以访问,因为i和result是function执行环境中的活动对象
}

outputNum(10) // 10 9

2.3.1.2 用匿名函数模仿块级作用域

  1. 将函数声明包含在一堆圆括号中,表示其实际上为函数表达式,然后紧跟一堆圆括号以立即执行该函数
  2. 重点:函数表达式、立即执行
// 模仿块级作用域的语法:
(function () {
  // 块级作用域 
})()

// 注意:错误的表达方法
// 下面function是一个函数声明,而不是一个函数表达式
function () {
  // statements
}()
  1. 样例:
// 样例:先定义一个函数表达式,然后调用执行该函数
var someFunction = function(arg) {
  // statements
}
someFunction(10)

2.3.2 私有变量

  1. 私有变量/公共方法概念参考:java中的private、public、getter()、setter()
  2. 注意要点:
    1. JavaScript中没有私有成员概念,所有对象属性都是公有的;
    2. 任何在函数中定义的变量,都可以认为是私有变量;
    3. 私有变量包括函数的参数、局部变量和在函数内部定义的其他函数;
    4. 可以通过特权方法访问私有变量和私有函数 - 类似setter和getter
// 三个私有变量: num1,num2,sum
function add (num1, num2) {
  var sum = num1 + num2
  return sum
}

2.3.2.1 特权方法 - 访问私有变量和私有函数

function Add (num1, num2) {
  // 私有变量
  var sum = num1 + num2
  
  // 私有函数
  function privateFun(result) {
    return result
  }
  
  // 特权方法 - 使用函数表达式创建
  this.publicFun = function () {
    return privateFun(sum)
  }
}

// 创建Add实例对象,然后调用特权方法方法
var result = new Add(10, 20)
result.publicFun() // 30

2.3.2.2 静态私有变量 - 获取块级作用域中的变量和函数

  1. 模仿块级作用域中创建的变量和函数时私有的,外部不可直接访问获取
  2. 可以通过在对象的prototype上,创建特权函数,以实现对这类私有变量的获取
  3. 注意:私有静态变量会因为使用原型而增进代码复用,但每个实例都没有自己的私有变量
(function () {
  // 私有变量
  var name = ''
  
  // 构造函数Person
  Person = function (value) {
    name = value
  }
  
  // 在原型上创建特权方法
  Person.prototype.getName = function() {
    return name
  }
  Person.prototype.setName = function(value) {
    name = value
  } 
})()

// 调用 - 私有静态变量是共享的
// 1. 创建person1对象,person1的name为Nicholas
var person1 = new Person('Nicholas')
person1.getName() // Nicholas

// 2. 创建person2对象
var person2 = new Person('Greg')
person2.getName() // Greg
person1.getName() // Greg - 每个实例都使用的是相同的私有变量,而不是自身的实例变量,所以共享了变化

2.3.3 模块模式 module pattern

  1. 为单例(只有一个实例的对象)创建私有变量和特权方法
  2. 如果需要创建一个对象,并以某些数据对其进行初始化,同时通过公有方法公开部分变量,则使用模块模式
var application = function() {
  // 私有变量
  var components = new Array()
  
  // 对私有变量进行初始化 - 通过BaseComponent()构造函数对私有变量components进行初始化
  components.push(new BaseComponent())
  
  // 公共方法 - 直接return一个对象,包含匿名函数,匿名函数中就给以返回私有变量的内容
  return {
    getComponentCount: function () {
      return components.length
    }
  }
}

2.3.4 增强模式 - 模块模式的增强版本

在返回对象之前加入对其增强的代码

// 用增强模式修改上面的例子
var application = function () {
  // 私有变量
  var components = new Array()
  
  // 初始化
  components.push(new BaseComponent())
  
  // 增强 - 创建application的一个局部副本,增加属性等
  var app = new BaseComponent()          // 限制application对象必须是BaseComponent的实例
  app.type = 'component'                 // 添加公有属性
  app.getComponentCount = function () {  // 添加公共方法
    return components.length
  }
  return app // 返回这个副本
}

3. 补充:ES6箭头函数

  • es6箭头函数可以修复闭包对于this的指向,也可以实现块级作用域
  • 原因:es6箭头函数是词法作用域,不绑定this,只会捕获上下文的this值

3.1 模拟块级作用域

// 用箭头函数声明函数表达式,模拟块级作用域
function output(count) {
  // 1. 声明函数表达式,表达式中的变量均为私有变量
  var func = (num) => {
    for(var i = 0; i < num; i++) {
      var result = i
      }
  }
  
  // 2. 执行函数
  func(10)
  console.log(i, result) // error!
}

3.2 修复闭包中this的指向

// 用箭头函数修复闭包中的this的指向
var name = "The window" // 全局变量
var object = {
  name: 'inner object', 
  // 常规匿名函数
  getName: function () {
    return function () {
      return this.name // this指向全局对象window
    }
  },
  
  // es6箭头函数
  getName2: function () {
    var fn = () => {
      return this.name // this指向object对象
    }
    return fn()
  }
}

alert(object.getName()()) // The window
alert(object.getName2()) // 'inner object'

3.3 ES6箭头函数的补充要点

  1. 箭头函数是匿名函数,不能用作构造函数,不能使用new,使用时会抛出错误
  2. 箭头函数没有prototype属性
  3. 箭头函数不绑定arguments
  4. 箭头函数不能在其参数与其箭头之间包含换行符
  5. 在简洁的主体中,只指定了一个表达式,该表达式为显式返回值。在块体中,必须使用显式return语句
// 只有一个表达式的时候可以直接写 x*x;相当于return x*x
var func = x => x * x;                  
  
// 当参数有多个时,则必须使用return语句;
var func = (x, y) => { return x + y; }; 

4. 小结

4.1 递归

  1. 定义:函数自身调用(自己调用自己)
  2. **优点:**可以实现多种循环场景,比常规的写for简单、优雅
  3. 缺点:
    1. 消耗内存
    2. 时间长(复杂度高)
    3. 在JavaScript中,一旦在递归函数外部,对同名的内部递归函数名称进行了重写覆盖,则会影响实际的递归调用(非严格模式下,用arguments.callee解决)

4.2 闭包

  1. 定义:函数嵌套函数,内部函数可以访问外部函数的局部变量
  2. 常见应用:
    1. 模仿块级作用域(因JavaScript本身没有块级作用域概念)
    2. 在构造函数内部,创建特权方法,便于外部访问私有变量
  3. 存在问题:
    1. 引用变量问题 - 闭包中访问的是外部函数活动对象最终值(因为闭包并没有立即执行获取结果,本质只是函数表达式)
    2. this指针问题 - 闭包的this往往指向全局作用域(window),如若想指向构造函数内部变量,需要转存一下this
    3. 内存泄露问题 - IE的bug,将函数中的变量都保存在内存中而无法回收,容易造成内存泄露。需要手动在退出函数的时候清理一下变量(赋值null)

4.3 闭包与递归的相同点/不同点

  1. 相同点:
    1. 都是函数
    2. 都是函数内部调用函数
  2. 不同点:
    1. 闭包是调用外部函数的变量和参数;递归是自己调用自己
    2. 闭包仅调用一次外部;递归是满足递归条件时进行多次循环调用
    3. 闭包函数由于变量内存问题,更消耗内存

4.4 闭包与匿名函数的区别

  1. 闭包是函数嵌套函数,内部函数会使用外部函数变量
  2. 匿名函数并不使用外部作用域的变量

笔记目录:

JavaScript学习(1) - JavaScript历史回顾

JavaScript学习(2) - 基础语法知识

JavaScript学习(3)- 聊聊原型链- 1. 变量

JavaScript学习(3)- 聊聊原型链- 2. 对象与原型

JavaScript学习(3)- 聊聊原型链- 3. 原型链与继承

JavaScript学习(4)- 聊聊闭包那些事

Github:

Github笔记链接(持续更新中,欢迎star,转载请标注来源)