[译]发现 JavaScript 中闭包的强大威力

原文地址: Discover the power of closures in JavaScript
原文做者: Cristi Salcescu
译者: wcflmyjavascript

闭包是一个能够访问外部做用域的内部函数,即便这个外部做用域已经执行结束。java

做用域

做用域决定这个变量的生命周期及其可见性。 当咱们建立了一个函数或者 {} 块,就会生成一个新的做用域。须要注意的是,经过 var 建立的变量只有函数做用域,而经过 letconst 建立的变量既有函数做用域,也有块做用域。git

嵌套做用域

Javascript 中函数里面能够嵌套函数,以下:github

(function autorun(){
    let x = 1;
    function log(){ 
       console.log(x); 
    }
    log();
})();
复制代码

log() 便是一个嵌套在 autorun() 函数里面的函数。在 log() 函数里面能够经过外部函数访问到变量 x。此时,log() 函数就是一个闭包。编程

闭包就是内部函数,咱们能够经过在一个函数内部或者 {} 块里面定义一个函数来建立闭包。后端

外部函数做用域

内部函数能够访问外部函数中定义的变量,即便外部函数已经执行完毕。以下:bash

(function autorun(){
    let x = 1;
    setTimeout(function log(){
      console.log(x);
    }, 10000);
})();
复制代码

而且,内部函数还能够访问外部函数中定义的形参,以下:闭包

(function autorun(p){
    let x = 1;
    setTimeout(function log(){
      console.log(x);//1
      console.log(p);//10
    }, 10000);
})(10);
复制代码

外部块做用域

内部函数能够访问外部块中定义的变量,即便外部块已执行完毕,以下:app

{
    let x = 1;
    setTimeout(function log(){
      console.log(x);
    }, 10000);
}
复制代码

词法做用域

词法做用域是指内部函数在定义的时候就决定了其外部做用域。异步

以下代码:

(function autorun(){
    let x = 1;
    function log(){
      console.log(x);
    };
    
    function run(fn){
      let x = 100;
      fn();
    }
    
    run(log);//1
})();
复制代码

log() 函数是一个闭包,它在这里访问的是 autorun() 函数中的 x 变量,而不是 run 函数中的变量。

闭包的外部做用域是在其定义的时候已决定,而不是执行的时候。

autorun() 的函数做用域便是 log() 函数的词法做用域。

做用域链

每个做用域都有对其父做用域的引用。当咱们使用一个变量的时候,Javascript引擎 会经过变量名在当前做用域查找,若没有查找到,会一直沿着做用域链一直向上查找,直到 global 全局做用域。

示例以下:

let x0 = 0;
(function autorun1(){
 let x1 = 1;
  
 (function autorun2(){
   let x2 = 2;
  
   (function autorun3(){
     let x3 = 3;
      
     console.log(x0 + " " + x1 + " " + x2 + " " + x3);//0 1 2 3
    })();
  })();
})();
复制代码

咱们能够看到,autorun3() 这个内部函数能够访问其自身局部变量 x3 ,也能够访问外部做用域中的 x1x2 变量,以及全局做用域中的 x0 变量。即:闭包能够访问其外部(父)做用域中的定义的全部变量。

外部做用域执行完毕后

当外部做用域执行完毕后,内部函数还存活(仍在其余地方被引用)时,闭包才真正发挥其做用。譬如如下几种状况:

  • 在异步任务例如 timer 定时器,事件处理,Ajax 请求中被做为回调
  • 被外部函数做为返回结果返回,或者返回结果对象中引用该内部函数

考虑以下的几个示例

Timer

(function autorun(){
    let x = 1;
    setTimeout(function log(){
      console.log(x);
    }, 10000);
})();
复制代码

变量 x 将一直存活着直到定时器的回调执行或者 clearTimeout() 被调用。 若是这里使用的是 setInterval() ,那么变量 x 将一直存活到 clearInterval() 被调用。

译者注:原文中说变量 x 一直存活到 setTimeout() 或者 setInterval() 被调用是错误的。

Event

