為了前端的深度-閉包概念與應用

lionel愛學習發表於2019-04-09

總結

定義:閉包可以讓一個函式訪問並操作其宣告時的作用域中的變數和函式,並且,即使宣告時的作用域消失了,也可以呼叫

應用:

  1. 私有變數
  2. 回撥與計時器
  3. 繫結函式上下文
  4. 偏應用函式
  5. 函式過載:快取記憶、函式包裝
  6. 即時函式:獨立作用域、簡潔程式碼、迴圈、類庫包裝、通過引數限制作用域內的名稱

前言

最近忙著公司的專案,沒有時間去繼續面試受虐,只抽空讀了一遍《javascript 忍者祕籍》。

今天晚上有點焦慮失眠,就乾脆寫一篇自己總結的閉包知識。

內容基本全部來自忍者祕籍,覺得寫的好的話,可以仔細再看一遍書;覺得寫的不好的,可能是因為我理解不到位,導致文中自己思考的地方出了差錯,也可能是我省略了書中的循序漸進,導致漏掉一些知識點。各種原因,都請指正。

正文

看了很多文章,都在說閉包的定義和閉包的優缺點。我呢,再加上閉包的應用吧。

閉包的定義很多文章裡都有,我記得有一種角度說只要能訪問外部變數的就是閉包,還有一種角度所有函式都是閉包。

我覺得這些回答是正確的,但是不太方便麵試官繼續問下去,或者說是不好引導面試官。所以,如果是我在面試,我會用忍者祕籍裡的定義:閉包是一個函式在建立時允許該自身函式訪問並操作該自身函式之外的變數時所建立的作用域。這個還有點繞口,更清晰的版本是:閉包可以讓一個函式訪問並操作其宣告時的作用域中的變數和函式,並且,即使宣告時的作用域消失了,也可以呼叫。要注意的是:閉包不是在建立的那一時刻點的狀態的快照,而是一個真實的封裝,只要閉包存在,就可以對其進行修改。

最簡單的閉包:

// 全域性作用於就是一個閉包
var outerVal = 'lionel';
function outerFn(){
  console.log(outerVal)
}
outerFn() // lionel
複製程式碼

複雜點的,也是我們印象中的:

var outerVal = 'lionel';
var later;
function outerFn(){
  var innerVal = 'karma';
  function innerFn(){
    console.log(outerVal, innerVal);
  }
  later = innerFn();
}
outerFn();  // 此時outerFn的作用域已經消失了
later();  // lionel karma
複製程式碼

難以理解的,這個例子我們可以理解到,閉包不是快照:

var later;
function outerFn(){
  function innerFn(){
    console.log(lateVal)
  }
  later = innerFn();
}
console.log(lateVal); // undefined
var lateVal = 'lionel'; // 變數提升,閉包宣告的那一刻存在這個變數
outerFn();
innerFn(); // lionel
複製程式碼

缺點大家很熟悉了,閉包裡的資訊會一直儲存在記憶體裡。解決方法是,在你覺得可以的地方,清除引用,像上面的例子中,使用 later = null 即可,這樣就可以在下次垃圾回收中,清除閉包。

下面我們重點來看一下閉包的實際應用

一、私有變數

閉包常見的用法,封裝私有變數。使用者無法直接獲取和修改變數的值,必須通過呼叫方法;並且這個用法可以建立只讀的私有變數哦。我們從下面的例子來理解:

function People(num) { // 構造器
  var age = num;
  this.getAge = function() {
    return age;
  };
  this.addAge = function() {
    age++;
  };
}
var lionel = new People(23); // new方法會固化this為lionel哦
lionel.addAge();
console.log(lionel.age);      // undefined
console.log(lionel.getAge()); // 24
var karma = new People(20);
console.log(karma.getAge()); // 20
複製程式碼

如下圖,lionel中並不存在age屬性,age只存在new的那個過程的作用域中,並且,getAge和addAge中,我們可以看到他們的作用域中都包含一個People的閉包。

alt

二、回撥和計時器

這部分我沒有多聊的,

三、繫結函式上下文

剛看到這個應用可能有點懵,仔細想想其實我們看到很多次了,那就是bind()函式的實現方式,這裡再貼一次簡單實現的程式碼:

Function.prototype.myBind = function() {
  var fn = this,
      args = [...arguments],
      object = args.shift();
  return function() {
    return fn.apply(object, args.concat(...arguments))
  }
}
複製程式碼

這裡要注意的是:bind()並不是apply和call的替代方法。該方法的潛在目的是通過匿名函式和閉包控制後續執行上下文。

