在前一篇《這些JS設(shè)計(jì)模式中的基礎(chǔ)知識(shí)點(diǎn)你都會(huì)了嗎等舔?》中講到了原型、原型鏈、this指向草雕、call()涩嚣、apply()、bind()以及JS中如何實(shí)現(xiàn)繼承省核,前一篇是必備基礎(chǔ)知識(shí)稿辙,這篇文章將從閉包和高階函數(shù)中初探JavaScript模式。
JavaScript是一門完整的面向?qū)ο蟮木幊陶Z言气忠,JavaScript在設(shè)計(jì)之初參考并引入了Lambda表達(dá)式邻储、閉包和高階函數(shù)等特性赋咽。
而在JavaScript中的一些設(shè)計(jì)模式都依賴閉包和高階函數(shù)來實(shí)現(xiàn),因此非常有必要掌握閉包和高階函數(shù)的知識(shí)點(diǎn)吨娜。
一脓匿、閉包(Closure)
閉包的形成與變量的“作用域(scope
)”和“生命周期(lifecycle
)”相關(guān),所以先對(duì)這兩個(gè)概念有一個(gè)清晰的認(rèn)識(shí)宦赠。
1.1 變量的作用域
變量的作用域:變量的有效范圍陪毡。
如下示例:
var a = 0;
function func() {
var a = 1;
var b = 2;
console.log(a, b);
}
func(); // output: 1 2;
console.log(a); // output: 0
console.log(b); // output: Uncaught ReferenceError: b is not defined
可以看出在函數(shù)內(nèi)部聲明的變量是局部變量,只在函數(shù)體內(nèi)部執(zhí)行環(huán)境有效勾扭,在函數(shù)外部是無法訪問到的毡琉,并且JS執(zhí)行時(shí)候會(huì)拋出一個(gè)未定義的錯(cuò)誤。
當(dāng)在函數(shù)中聲明一個(gè)變量時(shí)妙色,沒有帶上關(guān)鍵詞 var
桅滋,這個(gè)變量就會(huì)變成全局變量,所以推薦大家編程時(shí)候規(guī)范編程(借助TypeScript
+Eslint
)身辨,變量的聲明盡可能都用 const
和 let
丐谋, 避免不必要的內(nèi)存占用。
在JavaScript中煌珊,函數(shù)可以用來創(chuàng)建函數(shù)作用域号俐,此時(shí)的函數(shù)體內(nèi)部的執(zhí)行環(huán)境可以訪問函數(shù)外部的變量,而外部卻無法訪問函數(shù)體內(nèi)部的變量怪瓶。如果函數(shù)內(nèi)部搜索某個(gè)變量時(shí)萧落,如果該變量不存在,那么就會(huì)在由內(nèi)到外的作用域鏈上尋找該變量是否在對(duì)應(yīng)的作用域上有聲明洗贰,有則返回該變量的值找岖,否則會(huì)返回“Uncaught ReferenceError: variable is not defined
”
這里大家可以試試在“腦內(nèi)運(yùn)行”下,以加深對(duì)“變量作用域”的理解:
var a = 1;
var func = function() {
var b = 2;
var func2 = function() {
var c = 3;
console.log(a);
console.log(b);
console.log(c);
}
func2();
console.log(c);
}
func();
最后的正確輸出:
1
2
3
Uncaught ReferenceError: c is not defined
1.2 變量的生命周期
如果說變量的作用域是一個(gè)規(guī)則敛滋,那么變量的生命周期就規(guī)則的施行者许布。
變量的生命周期:簡單理解為變量的有效時(shí)間,例如全局變量在程序執(zhí)行的整個(gè)過程中都有效绎晃,函數(shù)中的局部變量在函數(shù)執(zhí)行結(jié)束后被銷毀蜜唾。
function func() {
var a = 1; //函數(shù)執(zhí)行完成后將自動(dòng)銷毀
console.log(a)
}
func();
1.3 閉包改變局部變量的生命周期
首先看個(gè)示例:
function func() {
var a = 0; //函數(shù)執(zhí)行完成后將自動(dòng)銷毀
return function() {
a = a + 1;
console.log(a);
}
}
var f = func();
f(); // output: 1
f(); // output: 2
f(); // output: 3
f(); // output: 4
console.log(a); // output: Uncaught ReferenceError: a is not defined
函數(shù)執(zhí)行后的輸出結(jié)果看起來有些違背“變量的生命周期”規(guī)則,似乎局部變量a
并未被銷毀庶艾,并且在最后的 console.log(a)
代碼執(zhí)行時(shí)候報(bào)了變量 a
未定義袁余。那么變量 a
存儲(chǔ)在什么地方吶?
在執(zhí)行 var f = func();
的時(shí)候咱揍,f
返回了一個(gè)匿名函數(shù)的引用颖榜,它可以訪問到 func()
被調(diào)用時(shí)產(chǎn)生的環(huán)境,而局部變量 a
一直在這個(gè)環(huán)境中。局部變量 a
還能被外界訪問掩完,所以就有了不被銷毀的理由噪漾。在這里產(chǎn)生了一個(gè)閉包結(jié)構(gòu),局部變量的生命周期被延續(xù)了且蓬。
通過查看 f.prototype
中的 scopes
(作用域):
函數(shù) f
的作用域有兩個(gè)一個(gè)是全局的欣硼,另一個(gè)是 Closure
,在 Closure
中可以看到此時(shí)的變量 a
的值是 4
恶阴。也就是說诈胜,局部變量 a
,實(shí)際上是被存儲(chǔ)在一個(gè)閉包環(huán)境中冯事。
1.4 閉包的更多作用
“閉包”可以改變局部變量的生命周期耘斩,并且不更改局部變量的作用范圍,這一特性使得閉包的運(yùn)用非常廣泛桅咆。
1.4.1 緩存
例如我們要實(shí)現(xiàn)一個(gè)“乘積”函數(shù),乘法需要較大的計(jì)算資源坞笙,如果每次傳入?yún)?shù)都需要重新計(jì)算將是對(duì)計(jì)算資源的浪費(fèi)岩饼,那么就想到了緩存結(jié)果。
如果用一個(gè)全局變量來存儲(chǔ)結(jié)果薛夜,那么就有些“污染”全局變量籍茧,因?yàn)槌朔e僅用于在“乘積”函數(shù)內(nèi)部,我們還是希望能夠?qū)⒆兞拷档婉詈咸堇剑钥梢越柚]包來實(shí)現(xiàn)寞冯。
const multiplication = (function() {
const cache = {};
return function() {
const args = Array.prototype.join.call(arguments, ',');
if (args in cache) {
return cache[args];
}
let sum = 1;
for (let i = 0; i < arguments.length; i++) {
sum = sum * arguments[i];
}
return cache[args] = sum;
}
})();
multiplication(1,2,3,4);
如此我們?cè)谟?jì)算相同乘法時(shí)候就可以直接通過緩存返回乘積結(jié)果,從而節(jié)省計(jì)算資源晚伙,提高程序性能和穩(wěn)定性吮龄。
軟件開發(fā)講究一個(gè)“高內(nèi)聚,低耦合”咆疗,有些通用方法函數(shù)可以獨(dú)立出來漓帚,因此上面的代碼還可以再優(yōu)化。
const multiplication = (function() {
const cache = {};
const calculate = function() {
let sum = 1;
for (let i = 0; i < arguments.length; i++) {
sum = sum * arguments[i];
}
return sum;
}
return function() {
const args = Array.prototype.join.call(arguments, ',');
if (args in cache) {
return cache[args];
}
return cache[args] = calculate.apply(null, arguments);
}
})();
multiplication(1,2,3,4);
1.4.2 面向?qū)ο缶幊蹋?/h3>
/****************** 寫法1 *******************/
var Person = function() {
var age = 18;
return {
addAge: function() {
age++;
console.log('age:', age);
}
}
}
var person = Person();
person.addAge(); // output: age: 19
person.addAge(); // output: age: 20
person.addAge(); // output: age: 21
/****************** 寫法2 *******************/
var person = {
age: 18,
addAge: function() {
this.age = this.age + 1;
console.log('age:', this.age);
}
};
person.addAge(); // output: age: 19
person.addAge(); // output: age: 20
person.addAge(); // output: age: 21
/****************** 寫法3 *******************/
var Person = function() {
this.age = 18;
}
Person.prototype.addAge = function() {
this.age++;
console.log(this.age);
}
var person = new Person();
person.addAge(); // output: age: 19
person.addAge(); // output: age: 20
person.addAge(); // output: age: 21
/****************** 寫法1 *******************/
var Person = function() {
var age = 18;
return {
addAge: function() {
age++;
console.log('age:', age);
}
}
}
var person = Person();
person.addAge(); // output: age: 19
person.addAge(); // output: age: 20
person.addAge(); // output: age: 21
/****************** 寫法2 *******************/
var person = {
age: 18,
addAge: function() {
this.age = this.age + 1;
console.log('age:', this.age);
}
};
person.addAge(); // output: age: 19
person.addAge(); // output: age: 20
person.addAge(); // output: age: 21
/****************** 寫法3 *******************/
var Person = function() {
this.age = 18;
}
Person.prototype.addAge = function() {
this.age++;
console.log(this.age);
}
var person = new Person();
person.addAge(); // output: age: 19
person.addAge(); // output: age: 20
person.addAge(); // output: age: 21
閉包特性其實(shí)已經(jīng)在面向?qū)ο蟮木幊田L(fēng)格中得到了體現(xiàn)午磁。
1.5 閉包與內(nèi)存
在面試過程中經(jīng)常被面試官問到:“說說你對(duì)閉包的認(rèn)識(shí)尝抖?”
被面試者經(jīng)常回答道閉包可能會(huì)因?yàn)闆]有被及時(shí)銷毀導(dǎo)致內(nèi)存泄漏迅皇,需要盡量減少閉包的使用昧辽,以及主動(dòng)賦值null
及時(shí)釋放內(nèi)存。
因?yàn)閷⒕植孔兞糠诺饺肿兞科溆绊懚际情L期占用了內(nèi)存沒有釋放登颓,所以內(nèi)存泄漏的真正原因并不是因?yàn)槭褂瞄]包搅荞。而內(nèi)存泄漏的關(guān)鍵點(diǎn)在于使用了閉包容易形成“循環(huán)引用”,比如閉包的作用域鏈中保存著一些DOM節(jié)點(diǎn),循環(huán)引用的兩個(gè)對(duì)象都不會(huì)被基于“引用計(jì)數(shù)的垃圾回收機(jī)制”回收內(nèi)存取具。所以其根本原因是對(duì)象的“循環(huán)引用”導(dǎo)致的內(nèi)存泄漏脖隶。
二、高階函數(shù)(HOF)
高階函數(shù)(Higher-Order Function
)是至少滿足如下條件之一的函數(shù):
- 函數(shù)可以作為參數(shù)被傳遞
- 函數(shù)可以作為返回值輸出
在JavaScript中常見于回調(diào)函數(shù)則是作為了參數(shù)被傳遞暇检,閉包則是返回了函數(shù)
2.1 簡單示例
例如一個(gè)單例模式的例子产阱,既將函數(shù)作為參數(shù),也將函數(shù)作為返回值:
const getSingleBuider = function(fn) {
let instance;
return function() {
return instance || (instance = fn.apply(this, arguments));
}
}
2.2 高階函數(shù)與AOP
AOP(面向切面編程)的主要作用是把一些跟核心業(yè)務(wù)邏輯無關(guān)的功能抽離出來块仆,例如日志統(tǒng)計(jì)构蹬、異常處理、安全控制等悔据。將這些功能抽離后庄敛,再通過“動(dòng)態(tài)織入”的方式摻入業(yè)務(wù)邏輯模塊中。能夠保證業(yè)務(wù)邏輯模塊的高內(nèi)聚科汗,以及抽離的功能能夠很好的復(fù)用藻烤。
在JavaScript中實(shí)現(xiàn)AOP,一般是將一個(gè)函數(shù)“動(dòng)態(tài)織入”另一個(gè)函數(shù)內(nèi)头滔,那么就可以通過咱在前一篇基礎(chǔ)文章《這些JS設(shè)計(jì)模式中的基礎(chǔ)知識(shí)點(diǎn)你都會(huì)了嗎怖亭?》中講到的原型鏈來實(shí)現(xiàn)。
來看一個(gè)簡單的示例來更好理解高階函數(shù)以及AOP:
Function.prototype.before = function(beforeFn) {
var _self = this; // 存儲(chǔ)原函數(shù)的引用
// 返回原函數(shù)與新函數(shù)的“代理”函數(shù)
return function() {
beforeFn.apply(this, arguments); // 執(zhí)行新函數(shù)
return _self.apply(this, arguments); // 執(zhí)行原函數(shù)返回執(zhí)行結(jié)果
}
}
Function.prototype.after = function(afterFn) {
var _self = this;
return function() {
const result = _self.apply(this, arguments);
afterFn.apply(this, arguments);
return result;
}
}
let func = function() {
console.log('run');
}
func = func.before(function(){
console.log('berfore run');
}).after(function() {
console.log('after run');
});
func();
這樣我們就可以在完全不影響原函數(shù)原有邏輯的情況下給加入了新的中間件坤检,類似于Koa的“洋蔥模型”兴猩。
使用AOP來給函數(shù)動(dòng)態(tài)添加職責(zé)(功能),這與設(shè)計(jì)模式之一的“裝飾者模式”的思想一致早歇。
2.3 柯里化(Curring)
柯里化又稱“部分求值”倾芝,一個(gè)curring函數(shù)首先會(huì)接受一些參數(shù),接受了這些參數(shù)后箭跳,該函數(shù)不會(huì)立即求值晨另,而是繼續(xù)返回另一個(gè)函數(shù),剛才傳入的參數(shù)在函數(shù)的閉包環(huán)境中存儲(chǔ)起來衅码,待到函數(shù)真正需要被求值的時(shí)候拯刁,之前傳入的參數(shù)都會(huì)被一次性用于求值。
例如面試中會(huì)通過讓大家實(shí)現(xiàn)一個(gè)求和函數(shù)逝段,使用的方法如下:
sum(1)(2)(3); // output: 6
看到這個(gè)我們首先會(huì)想到用高階函數(shù)不斷返回函數(shù)垛玻,讓參數(shù)在閉包中存起來,也就是上述的柯里化奶躯,我們的第一版代碼可能長這樣:
function sum(a) {
return function(b) {
return function(c) {
return a + b + c;
}
}
}
sum(1)(2)(3); // output: 6
第一版的代碼看起來不太優(yōu)雅帚桩?如果想要 sum(1)(2)...(1000)
咋辦,不可能去寫一千遍return函數(shù)吧嘹黔,因此想到了遞歸账嚎。
遞歸的次數(shù)依賴于函數(shù)行參的長度莫瞬,所以再來一個(gè)通用的curring,我們實(shí)際上遞歸的是“兩數(shù)求和”這一行為郭蕉,思考也就是可以將函數(shù)柯里化疼邀,那么就可以鏈?zhǔn)浇邮軈?shù)執(zhí)行。
我們先針對(duì)兩數(shù)求和來實(shí)現(xiàn)柯里化
// 柯里化函數(shù)第一版
function curry(fn) {
// 將傳入的函數(shù)fn從實(shí)參數(shù)組中移除
const args = Array.prototype.slice.call(arguments, 1);
// 返回函數(shù)用于接受下一個(gè)參數(shù)
return function() {
// 將返回函數(shù)需要接受的下一次入?yún)⒈4娴絥ewArgs中召锈,slice淺拷貝
const newArgs = args.concat(Array.prototype.slice.call(arguments));
// 將newArgs參數(shù)放到被柯里化函數(shù)中執(zhí)行
return fn.apply(this, newArgs);
};
}
function add(a, b) {
return a + b;
}
const addCurry = curry(add, 1, 2);
addCurry() // 3
// 或者
const addCurry = curry(add, 1);
addCurry(2) // 3
// 或者
const addCurry = curry(add);
addCurry(1, 2) // 3
第一版代碼我們可以發(fā)現(xiàn)有一個(gè)確定就是沒有實(shí)現(xiàn)我們想要的鏈?zhǔn)筋愃朴?code>sum(1)(2)(3)這樣形式旁振,其實(shí)現(xiàn)思路就是將返回的函數(shù)也柯里化。
已聲明的函數(shù)涨岁,可以通過原型里length屬性獲取到函數(shù)行參的長度拐袜。
所以改造第二版:
const curry = function(fn) {
return function inner() {
// 淺拷貝入?yún)? const args = Array.prototype.slice.call(arguments);
// 如果下一個(gè)參數(shù)的長度大于了函數(shù)的行參個(gè)數(shù),則跳出遞歸
if (arguments.length >= fn.length) {
return fn.apply(undefined, args);
} else {
// 否則繼續(xù)處理后續(xù)參數(shù)梢薪,返回curring函數(shù)
return function() {
// 獲取合并上一次和下一次的入?yún)? const allArgs = args.concat(Array.prototype.slice.call(arguments));
return inner.apply(undefined, allArgs);
};
}
};
}
function sum(a, b, c) {
return a + b + c;
}
const currySum = curry(sum);
如果利用ES6蹬铺,那么可以有更簡潔的寫法:
const curry = fn =>
judge = (...args) =>
args.length >= fn.length
? fn(...args)
: arg => judge(...args, arg);
2.4 防抖(debounce)和節(jié)流(throttle)
一般我們都是將這兩個(gè)概念放在一起來講,兩者都是防止用戶頻繁觸發(fā)函數(shù)調(diào)用秉撇,只是兩者的處理策略不同甜攀,筆者總結(jié)了一句幫助大家記憶區(qū)分的口訣:
“防抖多次觸發(fā),最后一次生效琐馆;節(jié)流多次觸發(fā)赴邻,周期性生效”。
對(duì)于防抖節(jié)流的示例分析這里便不展開了啡捶,相信大家也在學(xué)習(xí)或工作中都已經(jīng)運(yùn)用過,例如lodash中的debounce和throttle奸焙,或者單獨(dú)的防抖或節(jié)流的三方庫瞎暑,對(duì)于這倆的認(rèn)知都已經(jīng)比較清晰。
推薦閱讀:《debounce(防抖)和throttle(節(jié)流)》
2.5 分時(shí)函數(shù)
分時(shí)函數(shù)是一個(gè)用于程序性能優(yōu)化上的一個(gè)運(yùn)用与帆,最近在做程序性能優(yōu)化的過程中接觸到了了赌,筆者覺得非常有必要一說。
一個(gè)常見的案例是大量DOM節(jié)點(diǎn)插入玄糟,那么就會(huì)導(dǎo)致頁面初始化load的時(shí)候非澄鹚卡頓(假死現(xiàn)象)
一次性插入:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分時(shí)函數(shù)</title>
</head>
<body>
<div>分時(shí)函數(shù)性能優(yōu)化驗(yàn)證</div>
<script>
// 一次性添加到頁面
const dataSource = new Array(10000).fill('DYBOY');
// 創(chuàng)建DOM
const createDiv = (text = 'DYBOY') => {
const div = document.createElement('div');
div.innerHTML = text;
document.body.appendChild(div);
}
// 批量添加
for (data of dataSource) {
createDiv(data);
}
</script>
</body>
</html>
分時(shí)函數(shù)的思想就是將一次性執(zhí)行大量重復(fù)操作時(shí),分批次時(shí)間周期的進(jìn)行阵翎,這樣就可以不阻塞頁面首屏的渲染,避免出現(xiàn)假死現(xiàn)象郭卫。
改造后的代碼如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分時(shí)函數(shù)</title>
</head>
<body>
<div>分時(shí)函數(shù)性能優(yōu)化驗(yàn)證</div>
<script>
// 一次性添加到頁面
const dataSource = new Array(10000).fill('DYBOY');
// 創(chuàng)建DOM
const createDiv = (text = 'DYBOY') => {
const div = document.createElement('div');
div.innerHTML = text;
document.body.appendChild(div);
}
// 批量添加
// for (data of dataSource) {
// createDiv(data);
// }
/**
* 分時(shí)函數(shù)
* @param dataSource - 數(shù)據(jù)數(shù)組
* @param fn - 分時(shí)執(zhí)行的函數(shù)
* @param count - 每分段時(shí)間內(nèi)執(zhí)行函數(shù)的次數(shù)
* @param duration - 分段時(shí)長砍聊,單位ms
**/
const timeChunk = (dataSource, fn, count = 1, duration = 200) => {
let timer;
const start = () => {
const minCount = Math.min(count, dataSource.length);
for(let i = 0; i < minCount; i++) fn(dataSource.shift());
}
return () => {
timer = setInterval(() => {
if (dataSource.length === 0) return clearInterval(timer);
start();
}, duration);
}
}
const newRender = timeChunk(dataSource, createDiv, 100, 300);
newRender();
</script>
</body>
</html>
通過對(duì)比可以看到后者經(jīng)過分時(shí)函數(shù)的首屏里scripting的時(shí)間只有425ms,前者是2410ms贰军,通過分時(shí)函數(shù)使得首屏性能提節(jié)省了500%的時(shí)間玻蝌,非常可觀。
除了分時(shí)函數(shù)俯树,性能優(yōu)化過程中帘腹,如果某個(gè)函數(shù)計(jì)算任務(wù)的時(shí)間非常長,那么就會(huì)導(dǎo)致“長時(shí)間頁面白屏”的現(xiàn)象许饿,這里我們可著手該長時(shí)間計(jì)算任務(wù)阳欲,看看該任務(wù)里有啥耗時(shí)的操作,看看針對(duì)耗時(shí)操作能不能做緩存米辐,時(shí)間切片胸完,以及宏任務(wù)微任務(wù)插隊(duì),在后續(xù)的文章中將整理并分享給大家翘贮。
2.6 惰性加載函數(shù)
后續(xù)將梳理專項(xiàng)的關(guān)于性能優(yōu)化方法赊窥,這里僅僅提一下概念,惰性加載屬于程序性能優(yōu)化中的一種方法狸页,其目的是使得函數(shù)的執(zhí)行分支僅發(fā)生一次锨能。
類似于我們將某個(gè)耗時(shí)操作的函數(shù)結(jié)果保存到一個(gè)變量中,而不是在每次for循環(huán)中都去重新執(zhí)行函數(shù)拿到計(jì)算結(jié)果芍耘。
惰性加載函數(shù)的方式有兩種:
- 在函數(shù)調(diào)用時(shí)處理:函數(shù)內(nèi)部復(fù)寫函數(shù)址遇,直接返回值;
- 在函數(shù)聲明時(shí)處理:函數(shù)聲明時(shí)斋竞,確定返回值倔约。
三、總結(jié)
這篇文章是承接前一篇《這些JS設(shè)計(jì)模式中的基礎(chǔ)知識(shí)點(diǎn)你都會(huì)了嗎坝初?》內(nèi)容浸剩,從Javascript中的this指向、原型鳄袍、原型鏈绢要、JS繼承實(shí)現(xiàn)到閉包(Closure)和高階函數(shù)(HOF),這些都是學(xué)習(xí)設(shè)計(jì)模式的必要基礎(chǔ)拗小,因?yàn)樵贘avaScript中的設(shè)計(jì)模式很多地方都需要依賴于閉包和高階函數(shù)來實(shí)現(xiàn)重罪,所以能夠掌握并熟練運(yùn)用閉包和高階函數(shù),有助于大家能夠快速理解并在JS中實(shí)現(xiàn)程序設(shè)計(jì)哀九。
對(duì)于設(shè)計(jì)模式和前端進(jìn)階的同學(xué)不妨關(guān)注微信公眾號(hào):DYBOY剿配,添加筆者微信,交流學(xué)習(xí)阅束,內(nèi)推大廠惨篱!