(function autorun(){
    let x = 1;
    $("#btn").on("click", function log(){
      console.log(x);
    });
})();
复制代码

当变量 x 在事件处理函数中被使用时,它将一直存活直到该事件处理函数被移除。

Ajax

(function autorun(){
    let x = 1;
    fetch("http://").then(function log(){
      console.log(x);
    });
})();
复制代码

变量 x 将一直存活到接收到后端返回结果,回调函数被执行。

在已上几个示例中,咱们能够看到,log() 函数在父函数执行完毕后还一直存活着,log() 函数就是一个闭包。

除了 timer 定时器,事件处理,Ajax 请求等比较常见的异步任务,还有其余的一些异步 API 好比 HTML5 GeolocationWebSockets , requestAnimationFrame()也将使用到闭包的这一特性。

变量的生命周期取决于闭包的生命周期。被闭包引用的外部做用域中的变量将一直存活直到闭包函数被销毁。若是一个变量被多个闭包所引用,那么直到全部的闭包被垃圾回收后,该变量才会被销毁。

闭包与循环

闭包只存储外部变量的引用,而不会拷贝这些外部变量的值。 查看以下示例

function initEvents(){
  for(var i=1; i<=3; i++){
    $("#btn" + i).click(function showNumber(){
      alert(i);//4
    });
  }
}
initEvents();
复制代码

在这个示例中,咱们建立了3个闭包,皆引用了同一个变量 i,且这三个闭包都是事件处理函数。因为变量 i 随着循环自增,所以最终输出的都是一样的值。

修复这个问题最简单的方法是在 for 语句块中使用 let 变量声明,这将在每次循环中为 for 语句块建立一个新的局部变量。以下:

function initEvents(){
  for(let i=1; i<=3; i++){
    $("#btn" + i).click(function showNumber(){
      alert(i);//1 2 3
    });
  }
}
initEvents();
复制代码

可是,若是变量声明在 for 语句块以外的话,即便用了 let 变量声明,全部的闭包仍是会引用同一个变量,最终输出的仍是同一个值。

闭包与封装性

封装性意味着信息隐藏。

函数与私有状态

经过闭包,咱们能够建立拥有私有状态的函数,闭包使得状态被封装起来。

工厂模式与私有原型对象

咱们先来看一个经过原型建立对象的常规方式,以下:

let todoPrototype = {
  toString : function() {
    return this.id + " " + this.userName + ": " + this.title;
  }
}
function Todo(todo){
  let newTodo = Object.create(todoPrototype);
  Object.assign(newTodo, todo);
  return newTodo;
}
复制代码

在这个例子中,todoPrototype 原型对象是一个全局对象。

咱们能够经过闭包,只用建立原型对象一次,也可以被全部 Todo 函数调用所公用,而且保证其私有性。示例以下:

let Todo = (function createTodoFactory(){
  let todoPrototype = {
    toString : function() {
      return this.id + " " + this.userName + ": " + this.title;
    }
  }
  return function(todo){
    let newTodo = Object.create(todoPrototype);
    Object.assign(newTodo, todo);
    return newTodo;
  }
})();
let todo = Todo({id : 1, title: "This is a title", userName: "Cristi", completed: false });
复制代码

这里,Todo() 就是一个拥有私有状态的函数。

工厂模式与私有构造函数

查看以下代码:

let Todo = (function createTodoFactory(){
 function Todo(spec){
   Object.assign(this, spec);
 }
 
 return function(spec){
   let todo = new Todo(spec);
   return Object.freeze(todo);
 }
})();
复制代码

这里,Todo() 工厂函数就是一个闭包。经过它,不论是否使用 new ,咱们均可以建立不可变对象,原型对象也只用建立一次,而且它是私有的。

let todo = Todo({title : "A description"});
todo.title = "Another description"; 
// Cannot assign to read only property 'title' of object
todo.toString = function() {};
//Cannot assign to read only property 'toString' of object
复制代码

并且,在内存快照中,咱们能够经过构造函数名来识别这些示例对象。

