一個(gè)簡(jiǎn)單的JavaScript函數(shù)式編程教程
前言
4月初在北京的時(shí)候,徐昊同學(xué)表示我們公司的同事們寫的文章都太簡(jiǎn)單,太注重細(xì)節(jié),然后撿起了芝麻丟了西瓜,于是我就不再更新博客(其實(shí)根本原因是 項(xiàng)目太忙)。上周和其他幾個(gè)同事一起參加“Martin Fowler深圳行”的活動(dòng),我和同事扎西貢獻(xiàn)了一個(gè)《FullStack Language JavaScript》,一起的還有楊云(江湖人稱大魔頭)的話題是《掌握函數(shù)式編程,控制系統(tǒng)復(fù)雜度》,李新(江湖人稱新爺)的話題是《并發(fā):前生來 世》。
和其他同事預(yù)演的時(shí)候,突然發(fā)現(xiàn)其實(shí)我們的主題或多或少都有些關(guān)聯(lián),我講的部分也涉及到了基于事件的并發(fā)機(jī)制和函數(shù)式編程。仔細(xì)想想,應(yīng)該與JavaScript本身的特性不無關(guān)系:
-
基于事件(Event-Based)的Node.js的正是并發(fā)中很典型的一個(gè)模型
-
函數(shù)式編程使其天然支持回調(diào),從而非常適合異步/事件機(jī)制
-
函數(shù)式編程特性使其非常適合DSL的編寫
會(huì)后的第二天,我在項(xiàng)目代碼里忽然想要將一個(gè)聚合模型用函數(shù)式編程的方式重寫一下,結(jié)果發(fā)現(xiàn)思路竟然與NoSQL依稀有些聯(lián)系,進(jìn)一步發(fā)現(xiàn)自己很多不足。
下面這個(gè)例子來自于實(shí)際項(xiàng)目中的場(chǎng)景,不過Domain做了切換,但是絲毫不影響閱讀和理解背后的機(jī)制。
一個(gè)書簽應(yīng)用
設(shè)想有這樣一個(gè)應(yīng)用:用戶可以看到一個(gè)訂閱的RSS的列表。列表中的每一項(xiàng)(稱為一個(gè)Feed),包含一個(gè)id
,一個(gè)文章的標(biāo)題title
和一個(gè)文章的鏈接url
。
數(shù)據(jù)模型看起來是這樣的:
- var feeds = [
- {
- 'id': 1,
- 'url': 'http://abruzzi.github.com/2015/03/list-comprehension-in-python/',
- 'title': 'Python中的 list comprehension 以及 generator'
- },
- {
- 'id': 2,
- 'url': 'http://abruzzi.github.com/2015/03/build-monitor-script-based-on-inotify/',
- 'title': '使用inotify/fswatch構(gòu)建自動(dòng)監(jiān)控腳本'
- },
- {
- 'id': 3,
- 'url': 'http://abruzzi.github.com/2015/02/build-sample-application-by-using-underscore-and-jquery/',
- 'title': '使用underscore.js構(gòu)建前端應(yīng)用'
- }
- ];
當(dāng)這個(gè)簡(jiǎn)單應(yīng)用沒有任何用戶相關(guān)的信息時(shí),模型非常簡(jiǎn)單。但是很快,應(yīng)用需要從單機(jī)版擴(kuò)展到Web版,也就是說,我們引入了用戶的概念。每個(gè)用戶都能看到一個(gè)這樣的列表。另外,用戶還可以收藏Feed。當(dāng)然,收藏之后,用戶還可以查看收藏的Feed列表。
由于每個(gè)用戶可以收藏多個(gè)Feed,而每個(gè)Feed也可以被多個(gè)用戶收藏,因此它們之間的多對(duì)多關(guān)系如上圖所示??赡苣氵€會(huì)想到諸如
- $ curl http://localhost:9999/user/1/feeds
來獲取用戶1
的所有feed
等,但是這些都不重要,真正的問題是,當(dāng)你拿到了所有Feed之后,在UI上,需要為每個(gè)Feed填加一個(gè)屬性makred
。這個(gè)屬性用來標(biāo)示該feed是否已經(jīng)被收藏了。對(duì)應(yīng)到界面上,可能是一枚黃色的星星,或者一個(gè)紅色的心。
服務(wù)器端聚合
由于關(guān)系型數(shù)據(jù)庫的限制,你需要在服務(wù)器端做一次聚合,比如將feed對(duì)象包裝一下,生成一個(gè)FeedWrapper
之類的對(duì)象:
- public class FeedWrapper {
- private Feed feed;
- private boolean marked;
- public boolean isMarked() {
- return marked;
- }
- public void setMarked(boolean marked) {
- this.marked = marked;
- }
- public FeedWrapper(Feed feed, boolean marked) {
- this.feed = feed;
- this.marked = marked;
- }
- }
然后定義一個(gè)FeedService
之類的服務(wù)對(duì)象:
- public ArrayList<FeedWrapper> wrapFeed(List<Feed> markedFeeds, List<Feed> feeds) {
- return newArrayList(transform(feeds, new Function<Feed, FeedWrapper>() {
- @Override
- public FeedWrapper apply(Feed feed) {
- if (markedFeeds.contains(feed)) {
- return new FeedWrapper(feed, true);
- } else {
- return new FeedWrapper(feed, false);
- }
- }
- }));
- }
好吧,這也算是一個(gè)還湊合的實(shí)現(xiàn),但是靜態(tài)強(qiáng)類型的Java做這個(gè)事兒有點(diǎn)勉強(qiáng),而且一旦發(fā)生新的變化(幾乎肯定會(huì)發(fā)生),我們還是把這部分邏輯放在JavaScript中,來看看它是如何簡(jiǎn)化這一個(gè)過程的。
客戶端聚合
快要說到主題了,這篇文章我們會(huì)使用lodash
作為函數(shù)式編程的庫來簡(jiǎn)化代碼的編寫。由于JavaScript是一個(gè)動(dòng)態(tài)弱類型的語言,我們可以隨時(shí)為一個(gè)對(duì)象添加屬性,這樣一個(gè)簡(jiǎn)單的map
操作就可以完成上邊的Java對(duì)應(yīng)的代碼了:
- _.map(feeds, function(item) {
- return _.extend(item, {marked: isMarked(item.id)});
- });
- 其中函數(shù)isMarked會(huì)做這樣一件事兒:
- var userMarkedIds = [1, 2];
- function isMarked(id) {
- return _.includes(userMarkedIds, id);
- }
即查看傳入的參數(shù)是否在一個(gè)列表userMarkedIds
,這個(gè)列表可能由下列的請(qǐng)求來獲得:
$ curl http://localhost:9999/user/1/marked-feed-ids
之所有只獲取id是為了減少網(wǎng)絡(luò)傳輸?shù)臄?shù)據(jù)大小,當(dāng)然你也可以將全部的/marked-feeds
都請(qǐng)求到,然后在本地做_.pluck(feeds, 'id')
來抽取所有的id
屬性。
嗯,代碼是精簡(jiǎn)了許多。但是如果僅僅能做到這一步的話,也沒有多大的好處嘛?,F(xiàn)在需求又有了變化,我們需要在另一個(gè)頁面上展示當(dāng)前用戶的收藏夾(用以展示用戶所有收藏的feed)。作為程序員,我們可不愿意重新寫一套界面,如果能復(fù)用同一套邏輯當(dāng)然最好了。
比如對(duì)于上面這個(gè)列表,我們已經(jīng)有了對(duì)應(yīng)的模板:
- {{#each feeds}}
- <li class="list-item">
- <div class="section" data-feed-id="{{this.id}}">
- {{#if this.marked}}
- <span class="marked icon-favorite"></span>
- {{else}}
- <span class="unmarked icon-favorite"></span>
- {{/if}}
- <a href="/feeds/{{this.url}}">
- <div class="detail">
- <h3>{{this.title}}</h3>
- </div>
- </a>
- </div>
- </li>
- {{/each}}
事實(shí)上,這段代碼在收藏夾頁面上完全可以復(fù)用,我們只需要把所有的marked
屬性都設(shè)置為true就行了!簡(jiǎn)單,很快我們就可以寫出對(duì)應(yīng)的代碼:
- _.map(feeds, function(item) {
- return _.extend(item, {marked: true});
- });
漂亮!而且重要的是,它還可以如正常工作!但是作為程序員,你很快就發(fā)現(xiàn)了兩處代碼的相似性:
- _.map(feeds, function(item) {
- return _.extend(item, {marked: isMarked(item.id)});
- });
- _.map(feeds, function(item) {
- return _.extend(item, {marked: true});
- });
- 消除重復(fù)是一個(gè)有追求的程序員的基本素養(yǎng),不過要消除這兩處貌似有點(diǎn)困難:位于marked:后邊的,一個(gè)是函數(shù)調(diào)用,另一個(gè)是值!如果要簡(jiǎn)化,我們不得不做一個(gè)匿名函數(shù),然后以回調(diào)的方式來簡(jiǎn)化:
- function wrapFeeds(feeds, predicate) {
- return _.map(feeds, function(item) {
- return _.extend(item, {marked: predicate(item.id)});
- });
- }
對(duì)于feed列表,我們要調(diào)用:
wrapFeeds(feeds, isMarked);
而對(duì)于收藏夾,則需要傳入一個(gè)匿名函數(shù):
wrapFeeds(feeds, function(item) {return true});
在lodash
中,這樣的匿名函數(shù)可以用_.wrap
來簡(jiǎn)化:
wrapFeeds(feeds, _.wrap(true));
好了,目前來看,簡(jiǎn)化的還不錯(cuò),代碼縮減了,而且也好讀了一些(當(dāng)然前提是你已經(jīng)熟悉了函數(shù)式編程的讀法)。
更進(jìn)一步
如果仔細(xì)審視isMarked
函數(shù),會(huì)發(fā)現(xiàn)它對(duì)外部的依賴不是很漂亮(而且這個(gè)外部依賴是從網(wǎng)絡(luò)異步請(qǐng)求來的),也就是說,我們需要在請(qǐng)求到markedIds
的地方才能定義isMarked
函數(shù),這樣就把函數(shù)定義綁定
到了一個(gè)固定的地方,如果該函數(shù)的邏輯比較復(fù)雜,那么勢(shì)必會(huì)影響代碼的可維護(hù)性(或者更糟糕的是,多出維護(hù))。
要將這部分代碼隔離出去,我們需要將ids
作為參數(shù)傳遞出去,并得到一個(gè)可以當(dāng)做謂詞(判斷一個(gè)id是否在列表中的謂詞)的函數(shù)。
簡(jiǎn)而言之,我們需要:
- var predicate = createFunc(ids);
- wrapFeeds(feeds, predicate);
這里的createFunc
函數(shù)接受一個(gè)列表作為參數(shù),并返回了一個(gè)謂詞函數(shù)。而這個(gè)謂詞函數(shù)就是上邊說的isMarked
。這個(gè)神奇的過程被稱為柯里化currying
,或者偏函數(shù)partial
。在lodash
中,這個(gè)很容易實(shí)現(xiàn):
- function isMarkedIn(ids) {
- return _.partial(_.includes, ids);
- }
這個(gè)函數(shù)會(huì)將ids
保存起來,當(dāng)被調(diào)用時(shí),它會(huì)被展開為:_.includes(ids, <id>)
。只不過這個(gè)<id>
會(huì)在實(shí)際迭代的時(shí)候才傳入:
- $('/marked-feed-ids').done(function(ids) {
- var wrappedFeeds = wrapFeeds(feeds, isMarkedIn(ids));
- console.log(wrappedFeeds);
- });
這樣我們的代碼就被簡(jiǎn)化成了:
- $('/marked-feed-ids').done(function(ids) {
- var wrappedFeeds = wrapFeeds(feeds, isMarkedIn(ids));
- var markedFeeds = wrapFeeds(feeds, _.wrap(true));
- allFeedList.html(template({feeds: wrappedFeeds}));
- markedFeedList.html(template({feeds: markedFeeds}));
- });