写在前面的
在开始正文之前我想谈些与此文相关性很低的话题。对这部分不感兴趣的读者可直接跳过。
在我发表上一篇文章或许我们在 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]
复制代码
参考:
Let’s experiment with functional generators and the pipeline operator in JavaScript