xReact Fantasy
(require 'ob-shell)
mkdir -p src/transforms src/components src/views
xReact 从 2.3 版本之后多了一种模式, 纯函数式的 FantasyX. 大概思想是把所有的逻辑和操作写到普通函数,然后 lift 到 FantasyX, 之后就可以轻松完成各种 map, concat 之类的转换和组合, 形成新的 FantasyX.
如果听起来太奇怪, 让我们来看一个不是那么简单的 Counter 计数器实现.
Counter 例子
Transform
要知道计数器的核心逻辑其实非常简单:
import {lift, xinput} from 'xreact'
export function inc(state) {
return {count: state.count + 1}
}
export function dec(state) {
return {count: state.count -1 }
}
要么加1, 要么减1, 这个不那么简单计数器还多一个功能,可以改数字, 来来, 免费试玩一下:
先让我们看看如何把加减法lift起来
export const XInc = lift(inc)(xinput('inc'))
export const XDec = lift(dec)(xinput('dec'))
注意, lift(inc)
免费得到了个从 FantasyX 到 FantasyX 的函数, 再接收一个 FantasyX 类型的 xinput('inc')
, 得到我们需要的带有加一逻辑的FantasyX实例.
这里的 xinput
则免费创建一个带有名字为 "inc"
的输入框的值的 FantasyX.
所以如果我们用一般的函数 inc
调用 inc({count:0})
会返回值 {count:1}
同样的, 一旦 lift 起来, 所有的操作外面只是多套了个 FantasyX 类型而已.
完全可以想象成 FantasyX(inc)(FantasyX({count:0}))
返回 FantasyX({count:1})
. 虽然实际上内部实现并非如此简单, 事实上FantasyX内部的值都是reactive的.
对了我们还忘了可以改的计数器输入框
export const XCount = xinput('count').map(state => ({count: ~~state.count}))
就像我说过的 xinput('count')
会免费得到一个 FantasyX, 一旦有了这个类型, 我们是可以map的.
由于 input
DOM 上能拿到的 value
只能是 String 类型, 这里特意 map 一下把 String 转换成 Number
View
View 层是一个简单的 React stateless component
import * as React from 'react'
export const View = props => (
<div>
<input type="button" name="dec" onClick={(e)=>props.actions.fromEvent(e)} value="-" />
<input type="number" name="count" value={props.count} onChange={props.actions.fromEvent} />
<input type="button" name="inc" onClick={(e)=>props.actions.fromEvent(e)} value="+" />
</div>
)
View.defaultProps = {count: 0}
只需要把事件都 hook 到 props.actions
上
给 View 一个 defaultProps, 这样这个component就 selfcontain 了
Component
现在我们有了几个 FantasyX 类型, XInc
, XDec
和 XCounter
, 然后根据业务逻辑的需要, 将这三个功能拼起来, 再应用到我们写好的 View 上.
import {xinput,pure} from 'xreact'
import {XInc, XDec, XCount} from '../transforms/counter'
import {View} from '../views/counter'
const XCounter = XInc.concat(XDec).concat(XCount)
export const Counter = XCounter.apply(View)
concat
是 Monoid 的操作, 类似于两个数组的 concat
, 两个数组的内容会合到一起. 在 FantasyX 的概念里, concat 也是将内容合到一起, 而 FantasyX 的内容就是 reactive 的 state.
concat 到一起的 XCounter
依然是 FantasyX 类型, 我们可以 apply
到任何一个 View
上, 获得 正常的 Component 一枚.
render
import * as React from 'react'
import { render } from 'react-dom';
import {Counter} from './components/counter'
import {X} from 'xreact'
import * as RX from 'xreact/lib/xs/rx'
const xmount = (component, dom) => render(React.createFactory(X)({ x: RX }, component), dom)
xmount(<Counter />, document.getElementById('counter-app'))
最后, 当然是将这个 Counter component render 到 dom 上, 为了获得 Observable 引擎的选择, rxjs 或 mostjs, 需要给 xReact 的稍加配置.
多个参数
一个简单的计数器就这么写完了, 但是如果你够仔细, 会发现 inc
和 dec
都是单参数的函数, 但比如一个计算肥胖的 BMI Calculate, 需要同时有两个输入, 身高与体重, 才能计算出结果. 这时候怎么办呢?
比如我们的 View 是这样的
import * as React from 'react'
export const View = props => (
<div>
<label>Height: {props.height} cm
<input type="range" name="height" onChange={props.actions.fromEvent} min="150" max="200" defaultValue={props.height} />
</label>
<label>Weight: {props.weight} kg
<input type="range" name="weight" onChange={props.actions.fromEvent} min="40" max="100" defaultValue={props.weight} />
</label>
<p>HEALTH: <span>{props.health}</span></p>
<p>BMI: <span>{props.bmi}</span></p>
</div>
)
View.defaultProps = {health: '', bmi: 0, height: 175, weight: 70}
import {lift2, xinput} from 'xreact'
function bmiCalc({weight}, {height}) {
let health = 'N/A'
let bmi = weight * 10000 / (height * height)
if (bmi < 18.5) health = 'underweight'
else if (bmi < 24.9) health = 'normal'
else if (bmi < 30) health = 'Overweight'
else if (bmi >= 30) health = 'Obese'
return { bmi: bmi.toFixed(2), health }
}
function strToInt(field) {
return function(s) {
s[field] = ~~s[field]
return s
}
}
export const XWeight = xinput('weight').map(strToInt('weight'))
export const XHeight = xinput('height').map(strToInt('height'))
export const XBMI = lift2(bmiCalc)(XWeight, XHeight)
主要的逻辑也没什么不同, 把输入框 height 和 weight 都变成 FantasyX, lift bmiCalc, 然后应用到 height 和 weight 上.
render 到页面上, 大概就能工作了
import {XBMI} from './transforms/bmi.js'
import {View as BV} from './views/bmi.js'
const BMI = XBMI.apply(BV)
xmount(<BMI />, document.getElementById('bmi-app'))
试着拖动滑条, 有没有发现有个问题, 多动第一个滑条的时候页面没有任何变化, 知道第二个滑条被拖动才有反应.
这是因为我们只lift 了 bmiCalc, 这个函数必须两个参数都有值时才会有反应.
如果想拖动第一个滑块时 height 数字会跟着变, 我们只需要将 height 拖动的动作 concat 到一起就好了.
import {XBMI, XWeight, XHeight} from './transforms/bmi.js'
const BMI2 = XBMI.concat(XWeight).concat(XHeight).apply(BV)
xmount(<BMI2 />, document.getElementById('bmi-app-2'))
(org-babel-tangle)
src/app.jsx | src/views/counter.js | src/components/counter.js | src/transforms/counter.js |
yarn build
yarn | build | v0.27.5 | |||||
$ | browserify | -p | tsify | src/app.jsx | -dv | > | public/js/app.js |
Done | in | 4.65s. |
Asynchronous
异步一直是前端头疼的问题, 想想你用 redux, 或需要 saga 的帮助, 框架的胶水代码和 verbose 的命令式设计, 让你不但需要了解他们的概念和原理, 还需要写大量跟业务逻辑无关的多余的代码.
但是即使用 Reactive 库如 rxjs 或 mostjs 也需要大量的 FRP 背景知识和对 Observable 的理解.
所以 FantasyX 提供了这一层的抽象, 弱化了你需要关心的异步问题.
假设还是这个例子, 但是 bmi 的计算逻辑发生在后端. 这时, 异步需求就来了.
import {lift2, xinput} from 'xreact'
import {XWeight, XHeight} from './bmi'
function bmiCalc({weight}, {height}) {
return {
result:fetch(`https://gist.github.com.ru/jcouyang/edc3d175769e893b39e6c5be12a8526f?height=${height}&weight=${weight}`)
.then(resp => resp.json())
.then(resp => resp.result)
}
}
export const XBMI = lift2(bmiCalc)(XWeight, XHeight)
跟一般的函数一样,我们返回一个 state 的 patch, 不同的是, state 的 value 是个 promise.
一点也不用担心这个 promise, 它只是一个 lazy 的值, 在最后会 eval, 其结果 patch 到 state 上.
import {XBMI as XASYNCBMI} from './transforms/async-bmi.js'
const BMI3 = XASYNCBMI.concat(XWeight).concat(XHeight).apply(BV)
xmount(<BMI3 />, document.getElementById('bmi-app-3'))
我们render 到页面上就是这个效果了. 点击后本地不会发生计算, 注意看network 会发送请求到 github.com.ru 然后结果返回后页面会update.
小结
文章中的代码都是可以跑的, orgmode tangle 出来的代码见 这里
通过这两个例子, 你会发现使用 FantasyX 的原因非常简单, 明显代码中不需要像 redux 和 saga 一样命令式的 verbose 代码, 完全减掉了 reactive programming 的概念, 并不需要理解如何去filter map merge什么 Observable, 只需要简单的把一般函数(aka reducer 如果你喜欢redux的话)lift起来就好了.