1. 程式人生 > >JavaScript 系列部落格(三)

JavaScript 系列部落格(三)

JavaScript 系列部落格(三)

前言

本篇介紹 JavaScript 中的函式知識。

函式的三種宣告方法

function 命令

可以類比為 python 中的 def 關鍵詞。

function 命令宣告的程式碼區塊,就是一個函式。命令後面是函式名,函式名後面的圓括號裡面是要傳入的形參名。函式體放在大括號裡面。

function fn(name) {
    console.log(name);
}

使用 function 命名了一個 fn 函式,以後可以通過呼叫 fn 來執行該函式。這叫做函式的宣告(Function Declaration)。

函式表示式

除了使用 function 命令宣告函式外,可以採用變數賦值的寫法。(匿名函式)

var fn = function(name) {
    console.log(name);
};

這種寫法將一個匿名函式賦值給變數。這時,這個匿名函式又稱之為函式表示式(Function Expression),因為賦值語句的等號右側只能放表示式。

採用函式表示式宣告函式時,function 命令後面不帶有函式名。如果加上函式名,該函式名只能在函式體內訪問,在函式體外部無效。

var fn = function x(name) {
    console.log(typeof x);
};
x
// ReferenceError: x is not defined
fn();
// function

宣告函式時,在函式表示式後加了函式名 x,這個 x 只可以在函式內部使用,指代函式表示式本身。這種寫法有兩個用處:一可以在函式體內部呼叫自身;二方便debug(debug 顯示函式呼叫棧時,會顯示函式名)。需要注意的是,函式表示式需要在語句的結尾加上分號,表示語句結束。而函式的宣告在結尾的大括號後面不用加分號。

Function 建構函式

第三種宣告函式的方法是通過建構函式,可以理解為 python 中的函式類,通過傳入引數並且返回結果就可以建立一個函式。

建構函式接收三個引數,最後一個為 add函式的‘’函式體‘’,其他引數為add 函式的引數。可以為建構函式傳遞任意數量的引數,不過只有最後一個引數被當做函式體,如果只有一個引數,該引數就是函式體。

Function 建構函式也可以不用 new 命令,結果一樣。這種宣告函式的方式不直觀,使用概率很少。

函式的呼叫

和 python 一樣,呼叫一個函式通過圓括號,圓括號中是要傳入的實參。

函式體內部的 return 語句,表示返回。JavaScript 引擎遇到 return 時,就直接返回 return 後面表示式的值(和 python 一樣),所以 return 後面的程式碼是無意義的,如果沒有 return 那麼就會返回 undefined(python 中返回 None)。

函式作用域

作用域的定義

作用域指的是變數存在的範圍。在 ES5中,JavaScript 只有兩種作用域:一種是全域性作用域,變數在整個程式中一直存在,任意位置可以訪問到;另一種是函式作用域,也稱之為區域性作用域,變數只有在函式內部才能訪問到。ES6新增了塊級作用域,等價於區域性作用域一樣,就是新增了一種產生區域性作用域的方式。通過大括號產生塊級作用域。

在函式外部宣告的變數就是全域性變數,可以在任意位置讀取。

在函式內部定義的變數,外部無法讀取,只有在函式內部可以訪問到。並且函式內部定義的同名變數,會在函式內覆蓋全域性變數。

注意:對於 var 命令來說,區域性變數只可以在函式內部宣告,在其他區塊中宣告,一律都是全域性變數。ES6中宣告變數的命令改為 let,在區塊中宣告變數產生塊級作用域。

函式內部的變數提升

與全域性作用域一樣,函式作用域也會產生‘’變數提升‘’現象。var 命令生命的變數,不管在什麼位置,變數宣告都會被提升到函式體的頭部。

function foo(x) {
    if (x > 100) {
        var tmp = x - 100;
    }
}

// 等同於
function foo(x) {
    var tmp;
    if (x > 100) {
        tmp = x - 100;
    }
}

函式本身的作用域

函式和其他值(數值、字串、布林值等)地位相同。凡是可以使用值得地方,就可以使用函式。比如,可以把函式賦值給變數和物件的屬性,也可以當做引數傳入其他函式,或者作為函式的結果返回。函式是一個可以執行的值,此外沒有特殊之處。

函式也有自己的作用域,函式的作用域稱為區域性作用域。與變數一樣,就是其生命時所在的作用域,與其執行時所在的作用域無關(閉包、裝飾器)。通俗地講就是在定義函式的時候,作用域已經就確定好了,那麼在訪問變數的時候就開始從本作用域開始查詢,而與函式的呼叫位置無關。