Memory snapshot in Chrome DevTools

翻译功能与私有map

经过闭包,咱们能够建立一个 map,在全部翻译调用中被使用,且是私有的。

示例以下:

let translate = (function(){
  let translations = {};
  translations["yes"] = "oui";
  translations["no"]  = "non";
  
  return function(key){
    return translations[key];
  }
})();
translate("yes"); //oui
复制代码

自增生成器函数

经过闭包,咱们能够建立自增生成器函数。一样,内部状态是私有的。示例以下:

function createAGenerate(count, increment) {
  return function(){
    count += increment;
    return count;
  }
}
let generateNextNumber = createAGenerate(0, 1);
console.log(generateNextNumber()); //1
console.log(generateNextNumber()); //2
console.log(generateNextNumber()); //3
let generateMultipleOfTen = createAGenerate(0, 10);
console.log(generateMultipleOfTen()); //10
console.log(generateMultipleOfTen()); //20
console.log(generateMultipleOfTen()); //30
复制代码

译者注:原文中依次输出0,1,2,0,10,20是有误的,感谢@Round的指正

对象与私有状态

以上示例中,咱们能够建立一个拥有私有状态的函数。同时,咱们也能够建立多个拥有同一私有状态的函数。基于此,咱们还能够建立一个拥有私有状态的对象。

示例以下:

function TodoStore(){
  let todos = [];
  
  function add(todo){
    todos.push(todo);
  }
  function get(){
    return todos.filter(isPriorityTodo).map(toTodoViewModel);
  }
  
  function isPriorityTodo(todo){
     return task.type === "RE" && !task.completed;
  }
  
  function toTodoViewModel(todo) {
     return { id : todo.id, title : todo.title };
  }
  
  return Object.freeze({
    add,
    get
  });
}
复制代码

TodoStore() 函数返回了一个拥有私有状态的对象。在外部,咱们没法访问私有的 todos 变量,而且 addget 这两个闭包拥有相同的私有状态。在这里,TodoStore() 是一个工厂函数。

闭包 vs 纯函数

闭包就是那些引用了外部做用域中变量的函数。

为了更好的理解,咱们将内部函数拆成闭包和纯函数两个方面:

  • 闭包是那些引用了外部做用域中变量的函数。
  • 纯函数是那些没有引用外部做用域中变量的函数,它们一般返回一个值而且没有反作用。

在上述例子中,add()get() 函数是闭包,而 isPriorityTodo()toTodoViewModel() 则是纯函数。

闭包在函数式编程中的应用

闭包在函数式编程中也应用普遍。譬如,underscore 源码中 函数相关小节 中的全部函数都利用了闭包这一特性。

A function decorator is a higher-order function that takes one function as an argument and returns another function, and the returned function is a variation of the argument function — Javascript Allongé

装饰器函数也使用了闭包的特性。

咱们来看以下 not 这个简单的装饰器函数:

function not(fn){
  return function decorator(...args){
    return !fn.apply(this, args);
  }
}
复制代码

decorator() 函数引用了外部做用域的fn变量,所以它是一个闭包。

若是你想知道更多关于装饰器相关的知识,能够查看这篇文章

垃圾回收

Javascript 中,局部变量会随着函数的执行完毕而被销毁,除非还有指向他们的引用。当闭包自己也被垃圾回收以后,这些闭包中的私有状态随后也会被垃圾回收。一般咱们能够经过切断闭包的引用来达到这一目的。

在这个例子中,咱们首先建立了一个 add() 闭包。

let add = (function createAddClosure(){
    let arr = [];
    return function(obj){
       arr.push(obj);
    }
})();
复制代码

随后,咱们又定义了两个函数:

  • addALotOfObjects() 往闭包变量 arr 中加入对象。
  • clearAllObjects() 将闭包函数置为 null

而且两个函数皆做为事件处理函数:

function addALotOfObjects(){
    for(let i=1; i<=10000;i++) {
       add(new Todo(i));
    }
}
function clearAllObjects(){
    if(add){
       add = null;
    }
}
$("#add").click(addALotOfObjects);
$("#clear").click(clearAllObjects);
复制代码

