Javascript 函數、做用域鏈與閉包

閉包是js中一個極爲NB的武器,但也徹徹底底的成了初學者的難點。由於學好閉包就要學好做用域,正確理解做用域鏈,然而想作到這一點就要深刻的理解函數,因此咱們從函數提及。javascript

函數的聲明和調用

首先說明一下,本文基於原生js環境,不涉及DOM部分
最基本的就是函數的定義和調用,注意區分如下形式:java

//以2下個是函數的定義
function func(){  //函數聲明
  /*code*/
}
var func = function(){   //函數表達式
  /*code*/
};

//如下2個是函數的調用(執行)
func(); //沒法獲得函數的返回值
var returnValue = func();  //執行函數並將返回值賦給returnValue, 若是函數沒有指定返回值,返回undefined

//如下2各定義了當即執行函數
(function(){
  /*code*/
})();
(function(){
  /*code*/
}());

當即執行函數直接聲明一個匿名函數,當即使用,免得定義一個用一次就不用的函數,並且免了命名衝突的問題。若是寫爲以下形式可得到當即執行函的返回值。c++

var returnValue = (function(){return 1;}());
var returnValue = (function(){return 1;})();

除此以外,函數還有一種很是常見的調用方式——回調函數。將一個函數做爲參數傳入另外一個函數,並在這個函數內執行。好比下面這個形式編程

document.addEventListener("click", console.log, false);

理解了上面的部分,咱們看一個典型的例子,好好理解一下函數的定義和調用的關係,這個必定要分清。下面這段代碼很具備表明性:segmentfault

var arr = [];
for(var i = 0; i < 10; i++){
  arr[i] = function(){
  return i;
  };
}
for(var j = 0; j < arr.length; j++){
  console.log(arr[j]() + " ");
}  //獲得輸出:10 10 10 10 10 10 10 10 10 10

咱們須要理解這裏面第一個for循環其實至關於以下形式,它只是定義了10個函數,並把函數放在數組中,並無執行函數。因爲js遵循詞法做用(lexical scoping), i是一個全局變量,因此第二個for循環調用函數的時候,i等於10數組

var i = 0;
arr[0] = function(){ return i; }; i++;
arr[1] = function(){ return i; }; i++;
arr[2] = function(){ return i; }; i++;
//......省略
arr[9] = function(){ return i; }; i++;
//此時i == 10 循環結束

再講完了閉包咱們再回來解決這個問題。瀏覽器

關於函數的參數傳遞這裏就很少說了,值得強調的是,上述2種定義函數的方式是有區別的,想理解這個區別,先要理解聲明提早。安全

變量聲明提早

這個地方簡單理解一下js的預處理過程。js代碼會在執行前進行預處理,預處理的時候會進行變量聲明提早,每一個做用域的變量(用var聲明的變量,沒有用var聲明的變量不會提早)和函數定義會提早到這個做用域內的開頭。
函數中的變量聲明會提早到函數的開始,但初始化不會。好比下面這個代碼。所以咱們應該避免在函數中間聲明變量,以增長代嗎的可讀性。閉包

function(){
    console.log(a);  //undefined
    f();  //f called
    /*...*/
    function f(){
        console.log("f called");
    }
    var a = 3;
    console.log(a);  //3
}

這段代碼等於(而且瀏覽器也是這麼作的):異步

function(){
    function f(){
        console.log("f called");
    }
    var a;
    console.log(a);  //undefined
    f();  //f called
    /*...*/
    a = 3;
    console.log(a);  //3
}

不一樣函數定義方式的區別

第一個區別:

function big(){
  func();//函數正常執行
  func1();//TypeError: func1 is not a function
  function func(){  //這個函數聲明會被提早
    console.log("func is called");
  }
  var func1 = function(){  //這個函數聲明會被提早,但不是個函數,而是變量
    console.log("func1 is called");
  };
}
big();

第二個區別,比較下面2段代碼

function f() {
  var b=function(){return 1;};
  function b(){return 0;};
  console.log(b());
  console.log(a());
  function a(){return 0;};
  var a=function(){return 1;};
}
f();

不難發現,用表達式定義的函數能夠覆蓋函數聲明直接定義的函數;可是函數聲明定義的函數卻不能覆蓋表達式定義的函數。
實際中咱們發現,定義在調用以前var f = function(){};會覆蓋function f(){},而定義在調用以後function f(){}會覆蓋var f= function(){};(你能夠以不一樣順序組合交換上面代碼中的行,驗證這個結論)

第三個區別,其實這個算不上區別

var fun = function fun1(){
  //內部可見:fun和fun1
  console.log(fun1 === fun);
};
//外部僅fun可見
fun();  //true 說明這是同一個對象的2各不一樣引用
fun1(); //ReferenceError: fun1 is not defined

此外還有一個定義方法以下:

