您好,登錄后才能下訂單哦!
本文首發于 vivo互聯網技術 微信公眾號?
鏈接:https://mp.weixin.qq.com/s/ZoXYbjuezOWgNyJKmSQmTw
作者:楊昆?【編寫高質量函數系列】,往期精彩內容:
《如何編寫高質量的 JS 函數(1) -- 敲山震虎篇》介紹了函數的執行機制,此篇將會從函數的命名、注釋和魯棒性方面,闡述如何通過 JavaScript 編寫高質量的函數。
?《如何編寫高質量的 JS 函數(2)-- 命名/注釋/魯棒篇》從函數的命名、注釋和魯棒性方面,闡述如何通過 JavaScript編寫高質量的函數。
《如何 編寫高質量的 JS 函數(3)-- 函數式編程[理論篇]》通過背景加提問的方式,對函數式編程的本質、目的、來龍去脈等方面進行一次清晰的闡述。
本文會從如何用函數式編程思想編寫高質量的函數、分析源碼里面的技巧,以及實際工作中如何編寫,來展示如何打通你的任督二脈。話不多說,下面就開始實戰吧。
這里我通過簡單的 demo 來說明一些技巧。技巧點如下:
這可能是一個硬編碼,不夠靈活性,你可能需要進行處理了,如何處理呢?比如通過傳參來干掉值類型的變量,下面舉一個簡單的例子。
代碼如下:
document.querySelector('#msg').innerHTML = '<h2>Hello World'</h2>'
我們來欣賞一下上面的代碼:
第一:硬編碼味道很重,代碼都是寫死的。
第二:擴展性很差,復用性很低,難道我要在其他地方進行 crtl c ctrl v 然后再手工改?
第三:如果在 document.querySelector('#msg')獲取對象后,不想 innerHTML ,我想做一些其他的事情,怎么辦?
OK ,下面我就先向大家展示一下,如何完全重構這段代碼。這里我只寫 JS 部分:
代碼如下:// 使用到了組合函數,運用了函數的高階性等
const compose = (...fns) => value => fns.reverse().reduce((acc, fn) => fn(acc), value)
const documentWrite = document.write.bind(document)
const createNode = function(text) {
return '<h2>' + text + '</h2>'
}
const setText = msg => msg
const printMessage = compose(
documentWrite,
createNode,
setText
)
printMessage('hi~ godkun')
效果如圖所示:
完整代碼我放在了下面兩個地址上,小伙伴可自行查看。
codepen:?codepen.io/godkun/pen/…
gist:gist.github.com/godkun/772c…
compose?函數的執行順序是從右向左,也就是數據流是從右向左流,可以把
const printMessage = compose(
documentWrite,
createNode,
setText
)
看成是下面這種形式:
documentWrite(createNode(setText(value)))
在 linux 世界里,是遵循 pipe (管道) 的思想,也就是數據從左向右流,那怎么把上面的代碼變成 pipe 的形式呢?
很簡單,只需要把 const compose = (...fns) => value => fns.reverse().reduce((acc, fn) => fn(acc), value)?中的 reverse 去掉就好了,寫成:
const compose = (...fns) => value => fns.reduce((acc, fn) => fn(acc), value)
是不是發現通過用函數式編程進行重構后,這個代碼變得非常的靈活,好處大致有如下:
函數被拆成了一個個具有單一功能的小函數
硬編碼被干掉了,變得更加靈活
使用了組合函數、高階函數來靈活的組合各個小函數
思考題:這里我甩貼一張小伙伴在群里分享的圖:
這是我送個大家的禮物,大家可以嘗試把上面圖片的代碼用函數式進行完全重構,加油。
代碼 demo 如下:
let arr = [1,3,2,4,5]
function fun(arr) {
let result = arr.sort()
console.log('result', result)
console.log('arr', arr)
}
fun(arr)
結果如下圖所示:
看上面,你會發現數組 arr 被修改了。由于 fun(arr)?函數中的參數 arr 是引用類型,如果函數體內對此引用所指的數據進行直接操作的話,就會有潛在的副作用,比如原數組被修改了,這種情況下,該怎么辦呢?
很簡單,在函數體內對 arr 這個引用類型進行創建副本。如下面代碼:
let arr = [1,3,2,4,5]
function fun(arr) {
let arrNew = arr.slice()
let result = arrNew.sort()
console.log('result', result)
console.log('arr', arr)
}
fun(arr)
通過 slice 來創建一個新的數組,然后對新的數組進行操作,這樣就達到了消除副作用的目的。這里只是舉一個例子,但是核心思想已經闡述出來了,體現了理論卷中的數據不可變的思想了。
如果函數體內引用變量的變化,會造成超出其作用域的影響,比如上面代碼中對 arr 進行操作,影響到了數組 arr 本身 。這時就需要思考一下,要不要采用不可變的思想,對引用類型進行處理。
注意函數里面有沒有大量的?for?循環
為什么說這個呢,因為這個很好判斷。如果有的話,就要思考一下需不需要對 for 循環進行處理,下文有對 for 循環的專門介紹。
注意函數里面有沒有過多的?if/else
也是一樣的思想,過多的 if/else 也要根據情況去做相應的處理。
標題的意識其實可以這樣理解,對函數進行高階化處理。當把函數當成參數的時候,也就是把代碼本身當成參數了。
什么情況下要考慮高階化呢。
當優化到一定地步后,發現還是不夠復用性,這時就要考慮將參數進行函數化,這樣將參數變成可以提供更多功能的函數。
函數的高階化,往往在其他功能上得以體現,比如柯里化,組合。
通過上面例子的分析,我也向大家展示了如何將函數最小化。通過將大函數拆成多個具有單一職責的小函數,來提高復用性和靈活性。
函數式編程?不是萬能的,大家不要認為它很完美,它也有自己的缺點,如下兩點:
進行?函數式編程?時, 如果使用不恰當,會造成性能問題。比如遞歸用的不恰當,比如柯里化嵌套的過多。
在進行函數式編程時,不要過度的抽象,過度的抽象會導致可讀性變差。
說到函數式編程,那一定要看看 Ramda.js 的源碼。Ramda.js 的源碼搞懂后,函數式編程的思想也就基本沒什么問題了。
關于 Ramda.js 可以看一下阮大的博客:
Ramda 函數庫參考教程
看完了,那開始執行:
git clone git@github.com:ramda/ramda.git
然后我們來分析源碼,首先按照常規套路,看一下 source/index.js 文件。
如圖所示:
繼續分析,看一下 add.js。
import _curry2 from './internal/_curry2';
var add = _curry2(function add(a, b) {
return Number(a) + Number(b);
});
export default add;
看上面代碼,我們發現,add 函數被包了一個?_curry2 函數。下劃線代表這是一個內部方法,不暴露成 API 。這時,再看其他函數,會發現都被包了一個?_curry1/2/3/N 函數。
如下圖所示:
從代碼中可以知道,1/2/3/N 代表掉參數個數為 1/2/3/N 的函數的柯里化,而且會發現,所有的 ramda 函數都是經過柯里化的。
為什么 ramda.js 要對函數全部柯里化?
我們看一下普通的函數 f(a, b, c)?。如果只在調用的時候,傳遞 a 。會發現,JS 在運行調用時,會將 b 和 c 設置為 undefined 。
從上面可以知道,JS 語言不能原生支持柯里化。非柯里化函數會導致缺少參數的實參變成 undefined 。ramda.js 對函數全部柯里化的目的,就是為了優化上面的場景。
下面,我們看一下?_curry2 代碼,這里為了可讀性,我對代碼進行了改造,我把?_isPlaceholder 去掉了,假設沒有占位符,同時把?_curry1 放在函數內,并且對過程進行了相應注釋。
二元參數的柯里化,代碼如下:
function _curry2(fn) {
return function f2(a, b) {
switch (arguments.length) {
case 0:
return f2;
case 1:
return _curry1(function (_b) {
// 將參數從右到左依次賦值 1 2
// 第一次執行時,是 fn(a, 1)
return fn(a, _b);
});
default:
// 參數長度是 2 時 直接進行計算
return fn(a, b);
}
};
}
function _curry1(fn) {
return function f1(a) {
// 對參數長度進行判斷
if (arguments.length === 0) {
return f1;
} else {
// 通過 apply 來返回函數 fn(a, 1)
return fn.apply(this, arguments);
}
};
}
const add = _curry2(function add(a, b) {
return Number(a) + Number(b);
});
// 第一次調用是 fn(a, 1)
let r1 = add(1)
// 第二次調用是 fn(2,1)
let r2 = r1(2)
console.log('sss', r2)
完整代碼地址如下:
gist:gist.github.com/godkun/0d22…
codeopen:codepen.io/godkun/pen/…
看了上面對 ramda.js 源碼中柯里化的分析,是不是有點收獲,就像上面說的,柯里化的目的是為了優化在 JS 原生下的一些函數場景。好處如下:
從上面 add 函數可以知道,通過柯里化,可以讓函數在真正需要計算的時候進行計算,起到了延遲的作用,也可以說體現了惰性思想。
柯里化命名的由來
本文一開始,我就以一個例子向大家展示了組合函數 compose 和 pipe 的用法。
關于 ramda 中,compose 和 pipe 的實現這里就不再分析了,小伙伴自己看著源碼分析一下。這里我就簡潔說一下組合函數的一些個人看法。
在我看來,組合是函數式編程的核心,函數式編程的思想是要函數盡可能的小,盡可能的保證職責單一。這就直接確定了組合函數在?函數式編程中的地位,玩好了組合函數,函數式編程?也就基本上路了。
和前端的組件進行對比來深刻的理解組合函數
函數的組合思想是面向過程的一種封裝,而前端的組件思想是面對對象的一種封裝。
故事的背景
實際工作中,會遇到下面這種接收和處理數據的場景。
代碼如下:
// 偽代碼
res => {
// name 是字符串,age 是數字
if (res.data && res.data.name && res.data.age) {
// TODO:
}
}
上面這樣寫,看起來好像也沒什么問題,但是經不起分析。比如 name 是數字,age 返回的不是數字。這樣的話, if 中的判斷是能通過的,但是實際結果并不是想要的。
那該怎么辦呢?問題不大,跟著我一步步的優化就 OK 了。
res => {
if (res.data && typeof res.data.name === 'string' && typeof res.data.age === 'number') {
// TODO:
}
}
看起來是夠魯棒了,但是這段代碼過于命令式,無法復用到其他地方,在其他的場景中,還要重寫一遍這些代碼。
//?is?是一個對象函數?偽代碼
res => {
if (is.object(res.data) && is.string(res.data.name) && is.number(res.data.age)) {
// TODO:
}
}
將過程抽象掉的行為也是一種函數式思想。上面代碼,提高了復用性,將判斷的過程抽象成了 is 的對象函數中,這樣在其他地方都可以復用這個 is 。
但是,代碼還是有問題,一般來說,各個接口的返回數據都是 res.data 這種類型的。所以如果按照上面的代碼,我們會發現,每次都要寫 is.object(res.data)?這是不能容忍的一件事。我們能不能做到不寫這個判斷呢?
當然可以,你完全可以在 is 里面加一層對 data 的判斷,當然這個需要你把 data 作為參數 傳給 is 。
//?is?是一個對象函數?偽代碼
res => {
if (is.string(res.data, data.name) && is.number(res.data, data.age)) {
// TODO:
}
}
按照上面的寫法,is 系列函數會對第一個參數進行 object 類型判斷,會再次提高復用性。
好像已經很不錯了,但其實還遠遠不夠。
有 if 語句存在,可能會有人說,if 語句存在有什么的啊。現在我來告訴你,這塊有 if 為什么不好。是因為 if 語句的?()?里面,最終的值都會表現成布爾值。所以這塊限制的很死,需要解決 if 語句的問題。
說完這些問題,那下面我們來解決吧。
如果要做到高度抽象和復用的話,首先把需要的功能羅列一下,大致如下:
第一個功能:檢查類型
第二個功能:調試功能,可以自定義 console 的輸出形式
第三個功能:處理異常的功能(簡單版)
看到上面功能后,我們想一下函數式思想中有哪些武器可以被我們使用到。首先怎么把不同的函數組合在一起。
現在,如何將小函數組合成一個完成特定功能的函數呢?
想一下,你會發現,這里需要用到函數的高階性,要將函數作為參數傳入多功能函數中。ok ,現在我們知道實現的大致方向了,下面我們來嘗試一下吧。
這里我直接把我的實現過程貼出來了,有相應的注釋,代碼如下:
/** * 多功能函數 * @param {Mixed} value 傳入的數據 * @param {Function} predicate 謂詞,用來進行斷言 * @param {Mixed} tip 默認值是 value */
function tap(value, predicate, tip = value) {
if(predicate(value)) {
log('log', `{type: ${typeof value}, value: ${value} }`, `額外信息:${tip}`)
}
}
const is = {
undef : v => v === null || v === undefined,
notUndef : v => v !== null && v !== undefined,
noString : f => typeof f !== 'string',
noFunc : f => typeof f !== 'function',
noNumber : n => typeof n !== 'number',
noArray : !Array.isArray,
};
function log(level, message, tip) {
console[level].call(console, message, tip)
}
const res1 = {data: {age: '', name: 'godkun'}}
const res2 = {data: {age: 66, name: 'godkun'}}
// 函數的組合,函數的高階
tap(res1.data.age, is.noNumber)
tap(res2.data.age, is.noNumber)
結果圖如下:
會發現當,age 不是 Number 類型的時候,就會打印對應的提示信息,當是 Number 類型的時候,就不會打印信息。
這樣的話,在業務中就可以直接寫:
res => {
tap(res.data.age, is.noNumber)
// TODO: 處理 age
}
不用 if 語句,如果有異常,看一下打印信息,會一目了然的。
當然這樣寫肯定不能放到生產上的,因為 tap 不會阻止后續操作,我這樣寫的原因是:這個 tap 函數主要是用來開發調試的。
但是,如果需要保證不符合的數據需要直接在 tap 處終止,那可以在 tap 函數里面加下 return false return true 。然后寫成下面代碼的形式:
res => {
// if 語句中的返回值是布爾值
if (tap(res.data.age, is.noNumber)) {
// TODO: 處理 age
}
}
但是這樣寫,會有個不好的地方。那就是用到了 if 語句,用 if 語句也沒什么不好的。但退一步看 tap 函數,你會發現,還是不夠復用,函數內,還存在硬編碼的行為。
如下圖所示:
存在兩點問題:
第一點:把 console 的行為固定死了,導致不能設置 console.error()?等行為。
第二點:不能拋出異常,就算類型不匹配,也阻止不了后續步驟的執行。
怎么解決呢?
簡單分析一下,這里先采用惰性的思想,讓一個函數確定好幾個參數,然后再讓這個函數去調用其他不固定的參數。這樣做的好處是減少了相同參數的多次 coding ,因為相同的參數已經內置了,不用再去傳了。
分析到這,你會發現,這樣的行為其實就是柯里化,通過將多元函數變成可以一元函數。同時,通過柯里化,可以靈活設置好初始化需要提前確定的參數,大大提高了函數的復用性和靈活性。
對于柯里化,由于源碼分析篇,我已經分析了 ramda 的柯里化實現原理,這里我為了節省代碼,就直接使用 ramda 了。
代碼如下:
const R = require('ramda')
// 其實這里你可以站在一個高層去把它們想象成函數的重載
// 通過傳參的不同來實現不同的功能
const tapThrow = R.curry(_tap)('throw', 'log')
const tapLog = R.curry(_tap)(null, 'log')
function _tap(stop, level, value, predicate, error=value) {
if(predicate(value)) {
if (stop === 'throw') {
log(`${level}`, 'uncaught at check', error)
throw new Error(error)
}
log(`${level}`, `{type: ${typeof value}, value: ${value} }`, `額外信息:${error}`)
}
}
const is = {
undef : v => v === null || v === undefined,
notUndef : v => v !== null && v !== undefined,
noString : f => typeof f !== 'string',
noFunc : f => typeof f !== 'function',
noNumber : n => typeof n !== 'number',
noArray : !Array.isArray,
};
function log(level, message, error) {
console[level].call(console, message, error)
}
const res = {data: {age: '66', name: 'godkun'}}
function main() {
// 不開啟異常忽略,使用 console.log 的 tapLog 函數
// tapLog(res.data.age, is.noNumber)
// 開啟異常忽略,使用 console.log 的 tapThrow 函數
tapThrow(res.data.age, is.noNumber)
console.log('能不能走到這')
}
main()
代碼地址如下:
gist:?gist.github.com/godkun/d394…
關鍵注釋,我已經在代碼中標注了。上面代碼在第一次進行函數式優化的時候,在組合和高階的基礎上,加入了柯里化,從而讓函數變得更有復用性。
PS: 具有柯里化的函數,在我看來,也是體現了函數的重載性。
執行結果如下圖所示:
會發現使用 tapThrow 函數時,當類型不匹配的時候,會阻止后續步驟的執行。
我通過多次優化,向大家展示了,如何一步步的去優化一個函數。從開始的命令式優化,到后面的函數式優化,從開始的普通函數,到后面的逐步使用了高階、組合、柯里的特性。從開始的有 if/else 語句到后面的逐步干掉它,來獲得更高的復用性。通過這個實戰,大家可以知道,如何循序漸進的使用函數式編程,讓代碼變得更加優秀。
之前就有各種干掉 for 循環的文章。各種討論,這里按照我的看法來解釋一下,為什么會存在干掉 for 循環這一說。
代碼如下:
let arr = [1,2,3,4]
for (let i = 0; i < arr.length; i++) {
// TODO: ...
}
我們看上面這段代碼,我來問一個問題:上面這段代碼如何復用到其他的函數中?
稍微想一下,大家肯定可以很快的想出來,那就是封裝成函數,然后在其他函數中進行調用。
因為 for 循環是一種命令控制結構,它很難被插入到其他操作中,也發現了 for 循環很難被復用的現實。
當你在封裝 for 循環時,就是在抽象 for 循環,把它隱藏掉。就是在告訴用戶,你只需要調封裝的函數,而不需要關心內部實現。
于是乎,JS 就誕生了諸如 map filter reduce 等這種將循環過程隱藏掉的函數。底層本質上還是用 for 實現的,只不過是把 for 循環隱藏了,如果按照業界內的說話逼格,就是把 for 循環干掉了。這就是聲明式編程在前端中的應用之一。
三種方式:
第一種:傳統的循環結構 - 比如 for 循環
第二種:鏈式
第三種:函數式組合
在編寫函數時,要考慮緩存是為了避免計算重復值。計算就意味著消耗各種資源,而做重復的計算,就是在浪費各種資源。
純潔性和緩存有什么關系?我們想一下可以知道,純函數總是為給定的輸入返回相同的輸出,那既然如此,我們當然要想到可以緩存函數的輸出。
那如何做函數的緩存呢?記住一句話:給計算結果賦予唯一的鍵值并持久化到緩存中。
大致 demo 代碼:
function mian(key) {
let cache = {}
cache.hasOwnProperty(key) ?
main(key) :
cache[key] = main(key)
}
上面代碼是一種最簡單的利用純函數來做緩存的例子。下面實現一個非常完美的緩存函數。
給原生?JS?函數加上自動記憶化的緩存機制
代碼如下:
Function.prototype.memorized = () => {
let key = JSON.stringify(arguments)
// 緩存實現
this._cache = this._cache || {}
this._cache[key] = this._cache[key] || this.apply(this, arguments)
return this._cache[key]
}
Function.prototype.memorize = () => {
let fn = this
// 只記憶一元函數
if (fn.length === 0 || fn.length > 1) return fn
return () => fn.memorized.apply(fn, arguments)
}
代碼地址如下:
gist:?gist.github.com/godkun/5251…
通過擴展 Function 對象,我們就可以充分利用函數的記憶化來實現函數的緩存。
上面函數緩存實現的好處有以下兩點:
第一:消除了可能存在的全局共享的緩存
第二:將緩存機制抽象到了函數的內部,使其完全與測試無關,只需要關系函數的行為即可
實戰部分,我沒有提到函子知識,不代表我沒有實踐過,正是因為我實踐過,才決定不提它,因為對于前端來說,有時候你要顧及整個團隊的技術,組合和柯里還有高階函數等還是可以很好的滿足基本需求的。
小伙伴們看實戰篇的時候,一定要結合理論篇一起看,這樣才能無縫連接。
圖解 Monad
monad wiki
What is a monad?-stackoverflow
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。