函数是JavaScript的基石。它是一种灵活的抽象,可以作为其他抽象的基础,例如Promises,Iterables,Observables等。我一直在会议和研讨会上教授这些概念,随着时间的推移,我发现了一个金字塔模型,可以对这些抽象做一个优雅的总结。在这篇博客中,我将为大家介绍这个金字塔的各个层级。
FUNCTIONS
X => Y
一等公民是JavaScript的基础,如number,string,object,boolean等。尽管你可以只用值和控制流写出一个程序,但很快你就会需要写一个函数来改进你的程序。
函数是JavaScript中不可避免的抽象,它们通常用回调实现异步的I/O。“函数”这个词在JavaScript中并不像在函数式编程中那样代表“纯函数”。因为它们只是惰性的可复用代码块,具有可选的输入(参数)和输出(返回值),把它们理解为简单的“过程”会更好。
与硬编码的代码块相比,函数有两个很重要的优势:
- 惰性/可复用
- 函数体中的代码为了可复用,一定是惰性的(即调用时才执行)
- 实现灵活
- 函数的使用者不关心函数的内部是如何实现的,这意味着函数的实现方式灵活多变。
GETTERS
() => X
getter是一个没有输入参数并输出X的函数
getter是一种函数,它不需要传递参数但可以返回一个期望值。在JavaScript的运行时中有非常多这样的getter,如Math.random()
,Date.now()
等。getter作为值的抽象也非常有用。请比较下面的user
与getUser
:
const user = {name: 'Alice', age: 30};
console.log(user.name); // Alice
function getUser() {
return {name: 'Alice', age: 30};
}
console.log(getUser().name); // Alice
复制代码
通过使用getter表示一个值,我们继承了函数的优点,如惰性:如果我们不调用getUser()
,那么user对象就不会被创建出来。
因为我们可以用多种不同的方式(创建一个普通的对象,或者返回一个类的实例,又或者使用原型上的属性等等)来计算返回的对象,所以我们也获得了实现的灵活性。采用硬编码的话就做不到这么灵活。
getter还允许我们使用副作用钩子。无论getter在什么时候被执行,我们都能触发一个有用的副作用,像一个console.log
或者触发一个分析事件,下面是一个例子:
function getUser() {
Analytics.sendEvent('User object is now being accessed');
return {name: 'Alice', age: 30};
}
复制代码
getter上的计算也可以是抽象的,因为函数在JavaScript中可以被当作一等公民进行传递。举个例子,看下面这个求和函数,它用getter作为参数并返回一个number型的getter,而不是直接返回一个number类型的值。
function add(getX, getY) {
return function getZ() {
const x = getX();
const y = getY();
return x + y;
}
}
复制代码
当getter需要返回一个不可预测的值时,这种抽象计算的好处是很明显的,例如使用Math.random
作为参数:
const getTen = () => 10;
const getTenPlusRandom = add(getTen, Math.random);
console.log(getTenPlusRandom()); // 10.948117215055046
console.log(getTenPlusRandom()); // 10.796721274448556
console.log(getTenPlusRandom()); // 10.15350303918338
console.log(getTenPlusRandom()); // 10.829703269933633
复制代码
getter与Promise一同使用也是很常见的,由于Promise被认为是不可复用的计算,所以将Promise构造器包在getter(也被称为“工厂”或“形式转换”)中使其可复用。
SETTERS
X => ()
setter是一个接受X作为参数而没有输出的函数
setter是一种接收参数但没有返回值的函数。JavaScript运行时和DOM中有许多原生的setter,例如console.log(x)
,document.write(x)
等。
与getter不同,setter通常不是抽象,因为函数没有返回值意味着函数只能在JavaScript运行时中发送数据或命令。举个例子,名为getTen
的getter是一个对数字10的抽象并且我们可以把它当作一个值进行传递,而将setTen
作为值进行传递则没有任何意义,因为你不能通过调用它来获得任何数字。
也就是说,setter可以是对其他setter的简单封装,看下面对console.log
这个setter的封装:
function fancyConsoleLog(str) {
console.log('⭐ ' + str + ' ⭐');
}
复制代码
GETTER GETTERS
() => ( () => X )
getter-getter是一个不需要输入参数并输出一个getter的函数
有一类特殊的getter可以返回另一个getter,所以它是一个getter的getter。对getter-getter的需求源于使用getter迭代序列。举个例子,如果我们想要显示2的幂的数字序列,我们可以使用getNextPowerOfTwo()
这个getter:
let i = 2;
function getNextPowerOfTwo() {
const next = i;
i = i * 2;
return next;
}
console.log(getNextPowerOfTwo()); // 2
console.log(getNextPowerOfTwo()); // 4
console.log(getNextPowerOfTwo()); // 8
console.log(getNextPowerOfTwo()); // 16
console.log(getNextPowerOfTwo()); // 32
console.log(getNextPowerOfTwo()); // 64
console.log(getNextPowerOfTwo()); // 128
复制代码
这段代码的问题是变量i
是一个全局变量,如果我们想重启这个序列,就必须以正确的方式操作这个变量,从而暴露了这个getter的实现细节。
想要这段代码有更高的可复用性并且不依赖全局变量,我们需要做的是用一个函数封装这个getter。而这个包装函数也是一个getter。
function getGetNext() {
let i = 2;
return function getNext() {
const next = i;
i = i * 2;
return next;
}
}
let getNext = getGetNext();
console.log(getNext()); // 2
console.log(getNext()); // 4
console.log(getNext()); // 8
getNext = getGetNext(); // ? restart!
console.log(getNext()); // 2
console.log(getNext()); // 4
console.log(getNext()); // 8
console.log(getNext()); // 16
console.log(getNext()); // 32
复制代码
因为getter-getter是一类特殊的getter,它们继承了getter所有的优点,比如:
- 灵活的实现
- 副作用钩子
- 惰性
在这里惰性反映在初始化的步骤。外层函数支持惰性初始化,与此同时内层函数支持惰性的值迭代:
function getGetNext() {
// ? LAZY INITIALIZATION
let i = 2;
return function getNext() {
// ? LAZY ITERATION
const next = i;
i = i * 2;
return next;
}
}
复制代码
SETTER SETTERS
( X => () ) => ()
setter-setter是接收一个setter作为输入且没有输出的函数
setter-setter是一种特别的setter函数,其参数也是一个setter。尽管基础的setter不是抽象,但setter-setter是抽象,它能够表示可以在代码中进行传递的值。
例如,请思考是否可能借助下面的setter-setter表示数字10:
function setSetTen(setTen) {
setTen(10)
}
复制代码
要注意缺少返回值,因为setter从来没有返回值。通过对参数进行简单的重命名可以使上面的例子更具有可读性。
function setTenListener(cb) {
cb(10)
}
复制代码
顾名思义,cb
代表“回调(callback)”,表明了在有大量回调用例时setter-setter在JavaScript中是多么常见。将setter-setter表示的抽象值反过来用其实就得到了getter。
setSetTen(console.log);
// compare with...
console.log(getTen())
复制代码
setter-setter的好处与getter相同——惰性,灵活的实现,副作用钩子——但有两个getter没有的新属性:控制反转和异步性。
在上面的例子中,使用getter的代码决定何时将getter与console.log
一起使用。然而,使用setter-setter时,由setter-setter自己决定何时调用console.log
。责任倒置使setter-setter比getter更加强大,下面的例子中发送了多个值给消费者:
function setSetTen(setTen) {
setTen(10)
setTen(10)
setTen(10)
setTen(10)
}
复制代码
控制反转还允许setter-setter决定何时将值传递给回调,例如异步。假设把setSetTen
的名字改为setTenListener
:
function setTenListener(cb) {
setTimeout(() => { cb(10); }, 1000);
}
复制代码
尽管setter-setter在JavaScript中常用于异步编程,但回调中的代码不一定是异步的。在下面的这个setSetTen
的例子中,它与getter一样是同步的:
function setSetTen(setTen) {
setTen(10)
}
console.log('before');
setSetTen(console.log);
console.log('after');
// (Log shows:)
// before
// 10
// after
复制代码
ITERABLES
() => ( () => ({done, value}) )
可迭代对象(忽略了一些细节)是一个getter-getter,它返回一个描述了值和完成状态的对象
getter-getter能够表示一个可重启的值序列,但没有约定用什么标记序列的结束。可迭代对象是一类特殊的getter-getter,它的值总是一个有两个属性的对象:done
(指示是否结束的布尔值)和value
(done
不为true时实际被传递的值)。
结束标记让使用可迭代对象的消费者知道序列将返回无效的数据,所以消费者能够知道何时停止迭代。
在下面的例子中,我们可以根据完成指示器(completion indicator)生成一个有限的getter-getter,其值为40-48之间的偶数:
function getGetNext() {
let i = 40;
return function getNext() {
if (i <= 48) {
const next = i;
i += 2;
return {done: false, value: next};
} else {
return {done: true};
}
}
}
let getNext = getGetNext();
for (let result = getNext(); !result.done; result = getNext()) {
console.log(result.value);
}
复制代码
相比简单的() => ( () => ({done, value}) )
模式,ES6的可迭代对象有更深入的约定,它们在每个getter上添加了一个包装器对象:
- 外层的getter
f
变成了对象{[Symbol.iterator]: f}
- 内层的getter
g
变成了对象{next: g}
这里是一个有效的ES6可迭代对象,代码的功能与之前的例子相一致:
const oddNums = {
[Symbol.iterator]: () => {
let i = 40;
return {
next: () => {
if (i <= 48) {
const next = i;
i += 2;
return {done: false, value: next};
} else {
return {done: true};
}
}
}
}
}
let iterator = oddNums[Symbol.iterator]();
for (let result = iterator.next(); !result.done; result = iterator.next()) {
console.log(result.value);
}
复制代码
请注意两个例子之间的不同点:
-function getGetNext() {
+const oddNums = {
+ [Symbol.iterator]: () => {
let i = 40;
- return function getNext() {
+ return {
+ next: () => {
if (i <= 48) {
const next = i;
i += 2;
return {done: false, value: next};
} else {
return {done: true};
}
}
+ }
}
+}
-let getNext = getGetNext();
-for (let result = getNext(); !result.done; result = getNext()) {
+let iterator = oddNums[Symbol.iterator]();
+for (let result = iterator.next(); !result.done; result = iterator.next()) {
console.log(result.value);
}
复制代码
ES6提供了方便使用可迭代对象的语法糖for-let-of
:
for (let x of oddNums) {
console.log(x);
}
复制代码
ES6还提供了生成器函数的语法糖function*
以简化创建可迭代对象:
function* oddNums() {
let i = 40;
while (true) {
if (i <= 48) {
const next = i;
i += 2;
yield next;
} else {
return;
}
}
}
复制代码
从2015年开始,配合生产端和消费端的语法糖,JavaScript中的可迭代对象是一种易于使用的对可完成的值序列的抽象。注意生成器函数自身不是一个可迭代对象,但调用生成器函数会返回一个可迭代对象:
function* oddNums() {
let i = 40;
while (true) {
if (i <= 48) {
yield i;
i += 2;
} else {
return;
}
}
}
for (let x of oddNums()) {
console.log(x);
}
复制代码
PROMISES
( X => (), Err => () ) => ()
Promise(忽略了一些细节)是有附加保证的,含有两个setter的setter
尽管setter-setter已经很强大,但由于控制反转,它们可能会非常不可预测。它们可能是同步的,也可能是异步的,并且可以随着时间推移传递零或一个或多个值。Promise是一种特别的setter-setter,它可以在传递值时提供一些保证:
- 内层的setter(回调)一定不会被同步调用
- 内层的setter最多被调用一次
- 提供一个可选的额外的setter来处理抛出错误的情况
将下面的setter-setter与等效的Promise进行对比。Promise将只会传一次值,并且不在两个console.log
之间,因为值的传递是异步的:
function setSetTen(setTen) {
setTen(10)
setTen(10)
}
console.log('before setSetTen');
setSetTen(console.log);
console.log('after setSetTen');
// (Log shows:)
// before setSetTen
// 10
// 10
// after setSetTen
复制代码
与之相比:
const tenPromise = new Promise(function setSetTen(setTen) {
setTen(10);
setTen(10);
});
console.log('before Promise.then');
tenPromise.then(console.log);
console.log('after Promise.then');
// (Log shows:)
// before Promise.then
// after Promise.then
// 10
复制代码
Promise方便地表示了一个异步且不可复用的值,此外ES2017提供了生产和消费的语法糖:async-await
。只能在有async
前缀的函数中使用await
来消费Promise的值:
async function main() {
console.log('before await');
const ten = await new Promise(function setSetTen(setTen) {
setTen(10);
});
console.log(ten);
console.log('after await');
}
main();
// (Log shows:)
// before await
// 10
// after await
复制代码
async-await
语法糖可以用来创建一个Promise,因为async function
返回一个Promise,它包着函数中被返回的值。
async function getTenPromise() {
return 10;
}
const tenPromise = getTenPromise();
console.log('before Promise.then');
tenPromise.then(console.log);
console.log('after Promise.then');
// (Log shows:)
// before Promise.then
// after Promise.then
// 10
复制代码
OBSERVABLES
可观察对象(忽略了一些细节)是有附加保证的,含有三个setter的setter
就像可迭代对象是一类特别的getter-getter,能够标记完成的状态。可观察对象是一类能够添加完成状态的setter-setter。JavaScript中典型的setter-setter,像element.addEventListener
,不会通知事件流是否已完成,所以连接事件流或执行其他的与完成状态相关的逻辑会很困难。
与可迭代对象已经在JavaScript规范中被标准化不同,可观察对象是RxJS,most.js,xstream,Bacon.js等库之间达成的松散约定。尽管Observable被考虑为TC39的提案,但是该提案一直在变动,所以在这篇文章中让我们假定一个Fantasy Observable规范,像RxJS,most.js和xstream这样的库都遵循这个规范。
可观察对象是可迭代对象的另一面,这可以通过一些对称性看出来:
- 可迭代对象
- 是一个对象
- 有“迭代”方法,即
Symbol.iterator
- “迭代”方法是一个迭代器对象的getter
- 迭代器对象有一个
next
方法,是一个getter
- 可观察对象
- 是一个对象
- 有“观察”方法,即
subscribe
- “观察”方法是一个观察者对象的setter
- 观察者对象有一个
next
方法,是一个setter
观察者对象还有两个方法,complete
和error
,分别表示成功完成和失败。
complete
setter相当于可迭代对象里的done
指示符,而error
setter相当于从迭代器getter中抛出一个例外。
与Promise一样,可观察对象在传递值的时候增加了一些保证:
- 一旦
complete
setter被调用,error
setter将不会被调用 - 一旦
error
setter被调用,complete
setter将不会被调用 - 一旦
complete
setter或error
setter被调用,next
setter将不会被调用
在下面的例子中,可观察对象表示一个异步有限的数值序列:
const oddNums = {
subscribe: (observer) => {
let x = 40;
let clock = setInterval(() => {
if (x <= 48) {
observer.next(x);
x += 2;
} else {
observer.complete();
clearInterval(clock);
}
}, 1000);
}
};
oddNums.subscribe({
next: x => console.log(x),
complete: () => console.log('done'),
});
// (Log shows:)
// 40
// 42
// 44
// 46
// 48
// done
复制代码
与setter-setter一样,可观察对象导致控制反转,所以消费端(oddNums.subscribe
)没有办法暂停或取消进入的数据流。大多数可观察对象的实现添加了一个重要的细节——允许消费者发送取消信号给生产者:订阅者。
subscribe
函数可以返回一个对象——订阅者——拥有一个方法:unsubscribe
,消费端可以使用这个方法中止进入的数据流。subscribe
是一个既有输入(观察者)又有输出(订阅者)的函数,因此它不再是一个setter。下面,我们将一个订阅者对象添加到我们之前的例子中:
const oddNums = {
subscribe: (observer) => {
let x = 40;
let clock = setInterval(() => {
if (x <= 48) {
observer.next(x);
x += 2;
} else {
observer.complete();
clearInterval(clock);
}
}, 1000);
// ? Subscription:
return {
unsubscribe: () => {
clearInterval(clock);
}
};
}
};
const subscription = oddNums.subscribe({
next: x => console.log(x),
complete: () => console.log('done'),
});
// ? Cancel the incoming flow of data after 2.5 seconds
setTimeout(() => {
subscription.unsubscribe();
}, 2500);
// (Log shows:)
// 40
// 42
复制代码
ASYNC ITERABLES
**() => ( () => Promise<{done, value}>) **
异步可迭代对象(忽略一些细节)是一个生成Promise的可迭代对象,值在Promise中
可迭代对象可以表示任何无限或有限的值序列,但它有一个限制:在消费者调用next()
方法时值必须可以同步被使用。异步可迭代对象拓展了可迭代对象的能力,允许值被异步传递而不是在被请求时立即返回。
异步可迭代对象通过使用Promise实现了值的异步传递。每一次迭代器的next()
(内层的getter函数)被调用,创建并返回一个Promise。
下面的例子中,我们采用了oddNums
可迭代对象的例子并使它生成延迟resolve的Promise:
function slowResolve(val) {
return new Promise(resolve => {
setTimeout(() => resolve(val), 1000);
});
}
function* oddNums() {
let i = 40;
while (true) {
if (i <= 48) {
yield slowResolve(i); // ? yield a Promise
i += 2;
} else {
return;
}
}
}
复制代码
为了使用异步可迭代对象,我们要在请求下一个Promise前等待当前的Promise:
async function main() {
for (let promise of oddNums()) {
const x = await promise;
console.log(x);
}
console.log('done');
}
main();
// (Log shows:)
// 40
// 42
// 44
// 46
// 48
// done
复制代码
上面的例子很符合直觉,但它并不是一个有效的ES2018异步可迭代对象。我们在上面构造的是一个包含Promise的ES6可迭代对象,但ES2018异步可迭代对象是包着Promise的getter-getter,Promise返回的值是done, value
对象。将两者进行对比:
- 基于Promise的可迭代对象:() => ( () => {done, value: Promise} )
- ES2018异步可迭代对象:() => ( () => Promise<{done, value}> )
ES2018可迭代对象不是可迭代对象,它们只是基于Promise的getter-getter,在许多方面类似可迭代对象而已,这是反直觉的。这个细节上的差异是因为异步可迭代对象还需要异步地发送完成状态(done
),所以Promise必须包着整个{done, value}
对象。
因为异步可迭代对象不是可迭代对象,所以使用了不同的Symbol。可迭代对象依赖Symbol.iterator
,而异步可迭代对象使用Symbol.asyncIterator
。我们用了一个与前面类似的例子,实现了一个有效的ES2018异步可迭代对象:
const oddNums = {
[Symbol.asyncIterator]: () => {
let i = 40;
return {
next: () => {
if (i <= 48) {
const next = i;
i += 2;
return slowResolve({done: false, value: next});
} else {
return slowResolve({done: true});
}
}
};
}
};
async function main() {
let iter = oddNums[Symbol.asyncIterator]();
let done = false;
for (let promise = iter.next(); !done; promise = iter.next()) {
const result = await promise;
done = result.done;
if (!done) console.log(result.value);
}
console.log('done');
}
main();
复制代码
可迭代对象有function*
和for-let-of
语法糖,Promise有async-await
语法糖,ES2018中的异步可迭代对象同样有两个语法糖:
- 生产端:async function*
- 消费端:for-await-let-of
在下面的示例中,我们使用这两个特性来创建异步数字序列,并在for-await循环中使用它们:
function sleep(period) {
return new Promise(resolve => {
setTimeout(() => resolve(true), period);
});
}
// ? Production side can use both `await` and `yield`
async function* oddNums() {
let i = 40;
while (true) {
if (i <= 48) {
await sleep(1000);
yield i;
i += 2;
} else {
await sleep(1000);
return;
}
}
}
async function main() {
// ? Consumption side uses the new syntax `for await`
for await (let x of oddNums()) {
console.log(x);
}
console.log('done');
}
main();
复制代码
尽管它们是新的特性,但异步可迭代对象的语法糖已被Babel,TypeScript,Firefox,Chrome,Safari以及Node.js支持。异步可迭代对象可以十分方便地与基于Promise的API相结合(例如fetch
)以创建异步序列,如一次请求一个用户并列举数据库中的用户:
async function* users(from, to) {
for (let x = from; x <= to; x++) {
const res = await fetch('http://jsonplaceholder.typicode.com/users/' + x);
const json = await res.json();
yield json;
}
}
async function main() {
for await (let x of users(1, 10)) {
console.log(x);
}
}
main();
复制代码
操作符
这篇文章中所列举的抽象只是JavaScript函数的简单特列。从定义上来说,它们不会比函数更加强大,这使得函数成为最强大和灵活的抽象。完全灵活的缺点是不可预测。这些抽象提供的是保证,基于保证你可以写出更易组织和更可预测的代码。
从另一方面来说,函数是一个JavaScript值,这允许在JavaScript中传递和修改它们。把函数当作值传递的能力还能被用于我们在这篇文章中看到的抽象。我们能将可迭代对象或可观察对象或异步可迭代对象作为值传递并在这个过程中操作它们。
最常见的操作之一就是在数组中很流行的map
,但也可用于抽象中。下面的例子里,我们为异步可迭代对象创建了map
操作符,并使用它创建一个包含用户名称的异步可迭代对象:
async function* users(from, to) {
for (let i = from; i <= to; i++) {
const res = await fetch('http://jsonplaceholder.typicode.com/users/' + i);
const json = await res.json();
yield json;
}
}
// ? Map operator for AsyncIterables
async function* map(inputAsyncIter, f) {
for await (let x of inputAsyncIter) {
yield f(x);
}
}
async function main() {
const allUsers = users(1, 10);
// ? Pass `allUsers` around, create a new AsyncIterable `names`
const names = map(allUsers, user => user.name);
for await (let name of names) {
console.log(name);
}
}
main();
复制代码
在没有Getter-Setter金字塔中的抽象的情况下编写上面的代码示例需要更多的代码,也更难阅读。如何利用这些函数特例的优点,以更少的代码完成更多功能,而不牺牲可读性?请使用运算符和新语法糖特性。