編寫扁平化的代碼

原文:Writing flat & declarative code
作者:Peeke Kuepers

-- 給你的代碼增加一點點函數(shù)式編程的特性

最近我對函數(shù)式編程非常感興趣盼理。這個概念讓我著迷:應(yīng)用數(shù)學(xué)來增強(qiáng)抽象性和強(qiáng)制純粹性谈山,以避免副作用,并實現(xiàn)代碼的良好可復(fù)用性宏怔。同時奏路,函數(shù)式編程非常復(fù)雜抗蠢。

函數(shù)式編程有一個非常陡峭的學(xué)習(xí)曲線,因為它來源于數(shù)學(xué)中的范疇論思劳。接觸不久之后迅矛,就將遇到諸如組合(composition)、恒等(identity)潜叛,函子(functor)秽褒、單子(monad),以及逆變(contravariant)等術(shù)語威兜。我根本不太了解這些概念销斟,可能這也是我從來沒有在實踐中運用函數(shù)式編程的原因。

我開始思考:在常規(guī)的命令式編程和完全的函數(shù)式編程之間是否可能會有一些中間形式椒舵?既允許在代碼庫引入函數(shù)式編程的一些很好的特性蚂踊,同時暫時保留已有的舊代碼。

對我而言笔宿,函數(shù)式編程最大的作用就是強(qiáng)制你編寫聲明性代碼:代碼描述你做什么犁钟,而不是在描述如何做。這樣就可以輕松了解特定代碼塊的功能泼橘,而無需了解其真正的運行原理涝动。事實證明,編寫聲明式代碼是函數(shù)式編程中最簡單的部分之一炬灭。

循環(huán)

...一個循環(huán)就是一個命令式控制結(jié)構(gòu)醋粟,難以重用,并且難以插入到其他操作中重归。此外米愿,它還得不斷變化代碼來響應(yīng)新的迭代需求。

-- Luis Atencio

所以鼻吮,讓我們先看一下循環(huán)育苟,循環(huán)是命令式編程的一個很好的例子。循環(huán)涉及很多語法狈网,都是描述它們的行為是如何工作宙搬,而不是它們在做什么笨腥。例如拓哺,看看這段代碼:

function helloworld(arr) {
    for (let i = 1; i < arr.length; i++) {
        arr[i] *= 2
        if (arr[i] % 2 === 0) {
            doSomething(arr[i])
        }
    }
}

這段代碼在做什么呢?它將數(shù)組內(nèi)除第一個數(shù)字 (let i = 1)的其他所有數(shù)字乘以 2脖母,如果是偶數(shù)的話(if (arr % 2 === 0))士鸥,就進(jìn)行某些操作。在此過程中谆级,原始數(shù)組的值會被改變烤礁。但這通常是不必要的讼积,因為數(shù)組可能還會在代碼庫中的其他地方用到,所以剛才所做的改變可能會導(dǎo)致意外的結(jié)果脚仔。

但最主要的原因是勤众,這段代碼看起來很難一目了然。它是命令式的鲤脏,for 循環(huán)告訴我們?nèi)绾伪闅v數(shù)組们颜,在里面,使用一個 if 語句有條件地調(diào)用一個函數(shù)猎醇。

我們可以通過使用數(shù)組方法以聲明式的方式重寫這段代碼窥突。數(shù)組方法直接表達(dá)所做的事,比較常見的方法包括:forEach硫嘶,map阻问,filterreduceslice沦疾。

結(jié)果就像下面這樣:

function helloworld(arr) {
    const evenNumbers = n => n % 2 === 0

    arr
        .slice(1)
        .map(v => v * 2)
        .filter(evenNumbers)
        .forEach(v => doSomething(v))    
}

在這個例子中称近,我們使用一種很好的,扁平的鏈?zhǔn)浇Y(jié)構(gòu)去描述我們在做什么哮塞,明確表明意圖煌茬。此外,我們避免了改變原始數(shù)組彻桃,從而避免不必要的副作用坛善,因為大多數(shù)數(shù)組方法會返回一個新數(shù)組。當(dāng)箭頭函數(shù)開始變得越來越復(fù)雜時邻眷,可以地將其提取到一個特定的函數(shù)中眠屎,比如 evenNumbers,?從而盡量保持結(jié)構(gòu)簡單易讀肆饶。

