函数式的概念、思维和理论到这里已经讨论了不少,可能还不够深入,但应该已足够我们描述接触到的前端环境。这章我们继续贴合Coding,对我们前端使用的工具库中涉及函数式的表达进行整理。归根到底,所有的理论都是为了我们理解这些工具的设计思路和适用场景。
一 jQuery和链式调用
jQuery在我们手动操作事件响应和DOM变更的那几年,是我们必要掌握的前端工具。
它以一个基础库包帮我们处理了大量的工作: DOM操作、选择器、Ajax、动效、数组方法、早期的Promise异步操作甚至包括一些兼容性操作。在编码表达上它也有不少亮点,比如使用$符号提升DOM元素到jQuery元素,在$参数中实现选择器表达式的解析,使用链式操作进行DOM上的一系列手动操作等。
链式操作的可行性源自于:jQuery功能中,九成以上的基础方法、选择器方法、属性方法、筛选方法、文档处理方法、样式方法、事件方法的返回值都仍然是jQuery对象或它的集合。剩下的jQuery方法主要是求值方法、流程异步控制、Ajax包装等。
实际上jQuery上前端复杂度分布一直延续了下来。能被jQuery提升包装的Node节点最后演化成了组件,异步请求、事件处理和一些元数据成为了服务,在较新的前端框架中有了新的实现。
代码一 jQuery链式操作(使用jQuery1.x版本)
const _count = 0
jQuery("div.demo-li")
.find("ul[class^='shop-card']") // 此处返回类型 Array<Element(S)>
.removeAttr("disabled")
.one("click", { count: _count }, function(event){
$("#count-container").show().html(even.data.count);
})
.fadeIn(1000);
链式操作的另一个天然的宿主对象是数组,从上一章节我们可以看到数组是一个原生的容器,只不过它的value是一个集合。加入时序的数组Rxjs也延续了这类操作。
代码二 数组和Rxjs的链式操作
// Array 去除'Betty'以外的团队成员随机排序
const teamNames = ['Devin', 'Betty', 'Jack', 'Dolly', 'Shirley', 'Vivian'];
teamNames.map(x => [x, Math.random()])
.filter(x => x !== 'Betty')
.sort((x, y) => x[1] - y[1])
// Rxjs5 每等待一定间隔后,生成一些雨滴
const gapSec = 500
const generateDropTimes = 10
const rainMakingFunc = () => { console.log('generate some raindrops') }
_.Observable.timer(0, gapSec)
.take(generateDropTimes)
.map(x => 1 + Math.round(Math.random()))
.subscribe(rainMakingFunc)
链式操作实际上是将一系列二元操作写入到对象的成员方法中,它专注于对一个领域的对象进行操作。对于jQuery来说,DOM元素、DOM元素集合、页面钩子、异步回调函数,甚至于扩展的插件,都是jQuery的包裹内容,它利用自己的范畴形成了一个高于库的前端工具集。
而现实中的业务操作并不能形成完整的领域范畴。 我们有端之间的通讯、有BOM操作、有容器jsBridge等。操作的主体如果仅限于一类内容是局限的,有时我们需要能传入两个或以上参数的操作。
二 Pipeline 和 Combination
对于二元或多元操作的组合,我们依然可以把关注点投向函数过程的组合。在函数支持高阶调用,并在某些情况下满足结合律后,我们可以刻意组合(combine)过程中的各个函数。
组合函数的方法有两种,一种是按照函数的包裹次序进行;另一种是按照执行的顺序,即按照我们第二章中运算符重载部分提到的类似 ' |> ' 一样的管道运算符的次序进行。
我们下面以一个实例展示几个阶段的不同演化。
假设团队有13个人,我们按照0-12的次序进行编号。将编号在 [2, 4, 5, 8, 9] 这个集合内的人员选出,并转换为一个包含org, orgNum 属性的对象。
代码三 Pipeline/Combination
// 入参 使用标注使得更清晰
const teamCount = 13
const newTeamNumbers = [2, 4, 5, 8, 9]
const C = teamCount
const N = newTeamNumbers
// 函数1 常规循环做法
function pickMember(C, N) {
const members = []
for (i = 0; i < C; i += 1) {
const _member = {
org: 'techTeam',
number: i
}
if(N.indexOf(_member.number) >=0) {
members[i] = _member
}
}
return members
}
// 函数2 基于过程的简单封装
import _ from 'lodash'
function pickMember(C, N) {
const setRangeList = count => _.range(count)
const fillMember = (x, i) => { org: 'techTeam', number: i }
const ifPick = (_N, number) => _N.indexOf(number) >= 0
const filterPicked = number => ifPick(N, number) // 此处依赖N, 可再拆分
return _.filter(_.map(setRangeList(C),fillMember),filterPicked);
// 基本的封装下,入参C在最中间的函数处传入
}
// 函数3 基于过程的compose/pipeline处理
import _ from 'lodash'
function pickMember(C, N) {
// 改造方法,将 N 也作为入参传入
const setRangeList = [count, _N] => [_.range(count), _N] // 第一个函数
const fillMember = (x, i) => { org: 'techTeam', number: i }
const mapAndFill = _.curry(_.map)(_, fillMember)
const mapFillWithTail = [range, _N] => [mapAndFill(range), _N] // 第二个函数
const ifPick = [number, _N] => _N.indexOf(number) >= 0
const filterByPicker = _.curry(_.filter)(_, ifPick) // 第三个函数
if (Math.random() > 0.5) {
// 3.1 compose _.flowRight 在lodash4 之前为 _.compose
// 从右到左依次调用函数
return _.flowRight([filterByPicker, mapFillWithTail, setRangeList])([C, N])
} else {
// 3.2 pipeline _.flow 在lodash4 之前为 _.pipeline
// 从左到右依次调用函数
return _.flow([setRangeList, mapFillWithTail, filterByPicker])([C, N])
}
}
// 函数4 适度转换后的链式操作
function pickMember(C, N) {
return _.range(C).map(x => ({ org: 'techTeam', number: x }))
.filter(x => N.indexOf(x.number) >= 0)
}
通过示例我们可以看到3.1 compose 、 3.2 pipeline 两种次序的函数组合。 为了保证入参出餐的连续性和细粒度,我们借助了curry柯里化; 同时我们进行了一些改造,让参数N 和 C一样,在初始调用时传入。
pipeline的调用顺序和自然顺序一致,使得我们重点关注到 _.flow 里面的参数内容即为 流程的3个步骤。其他一些语言管道操作时,则可以把此处内容写成 '[C, N] |> setRangeList |> mapFillWithTail |> filterWithTail' 的形式。
上一节链式操作示例Rxjs的雨滴生成方法,我们可以更换成pipe的写法如下。
代码四 Rxjs的pipe写法
// Rxjs6 pipe方法
import * as __ from 'rxjs/operators'
import { timer, range, interval } from 'rxjs/index'
// 中间省略
timer(0, _gdSec).pipe(
__.take(_generateDropTimes),
__.map(x => 1 + Math.round(Math.random()))
)
.subscribe(this.rainMaking)
// 对比Rxjs5 链式操作
// _.Observable.timer(0, gapSec)
// .take(generateDropTimes)
// .map(x => 1 + Math.round(Math.random()))
// .subscribe(rainMakingFunc)
此处的变化在于 take等方法是一个独立的 operators 方法,而不再是绑定在 Observable对象上了。
回到我们本节的示例,我们看到在最后有一个适当调整实现的链式操作方法: 函数4,它只用了两行就完成了示例的操作。pipe操作也好,链式操作也好,具体到某个的实现,我们需要根据过程是否需要拆分、入参是否需要控制、方法需不需要调试等细节来决定更合理的实现。
如果是一类我们需要包装控制的内容,pipe的入参形式:一个数组包含所有过程的实现更方便我们进行提取。
三 Monadic 和 Promise
我们再一次回到了Monadic和Promise。 这次我们除了基础的链式操作形式外,我们还需要关注重要的异步操作。
前端编码从jQuery中引入链式操作后,lodash等库组件引入了组合和管道的概念。这些使得我们操作逐步操作有了不错的选择。 jQuery除了形式上的链式操作和核心的DOM操作/事件绑定外,还进行了 XHR也就是Ajax的封装。 封装除进行XHR的属性设定外,最常见的是两个方法 success 和 error。
在接口返回后进行一些回调操作,并且支持异步调用(JQuery初期依然很多人使用同Ajax操作以避免时序问题),是后续JS时序问题的开启的重要进步,也是我们的代码从函数式向函数响应式变化的起点。
为了代码可读性的连续,我们写出了一些典型的回调地狱。为了解决回调地狱,我们结合生成器方法,实现了thenable的对象Promise。 结合上一章节我们看到的 Monadic 的语法推导,代码中用 Promise 实现了异步的链式调用,将事件响应的代码封装到一个单独的对象和过程中。
图一 回调地狱
这里仍然会有一些困扰我们的内容。 回调事件书写在then中有时会破坏我们阅读的连贯性;有时一个事件会是同步调用的,也可能是异步调用的,我们希望能兼容这两种形式;更普遍的问题是,Promise和异步回调涉及到了事件响应编程的一个一开始没有太多控制的领域: 时序上的调度。
Promise本身提供了一些方法,比如 race、all 等方法,来处理多个事件并行时的后续选择。这解决了少数同级别事件的自我协调问题。既然涉及到任务调度和时序管理,我们希望可以有更多的时序控制: 任务优先级、手动触发、精确的时间间隔控制、流程的合并分割、控制执行内容的界限 等。
四 Async/Await
async/await 是生成器的另一种实现。 他在Promise之后出现,帮我们解决了上面的两个问题,回调事件的书写位置、兼容同步异步调用。它可以和Promise做方便的切换或调用。
这一步标准的引入,使得我们函数式的过程调用得以简化,我们看似使用同步调用又回到了 jQuery的同步Ajax时代,实际上调用次序仍然是异步的。它非常适合一个时序不受外部影响也对外部没有显著影响的过程,契合我们的函数式思想;但当async方法的过程内有和其他过程公用的变量,会仍然造成'线程'不安全的困扰。
鉴于async是默认根据 resolve状态命令式进行,我们要做异常态兜底需要手动包裹try...catch。
代码5 async await 和 Promise 的异同
// promise表示条件语句
const makeRequest = () => {
return getJSON()
.then(data => {
if (data.needsAnotherRequest) {
return makeAnotherRequest(data)
.then(moreData => {
console.log(moreData)
return moreData
})
} else {
console.log(data)
return data
}
})
}
// async 表示条件语句
const makeRequest = async () => {
const data = await getJSON()
if (data.needsAnotherRequest) {
const moreData = await makeAnotherRequest(data);
console.log(moreData)
return moreData
} else {
console.log(data)
return data
}
}
五 Mobx、Rxjs 和 响应式编程
把Mobx 和 Rxjs 放在一起是因为他们都引入了 observable的概念,进而进入响应式编程的思路。
任何源自应用状态的东西都应该自动地获得。
前端在大量命令式编程的探索后,仍然要解决它事件推动的核心问题。一个较大规模的前端APP会有多个触发条件,我们从一个表单页面只有一个初始化资源获取加一个submit事件,发展到现在会有联动的选择框、有表单验证、有事件反馈轮询、会记录用户行为的复杂场景。
事实上,我们从Angular开始正式进入前端框架后,业务/框架逻辑有一份分给了声明响应事件、一份分给了页面的自动操作。在引入observable之前,响应事件的串联和数据到页面展示的映射有时已不太可控。使用自动化的代价在于开发者失去了直观跟踪流程的能力。
我们使用Computed、Watch、useState串联响应事件,使用 nextTick和setState迭代生成页面变化,合理的使用也能规避时序上不确定问题,但无法观测到不可控部分比如页面渲染完成等时机,更无法直观串联起一个点击事件后续的流程。
Mobx和Redux最大的区别在于它提供非React钩子带来的自动化响应,而通常使用手动autorun观测状态的变化,进而手动触发后续的业务事件; Rxjs则为响应式事件的后续操作提供了更强力的API支持。
代码6 Mobx示例
import React, { useMemo } from "react";
import { observable } from "mobx";
import { observer, useLocalStore } from "mobx-react";
const Counter = () => {
const store = useLocalStore(() => ({
count: 0
}));
// const store = useMemo(() => observable({ count: 0 }), []);
return (
<button onClick={() => store.count++}>
{store.count}
</button>
)
};
export default observer(Counter);
从函数式的形态的结构上,Rxjs的表现更有借鉴性。上面我们看到它的形式经历了链式调用到pipe的结合,此外它在语法层面包容了时序和异步的一些操作(和Promise的转换等)。
Rxjs提供了时序上的同步写法,我们会使用map的类似形式,处理加入了时间的队列、无限增长队列和响应式的事件队列(一个按钮的多次点击事件);它提供了响应式事件的拆分、结合;提供了时间和事件的控制如防抖节流等等; 最后他还引入了调度表schedule的概念。下一章我们会通过一个示例单独介绍Rxjs的结构和重要操作符。
六 函数式的并发设计
虽然浏览器引擎中大多为单线程处理,但聊到这里我们要关注前端JS在并发这边遇到的一些问题。当流程的时序不确定时,资源的占用次序可能影响运行结果,也即是另一种形式的线程不安全。
JS代码的并行控制我们可以尽量使用数据的集合映射操作代替循环,使用rxjs和await这种用同步方式兼容异步调用的集合操作。使用语言和库的一个原则是,尽量相信他们实现的最优性,虽然原生数组并不会像某些函数式语言对reduce做引擎级优化。
通过系列前章我们还知道确保线程安全(时序安全)需要保证数据的不可变性。
React中有一个不可变数据的示例。在setState的操作时,我们可以理解语法上使用了Object.assign把参数的状态mix到原state中;而实际上,React创建了一个新的对象,来执行新的一次render操作。这样能确保上一个state状态下的异步事件不会受到state改变带来的影响。
代码7 React setState
class Example extends React.Component {
constructor() {
super();
this.state = {
val: 0
};
}
componentDidMount() {
this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 1 次 log
this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 2 次 log
setTimeout(() => {
this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 3 次 log
this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 4 次 log
}, 0);
}
render() {
return null;
}
};
// 0 0 2 3
以上两个例子概括了函数式在并发并行上的处理方式,其实已涵盖在之前提到的函数式思维和概念里。从JS的单线程和函数式的纯函数特性上,我们已有了前端的并行并发实践;在这种层面上没必要去向锁、原子性这些概念再进行靠拢。
前端在组件通讯、node并发时还会涉及一些并发模型,这里就不再一一讨论了。
更多和小结
事实上我们还有很多前端关于函数式的内容没有提及,比如lodash的fp函数式库,redux的设计思想,react的高阶组件,还有react Hook等。我们本章还是专注于探索函数式的语法形式,以及使用同步的姿势书写包含时序控制的响应式形式。
后面我会继续以几个具体的工具内容,来阐述函数式思想在前端不同阶段不同场景的应用,包括Rxjs和React Hook等内容。我的理解中前端一直是Coding学习的理想场所,希望大家多以工程的角度考虑编程理论落地的合理姿势和良好的工程特性(健壮性、扩展性等)考量。