徹底搞懂Javascript閉包,柯里化,手寫代碼!
本文總結了 javascript 中函數的常見知識點,包含了基礎概念,閉包,this 指向問題,高階函數,柯里化等,手寫代碼那部分也是滿滿的乾貨,無論您是想複習準備面試,還是想深入瞭解原理,本文都應該有你想看的點,總之還是值得一看的。
老規矩,先上思維導圖。
什麼是函數
一般來說,一個函數是可以通過外部代碼調用的一個 “子程序”(或在遞歸的情況下由內部函數調用)。像程序本身一樣,一個函數由稱爲函數體的一系列語句組成。值可以傳遞給一個函數,函數將返回一個值。
函數首先是一個對象,並且在 javascript 中,函數是一等對象(first-class object)。函數可以被執行(callable,擁有內部屬性 [[Call]]),這是函數的本質特性。除此之外,函數可以賦值給變量,也可以作爲函數參數,還可以作爲另一個函數的返回值。
函數基本概念
函數名
函數名是函數的標識,如果一個函數不是匿名函數,它應該被賦予函數名。
-
函數命名需要符合 javascript 標識符規則,必須以字母、下劃線_或美元符 $ 開始,後面可以跟數字,字母,下劃線,美元符。
-
函數命名不能使用 javascript 保留字,保留字是 javascript 中具有特殊含義的標識符。
-
函數命名應該語義化,儘量採用動賓結構,小駝峯寫法,比如
getUserName()
,validateForm()
,isValidMobilePhone()
。 -
對於構造函數,我們通常寫成大駝峯格式(因爲構造函數與類的概念強關聯)。
下面是一些不成文的約定,不成文代表它不必遵守,但是我們按照這樣的約定來執行,會讓開發變得更有效率。
-
__xxx__
代表非標準的方法。 -
_xxx
代表私有方法。
函數參數
形參
形參是函數定義時約定的參數列表,由一對圓括號()
包裹。
在 MDN 上有看到,一個函數最多可以有255
個參數。
然而形參太多時,使用者總是容易在引用時出錯。所以對於數量較多的形參,一般推薦把所有參數作爲屬性或方法整合到一個對象中,各個參數作爲這個對象的屬性或方法來使用。舉個例子,微信小程序的提供的 API 基本上是這種調用形式。
wx.redirectTo(Object object)
調用示例如下:
wx.redirectTo({
url: '/article/detail?id=1',
success: function() {},
fail: function() {}
})
形參的數量可以由函數的length
屬性獲得,如下所示。
function test(a, b, c) {}
test.length; // 3
實參
實參是調用函數時傳入的,實參的值在函數執行前被確定。
javascript 在函數定義時並不會約定參數的數據類型。如果你期望函數調用時傳入正確的數據類型,你必須在函數體中對入參進行數據類型判斷。
function add(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error("參數必須是數字類型")
}
}
好在 Typescript 提供了數據類型檢查的能力,這一定程度上防止了意外情況的發生。
實參的數量可以通過函數中arguments
對象的length
屬性獲得,如下所示。
實參數量不一定與形參數量一致。
function test(a, b, c) {
var argLength = arguments.length;
return argLength;
}
test(1, 2); // 2
默認參數
函數參數的默認值是undefined
,如果你不傳入實參,那麼實際上在函數執行過程中,相應參數的值是undefined
。
ES6 也支持在函數聲明時設置參數的默認值。
function add(a, b = 2) {
return a + b;
}
add(1); // 3
在上面的add
函數中,參數b
被指定了默認值2
。所以,即便你不傳第二個參數b
,也能得到一個預期的結果。
假設一個函數有多個參數,我們希望不給中間的某個參數傳值,那麼這個參數值必須顯示地指定爲undefined
,否則我們期望傳給後面的參數的值會被傳到中間的這個參數。
function printUserInfo(name, age = 18, gender) {
console.log(`姓名:${name},年齡:${age},性別:${gender}`);
}
// 正確地使用
printUserInfo('Bob', undefined, 'male');
// 錯誤,'male'被錯誤地傳給了age參數
printUserInfo('Bob', 'male');
PS:注意,如果你希望使用參數的默認值,請一定傳undefined
,而不是null
。
當然,我們也可以在函數體中判斷參數的數據類型,防止參數被誤用。
function printUserInfo(name, age = 18, gender) {
if (typeof arguments[1] === 'string') {
age = 18;
gender = arguments[1];
}
console.log(`姓名:${name},年齡:${age},性別:${gender}`);
}
printUserInfo('bob', 'male'); // 姓名:bob,年齡:18,性別:male
這樣一來,函數的邏輯也不會亂。
剩餘參數
剩餘參數語法允許我們將一個不定數量的參數表示爲一個數組。
剩餘參數通過剩餘語法...
將多個參數聚合成一個數組。
function add(a, ...args) {
return args.reduce((prev, curr) => {
return prev + curr
}, a)
}
剩餘參數和arguments
對象之間的區別主要有三個:
-
剩餘參數只包含那些沒有對應形參的實參,而
arguments
對象包含了傳給函數的所有實參。 -
arguments
對象不是一個真正的數組,而剩餘參數是真正的Array
實例,也就是說你能夠在它上面直接使用所有的數組方法,比如sort
,map
,forEach
或pop
。而arguments
需要借用call
來實現,比如[].slice.call(arguments)
。 -
arguments
對象還有一些附加的屬性(如callee
屬性)。
剩餘語法和展開運算符看起來很相似,然而從功能上來說,是完全相反的。
剩餘語法 (Rest syntax) 看起來和展開語法完全相同,不同點在於, 剩餘參數用於解構數組和對象。從某種意義上說,剩餘語法與展開語法是相反的:展開語法將數組展開爲其中的各個元素,而剩餘語法則是將多個元素收集起來並“凝聚” 爲單個元素。
arguments
函數的實際參數會被保存在一個類數組對象arguments
中。
類數組(ArrayLike)對象具備一個非負的length
屬性,並且可以通過從0
開始的索引去訪問元素,讓人看起來覺得就像是數組,比如NodeList
,但是類數組默認沒有數組的那些內置方法,比如push
, pop
, forEach
, map
。
我們可以試試,隨便找一個網站,在控制檯輸入:
var linkList = document.querySelectorAll('a')
會得到一個NodeList
,我們也可以通過數字下標去訪問其中的元素,比如linkList[0]
。
但是NodeList
不是數組,它是類數組。
Array.isArray(linkList); // false
回到主題,arguments
也是類數組,arguments
的length
由實參的數量決定,而不是由形參的數量決定。
function add(a, b) {
console.log(arguments.length);
return a + b;
}
add(1, 2, 3, 4);
// 這裏打印的是4,而不是2
arguments
也是一個和嚴格模式有關聯的對象。
- 在 非嚴格模式下,
arguments
裏的元素和函數參數都是指向同一個值的引用,對arguments
的修改,會直接影響函數參數。
function test(obj) {
arguments[0] = '傳入的實參是一個對象,但是被我變成字符串了'
console.log(obj)
}
test({name: 'jack'})
// 這裏打印的是字符串,而不是對象
- 在 嚴格模式下,
arguments
是函數參數的副本,對arguments
的修改不會影響函數參數。但是arguments
不能重新被賦值,關於這一點,我在 解讀閉包,這次從 ECMAScript 詞法環境,執行上下文說起這篇文章中解讀 不可變綁定時有提到。在嚴格模式下,也不能使用arguments.caller
和arguments.callee
,限制了對調用棧的檢測能力。
函數體
函數體(FunctionBody)是函數的主體,其中的函數代碼 (function code) 由一對花括號{}
包裹。函數體可以爲空,也可以由任意條 javascript 語句組成。
函數的調用形式
大體來說,函數的調用形式分爲以下四種:
作爲普通函數
函數作爲普通函數被調用,這是函數調用的常用形式。
function add(a, b) {
return a + b;
}
add(); // 調用add函數
作爲普通函數調用時,如果在非嚴格模式下,函數執行時,this
指向全局對象,對於瀏覽器而言則是window
對象;如果在嚴格模式下,this
的值則是undefined
。
作爲對象的方法
函數也可以作爲對象的成員,這種情況下,該函數通常被稱爲對象方法。當函數作爲對象的方法被調用時,this
指向該對象,此時便可以通過this
訪問對象的其他成員變量或方法。
var counter = {
num: 0,
increase: function() {
this.num++;
}
}
counter.increase();
作爲構造函數
函數配合new
關鍵字使用時就成了構造函數。構造函數用於實例化對象,構造函數的執行過程大致如下:
-
首先創建一個新對象,這個新對象的
__proto__
屬性指向構造函數的prototype
屬性。 -
此時構造函數的
this
指向這個新對象。 -
執行構造函數中的代碼,一般是通過
this
給新對象添加新的成員屬性或方法。 -
最後返回這個新對象。
實例化對象也可以通過一些技巧來簡化,比如在構造函數中顯示地return
另一個對象,jQuery 很巧妙地利用了這一點。具體分析詳見面試官真的會問:new 的實現以及無 new 實例化。
通過 call, apply 調用
apply
和call
是函數對象的原型方法,掛載於Function.prototype
。利用這兩個方法,我們可以顯示地綁定一個this
作爲調用上下文,同時也可以設置函數調用時的參數。
apply
和call
的區別在於:提供參數的形式不同,apply
方法接受的是一個參數數組,call
方法接受的是參數列表。
someFunc.call(obj, 1, 2, 3)
someFunc.apply(obj, [1, 2, 3])
注意,在非嚴格模式下使用call
或者apply
時,如果第一個參數被指定爲null
或undefined
,那麼函數執行時的this
指向全局對象(瀏覽器環境中是window
);如果第一個參數被指定爲原始值,該原始值會被包裝。這部分內容在下文中的手寫代碼會再次講到。
call
是用來實現繼承的重要方法。在子類構造函數中,通過call
來調用父類構造函數,以使對象實例獲得來自父類構造函數的屬性或方法。
function Father() {
this.nationality = 'Han';
};
Father.prototype.propA = '我是父類原型上的屬性';
function Child() {
Father.call(this);
};
Child.prototype.propB = '我是子類原型上的屬性';
var child = new Child();
child.nationality; // "Han"
call, apply, bind
call
,apply
,bind
都可以綁定this
,區別在於:apply
和call
是綁定this
後直接調用該函數,而bind
會返回一個新的函數,並不直接調用,可以由程序員決定調用的時機。
bind
的語法形式如下:
function.bind(thisArg[, arg1[, arg2[, ...]]])
bind
的arg1, arg2, ...
是給新函數預置好的參數(預置參數是可選的)。當然新函數在執行時也可以繼續追加參數。
手寫 call, apply, bind
提到call
,apply
,bind
總是無法避免手寫代碼這個話題。手寫代碼不僅僅是爲了應付面試,也是幫助我們理清思路和深入原理的一個好方法。手寫代碼一定不要抄襲,如果實在沒思路,可以參考下別人的代碼整理出思路,再自己按照思路獨立寫一遍代碼,然後驗證看看有沒有缺陷,這樣纔能有所收穫,否則忘得很快,只能短時間應付應付。
那麼如何才能順利地手寫代碼呢?首先是要清楚一段代碼的作用,可以從官方對於它的定義和描述入手,同時還要注意一些特殊情況下的處理。
就拿call
來說,call
是函數對象的原型方法,它的作用是綁定this
和參數,並執行函數。調用形式如下:
function.call(thisArg, arg1, arg2, ...)
那麼我們慢慢來實現它,將我們要實現的函數命名爲myCall
。首先myCall
是一個函數,接受的第一個參數thisArg
是目標函數執行時的this
的值,從第二個可選參數arg1
開始的其他參數將作爲目標函數執行時的實參。
這裏面有很多細節要考慮,我大致羅列了一下:
-
要考慮是不是嚴格模式。如果是非嚴格模式,對於
thisArg
要特殊處理。 -
如何判斷嚴格模式?
-
thisArg
被處理後還要進行非空判斷,然後考慮是以方法的形式調用還是以普通函數的形式調用。 -
目標函數作爲方法調用時,如何不覆蓋對象的原有屬性?
實現代碼如下,請仔細看我寫的註釋,這是主要的思路!
// 首先apply是Function.prototype上的一個方法
Function.prototype.myCall = function() {
// 由於目標函數的實參數量是不定的,這裏就不寫形參了
// 實際上通過arugments對象,我們能拿到所有實參
// 第一個參數是綁定的this
var thisArg = arguments[0];
// 接着要判斷是不是嚴格模式
var isStrict = (function(){return this === undefined}())
if (!isStrict) {
// 如果在非嚴格模式下,thisArg的值是null或undefined,需要將thisArg置爲全局對象
if (thisArg === null || thisArg === undefined) {
// 獲取全局對象時兼顧瀏覽器環境和Node環境
thisArg = (function(){return this}())
} else {
// 如果是其他原始值,需要通過構造函數包裝成對象
var thisArgType = typeof thisArg
if (thisArgType === 'number') {
thisArg = new Number(thisArg)
} else if (thisArgType === 'string') {
thisArg = new String(thisArg)
} else if (thisArgType === 'boolean') {
thisArg = new Boolean(thisArg)
}
}
}
// 截取從索引1開始的剩餘參數
var invokeParams = [...arguments].slice(1);
// 接下來要調用目標函數,那麼如何獲取到目標函數呢?
// 實際上this就是目標函數,因爲myCall是作爲一個方法被調用的,this當然指向調用對象,而這個對象就是目標函數 // 這裏做這麼一個賦值過程,是爲了讓語義更清晰一點
var invokeFunc = this;
// 此時如果thisArg對象仍然是null或undefined,那麼說明是在嚴格模式下,並且沒有指定第一個參數或者第一個參數的值本身就是null或undefined,此時將目標函數當成普通函數執行並返回其結果即可
if (thisArg === null || thisArg === undefined) {
return invokeFunc(...invokeParams)
}
// 否則,讓目標函數成爲thisArg對象的成員方法,然後調用它
// 直觀上來看,可以直接把目標函數賦值給對象屬性,比如func屬性,但是可能func屬性本身就存在於thisArg對象上
// 所以,爲了防止覆蓋掉thisArg對象的原有屬性,必須創建一個唯一的屬性名,可以用Symbol實現,如果環境不支持Symbol,可以通過uuid算法來構造一個唯一值。
var uniquePropName = Symbol(thisArg)
thisArg[uniquePropName] = invokeFunc
// 返回目標函數執行的結果
return thisArg[uniquePropName](...invokeParams)
}
寫完又思考了一陣,我突然發現有個地方考慮得有點多餘了。
// 如果在非嚴格模式下,thisArg的值是null或undefined,需要將thisArg置爲全局對象
if (thisArg === null || thisArg === undefined) {
// 獲取全局對象時兼顧瀏覽器環境和Node環境
thisArg = (function(){return this}())
} else {
其實這種情況下不用處理thisArg
,因爲代碼執行到該函數後面部分,目標函數會被作爲普通函數執行,那麼this
自然指向全局對象!所以這段代碼可以刪除了!
接着來測試一下myCall
是否可靠,我寫了一個簡單的例子:
function test(a, b) {
var args = [].slice.myCall(arguments)
console.log(arguments, args)
}
test(1, 2)
var obj = {
name: 'jack'
};
var name = 'global';
function getName() {
return this.name;
}
getName();
getName.myCall(obj);
我不敢保證我寫的這個myCall
方法沒有 bug,但也算是考慮了很多情況了。就算是在面試過程中,面試官主要關注的就是你的思路和考慮問題的全面性,如果寫到這個程度還不能讓面試官滿意,那也無能爲力了......
理解了手寫call
之後,手寫apply
也自然觸類旁通,只要注意兩點即可。
-
myApply
接受的第二個參數是數組形式。 -
要考慮實際調用時不傳第二個參數或者第二個參數不是數組的情況。
直接上代碼:
Function.prototype.myApply = function(thisArg, params) {
var isStrict = (function(){return this === undefined}())
if (!isStrict) {
var thisArgType = typeof thisArg
if (thisArgType === 'number') {
thisArg = new Number(thisArg)
} else if (thisArgType === 'string') {
thisArg = new String(thisArg)
} else if (thisArgType === 'boolean') {
thisArg = new Boolean(thisArg)
}
}
var invokeFunc = this;
// 處理第二個參數
var invokeParams = Array.isArray(params) ? params : [];
if (thisArg === null || thisArg === undefined) {
return invokeFunc(...invokeParams)
}
var uniquePropName = Symbol(thisArg)
thisArg[uniquePropName] = invokeFunc
return thisArg[uniquePropName](...invokeParams)
}
用比較常用的Math.max
來測試一下:
Math.max.myApply(null, [1, 2, 4, 8]);// 結果是8
接下來就是手寫bind
了,首先要明確,bind
與call
, apply
的不同點在哪裏。
-
bind
返回一個新的函數。 -
這個新的函數可以預置參數。
好的,按照思路開始寫代碼。
Function.prototype.myBind = function() {
// 保存要綁定的this
var boundThis = arguments[0];
// 獲得預置參數
var boundParams = [].slice.call(arguments, 1);
// 獲得綁定的目標函數
var boundTargetFunc = this;
if (typeof boundTargetFunc !== 'function') {
throw new Error('綁定的目標必須是函數')
}
// 返回一個新的函數
return function() {
// 獲取執行時傳入的參數
var restParams = [].slice.call(arguments);
// 合併參數
var allParams = boundParams.concat(restParams)
// 新函數被執行時,通過執行綁定的目標函數獲得結果,並返回結果
return boundTargetFunc.apply(boundThis, allParams)
}
}
本來寫到這覺得已經結束了,但是翻到一些資料,都提到了手寫bind
需要支持new
調用。仔細一想也對,bind
返回一個新的函數,這個函數被作爲構造函數使用也是很有可能的。
我首先思考的是,能不能直接判斷一個函數是不是以構造函數的形式執行的呢?如果能判斷出來,那麼問題就相對簡單了。
於是我想到構造函數中很重要的一點,那就是在構造函數中,this 指向對象實例。所以,我利用instanceof
改了一版代碼出來。
Function.prototype.myBind = function() {
var boundThis = arguments[0];
var boundParams = [].slice.call(arguments, 1);
var boundTargetFunc = this;
if (typeof boundTargetFunc !== 'function') {
throw new Error('綁定的目標必須是函數')
}
function fBound () {
var restParams = [].slice.call(arguments);
var allParams = boundParams.concat(restParams)
// 通過instanceof判斷this是不是fBound的實例
var isConstructor = this instanceof fBound;
if (isConstructor) {
// 如果是,說明是通過new調用的(這裏有bug,見下文),那麼只要把處理好的參數傳給綁定的目標函數,並通過new調用即可。
return new boundTargetFunc(...allParams)
} else {
// 如果不是,說明不是通過new調用的
return boundTargetFunc.apply(boundThis, allParams)
}
}
return fBound
}
最後看了一下 MDN 提供的 bind 函數的 polyfill,發現思路有點不一樣,於是我通過一個實例進行對比。
function test() {}
var fBoundNative = test.bind()
var obj1 = new fBoundNative()
var fBoundMy = test.myBind()
var obj2 = new fBoundMy()
var fBoundMDN = test.mdnBind()
var obj3 = new fBoundMDN()
我發現我的寫法看起來竟然更像原生bind
。瞬間懷疑自己,但一下子卻沒找到很明顯的 bug......
終於我還是意識到了一個很大的問題,obj1
是fBoundNative
的實例,obj3
是fBoundMDN
的實例,但obj2
不是fBoundMy
的實例(實際上obj2
是test
的實例)。
obj1 instanceof fBoundNative; // true
obj2 instanceof fBoundMy; // false
obj3 instanceof fBoundMDN; // true
存在這個問題麻煩就大了,假設我要在fBoundMy.prototype
上繼續擴展原型屬性或方法,obj2
是無法繼承它們的。所以最直接有效的方法就是用繼承的方法來實現,雖然不能達到原生bind
的效果,但已經夠用了。於是我參考 MDN 改了一版。
Function.prototype.myBind = function() {
var boundTargetFunc = this;
if (typeof boundTargetFunc !== 'function') {
throw new Error('綁定的目標必須是函數')
}
var boundThis = arguments[0];
var boundParams = [].slice.call(arguments, 1);
function fBound () {
var restParams = [].slice.call(arguments);
var allParams = boundParams.concat(restParams)
return boundTargetFunc.apply(this instanceof fBound ? this : boundThis, allParams)
}
fBound.prototype = Object.create(boundTargetFunc.prototype || Function.prototype)
return fBound
}
這裏面最重要的兩點:處理好原型鏈關係,以及理解 bind 中構造實例的過程。
- 原型鏈處理
fBound.prototype = Object.create(boundTargetFunc.prototype || Function.prototype)
這一行代碼中用了一個||
運算符,||
的兩端充分考慮了myBind
函數的兩種可能的調用方式。
- 常規的函數綁定
function test(name, age) {
this.name = name;
this.age = age;
}
var bound1 = test.myBind('小明')
var obj1 = new bound1(18)
這種情況把fBound.prototype
的原型指向boundTargetFunc.prototype
,完全符合我們的思維。
- 直接使用 Function.prototype.myBind
var bound2 = Function.prototype.myBind()
var obj2 = new bound2()
這相當於創建一個新的函數,綁定的目標函數是Function.prototype
。這裏必然有朋友會問了,Function.prototype
也是函數嗎?是的,請看!
typeof Function.prototype; // "function"
雖然我還不知道第二種調用方式存在的意義,但是存在即合理,既然存在,我們就支持它。
- 理解 bind 中構造實例的過程
首先要清楚new
的執行過程,如果您還不清楚這一點,可以看看我寫的這篇面試官真的會問:new 的實現以及無 new 實例化。
還是之前那句話,先要判斷是不是以構造函數的形式調用的。核心就是這:
this instanceof fBound
我們用一個例子再來分析下new
的過程。
function test(name, age) {
this.name = name;
this.age = age;
}
var bound1 = test.myBind('小明')
var obj1 = new bound1(18)
obj1 instanceof bound1 // true
obj1 instanceof test // true
-
執行構造函數
bound1
,實際上是執行myBind
執行後返回的新函數fBound
。首先會創建一個新對象obj1
,並且obj1
的非標準屬性__proto__
指向bound1.prototype
,其實就是myBind
執行時聲明的fBound.prototype
,而fBound.prototype
的原型指向test.prototype
。所以到這裏,原型鏈就串起來了! -
執行的構造函數中,
this
指向這個obj1
。 -
執行構造函數,由於
fBound
是沒有實際內容的,執行構造函數本質上還是要去執行綁定的那個目標函數,本例中也就是test
。因此如果是以構造函數形式調用,我們就把實例對象作爲this
傳給test.apply
。 -
通過執行
test
,對象實例被掛載了name
和age
屬性,一個嶄新的對象就出爐了!
最後附上 Raynos 大神寫的 bind 實現,我感覺又受到了 “暴擊”!有興趣鑽研bind
終極奧義的朋友請點開鏈接查看源碼!
this 指向問題
分析this
的指向,首先要確定當前執行代碼的環境。
全局環境中的 this 指向
全局環境中,this 指向全局對象(視宿主環境而定,瀏覽器是 window,Node 是 global)。
函數中的 this 指向
在上文中介紹函數的調用形式時已經比較詳細地說過this
指向問題了,這裏再簡單總結一下。
函數中this
的指向取決於函數的調用形式,在一些情況下也受到嚴格模式的影響。
-
作爲普通函數調用:嚴格模式下,
this
的值是undefined
,非嚴格模式下,this
指向全局對象。 -
作爲方法調用:
this
指向所屬對象。 -
作爲構造函數調用:
this
指向實例化的對象。 -
通過 call, apply, bind 調用:如果指定了第一個參數
thisArg
,this
的值就是thisArg
的值(如果是原始值,會包裝爲對象);如果不傳thisArg
,要判斷嚴格模式,嚴格模式下this
是undefined
,非嚴格模式下this
指向全局對象。
函數聲明和函數表達式
撕了這麼久代碼,讓大腦休息一會兒,先看點輕鬆點的內容。
函數聲明
函數聲明是獨立的函數語句。
function test() {}
函數聲明存在提升(Hoisting)現象,如變量提升一般,對於同名的情況,函數聲明優於變量聲明(前者覆蓋後者,我說的是聲明階段哦)。
函數表達式
函數表達式不是獨立的函數語句,常作爲表達式的一部分,比如賦值表達式。
函數表達式可以是命名的,也可以是匿名的。
// 命名函數表達式
var a = function test() {}
// 匿名函數表達式
var b = function () {}
匿名函數就是沒有函數名的函數,它不能單獨使用,只能作爲表達式的一部分使用。匿名函數常以 IIFE(立即執行函數表達式)的形式使用。
(function(){console.log("我是一個IIFE")}())
閉包
關於閉包,我已經寫了一篇超詳細的文章去分析了,是個人原創總結的乾貨,建議直接打開解讀閉包,這次從 ECMAScript 詞法環境,執行上下文說起。
PS:閱讀前,您應該對 ECMAScript5 的一些術語有一些簡單的瞭解,比如 Lexical Environment, Execution Context 等。
純函數
-
純函數是具備冪等性(對於相同的參數,任何時間執行純函數都將得到同樣的結果),它不會引起副作用。
-
純函數與外部的關聯應該都來源於函數參數。如果一個函數直接依賴了外部變量,那它就不是純函數,因爲外部變量是可變的,那麼純函數的執行結果就不可控了。
// 純函數
function pure(a, b) {
return a + b;
}
// 非純函數
function impure(c) {
return c + d
}
var d = 10;
pure(1, 2); // 3
impure(1); // 11
d = 20;
impure(1); // 21
pure(1, 2); // 3
惰性函數
相信大家在兼容事件監聽時,都寫過這樣的代碼。
function addEvent(element, type, handler) {
if (window.addEventListener) {
element.addEventListener(type, handler, false);
} else if (window.attachEvent){
element.attachEvent('on' + type, handler);
} else {
element['on' + type] = handler;
}
}
仔細看下,我們會發現,每次調用addEvent
,都會做一次if-else
的判斷,這樣的工作顯然是重複的。這個時候就用到惰性函數了。
惰性函數表示函數執行的分支只會在函數第一次調用的時候執行。後續我們所使用的就是這個函數執行的結果。
利用惰性函數的思維,我們可以改造下上述代碼。
function addEvent(element, type, handler) {
if (window.addEventListener) {
addEvent = function(element, type, handler) {
element.addEventListener(type, handler, false);
}
} else if (window.attachEvent){
addEvent = function(element, type, handler) {
element.attachEvent('on' + type, handler);
}
} else {
addEvent = function(element, type, handler) {
element['on' + type] = handler;
}
}
addEvent(type, element, fun);
}
這代碼看起來有點 low,但是它確實減少了重複的判斷。在這種方式下,函數第一次執行時才確定真正的值。
我們還可以利用 IIFE 提前確定函數真正的值。
var addEvent = (function() {
if (window.addEventListener) {
return function(element, type, handler) {
element.addEventListener(type, handler, false);
}
} else if (window.attachEvent){
return function(element, type, handler) {
element.attachEvent('on' + type, handler);
}
} else {
return function(element, type, handler) {
element['on' + type] = handler;
}
}
}())
高階函數
函數在 javascript 中是一等公民,函數可以作爲參數傳給其他函數,這讓函數的使用充滿了各種可能性。
不如來看看維基百科中高階函數(High-Order Function)的定義:
在數學和計算機科學中,高階函數是至少滿足下列一個條件的函數:
接受一個或多個函數作爲輸入
輸出一個函數
看到這,大家應該都意識到了,平時使用過很多高階函數。數組的一些高階函數使用得尤爲頻繁。
[1, 2, 3, 4].forEach(function(item, index, arr) {
console.log(item, index, arr)
})
[1, 2, 3, 4].map(item => `小老弟${item}`)
可以發現,傳入forEach
和map
的就是一個函數。我們自己也可以封裝一些複用的高階函數。
我們知道Math.max
可以求出參數列表中最大的值。然而很多時候,我們需要處理的數據並不是1, 2, 3, 4
這麼簡單,而是對象數組。
假設有這麼一個需求,存在一個數組,數組元素都是表示人的對象,我們想從數組中選出年紀最大的人。
這個時候,就需要一個高階函數來完成。
/**
* 根據求值條件判斷數組中最大的項
* @param {Array} arr 數組
* @param {String|Function} iteratee 返回一個求值表達式,可以根據對象屬性的值求出最大項,比如item.age。也可以通過自定義函數返回求值表達式。
*/function maxBy(arr, iteratee) {
let values = [];
if (typeof iteratee === 'string') {
values = arr.map(item => item[iteratee]);
} else if (typeof iteratee === 'function') {
values = arr.map((item, index) => {
return iteratee(item, index, arr);
});
}
const maxOne = Math.max(...values);
const maxIndex = values.findIndex(item => item === maxOne);
return arr[maxIndex];
}
利用這個高階函數,我們就可以求出數組中年紀最大的那個人。
var list = [
{name: '小明', age: 18},
{name: '小紅', age: 19},
{name: '小李', age: 20}
]// 根據age字段求出最大項,結果是小李。
var maxItem = maxBy(list, 'age');
我們甚至可以定義更復雜的求值規則,比如我們需要根據一個字符串類型的屬性來判定優先級。這個時候,就必須傳一個自定義的函數作爲參數了。
const list = [
{name: '小明', priority: 'middle'},
{name: '小紅', priority: 'low'},
{name: '小李', priority: 'high'}
]
const maxItem = maxBy(list, function(item) {
const { priority } = item
const priorityValue = priority === 'low' ? 1 : priority === 'middle' ? 2 : priority === 'high' ? 3 : 0
return priorityValue;
});
maxBy
接受的參數最終都應該能轉化爲一個Math.max
可度量的值,否則就沒有可比較性了。
要理解這樣的高階函數,我們可以認爲傳給高階函數的函數就是一箇中間件,它把數據預處理好了,然後再轉交給高階函數繼續運算。
PS:寫完這句總結,突然覺得挺有道理的,反手給自己一個贊!
柯里化
說柯里化之前,首先拋出一個疑問,如何實現一個add
函數,使得這個add
函數可以靈活調用和傳參,支持下面的調用示例呢?
add(1, 2, 3) // 6
add(1) // 1
add(1)(2) // 3
add(1, 2)(3) // 6
add(1)(2)(3) // 6
add(1)(2)(3)(4) // 10
要解答這樣的疑問,還是要先明白什麼是柯里化。
在計算機科學中,柯里化(Currying)是把接受多個參數的函數變換成接受一個單一參數 (最初函數的第一個參數) 的函數,並且返回接受餘下的參數且返回結果的新函數的技術。
這段解釋看着還是挺懵逼的,不如舉個例子:
本來有這麼一個求和函數dynamicAdd()
,接受任意個參數。
function dynamicAdd() {
return [...arguments].reduce((prev, curr) => {
return prev + curr
}, 0)
}
現在需要通過柯里化把它變成一個新的函數,這個新的函數預置了第一個參數,並且可以在調用時繼續傳入剩餘參數。
看到這,我覺得有點似曾相識,預置參數的特性與bind
很相像。那麼我們不如用bind
的思路來實現。
function curry(fn, firstArg) {
// 返回一個新函數
return function() {
// 新函數調用時會繼續傳參
var restArgs = [].slice.call(arguments)
// 參數合併,通過apply調用原函數
return fn.apply(this, [firstArg, ...restArgs])
}
}
接着我們通過一些例子來感受一下柯里化。
// 柯里化,預置參數10
var add10 = curry(dynamicAdd, 10)
add10(5); // 15
// 柯里化,預置參數20
var add20 = curry(dynamicAdd, 20);
add20(5); // 25
// 也可以對一個已經柯里化的函數add10繼續柯里化,此時預置參數10即可
var anotherAdd20 = curry(add10, 10);
anotherAdd20(5); // 25
可以發現,柯里化是在一個函數的基礎上進行變換,得到一個新的預置了參數的函數。最後在調用新函數時,實際上還是會調用柯里化前的原函數。
並且柯里化得到的新函數可以繼續被柯里化,這看起來有點像俄羅斯套娃的感覺。
實際使用時也會出現柯里化的變體,不侷限於只預置一個參數。
function curry(fn) {
// 保存預置參數
var presetArgs = [].slice.call(arguments, 1)
// 返回一個新函數
return function() {
// 新函數調用時會繼續傳參
var restArgs = [].slice.call(arguments)
// 參數合併,通過apply調用原函數
return fn.apply(this, [...presetArgs, ...restArgs])
}
}
其實Function.protoype.bind
就是一個柯里化的實現。不僅如此,很多流行的庫都大量使用了柯里化的思想。
實際應用中,被柯里化的原函數的參數可能是定長的,也可能是不定長的。
參數定長的柯里化
假設存在一個原函數fn
,fn
接受三個參數a
, b
, c
,那麼函數fn
最多被柯里化三次(有效地綁定參數算一次)。
function fn(a, b, c) {
return a + b + c
}
var c1 = curry(fn, 1);
var c2 = curry(c1, 2);
var c3 = curry(c2, 3);
c3(); // 6
// 再次柯里化也沒有意義,原函數只需要三個參數
var c4 = curry(c3, 4);
c4();
也就是說,我們可以通過柯里化緩存的參數數量,來判斷是否到達了執行時機。那麼我們就得到了一個柯里化的通用模式。
function curry(fn) {
// 獲取原函數的參數長度
const argLen = fn.length;
// 保存預置參數
const presetArgs = [].slice.call(arguments, 1)
// 返回一個新函數
return function() {
// 新函數調用時會繼續傳參
const restArgs = [].slice.call(arguments)
const allArgs = [...presetArgs, ...restArgs]
if (allArgs.length >= argLen) {
// 如果參數夠了,就執行原函數
return fn.apply(this, allArgs)
} else {
// 否則繼續柯里化
return curry.call(null, fn, ...allArgs)
}
}
}
這樣一來,我們的寫法就可以支持以下形式。
function fn(a, b, c) {
return a + b + c;
}
var curried = curry(fn);
curried(1, 2, 3); // 6
curried(1, 2)(3); // 6
curried(1)(2, 3); // 6
curried(1)(2)(3); // 6
curried(7)(8)(9); // 24
參數不定長的柯里化
解決了上面的問題,我們難免會問自己,假設原函數的參數不定長呢,這種情況如何柯里化?
首先,我們需要理解參數不定長是指函數聲明時不約定具體的參數,而在函數體中通過arguments
獲取實參,然後進行運算。就像下面這種。
function dynamicAdd() {
return [...arguments].reduce((prev, curr) => {
return prev + curr
}, 0)
}
回到最開始的問題,怎麼支持下面的所有調用形式?
add(1, 2, 3) // 6
add(1) // 1
add(1)(2) // 3
add(1, 2)(3) // 6
add(1)(2)(3) // 6
add(1)(2)(3)(4) // 10
思考了一陣,我發現在參數不定長的情況下,要同時支持1~N
次調用還是挺難的。add(1)
在一次調用後可以直接返回一個值,但它也可以作爲函數接着調用add(1)(2)
,甚至可以繼續add(1)(2)(3)
。那麼我們實現add
函數時,到底是返回一個函數,還是返回一個值呢?這讓人挺犯難的,我也不能預測這個函數將如何被調用啊。
而且我們可以拿上面的成果來驗證下:
curried(1)(2)(3)(4);
運行上面的代碼會報錯:Uncaught TypeError: curried(...)(...)(...) is not a function,因爲執行到curried(1)(2)(3)
,結果就不是一個函數了,而是一個值,一個值當然是不能作爲函數繼續執行的。
所以如果要支持參數不定長的場景,已經柯里化的函數在執行完畢時不能返回一個值,只能返回一個函數;同時要讓 JS 引擎在解析得到的這個結果時,能求出我們預期的值。
大家看了這個可能還是不懂,好,說人話!我們實現的curry
應該滿足:
- 經
curry
處理,得到一個新函數,這一點不變。
// curry是一個函數
var curried = curry(add);
- 新函數執行後仍然返回一個結果函數。
// curried10也是一個函數
var curried10 = curried(10);
var curried30 = curried10(20);
- 結果函數可以被 Javascript 引擎解析,得到一個預期的值。
curried10; // 10
好,關鍵點在於 3,如何讓 Javascript 引擎按我們的預期進行解析,這就回到 Javascript 基礎了。在解析一個函數的原始值時,會用到toString
。
我們知道,console.log(fn)
可以把函數 fn 的源碼輸出,如下所示:
console.log(fn)
ƒ fn(a, b, c) {
return a + b + c;
}
那麼我們只要重寫toString
,就可以巧妙地實現我們的需求了。
function curry(fn) {
// 保存預置參數
const presetArgs = [].slice.call(arguments, 1)
// 返回一個新函數
function curried () {
// 新函數調用時會繼續傳參
const restArgs = [].slice.call(arguments)
const allArgs = [...presetArgs, ...restArgs]
return curry.call(null, fn, ...allArgs)
}
// 重寫toString
curried.toString = function() {
return fn.apply(null, presetArgs)
}
return curried;
}
這樣一來,魔性的add
用法就都被支持了。
function dynamicAdd() {
return [...arguments].reduce((prev, curr) => {
return prev + curr
}, 0)
}
var add = curry(dynamicAdd);
add(1)(2)(3)(4) // 10
add(1, 2)(3, 4)(5, 6) // 21
至於爲什麼是重寫toString
,而不是重寫valueOf
,這裏留個懸念,大家可以想一想,也歡迎與我交流!
柯里化總結
柯里化是一種函數式編程思想,實際上在項目中可能用得少,或者說用得不深入,但是如果你掌握了這種思想,也許在未來的某個時間點,你會用得上!
大概來說,柯里化有如下特點:
-
簡潔代碼:柯里化應用在較複雜的場景中,有簡潔代碼,可讀性高的優點。
-
參數複用:公共的參數已經通過柯里化預置了。
-
延遲執行:柯里化時只是返回一個預置參數的新函數,並沒有立刻執行,實際上在滿足條件後纔會執行。
-
管道式流水線編程:利於使用函數組裝管道式的流水線工序,不污染原函數。
小結
本文是筆者回顧函數知識點時總結的一篇非常詳細的文章。在理解一些晦澀的知識模塊時,我加入了一些個人解讀,相信對於想要深究細節的朋友會有一些幫助。如果您覺得這篇文章有所幫助,請無情地關注點贊支持一下吧!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Q6B_gaoGD68tF8VrS4B3ug