在上面的例子改衩,鏈?zhǔn)秸{(diào)用并沒有返回值,而是以 forEach 結(jié)束驯镊。然而葫督,我們可以輕松地剝離最后一部分,并返回結(jié)果板惑,以便我們可以在其他地方處理它橄镜。如果還需要返回除數(shù)組以外的任何東西,可以使用 reduce 函數(shù)冯乘。

對于接下來的一個例子洽胶,假設(shè)我們有一組 JSON 數(shù)據(jù),其中包含在一個虛構(gòu)歌唱比賽中不同國家獲得的積分:

[
    {
        "country": "NL",
        "points": 12
    },
    {
        "country": "BE",
        "points": 3
    },
    {
        "country": "NL",
        "points": 0
    },
    ...
]

我們想計算荷蘭(NL)獲得的總積分裆馒,根據(jù)印象中其強(qiáng)大的音樂能力姊氓,我們可以認(rèn)為這是一個非常高的分?jǐn)?shù)丐怯,但我們想要更精確地確認(rèn)這一點。

使用循環(huán)可能會是這樣:

function countVotes(votes) {
    let score = 0;

    for (let i = 0; i < votes.length; i++) {
        if (votes[i].country === 'NL') {
            score += votes[i].points;
        }
    }

    return score;
}

使用數(shù)組方法重構(gòu)翔横,我們得到一個更干凈的代碼片段:

function countVotes(votes) {
    const sum = (a, b) => a + b;

    return votes
        .filter(vote => vote.country === 'NL')
        .map(vote => vote.points)
        .reduce(sum);
}

有時候 reduce 可能有點難以閱讀读跷,將 reduce 函數(shù)提取出來會在理解上有幫助。在上面的代碼片段中禾唁,我們定義了一個 sum 函數(shù)來描述函數(shù)的作用舔亭,因此方法鏈仍然保持很好的可讀性。

if else 語句

接下來蟀俊,我們來聊聊大家都很喜歡的 if else 語句钦铺,if else 語句也是命令式代碼里一個很好的例子。為了使我們的代碼更具聲明式肢预,我們將使用三元表達(dá)式矛洞。

一個三元表達(dá)式是 if else 語句的替代語法。以下兩個代碼塊具有相同的效果:

// Block 1
if (condition) {
    doThis();
} else {
    doThat();
}

// Block 2
const value = condition ? doThis() : doThat();

當(dāng)在定義(或返回)一個常量時烫映,三元表達(dá)式非常有用沼本。使用 if else 語句會將該變量的使用范圍限制在語句內(nèi),通過使用三元語句锭沟,我們可以避免這個問題:

if (condition) {
    const a = 'foo';
} else {
    const a = 'bar';
}

const b = condition ? 'foo' : 'bar';

console.log(a); // Uncaught ReferenceError: a is not defined
console.log(b); // 'bar'

現(xiàn)在抽兆,我們來看看如何應(yīng)用這一點來重構(gòu)一些更重要的代碼:

const box = element.getBoundingClientRect();

if (box.top - document.body.scrollTop > 0 && box.bottom - document.body.scrollTop < window.innerHeight) {
    reveal();
} else {
    hide();
}

那么,上面的代碼發(fā)生了什么呢族淮?if 語句檢查元素當(dāng)前是否在頁面的可見部分內(nèi)辫红,這個信息在代碼的任何地方都沒有表達(dá)出來∽@保基于此布爾值贴妻,再調(diào)用 reveal() 或者 hide() 函數(shù)。

將這個 if 語句轉(zhuǎn)換成三元表達(dá)式迫使我們將條件移動到它自己的變量中蝙斜。這樣我們可以將三元表達(dá)式組合在一行上名惩,現(xiàn)在通過變量的名稱來傳達(dá)布爾值表示的內(nèi)容,這樣還不錯孕荠。

const box = element.getBoundingClientRect();
const isInViewport = 
    box.top - document.body.scrollTop > 0 && 
    box.bottom - document.body.scrollTop < window.innerHeight;

isInViewport ? reveal() : hide();

通過這個例子娩鹉,重構(gòu)帶來的好處可能看起來不大。接下來會有一個相比更復(fù)雜的例子:

elements
    .forEach(element => {
        const box = element.getBoundingClientRect();

        if (box.top - document.body.scrollTop > 0 && box.bottom - document.body.scrollTop < window.innerHeight) {
            reveal();
        } else {
            hide();
        }

    });

這很不好稚伍,打破了我們優(yōu)雅的扁平的調(diào)用鏈弯予,從而使代碼更難讀。我們再次使用三元操作符槐瑞,而在使用它的時候熙涤,使用 isInViewport 檢查,并跟它自己的動態(tài)函數(shù)分開困檩。

const isInViewport = element => {
    const box = element.getBoundingClientRect();
    const topInViewport = box.top - document.body.scrollTop > 0;
    const bottomInViewport = box.bottom - document.body.scrollTop < window.innerHeight;
    return topInViewport && bottomInViewport;
};

elements
    .forEach(elem => isInViewport(elem) ? reveal() : hide());

此外祠挫,現(xiàn)在我們將 isInViewport 移動到一個獨立函數(shù),可以很容易地把它放在它自己的 helper 類/對象之內(nèi):

import { isInViewport } from 'helpers';

elements
    .forEach(elem => isInViewport(elem) ? reveal() : hide());