当我点击 Add 按钮时,将往 闭包变量 arr 中加入10000个 todo 对象,内存快照以下:

Memory snapshot after adding 10000 to-dos

当我点击 Clear 按钮时,咱们将闭包引用置为 null 。随后,闭包变量 arr 将被垃圾回收,内存快照以下:

Memory snapshot after setting the closure reference to null

避免全局变量

Javascript 中,咱们很容易建立出全局变量。任何定义在函数和 {} 块以外的变量都是全局的,定义在全局做用域中的函数也是全局的。

这里以定义建立不一样对象的工厂函数为例。为了不将全部的工厂函数都放在全局做用域下,最简单的方法就是将他们挂在 app 全局变量下。

示例以下:

let app = Loader();
app.factory(function DataService(args){ return {}});
app.factory(function Helper(args){ return {}});
app.factory(function Mapper(args){ return {}});
app.factory(function Model(args){});
复制代码

app.factory() 方法还能够将不一样的工厂函数归类到不一样的模块中。下面这个示例就是将 Timer 工厂函数归类到 tools 模块下。

app.factory("tools")(function Timer(args){ return {}});
复制代码

咱们能够在 app 对象上暴露一个 start 方法来做为应用的入口点,经过 回调函数中 factories 参数来访问这些工厂函数。这里 start() 函数只能被调用一次,以下:

app.start(function startApplication(factories){
  let helper = factories.Helper();
  
  let dataService = factories.DataService();
  let model = factories.Model({
      dataService : dataService,
      helper : helper,
      timer : factories.tools.Timer()
  });
});
复制代码

A Composition Root is a (preferably) unique location in an application where modules are composed together.

Mark Seemann

loader 对象

让咱们来将 app 完善为一个 loader 对象,示例以下:

function Loader(){
  let modules = Object.create(null);
  let started = false;
  
  function getNamespaceModule(modulesText){
    let parent = modules;
    if(modulesText){
      let parts = modulesText.split('.');
      for(let i=0; i<parts.length; i++){
        let part = parts[i];
        if (typeof parent[part] === "undefined") {
          parent[part] = Object.create(null);
        }
        
        parent = parent[part];
      }
    }
    
    return parent;
  }
  
  function addFunction(namespace, fn){
    if(typeof(fn) !== "function") {
      throw "Only functions can be added";
    }
       
    let module = getNamespaceModule(namespace);
    let fnName = fn.name;    
    module[fnName] = fn;
  }
  
  function addNamespace(namespace){
    return function(fn){
      addFunction(namespace, fn)
    }
  }
  
  function factory(){
    if(typeof(arguments[0]) === "string"){
      return addNamespace(arguments[0]);
    } else {
      return addFunction(null, arguments[0]);
    }
  }
  
  function start(startApplication){
    if(started){
      throw "App can be started only once";
    }
     
    startApplication(Object.freeze(modules));
    started = true;
  }
  
  return Object.freeze({
    factory,
    start
  });
};
let app = Loader();
复制代码

factory() 方法用于添加新的工厂函数到内部变量 modules 中。

start() 方法则会调用回调函数,在回调函数中访问内部变量。

经过 factory() 定义工厂函数,将 start() 做为整个应用中调用各类工厂函数生成不一样对象的惟一入口点,这是如此简洁优雅的方式。

在这里,factorystart 都是闭包。

总结

闭包是一个能够访问外部做用域中变量的内部函数。

这些被引用的变量直到闭包被销毁时才会被销毁。

闭包使得 timer 定时器,事件处理,AJAX 请求等异步任务更加容易。

能够经过闭包来达到封装性。

最后,想得到更多关于 Javascript 函数相关知识,能够查看如下文章:

Discover Functional Programming in JavaScript with this thorough introduction

Discover the power of first class functions

How point-free composition will make you a better functional programmer

Here are a few function decorators you can write from scratch

Make your code easier to read with Functional Programming

相关文章
相关标签/搜索