[譯]發現 JavaScript 中閉包的強大威力

拾雪兒在海邊發表於2019-01-30

原文地址: Discover the power of closures in JavaScript
原文作者: Cristi Salcescu
譯者: wcflmy

閉包是一個可以訪問外部作用域的內部函式,即使這個外部作用域已經執行結束。

作用域

作用域決定這個變數的生命週期及其可見性。 當我們建立了一個函式或者 {} 塊,就會生成一個新的作用域。需要注意的是,通過 var 建立的變數只有函式作用域,而通過 letconst 建立的變數既有函式作用域,也有塊作用域。

巢狀作用域

Javascript 中函式裡面可以巢狀函式,如下:

(function autorun(){
    let x = 1;
    function log(){ 
       console.log(x); 
    }
    log();
})();
複製程式碼

log() 即是一個巢狀在 autorun() 函式裡面的函式。在 log() 函式裡面可以通過外部函式訪問到變數 x。此時,log() 函式就是一個閉包。

閉包就是內部函式,我們可以通過在一個函式內部或者 {} 塊裡面定義一個函式來建立閉包。

外部函式作用域

內部函式可以訪問外部函式中定義的變數,即使外部函式已經執行完畢。如下:

(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);
複製程式碼

外部塊作用域

內部函式可以訪問外部塊中定義的變數,即使外部塊已執行完畢,如下:

{
    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

相關文章