縱觀JavaScript中所有必須需要掌握的重點知識中,函數是我們在初學的時候最容易忽視的一個知識點。在學習的過程中,可能會有很多人、很多文章告訴你面向對象很重要,原型很重要,可是卻很少有人告訴你,面向對象中所有的重點難點,幾乎都與函數息息相關。
包括我之前幾篇文章介紹的執行上下文,變量對象,閉包,this等,都是圍繞函數的細節來展開。
我知道很多人在學習中,很急切的希望自己快一點開始學習面向對象,學習模塊,學習流行框架,然后迅速成為高手。但是我可以很負責的告訴你,關于函數的這些基礎東西沒理解到一定程度,那么你的學習進展一定是舉步維艱的。
所以,大家一定要重視函數!
當然,關于函數的重點,難點在前面幾篇文章都已經說得差不多了,這篇文章主要總結一下函數的基礎知識,并初步學習函數式編程的思維。
一、函數聲明、函數表達式、匿名函數與自執行函數
關于函數在實際開發中的應用,大體可以總結為函數聲明、函數表達式、匿名函數、自執行函數。
函數聲明
JavaScript中,有兩種聲明方式,一個是使用var/let/const
的變量聲明,另一個是使用function
的函數聲明。
在前端基礎進階(三):變量對象詳解中我有提到過,變量對象的創建過程中,函數聲明比變量聲明具有更為優先的執行順序,即我們常常提到的函數聲明提前。因此我們在執行上下文中,無論在什么位置聲明了函數,都可以在同一個執行上下文中直接使用該函數。
fn(); // function
function fn() {
console.log('function');
}
函數表達式
與函數聲明不同,函數表達式使用了var/let/const進行聲明,那么我們在確認他是否可以正確使用的時候就必須依照var/let/const的規則進行判斷,即變量聲明。我們知道使用var進行變量聲明,其實是進行了兩步操作。
// 變量聲明
var a = 20;
// 實際執行順序
var a = undefined; // 變量聲明,初始值undefined,變量提升,提升順序次于function聲明
a = 20; // 變量賦值,該操作不會提升
同樣的道理,當我們使用變量聲明的方式來聲明函數時,就是我們常常說的函數表達式。函數表達的提升方式與變量聲明一致。
fn(); // 報錯
var fn = function() {
console.log('function');
}
上例子的執行順序為:
var fn = undefined; // 變量聲明提升
fn(); // 執行報錯
fn = function() { // 賦值操作,此時將后邊函數的引用賦值給fn
console.log('function');
}
因此,由于聲明方式的不同,導致了函數聲明與函數表達式在使用上的一些差異需要我們注意,除此之外,這兩種形式的函數在使用上并無不同。
關于上面例子,函數表達式中的賦值操作,在其他一些地方也會被經常使用,我們清楚其中的關系即可。
在構造函數中添加方法
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;
}
}
匿名函數
匿名函數,顧名思義,就是指的沒有被顯示進行賦值操作的函數。它的使用場景,多作為一個參數傳入另一個函數中。
var a = 10;
var fn = function(bar, num) {
return bar() + num;
}
fn(function() {
return a;
}, 20)
在上面的例子中,fn的第一個參數傳入了一個匿名函數。雖然該匿名函數沒有顯示的進行賦值操作,我們沒有辦法在外部執行上下文中引用到它,但是在fn函數內部,我們將該匿名函數賦值給了變量bar,保存在了fn變量對象的arguments對象中。
// 變量對象在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中,沒有塊級作用域,因此我們常常使用函數自執行的方式來模仿塊級作用域,這樣就提供了一個獨立的執行上下文,結合閉包,就為模塊化提供了基礎。而函數自執行,其實是匿名函數的一種應用。
(function() {
// ...
})();
一個模塊往往可以包括:私有變量、私有方法、公有變量、公有方法。
根據作用域鏈的單向訪問,外面可能很容易知道在這個獨立的模塊中,外部執行環境是無法訪問內部的任何變量與方法的,因此我們可以很容易的創建屬于這個模塊的私有變量與私有方法。
(function() {
// 私有變量
var age = 20;
var name = 'Tom';
// 私有方法
function getName() {
return `your name is ` + name;
}
})();
但是共有方法和變量應該怎么辦?大家還記得我們前面講到過的閉包的特性嗎?沒錯,利用閉包,我們可以訪問到執行上下文內部的變量和方法,因此,只需要根據閉包的定義,創建一個閉包,將你認為需要公開的變量和方法開放出來即可。
(function() {
// 私有變量
var age = 20;
var name = 'Tom';
// 私有方法
function getName() {
return `your name is ` + name;
}
// 共有方法
function getAge() {
return age;
}
// 將引用保存在外部執行環境的變量中,形成閉包,防止該執行環境被垃圾回收
window.getAge = getAge;
})();
當然,閉包在模塊中的重要作用,我們在講解閉包的時候已經強調過,但是這個知識點真的太重要,需要我們反復理解并且徹底掌握。
為了幫助大家進一步理解閉包,我們來看看jQuery中,是如何利用模塊與閉包的。
// 使用函數自執行的方式創建模塊
(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是一個有點高深莫測的東西,但是在我們學習之前,可以先通過簡單的方式,讓大家大致了解狀態管理器的實現原理,為我們未來的學習奠定堅實的基礎。
先來直接看代碼。
// 自執行創建模塊
(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(){}()
二、函數參數傳遞方式:按值傳遞
還記得基本數據類型與引用數據類型在復制上的差異嗎?基本數據類型復制,是值直接發生了復制,因此改變后,各自相互不影響。但是引用數據類型的復制,是保存在變量對象中的引用發生了復制,因此復制之后的這兩個引用實際訪問的實際是同一個堆內存中的值。當改變其中一個時,另外一個自然也被改變。如下例。
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
當值作為函數的參數傳遞進入函數內部時,也有同樣的差異。我們知道,函數的參數在進入函數后,實際是被保存在了函數的變量對象中,因此,這個時候相當于發生了一次復制。如下例。
var a = 20;
function fn(a) {
a = a + 10;
return a;
}
fn(a);
console.log(a); // 20
var a = { m: 10, n: 20 }
function fn(a) {
a.m = 20;
return a;
}
fn(a);
console.log(a); // { m: 20, n: 20 }
正是由于這樣的不同,導致了許多人在理解函數參數的傳遞方式時,就有許多困惑。到底是按值傳遞還是按引用傳遞?實際上結論仍然是按值傳遞,只不過當我們期望傳遞一個引用類型時,真正傳遞的,只是這個引用類型保存在變量對象中的引用而已。為了說明這個問題,我們看看下面這個例子。
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并不是一門純函數式編程的語言,但是它使用了許多函數式編程的特性。因此了解這些特性可以讓我們更加了解自己寫的代碼。
當我們想要使用一個函數時,通常情況下其實就是想要將一些功能,邏輯等封裝起來。相信大家對于封裝這個概念并不陌生。
我們通常通過函數封裝來完成一件事情。例如,想要計算任意三個數的和,我們就可以將這三個數作為參數,封裝一個簡單的函數。
function add(a, b, c) {
return a + b + c;
}
當我們想要計算三個數的和時,直接調用該方法即可。
add(1, 2, 3); // 6
當然,當想要做的事情比較簡單時,可能還看不出來封裝成為函數之后帶來的便利。如果我們想要做的事情稍微復雜一點呢。例如我想要計算一個數組中的所有子項目的和。
function mergeArr(arr) {
var result = 0;
for(var i = 0; i < arr.length; i++) { result += arr[i] }
return result;
}
如果不通過函數封裝的方式,那么再每次想要實現這個功能時,就不得不重新使用一次for循環,這樣的后果就是我們的代碼中充斥著越來越多的重復代碼。而封裝之后,當我們想要再次做這件事情的時候,只需要一句話就可以了。
mergeArr([1, 2, 3, 4, 5]);
當然,我相信大家對于函數封裝的意義都應該有非常明確的認知,但是我們要面臨的問題是,當我們想要去封裝一個函數時,如何做才是最佳實踐呢?
函數式編程能給我們答案。
我們在初學時,往往會不由自主的使用命令式編程的風格來完成我們想要干的事情。因為命令式編程更加的簡單,直白。例如我們現在有一個數組,array = [1, 3, 'h', 5, 'm', '4']
,現在想要找出這個數組中的所有類型為number的子項。當我們使用命令式編程思維時,可能就會直接這樣做。
var array = [1, 3, 'h', 5, 'm', '4'];
var res = [];
for(var i = 0; i < array.length; i ++) {
if (typeof array[i] === 'number') {
res.push(array[i]);
}
}
在這種實現方式中,我們平鋪直敘的實現了我們的目的。這樣做的問題在于,當我們在另外的時刻,想要找出另外一個數組中所有的子項時,我們不得不把同樣的邏輯再寫一次。當出現次數變多時,我們的代碼也變得更加糟糕且難以維護。
而函數式編程的思維則建議我們將這種會多次出現的功能封裝起來以備調用。
function getNumbers(array) {
var res = [];
array.forEach(function(item) {
if (typeof item === 'number') {
res.push(item);
}
})
return res;
}
// 以上是我們的封裝,以下是功能實現
var array = [1, 3, 'h', 5, 'm', '4'];
var res = getNumbers(array);
將功能封裝之后,我們實現同樣的功能時,只需要寫一行代碼。而如果未來需求變動,或者稍作修改,我們只需要對getNumbers方法進行調整就可以了。而且我們在使用時,只需要關心這個方法能做什么,而不用關心他具體是怎么實現的。這也是函數式編程思維與命令式不同的地方之一。
函數式編程思維還具有以下幾個特征。
函數是第一等公民
所謂"第一等公民"(first class),指的是函數與其他數據類型一樣,處于平等地位,可以賦值給其他變量,也可以作為參數,傳入另一個函數,或者作為別的函數的返回值。這些場景,我們應該見過很多。
var a = function foo() {} // 賦值
function fn(function() {}, num) {} // 函數作為參數
// 函數作為返回值
function var() {
return function() {
... ...
}
}
當然,這都是JavaScript的基本概念。但是我想很多人,甚至包括正在閱讀的你自己都可能會無視這些概念。可以用一個簡單的例子來驗證一下。
我們先自定義這樣一個函數。
function delay() {
console.log('5000ms之后執行該方法.');
}
現在要做的是,如果要求你結合setTimeout方法,讓delay方法延遲5000ms執行,應該怎么做?
其實很簡單,對不對,直接這樣就可以了。
var timer = setTimeout(function() {
delay();
}, 5000);
那么現在問題來了,如果你對函數是一等公民有一個深刻的認知,我想你會發現上面這種寫法其實是有一些問題的。所以思考一下,問題出在哪里?
函數既然能夠作為一個參數傳入另外一個函數,那么我們是不是可以直接將delay作為setTimeout的第一個參數,而不用額外的多加一層匿名函數呢?
因此,其實最正確的解法應該這樣寫。
var timer = setTimeout(delay, 5000);
當然,如果你已經提前想到這樣做了,那么恭喜你,說明你在JavaScript上比普通人更有天賦。其實第一種糟糕的方式很多人都在用,包括有多年工作經驗的人也沒有完全避免。而他們甚至還不知道自己問題出在什么地方。
在未來的實踐中,你還會遇到更多類似的場景。為了驗證讀者朋友們的理解,我們不妨來思考一下如何優化下面的代碼。
function getUser(path, callback) {
return $.get(path, function(info) {
return callback(info);
})
}
getUser('/api/user', function(resp) {
// resp為成功請求之后返回的數據
console.log(resp);
})
優化的原理和setTimeout的例子一模一樣,我這里賣個關子,不打算告訴大家結論,僅提示一句,getUser優化之后,僅有一句代碼。考驗大家學習成果的時候到了 ^ ^。
只用"表達式",不用"語句"
"表達式"(expression)是一個單純的運算過程,總是有返回值;"語句"(statement)是執行某種操作,沒有返回值。函數式編程要求,只使用表達式,不使用語句。也就是說,每一步都是單純的運算,而且都有返回值。
假如我們的項目中,多處需要改變某個元素的背景色。因此我們可以這樣封裝一下。
var ele = document.querySelector('.test');
function setBackgroundColor(color) {
ele.style.backgroundColor = color;
}
// 多處使用
setBackgroundColor('red');
setBackgroundColor('#ccc');
我們可以很明顯的感受到,setBackgroundColor封裝的僅僅只是一條語句。這并不是理想的效果。函數式編程期望一個函數有輸入,也有輸出。因此良好的習慣應該如下做。
function setBackgroundColor(ele, color) {
ele.style.backgroundColor = color;
return color;
}
// 多處使用
var ele = document.querySelector('.test');
setBackgroundColor(ele, 'red');
setBackgroundColor(ele, '#ccc');
了解這一點,可以讓我們自己在封裝函數的時候養成良好的習慣。
純函數
相同的輸入總會得到相同的輸出,并且不會產生副作用的函數,就是純函數。
所謂"副作用"(side effect),指的是函數內部與外部互動(最典型的情況,就是修改全局變量的值),產生運算以外的其他結果。
函數式編程強調沒有"副作用",意味著函數要保持獨立,所有功能就是返回一個新的值,沒有其他行為,尤其是不得修改外部變量的值。
即:只要是同樣的參數傳入,返回的結果一定是相等的。
例如我們期望封裝一個函數,能夠得到傳入數組的最后一項。那么可以通過下面兩種方式來實現。
function getLast(arr) {
return arr[arr.length - 1];
}
function getLast_(arr) {
return arr.pop();
}
var source = [1, 2, 3, 4];
var last = getLast(source); // 返回結果4 原數組不變
var last_ = getLast_(source); // 返回結果4 原數據最后一項被刪除
getLast與getLast_雖然同樣能夠獲得數組的最后一項值,但是getLast_改變了原數組。而當原始數組被改變,那么當我們再次調用該方法時,得到的結果就會變得不一樣。這樣不可預測的封裝方式,在我們看來是非常糟糕的。它會把我們的數據搞得非常混亂。在JavaScript原生支持的數據方法中,也有許多不純的方法,我們在使用時需要非常警惕,我們要清晰的知道原始數據的改變是否會留下隱患。
var source = [1, 2, 3, 4, 5];
source.slice(1, 3); // 純函數 返回[2, 3] source不變
source.splice(1, 3); // 不純的 返回[2, 3, 4] source被改變
source.pop(); // 不純的
source.push(6); // 不純的
source.shift(); // 不純的
source.unshift(1); // 不純的
source.reverse(); // 不純的
// 我也不能短時間知道現在source被改變成了什么樣子,干脆重新約定一下
source = [1, 2, 3, 4, 5];
source.concat([6, 7]); // 純函數 返回[1, 2, 3, 4, 5, 6, 7] source不變
source.join('-'); // 純函數 返回1-2-3-4-5 source不變
下一篇:前端基礎進階(十):深入詳解函數的柯里化
上一篇:前端基礎進階(八):在chrome開發者工具中觀察函數調用棧、作用域鏈與閉包
前端基礎進階目錄