點擊上方藍字「 大前端技術沙龍 」關注我javascript
您的關注意義重大前端
原創@大前端技術沙龍java
這段時間我試着經過思惟導圖來總結知識點,主要關注的是一些相對重要或理解難度較高的內容。下面是同系列文章:程序員
若是您須要換個角度看閉包,請直接打開解讀閉包,此次從ECMAScript詞法環境,執行上下文提及。算法
本文總結了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
,這裏留個懸念,你們能夠想想,也歡迎與我交流!
柯里化總結
柯里化是一種函數式編程思想,實際上在項目中可能用得少,或者說用得不深刻,可是若是你掌握了這種思想,也許在將來的某個時間點,你會用得上!
大概來講,柯里化有以下特色:
-
簡潔代碼:柯里化應用在較複雜的場景中,有簡潔代碼,可讀性高的優勢。
-
參數複用:公共的參數已經經過柯里化預置了。
-
延遲執行:柯里化時只是返回一個預置參數的新函數,並無馬上執行,實際上在知足條件後纔會執行。
-
管道式流水線編程:利於使用函數組裝管道式的流水線工序,不污染原函數。
小結
本文是筆者回顧函數知識點時總結的一篇很是詳細的文章。在理解一些晦澀的知識模塊時,我加入了一些我的解讀,相信對於想要深究細節的朋友會有一些幫助。若是您以爲這篇文章有所幫助,請無情地關注點贊支持一下吧!同時也歡迎加我微信laobaife
一塊兒交流學習。
❤️ 愛心三連
-
若是您以爲這篇文章質量還不錯,請滑動至下方,點擊在看、分享 、點贊 ,讓更多人看到優質的文章。
-
長按下方二維碼識別並關注公衆號,您的鼓勵意義重大。
-
進入公衆號,在對話框內回覆「加羣」,進入技術交流羣,一塊兒交流學習成長。
本文分享自微信公衆號 - 大前端技術沙龍(is_coder)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。