详解js中的闭包
2017-02-28 22:13
204 查看
独立作用域
在ES6出现之前,js中并没有块级作用域的存在,这意味者单纯一个大括号并不能隔离出一块作用域{ var a = 1; }
这样的大括号没有隔离出一块作用域,那么变量a声明在括号内或者括号外都是一样的,那么js中什么时候能隔离出一个局部作用域呢,答案是函数
var b = 1; function fn(){ var a = 1; console.log(b); //1 } console.log(a) // a is undefined
这时候函数单独隔离出了一个作用域。而函数外面的作用域在函数作用域的外层,因而函数内部能够访问到外部变量b,但函数外作用域无法访问函数内变量a。
那么在同时声明了变量a的时候会怎么样呢
var a = 1; function fn(){ var a = 2; console.log(a) } fn(); // 2 console.log(a) // 1
当函数内部作用域再次声明变量a的时候,这时候变量a的新声明被压入函数调用栈中,这时js引擎读取a的值时候,会读取到新的声明,所以a的值是2。而执行完函数,局部作用域的a就被弹出(变量a的生命周期结束)。上下文切换到外部作用域之后,a的值就是原来外部作用域中的a,因此输出1。
同样,把fn函数替换成一个立即执行函数(学名缩写为IIFE)效果相同
var a = 1; (function(){ var a = 2; console.log(a) // 2 })()
闭包
之前说到,函数可以访问外部作用域中的变量,但外部作用域不能访问函数内部变量。function fn1(){ var a = 2 function fn2(){ console.log(a); } return fn2; } var fn3 = fn1(); fn3(); // 2 这就是闭包
上面代码的fn2可以轻松访问到变量a,这个毫无疑问。当fn2的引用被赋值给fn3,那么fn3现在和fn2一样,能访问到变量a,这个也毫无疑问。然而fn3的声明却在外部作用域,这和我们上文说的外部作用域不能访问到函数内部变量相悖,这,就是闭包。
由于内部函数fn2和fn3的特殊关系,原本fn1的内部作用域原本会被销毁并被js引擎的垃圾回收器回收内存,现在fn1却能一直存活。
顽强的闭包
内部函数fn2的引用无论被传递到哪个作用域中,它都会持有对原始作用域的引用,也就是说,一直能读取到变量avar fn4; function fn1(){ var a = 2 function fn2(){ console.log(a); } fn4 = fn2; } function fn3(){ fn4(); // 还是强行输出了2 } fn3();
闭包无处不在
在定时器,事件监听器,Ajax请求或者其他异步任务中,只要使用了回调函数,实际上就是在使用闭包(回调函数被扔在事件队列中,还保存着对msg等变量的作用域引用)function fn(msg){ setTimeout(function(){ console.log(msg); }, 1000); } fn('hello');
fn执行1000毫秒之后,它的内部作用域并不会消失,依然拥有对fn作用域的闭包。
var btn = document.getElementById('button'); var action = 'Click'; function fn(btn, action){ btn.onclick = function(){ console.log(action); } } fn(btn, action); // 每次点击都能得到action
有一个比较常见的场景是,给循环的元素绑定事件监听函数
var nodes = document.getElementsByTagName('div'); for(var i = 0, len = nodes.length; i < len; i++){ //这里通过一个IIFE封闭一个关于i的内部作用域 (function(i){ nodes[i].onclick = function(){ //click回调函数中通过闭包拿到i变量 alert(i); } })(i) }
内存泄漏
function Handler(){ var element = document.getElementById('someElement'); var id = element.id; element.onclick = function(){ alert(id); } //只要onclick的回调匿名函数存在,element所占的内存就永远不会被回收,而我们这里只需要变量id,所以我们需要把element的引用设为null,确保正常回收占用的内存 element = null; }
使用闭包封装变量
假设有一个计算乘积的简单函数var mult = function(){ var a = 1; for(var i = 0; i < arguments.length; i++){ a = a * arguments[i]; } return a; }
对于那些相同的参数来说,可以使用缓存来提高效率
var cache = {}; var mult = function(){ //mult(1, 2, 3) => '1, 2, 3' var args = Array.prototype.join.call(arguments, ','); if(cache[args]){ //使用cache.args会把args自动转成字符串 return cache[args]; } var a = 1; for(var i = 0; i < arguments.length; i++){ a = a * arguments[i]; } return a; }
与其让cache暴露在全局,不如将它封装在IIFE中
var mult = (function(){ var cache = {}; return function(){ var args = Array.prototype.join.call(arguments, ','); if(cache[args]){ //使用cache.args会把args自动转成字符串 return cache[args]; } var a = 1; for(var i = 0; i < arguments.length; i++){ a = a * arguments[i]; } return a; } })();
提炼函数是代码重构中的一种常见技巧,如果在一个大函数中有一些代码块能够提炼出来,我们常常把这些代码块封装在独立的小函数里面,独立出来的小函数有助于代码复用,如果这些小函数有好的命名,它们本身页起到了注释的作用
var mult = (function(){ var cache = {}; var calculate = function(){ var a = 1; for(var i = 0; i < arguments.length; i++){ a = a * arguments[i]; } return a; } return function(){ var args = Array.prototype.join.call(arguments, ','); if(cache[args]){ return cache[args]; } //将参数传入 return cache[args] = caculate.apply(null, arguments); } })()
延续局部变量的寿命
img对象经常用于数据上报var report = function(src){ var img = new Image(); img.src = src; }; report('http://xxx.com/getUserInfo');
而在一些低版本浏览器中,report函数并不是每一次都成功发起了HTTP请求,原因是img是局部变量,函数结束调用后就被销毁,可能还没来得及发出HTTP请求
var report = (function(){ var imgs = []; return function(){ var img = new Image(); //将img放进闭包变量中 imgs.push(img); img.src = src; } })()
用闭包实现命令模式
在完成闭包实现的命令模式之前,我们先用面向对象的方式来编写一段命令模式的代码<html> <body> <button id="execute">点击我执行命令</button> <button id="undo">点击我执行命令</button> </body> </html> <script> var Tv = { open: function(){ console.log('打开电视机'); }, close: function(){ console.log('关上电视机'); } }; var OpenTvCommand = function(receiver){ this.receiver = receiver; }; OpenTvCommand.prototype.execute = function(){ this.receiver.open(); //执行命令,打开电视机 } OpenTvCommand.prototype.undo = function(){ this.receiver.close(); //撤销命令,关闭电视机 } var setCommand = function(command){ document.getElementById('exucute').onclick = function(){ command.execute(); } document.getElementById('undo').onclick = function(){ command.undo(); } } setCommand(new OpenTvCommand(Tv)); </script>
命令模式的意图是把请求封装成对象,从而分离请求的发起者和请求的接收者之间的耦合关系。在命令执行之前,可以预先往命令对象中植入命令的接收者。在闭包的模式中,命令接收者会被封闭在闭包形成的环境中
var Tv = { open: function(){ console.log('打开电视机'); }, close: function(){ console.log('关上电视机'); } }; var createCommand = function(receiver){ var execute = function(){ return receiver.open(); //执行命令,打开电视机 } var undo = function(){ return receiver.close(); //执行命令,关闭电视机 } return { execute: execute, undo: undo } } var setCommand = function(command){ document.getElementById('exucute').onclick = function(){ command.execute(); } document.getElementById('undo').onclick = function(){ command.undo(); } } setCommand(createCommand(Tv));
执行上下文
讲完实际应用之后,下面来看一下高能的理论原理。执行上下文是ECMAScript标准中定义的一个抽象概念,用来记录代码的运行环境。它可以是代码最开始执行的全局上下文,也可以是执行某个函数体内的上下文。
需要注意的是,程序至始至终只能进入一个执行上下文,这就是为什么js是单线程的原因,即每次只能有一个命令在执行。浏览器用栈来维护执行上下文,当前起作用的执行上下文位于栈顶,当它内部的代码执行完毕之后出栈,然后将下一个元素作为当前的上下文。
然而,程序并不需要执行完上下文中的所有代码,才能进入另一个执行上下文(在一个函数中调用另一个函数)。经常有当前的执行上下文A执行到一半暂停,又进入另一个执行上下文的情况。每次一个上下文被另一个上下文替代的时,这个新的上下文就入栈称为栈顶。
当有一堆上下文,有些执行到一半暂停的时候又继续,当继续执行的时候我们需要一种方式去记住当前的状态,事实上ECMAScript中已经做出了规定,每个执行上下文都有用来追踪执行状态的记录器
代码执行状态(Code evaluation state)在当前执行上下文中用来记录代码执行,暂停,重新执行的状态
函数(Function):当前上下文正在执行的函数体
范畴(Realm):内部对象集合,全局运行环境极其作用域下的所有代码,其他相关的状态、资源
词法环境(Lexical Environment):用来解决当前上下文中的标识符引用问题
变量环境(Variable Environment):包含环境记录(EnvironmentRecord)的词法环境,而环境变量是由变量声明(VariableStatements)所产生的
词法环境
用来定义标识符的值:词法环境的目的就是管理代码中的数据。也就是说,它给标识符赋值,让标识符变得有意义。比如,代码段console.log(x/10),如果变量x没有具体值,它是没有意义的,这段代码也没有意义。词法环境通过环境记录将标识符和具体的值联系在一起(见下一点)。
词法环境包含环境记录:环境记录完美地记录了词法环境中所有标识符和具体值之间的联系,并且每个词法环境都有自己的环境记录。
词法嵌套结构:内部环境引用包含它的外部环境,外部环境还可以有自己的外部环境。因此,一个环境可以作为多个内部环境的外部环境。全局环境是唯一一个没有外部环境的环境。
回到闭包
每个函数都有一个包含词法环境的执行上下文,它的词法环境确定了函数内的变量赋值以及对外部环境的引用。看上去函数“记住”了外部环境,但其实上是这个函数有个指向外部环境的引用。这就是“闭包”的概念。每当外部封闭函数执行的时候就产生了闭包,也就是说闭包的创建并不一定需要内部函数返回。
JavaScript中闭包作用域是词法作用域,即它在代码写好之后就被静态决定了它的作用域。