var x = function () {
    var a = 1;
    console.log(a);
};
function y() {
    var a = 2;
    x();
}
y(); // 1

函式 x 是在函式 f 的外部生命的,所以它的作用域繫結外層,內部變數 a 不會到函式 f 體內取值,所以輸出1,而不是2。

總之,函式執行時所在的作用域,是定義時的作用域,而不是呼叫時所在的作用域。

函式引數

呼叫函式時,有時候需要外部傳入的實參,傳入不同的實參會得到不同的結果,這種外部資料就叫引數。

引數的省略

在 JavaScript 中函式引數不是必需的,就算傳入的引數和形參的個數不相等也不會報錯。呼叫時無論提供多少個引數(或者不提供引數),JavaScript 都不會報錯。省略的引數的值變為 undefined。需要注意的是,函式的 length 屬性值與實際傳入的引數個數無關,只反映函式預期傳入的引數個數。

但是,JavaScript 中的引數都是位置引數,所以沒有辦法只省略靠前的引數,而保留靠後的引數。如果一定要省略靠前的引數,只有顯示的傳入 undefined。

傳遞方式

函式引數如果是原始型別的值(數值、字串、布林值),傳遞方式是傳值傳遞(pass by value)。這意味著,在函式體內修改引數值,不會影響到函式外部(區域性變數的修改不會影響到全域性變數:對於基本資料型別)。

但是,如果函式引數是複合型別的值(陣列、物件、其他函式),因為傳值方式為地址傳遞(pass by reference)。也就是說,傳入函式的原始值的地址,因此在函式內部修改引數,將會影響到原始值。

注意:如果函式內部修改的不是引數物件的某個屬性,而是直接替換掉整個引數,這時不會影響到原始值。

var obj = [1, 2, 3];

function f(o) {
    o = [2, 3, 4];
}
f(obj);

obj // [1, 2, 3]

上面程式碼,在函式 f 內部,引數物件 obj 被整個替換成另一個值。這時不會影響到原始值。這是因為,形式引數(o)的值實際上是引數 obj 的地址,重新對o 賦值導致 o 指向另一個地址,儲存在原地址上的資料不會被改變。

同名引數

如果有同名的引數,則取最後出現的那個值。

function f(a, a) {
    console.log(a);
}

f(1, 2) // 2

上面程式碼中,函式 f 有兩個引數,且引數名都是 a。取值的時候,以後面的 a 為準,即使後面的a 沒有值或被省略,也是以其為準。

function f(a, a) {
    console.log(a);
}
f(1) // undefined

呼叫函式 f 時,沒有提供第二個引數,a 的取值就變成了 undefined。這時,如果要獲得第一個 a 的值,可以使用 arguments 物件(類比linux 中的arg)。

function f(a, a) {
    console.log(arguments[0]);
}

f(1) // 1

arguments 物件

定義

由於 JavaScript 允許函式有不定數目的引數,所以需要一種機制,可以在函式體內部讀取所有引數。這就是 arguments 物件的由來。

arguments 物件包含了函式執行時的所有引數,arguments[0]就是第一個引數,以此類推。注意:該物件只有在函式體內部才可以使用。

正常模式下,arguments 物件可以在執行時修改。

var f = function(a, b) {
    arguments[0] = 3;
    arguments[1] = 3;
    return a + b;
}
f(1, 1) // 5

上面程式碼中,呼叫 f 時傳入的引數,在函式體內被修改了,那麼結果也會修改。

嚴格模式下,arguments 物件是一個只讀物件,修改它是無效的,但不會報錯。

var f = function(a, b) {
    'use strict'; // 開啟嚴格模式
    arguments[0] = 3; // 無效
    arguments[1] = 2; // 無效
    return a + b;
}

f(1, 1) // 2

開啟嚴格模式後,雖然修改引數不報錯,但是是無效的。

通過 arguments 物件的 length 屬性,可以判斷函式呼叫時到底帶幾個引數。

function f() {
    return arguments.length;
}
f(1, 2, 3) // 3
f(1) // 1

與陣列的關係

需要注意的是,雖然 arguments 很像陣列,但它是一個物件。陣列專有的方法(比如 slice 和 forEach),不能再 arguments 物件上直接使用。

如果要讓 arguments 物件使用陣列方法,真正的解決方法是將 arguments 轉為真正的陣列。下面是兩種常用的轉換方法:slice 方法和逐一填入新陣列。

var args = Array.prototype.slice.call(arguments);

// var args = [];
for (var i = 0; i < arguments.length; i++) {
    args.push(arguments[i]);
}

callee 屬性

arguments 物件帶有一個 callee 屬性,返回它所對應的原函式。

