編寫扁平化的代碼

原文:Writing flat & declarative code

作者:Peeke Kuepers

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

最近我對函數式編程非常感興趣。這個概念讓我著迷:應用數學來增強抽象性和強制純粹性,以避免副作用,並實現代碼的良好可復用性。同時,函數式編程非常複雜。

函數式編程有一個非常陡峭的學習曲線,因為它來源於數學中的範疇論。接觸不久之後,就將遇到諸如組合(composition)、恆等(identity),函子(functor)、單子(monad),以及逆變(contravariant)等術語。我根本不太了解這些概念,可能這也是我從來沒有在實踐中運用函數式編程的原因。

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

對我而言,函數式編程最大的作用就是強制你編寫聲明性代碼:代碼描述你做什麼,而不是在描述如何做。這樣就可以輕鬆了解特定代碼塊的功能,而無需了解其真正的運行原理。事實證明,編寫聲明式代碼是函數式編程中最簡單的部分之一。

循環

...一個循環就是一個命令式控制結構,難以重用,並且難以插入到其他操作中。此外,它還得不斷變化代碼來響應新的迭代需求。

-- Luis Atencio

所以,讓我們先看一下循環,循環是命令式編程的一個很好的例子。循環涉及很多語法,都是描述它們的行為是如何工作,而不是它們在做什麼。例如,看看這段代碼:

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

這段代碼在做什麼呢?它將數組內除第一個數字 (let i = 1)的其他所有數字乘以 2,如果是偶數的話(if (arr % 2 === 0)),就進行某些操作。在此過程中,原始數組的值會被改變。但這通常是不必要的,因為數組可能還會在代碼庫中的其他地方用到,所以剛才所做的改變可能會導致意外的結果。

但最主要的原因是,這段代碼看起來很難一目了然。它是命令式的,for 循環告訴我們如何遍曆數組,在裡面,使用一個 if 語句有條件地調用一個函數。

我們可以通過使用數組方法以聲明式的方式重寫這段代碼。數組方法直接表達所做的事,比較常見的方法包括:forEachmapfilterreduceslice

結果就像下面這樣:

function helloworld(arr) { const evenNumbers = n => n % 2 === 0 arr .slice(1) .map(v => v * 2) .filter(evenNumbers) .forEach(v => doSomething(v)) }

在這個例子中,我們使用一種很好的,扁平的鏈式結構去描述我們在做什麼,明確表明意圖。此外,我們避免了改變原始數組,從而避免不必要的副作用,因為大多數數組方法會返回一個新數組。當箭頭函數開始變得越來越複雜時,可以地將其提取到一個特定的函數中,比如 evenNumbers,?從而盡量保持結構簡單易讀。

在上面的例子,鏈式調用並沒有返回值,而是以 forEach 結束。然而,我們可以輕鬆地剝離最後一部分,並返回結果,以便我們可以在其他地方處理它。如果還需要返回除數組以外的任何東西,可以使用 reduce 函數。

對於接下來的一個例子,假設我們有一組 JSON 數據,其中包含在一個虛構歌唱比賽中不同國家獲得的積分:

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

我們想計算荷蘭(NL)獲得的總積分,根據印象中其強大的音樂能力,我們可以認為這是一個非常高的分數,但我們想要更精確地確認這一點。

使用循環可能會是這樣:

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;}

使用數組方法重構,我們得到一個更乾淨的代碼片段:

function countVotes(votes) { const sum = (a, b) => a + b; return votes .filter(vote => vote.country === "NL") .map(vote => vote.points) .reduce(sum);}

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

if else 語句

接下來,我們來聊聊大家都很喜歡的 if else 語句,if else 語句也是命令式代碼里一個很好的例子。為了使我們的代碼更具聲明式,我們將使用三元表達式。

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

// Block 1if (condition) { doThis();} else { doThat();}// Block 2const value = condition ? doThis() : doThat();