var func = new Function("alert('hello')");

這個方式不經常使用,也不建議使用。由於它定義的函數都是在window中的,更嚴重的是,這裏的代碼實在eval()中解析的,這使得這個方式很糟糕,帶來性能降低和安全風險。具體就不贅述了。

詞法做用域

C++和Java等語言使用的都是塊級做用域,js與它們不一樣,遵循詞法做用域(lexical scoping)。講的通俗一些,就是函數定義決定變量的做用域函數內是一部分,函數外是另外一部分,內部能夠訪問外部的變量,但外部沒法直接訪問內部的變量。首先咱們看下面這個代碼

//這裏是全局做用域
var a = 3;
var b = 2;
var c = 20;
function f(){ //這裏是一個局部做用域
  var a = 12; //這是一個局部變量
  b = 10; //覆蓋了全局變量
  var d = e = 15;   //只有第一參數d是局部變量,後面的都是全局變量
  f = 13; //新的全局變量
  console.log(a + " " + b + " " + d);
}
f(); //12 10 15
console.log(a);  //3
console.log(b);  //10
console.log(c);  //20
console.log(d);  //undefined
console.log(e);  //15
console.log(f);  //13

注:原生js在沒有定使用義的變量時會獲得undefined,並在使用過程當中遵循隱式類型轉換,但如今的瀏覽器不會這樣,它們會直接報錯。不過在函數中使用滯後定義的變量依然是undefined,不會報錯,這裏遵循聲明提早的原則。

這是一個最基本的做用域模型。咱們上文提到過,函數裏面能夠訪問外面的變量,函數外部不能直接訪問內部的變量.
咱們再看一個複雜一點的:

var g = "g";
function f1(a){
  var b = "f1";
  function f2(){
    var c = "f2";
    console.log(a + b + c + g);
  }
  f2();
}
f1("g"); //gf1f2g

在js中,函數裏面定義函數十分廣泛,這就須要咱們十分了解做用域鏈。
以下這個代碼定義了下圖中的做用域鏈:

var g = 10;
function f1(){
  var f_1 = "f1";
  function f2(){
    var f_2 = "f2";
    function f3(){
      var f_3 = "f3";
      /*function f...*/
    }
  }
}

這裏寫圖片描述

這裏內層的函數能夠由內向外查找外層的變量(或函數),當找到相應的變量(或函數)當即中止向外查找,並使用改變量(或函數)。而外層的函數不能訪問內層的變量(或函數),這樣的層層嵌套就造成了做用域鏈。

值得一提的是,函數的參數在做用於上至關於在函數內部第一行就聲明瞭的變量,注意這裏指的僅僅是聲明,但不必定完成初始化,也就說明參數在沒有傳入值的時候值爲undefined。

回調函數

那麼問題來了,在一個函數外部永遠不能訪問函數內部的變量嗎?答案是否認的,咱們能夠用回調函數實現這個過程:

function A(arg){
  console.log(arg);
}
function B(fun){
  var a = "i am in function B";
  var i = 10;
  fun(a);
}

B(A); //i am in function B

上面這個過程對於B而言,只把本身內部的變量a給了fun,而外部的A不管如何也訪問不到B中的i變量,也就是說傳入的fun函數只能訪問B想讓它訪問的變量,所以回調函數這樣的設計能夠在代碼的隔離和開放中間取得一個極好的平衡。
說句題外話:javascript特別適用於事件驅動編程,由於回調模式支持程序以異步方式運行。

好了,若是上面的你都看懂了,那麼能夠開始看閉包了。

閉包

閉包是指有權訪問另外一個函數做用域中的變量的函數,建立閉包的最多見的方式就是在一個函數內建立另外一個函數,經過另外一個函數訪問這個函數的局部變量。閉包主要是爲了區分私有和公有的方法和變量,相似於c++和java中對象的public成員和protected成員。

一言以蔽之:做用域的嵌套構成閉包!

構成閉包如下幾個必要條件

  1. 函數(做用域)嵌套函數
  2. 函數(做用域)內部能夠引用外部的參數和變量
  3. 參數和變量不會被垃圾回收機制回收。能夠查看: 內存管理與垃圾回收

閉包的優缺點

  • 優勢
  1. 但願一個變量長期駐紮在內存中(如同c++中static局部變量)
  2. 避免全局變量的污染
  3. 私有成員的存在
  • 缺點
  1. 閉包常駐內存,會增大內存使用量,大量使用影響程序性能。
  2. 使用不當很容易形成內存泄露(關於內存管理和垃圾回收的細節之後會專門講一篇的)。<!--TODO-->

通常函數執行完畢後,局部活動對象就被銷燬,內存中僅僅保存全局做用域。但閉包不會

爲何有閉包

咱們考慮實現一個局部變量調用並自加的過程:

