Mrr是React的功能库(我为虚构的重言道歉)。
“反应性”一词通常
将Rx.js称为参考FRP。 但是,有关Habré的一系列有关该主题的近期文章(
[1] ,
[2] ,
[3] )显示了Rx解决方案的麻烦,在简单的示例中,Rx解决方案在清晰度和简单性上几乎不及其他任何方法。 Rx既强大又强大,非常适合解决流程抽象本身所暗示的问题(实际上,这主要是异步任务的协调)。 但是,例如,您会在Rx上编写一个简单的同步表单验证吗? 与通常的命令式方法相比,他会节省您的时间吗?
mrr试图证明FRF不仅可以在特定的“流”问题中而且在最常见的常规前端任务中都是一种便捷有效的解决方案。
反应式编程是一个非常强大的抽象,目前在前端,它以两种方式出现:
- 反应变量(计算变量):简单,可靠,直观,但RP的潜力远未得到充分揭示
- 用于处理流(例如Rx,Bacon等)的库:功能强大,但相当复杂,实际使用范围仅限于特定任务。
Mrr结合了这些方法的优点。 与Rx.js不同,mrr有一个简短的API,用户可以通过添加它来扩展它。 而不是数十种方法和运算符-四个基本运算符,而不是Observable(冷热),Subject等。 -一种抽象:流。 另外,mrr缺少一些复杂的概念,例如元流,这些概念会使代码的可读性大大复杂化。
但是,mrr不是“以新方式简化的Rx”。 基于与Rx相同的基本原理,mrr声称是一个更大的市场:管理全局和本地(在组件级别)应用程序状态。 尽管反应式编程的原始概念旨在用于异步任务,但mrr已成功将反应性方法用于普通的同步任务。 这就是“总FRP”的原则。
通常在React上创建应用程序时,会使用几种不同的技术:用于组件状态的重组(或很快-钩子),用于全局状态的Redux / mobx,具有redux-observable(或thunk / saga)的Rx用于管理副作用和协调异步编辑器中的任务。 通过使用mrr,您可以使用单个技术和范例来代替同一应用程序中不同方法和技术的“沙拉”。
mrr接口与Rx和类似的库也有很大不同-它更具声明性。 多亏了反应性抽象和声明性方法,mrr允许您编写富有表现力和简洁的代码。 例如,
mrr上的
标准TodoMVC占用少于50行代码(不计算JSX模板)。
但是足够的广告。 您是否在一个瓶子中结合了“轻型”和“重型” RP的优点-您应该判断,但首先请阅读代码示例。
TodoMVC已经很烦人了,下载有关Github用户数据的示例太原始了,以至于无法感知其库的功能。 我们将mrr作为有条件购买火车票的示例。 在我们的用户界面中,将存在用于选择开始和结束站点,日期的字段。 然后,在发送数据后,将返回可用火车列表和其中的位置。 选择了特定的火车和汽车类型后,用户将输入乘客数据,然后将票添加到购物篮中。 走吧
我们需要一个可以选择站点和日期的表格:

创建用于输入站的自动完成字段。
import { withMrr } from 'mrr'; const stations = [ '', '', '', ' ', ... ] const Tickets = withMrr({
使用withMrr函数创建mrr组件,该函数接受反应式链接图(流程描述)和render函数。 渲染功能传递给组件的props以及状态,而状态现在完全由mrr控制。 它将包含初始值(块$ init),并由反应池的公式值计算得出。
现在,我们有两个单元格(或两个流,这是相同的):
stationFromInput ,使用
$ helper(默认为数据输入元素传递
event.target.value)从用户输入中
减去的值,以及从其派生的单元格
stationFromOptions ,按名称包含匹配站的数组。
每次使用函数更改父单元格时,
都会自动计算
stationFromOptions的值(在mrr术语中称为“
公式 ”-与Excel公式类似)。 mrr表达式的语法很简单:首先是用来计算单元格值的函数(或运算符),然后是该单元格所依赖的单元格列表:它们的值传递给函数。 乍一看,这种奇怪的语法具有很多优点,我们将在后面讨论。 到目前为止,这里的mrr逻辑使人想起了Vue,Svelte和其他库中使用的可计算变量的常用方法,唯一的区别是可以使用纯函数。
我们在输入字段中的列表中实现所选电台的替换。 用户单击其中一个站点后,还必须隐藏站点列表。
const Tickets = withMrr({ $init: { stationFromOptions: [], stationFromInput: '', }, stationFromOptions: [str => stations.filter(s => str.indexOf(a) === 0), 'stationFromInput'], stationFrom: ['merge', 'stationFromInput', 'selectStationFrom'], optionsShown: ['toggle', 'stationFromInput', 'selectStationFrom'], }, (state, props, $) => { return (<div> <div> : <input onChange={ $('stationFromInput') } value={ state.stationFrom }/> </div> { state.optionsShown && <ul className="stationFromOptions"> { state.stationFromOptions.map(s => <li onClick={ $('selectStationFrom', s) }>{ s }</li>) } </ul> } </div>); });
使用$助手在工作站列表中创建的事件处理程序将为每个选项发出固定值。
mrr的声明方法与任何突变都相符。 选择站点后,我们无法“强制”更改单元格值。 相反,我们创建一个新的
stationFrom单元格,该单元格使用合并流运算符(Rx上的近似等效项是CombineLatest)将收集两个流的值:用户输入(
stationFromInput )和站选择(
selectStationFrom )。
用户输入内容后,我们必须显示选项列表,而在用户选择了其中一个选项之后,我们必须隐藏它们。
optionsShown单元格将负责选项列表的可见性,该列表将采用布尔值,具体取决于其他单元格中的更改。 这是存在语法糖的一种非常常见的模式-
切换运算符。 当第一个参数(流)发生任何更改时,它将单元的值设置为
true ,将第二个参数设置为
false 。
添加按钮以清除输入的文本。
const Tickets = withMrr({ $init: { stationFromOptions: [], stationFromInput: '', }, stationFromOptions: [str => stations.filter(s => str.indexOf(a) === 0), 'stationFromInput'], clearVal: [a => '', 'clear'], stationFrom: ['merge', 'stationFromInput', 'selectStationFrom', 'clearVal'], optionsShown: ['toggle', 'stationFromInput', 'selectStationFrom'], }, (state, props, $) => { return (<div> <div> : <input onChange={ $('stationFromInput') } value={ state.stationFrom }/> { state.stationFrom && <button onClick={ $('clear') }></button> } </div> { state.optionsShown && <ul className="stationFromOptions"> { state.stationFromOptions.map(s => <li onClick={ $('selectStationFrom', s) }>{ s }</li>) } </ul> } </div>); });
现在,负责输入字段中文本内容的
stationFrom单元,不是从两个流而是从三个流中收集其值。 此代码可以简化。 [*公式*,* ...单元格参数*]形式的mrr构造类似于Lisp中的S表达式,就像Lisp中一样,您可以任意地将这样的构造相互嵌套。
让我们摆脱无用的clearVal单元并缩短代码:
stationFrom: ['merge', 'stationFromInput', 'selectStationFrom', [a => '', 'clear']],
可以将以命令式风格编写的程序与组织不佳的团队进行比较,该团队中每个人都在不断地相互订购某些东西(我暗示方法调用和更改变更),而且,两位经理都是下属,反之亦然。 声明式程序类似于相反的乌托邦模式:每个人都清楚地知道自己在任何情况下都应该如何采取行动的集体。 在这样的团队中不需要订单;每个人都在他们自己的位置,并根据情况进行工作。
我们没有描述事件的所有可能后果(阅读-进行某些突变),而是描述了该事件可能发生的所有情况,即 在其他单元格发生任何变化时,该单元格将具有什么价值。 到目前为止,在我们的小示例中,我们描述了stationFrom单元以及影响其值的三种情况。 对于习惯命令式代码的程序员来说,这种方法似乎很不寻常(甚至是“拐杖”,“变态”)。 实际上,正如我们将在实践中看到的那样,由于代码的简洁(和稳定性),它使您节省了工作量。
异步呢? 是否可以使用Ajax列出建议的站点列表? 没问题! 本质上,函数是否返回值或承诺对于mrr都无关紧要。 当返回mrr时,它将等待其解析并将接收到的数据“推送”到流中。
stationFromOptions: [str => fetch('/get_stations?str=' + str).then(res => res.toJSON()), 'stationFromInput'],
这也意味着您可以将异步函数用作公式。 稍后将考虑更复杂的情况(错误处理,承诺状态)。
选择出发站的功能已准备就绪。 为到达站复制相同的内容是没有意义的,值得将其放入可以重复使用的单独组件中。 这将是具有自动补全功能的通用输入组件,因此我们将重命名字段并使用该功能来获取道具中设置的适当选项。
const OptionsInput = withMrr(props => ({ $init: { options: [], }, val: ['merge', 'valInput', 'selectOption', [a => '', 'clear']], options: [props.getOptions, 'val'], optionsShown: ['toggle', 'valInput', 'selectOption'], }), (state, props, $) => <div> <div> <input onChange={ $('valInput') } value={ state.val } /> </div> { state.optionsShown && <ul className="options"> { state.options.map(s => <li onClick={ $('selectOption', s) }>{ s }</li>) } </ul> } { state.val && <div className="clear" onClick={ $('clear') }> X </div> } </div>)
如您所见,您可以根据props组件指定mrr单元的结构(但是,它仅会执行一次-初始化后不会响应props的更改)。
组件之间的通讯
现在,将此组件连接到父组件中,并查看mrr如何允许相关组件交换数据。
const getMatchedStations = str => fetch('/get_stations?str=' + str).then(res => res.toJSON()); const Tickets = withMrr({ stationTo: 'selectStationFrom/val', stationFrom: 'selectStationTo/val', }, (state, props, $, connectAs) => { return (<div> <OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } /> - <OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } /> <input type="date" onChange={ $('date') } /> <button onClick={ $('searchTrains') }></button> </div>); });
要将父组件与子组件关联,我们必须使用
connectAs函数(render函数的第四个参数)向其传递参数。 在这种情况下,我们指出我们要赋予子组件的名称。 通过以这种方式附加组件,通过此名称,我们可以访问其单元。 在这种情况下,我们正在监听val单元。 反之亦然-从父单元的子组件监听。
如您所见,这里mrr遵循一种声明性方法:不需要onChange回调,我们只需要在connectAs函数中为子组件指定一个名称,然后就可以访问其单元了! 在这种情况下,再次由于声明性,不存在干扰其他组件工作的威胁-我们没有能力“更改”其中的任何内容,进行变异,我们只能“侦听”数据。
信号与价值
下一步是为所选参数搜索合适的火车。 在命令式方法中,我们可能会编写一个特定的处理器来提交onSubmit表单,该表单将启动进一步的操作-ajax请求并显示结果。 但是,您还记得,我们无法“订购”任何东西! 我们只能从该表格的单元格中创建另一组单元格。 让我们再写一个请求。
const getTrains = (from, to, date) => fetch('/get_trains?from=' + from + '&to=' + to + '&date=' + date).then(res => res.toJSON()); const Tickets = withMrr({ stationFrom: 'selectStationFrom/val', stationTo: 'selectStationTo/val', results: [getTrains, 'stationFrom', 'stationTo', 'date', 'searchTrains'], }, (state, props, $, connectAs) => { return (<div> <OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } /> - <OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } /> <input type="date" onChange={ $('date') } /> <button onClick={ $('searchTrains') }></button> </div>); });
但是,这样的代码将无法正常工作。 当任何一个参数改变时,都会开始计算(单元格值的重新计算),因此,例如,在选择第一个站后,将立即发送请求,而不仅仅是单击“搜索”。 一方面,我们需要将桩号和日期传递到公式的参数中,但另一方面,不要响应它们的更改。 在mrr中,有一种称为被动监听的优雅机制。
results: [getTrains, '-stationFrom', '-stationTo', '-date', 'searchTrains'],
只需在单元名称前面添加减号,瞧! 现在,
结果将仅响应
searchTrains单元格的更改。
在这种情况下,
searchTrains单元格充当“单元格信号”,
stationFrom和其他单元格充当“单元格值”。 对于信号单元,只有值“流”过的那一刻很重要,以及它将是什么样的数据-无论如何:它可以是真实的,“ 1”或其他任何值(在我们的情况下,这些将是DOM Event对象) ) 对于价值单元而言,重要的是它的价值,但与此同时,其变化的时刻并不重要。 这两种类型的单元格不是互斥的:许多单元格既是信号又是值。 在mrr的语法级别上,这两种类型的单元格没有任何区别,但是在编写反应式代码时,从概念上理解这种区别非常重要。
分割流
请求搜索火车上的座位可能需要一些时间,因此我们必须展示装载机,并且在出现错误的情况下也要做出响应。 对于采用自动解析的默认方法,已经没有什么希望。
const Tickets = withMrr({ $init: { results: {}, } stationFrom: 'selectStationFrom/val', stationTo: 'selectStationTo/val', searchQuery: [(from, to, date) => ({ from, to, date }), '-stationFrom', '-stationTo', '-date', 'searchTrains'], results: ['nested', (cb, query) => { cb({ loading: true, error: null, data: null }); getTrains(query.from, query.to, query.date) .then(res => cb('data', res)) .catch(err => cb('error', err)) .finally(() => cb('loading', false)) }, 'searchQuery'], availableTrains: 'results.data', }, (state, props, $, connectAs) => { return (<div> <div> <OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } /> - <OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } /> <input type="date" onChange={ $('date') } /> <button onClick={ $('searchTrains') }></button> </div> <div> { state.results.loading && <div className="loading">...</div> } { state.results.error && <div className="error"> . , . .</div> } { state.availableTrains && <div className="results"> { state.availableTrains.map((train) => <div />) } </div> } </div> </div>); });
嵌套运算符使您可以将数据“分解”为子单元格;为此,公式的第一个参数是回调,您可以使用其将数据“推入”子单元格(一个或多个)。 现在,我们有了独立的流,这些流负责错误,承诺状态和接收到的数据。 嵌套运算符是一个非常强大的工具,也是mrr(我们自己指定将数据放入哪个单元格)中为数不多的命令之一。 在merge运算符将多个线程合并为一个线程的同时,nested将线程拆分为多个子线程,因此相反。
上面的示例是使用Promise的标准方式,在mrr中,它被概括为Promise运算符,并允许您缩短代码:
results: ['promise', (query) => getTrains(query.from, query.to, query.date), 'searchQuery'],
同样,promise运算符确保仅使用最新的promise的结果。

