HOME | EDIT | RSS | INDEX | ABOUT | GITHUB

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, XDecXCounter, 然后根据业务逻辑的需要, 将这三个功能拼起来, 再应用到我们写好的 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 的稍加配置.

多个参数

一个简单的计数器就这么写完了, 但是如果你够仔细, 会发现 incdec 都是单参数的函数, 但比如一个计算肥胖的 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起来就好了.