【译】理解JavaScript:闭包

原文链接

为什么深度学习JavaScript?

JavaScript如今是最流行的编程语言之一。它运行在浏览器、服务器、移动设备、桌面应用,也可能包括冰箱。无需我举其他再多不相干的例子,只要你正从事web开发,你就不可避免地要写JavaScript。

很多web开发者仅仅因为能写可以运行的代码就声称了解JavaScript。对于JavaScript,你可以用一个月就能写代码,掌握它之后终生收益。(If there are no errors and nobody’s complaining why should you need to learn more?)(译者注:不知所云)

好吧,我就是曾经声称很了解此语言的一员。几年前我用AngularJS和Node写应用,当时对自己的能力非常自信。抛开功能,我坚信我已经征服了JavaScript。

当面试中让我解释一下闭包时我懵逼了。我感觉自己知道一点,和回调有关,我当时一直用回调(当时还不知道Promise),但就是不知道怎么描述其原理。

在我的开发职业生涯中那次失败的JavaScript面试是最耻辱和最具教育意义的经历。从那时起我历时一年半致力于JavaScript的高价段位,并决定分享于世人。先从一个最常见的JavaScript面试题开始:

什么是闭包?

毫无疑问你已经在各种应用中使用过闭包。你每次为事件处理器添加回调时你都在用闭包的神奇属性。

我遇到过很多关于此概念的解释,但我最信服是Kyle Simpson下的定义:

当一个方法执行完脱离了自己的词法作用域,但仍然能够记住并访问其词法作用域,这就是闭包。

这个解释开始可能有点晦涩,让我们抽丝剥茧摘下闭包的真面目。

此文不详述作用域(有专门的主题阐述),不过作用域是理解闭包原理的基础。作用域就是包含某些属性和方法的区域。每个JavaScript方法都会创建一个新的作用域,它内部的变量和入参都只能在其内部访问。

如果你在函数内声明一个变量,函数外是访问不到的。不过,我们可以在函数内部定义拥有作用域的内部函数。这些内嵌函数的特别之处在于它们可以访问父作用域的变量。

坦白说这也算不上什么特别之处,因为每一个在全局作用域中定义的函数都能访问全局变量。虽然我们提到的这些内嵌函数可以访问父函数的作用域,但它们不能在父函数之外被调用。除非我们将其暴露出来。

我们将内部函数暴露出来就可以在全局作用域中使用。牛逼!现在我们就可以随心所欲了。不过,暴露出来的内部函数实际上引用了它父作用域的变量,会不会有问题?不会!绝对不会,这就是闭包!

闭包是暴露出来的内嵌方法

我不确定这是否是给闭包下的最好的定义,但这确实能够很好地抓住此术语的本质。闭包就是我们在函数外部就能访问其父作用域的内部函数。你能否通过我们之前提到的词法作用域理解此解释呢?

function person(name) {
  return {
    greet: function() {
      console.log('hello from ' + name)
    }
  }
}

let alex = person('alex');
alex.greet(); // hello from alex
console.log(alex.name); // undefined
console.log(name); // will throw ReferenceError

我们在此定义了只有一个参数nameperson函数。它返回一个以greet为属性的对象。现在我们知道,暴露出的greet函数可以访问父函数参数。尽管name变量并没有定义在greet的作用域中,因为它是闭包,所以greet可以从其父作用域中获取。

并不是特别难理解,你可能都用了很多次了。我学闭包前从没把它想象的多难,理解了其背后的原理,我就明白了封装并使用模块。

哇唔,哇唔...模块?封装?出乎意料。

模块和用闭包封装

我深陷JavaScript漩涡之前首先了解到其中很多高深词汇都有实践解释。模块和封装就是这类术语很完美的例子。我先从封装开始,用相同的策略各个击破去理解它们。

封装是基本的编程原则之一。学过OOP(面向对象编程)的人对此概念非常熟悉,但对于没学过的人来说---封装就是允许我们保持数据私有的基本隐藏机制。我们不想把方法的所有内容暴露给全局作用域,我们想让大多数内容保持私有且不可访问。