四、偏應用函式

偏應用函式返回了一個含有預處理引數的函式,以便後期可以呼叫。具體還是看程式碼吧

Function.prototype.partial = function() {
  var fn = this,
      args = [...arguments];
  return function() {
    var arg = 0;
    var argsTmp = [...args]
    for (var i=0; i<argsTmp.length && arg < arguments.length; i++) {
      if (argsTmp[i] === undefined) {
        argsTmp[i] = arguments[arg++]
      }
    }
    return fn.apply(this, argsTmp)
  }
}
function addAB(a ,b) {
  console.log( a + b);
}
var hello = addAB.partial('hello ', undefined);
hello('lionel'); // hello lionel
hello('karma'); // hello karma
var bye = addAB.partial(undefined, ' bye')
bye('lionel'); // lionel bye
bye('karma'); // karma bye
複製程式碼

上面的例子可能有點難以理解,下面是一個簡化版的例子:

function add(a) {
  return function(b) {
    console.log( a + b);
  };
}
var hello = add('hello ')
hello('lionel'); // hello lionel
hello('karma'); // hello karma
複製程式碼

emmm... 寫到這裡去研究了半天柯里化和偏函式的區別,最終找到一篇文章符合我的想法:偏函式與函式柯里化,不對的地方請指正。

五、函式過載

1 快取記憶

我們可以通過閉包來包裝一個函式,,從而讓呼叫我們函式的人,不知道我們採用了快取的方法,或者說,不需要呼叫者額外做什麼,就可以快取計算結果,如下程式碼

Function.prototype.memoized = function(key) {
  this._values = this._values || {};
  return this._values[key] !== undefined ?
    this._values[key] + ' memoized' :
    this._values[key] = this.apply(this, arguments);
}
Function.prototype.memoize = function() {
  var fn = this;
  return function() {
    // return fn.memoized.apply(fn, arguments);
    console.log(fn.memoized.apply(fn, arguments))
  }
}
var computed = (function(num){
  // 這裡有超級超級複雜的計算,耗時特別久
  console.log('----計算了很久-----')
  return 2
}).memoize();
computed(1); // ----計算了很久-----     2
computed(1); // 2 memoized
複製程式碼

2 函式包裝

下面的這個例子寫的沒有書裡的好。

function wrap(object, method, wrapper){
  var fn = object[method];
  return object[method] = function() {
    return wrapper.apply(this, [fn.bind(this)].concat(...arguments))
  }
}
let config = {
  baseUrl: '真實url',
  getBaseUrl: function(){
    console.log(this.baseUrl)
  }
}
if(process.env.NODE_ENV === 'development'){
  wrap(config, getBaseUrl, function(){
    console.log('測試url')
  })
  config.getBaseUrl(); // 測試url
}else{
   config.getBaseUrl() // 真實url
}
複製程式碼

六、即時函式

針對為什麼即時函式會放在閉包裡介紹,下圖是一個很好的說明:

alt

1 獨立作用域

(function(){
  var numClicks = 0;
  button.click = function(){
    alert(++numClicks)
  }
})
複製程式碼

2 簡潔程式碼

// 例如有如下data
data = {
  a: {
    b: {
      c: {
        get: function(){},
        set: function(){},
        add: function(){}
      }
    }
  }
}
// 第一種呼叫這三個方法的程式碼如下, 繁瑣
data.a.b.c.get();
data.a.b.c.set();
data.a.b.c.add();
// 第二種方法如下, 引入多餘變數
var short = data.a.b.c;
short.get();
short.set();
short.add();
// 第三種使用即時函式 優雅
(function(short){
  short.get();
  short.set();
  short.add();
})(data.a.b.c)
複製程式碼

3 迴圈

這部分是經典的for迴圈中呼叫setTimeout列印i,之所以列印i為固定值,是因為閉包並不是快照,而是變數的引用,在執行到非同步佇列時,i已經改變。

解決方法就是再用一個閉包和即時函式。

4 類庫包裝

// 下方的程式碼展示了,為什麼jquery庫中,它可以放心的用jquery而不擔心這個變數被替換
(function(){
  var jQuery = window.jQuery = function() {
    // Initialize
  };
  // ...
})()
複製程式碼

5 通過引數限制作用域內的名稱

// 當我們擔心jquery中的$符號,被其他庫佔用,導致我們程式碼出問題的時候,
// 用下面的方法,就可以放心大膽的用啦(不過要注意:如果jQuery也被佔用的話就...)
(function($){
  $.post(...)
})(jQuery)
複製程式碼

相關文章