var a = 0;
function fun(){
  return a++;
}
fun(); //返回0
fun(); //返回1
fun(); //返回2

function func(){
  var a = 0;
  return a++;
}
func(); //返回0
func(); //返回0
func(); //返回0

看了上面代碼你會發現,當a是全局變量的時候能夠實現,但a成爲了局部變量就不行了,固然,必須是閉包才能夠實現這個功能:

var f = (function(){
  var a = 0;
  return function(){
    return a++;
  }
})();
f(); //返回0
f(); //返回1
f(); //返回2

這樣不只實現了功能,還防止了可能的全局污染。

上文舉了在循環內定義函數訪問循環變量的例子,可結果並不如意,獲得了十個10,下面咱們用閉包修改這個代碼,使它能夠產生0~9:

var arr = [];
for(var i = 0; i < 10; i++){
    arr[i] = (function(i){
    return function(){
      return i;
    };
  })(i);
}
for(var j = 0; j < arr.length; j++){
  console.log(arr[j]());
}//這樣就能夠獲得0~9了

固然還以其餘的解決方法:

//方法2
var arr = [];
for(var i = 0; i < 10; i++){
  arr[i] = console.log.bind(null, i);
}
for(var j = 0; j < arr.length; j++){
  console.log(arr[j]());

//方法3
var arr = [];
for(let i = 0; i < 10; i++){
  arr[i] = function(){
    console.log(i);
  };
}
for(var j = 0; j < arr.length; j++){
  console.log(arr[j]());
}//這樣也能夠獲得0~9了

迭代器

好了,是時候放鬆一下了,看看下面這個代碼,這個會簡單一些

var inc = function(){
  var x = 0;
  return function(){
    console.log(x++);
  };
};
inc1 = inc();
inc1();  //0
inc1();  //1
inc2 = inc();
inc2();  //0
inc2();  //1
inc2 = null;  //內存回收
inc2 = inc();
inc2();  //0

你會發現,inc返回了一個函數,這個函數是個累加器,它們能夠獨立工做互補影響。這個就是js中迭代器next()的實現原理。下面是一個簡單的迭代器:

//實現對數組遍歷
function iterator(arr){
  var num = 0;
  return {
    next: function(){
      if(num < arr.length)
        return arr[num++];
      else return null;
    }
  };
}
var a = [1,3,5,7,9];
var it = iterator(a);
var num = it.next()
while(num !== null){
  console.log(num)
   num = it.next();
}//依次輸出1,3,5,7,9

若是你學了ES6,那麼你能夠用現成的迭代器,就不用自定義迭代器了。

箭頭函數

箭頭函數自己也是一個函數,具備本身的做用域。不過在箭頭函數裏面的this上下文同函數定義所在的上下文,具體能夠看個人另外一篇文章:javascript中this詳解

典型實例

這個實例會涉及到對象的相關知識,若是不能徹底理解,能夠參考:javascript中this詳解javascript對象、類與原型鏈

function Foo() {
    getName = function () { console.log (1); };
    return this;
}
Foo.getName = function () { console.log (2);};
Foo.prototype.getName = function () { console.log (3);};
var getName = function () { console.log (4);};
function getName() { console.log (5);}
//請寫出如下輸出結果:
Foo.getName();     //2, 函數的靜態方法,直接調用相關函數就能夠了。
getName();    //4, 變量函數定義在調用以前,成功完成初始化,覆蓋函數聲明方式定義的同名函數
Foo().getName();   //1, 這裏 Foo()返回的 this 是 window,在 Foo調用時,對全局的變量型函數 getName 從新定義了,因此獲得1。
getName();   //1, 上一句改變了全局的 getName 函數爲 cosnole.log(1)
new Foo.getName();   //2,無參數 new 運算比 . 運算低,因此先運行 Foo.getName,獲得2
new Foo().getName();   //3,有參數 new 運算和 . 運算同一等級,故從左到右,先運算 new Foo() 獲得一個匿名對象,在該對象上調用getName 函數獲得3
new new Foo().getName();    //3,同上,先獲得匿名對象,而後將該對象的方法 getName 當作構造函數來調用,獲得一個新對象,並輸出3;

Curry化

Curry化技術是一種經過把多個參數填充到函數體中,實現將函數轉換爲一個新的通過簡化的(使之接受的參數更少)函數的技術。當發現正在調用同一個函數時,而且傳遞的參數絕大多數都是相同的,那麼用一個Curry化的函數是一個很好的選擇.

下面利用閉包實現一個curry化的加法函數

function add(x,y){
  if(x && y) return x + y;
  if(!x && !y) throw Error("Cannot calculate");
  return function(newx){
    return x + newx;
  };
}
add(3)(4); //7
add(3, 4); //7
var newAdd = add(5);
newAdd(8); //13
var add2000 = add(2000);
add2000(100); //2100
相關文章
相關標籤/搜索