[譯]發現 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

相關文章
相關標籤/搜索