如今,很少有人用Perl写作,但是拉里·沃尔(Larry Wall)著名的格言“让简单的事情变得容易和困难的事情变得可能”已成为有效技术的公认公式。 它可以从任务的复杂性和方法的角度进行解释:理想的技术一方面应允许中小型应用程序(包括“只写”)的快速开发,另一方面,应为深思熟虑的开发提供工具。可靠性,可维护性和结构性至关重要的复杂应用。 甚至翻译成人类平面:可供琼斯使用,并同时满足Signyors的要求。
现在可以从两方面批评现在流行的编辑器-至少要这样一个事实:即使编写基本功能也可能导致多个文件之间的行距很长-但是我们不会做得更深,因为已经对此进行了很多论述。
“您应该将所有桌子放在一个房间里,把椅子放在另一个房间里”
-Bacon.js库的创建者Juha Paananen关于编辑器的信息
今天将要讨论的技术不是灵丹妙药,而是声称与这些标准更加一致。
Mrr是一个功能性的反应式库,它奉行“万物皆流”的原则。 功能响应式方法在mrr中提供的主要优点是代码的简洁性,表达性以及用于同步和异步数据转换的统一方法。
乍一看,这听起来不像是一种可供初学者轻松使用的技术:流的概念可能很难理解,它在前端并不普及,主要与Rx这样的哑库相关联。 最重要的是,还不清楚如何基于基本的action-reaction-update DOM方案来解释流程。 但是...我们不会抽象地谈论流量! 让我们谈谈更容易理解的事情:事件,条件。
按照食谱做饭
在不涉及FRP的情况下,我们将遵循一个简单的方案来规范主题领域:
- 列出描述页面状态的数据列表,这些数据将在用户界面中使用,以及它们的类型。
- 在页面上列出用户发生的事件或由用户生成的事件,以及将与事件一起传输的数据类型
- 列出页面上将要发生的过程
- 确定它们之间的相互依赖性。
- 使用适当的运算符描述相互依赖性。
同时,我们仅在最后阶段才需要图书馆的知识。
因此,让我们以一个网上商店的简化示例为例,其中有一个按类别进行分页和过滤的产品列表以及一个购物篮。
建立接口所依据的数据:
- 商品清单(数组)
- 选择的类别(行)
- 带商品的页数(数量)
- 购物篮中的产品列表(数组)
- 当前页数
- 购物篮中的产品数量(数量)
事件(通过“事件”,它们仅表示瞬时事件。需要将一段时间内发生的动作(过程)分解为单独的事件):
- 打开页面(无效)
- 类别选择(字符串)
- 将商品添加到购物篮(“商品”对象)
- 从购物篮中取出货物(要删除的货物编号)
- 转到产品列表的下一页(编号-页码)
流程:这些动作是开始的,然后可以一次或之后以不同的事件结束。 在我们的例子中,这将是从服务器加载产品数据,这可能需要两个事件:成功完成和有错误完成。
事件和数据之间的相互依赖性。 例如,产品列表将取决于以下事件:“产品列表加载成功” 以及“开始加载商品清单”-从“打开页面”,“选择当前页面”,“选择类别”。 列出以下形式的元素:[...依赖项]:
{ requestGoods: ['page', 'category', 'pageLoaded'], goods: ['requestGoods.success'], page: ['goToPage', 'totalPages'], totalPages: ['requestGoods.success'], cart: ['addToCart', 'removeFromCart'], goodsInCart: ['cart'], category: ['selectCategory'] }
哦...但这几乎是mrr的代码!