雖然上面的例子依賴于所處理的是數(shù)組悼沿,但是在不明確是在數(shù)組的情況下等舔,也可以采用這種編碼風(fēng)格。

例如糟趾,看看下面的函數(shù)慌植,它通過三條規(guī)則來驗證密碼的有效性。

import { passwordRegex as requiredChars } from 'regexes'
import { getJson } from 'helpers'

const validatePassword = async value => {
  if (value.length < 6) return false
  if (!requiredChars.test(value)) return false

  const forbidden = await getJson('/forbidden-passwords')
  if (forbidden.includes(value)) return false

  return value
}

validatePassword(someValue).then(persist)

如果我們使用數(shù)組包裝初始值义郑,就可以使用在上面的例子中里面所用到的所有數(shù)組方法蝶柿。此外,我們已經(jīng)將驗證函數(shù)打包成 validationRules 使其可重用非驮。

import { minLength, matchesRegex, notBlacklisted } from 'validationRules'
import { passwordRegex as requiredChars } from 'regexes'
import { getJson } from 'helpers'

const validatePassword = async value => {
  const result = Array.from(value)
    .filter(minLength(6))
    .filter(matchesRegex(requiredChars))
    .filter(await notBlacklisted('/forbidden-passwords'))
    .shift()

  if (result) return result
  throw new Error('something went wrong...')
}

validatePassword(someValue).then(persist)

目前在 JavaScript 中有一個 管道操作符 的提案交汤。使用這個操作符,就不用再把原始值換成數(shù)組了劫笙≤皆可以直接在前面的值調(diào)用管道操作符之后的函數(shù),有點像 Arraymap 功能填大。修改之后的代碼大概就像這樣:

import { minLength, matchesRegex, notBlacklisted } from 'validationRules'
import { passwordRegex as requiredChars } from 'regexes'
import { getJson } from 'helpers'

const validatePassword = async value =>
  value
    |> minLength(6)
    |> matchesRegex(requiredChars)
    |> await notBlacklisted('/forbidden-passwords')

try { someValue |> await validatePassword |> persist }
catch(e) {
  // handle specific error, thrown in validation rule
}

但需要注意的是戒洼,這仍然是一個非常早期的提案,不過可以稍微期待一下允华。

事件

最后圈浇,我們來看看事件處理。一直以來靴寂,事件處理很難以扁平化的方式編寫代碼汉额。可以 Promise 化來保持一種鏈?zhǔn)降恼ヌ溃馄交木幊田L(fēng)格蠕搜,但 Promise 只能 resolve 一次,而事件絕對會多次觸發(fā)收壕。

在下面的示例中妓灌,我們創(chuàng)建一個類,它對用戶的每個輸入值進(jìn)行檢索蜜宪,結(jié)果是一個自動補(bǔ)全的數(shù)組虫埂。首先檢查字符串是否長于給定的閾值長度。如果滿足條件圃验,將從服務(wù)器檢索自動補(bǔ)全的結(jié)果掉伏,并將其渲染成一系列標(biāo)簽。

注意代碼的不“純”,頻繁地使用 this 關(guān)鍵字斧散。幾乎每個函數(shù)都在訪問 this 這個關(guān)鍵字:

譯注:作者在這里使用 "this keyword"供常,有一種雙關(guān)的意味

import { apiCall } from 'helpers'

class AutoComplete {

  constructor (options) {

    this._endpoint = options.endpoint
    this._threshold = options.threshold
    this._inputElement = options.inputElement
    this._containerElement = options.list

    this._inputElement.addEventListener('input', () =>
      this._onInput())

  }

  _onInput () {

    const value = this._inputElement.value

    if (value > this._options.threshold) {
      this._updateList(value)
    }

  }

  _updateList (value) {

    apiCall(this._endpoint, { value })
      .then(items => this._render(items))
      .then(html => this._containerElement = html)

  }

  _render (items) {

    let html = ''

    items.forEach(item => {
      html += `<a href="${ item.href }">${ item.label }</a>`
    })

    return html

  }

}

通過使用 Observable,我們將用一種更好的方式對這段代碼進(jìn)行重寫鸡捐≌幌荆可以簡單將 Observable 理解成一個能夠多次 resolvePromise

Observable 類型可用于基于推送模型的數(shù)據(jù)源箍镜,如 DOM 事件源祈,定時器和套接字

Observable 提案目前處于 Stage-1。在下面 listen 函數(shù)的實現(xiàn)是從 GitHub 上的提案中直接復(fù)制的色迂,主要是將事件監(jiān)聽器轉(zhuǎn)換成 Observable香缺。可以看到歇僧,我們可以將整個 AutoComplete 類重寫為單個方法的函數(shù)鏈图张。

import { apiCall, listen } from 'helpers';
import { renderItems } from 'templates'; 

