4

file:xreact/fun-with-fantasy.org

 1 year ago
source link: https://blog.oyanglul.us/xreact/fun-with-fantasy.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

xReact Fantasy

Table of Contents

(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 层是一个简单的 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'))
Height: 175 cmWeight: 70 kg

HEALTH:

BMI: 0

试着拖动滑条, 有没有发现有个问题, 多动第一个滑条的时候页面没有任何变化, 知道第二个滑条被拖动才有反应.

这是因为我们只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'))
Height: 175 cmWeight: 70 kg

HEALTH:

BMI: 0

(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.

Height: 175 cmWeight: 70 kg

HEALTH:

BMI: 0

文章中的代码都是可以跑的, orgmode tangle 出来的代码见 这里

通过这两个例子, 你会发现使用 FantasyX 的原因非常简单, 明显代码中不需要像 redux 和 saga 一样命令式的 verbose 代码, 完全减掉了 reactive programming 的概念, 并不需要理解如何去filter map merge什么 Observable, 只需要简单的把一般函数(aka reducer 如果你喜欢redux的话)lift起来就好了.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK