前言小序
一場失敗面試
2023年的某一天,一場讓我印象深刻的面試:
面試官:
“你了解閉包嗎?請說一下你對閉包的理解。”
我自信滿滿地答道:
“閉包就是函數里面套函數,里面的函數可以訪問外部函數的變量。”
面試官點點頭,繼續問:
“那你知道高階函數嗎?”
我依舊不慌不忙:
“高階函數嘛,就是能接收函數作為參數,或者返回一個函數的函數,在函數里執行回調的那種。”
當時我心里還美滋滋地想:這不都是我看過的八股文嘛,穩了!走出面試室,我仿佛已經收到了 offer,決定先去吃頓大餐犒勞自己。吃完飯回到家,手機一響,是面試來電。
電話那頭傳來溫柔而熟悉的聲音:
“很抱歉,您未能通過本次面試……盡管這次未能成功,我們仍希望您能找到更適合您的工作機會……祝您未來職業道路順利……吧啦吧啦吧啦……”
通話結束,我的心情也隨著“嘟——”的一聲沉入谷底,那一晚,我久久不能平靜,回想起這場看似順利的面試,卻以失敗告終,我突然意識到一個問題:我背得再標準,說得再流利,但那不是我真正理解的東西。于是,我下定決心:“一定要搞懂閉包到底是什么!”
面試后的覺醒
學習,啟動!
我翻開了《JavaScript設計模式與開發實踐》
,一頁頁啃,一遍遍讀,反復琢磨,不斷練習。雖然學得不算特別深入,但也算是從“八股文選手”邁向了“理解者”的第一步。現在,我想用我自己的話,重新講一遍我對閉包的理解:
“閉包是一個函數能夠訪問并記住它定義時所處的詞法環境,即使這個函數在其作用域外執行。它不僅能延長變量的生命周期,還能封裝私有狀態,是JavaScript 中非常強大且常用的一種機制。”
至于高階函數,我也有了更深的認識:
“高階函數并不只是‘傳函數進去然后調用’這么簡單。它的核心在于抽象行為 —— 你可以把邏輯像數據一樣傳遞,從而寫出更靈活、可復用、富有表現力的代碼。”
我希望下一次面試,我不再是那個只會背誦八股文的“偽高手”,而是能用自己的語言,準確表達出技術背后思想的那個“真正的我”。一雪前恥,就在下一場面試。加油!
閉包
在前端開發JavaScript
這門語言中,JavaScript
不僅是前端開發得力助手,還是一個多才多藝的小能手! 為什么這么說呢,因為javaScript
不僅能干面向對象編程的活,還掌握了一些函數式編程的小技巧——比如閉包。
閉包這東西,聽起來高深莫測,新手一聽到閉包就懵,面試官一問閉包就巴拉巴拉開始背八股文:“函數里面套函數,訪問外部變量…”,然后被pass掉😅!
經過反復學習研磨,我就對《JavaScript設計模式與開發實踐》
第一部分第三章進行個人理解與總結。如果覺得我的總結錯誤請"Coach Me !!"
哇哈哈哈哈。
變量作用域:誰的地盤聽誰的
在JavaScript
編程中,變量也是有"勢力范圍"
的,這個范圍叫做作用域,比如你在函數里定義了一個變量,那它就是一個宅男,只能在這個函數內部活動,外面是看不到他的。
function sayHi() {var name = '小明';console.log(name); // 小明出來打招呼了
}
sayHi();
console.log(name); // Uncaught ReferenceError: name is not defined
看,小明只在函數里活躍,一出函數門,世界就忘了他。但 JavaScript
的函數有個特點:它像個玻璃罩子,從內往外能看到外面的東西,但從外往內是看不到里面的。這就是所謂的作用域鏈。再比如:
<html><body><script>// 全局變量 a:爺爺,在客廳里大聲宣布:"我是1!"var a = 1;// func1 是爸爸,有自己的小秘密 b = 2var func1 = function () {var b = 2;console.log("爸爸說:我有秘密b =", b);// func2 是兒子,偷偷藏了一個寶貝 c = 3var func2 = function () {var c = 3;console.log("兒子說:我能看到爸爸的秘密b =", b); // 看得見爸爸console.log("兒子說:我也能看見爺爺a =", a); // 看得見爺爺};func2(); // 兒子執行完畢,把c藏回房間// 爸爸試圖查看兒子的玩具c...console.log("爸爸說:我想看看兒子的c =", c); // ? 找不到!ReferenceError!};func1(); // 全家開始聚會</script></body>
</html>
輸出結果
總結:JavaScript
中,函數可以用來創造作用域,在函數里面可以看到外面的變量,在函數外面則無法看到函數里面的變量,搜索過程會隨著代碼執行環境創建的作用域鏈往外層逐層搜索,一直搜索到全局對象為止,變量的搜索是從內到外而不是從外到內的。
變量的生命周期:該走了,不該走的還在
變量不僅有“地盤”,還有“壽命”,全局變量活得久,除非手動刪除,否則它一直活在,而函數里的局部變量呢?通常函數執行完,它們就“退休了”,但是有時候,局部變量就是“賴著不走”,比如:
var func = function () {var a = 1;return function () {a++;console.log(a);};};var f = func();f(); // 輸出:2f(); // 輸出:3f(); // 輸出:4f(); // 輸出:5
上面代碼中,a
明明是在函數里聲明的,按理說函數執完它就該"退休"了,但它偏偏沒有走,為啥? 因為有一個函數(也就是返回的哪個匿名函數)還”愛著它“,它被閉包捕獲了。于是這個變量不再只是一個普通的變量,而是變成了"有故事的變量”。局部變量的生命看起來被延續了!!!
利用閉包,可以完成很多有趣的工作,比如下面的經典案例:“假設頁面上有5個div節點,我們通過循環來給每個div綁定onclick實踐,按照索引順序,點擊第1個div時彈出0,點擊第2個div彈出1,以此類推”:
<html><body><div>1</div><div>2</div><div>3</div><div>4</div><div>5</div><script>var nodes = document.getElementsByTagName("div");for (var i = 0, len = nodes.length; i < len; i++) {nodes[i].onclick = function () {alert(i);};}</script></body>
</html>
欻欻欻,這段簡單的事件綁定就寫好了,但是當你運行的時候,你就會發現吳倫點擊哪個div,最后彈出的結果都是5,這是因為div節點的onclick事件被異步出發的,當事件觸發的時候,for循環早就結束了,此時變量i的值已經是5,所以在div的onclick事件函數中順著作用域鏈從內到外查找i的時候,查找到的值總是5。
上面的BUG
可以通過閉包去解決,加入閉包,把每次循環的i值都封閉起來,當事件函數中順著作用域鏈中從內到外查找i
變量的時候,會先找到被封閉在閉包環境中的i
,如果有5
個div
,這里的i就分別是0,1,2,3,4
<html><body><div>1</div><div>2</div><div>3</div><div>4</div><div>5</div><script>var nodes = document.getElementsByTagName("div");for (var i = 0, len = nodes.length; i < len; i++) {(function (i) {nodes[i].onclick = function () {alert(i);return;};})(i);}</script></body>
</html>
根據上面了解的閉包機制,可以寫一個通過閉包進行判斷數據類型的案例
<html><body><script>var Type = {};for (var i = 0, type; (type = ["String", "Array", "Number"][i++]); ) {(function (type) {Type["is" + type] = function (obj) {return (Object.prototype.toString.call(obj) === "[object " + type + "]");};})(type);}console.log(Type.isArray([])); // 輸出:trueconsole.log(Type.isString("str")); // 輸出:true</script></body>
</html>
用IIFE
把當前的 type
值保存下來,這樣每次循環都把當前的 type
傳進一個新的函數作用域中,確保每個生成的方法都能正確記住自己的 type
。運行完這段代碼后,Type
對象變成了:
Type = {isString: function(obj) {return Object.prototype.toString.call(obj) === '[object String]';},isArray: function(obj) {return Object.prototype.toString.call(obj) === '[object Array]';},isNumber: function(obj) {return Object.prototype.toString.call(obj) === '[object Number]';}
};
輸出:
閉包的更多作用:不只是談戀愛,還能干活!
1、封裝私有變量:你的變量我來守護
如果你想讓某些變量不被外界隨意修改,閉包就能幫你做到這一點,閉包可以把一些不需要暴露在全局的變量封裝成"私有變量"。假設有一個計算乘積的簡單函數:
var mult = function () {var a = 1;for (var i = 0, l = arguments.length; i < l; i++) {a = a * arguments[i];}return a;};console.log(mult(2, 3, 4, 5));
上面實現了Mult的乘積函數,你會發現,每一次都會清空a,重新計算,每一次計算都是浪費,但是我們可以借用閉包的機制實現一個通過閉包實現的緩存機機制提高這個函數的性能:
<html><body><script>var mult = (function () {var cache = {};return function () {var args = Array.prototype.join.call(arguments, ",");if (args in cache) {return cache[args];}console.log("我被執行了");var a = 1;for (var i = 0, l = arguments.length; i < l; i++) {a = a * arguments[i];}return (cache[args] = a);};})();console.log(mult(1, 2, 3, 4, 5));console.log(mult(1, 2, 3, 4));console.log(mult(1, 2, 3, 4, 5));console.log(mult(1, 2, 3, 4, 6));console.log(mult(1, 2, 3, 4, 6));console.log(mult(1, 2, 3, 4, 6));</script></body>
</html>
效果:
不難發現,我調用了六次mult
,但是乘積邏輯只運行了3
次,將每次的乘積結果都緩存到cache
中,每次先判斷是否有,如果有運行返回結果,如果沒有運行乘積邏輯。上面的乘積代碼還可以進行優化,將乘積邏輯獨立出來。
<html><body><script>var mult = (function () {var cache = {};var calculate = function () {console.log("我被執行了");// 封閉 calculate 函數var a = 1;for (var i = 0, l = arguments.length; i < l; i++) {a = a * arguments[i];}return a;};return function () {var args = Array.prototype.join.call(arguments, ",");if (args in cache) {return cache[args];}return (cache[args] = calculate.apply(null, arguments));};})();console.log(mult(1, 2, 3, 4, 5));console.log(mult(1, 2, 3, 4));console.log(mult(1, 2, 3, 4, 5));console.log(mult(1, 2, 3, 4, 6));console.log(mult(1, 2, 3, 4, 6));console.log(mult(1, 2, 3, 4, 6));</script></body>
</html>
2、延長變量的壽命:別著急銷毀,我還想用
在數據上報業務中,如果你以為直接在函數里面創建了一個img對象就完事了,瀏覽器可能還沒有把請求發送出去就把這個變量回收了,這個時候,我們可以用閉包吧img給鎖住,確保請求一定能發出去。有關前端埋點上報內容直通車>>>>
var report = function( src ){ var img = new Image(); img.src = src;
};
report( 'http://xxx.com/getUserInfo' );
上面代碼中實現了原始圖片上報的功能,但是在低版本的瀏覽器下使用report函數進行數據上報會數據丟失,也就是說,report函數并不是每次都發起了http請求,丟失數據原因是img是report函數中的局部變量,當report函數調用結束后,img局部變量就會被立即銷毀,而此時還沒來得及發出http請求就會導致數據丟失,接下來通過閉包將img變量封閉起來,就能解決了數據上報丟失的問題了:
var report = (function () {var imgs = [];return function (src) {var img = new Image();imgs.push(img);img.src = src;};
})();
report( 'http://xxx.com/getUserInfo' );
閉包就像是一個“拖延癥患者”,不讓變量輕易被銷毀。
閉包的面向對象設計:換湯不換藥
你說閉包厲害吧,其實它也能實現面向對象的一些功能,比如下面寫一個計數器:
<html><body><script>function createCounter() {var count = 0;return {increment: function () {count++;},get: function () {return count;},};}var counter = createCounter();counter.increment();console.log(counter.get()); // 1</script></body>
</html>
是不是很像一個簡單的類?只不過它是用閉包實現的,看到這里是不是覺得非常眼熟? 你想想,仔細想想哪里出現過這種結構的使用!我想你已經想到了,vue
的data
就是使用了類似于閉包的概念來封裝和保護組件內的數據,但是區別在于vue
的data
更多的結合了閉包,響應式系統以及其他高級特性的綜合方案。
用閉包實現命令模式:命令也能談戀愛
我雖然學到第一部分,還沒學到命令模式,但是經過大量的網頁搜索得出:是一種行為型設計模式,主要用于將請求封裝成對象,從而實現調用者和執行者之間的解耦,方便擴展和修改。命令模式的核心思想是將請求封裝成一個對象,使命令的發起者和執行者分離。
命令模式本來是面向對象中的一種經典設計模式,在js中可以通過閉包來實現他,比如書中封裝了一個打開電視
和關閉電視
的命令模式:
<html><body><script>var tv = {open: function () {console.log("打開電視機");},close: function () {console.log("關上電視機");},};function createCommand(receiver) {return {execute: function () {receiver.open();},undo: function () {receiver.close();},};}var command = createCommand(tv);command.execute(); // 打開電視機command.undo(); // 關上電視機</script></body>
</html>
閉包在這里的作用就是吧接收者tv
悄悄地保存起來,等需要的時候在進行調用。
閉包與內存管理:不是泄露,是你太執著
每次面試,問道閉包必會與“內存泄漏”
拿出來一塊說事,一聽到閉包就會想:“哎呀,會內存泄漏啊!” 其實太冤枉閉包了,閉包之所以讓變量“不死”,是因為你還需要他,這不是內存泄漏,而是你主動選擇了保留變量。
但是如果你使用不恰當,比如你在DOM
元素上面綁定了一個閉包,而這個閉包又引起了這個DOM
元素本身,那這就形成了循環調用,這時候才可能造成內存泄漏的問題。
如果想解決內存泄漏問題其實非常的簡單:只需要即使斷開引用,讓垃圾回收機制正常工作【myObject = null;】
。
一句話總結:閉包不是洪水猛獸,它只是一個有感情,有記憶,有責任的函數。
高階函數
什么是高階函數:你以為的函數 VS 真相
在 JavaScript 的世界里,函數不僅僅是個執行器,它是超級英雄。為什么?因為它可以:
上得了參數舞臺(函數當參數),下得了返回結果(函數當返回值)這類能文能武、能攻能守的函數,被稱為——高階函數。
高階函數作為參數傳遞:屬實打工人
我們可以把函數當作參數傳遞,抽離出一部分容易變化的業務邏輯,把這部分業務邏輯放在函數參數中,這樣一來分離業務代碼種變化與不變的部分。其中非常常見和重要的應用場景就是常見的回調函數。“函數也能做回調?這不是打工人嗎!”
回調函數
在以往Ajax
請求的使用中,回調非常非常頻繁,大名鼎鼎的“回調地獄”
就處至ajax
應用出現的問題,當我們想在ajax
請求返回之后做一些事情,但是又不知道請求返回的時間,最常見的方案就是把callBack
函數當作參數傳入發起ajax
請求的方法中,等待請求完成后執行callBack的
函數
var getUserInfo = function (userId, callback) {$.ajax("http://xxx.com/getUserInfo?" + userId, function (data) {if (typeof callback === "function") {callback(data);}});};getUserInfo(13157, function (data) {alert(data.userName);});
看著像正常 AJAX
,其實背地里 Callback
像極了“我寫好了函數,結果還得把邏輯交給別人決定”,屬實打工人無疑了。
典型應用:創建 100
個 div
,然后把這些div設置成隱藏,正常想法是將創建div
與隱藏div
的代碼硬編碼到appendDiv
的創建函數中,這顯然不合理更不夠靈活,更更難以服用,于是可以將隱藏代碼抽離出來,用回調的形式傳入appendDiv
方法:"哦~ 雅,實在太雅!!真的不要太雅!!!"
<html><body><script>var appendDiv = function (callback) {for (var i = 0; i < 100; i++) {var div = document.createElement("div");div.innerHTML = i;document.body.appendChild(div);if (typeof callback === "function") {callback(div);}}};appendDiv(function (node) {console.log("回調隱藏");node.style.display = "none";});</script></body>
</html>
Array.prototype.sort
Array.prototype.sort
接收一個函數當作參數,這個函數里面封裝了數組元素的排序規則,從Array.prototype.sort
這個API可以看淡,這個API不變的是對數組進行排序,而需要變是使用什么規則排序是可變的,把可變的部分封裝在函數里面,動態傳入,使Array.prototype.sort
方法成為了一個非常靈活好用的方法。
//從小到大排列
[ 1, 4, 3 ].sort( function( a, b ){ return a - b;
}); // 輸出: [ 1, 3, 4 ] //從大到小排列
[ 1, 4, 3 ].sort( function( a, b ){ return b - a;
}); // 輸出: [ 4, 3, 1 ]
函數作為返回值輸出:什么函數還能生孩子?
啥玩意? 函數還能生孩子? 哦哦哦,看錯了,原來是返回函數呀!相比把函數當作參數傳遞,函數當作返回值輸出的應用場景那可更多了,讓函數繼續生孩子傳宗接代,哦,不對,是讓函數繼續返回一個可執行函數,那不就代表著運算過程可以一直延續了嘛!!!
判斷數據類型
小白寫法:
<html><body><script>var isString = function (obj) {return Object.prototype.toString.call(obj) === "[object String]";};var isArray = function (obj) {return Object.prototype.toString.call(obj) === "[object Array]";};var isNumber = function (obj) {return Object.prototype.toString.call(obj) === "[object Number]";};console.log(isArray([])); // 輸出:trueconsole.log(isString("str")); // 輸出:trueconsole.log(isNumber(1)); // 輸出:true</script></body>
</html>
這種雖然也可以進行類型的判斷,但是不難發現,這些函數大部分實現都是相同的,不同的只是 Object.prototype.toString.call( obj )
返回的字符串。為了避免多余的代碼,我們嘗試把這些字符串作為參數提前值入 isType
,通過循環批量注冊isType函數,大佬寫法:
<html><body><script>var Type = {};for (var i = 0, type; (type = ["String", "Array", "Number"][i++]); ) {(function (type) {Type["is" + type] = function (obj) {return (Object.prototype.toString.call(obj) === "[object " + type + "]");};})(type);}Type.isArray([]); // 輸出:trueType.isString("str"); // 輸出:true</script></body>
</html>
單例模式getSingle
javaScript中的單例模式大概意思是一種確保類只有一個實例,并提供全局訪問點的方式。由于 JavaScript 的語言特性(如對象字面量、模塊系統等),實現單例有多種方式,接下來通過高階函數實現一個getSingle的單例模式:
<html><body><script>var getSingle = function (fn) {var ret;return function () {return ret || (ret = fn.apply(this, arguments));};};var getScript = getSingle(function () {return document.createElement("script");});var script1 = getScript();var script2 = getScript();console.log(script1 === script2); // 輸出:true</script></body>
</html>
上面代碼是通過閉包+高階函數實現了一個惰性單例模式,確保某個程序運行過程中最多執行一次,并且始終返回相同的實例。媽的,真特娘優雅!!!
高階函數AOP(面向切面編程):前后偷摸加戲AOP
所謂的AOP就是面向切面編程,就是原本安安靜靜寫個函數,突然前后各插一腳:
<html><body><script>Function.prototype.before = function (beforefn) {var __self = this; // 保存原函數的引用return function () {// 返回包含了原函數和新函數的"代理"函數beforefn.apply(this, arguments); // 執行新函數,修正 thisreturn __self.apply(this, arguments); // 執行原函數};};Function.prototype.after = function (afterfn) {var __self = this; // 保存原函數的引用return function () {var ret = __self.apply(this, arguments);afterfn.apply(this, arguments);return ret;};};var func = function () {console.log(2);};func = func.before(function () {console.log(1);}).after(function () {console.log(3);});func();</script></body>
</html>
上面函數把一個函數“動態織入”到另外一個函數中。
高階函數的其他應用
函數柯里化:我先不執行,等等再說
面試中,如雷貫耳的curring【函數柯里化】
又稱部分求值,一個curring
的函數首先會接收一些參數,接收這些參數之后,該函數并不會立即執行,而是繼續返回另外一個函數,剛傳入的參數在函數形成的閉包中被保存起來,等到函數被真正需要求值得時候,之前傳入的所有參數都會被一次性求值。
案例一、編寫一個計算每月開銷得函數,在每天結束之前,都要記錄今天花了多少錢;
沒接觸過函數柯里化之前:
var monthlyCost = 0;var cost = function (money) {monthlyCost += money;};cost(100); // 第 1 天開銷cost(200); // 第 2 天開銷cost(300); // 第 3 天開銷//cost( 700 ); // 第 30 天開銷console.log(monthlyCost); // 輸出:600
雖然通過上面代碼也能實現,但是我不想關心每天花多少錢,我只想在月底統一計算一次,如果每月的前29天,我們都只是保存好當天的開銷,直到30天才會進行求值計算,這樣就達到了要求,函數柯里化寫法如下:
<html><body><script>var cost = (function () {var args = [];return function () {if (arguments.length === 0) {var money = 0;for (var i = 0, l = args.length; i < l; i++) {money += args[i];}return money;} else {[].push.apply(args, arguments);}};})();cost(100); // 未真正求值cost(200); // 未真正求值cost(300); // 未真正求值cost(300); // 未真正求值console.log(cost()); // 求值并輸出:600</script></body>
</html>
上面還是不是一個標準的柯里化函數,接下來我們實現一個任意參數鏈式調用的標磚柯里化函數,這個函數將傳入的函數 fn
轉換為一個“延遲執行”
的柯里化版本。它會收集所有傳入的參數,直到你調用時不帶參數,才真正執行原函數。
解析:當調用cost()
的時候,如果明確帶上了參數,表示此時并不是真的在計算結果,而是將參數保存起來,此時讓const
函數返回另外一個函數,只有當不帶參數的形式執行cost()
時,才會計算結果,通過調用return arguments.callee;
,每調用一次 cost(...)
,它都會返回自己(即 arguments.callee
),所以可以繼續鏈式調用【cost(300, 300)(300)()】
。
<html><body><script>var currying = function (fn) {console.log("我被執行了");var args = [];return function () {if (arguments.length === 0) {return fn.apply(this, args);} else {[].push.apply(args, arguments);return arguments.callee;}};};var cost = (function () {var money = 0;return function () {console.log("我cost執行了", arguments);for (var i = 0, l = arguments.length; i < l; i++) {money += arguments[i];}return money;};})();var cost = currying(cost); // 轉化成 currying 函數cost(100); // 未真正求值cost(200); // 未真正求值cost(300)(50); // 未真正求值console.log(cost(300, 300)(300)()); // 未真正求值</script></body>
</html>
非函數柯里化:函數世界的開放平臺
uncurring
不像curring
那樣拆分參數,而是相反把一個原來只能為某個對象服務的方法變成都能使用的通用函數,比如數組對象得push
函數,其實push
不止只能為數組服務,也可以為其他對象服務只要你有和數組一樣的特性即可使用,你可以理解為“去標簽化”,js
本來就是鴨子語言,只要你會“嘎嘎叫”,你就是只鴨子,所以,不止數組才能調用push
,如:
(function() {Array.prototype.push.call(arguments, 4);console.log(arguments); // [1, 2, 3, 4]
})(1, 2, 3);
上面代碼中就是一個類數組對象,調用了專屬于push
得方法,但是上面每次都要寫.call(obj,...)
實在是太麻煩,就像你每次吃火鍋都要帶上鍋和調料,于是uncurring
就誕生了,類似于函數世界里的開放平臺
一樣。
uncurring
把一個方法從它的原生對象中解放出來,讓他為所有人都能使用,比如Array.prototype.push(obj,value)
,你會發現每次讓 obj
借用 Array.prototype.push
方法的功能都比較麻煩,但是你使用uncurring
后會變得非常清爽,直接push(obj,value)
即可,原先push
只為個人服務
,經過uncurring
的解放后,push
可以為人民服務
!
其實實現這個`uncurring也不復雜,看代碼:
Function.prototype.uncurrying = function () {var self = this;return function () {var obj = Array.prototype.shift.call(arguments); // 拿第一個參數當 thisreturn self.apply(obj, arguments); // 然后執行原方法};
};
在所有函數原型后面添加一個uncurring方法,先記錄下該方法的this,然后返回一個匿名函數,拿到第一個參數當作this,然后執行原方法,即可解放數組中所有的方法。
<html><body><script>Function.prototype.uncurrying = function () {var self = this;return function () {var obj = Array.prototype.shift.call(arguments); // 拿第一個參數當 thisreturn self.apply(obj, arguments); // 然后執行原方法};};var push = Array.prototype.push.uncurrying();var obj = { length: 0 };push(obj, "hello");push(obj, "hello1");push(obj, "hello2");push(obj, "hello3");push(obj, "hello4");console.log(obj); // { '0': 'hello', length: 1 }</script></body>
</html>
你想用我的方法?沒問題,請把你要代表的對象第一個傳進來,我來假裝它是 this
,如下運行結果
致敬—— 《JavaScript設計模式》· 曾探