仅保留添加将描述关系的功能。 您可能希望事件,数据,进程在mrr中是不同的实体-但是不,所有这些都是线程! 我们的任务是正确连接它们。
如您所见,我们有两种类型的依赖关系:“事件”中的“数据”(例如,goToPage中的页面)和“数据”中的“数据”(购物车中的goodsInCart)。 对于每个人都有适当的方法。
最简单的方法是使用“数据中的数据”:在这里,我们仅添加一个纯函数“公式”:
goodsInCart: [arr => arr.length, 'cart'],
每次更改购物车数组时,都会重新计算goodsInCart的值。
如果我们的数据取决于一个事件,那么一切也都非常简单:
category: 'selectCategory', goods: [resp => resp.data, 'requestGoods.success'], totalPages: [resp => resp.totalPages, 'requestGoods.success'],
形式[函数,...线程参数]的设计是mrr的基础。 为了直观理解,使用Excel作类比,mrr中的流也称为单元格,计算它们所依据的函数称为公式。
如果我们的数据取决于多个事件,则必须分别转换它们的值,然后使用merge运算符将它们组合为一个流:
page: ['merge', [a => a, 'goToPage'], [(a, prev) => a < prev ? a : prev, 'totalPages', '-page'] ], cart: ['merge', [(item, arr) => [...arr, item], 'addToCart', '-cart'], [(id, arr) => arr.filter(item => item.id !== id), 'removeFromCart', '-cart'], ],
在两种情况下,我们都引用该单元格的先前值。 为避免无限循环,我们被动地引用了cart和page单元(单元名称前的减号):它们的值将替换为公式,但是如果它们更改,则不会开始重新计算。
所有线程都基于其他线程构建或从DOM发出。 但是“打开页面”流又如何呢? 幸运的是,您不必使用componentDidMount:在mrr中,有一个特殊的流$ start,表示已创建并安装了该组件。
“进程”是异步计算的,虽然我们从中发出某些事件,但“嵌套”运算符将在这里为我们提供帮助:
requestGoods: ['nested', (cb, page, category) => { fetch("...") .then(res => cb('success', res)) .catch(e => cb('error', e)); }, 'page', 'category', '$start'],
当使用嵌套运算符时,第一个参数将传递给我们一个用于发出某些事件的回调函数。 在这种情况下,可以从外部通过根单元的名称空间访问它们,例如,
cb('success', res)
在requestGoods公式中,将生成requestGoods.success单元格的更新。
为了在计算数据之前正确呈现页面,您可以指定其初始值:
{ goods: [], page: 1, cart: [], },
添加标记。 我们使用withMrr函数创建一个React组件,该函数接受一个反应式链接图和render函数。 为了将值“放入”流,我们使用$函数,该函数创建(并缓存)事件处理程序。 现在,我们可以正常运行的应用程序如下所示:
import { withMrr } from 'mrr'; const App = withMrr({ $init: { goods: [], cart: [], page: 1, }, requestGoods: ['nested', (cb, page = 1, category = 'all') => { fetch('https://reqres.in/api/products?page=', page, category).then(r => r.json()) .then(res => cb('success', res)) .catch(e => cb('error', e)) }, 'page', 'selectCategory', '$start'], goods: [res => res.data, 'requestGoods.success'], page: ['merge', 'goToPage', [(a, prev) => a < prev ? a : prev, 'totalPages', '-page']], totalPages: [res => res.total_pages, 'requestGoods.success'], category: 'selectCategory', cart: ['merge', [(item, arr) => [...arr, item], 'addToCart', '-cart'], [(id, arr) => arr.filter(item => item.id !== id), 'removeFromCart', '-cart'], ], }, (state, props, $) => { return (<section> <h2>Shop</h2> <div> Category: <select onChange={$('selectCategory')}> <option>All</option> <option>Electronics</option> <option>Photo</option> <option>Cars</option> </select> </div> <ul className="goods"> { state.goods.map((item, i) => { const cartI = state.cart.findIndex(a => a.id === item.id); return (<li key={i}> { item.name } <div> { cartI === -1 && <button onClick={$("addToCart", item)}>Add to cart</button> } { cartI !== -1 && <button onClick={$("removeFromCart", item.id)}>Remove from cart</button> } </div> </li>); }) } </ul> <ul className="pages"> { new Array(state.totalPages).fill(true).map((_, p) => { const page = Number(p) + 1; return ( <li className="page" onClick={$('goToPage', page)} key={p}> { page } </li> ); }) } </ul> </section> <section> <h2>Cart</h2> <ul> { state.cart.map((item, i) => { return (<li key={i}> { item.name } <div> <button onClick={$("removeFromCart", item.id)}>Remove from cart</button> </div> </li>); }) } </ul> </section>); }); export default App;
建筑施工
<select onChange={$('selectCategory')}>
表示更改该字段时,该值将“推送”到selectCategory流中。 但是,这是什么意思呢? 默认情况下,这是event.target.value,但是如果需要推送其他内容,则可以使用第二个参数指定它,如下所示:
<button onClick={$("addToCart", item)}>
这里的所有内容-事件,数据和流程-都是流程。 事件的触发会导致重新计算取决于它的数据或事件,依此类推。 使用可以返回值或承诺的公式计算从属流的值(然后mrr将等待其解析)。
mrr API非常简明扼要-在大多数情况下,我们只需要3-4个基本运算符,没有它们就可以完成很多事情。 当产品列表未成功加载时,添加一条错误消息,该消息将显示一秒钟:
hideErrorMessage: [() => new Promise(res => setTimeout(res, 1000)), 'requestGoods.error'], errorMessageShown: [ 'merge', [() => true, 'requestGoods.error'], [() => false, 'hideErrorMessage'], ],
盐,胡椒粉 糖调味
mrr中还有语法糖,它对于开发是可选的,但可以加快速度。 例如,切换运算符:
errorMessageShown: ['toggle', 'requestGoods.error', [() => new Promise(res => setTimeout(res, 1000)), 'showErrorMessage']],
第一个参数的更改会将单元格设置为true,第二个参数将设置为false。
将异步任务的结果“分解”为成功和错误子单元格的方法也非常广泛,您可以使用特殊的promise运算符(自动消除竞争条件):
requestGoods: [ 'promise', (page = 1, category = 'all') => fetch('https://reqres.in/api/products?page=', page, category).then(r => r.json()), 'page', 'selectCategory', '$start' ],
仅几十行中就包含了很多功能。 我们有条件的June感到满意-他设法编写了工作代码,事实证明代码非常紧凑:所有逻辑都集中在一个文件和一个屏幕上。 但是签名者斜视了一下:Eka看不见...您可以在钩子上/重新编写/撰写此类内容。
是的,的确是这样! 当然,代码不太可能更加紧凑和结构化,但这不是重点。 假设该项目正在开发中,我们需要将功能分为两个单独的页面:产品列表和购物篮。 而且,很显然,购物篮数据需要在两个页面上全局存储。
一种方法,一种界面
在这里,我们遇到了反应开发的另一个问题:存在用于在整个应用程序级别本地(在组件内)和全局管理状态的异构方法。 我敢肯定,许多人都面临一个难题:在本地或全局实施某些逻辑? 或另一种情况:事实证明,某些局部数据需要全局保存,并且您必须重写部分功能,例如,从重新编写到编辑器...
对比当然是人为的,而在mrr中则不是:它同样好,而且最重要的是-均匀! -适用于本地和全球状态管理。 通常,我们不需要任何全局状态,只需要在组件之间交换数据,因此根组件的状态将是“全局”。
现在,我们的应用方案如下:包含购物篮中商品清单的根组件和两个子组件:商品和购物篮,全局组件“侦听”流“添加到购物篮”和“从购物篮中删除”子组件。
const App = withMrr({ $init: { cart: [], currentPage: 'goods', }, cart: ['merge', [(item, arr) => [...arr, item], 'addToCart', '-cart'], [(id, arr) => arr.filter(item => item.id !== id), 'removeFromCart', '-cart'], ], }, (state, props, $, connectAs) => { return ( <div> <menu> <li onClick={$('currentPage', 'goods')}>Goods</li> <li onClick={$('currentPage', 'cart')}>Cart{ state.cart && state.cart.length ? '(' + state.cart.length + ')' : '' }</li> </menu> <div> { state.currentPage === 'goods' && <Goods {...connectAs('goods', ['addToCart', 'removeFromCart'], ['cart'])}/> } { state.currentPage === 'cart' && <Cart {...connectAs('cart', { 'removeFromCart': 'remove' }, ['cart'])}/> } </div> </div> ); })
const Goods = withMrr({ $init: { goods: [], page: 1, }, goods: [res => res.data, 'requestGoods.success'], requestGoods: [ 'promise', (page = 1, category = 'all') => fetch('https://reqres.in/api/products?page=', page, category).then(r => r.json()), 'page', 'selectCategory', '$start' ], page: ['merge', 'goToPage', [(a, prev) => a < prev ? a : prev, 'totalPages', '-page']], totalPages: [res => res.total, 'requestGoods.success'], category: 'selectCategory', errorShown: ['toggle', 'requestGoods.error', [cb => new Promise(res => setTimeout(res, 1000)), 'requestGoods.error']], }, (state, props, $) => { return (<div> ... </div>); });
const Cart = withMrr({}, (state, props, $) => { return (<div> <h2>Cart</h2> <ul> { state.cart.map((item, i) => { return (<div> { item.name } <div> <button onClick={$('remove', item.id)}>Remove from cart</button> </div> </div>); }) } </ul> </div>); });
几乎没有变化,这真是太神奇了! 我们只需将流程布置到相应的组件中,并在它们之间架起“桥梁”! 通过使用mrrConnect函数连接组件,我们为下游和上游流指定了映射:
connectAs( 'goods', ['addToCart', 'removeFromCart'], ['cart'] )
在这里,子组件的addToCart和removeFromCart流将转到父组件,而购物车流将返回。 我们不需要使用相同的流名称-如果它们不匹配,则使用映射:
connectAs('cart', { 'removeFromCart': 'remove' })
来自子组件的remove流将成为父组件中removeFromCart流的源。
如您所见,在mrr情况下选择数据存储位置的问题已完全消除:将数据存储在逻辑上确定的位置。
再次强调,编辑器的缺点:您必须将所有数据保存在一个中央存储库中。 甚至只有一个单独的组件或其子树可能请求和使用的数据! 如果我们以“编辑风格”撰写,那么我们还将货物的装载和分页提高到全球水平(公平地说,由于mrr的灵活性,这种方法也是可行的,并且具有生命权,即源代码 )。
但是,这不是必需的。 装载的货物仅用于货物的组成部分,因此,将它们带到全球范围,我们只会阻塞并膨胀全球状态。 此外,当用户再次返回到产品页面时,我们将必须清除过时的数据(例如,分页页面)。 选择合适的数据存储级别,我们会自动避免此类问题。
这种方法的另一个优势是将应用程序逻辑与演示文稿结合在一起,这使我们能够将各个React组件重用为功能齐全的小部件,而不是“哑”模板。 同样,在全局级别上保留最少的信息(理想情况下,这只是会话数据),并去除页面各个组件中的大多数逻辑,我们大大降低了代码的一致性。 当然,这种方法并不总是适用,但是有许多任务的全局状态非常小,并且各个“屏幕”几乎完全彼此独立:例如,各种管理员等。 与之不同的是,编辑器激发我们将需要的和不需要的所有内容都带到了全局级别,mrr允许您将数据存储在单独的子树中,从而鼓励并实现封装,从而将我们的应用程序从单片“饼”变成分层的“蛋糕”。
值得一提的是:建议的方法当然没有革命性的新功能! 自js框架问世以来,组件,独立的小部件一直是使用的基本方法之一。 唯一的显着区别是,mrr遵循声明性原则:组件只能侦听其他组件的流,而不能影响它们(从下向上或从上到下的方向(与磁通不同的是什么)方法)。 只能与基础组件和父组件交换消息的智能组件与前端开发中流行但鲜为人知的参与者模型相对应(在反应式编程简介中很好地介绍了在前端使用参与者和线程的主题)。
当然,这离参与者的规范实现还差得很远,但是本质就是这样:参与者的角色是由通过MPP流交换消息的组件来扮演的; 一个组件可以(声明地!)通过虚拟DOM和React来创建和删除子组件actor:本质上,render函数确定子actor的结构。
代替React的标准情况,当我们通过props将父组件“放”到某个回调中时,我们应该侦听来自父组件的子组件流。 从父母到孩子,这是相反的方向。 例如,您可能会问:为什么将购物篮数据作为流传输到购物车组件,如果我们可以不费吹灰之力地简单地将它们作为道具传递呢? 有什么区别? 确实,也可以使用这种方法,但是只能在需要对道具更改做出响应之前使用。 如果您曾经使用componentWillReceiveProps方法,那么您知道这是什么意思。 这是一种“对穷人的反应”:您绝对会听所有道具的变化,确定发生了什么变化并做出反应。 但是这种方法很快就会从React中消失,并且可能需要对“来自上方的信号”做出反应。
在mrr中,流不仅向上“向下”流动,还向下“向下”流动组件层次,从而使各个组件可以独立地响应状态变化。 这样,您就可以使用mrr jet工具的全部功能。
const Cart = withMrr({ foo: [items => {
添加一点官僚主义
该项目正在不断发展,很难跟踪流程的名称,它们是-哦,恐怖! -存储在行中。 好吧,我们可以将常量用于流名称以及mrr语句。 现在通过打个小错字来破坏应用程序变得更加困难。
import { withMrr } from 'mrr'; import { merge, toggle, promise } from 'mrr/operators'; import { cell, nested, $start$, passive } from 'mrr/cell'; const goods$ = cell('goods'); const page$ = cell('page'); const totalPages$ = cell('totalPages'); const category$ = cell('category'); const errorShown$ = cell('errorShown'); const addToCart$ = cell('addToCart'); const removeFromCart$ = cell('removeFromCart'); const selectCategory$ = cell('selectCategory'); const goToPage$ = cell('goToPage'); const Goods = withMrr({ $init: { [goods$]: [], [page$]: 1, }, [goods$]: [res => res.data, requestGoods$.success], [requestGoods$]: promise((page, category) => fetch('https://reqres.in/api/products?page=', page).then(r => r.json()), page$, category$, $start$), [page$]: merge(goToPage$, [(a, prev) => a < prev ? a : prev, totalPages$, passive(page$)]), [totalPages$]: [res => res.total, requestGoods$.success], [category$]: selectCategory$, [errorShown$]: toggle(requestGoods$.error, [cb => new Promise(res => setTimeout(res, 1000)), requestGoods$.error]), }, ...);
黑匣子里有什么?
那测试呢? mrr组件中描述的逻辑很容易与模板分离,然后进行测试。
让我们将mrr结构与文件分开。
const GoodsStruct = { $init: { [goods$]: [], [page$]: 1, }, ... } const Goods = withMrr(GoodsStruct, (state, props, $) => { ... }); export { GoodsStruct }
然后将其导入测试中。 一个简单的包装,我们可以
将值放入流中(就像它是从DOM中完成的一样),然后检查依赖于它的其他线程的值。
import { simpleWrapper} from 'mrr'; import { GoodsStruct } from '../src/components/Goods'; describe('Testing Goods component', () => { it('should update page if it\'s out of limit ', () => { const a = simpleWrapper(GoodsStruct); a.set('page', 10); assert.equal(a.get('page'), 10); a.set('requestGoods.success', {data: [], total: 5}); assert.equal(a.get('page'), 5); a.set('requestGoods.success', {data: [], total: 10}); assert.equal(a.get('page'), 5); }) })
闪耀和缺乏反应性
值得注意的是,与基于编辑器中事件的状态的“手动”形式相比,反应性是更高层次的抽象。 一方面,促进发展会创造机会让自己步履蹒跚。 考虑这种情况:用户转到第5页,然后切换过滤器“类别”。 我们必须在第五页上加载所选类别的产品列表,但事实证明,该类别的商品只有三页。 对于“愚蠢”的后端,我们的操作算法如下:
- 请求数据页= 5&类别=%类别%
- 从答案中取页数的值
- 如果返回零个记录,则请求最大的可用页面
如果要在编辑器上实现此功能,则必须使用所描述的逻辑创建一个大型异步动作。 在与mrr发生反应的情况下,无需单独描述此方案。 这些行中已经包含了所有内容:
[requestGoods$]: ['nested', (cb, page, category) => { fetch('https://reqres.in/api/products?page=', page).then(r => r.json()) .then(res => cb('success', res)) .catch(e => cb('error', e)) }, page$, category$, $start$], [totalPages$]: [res => res.total, requestGoods$.success], [page$]: merge(goToPage$, [(a, prev) => a < prev ? a : prev, totalPages$, passive(page$)]),
如果新的totalPages值小于当前页面,我们将更新页面值,从而向服务器发起第二个请求。
但是,如果我们的函数返回相同的值,它仍将被视为页面流中的更改,然后是所有相关流的限制。 为避免这种情况,mrr具有特殊含义-跳过。 返回它,我们发出信号:没有发生任何更改,不需要更新任何内容。
import { withMrr, skip } from 'mrr'; [requestGoods$]: nested((cb, page, category) => { fetch('https://reqres.in/api/products?page=', page).then(r => r.json()) .then(res => cb('success', res)) .catch(e => cb('error', e)) }, page$, category$, $start$), [totalPages$]: [res => res.total, requestGoods$.success], [page$]: merge(goToPage$, [(a, prev) => a < prev ? a : skip, totalPages$, passive(page$)]),
因此,一个小错误可能导致我们陷入无限循环:如果我们返回的不是“ skip”,而是“ prev”,则页面单元将发生变化,第二个请求将发生,依此类推。 当然,这种情况的可能性不是FRP或mrr的“缺陷”,因为无限递归或循环的可能性并不表示结构化程序设计的缺陷。 然而,应该理解,mrr仍然需要对反应机理的一些理解。 回到众所周知的刀具隐喻,mrr是一把非常锋利的刀,可以提高工作效率,但也可以伤害无能的工人。
顺便说一句,无需安装任何扩展程序就可以很容易地从mrr借记:
const GoodsStruct = { $init: { ... }, $log: true, ... }
只需在mrr结构中添加$ log:true,对单元格的所有更改都将输出到控制台,因此您可以看到哪些更改以及如何更改。
诸如被动收听或跳过含义之类的概念不是特定的“拐杖”:它们扩展了反应性的可能性,因此它可以轻松地描述应用程序的整个逻辑,而无需诉诸命令性方法。 例如,Rx.js中有类似的机制,但是它们的接口使用起来不太方便。 : Mrr: FRP
.
总结
- FRP, mrr ,
- : ,
- , ,
- , , - ( - !)
- mrr : " , !"
- ,
- , , ( ). !
- : , , , TMTOWTDI: , - .
聚苯乙烯
. , mrr , , :
import useMrr from 'mrr/hooks'; function Foo(props){ const [state, $, connectAs] = useMrr(props, { $init: { counter: 0, }, counter: ['merge', [a => a + 1, '-counter', 'incr'], [a => a - 1, '-counter', 'decr'] ], }); return ( <div> Counter: { state.counter } <button onClick={ $('incr') }>increment</button> <button onClick={ $('decr') }>decrement</button> <Bar {...connectAs('bar')} /> </div> ); }