显示可用座位的组件(为简单起见,我们将拒绝使用不同类型的汽车)
const TrainSeats = withMrr({ selectSeats: [(seatsNumber, { id }) => new Array(Number(seatsNumber)).fill(true).map(() => ({ trainId: id })), '-seatsNumber', '-$props', 'select'], seatsNumber: [() => 0, 'selectSeats'], }, (state, props, $) => <div className="train"> №{ props.num } { props.from } - { props.to }. : { props.seats || 0 } { props.seats && <div> : <input type="number" onChange={ $('seatsNumber') } value={ state.seatsNumber || 0 } max={ props.seats } /> <button onClick={ $('select') }></button> </div> } </div>);
要访问公式中的道具,您可以订阅特殊的$道具框。
const Tickets = withMrr({ ... selectedSeats: '*/selectSeats', }, (state, props, $, connectAs) => { ... <div className="results"> { state.availableTrains.map((train, i) => <TrainSeats key={i} {...train} {...connectAs('train' + i)}/>) } </div> }
当您单击“选择”按钮时,我们再次使用被动收听来拾取所选地点的数量。 我们使用connectAs函数将每个子组件与父组件相关联。 用户可以在任何拟议的火车中选择座位,因此我们使用掩码“ *”收听所有子组件的变化。
但这就是问题所在:用户可以先在一列火车上添加座位,然后再在另一列火车上添加座位,以便新数据可以磨碎先前的数据。 如何“积累”流数据? 为此,有一个
闭包运算符,它与嵌套和漏斗一起构成mrr的基础(所有其他函数不过是基于这三个函数的语法糖)。
selectedSeats: ['closure', () => { let seats = [];
第一次使用
闭包时 (在componentDidMount上),将创建一个返回公式的闭包。 因此,她可以访问闭包变量。 这样,您就可以安全地保存两次调用之间的数据,而不会陷入全局变量和共享可变状态的深渊。 因此,闭包允许您实现Rx运算符的功能,例如scan和其他功能。 但是,此方法适用于困难情况。 如果只需要保存一个变量的值,则可以使用特殊名称“ ^”简单地使用指向单元格先前值的链接:
selectedSeats: [(seats, prev) => [...seats, ...prev], '*/selectSeats', '^']
现在,用户必须为每个选定的票证输入名字和姓氏。
const SeatDetails = withMrr({}, (state, props, $) => { return (<div> { props.trainId } <input name="name" value={ props.name } onChange={ $('setDetails', e => ['name', e.target.value, props.i]) } /> <input name="surname" value={ props.surname } onChange={ $('setDetails', e => ['surname', e.target.value, props.i]) }/> <a href="#" onClick={ $('removeSeat', props.i) }>X</a> </div>); }) const Tickets = withMrr({ $init: { results: {}, selectedSeats: [], } stationFrom: 'selectStationFrom/val', stationTo: 'selectStationTo/val', searchQuery: [(from, to, date) => ({ from, to, date }), '-stationFrom', '-stationTo', '-date', 'searchTrains'], results: ['promise', (query) => getTrains(query.from, query.to, query.date), 'searchQuery'], availableTrains: 'results.data', selectedSeats: [(seats, prev) => [...seats, ...prev], '*/selectSeats', '^'] }, (state, props, $, connectAs) => { return (<div> <div> <OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } /> - <OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } /> <input type="date" onChange={ $('date') } /> <button onClick={ $('searchTrains') }></button> </div> <div> { state.results.loading && <div className="loading">...</div> } { state.results.error && <div className="error"> . , . .</div> } { state.availableTrains && <div className="results"> { state.availableTrains.map((train, i) => <TrainSeats key={i} {...train} {...connectAs('train' + i)}/>) } </div> } { state.selectedSeats.map((seat, i) => <SeatDetails key={i} i={i} { ...seat } {...connectAs('seat' + i)}/>) } </div> </div>); });
selectedSeats单元格包含选定位置的数组。 当用户输入每个票证的名称和姓氏时,我们必须更改数组对应元素中的数据。
selectedSeats: [(seats, details, prev) => {
标准方法不适合我们:在公式中,我们需要知道哪个单元已更改并做出相应响应。 合并运算符的一种形式将为我们提供帮助。
selectedSeats: ['merge', { '*/selectSeats': (seats, prev) => { return [...prev, ...seats]; }, '*/setDetails': ([field, value, i], prev) => { prev[i][field] = value; return prev; }, '*/removeSeat': (i, prev) => { prev.splice(i, 1); return prev; }, }, '^'],
这有点像Redux减速器,但是语法更加灵活和强大。 而且,您不必担心会对数组进行更改,因为只有一个单元格的公式可以控制该数组,因此不包括并行更改(但是对作为参数传递的数组进行更改当然不值得)。
反应性馆藏
单元格存储和更改数组时的模式非常普遍。 数组的所有操作都具有三种类型:插入,修改,删除。 为了描述这一点,有一个优雅的
coll运算符。 用它来简化
selectedSeats的计算。
那是:
selectedSeats: ['merge', { '*/selectSeats': (seats, prev) => { return [...prev, ...seats]; }, '*/setDetails': ([field, value, i], prev) => { prev[i][field] = value; return prev; }, '*/removeSeat': (i, prev) => { prev.splice(i, 1); return prev; }, 'addToCart': () => [], }, '^']
成为:
selectedSeats: ['coll', { create: '*/selectSeats', update: '*/setDetails', delete: ['merge', '*/removeSeat', [() => ({}), 'addToCart']] }]
但是,setDetails流中的数据格式需要稍作更改:
<input name="name" onChange={ $('setDetails', e => [{ name: e.target.value }, props.i]) } /> <input name="surname" onChange={ $('setDetails', e => [{ surname: e.target.value }, props.i]) }/>
使用
coll运算符,我们描述了三个会影响数组的线程。 在这种情况下,
创建流必须包含元素本身,应将这些元素添加到数组(通常是对象)中。
delete流接受要删除的元素的索引(均在'* / removeSeat'中)和掩码。 掩码{}将删除所有元素,例如,掩码{name:'Carl'}将删除所有名称为Carl的元素。
更新流接受成对的值:需要使用元素(掩码或函数)进行的更改,以及需要更改的元素的索引或掩码。 例如,[{{surname:'Johnson'},{}]将约翰逊姓氏设置为数组的所有元素。
coll运算符使用诸如内部查询语言之类的东西,从而使使用集合变得更加容易,并使它更具声明性。
JsFiddle
中我们应用程序
的完整代码 。
我们熟悉mrr几乎所有必需的基本功能。 全球财富管理仍然是一个相当重要的话题,全球财富管理可能会在以后的文章中进行讨论。 但是现在您可以开始使用mrr来管理一个组件或一组相关组件内部的状态。
结论
mrr的作用是什么?
mrr允许您以功能响应式的方式在React中编写应用程序(mrr可以解密为Make React Reactive)。 mrr非常有表现力-您花费更少的时间编写代码行。
mrr提供了一小组基本抽象,足以满足所有情况-本文介绍了mrr的几乎所有主要功能和技术。 ( ). , — , , , .. mrr , .
( ), «», mrr — , . , , , — mrr ( mrr «--», ).
?
«» , . , , . , mrr , , , , Redux , state setState, .
?
, — , . , ClojureScript, , React . Redux, mrr , . , mrr « », , mrr .
?
, :) , , API , ( ), . , mrr React', (React ).
, !