var f = function() {
    console.log(arguments.callee === f); 
}
f(); // true

可以通過 arguments.callee,達到呼叫自身的目的。這個屬性在嚴格模式裡面是禁用的,不建議使用。

函式閉包

閉包是所有程式語言的難點,在 python 中閉包的多應用於裝飾器中。在 JavaScript 中閉包多用於建立作用域,或者解決變數汙染的問題。

理解閉包,首先需要理解變數作用域。在 ES5中,JavaScript 只有兩種作用域:全域性作用於和函式作用域。函式內部可以直接讀取全域性變數。

var n = 999;

function f1() {
    console.log(n);
}
f1(); // 999,n是全域性變數,可以被訪問到

但是函式外部無法讀物函式內部宣告的變數。

function f1() {
    var n = 999;
}
console.log(n);
// Uncaught ReferenceError: n is not defined

因為變數作用域的關係,在外部需要訪問到區域性變數在正常情況下是做不到的,這就可以通過閉包來實現。下來來看一個經典例子:迴圈繫結事件產生的變數汙染

<div class="box">
    0000001
</div>
<div class="box">
    0000002
</div>
<div class="box">
    0000003
</div>
<script>
    var divs = document.querySelectorAll(".box");
    // 存在汙染的寫法
    for (var i =0; i < divs.length; i++) {
        divs.onclick = function () {
            console.log('xxx', i)
        }
    }
    // 執行結果顯示4
</script>

會產生變數汙染的原因是作用域,因為 var 並不產生作用域,所以在 for迴圈中的變數就是全域性變數,只要 for迴圈結束那麼 i 的值就確定了,除非在極限情況下,你的手速比 cpu 還要快,那麼可能會看到小於4的值。這樣的問題可以通過函式的閉包來解決。產生新的作用域用來儲存 i 的值。

for (var i = 0; i < divs.length; i++) {
    (function () {
        var index = i;
        divs[index].onclick = function () {
            console.log('xxx', index);
        }
    })()
}
// 另一種版本
for (var i = 0; i < divs.length; i++) {
    function(i) {
        divs[i].onclick = function () {
            console.log('yyy', i)
        }
    }(i)
}

利用閉包原理產生新的作用域用來儲存變數 i 的值,這樣就解決了變數汙染的問題,還有利用ES6的宣告變數關鍵詞 let,也會產生新的作用域(塊級作用域)也可以解決變數汙染的問題。

在 JavaScript 中,巢狀函式中的子函式中可以訪問到外部函式中的區域性變數,但是外部函式訪問不到子函式中的區域性變數,這是 JavaScript 中特有的‘’鏈式作用域‘’結構(python 也一樣),子物件會一級一級的向上尋找所有父物件的變數。所以,父物件的所有變數,對子物件都是可見的,反之則不成立。可以簡單地把閉包理解為‘’定義在一個函式內部的函式‘’,閉包最大的特點就是它可以‘’記住‘’誕生的環境,在本質上閉包就是將函式內部和函式外連線起來的一座橋樑。

必報的最大用處有兩個,一個是可以讀取函式內部的變數,另一個就是讓這些變數始終保持在記憶體中,即閉包可以使得它誕生的環境一直存在。下面的例子:

function createIncrementor(start) {
    return function () {
        return start++;
    };
}

var inc = createIncrementor(5);

inc(); // 5
inc(); // 6
inc(): // 7

上面程式碼中,start 是函式 createIncrementor 的內部變數。通過閉包,start 的狀態被儲存,每一次呼叫都是在上一次呼叫的基礎上進行計算。從中可以看出,閉包 inc 使得函式 createIncrementor 的內部環境一直存在。所以閉包可以看做是函式內部作用域的一個介面。為什麼會這樣呢?原因就在於 inc 始終在記憶體中,而 inc 的存在依賴於 createIncrementor,因此也一直存在於記憶體中,不會再外層函式呼叫結束後 start 變數被垃圾回收機制回收。

閉包的另外一個用處是封裝物件的私有屬性和私有方法。(這部分還不太懂,還需要琢磨)

function Person(name) {
  var _age;
  function setAge(n) {
    _age = n;
  }
  function getAge() {
    return _age;
  }

  return {
    name: name,
    getAge: getAge,
    setAge: setAge
  };
}

var p1 = Person('張三');
p1.setAge(25);
p1.getAge() // 25

上面程式碼中,函式 Person 的內部變數_age,通過閉包 getAge 和 setAge,變成了返回物件p1的私有變數。

注意:外城函式每次執行,都會產生一個新的閉包,而這個閉包又會保留外城函式的內部變數,所以記憶體消耗很大。