function AutoComplete ({ endpoint, threshold, input, container }) {

  listen(input, 'input')
    .map(e => e.target.value)
    .filter(value => value.length >= threshold)
    .forEach(value => apiCall(endpoint, { value }))
    .then(items => renderItems(items))
    .then(html => container.innerHTML = html)

}

由于大多數(shù) Observable 庫的實現(xiàn)過于龐大,我很期待 ES 原生的實現(xiàn)馏慨。map埂淮,filterforEach方法還不是規(guī)范的一部分,但是在 zen-observable 已經(jīng)在擴(kuò)展 API 實現(xiàn)写隶,而 zen-observable 本身是 ES Observables 的一種實現(xiàn) 倔撞。

--

我希望你會對這些“扁平化”模式感興趣。就個人而言慕趴,我很喜歡以這種方式重寫我的程序痪蝇。你接觸到的每一段代碼都可以更易讀。使用這種技術(shù)獲得的經(jīng)驗越多冕房,就越來越能認(rèn)識到這一點躏啰。記住這個簡單的法則:

The flatter the better!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市耙册,隨后出現(xiàn)的幾起案子给僵,更是在濱河造成了極大的恐慌,老刑警劉巖详拙,帶你破解...
    沈念sama閱讀 216,324評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件帝际,死亡現(xiàn)場離奇詭異,居然都是意外死亡饶辙,警方通過查閱死者的電腦和手機(jī)蹲诀,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,356評論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來弃揽,“玉大人脯爪,你說我怎么就攤上這事则北。” “怎么了痕慢?”我有些...
    開封第一講書人閱讀 162,328評論 0 353
  • 文/不壞的土叔 我叫張陵尚揣,是天一觀的道長。 經(jīng)常有香客問我守屉,道長惑艇,這世上最難降的妖魔是什么蒿辙? 我笑而不...
    開封第一講書人閱讀 58,147評論 1 292
  • 正文 為了忘掉前任拇泛,我火速辦了婚禮,結(jié)果婚禮上思灌,老公的妹妹穿的比我還像新娘俺叭。我一直安慰自己,他們只是感情好泰偿,可當(dāng)我...
    茶點故事閱讀 67,160評論 6 388
  • 文/花漫 我一把揭開白布熄守。 她就那樣靜靜地躺著,像睡著了一般耗跛。 火紅的嫁衣襯著肌膚如雪裕照。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,115評論 1 296
  • 那天调塌,我揣著相機(jī)與錄音晋南,去河邊找鬼。 笑死羔砾,一個胖子當(dāng)著我的面吹牛负间,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播姜凄,決...
    沈念sama閱讀 40,025評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼政溃,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了态秧?” 一聲冷哼從身側(cè)響起董虱,我...
    開封第一講書人閱讀 38,867評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎申鱼,沒想到半個月后愤诱,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,307評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡润讥,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,528評論 2 332
  • 正文 我和宋清朗相戀三年转锈,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片楚殿。...
    茶點故事閱讀 39,688評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡撮慨,死狀恐怖竿痰,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情砌溺,我是刑警寧澤影涉,帶...
    沈念sama閱讀 35,409評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站规伐,受9級特大地震影響蟹倾,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜猖闪,卻給世界環(huán)境...
    茶點故事閱讀 41,001評論 3 325
  • 文/蒙蒙 一鲜棠、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧培慌,春花似錦豁陆、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,657評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至馅而,卻和暖如春祥诽,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背瓮恭。 一陣腳步聲響...
    開封第一講書人閱讀 32,811評論 1 268
  • 我被黑心中介騙來泰國打工雄坪, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人偎血。 一個月前我還...
    沈念sama閱讀 47,685評論 2 368
  • 正文 我出身青樓诸衔,卻偏偏與公主長得像,于是被迫代替她去往敵國和親颇玷。 傳聞我的和親對象是個殘疾皇子笨农,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,573評論 2 353

推薦閱讀更多精彩內(nèi)容

  • 本篇文章介主要紹RxJava中操作符是以函數(shù)作為基本單位,與響應(yīng)式編程作為結(jié)合使用的帖渠,對什么是操作谒亦、操作符都有哪些...
    嘎啦果安卓獸閱讀 2,855評論 0 10
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,070評論 25 707
  • 阮小籍狞甚,作品見《讀者》锁摔、《散文》、《小說月報》哼审、《文學(xué)界》谐腰、《延河》孕豹、《青年作家》等! 素秋漸老 進(jìn)了一趟山里十气,一...
    阮小籍閱讀 666評論 0 3
  • 四川人有句諺語:冷水泡茶慢慢來励背。但實際上紅茶綠茶分成普洱茶鐵觀音西湖龍井大紅袍等等種類,對于泡茶的水溫要求都是不一...
    fengqihaiwai閱讀 232評論 0 0