寫在前面的
在開始正文之前我想談些與此文相關性很低的話題。對這部分不感興趣的讀者可直接跳過。
在我發表上一篇文章或許我們在 JavaScript 中不需要 this 和 class之後,看到一種評論比較有代表性。此評論認為我們應該以 MDN 文件為指南。MDN 推薦的寫法,應當是無需質疑的寫法。
我不這麼看。
MDN 文件不是用來學習的,它是你在不確定某個具體語法時的參考指南。它是 JavaScript 使用說明書。說明書一般都不帶主觀思辨,它無法提供指引。就比如你光是把市面上的所有調味料買來,看它們的說明書,你還是學不會怎麼做菜的……
很碰巧我看的比較多的一些 JS 教程,都比較主觀,與 MDN 有很多偏差。比如 Kyle Simpson 認為 JS 裡面根本沒有繼承,提供 new 操作符以及 class 語法糖是在誤導開發者。JS 原型鏈的正確用法應該是代理而不是繼承。(我同意他)
更明顯的例子是 Douglas Crockford,他認為 JS 中處理非同步程式設計的主流方案—— callback hell, promise, async/await 全都錯了。你在看他的論述之前有十足把握斷定他在胡說嗎?他在 How JavaScript Works 裡面論述了他對事件程式設計(Eventual Programming)的看法,並寫了個完整的庫,提供他的解決方案。
批判和辯證地看問題,我們才能進步。
引言
我之前有兩篇文章寫過 JS 裡面惰性求值的實現,但都是淺嘗輒止,沒有過橫向擴充套件的打算。相關工作已經有人做了(如 lazy.js),我再做意義就不大了。這周在 GitHub 上看到有人寫了個類似的庫,用原生 Generator/Iterator 實現,人氣還很高。我一看還是有人在寫,我也試試吧。然後我就用 Douglas Crockford 倡導的一種程式設計風格去寫這個庫,想驗證下這種寫法是否可行。
Crockford 倡導的寫法是,不用 this 和原型鏈,不用 ES6 Generator/Iterator,不用箭頭函式…… 資料封裝則用工廠函式來實現。
Douglas Functions
首先,如果不用 ES6 Generator 的話,我們得自己實現一個 Generator,這個比較簡單:
function getGeneratorFromList(list) {
let index = 0;
return function next() {
if (index < list.length) {
const result = list[index];
index += 1;
return result;
}
};
}
// 例子:
const next = getGeneratorFromList([1, 2, 3]);
next(); // 1
next(); // 2
next(); // 3
next(); // undefined
複製程式碼
ES6 給陣列提供了 [Symbol.Iterator] 屬性,給資料賦予了行為,很方便我們進行惰性求值操作。而拋棄了 ES6 提供的這個便利之後,我們就只有手動將資料轉換成行為了。來看看怎麼做:
function Sequence(list) {
const iterable = Array.isArray(list)
? { next: getGeneratorFromList(list) }
: list;
}
複製程式碼
如果給 Sequence 傳入原生陣列的話,它會將陣列傳給 getGeneratorFromList
,生成一個 Generator,這樣就完成了資料到行為的轉換
最核心的這兩個功能寫完之後,我們來實現一個 map
:
function createMapIterable(mapping, { next }) {
function map() {
const value = next();
if (value !== undefined) {
return mapping(value);
}
}
return { next: map };
}
function Sequence(list) {
const iterable = Array.isArray(list)
? { next: getGeneratorFromList(list) }
: list;
function map(mapping) {
return Sequence(createMapIterable(mapping, iterable));
}
return {
map,
};
}
複製程式碼
map
寫完後,我們還需要一個函式幫我們把行為轉換回資料:
function toList(next) {
const arr = [];
let value = next();
while (value !== undefined) {
arr.push(value);
value = next();
}
return arr;
}
複製程式碼
然後我們就有一個半完整的惰性求值的庫了,雖然現在它只能 map
:
function Sequence(list) {
const iterable = Array.isArray(list)
? { next: getGeneratorFromList(list) }
: list;
function map(mapping) {
return Sequence(createMapIterable(mapping, iterable));
}
return {
map,
toList: () => toList(iterable.next);
};
}
// 例子:
const double = x => x * 2 // 箭頭函式這樣用是沒問題的啊啊啊,破個例吧
Sequence([1, 3, 6])
.map(double)
.toList() // [2,6,12]
複製程式碼
再給 Sequence 加個 filter
方法就差不多完整了,其它方法再擴充套件很簡單了。
function createFilterIterable(predicate, { next }) {
function filter() {
const value = next();
if (value !== undefined) {
if (predicate(value)) {
return value;
}
return filter();
}
}
return {next: filter};
}
function Sequence(list) {
const iterable = Array.isArray(list)
? { next: getGeneratorFromList(list) }
: list;
function map(mapping) {
return Sequence(createMapIterable(mapping, iterable));
}
function filter(predicate) {
return Sequence(createFilterIterable(predicate, iterable));
}
return {
map,
filter,
toList: () => toList(iterable.next);
};
}
// 例子:
Sequence([1, 2, 3])
.map(triple)
.filter(isEven)
.toList() // [6]
複製程式碼
看樣子接著上面的例子繼續擴充套件就沒問題了。
問題
我繼續寫了十幾個函式,如 take, takeWhile, concat, zip 等。直到寫到我不知道接著寫哪些了,然後我去參考了下 lazy.js 的 API,一看倒吸一口涼氣。lazy.js 快 200 個 API 吧(沒數過,目測),寫完程式碼還要寫文件。我實在不想這麼折騰了。更嚴重的問題不在於工作量,而是這麼龐大的 API 數量讓我意識到我這種寫法的問題。
在使用工廠函式實現鏈式呼叫的時候,每次呼叫都返回了一個新的物件,這個新物件包含了所有的 API。假設有 200 個 API,每次呼叫都是隻取了其中一個,剩下 199 個全扔掉了…… 記憶體再夠用也不能這麼玩吧。我有強迫症,受不了這種浪費。
結論就是,如果想實現鏈式呼叫,還是用原型鏈實現比較好。
然而鏈式呼叫本身就沒問題了嗎?雖然用原型鏈實現的鏈式呼叫能省去後續呼叫的物件建立,但是在初始化的時候也無可避免浪費記憶體。比如,原型鏈上有 200 個方法,我只呼叫其中 10 個,剩下的那 190 個都不需要,但它們還是會在初始化時建立。
我想到了 Rx.js 在版本 5 升級到版本 6 的 API 變動。
// rx.js 5 的寫法:
Source.startWith(0)
.filter(predicate)
.takeWhile(predicate2)
.subscribe(() => {});
// rx.js 6 的寫法:
import { startWith, filter, takeWhile } from 'rxjs/operators';
Source.pipe(
startWith(0),
filter(predicate),
takeWhile(predicate2)
).subscribe(() => {});
複製程式碼
RxJS 6 裡面採用了管道組合替代了鏈式呼叫。這樣子改動之後,想用什麼操作符就引用什麼,沒有多餘的操作符初始化,也利於 tree shaking。那麼我們就模仿 Rxjs 6 的 API 改寫上面的 Sequence 庫吧。
用管道組合實現惰性求值
操作符的實現和上面沒太大區別,主要區別在操作符的組合方式變了:
function getGeneratorFromList(list) {
let index = 0;
return function generate() {
if (index < list.length) {
const result = list[index];
index += 1;
return result;
}
};
}
function toList(sequence) {
const arr = [];
let value = sequence();
while (value !== undefined) {
arr.push(value);
value = sequence();
}
return arr;
}
// Sequence 函式本身非常輕量,操作符按需引入
function Sequence(list) {
const initSequence = getGeneratorFromList(list);
function pipe(...args) {
return args.reduce((prev, current) => current(prev), initSequence);
}
return { pipe };
}
function filter(predicate) {
return function(sequence) {
return function filteredSequence() {
const value = sequence();
if (value !== undefined) {
if (predicate(value)) {
return value;
}
return filteredSequence();
}
};
};
}
function map(mapping) {
return function(sequence) {
return function mappedSequence() {
const value = sequence();
if (value !== undefined) {
return mapping(value);
}
};
};
}
function take(n) {
return function(sequence) {
let count = 0;
return function() {
if (count < n) {
count += 1;
return sequence();
}
};
};
}
function skipWhile(predicate) {
return function(sequence) {
let startTaking = false;
return function skippedSequence() {
const value = sequence();
if (value !== undefined) {
if (startTaking) {
return value;
} else if (!predicate(value)) {
startTaking = true;
return value;
}
return skippedSequence();
}
};
};
}
function takeUntil(predicate) {
return function(sequence) {
return function() {
const value = sequence();
if (value !== undefined) {
if (predicate(value)) {
return value;
}
}
};
};
}
Sequence([2, 4, 6, 7, 9, 11, 13]).pipe(
filter(x => x % 2 === 1),
skipWhile(y => y < 10),
toList
); // [11,13]
複製程式碼
【重點】
螞蟻金服保險體驗與社群技術組招高階前端開發工程師/專家。我所在的團隊,隊友們個個都是獨當一面。學霸很多,我天天跟著他們學習。(坐在我右手邊的同學是清華醫學博士。可能是因為玩過手術刀,這位大神擼程式碼行雲流水,全 Vim 擼到底)我們開發了很有社會公益價值的相互寶,接下來會有更多激動人心的產品。有興趣的同學聯絡我 ray.hl@alipay.com
參考:
Let’s experiment with functional generators and the pipeline operator in JavaScript