【译】理解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
我们在此定义了只有一个参数name
的person
函数。它返回一个以greet
为属性的对象。现在我们知道,暴露出的greet
函数可以访问父函数参数。尽管name
变量并没有定义在greet
的作用域中,因为它是闭包,所以greet
可以从其父作用域中获取。
并不是特别难理解,你可能都用了很多次了。我学闭包前从没把它想象的多难,理解了其背后的原理,我就明白了封装并使用模块。
哇唔,哇唔...模块?封装?出乎意料。
模块和用闭包封装
我深陷JavaScript漩涡之前首先了解到其中很多高深词汇都有实践解释。模块和封装就是这类术语很完美的例子。我先从封装开始,用相同的策略各个击破去理解它们。
封装是基本的编程原则之一。学过OOP(面向对象编程)的人对此概念非常熟悉,但对于没学过的人来说---封装就是允许我们保持数据私有的基本隐藏机制。我们不想把方法的所有内容暴露给全局作用域,我们想让大多数内容保持私有且不可访问。
这才是闭包的真正便利之处。我们可以利用闭包访问父作用域,甚至在外部访问的时候获得适当地封装。在父函数中可能有很多方法和变量,通过利用闭包我们可以将其暴露给我们需要的函数。
我们可以用闭包为我们的方法定义一个公共API,并保持方法中所有东西私有。
我们现在已经掌握了封装,只需实践即可。在JavaScript中对此概念的实践就是使用模块。
模块
在ES6中可以使用import
和export
关键字产生以文件为基础的模块,但要注意这些只是语法糖而已。
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
对象,唯一暴露出来的方法是calculateTotal
。order
函数有一个闭包,允许此闭包使用它的变量和入参。在你计算订单总价时隐藏了内部逻辑,也方便以后扩展。
怪异之处
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的部分。