闭包是javascript中一个十分常见但又很难掌握的概念,无论是自己写代码还是阅读别人的代码都会接触到大量闭包。之前也没有系统学习过,最近系统地总结了一些闭包的使用方法和使用场景,这里做个记录,希望大家指正补充。
一、定义
《JavaScript忍者秘籍》中对于闭包的定义是这样的:
闭包是一个函数在创建时允许该自身函数访问并操作该自身函数之外的变量时所创建的作用域。换句话说,闭包可以让函数访问所有的变量和函数,只要这些变量和函数存在于该函数声明时的作用域内就行。
注意:这里说的是创建时,而不是调用时。
二、外部操作函数私有变量
正常来讲,函数可以声明一个块级作用域,作用域内部对外部是不可见的,如:
function P(){ var innerValue = 1 } var p = new P() console.log(p.innerValue) //输出undefined
但是,闭包可以让我们能够访问私有变量:
function P(){ var innerValue = 1 this.getValue = function(){ console.log(innerValue) } this.setValue = function(newValue){ innerValue = newValue } } var p = new P() console.log(p.getValue()) //1 p.setValue(2) console.log(p.getValue()) //2
三、只要有回调的地方就有闭包
这可能是我们在日常开发中接触闭包最多的场景,可能有些同学还没有意识到这就是闭包,举个例子:
function bindEvent(name, selector) { document.getElementById(selector).addEventListener('click',function () { console.log( "Activating: " + name ); } ); } bindEvent( "Closure 1", "test1" ); bindEvent( "Closure 2", "test2" );
执行了两次bindEvent函数后,最后传入的name是Closure 2,为什么点击id为test1的按钮输出的不是Closure 2而是Closure 1?这当然是闭包帮我们记住了每次调用bindEvent时的入参name。
四、绑定函数上下文(bind方法的实现)
先看一段代码:
HTML: <button id="test1">click1</button> Js: var elem = document.getElementById('test1') var aHello = { name : "hello", showName : function(){ console.log(this.name); } } elem.onclick = aHello.showName
web前端/H5/javascript学习群:250777811
欢迎关注此公众号→【web前端EDU】跟大佬一起学前端!欢迎大家留言讨论一起转发
当点击按钮时会有什么现象呢?会输出“hello”吗?结果是会输出something,但是输出的不是“hello”,而是空。为什么呢?显然是“this.name”的this搞的鬼,原来当我们绑定事件后触发这个事件,浏览器会自动把函数调用上下文切换到目标元素(本例中是id为test1的button元素)。所以this是指向button按钮的,并不是aHello 对象,所以没有输出“hello”。
那么我们如何将代码改成我们想要的样子呢?
1. 最常用的方式就是用一个匿名函数将showName包装一下:
elem.onclick = function(){ aHello.showName() }
通过这样使aHello来调用showName,这样this就指向aHello了。
2. 使用bind函数来改变上下文
elem.onclick = aHello.showName.bind(aHello)
强行把this指向aHello对象,再点击按钮,就能正常输出“hello”了。是不是很神奇?那么如果让你来实现bind函数,怎么写呢?我简单写了一个:
Function.prototype.bind = function(){ var fun = this; //指向aHello.showName函数 var obj = Array.prototype.slice.call(arguments).shift(); //这里没有处理多个参数,假设只有一个参数 return function(){ fun.apply(obj) } }
核心代码是使用apply方法来改变this的指向,通过闭包来记住调用bind函数的函数,还有bind函数的入参。
五、函数柯里化
有同学可能会问柯里化是什么?先看一个例子:
假如有一个求和函数:
function add(a,b){ return a + b } console.log(add(1,2)) //3
如果是柯里化的写法:
function add(a){ return function(b){ return a+b } } console.log(add(1)(2)) //3
来看百度百科中柯里化的定义:
把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。
通俗来讲,柯里化也叫部分求值(不会立刻求值,而是到了需要的时候再去求值),是一个延迟计算的过程。之所以能延迟,少不了闭包来记录参数。来看一个更有深度的例子,这是社区中某大神丢出的一个问题:
完成plus函数,通过全部的测试用例
function plus(n){}
module.exports = plus
var assert = require('assert') var plus = require('../lib/assign-4') describe('闭包应用',function(){ it('plus(0) === 0',function(){ assert.equal(0,plus(0).sum()) }) it('plus(1)(1)(2)(3)(5) === 12',function(){ assert.equal(12,plus(1)(1)(2)(3)(5).sum()) }) it('plus(1)(4)(2)(3) === 10',function(){ assert.equal(10,plus(1)(4)(2)(3).sum()) }) it('方法引用',function(){ var plus2 = plus(1)(1) assert.equal(12,plus2(1)(4)(2)(3).sum()) }) })
整理思路时考虑到以下几点:
1. plus()()这种调用方式意味着plus函数的返回值一定是个函数,而且由于后面括号的个数并没有限制,想到plus函数是在递归调用自己。
2. plus所有的入参都应该保存起来,可以建一个数组来保存,而这个数组是要放在闭包中的。
3. plus()().sum(),sum的调用形式意味着sum应该是plus的一个属性,而且最终的求和计算是sum来完成的
基于这几点,我写了一个plus函数:
var plus1 = function(){ var arr = [] var f = function(){ f.sum = function(){ return arr.reduce(function(total, curvalue){ return total + curvalue }, 0) } Array.prototype.push.apply(arr, Array.prototype.slice.call(arguments)) return arguments.callee } return f } var plus = plus1()
六、缓存记忆功能
有些函数的操作可能比较费时,比如做复杂计算。这时就需要用缓存来提高运行效率,降低运行环境压力。以前我通常的做法是直接搞个全局对象,然后以键值对的形式将函数的入参和结果存到这个对象中,如果函数的入参在该对象中能查到,那就根据键读出值返回就好,不用重新计算。
这种全局对象的搞法肯定不具有通用性,所以我们想到使用闭包,来看一个《JavaScript忍者秘籍》中的例子:
Function.prototype.memoized = function(key){ this._values = this._values || {} //this指向function(num){...}函数 return this._values[key] !== undefined ? this._values[key] : this._values[key] = this.apply(this, arguments); } Function.prototype.memoize = function(){ var fn = this; //this指向function(num){...}函数 return function(){ return fn.memoized.apply(fn, arguments) } } var isPrime = (function(num){ console.log("没有缓存") var prime = num != 1;//1不是质数 for(var i = 2;i < num; i++){ if(num % i == 0){ prime = false; break; } } return prime }).memoize()
测试执行:
console.log(isPrime(5))
console.log(isPrime(5))
输出:
没有缓存
true
true
该例子巧妙地利用闭包将缓存存在计算函数的一个属性中,而且实现了缓存函数与计算函数的解耦,使得缓存函数具有通用性。
七、即时函数IIFE
先来看代码:
var p = (function(){ var a = 0 return function(){ console.log(++a) } })() p() //1 p() //2 p() //3
web前端/H5/javascript学习群:250777811
欢迎关注此公众号→【web前端EDU】跟大佬一起学前端!欢迎大家留言讨论一起转发
有了IIFE和闭包,这种功能再也不需要全局变量了。所以,IIFE的一个作用就是创建一个独立的、临时的作用域,这也是后面要说的模块化实现的基础。
再来看一个基本所有前端都遇到过的面试题:
for (var i=1; i<=5; i++) { setTimeout( function timer() { console.log( i ); }, 1000 ); }
大家都知道这段代码会在1s后打印5个6,为什么会这样呢?因为timer中每次打印的i和for循环里面的i是同一个变量,所以当1s后要打印时,循环早已跑完,i的值定格在6,故打印5个6。
那么,怎么输出1,2,3,4,5呢?
答案就是使用IIFE:
for (var j=1; j<=5; j++) { (function(n){ setTimeout(function timer() { console.log( n ); }, 1000 ) })(j) }
通过在for循环中加入即时函数,我们可以将正确的值传给即时函数(也就是内部函数的闭包),在for循环每次迭代的作用域中,j变量都会被重新定义,从而给timer的闭包传入我们期望的值。
当然,在ES6的时代,大可不必这么麻烦,上代码:
for (let i=1; i<=5; i++) { setTimeout( function timer() { console.log( i ); }, 1000 ); }
问题解决!具体原因,大家请自行百度…
八、模块机制
先看一个最简单的函数实现模块封装的例子:
function CoolModule() { var something = "cool"; var another = [1, 2, 3]; function doSomething() { console.log( something ); } function doAnother() { console.log( another.join( " ! " ) ); } return { doSomething: doSomething, doAnother: doAnother }; } var foo = CoolModule(); foo.doSomething(); // cool foo.doAnother(); // 1 ! 2 ! 3
简单分析一下,创建实例的过程就是执行构造函数的过程,执行后产生闭包,闭包使我们能达到使用模块来封装数据、函数的目的。再来看返回值,是不是有点“export”的意思,将函数封装成一个对象return出来。
模块模式的两个必要条件:
1. 必须有外部的封闭函数, 该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
2. 封闭函数必须返回至少一个内部函数, 这样内部函数才能在私有作用域中形成闭包, 并且可以访问或者修改私有的状态。
上面的代码每调用一次就会创建一个实例,如果只需要一个实例,可使用单例模式:
var foo = (function CoolModule() { var something = "cool"; var another = [1, 2, 3]; function doSomething() { console.log( something ); } function doAnother() { console.log( another.join( " ! " ) ); } return { doSomething: doSomething, doAnother: doAnother }; })(); foo.doSomething(); // cool foo.doAnother(); // 1 ! 2 ! 3
在ES6的import和export之前,大多数模块加载库的核心代码基本如下:
var MyModules = (function Manager() { var modules = {}; function define(name, deps, impl) { for (var i=0; i<deps.length; i++) { deps[i] = modules[deps[i]]; } modules[name] = impl.apply( impl, deps ); } function get(name) { return modules[name]; } return { define: define, get: get }; })();
这段代码的核心是 modules[name] = impl.apply(impl, deps)。 为了模块的定义引入了包装函数(可以传入任何依赖), 并且将返回值, 也就是模块的 API, 储存在一个根据名字来管理的模块列表中。
下面展示了如何使用它来定义模块:
MyModules.define( "bar", [], function() { function hello(who) { return "Let me introduce: " + who; } return { hello: hello }; }); MyModules.define( "foo", ["bar"], function(bar) { var hungry = "hippo"; function awesome() { console.log( bar.hello( hungry ).toUpperCase() ); } return { awesome: awesome }; }); var bar = MyModules.get( "bar" ); var foo = MyModules.get( "foo" ); console.log(bar.hello( "hippo" )); // Let me introduce: hippo foo.awesome(); // LET ME INTRODUCE: HIPPO
web前端/H5/javascript学习群:250777811
欢迎关注此公众号→【web前端EDU】跟大佬一起学前端!欢迎大家留言讨论一起转发
这是一个很基础的模拟模块加载器的代码,但是十分经典,完整的向我们展示了闭包在其中的作用。
小结:
以上闭包的用法都是在学习和工作中可能遇到的比较常见的用法,相信在掌握这些用法后自己对闭包的认识会上一个台阶,起码在阅读源码时,对这块不会有太多困难。
最后,有什么问题或者不对的地方欢迎大家在评论区指正,后面我也会继续完善此文。
参考文献:
《你不知道的JavaScript》
《JavaScript忍者秘籍》