这才是闭包的真正便利之处。我们可以利用闭包访问父作用域,甚至在外部访问的时候获得适当地封装。在父函数中可能有很多方法和变量,通过利用闭包我们可以将其暴露给我们需要的函数。

我们可以用闭包为我们的方法定义一个公共API,并保持方法中所有东西私有。

我们现在已经掌握了封装,只需实践即可。在JavaScript中对此概念的实践就是使用模块。

模块

在ES6中可以使用importexport关键字产生以文件为基础的模块,但要注意这些只是语法糖而已。

function Person(firstName, lastName, age) {
  var private = 'this is a private member';

  return {
    getName: function() {
      console.log('My name is ' + firstName + ' ' + lastName);
    },
    getAge: function() {
      console.log('I am ' + age + ' years old')
    }
  }
}

let person = new Person('Alex', 'Kondov', 22);
person.getName();
person.getAge();
console.log(person.private); //undefined

这是一个我们可以保持一些数据私有的简单例子。我们可以有其他内嵌方法,尽管导出后可以使用,但并没有都暴露出来。

function Order (items) {
  const total = items => {
    return items.reduce((acc, curr) => {
      return acc + curr.price
    }, 0)
  }
  
  const addTaxToPrice = price => price + (price * 0.2)
  
  return {
    calculateTotal: () => {
      return addTaxToPrice(total(items)).toFixed(2)
    }
  }
}

const items = [
  { name: 'Toy', price: 14.99 },
  { name: 'Candy', price: 7.99 }
]

const order = Order(items)
console.log(order.total) // undefined
console.log(order.addTaxToPrice) // undefined
console.log(order.calculateTotal()) // 27.58

在这个更接近真实的例子中方法返回了一个order对象,唯一暴露出来的方法是calculateTotalorder函数有一个闭包,允许此闭包使用它的变量和入参。在你计算订单总价时隐藏了内部逻辑,也方便以后扩展。

怪异之处

JavaScript也有其怪异之处。实际上有些怪异之处让人非常蛋疼。闭包使用不当就会很坑。

下面的代码经常出现在JavaScript面试中让猜它的输出。

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer () {
    console.log(i);
  }, i * 1000);
}

从1循环到5并在一段时间后打印出当前的数字。正常感觉会输出1,2,3,4,5,对吗?

让我惊奇的是上面的代码会在输出台上连续5次打印出6。如果循环之中没有setTimeout不会有任何问题,因为日志输出会被立即执行。很明显,排队操作引发了这个问题。

我们期望每次调用setTimeout都会获取i变量自身的拷贝,但实际情况却是它访问的是它的父作用域。又因为都在排队,第一个日志会在它排队1秒后发生。当1000毫秒过去的时候,循环早已结束,i变量也早已被赋值为6。

我明白了这个问题但如何修复呢?setTimeout会在全局作用域寻找i变量,无法打印出我们想要的数字。我们可以把setTimeout包裹到一个方法中并将我们想要输出的变量传进去。这样setTimeout会从它的父作用域而不是全局作用域进行访问。

for (var i = 1; i <= 5; i++) {
  (function(index) {
    setTimeout(function timer () {
      console.log(index);
    }, index * 1000);
  })(i)
}

我们使用IIFE(立即执行函数,Immediately Invoked Function Expression)并把想输出的数字传进去。IIFE是一种定义后立即调用的函数,它常用于这种情况---我们想要创建作用域。这种方式每次函数调用都用它们自己的变量拷贝,这也意味着setTimeout运行时会访问对应的数字。所以上面的例子我们会达到期待的结果:1,2,3,4,5

结束语

此文介绍了闭包的本质,但还有很多需要学习和更多的边际情况需要考虑。如果你想更进一步了解闭包,我强烈推荐Kyle Simpson的书中Scope & Closures的部分。

相关推荐