縱觀JavaScript中所有必須需要掌握的重點知識中,函式是我們在初學的時候最容易忽視的一個知識點。在學習的過程中,可能會有很多人、很多文章告訴你物件導向很重要,原型很重要,可是卻很少有人告訴你,物件導向中所有的重點難點,幾乎都與函式息息相關。
包括我之前幾篇文章介紹的執行上下文,變數物件,閉包,this等,都是圍繞函式來展開。
我知道很多人在學習中,很急切的希望自己快一點開始學習物件導向,學習模組,學習流行框架,然後迅速成為高手。但是我可以很負責的告訴你,關於函式的這些基礎東西沒理解到一定程度,那麼你的學習進展一定是舉步維艱的。
所以,大家一定要重視函式!
一、函式宣告、函式表示式、匿名函式與自執行函式
關於函式在實際開發中的應用,大體可以總結為函式宣告、函式表示式、匿名函式、自執行函式。
函式宣告
我們知道,JavaScript中,有兩種宣告方式,一個是使用var
的變數宣告,另一個是使用function
的函式宣告。
在前端基礎進階(三):變數物件詳解中我有提到過,變數物件的建立過程中,函式宣告比變數宣告具有更為優先的執行順序,即我們常常提到的函式宣告提前。因此我們在執行上下文中,無論在什麼位置宣告瞭函式,我們都可以在同一個執行上下文中直接使用該函式。
1 2 3 4 5 |
fn(); // function function fn() { console.log('function'); } |
函式表示式
與函式宣告不同,函式表示式使用了var進行宣告,那麼我們在確認他是否可以正確使用的時候就必須依照var的規則進行判斷,即變數宣告。我們知道使用var進行變數宣告,其實是進行了兩步操作。
1 2 3 4 5 6 |
// 變數宣告 var a = 20; // 實際執行順序 var a = undefined; // 變數宣告,初始值undefined,變數提升,提升順序次於function宣告 a = 20; // 變數賦值,該操作不會提升 |
同樣的道理,當我們使用變數宣告的方式來宣告函式時,就是我們常常說的函式表示式。函式表達的提升方式與變數宣告一致。
1 2 3 4 |
fn(); // 報錯 var fn = function() { console.log('function'); } |
上例子的執行順序為:
1 2 3 4 5 |
var fn = undefined; // 變數宣告提升 fn(); // 執行報錯 fn = function() { // 賦值操作,此時將後邊函式的引用賦值給fn console.log('function'); } |
因此,由於宣告方式的不同,導致了函式宣告與函式表示式在使用上的一些差異需要我們注意,除此之外,這兩種形式的函式在使用上並無不同。
關於上面例子中,函式表示式中的賦值操作,在其他一些地方也會被經常使用,我們清楚其中的關係即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
在建構函式中新增方法 function Person(name) { this.name = name; this.age = age; // 在建構函式內部中新增方法 this.getAge = function() { return this.age; } this. } // 給原型新增方法 Person.prototype.getName = function() { return this.name; } // 在物件中新增方法 var a = { m: 20, getM: function() { return this.m; } } |
匿名函式
在上面我們大概講述了函式表示式中的賦值操作。而匿名函式,顧名思義,就是指的沒有被顯示進行賦值操作的函式。它的使用場景,多作為一個引數傳入另一個函式中。
1 2 3 4 5 6 7 8 |
var a = 10; var fn = function(bar, num) { return bar() + num; } fn(function() { return a; }, 20) |
在上面的例子中,fn的第一個引數傳入了一個匿名函式。雖然該匿名函式沒有顯示的進行賦值操作,我們沒有辦法在外部執行上下文中引用到它,但是在fn函式內部,我們將該匿名函式賦值給了變數bar,儲存在了fn變數物件的arguments物件中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// 變數物件在fn上下文執行過程中的建立階段 VO(fn) = { arguments: { bar: undefined, num: undefined, length: 2 } } // 變數物件在fn上下文執行過程中的執行階段 // 變數物件變為活動物件,並完成賦值操作與執行可執行程式碼 VO -> AO AO(fn) = { arguments: { bar: function() { return a }, num: 20, length: 2 } } |
由於匿名函式傳入另一個函式之後,最終會在另一個函式中執行,因此我們也常常稱這個匿名函式為回撥函式。關於匿名函式更多的內容,我會在下一篇深入探討柯里化的文章中進行更加詳細講解。
匿名函式的這個應用場景幾乎承擔了函式的所有難以理解的知識點,因此我們一定要對它的這些細節瞭解的足夠清楚,如果對於變數物件的演變過程你還看不太明白,一定要回過頭去看這篇文章:前端基礎進階(三):變數物件詳解
函式自執行與塊級作用域
在ES5中,沒有塊級作用域,因此我們常常使用函式自執行的方式來模仿塊級作用域,這樣就提供了一個獨立的執行上下文,結合閉包,就為模組化提供了基礎。
1 2 3 |
(function() { // ... })(); |
一個模組往往可以包括:私有變數、私有方法、公有變數、公有方法。
根據作用域鏈的單向訪問,外面可能很容易知道在這個獨立的模組中,外部執行環境是無法訪問內部的任何變數與方法的,因此我們可以很容易的建立屬於這個模組的私有變數與私有方法。
1 2 3 4 5 6 7 8 9 10 |
(function() { // 私有變數 var age = 20; var name = 'Tom'; // 私有方法 function getName() { return `your name is ` + name; } })(); |
但是共有方法和變數應該怎麼辦?大家還記得我們前面講到過的閉包的特性嗎?沒錯,利用閉包,我們可以訪問到執行上下文內部的變數和方法,因此,我們只需要根據閉包的定義,建立一個閉包,將你認為需要公開的變數和方法開放出來即可。
如果你對閉包瞭解不夠,前端基礎進階(四):詳細圖解作用域鏈與閉包應該可以幫到你。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
(function() { // 私有變數 var age = 20; var name = 'Tom'; // 私有方法 function getName() { return `your name is ` + name; } // 共有方法 function getAge() { return age; } // 將引用儲存在外部執行環境的變數中,形成閉包,防止該執行環境被垃圾回收 window.getAge = getAge; })(); |
當然,閉包在模組中的重要作用,我們也在講解閉包的時候已經強調過,但是這個知識點真的太重要,需要我們反覆理解並且徹底掌握,因此為了幫助大家進一步理解閉包,我們來看看jQuery中,是如何利用我們模組與閉包的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// 使用函式自執行的方式建立模組 (function(window, undefined) { // 宣告jQuery建構函式 var jQuery = function(name) { // 主動在建構函式中,返回一個jQuery例項 return new jQuery.fn.init(name); } // 新增原型方法 jQuery.prototype = jQuery.fn = { constructor: jQuery, init:function() { ... }, css: function() { ... } } jQuery.fn.init.prototype = jQuery.fn; // 將jQuery改名為$,並將引用儲存在window上,形成閉包,對外開發jQuery建構函式,這樣我們就可以訪問所有掛載在jQuery原型上的方法了 window.jQuery = window.$ = jQuery; })(window); // 在使用時,我們直接執行了建構函式,因為在jQuery的建構函式中通過一些手段,返回的是jQuery的例項,所以我們就不用再每次用的時候在自己new了 $('#div1'); |
在這裡,我們只需要看懂閉包與模組的部分就行了,至於內部的原型鏈是如何繞的,為什麼會這樣寫,我在講物件導向的時候會為大家慢慢分析。舉這個例子的目的所在,就是希望大家能夠重視函式,因為在實際開發中,它無處不在。
接下來我要分享一個高階的,非常有用的模組的應用。當我們的專案越來越大,那麼需要儲存的資料與狀態就越來越多,因此,我們需要一個專門的模組來維護這些資料,這個時候,有一個叫做狀態管理器的東西就應運而生。對於狀態管理器,最出名的,我想非redux莫屬了。雖然對於還在學習中的大家來說,redux是一個有點高深莫測的東西,但是在我們學習之前,可以先通過簡單的方式,讓大家大致瞭解狀態管理器的實現原理,為我們未來的學習奠定堅實的基礎。
先來直接看程式碼。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
// 自執行建立模組 (function() { // states 結構預覽 // states = { // a: 1, // b: 2, // m: 30, // o: {} // } var states = {}; // 私有變數,用來儲存狀態與資料 // 判斷資料型別 function type(elem) { if(elem == null) { return elem + ''; } return toString.call(elem).replace(/[\[\]]/g, '').split(' ')[1].toLowerCase(); } /** * @Param name 屬性名 * @Description 通過屬性名獲取儲存在states中的值 */ function get(name) { return states[name] ? states[name] : ''; } function getStates() { return states; } /* * @param options {object} 鍵值對 * @param target {object} 屬性值為物件的屬性,只在函式實現時遞迴中傳入 * @desc 通過傳入鍵值對的方式修改state樹,使用方式與小程式的data或者react中的setStates類似 */ function set(options, target) { var keys = Object.keys(options); var o = target ? target : states; keys.map(function(item) { if(typeof o[item] == 'undefined') { o[item] = options[item]; } else { type(o[item]) == 'object' ? set(options[item], o[item]) : o[item] = options[item]; } return item; }) } // 對外提供介面 window.get = get; window.set = set; window.getStates = getStates; })() // 具體使用如下 set({ a: 20 }); // 儲存 屬性a set({ b: 100 }); // 儲存屬性b set({ c: 10 }); // 儲存屬性c // 儲存屬性o, 它的值為一個物件 set({ o: { m: 10, n: 20 } }) // 修改物件o 的m值 set({ o: { m: 1000 } }) // 給物件o中增加一個c屬性 set({ o: { c: 100 } }) console.log(getStates()) |
我之所以說這是一個高階應用,是因為在單頁應用中,我們很可能會用到這樣的思路。根據我們提到過的知識,理解這個例子其實很簡單,其中的難點估計就在於set方法的處理上,因為為了具有更多的適用性,因此做了很多適配,用到了遞迴等知識。如果你暫時看不懂,沒有關係,知道如何使用就行了,上面的程式碼可以直接運用於實際開發。記住,當你需要儲存的狀態太多的時候,你就想到這一段程式碼就行了。
函式自執行的方式另外還有其他幾種寫法,諸如
!function(){}()
,+function(){}()
二、函式引數傳遞方式:按值傳遞
還記得基本資料型別與引用資料型別在複製上的差異嗎?基本資料型別複製,是直接值發生了複製,因此改變後,各自相互不影響。但是引用資料型別的複製,是儲存在變數物件中的引用發生了複製,因此複製之後的這兩個引用實際訪問的實際是同一個堆記憶體中的值。當改變其中一個時,另外一個自然也被改變。如下例。
1 2 3 4 5 6 7 8 9 |
var a = 20; var b = a; b = 10; console.log(a); // 20 var m = { a: 1, b: 2 } var n = m; n.a = 5; console.log(m.a) // 5 |
當值作為函式的引數傳遞進入函式內部時,也有同樣的差異。我們知道,函式的引數在進入函式後,實際是被儲存在了函式的變數物件中,因此,這個時候相當於發生了一次複製。如下例。
1 2 3 4 5 6 7 8 |
var a = 20; function fn(a) { a = a + 10; return a; } console.log(a); // 20 |
1 2 3 4 5 6 7 8 |
var a = { m: 10, n: 20 } function fn(a) { a.m = 20; return a; } fn(a); console.log(a); // { m: 20, n: 20 } |
正是由於這樣的不同,導致了許多人在理解函式引數的傳遞方式時,就有許多困惑。到底是按值傳遞還是按引用傳遞?實際上結論仍然是按值傳遞,只不過當我們期望傳遞一個引用型別時,真正傳遞的,只是這個引用型別儲存在變數物件中的引用而已。為了說明這個問題,我們看看下面這個例子。
1 2 3 4 5 6 7 8 9 10 11 12 |
var person = { name: 'Nicholas', age: 20 } function setName(obj) { // 傳入一個引用 obj = {}; // 將傳入的引用指向另外的值 obj.name = 'Greg'; // 修改引用的name屬性 } setName(person); console.log(person.name); // Nicholas 未被改變 |
在上面的例子中,如果person是按引用傳遞,那麼person就會自動被修改為指向其name屬性值為Gerg的新物件。但是我們從結果中看到,person物件並未發生任何改變,因此只是在函式內部引用被修改而已。
四、函數語言程式設計
雖然JavaScript並不是一門純函數語言程式設計的語言,但是它使用了許多函數語言程式設計的特性。因此瞭解這些特性可以讓我們更加了解自己寫的程式碼。
函式是第一等公民
所謂”第一等公民”(first class),指的是函式與其他資料型別一樣,處於平等地位,可以賦值給其他變數,也可以作為引數,傳入另一個函式,或者作為別的函式的返回值。這些場景,我們應該見過很多。
1 2 3 4 5 6 7 8 9 |
var a = function foo() {} // 賦值 function fn(function() {}, num) {} // 函式作為引數 // 函式作為返回值 function var() { return function() { ... ... } } |
只用”表示式”,不用”語句”
“表示式”(expression)是一個單純的運算過程,總是有返回值;”語句”(statement)是執行某種操作,沒有返回值。函數語言程式設計要求,只使用表示式,不使用語句。也就是說,每一步都是單純的運算,而且都有返回值。
瞭解這一點,可以讓我們自己在封裝函式的時候養成良好的習慣。藉助這個特性,我們在學習其他API的時候,瞭解函式的返回值也是一個十分重要的習慣。
沒有”副作用”
所謂”副作用”(side effect),指的是函式內部與外部互動(最典型的情況,就是修改全域性變數的值),產生運算以外的其他結果。
函數語言程式設計強調沒有”副作用”,意味著函式要保持獨立,所有功能就是返回一個新的值,沒有其他行為,尤其是不得修改外部變數的值。
即所謂的只要是同樣的引數傳入,返回的結果一定是相等的。
閉包
閉包是函數語言程式設計語言的重要特性,我也在前面幾篇文章中說了很多關於閉包的內容。這裡不再贅述。
柯里化
理解柯里化稍微有點難,我在下一篇文章裡專門單獨來深入分析。
五、函式封裝
在我們自己封裝函式時,最好儘量根據函數語言程式設計的特點來編寫。當然在許多情況下並不能完全做到,比如函式中我們常常會利用模組中的私有變數等。
普通封裝
1 2 3 4 5 |
function add(num1, num2) { return num1 + num2; } add(20, 10); // 30 |
掛載在物件上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
if(typeof Array.prototype.add !== 'function') { Array.prototype.add = function() { var i = 0, len = this.length, result = 0; for( ; i < len; i++) { result += this[i] } return result; } } [1, 2, 3, 4].add() // 10 |
修改陣列物件的例子,常在面試中被問到類似的,但是並不建議在實際開發中擴充套件原生物件。與普通封裝不一樣的是,因為掛載在物件的原型上我們可以通過this來訪問物件的屬性和方法,所以這種封裝在實際使用時會有許多的難點,因此我們一定要掌握好this。