當在定義(或返回)一個常量時,三元表達式非常有用。使用 if else 語句會將該變數的使用範圍限制在語句內,通過使用三元語句,我們可以避免這個問題:

if (condition) { const a = "foo";} else { const a = "bar";}const b = condition ? "foo" : "bar";console.log(a); // Uncaught ReferenceError: a is not definedconsole.log(b); // "bar"

現在,我們來看看如何應用這一點來重構一些更重要的代碼:

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

那麼,上面的代碼發生了什麼呢?if 語句檢查元素當前是否在頁面的可見部分內,這個信息在代碼的任何地方都沒有表達出來。基於此布爾值,再調用 reveal() 或者 hide() 函數。

將這個 if 語句轉換成三元表達式迫使我們將條件移動到它自己的變數中。這樣我們可以將三元表達式組合在一行上,現在通過變數的名稱來傳達布爾值表示的內容,這樣還不錯。

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

通過這個例子,重構帶來的好處可能看起來不大。接下來會有一個相比更複雜的例子:

elements .forEach(element => { const box = element.getBoundingClientRect(); if (box.top - document.body.scrollTop > 0 && box.bottom - document.body.scrollTop < window.innerHeight) { reveal(); } else { hide(); } });

這很不好,打破了我們優雅的扁平的調用鏈,從而使代碼更難讀。我們再次使用三元操作符,而在使用它的時候,使用 isInViewport 檢查,並跟它自己的動態函數分開。

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());

此外,現在我們將 isInViewport 移動到一個獨立函數,可以很容易地把它放在它自己的 helper 類/對象之內:

import { isInViewport } from "helpers";elements .forEach(elem => isInViewport(elem) ? reveal() : hide());

雖然上面的例子依賴於所處理的是數組,但是在不明確是在數組的情況下,也可以採用這種編碼風格。

例如,看看下面的函數,它通過三條規則來驗證密碼的有效性。

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)

如果我們使用數組包裝初始值,就可以使用在上面的例子中裡面所用到的所有數組方法。此外,我們已經將驗證函數打包成 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 中有一個 管道操作符 的提案。使用這個操作符,就不用再把原始值換成數組了。可以直接在前面的值調用管道操作符之後的函數,有點像 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 化來保持一種鏈式的,扁平化的編程風格,但 Promise 只能 resolve 一次,而事件絕對會多次觸發。

在下面的示例中,我們創建一個類,它對用戶的每個輸入值進行檢索,結果是一個自動補全的數組。首先檢查字元串是否長於給定的閾值長度。如果滿足條件,將從伺服器檢索自動補全的結果,並將其渲染成一系列標籤。

注意代碼的不「純」,頻繁地使用 this 關鍵字。幾乎每個函數都在訪問 this 這個關鍵字:

譯註:作者在這裡使用 "this keyword",有一種雙關的意味

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,我們將用一種更好的方式對這段代碼進行重寫。可以簡單將 Observable 理解成一個能夠多次 resolvePromise

Observable 類型可用於基於推送模型的數據源,如 DOM 事件,定時器和套接字

Observable 提案目前處於 Stage-1。在下面 listen 函數的實現是從 GitHub 上的提案中直接複製的,主要是將事件監聽器轉換成 Observable。可以看到,我們可以將整個 AutoComplete 類重寫為單個方法的函數鏈。

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)}

由於大多數 Observable 庫的實現過於龐大,我很期待 ES 原生的實現。mapfilterforEach方法還不是規範的一部分,但是在 zen-observable 已經在擴展 API 實現,而 zen-observable 本身是 ES Observables 的一種實現 。

--

我希望你會對這些「扁平化」模式感興趣。就個人而言,我很喜歡以這種方式重寫我的程序。你接觸到的每一段代碼都可以更易讀。使用這種技術獲得的經驗越多,就越來越能認識到這一點。記住這個簡單的法則:

The flatter the better!

推薦閱讀:

前端工程師為什麼總分開div+css和js?
教你如何在Chrome下恢復老版本知乎界面

TAG:前端开发 | JavaScript | 编程 |