xufei / blog

my personal blog
6.67k stars 762 forks source link

单页应用的数据流方案探索 #47

Closed xufei closed 3 years ago

xufei commented 7 years ago

大家好,现在是2017年4月。过去的3年里,前端开发领域可谓风起云涌,革故鼎新。除了开发语言的语法增强和工具体系的提升之外,大部分人开始习惯几件事:

所谓组件化,很容易理解,把视图按照功能,切分为若干基本单元,所得的东西就可以称为组件,而组件又可以一级一级组合而成复合组件,从而在整个应用的规模上,形成一棵倒置的组件树。这种方法论历史久远,其实现方式或有瑜亮,理念则大同小异。

而MDV,则是对很多低级DOM操作的简化,把对DOM的手动修改屏蔽了,通过从数据到视图的一个映射关系,达到了只要操作数据,就能改变视图的效果。

Model-Driven-View

给定一个数据模型,可以得到对应的的视图,这一过程可以表达为:

V = f(M)

其中的f就是从Model到View的映射关系,在不同的框架中,实现方式有差异,整体理念则是类似的。

当数据模型产生变化的时候,其对应的视图也会随之变化:

V + ΔV = f(M + ΔM)

另外一个方面,如果从变更的角度去解读Model,数据模型不是无缘无故变化的,它是由某个操作引起的,我们也可以得出另外一个表达式:

ΔM = perform(action) 

把每次的变更综合起来,可以得到对整个应用状态的表达:

state := actions.reduce(reducer, initState)

这个表达式的含义是:在初始状态上,依次叠加后续的变更,所得的就是当前状态。这就是当前最流行的数据流方案Redux的核心理念。

从整体来说,使用Redux,相当于把整个应用都实现为命令模式,一切变动都由命令驱动。

Reactive Programming 库简介

在传统的编程实践中,我们可以:

但是,很难做到:提供一种会持续变化的数据让其他模块复用。

而一些基于Reactive Programming的库可以提供一种能力,把数据包装成可持续变更、可观测的类型,供后续使用,这种库包括:RxJS,xstream,most.js等等。

对数据的包装过程类似如下:

const a$ = xs.of(1)
const arr$ = xs.from([1, 2, 3])
const interval$ = xs.periodic(1000)

这段代码中的a$arr$interval$都是一种可观测的数据包装,如果对它们进行订阅,就可以收到所有产生的变更。

interval$.subscribe(console.log)

我们可以把这种封装结构视为数据管道,在这种管道上,可以添加统一的处理规则,这种规则会作用在管道中的每个数据上,并且形成新的管道:

const interval$ = xs.periodic(1000)
const result$ = interval$
  .filter(num => num % 3)
  .map(num => num * 2)

管道可被连续拼接,并形成新的管道。

需要注意的是:

也可以把多个管道组合在一起形成新的管道:

const priv$ = xs.combine(user$, article$)
  .map(arr => {
    const [user, article] = arr
    return user.isAdmin || article.creator === user.id
  })

从这个关系中可以看出,当user$task$中的数据发生变更的时候,priv$都会自动计算出最新结果。

在业务开发的过程中,可以使用数据流的理念,把很多东西提高一个抽象等级:

const data$ = xs.fromPromise(service(params))
  .map(data => ({ loading: false, data }))
  .replaceError(error => xs.of({ loading: false, error }))
  .startWith({
    loading: true,
    error: null,
  })

比如上面这个例子,统一处理了一个普通请求过程中的三种状态:请求前、成功、异常,并且把它们的数据:loading、正常数据、异常数据都统一成一种,视图直接订阅处理就行了。

高度抽象的数据来源

很多时候,我们进行业务开发,都是在一种比较低层次的抽象维度上,在低层抽象上,存在着太多的冗余过程。如果能够对数据的来源和去向做一些归纳会怎样呢?

比如说,从实体的角度,很可能一份数据初始状态有多个来源:

也很可能有多个事件都是在修改同一个东西:

如果不做归纳,可能会写出包含以上各种东西的逻辑组合。若干个类似的操作,在过滤掉额外信息之后,可能都是一样的。从应用状态的角度,我们不会需要关心一个数据究竟是从哪里来的,也不会需要关心是通过什么东西发起的修改。

用传统的Redux写法,可能会提取出一些公共方法:

const changeTodo = todo => {
  dispatch({type: 'updateTodo', payload: todo})
}

const changefromDOMEvent = () => {
  const todo = formState
  changeTodo(todo)
}

const changefromWebSocket = () => {
  const todo = fromWS
  changeTodo(todo)
}

基于方法调用的逻辑不能很好地展示一份数据的生命周期,它可能有哪些来源?可能被什么修改?它是经过几千年怎样的辛苦修炼之后才能够化成人形,跟你坐在一张桌子上喝咖啡?

我们可以借助RxJS或者xstream这样的库,以数据管道的理念,把这些东西更加直观地组织在一起:

初始状态来源

const fromInitState$ = xs.of(todo)
const fromLocalStorage$ = xs.of(getTodoFromLS())

// initState
const init$ = xs
  .merge(
    fromInitState$,
    fromLocalStorage$
  )
  .filter(todo => !todo)
  .startWith({})

数据变更过程的统一

const changeFromHTTP$ = xs.fromPromise(getTodo())
  .map(result => result.data)
const changeFromDOMEvent$ = xs
  .fromEvent($('.btn', 'click'))
  .map(evt => evt.data)
const changeFromWebSocket$ = xs
  .fromEvent(ws, 'message')
  .map(evt => evt.data)

// 合并所有变更来源
const changes$ = xs
  .merge(
    changeFromHTTP$,
    changeFromDOMEvent$,
    changeFromWebSocket$
  )

在这样的机制里,我们可以很清楚地看到一块数据的来龙去脉,它最初是哪里来的,后来可能会被谁修改过。所有这样的数据都放置在管道中,除了指定的入口,不会有其他东西能够修改这些数据,视图可以很安全地订阅他们。

基于Reactive理念的这些数据流库,一般是没有针对业务开发的强约束的,也以直接订阅并设置组件状态,也可以拿它按照Redux的理念来使用,丰俭由人。

简单的使用

changes$.subscribe(({ payload }) => {
  xxx.setState({ todo: payload })
})

类似Redux的使用方式

const updateActions$ = changes$
  .map(todo => ({type: 'updateTodo', payload: todo}))

const todo$ = changeActions$
  .fold((state, action) => {
    const { payload } = action
    return {...state, ...payload}
  }, initState)

组件与外置状态

我们前面提到,组件树是一个树形结构。理想中的组件化,是所有视图状态全部内置在组件中,一级一级传递。只有这样,才能达到组件的最佳可复用状态,并且,组件可以放心把自己该做的事情都做了。

但事实上,组件树的层级可能很多,这会导致传递层级很多,很繁琐,而且,存在一个经典问题,那就是兄弟组件,或者是位于组件树的不同树枝上的组件之间的通信很麻烦,必须通过共同的最近的祖先节点去转发。

像Redux这样的机制,把状态的持有和更新外置,然后通过connect这样的方法,去把特定组件所需的外部状态从props设置进去,但它不仅仅是一个转发器。

我们可以看到如下事实:

所以:

这样看来,可以通过中转器修改应用中的一切状态。那么,如果所有状态都可以通过中转器修改,是否意味着都应当通过它修改?

这个问题很大程度上等价于:

组件是否应当拥有自己的内部状态?

我们可能会有如下的选择:

这两种方式,在传统软件开发领域分别称为贫血组件、充血组件,它们的差别是:组件究竟是纯展示,还是带一些逻辑。

也可以拿蚁群和人群来形容这两种组件实践。单个蚂蚁的智能程度很低,但它可以接受蚁王的指令去做某些事情,所有的麻烦事情都集中在上层,决策层的事务非常繁琐。而人类则不同,每个人都有自己的思考和执行能力,一个管理有序的体系中,管理者只需决定他和自己直接下属所需要做的事情就可以了。

在React体系中,纯展示组件可被简化为这样的形式:

const ComponentA = (props) => {
  return (<div>{props.data}</div>)
}

显而易见,这种组件的优势在于它的展示结果只跟输入数据有关,所有状态外置,因此,在热替换等方面,可以做到极致。

然而,一旦这个组件复杂起来,自带交互,可能就需要在事件、生命周期上做文章,免不了会需要一些中间状态来表达组件自身的形态。

我们当然可以把这种状态也外置,但这么做有几个问题:

如果是一种单独提供的组件库,比如像Ant Design这样的,却要依赖一个外部的状态管理器,这是很不合适的,它会导致组件库带有倾向性,从而对使用者造成困扰。

总的来说,状态全外置,组件退化为贫血组件这种实践,可以得到不少好处,但代价是比较大的。

You might not need Redux这篇文章中,Redux的作者Dan Abramov提到:

Local State is Fine.

因此,我们就可能会面临一个尴尬的状况,在大部分实践中:

一个组件的状态,可能一半在组件内管理,一半在全局的Store里

以React为例,大致是这样一个状况:

constructor(props) {
  super(props)  
  this.state = { b: 1 }
}

render(props) {
  const a = this.state.b + props.c;
  return (<div>{a}</div>)
}

我们看到,在render里面,需要合并state和props的数据,但是在这里做这个事情,是破坏了render函数的纯洁性的。可是,除了这里,别的地方也不太适合做这种合并,怎么办呢?

所以,我们需要一种机制,能够把本地状态和props在render之外统一起来,这可能就是很多实践者倾向于把本地状态也外置的最重要原因。

在React + Redux的实践中,通常会使用connect对视图组件包装一层,变成一种叫做容器组件的东西,这个connect所做的事情就是把全局状态映射到组件的props中。

那么,考虑如下代码:

const mapStateToProps = (state: { a }) => {
  return { a }
}

// const localState = { b: 1 }
// const mapLocalStateToProps = localState => localState

const ComponentA = (props) => {
  const { a, b } = props
  const c = a + b
  return (<div>{ c }</div>)
}

return connect(mapStateToProps/*, mapLocalStateToProps*/)(ComponentA)

我们是否可以把一个组件的内部状态外置到被注释掉的这个位置,然后也connect进来呢?这段代码其实是不起作用的,因为对localState的改变不会被检测到,所以组件不会刷新。

我们先探索这种模式是否可行,然后再来考虑实现的问题。

MVI架构

Plug and Play All Your Observable Streams With Cycle.js这篇文章中,我们可以看到一组理念:

基于这套理念,编写代码的方式可以变得很简洁流畅:

在CycleJS的理念中,这种模式叫做MVI(Model View Intent)。在这套理念中,我们的应用可以分为三个部分:

整体结构可以这样描述:

App := View(Model(Intent({ DOM, Http, WebSocket })))

对比Redux这样的机制,它的差异在于:

此外,在CycleJS中,View是纯展示,连事件监听也不做,这部分监听的工作放在Intent中去做。

const model = (a$, b$) => {
  return xs.combine(a$, b$)
}

const view = (state$) => {
  return state$.map(({ a, b }) => {
    const c = a + b;
    return h2('c is ' + c)
  })
}

我们可以从中发掘这么一些东西:

对我们来说,这里面最大关键在于:所有东西的输入输出都是数据流,甚至连视图接受的参数、还有它的渲染结果也是一个流!奥秘就在这里。

因此,我们只需在把待传入视图的props与视图的state以流的方式合并,直接把合并之后的流的结果传入视图组件,就能达到我们在上一节中提出的需求。

组件化与分形

我们之前提到过一点,在一个应用中,组件是形成倒置的树形结构的。当组件树上的某一块越来越复杂,我们就把它再拆开,延伸出新的树枝和叶子,这个过程,与分形有异曲同工之妙。

然而,因为全局状态和本地状态的分离,导致每一次分形,我们都要兼顾本组件、下级组件、全局状态、本地状态,在它们之间作一些权衡,这是一个很麻烦的过程。在React的主流实践中,一般可以利用connect这样的高阶函数,把全局状态映射进组件的props,转化为本地状态。

上一节提及的MVI结构,不仅仅能够描述一个应用的执行过程,还可以单独描述一个组件的执行过程。

Component := View(Model(Intent({ DOM, Http, WebSocket })))

所以,从整体来理解我们的应用,就是这样一个关系:

              APP [ View <-- Model <-- Intent ]
                     |
           ------------------------------------------------
           |                                              |
ComponentA [ ViewA <-- ModelA <-- IntentA ]          ComponentB

这样一直分形下去,每一级组件都可以拥有自己的View、Model、Intent。

状态的变更过程

在模型驱动视图这个理念下,视图始终会是调用链的最后一段,它的职责就是消费已经计算好的数据,渲染出来。所以,从这个角度看,我们的重点工作在于怎么管理状态,包括结构的定义和变更的流转过程。

Redux提供了对状态定义和变更过程的管理思路,但有不少值得探讨的地方。

基于标准Flux/Redux的实践有一个共同点:繁琐。产生这种繁琐的最主要原因是,它们都是以自定义事件为核心的,自定义事件本身就是繁琐的。由于收发事件通常位于两个以上不相同的模块中,不得不以封装的事件对象为通信载体,并且必须显式定义事件的key,否则接收方无法指定自己的响应。

一旦整个应用都是以此为基石,其中的繁琐程度可想而知,所以社区会存在一些简化action创建,或者通过约定来减少action收发中间环节的Redux周边。

如果不从根本上对事件这种机制进行抽象,就不可能彻底解决繁琐的问题,基于Reactive理念的这几个库天然就是为了处理对事件机制的抽象而出现的,所以用在这种场景下有奇效,能把action的派发与处理过程描述得优雅精妙。

const updateActions$ = changes$
  .map(todo => ({type: 'updateTodo', payload: todo}))

const todo$ = updateActions$
  .fold((state, action) => {
    const { payload } = action
    return {...state, ...payload}
  }, initState)

注意一个问题,既然我们之前得到一种思路,把全局状态和本地状态分开,然后合并注入组件,就需要考虑这样的问题:如何管理本地状态和全局状态,使用相同的方式去管理吗?

在Redux体系中,我们在修改全局状态的时候,使用指定的action去修改状态,原因是要区分那个哪个action修改state的什么部分,怎样修改。但是考虑本地状态的情况,它反映的只是组件内部的数据变化,一般而言,其结构复杂程度远远低于全局状态,继续采用这种方式的话并不划算。

Redux这类东西出现的初衷只是为了提供一种单向数据流的思路,防止状态修改的混乱。但是在基于数据管道的这些库中,数据天然就是单向流动的。在刚才那段代码里,其实action的type是没有意义的,一直就没有用到。

实际上,这个代码中的updateActions$自身就表达了updateTodo的含义,而它后续的fold操作,实际上就是直接在reduce。理解了这一点之后,我们就可以写出反映若干种数据变更的合集了,这个时候,可以根据不同的action去选择不同的reducer操作:

// 我们可以先把这些action全部merge之后再fold,跟Redux的理念类似
const actions = xs.merge(
  addActions$,
  updateActions$,
  deleteActions$
)

const localState$ = actions.fold((state, action) => {
  switch(action.type) {
    case 'addTodo':
      return addTodo(state, action)
    case 'updateTodo':
      return updateTodo(state, action)
    case 'deleteTodo':
      return deleteTodo(state, action)
  }
}, initState)

我们注意到,这里是把所有action全部merge了之后再fold的,这是符合Redux方式的做法。有没有可能各自fold之后再merge呢?

其实是有可能的,我们只要能够确保action导致的reducer粒度足够小,比如只修改state的同一个部分,是可以按照这种维度去组织action的。

const a$ = actionsA$.fold(reducerA, initA)
const b$ = actionsB$.fold(reducerB, initB)
const c$ = actionsC$.fold(reducerC, initC)

const state$ = xs.combine(a$, b$, c$)
  .map(([a, b, c]) => ({a, b, c}))

如果我们一个组件的内部状态足够简单,甚至连action的类型都可以不需要,直接从操作映射到状态结果。

const state$ = xs.fromEvent($('.btn'), click)
  .map(e => e.data)

这样,我们可以在组件内运行这种简化版的Redux机制,而在全局状态上运行比较完善的。这两种都是基于数据管道的,然后在容器组件中可以把它们合并,传入视图组件。

整个流程如图所示:

  ---------------------
  ↑                   ↓ 
              |-- LocalState
 View   <--   |    
              |-- GlobalState
  ↓                   ↑
Action     -->     Reducer

状态的分组与管理

基于redux-saga的封装库dva提供了一种分类机制,可以把一类业务的东西进行分组:

export const project = {
  namespace: 'project',
  state: {},
  reducers: {},
  effects: {},
  subscriptions: {}
}

从这个结构可以看出,这个在dva中被称为model的东西,定义了:

面向同一种业务实体的数据结构、业务逻辑可以组织到一起,这样,对业务代码的维护是比较有利的。对一个大型应用来说,可以根据业务来划分model。Vue技术栈的Vuex也是用类似的结构来进行业务归类的,它们都是受elm的启发而创建,因此会有类似结构。

回想到上一节,我们提到,如果若干个reducer修改的是state的不同位置,可以分别收敛之后,再进行合并。如果我们把状态结构按照上面这种业务模型的方式进行管理,就可以采用这种机制来分别收敛。这样,单个model内部就形成了一个闭环,能够比较清晰的描述自身所代表的业务含义,也便于做测试等等。

MobX的Store就是类似这样的一个组织形式:

class TodoStore {
  authorStore

  @observable todos = []
  @observable isLoading = true

  constructor(authorStore) {
    this.authorStore = authorStore
    this.loadTodos()
  }

  loadTodos() {}
  updateTodoFromServer(json) {}
  createTodo() {}
  removeTodo(todo) {}
}

依照之前的思路,我们所谓的model其实就是一个合并之后生成state结构的数据管道,因为我们的管道是可以组合的,所以没有特别的必要去按照上面那种结构定义。

那么,在整个应用的最上层,是否还有必要去做combineReducer这种操作呢?

我们之前提到一个表达式:

View = f(Model)

整个React-Redux体系,都是倾向于让使用者尽可能去从整体的角度关注变化,比如说,Redux的输入输出结果是整个应用变更前后的完整状态,React接受的是整个组件的完整状态,然后,内部再去做diff。

我们需要注意到,为什么不是直接把Redux接在React上,而是通过一个叫做react-redux的库呢?因为它需要借助这个库,去从整体的state结构上检出变化的部分,拿给对应的组件去重绘。

所以,我们发现如下事实:

整个过程,是经历了变更信息的拥有——丢失——重新拥有过程的。如果我们的数据流是按照业务模型去分别建立的,我们可以不需要去做这个全合并的操作,而是根据需要,选择合并其中一部分去进行运算。

这样的话,整个变更过程都是精确的,减少了不必要的diff和缓存。

如果为了使用redux-tool的话,可以全部合并起来,往redux-tool里面写入每次的全局状态变更信息,供调试使用,而因为数据管道是懒执行的,我们可以做到开发阶段订阅整个state,而运行时不订阅,以减少不必要的合并开销。

Model的结构

我们从宏观上对业务模型作了分类的组织,接下来就需要关注每种业务模型的数据管道上,数据格式应当如何管理了。

在Redux,Vuex这样的实践中,很多人都会有这样的纠结:

在store中,应当以什么样的形式存放数据?

通常,会有两种选择:

前者有利于查询和更新,而后者能够直接给视图使用。我们需要思考一个问题:

将处理过后的视图状态存放在store中是否合理?

我认为不应当存太偏向视图结构的数据,理由如下:

某一种业务数据,很可能被不同的视图使用,它们的结构未必一致,如果按照视图的格式存储,就要在store中存放不同形式的多份,它们之间的同步是个大问题,也会导致store严重膨胀,随着应用规模的扩大,这个问题更加严重。

既然这样,那就要解决从这种数据到视图所需数据的关联关系,这个处理过程放在哪里合适呢?

在Redux和Vuex中,为了数据的变更受控,应当在reducer或者mutation中去做状态变更,但这两者修改的又是store,这又绕回去了:为了视图渲染方便而计算出来的数据,如果在reducer或者mutation中做,还是得放在store里。

所以,就有了一个结论:从原始数据到视图数据的处理过程不应当放在reducer或mutation中,那很显然就应当放在视图组件的内部去做。

我们理一下这个关系:

[ View <-- VM ] <-- State
  ↓                   ↑
Action     -->     Reducer

这个图中,方括号的部分是视图组件,它内部包含了从原始state到view所需数据的变动,以React为例,用代码表示:

render(props) {
  const { flatternData } = props
    const viewData = formatData(flatternData)
    // ...render viewData
}

经过这样的拆分之后,store中的结构更加简单清晰,reducer的职责也更少了,视图有更大的自主权,去从原始数据组装成自己要的样子。

在大型业务开发的过程中,store的结构应当尽早稳定无争议,避免因为视图的变化而不停调整,因此,存放相对原始一些的数据是更合理的,这样也会避免视图组件在理解数据上的歧义。多个视图很可能以不同的业务含义去看待状态树上的同一个分支,这会造成很多麻烦。

我们期望在store中存储更偏向于更扁平化的原始数据。即使是对于从后端返回的层级数据,也可以借助normalizr这样的辅助库去展开。

展开前:

[{
  id: 1,
  title: 'Some Article',
  author: {
    id: 1,
    name: 'Dan'
  }
}, {
  id: 2,
  title: 'Other Article',
  author: {
    id: 1,
    name: 'Dan'
  }
}]

展开后:

{
  result: [1, 2],
  entities: {
    articles: {
      1: {
        id: 1,
        title: 'Some Article',
        author: 1
      },
      2: {
        id: 2,
        title: 'Other Article',
        author: 1
      }
    },
    users: {
      1: {
        id: 1,
        name: 'Dan'
      }
    }
  }
}

很明显,这样的结构对我们的后续操作是比较便利的。因为我们手里有数据管道这样的利器,所以不担心数据是比较原始的、离散的,因为对它们作聚合处理是比较容易的,所以可以放心地把这些数据打成比较原始的形态。

前端的数据建模

之前我们提到过store里面存放的是扁平化的原始数据,但是需要注意到,同样是扁平化,可能有像map那样基于id作索引的,也可能有基于数组形式存放的,很多时候,我们是两种都要的。

在更复杂的情况下,还会需要有对象关系的关联,一对一,一对多,多对多,这就导致视图在需要使用store中的数据进行组合的时候,不管是store的结构定义还是组合操作都比较麻烦。

如果前端是单一业务模型,那我们按照前一节的方案,已经可以做到当数据变更的时候,把当前状态推送给订阅它的组件,但实际情况下,都会比这个复杂,业务模型之间会存在关联关系,在一个模型变更的时候,可能需要自动触发所关联到的模型的更新。

如果复杂度较低,我们可以手动处理这种关联,如果联动关系非常复杂,可以考虑对数据按照实体、关系进行建模,甚至加入一个迷你版的类似ORM的库来定义这种关系。

举例来说:

如果一个数据流订阅了某个组织的基本信息,它可能只反映这个组织自身实体上的变更,而另外一个数据流订阅了该组织的全部信息,用于形成一个实时更新的组织全视图,则需要聚合该组织和可能的下级组织、人员的变动汇总。

上层视图可以根据自己的需要,选择从不同的数据流订阅不同复杂度的信息。在这种情况下,可以把整个ORM模块整体视为一个外部的数据源。

整个流程如下:

[ View <-- VM ] <-- [State <-- ORM]
  ↓                             ↑
Action          -->          Reducer

这里面有几个需要注意的地方:

在这么一种体系下,实际上前端存在着一个类似数据库的机制,我们可以把每种数据的变动原子化,一次提交只更新单一类型的实体。这样,我们相当于在前端部分做了一个读写分离,读取的部分是被实时更新的,可以包含一种类似游标的机制,供视图组件订阅。

下面是Redux-ORM的简单示例,是不是很像在操作数据库?

class Todo extends Model {}
Todo.modelName = 'Todo';
Todo.fields = {
  user: fk('User', 'todos'),
  tags: many('Tag', 'todos'),
};

class Tag extends Model {}
Tag.modelName = 'Tag';
Tag.backend = {
  idAttribute: 'name';
};

class User extends Model {}
User.modelName = 'User';

小结

文章最开始,我们提到最理想的组件化开发方式是依托组件树的结构,每个组件完成自己内部事务的处理。当组件之间出现通信需求的时候,不得不借助于Redux之类的库来做转发。

但是Redux的理念,又不仅仅是只定位于做转发,它更是期望能管理整个应用的状态,这反过来对组件的实现,甚至应用的整体架构造成了较大的影响。

我们仍然会期望有一种机制,能够像分形那样进行开发,但又希望能够避免状态管理的混乱,因此,MVI这样的模式某种程度上能够满足这种需求,并且达到逻辑上的自洽。

如果以MVI的理念来进行开发,它的一个组件其实是:数据模型、动作、视图三者的集合,这么一个MVI组件相当于React-Redux体系中,connect了store之后的高阶组件。

因此,我们只需把传统的组件作一些处理:

这样,组件就是自洽的一个东西,它不关注外面是不是Redux,有没有全局的store,每个组件自己内部运行着一个类似Redux的东西,这样的一个组件可以更加容易与其他组件进行配合。

与Redux相比,这套机制的特点是:

回顾整个操作过程:

借助RxJS或者xstream这样的数据管道的理念,我们可以直观地表达出数据的整个变更过程,也可以把多个数据流进行便捷的组合。如果使用Redux,正常情况下,需要引入至少一种异步中间件,而RxJS因为自身就是为处理异步操作而设计的,所以,只需用它控制好从异步操作到同步的收敛,就可以达到Redux一样的数据单向流动。如果想要在数据管道中接入一段承担中间件职责的东西,也是非常容易的。

而RxJS、xstream所提供的数据流组合功能非常强大,天然提供了一切异步操作的统一抽象,这一点是其他异步方案很难相比的。

所以,这些库,因为拥有下面这些特性,很适合做数据流控制:

xufei commented 7 years ago

QCon上面讲的主题,这里补一下

sprying commented 7 years ago

前排占座

nighca commented 7 years ago

借楼请教 & 讨论。

背景是一个常见的业务场景:表单页上,一个单选输入(比如 select 控件),值为 v,其可选值列表 options 是一份动态的数据,可能会发生变化(也许是请求后端获取到,也可能是前端逻辑,在某些情况下向列表中插入特定项)。

有这样一个逻辑:当可选值列表 options 发生变化时,将单选的值复位,即,v 的值设置为 options[0]

在 RxJS 驱动的数据流背景下,怎么表达这份逻辑比较合理呢?

我想到的是通过 merge 可选值列表 options 的数据流,与用户对 select 进行操作的事件流,得到值 v 的数据流,即:

const vFromOptions$ = options$.map(options => options[0])
const vFromSelect$ = selectChangeEvent$.map(e => e.target.value)
const v$ = merge(vFromOptions$, vFromSelect$)

然后再用这个结果控制界面上的 select 的值。不过我对 RxJS 实践很有限,不确定这么做有没有什么潜在的缺陷,或者是不是有更好的方式。

另外这里抛出这个问题,在于据我所知,在 Redux 的背景下,表达这样一份逻辑会很尴尬,因为在做数据推导的时候,没有时间这个纬度,除非去手动记录(而这里 v 的值应选取哪个取决于这俩的发生次序);如果不能基于数据做推导,实现这个逻辑就只能在每个可能会导致 options 改变的地方,手动去维护 v 的值,这种做法会导致 options 值的维护逻辑与 v 的维护逻辑之间的耦合。RxJS 的 merge 似乎恰好能很优雅地解决这个问题,我觉得很有意思。

nighca commented 7 years ago

自己补充下,在 Redux 的背景下,有一个思路是,监听每次的 action dispatch,然后比较 state 中的 options 值是否改变,若发生改变,再通过 dispatch 对应的 action 更新 v 的值。这种做法有几个小问题:

  1. 监听每次 action dispatch 并比较的行为略粗暴,可能有性能问题(不过可借助 reselect 缓解)
  2. 这类逻辑的组织会有点麻烦,它可能是跨 domain 的逻辑:options 是 specific domain 的数据或由之推导得到,v 是局部界面的状态(借助 redux-saga 的 select 的话,可以把这一行为写成 saga,然后跟其它的 saga 一起去组织)
  3. 即便如此,好像还是不如 RxJS 的方式自然,数据与决定数据的逻辑之间割裂严重
codering commented 7 years ago

文中提到了 RxJSxstream,开发时该选择哪个?选择依据是什么?

cdll commented 7 years ago

Cycle和Rx貌似没啥区别啊?

xufei commented 7 years ago

@nighca 你用rx那样写是可以的。因为从源头来说,确实是options的变化引发了复位这个操作,而所选择的项也确实来自复位的默认选择和用户选择的合并。

在redux里,除非你把这个选中项丢在全局state里,然后每次赋值options的时候,同时也给它赋值,不然就会很别扭。但是把一个选中项丢在全局state里面真的好吗……,所以我对redux一直有很多方面的不认同,这个只是其中一方面

xufei commented 7 years ago

@codering xstream比较简单一些,容易上手一些,而且体积也小,RxJS比较大,功能也强很多,这个看你需求了,大部分场景,xstream应该也够了

xufei commented 7 years ago

@cdll cycle最初就是基于rx的理念开发的

hax commented 7 years ago

按照我的理解,重置到options[0]实际是组件自己应该处理的事情。也就是要在props发生变化的时候更新state……对react不熟,不知道这个事情的最佳方案是啥。。。

xufei commented 7 years ago

@hax 在React技术栈中,传统方式是组件处理,如果options是外部从props传入,这样就必须在componentDidUpdate这样的地方,去处理后续的变更。

但是加了Redux的React就大不一样,这涉及到把哪些状态放出去给Redux管,极端情况是把所有东西都放出去管,那就必须在写入options的地方,同时就把这个值设置起来。不然的话,只要是从props接受options,还是面临一个:需要额外监控options赋值过程 这么一个尴尬的事情。

不过,看上去React-Redux技术栈的很多人是倾向于把一切状态外置到Redux中的,但我是强烈反对这一点的,那天 @Huxpro 质疑的就是这个问题,他是认为应当把状态全外置到组件外的。这等于是贫血模型做到极致,模型全部退化为全数据的形态了。

nighca commented 7 years ago

@hax 如果是这种情况,v 的值在于某个组件 selectExstate 中,不过最终要去使用这份值(譬如提交后端接口),所以它在外部的 state 中会有一份对应的数据(如 globalState.v,或者 parentComponent.state.v),那么这里会有一个组件 selectEx 通过 onChange 方法将自己的 state.v 设置回外部的过程。

一方面,这样相当于绕了个圈,没有降低复杂度(因为从外部状态计算得到 selectEx.props.options,然后组件观察其变化,然后对应更新 selectEx.state.v 再设置回去的过程,跟先前我补充的部分里说的“监听每次外部状态变更,计算结果 options 再更新 v 的过程是逻辑上等价的”);

另外一方面,这样做之后,对于复杂的表单页面,我们会趋于为每个类似的数据逻辑去封装一个组件实现,而 React 组件的本质角色是界面逻辑,用大量的 React 组件去封装数据变更逻辑(尤其是用 view 组件的生命周期方法 componentWillRecieveProps/componentDidUpdate 驱动数据的更新行为),我觉得不是特别合理。

目前看来,react 背景下最佳的方式可能就是通过 reselect + redux-saga 去做监听 & 比较,将这样一份逻辑维护为 saga 的做法。(FIXME?)

nighca commented 7 years ago

@xufei redux 倒也不是要求所有状态都在全局 state 里,不过不在全局 state 中的数据(即存在在组件 state 中)基本只有两种:

  1. 可以由全局 state 中的数据推导得到
  2. 使用/影响范围很有限,全局的行为(action)不会用到它

我觉得这个问题不大,因为即便是 RxJS 驱动的模型,除这两类之外的数据也会分布在各个数据流中,redux 只是要求这些分散的数据全部挂在一棵树上,以带来一些额外的好处。

我认为这个问题的根源在于 RxJS 在对数据做抽象的过程中默认地引入了时间这个维度,大部分时候我们做一次推导/计算不会依赖时间维度的信息,但是在像这个例子这种特定的情况下,它会让推导天生地便利。

我设想了下,如果对于每份数据,我都在旁边额外维护一个它被修改的时间信息,那么这事儿在 redux 的背景里也就变得简单了。

xufei commented 7 years ago

@nighca 这个例子你也可以认为是跟时间无关,只是对事件机制的一种简化,等同于在saga中,take了两种action,然后做了相同的事情。

从对数据变化的来龙去脉看,是明显比saga清晰的。

nighca commented 7 years ago

@xufei 如果是用时间无关的角度去看,我们就是在维护这样两个事件监听逻辑:

  1. options 变化触发 v 改变
  2. select 组件的用户选择行为触发 v 改变

如果引入了时序,我们就是在维护一份数据到数据的推导逻辑:

后者明显好一点,不过我没能想清楚,后者会更好的根本原因是啥。 🤣

xufei commented 7 years ago

@nighca 后者更好的原因是:如果你想要在后面再做些事情,是往后接的方式;而事件那种,是往里嵌套的方式。往后接不需要动原先的代码,往里嵌套需要……

codering commented 7 years ago

@xufei 如果我用了xstream + react, dom事件处理是不是都要用fromEvent来处理? 如果用了fromEvent, 是不是要涉及到dom节点选择器? 还有就是fromEvent应该在什么时候定义或者说应该放在哪里去定义?

xufei commented 7 years ago

@codering 不需要的,fromEvent的来源可以是一个EventEmitter,你把你要做的事情往一个emitter里面发就行了,不一定是dom事件:

render() {
  const emitter = new EventEmitter();  //这个东西也可以被用来fromEvent

  const click = () => {
    emitter.emit('someEvent', 111);
  }

  return (<button onClick={click}>test</button>)
}
nighca commented 7 years ago

@xufei 我觉得不是哎,前者的话,我想在 v 变化时做一些事情,也可以通过往后接的方式,即添加另一份监听逻辑:当 v 变化的时候,blabla...

这里实现对某个值的监听,既可以是利用像 mobx / vuex 这种 push 形式的数据源,也可以是在 redux 这种 pull 形式的数据源基础上借助 reselect 的优化实现

Huxpro commented 7 years ago

@xufei 感谢 AT,过来说两句。

“把状态全外置到组件外” 这个更接近纯粹 FP 的思路,刚才跟 @jiyinyiyong 聊了一下他的确是可以完全 follow 这个实践的,但其实我还是有纠结的,就像 QCon 那天聊得至少我觉得组件状态应该自含。

其实我个人更看重可预测的数据流而非可预测的渲染流,换言之我觉得部分 render() 不纯是可以接受的,一些 UI 状态不可从 props 预测到是可以接受的,也就是说组件是可以有局部状态从而影响到渲染的结果。除了很多与视图相关的状态、我甚至觉得临时与局部的数据流也是可以内含在组件里的,只要整个过程中不会对全局的数据流产生 side effect,就能保证全局数据流是单向的,而局部的数据流则可以认为会被组件消化掉。如果把 Store 中的状态/数据分为 Model 和 ViewModel 两类话,我觉得组件的 ViewModel 自含成局部状态是 OK 的,而跟 Business Logic 强相关的 Model 不行(所以还是贫血模型)

从这个角度来说,@nighca 这个问题中的 option 来自 IO 的话,我和 @jiyinyiyong 都会倾向于认为它属于 Model 层,所以 "options 修改重置 value" 的逻辑肯定倾向于外置。

如果再放宽一点的话,React 社区之前流行过的 "Smart Component vs. Dumb Component" 倒是也有直接让 Smart Component 去做 IO 的,比如说 <SelectService/> 或者 <FormService/>,但是通常也还是只读不写(这样对全局数据流还是保持没有 side effect 的)。从这个角度看,options 可以从 Service 读进来,让 Service 以一个 decorator/HOC 的方式去重置 value。不过 value 仍然要外置,需要完善的实现 shouldComponentUpdate 保证性能。但是从状态的放置来说,options 可以不用放在全局状态上。

NE-SmallTown commented 7 years ago

叔你好,我觉得"看上去React-Redux技术栈的很多人是倾向于把一切状态外置到Redux中的"中"很多人"这个用词不太准确,就我在twitter和社区看的issue,文章来看,大部分人都倾向于dan(redux作者) 的观点(当然文章也提到了)-"local state is fine",另外我记得dan在medium,reddit和stackoverflow都写过到底哪些状态要(或者说适合)放到redux,哪些要组件自己维护的文章或者回答。

还有一点就是,看了你的描述,不知道能不能概括为"既不想用redux,又不想层层或者说来回传递改变state的回调函数,因此在探索一种更加合适的方案去处理组件通信或者说数据流动"

Galen-Yip commented 7 years ago

@Huxpro UI状态如果无法从props预测,即内部状态变了,导致UI变了,但外部props没变,但其实就组件而言,组件内部状态的变更导致的UI变化,是会emit事件出来的,需要使用者去赋值,在vue里面也是主张的「props down event up」,但是就这点而言,对于使用者而言是十分无法适从的。然而如果为了组件「自由度」,需要放开足够的props的话,这就让使用者更头大了。其实也就飞叔上面说到的,属性外置带来使用过程中很多不便利性。这个锅我算在单向数据流的头上。。。好吧,所以我觉得就目前为止,是觉得还没有很好的方案来作为组件化中组件的通讯的

xufei commented 7 years ago

@nighca 你看看这篇:https://blog.nrwl.io/reactive-programming-in-angular-7dcded697e6c

nighca commented 7 years ago

@Huxpro 一般来说,redux 社区在讨论 Smart Component vs. Dumb Component 的时候,说到的 Smart Component 与 Dumb Component,都首先是 redux 治下的组件。在与全局 store 的关系上,二者的区别主要在于 Smart Component 会直接去跟全局 store 打交道,而 Dumb Component 不知道全局 store / redux 的存在。Smart Component 如果要进行 IO 行为(不管是读还是写),都是要通过跟全局的 store 交互实现。

至于 Component 是不是绕开 redux store 直接去做 IO,这个主要是取决于开发者是不是希望这个 Component 与 redux 管理的项目本身的状态耦合。从这个角度说,Dumb Component 甚至比 Smart Component 更适合做这样的事情,因为 Dumb Component 相对来说更加独立于项目本身的状态。

前面讨论会以 options 在于外部状态中为前提,主要是考虑到

(数据 options)也许是请求后端获取到,也可能是前端逻辑,在某些情况下向列表中插入特定项

它的来源逻辑比较复杂,几乎是不可避免要与项目的其他部分逻辑耦合在一起的,完全由一个独立的组件来自己完全处理掉 options 相关的逻辑不太现实。

另外这里我觉得在非 RxJS 或类似的 reactive 的数据流背景下,暂时没有找到很好的逻辑实现形式(https://github.com/xufei/blog/issues/47#issuecomment-296962521 https://github.com/xufei/blog/issues/47#issuecomment-296969396 ),不知道这点有没有什么看法。

@xufei 多谢链接,我看一下~

nighca commented 7 years ago

@xufei 文章很贴切,感觉是印证了我先前的想法:

大部分时候,我们做数据推导都不需要依赖时序因素,采用文中提到的 transparent reactive programming 的形式会更便利;而在特定情况下,比如前面提到的例子,在数据中保留时序信息(即文中提到的 reified reactive programming),使得例子中的逻辑可以被描述为纯数据推导,这是它能够优雅解决问题的关键。

有趣的是,angular 这里默认对数据采用 transparent reactive programming 的做法,似乎使得它在解决我例子中的问题时也会面临类似的尴尬。

Huxpro commented 7 years ago

@nighca

一般来说,redux 社区在讨论 Smart Component vs. Dumb Component 的时候,说到的 Smart Component 与 Dumb Component,都首先是 redux 治下的组件。

如果是 Redux 社区,当然是 Redux 治下……但是从 React 社区来说,用什么 Flux 甚至不用都没关系。Dumb Component 既然是 Store/Flux agnostic 的就更不用关心 Store/Flux 的实现了

在与全局 store 的关系上,二者的区别主要在于 Smart Component 会直接去跟全局 store 打交道,而 Dumb Component 不知道全局 store / redux 的存在。

同意

Smart Component 如果要进行 IO 行为(不管是读还是写),都是要通过跟全局的 store 交互实现。

不完全同意,Dan 这篇 https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0 在划分 Container Component 时,并不强调数据的来源:

  • Provide the data and behavior to presentational or other container components.
  • Call Flux actions and provide these as callbacks to the presentational components.

文中引用的 https://medium.com/@learnreact/container-components-c0e67432e005 应该是比较早的介绍 Container Component 的,其中的 IO read 就是内含的

class CommentListContainer extends React.Component {
  state = { comments: [] };
  componentDidMount() {
    fetchSomeComments(comments =>
      this.setState({ comments: comments }));
  }
  render() {
    return <CommentList comments={this.state.comments} />;
  }
}

当然直接从 Store 下来也没什么不好,这就是完全业务逻辑外置的贫血模型,但这篇文章考虑的就是中间组件适当充血的事。

Dumb Component 相对来说更加独立于项目本身的状态。

同意

至于 Component 是不是绕开 redux store 直接去做 IO,这个主要是取决于开发者是不是希望这个 Component 与 redux 管理的项目本身的状态耦合。从这个角度说,Dumb Component 甚至比 Smart Component 更适合做这样的事情,

不完全同意,同上,这里讨论的就是一种介于被全局管理与完全 Dumb 之间的形态。

它的来源逻辑比较复杂,几乎是不可避免要与项目的其他部分逻辑耦合在一起的,完全由一个独立的组件来自己完全处理掉 options 相关的逻辑不太现实。

但是如果业务场景里 options 是一个无法被消化的逻辑,那确实只能扔到全局了。

Huxpro commented 7 years ago

@NE-SmallTown You dont need Redux cuz “local state is FINE”.

Huxpro commented 7 years ago

@Galen-Yip 不好意思啊没太看懂 OTZ

marswong commented 7 years ago

叔叔的功力好深厚,函数式功夫更上一层楼了

yozman commented 7 years ago

@xufei 把贫血模式做到极致, 然后让 node 后端来写数据操作 并通过工具翻译成前端可用的 sdk 这样的结构会不会更好一些? 逻辑分给后端, 前端需要操作数据就调用 sdk 的函数 sdk 屏蔽了 调用 api 接口的细节

Vincent1993 commented 7 years ago

@yozman 我现在也是有这样的思路 现在后端只提供基础的数据接口 不提供业务接口 由node中间层去完成业务接口的拼接和逻辑的整合,这样前端只需要去调用node中间层所提供的"api"即可

yozman commented 7 years ago

@Vincent1993 忒好了,有同道中人:)

分享下我的思路 这中间的关键在于 node 中间层的编写 打包出来的 sdk 需要隐藏 BS 模型的 http 通信 对于实现界面的前端同学来说 sdk 就是一个状态树, 并提供更改相应 state 的 action。

这样的话,传统的 restful 是有限制的, 或者说不够直观,按照面向对象的思想 一个类是有属性和行为两部分来描述的 restful 只能算作一个 function 也就是行为, 但相应的可直接操作的属性他是没法描述的 这部分如何通过工具来翻译还没想好, 有部分想法是想通过 typescript 的 @annotation 的语法来弄。

最后我觉得任何一个新方案都是为了使用更方便 直观、更效率为出发点, 如果弄出来反而复杂了(不包括学习路径陡峭) 不如不弄

有相同想法的同学 可以加我微信交流:908670512 人变多了就建群;)

hopperhuang commented 7 years ago

@nighca 你好,看了你提出的场景,我也做了一番思考。因为最近又要投入开发阶段了,第一部分写组件,所以我自己也很关注,组件应该怎么抽象的问题。到底时pure function还是充血组件?我自己也有些疑惑。结合之前写过的一些经验,和对react数据流这一块的理解,我也想跟你交流一下。我现在用的时dva的做数据流的处理,对于select组件,可能因为io而变化,也可能因为前端操作而变化的逻辑。我个人倾向是将数据交给外部的store去管理的。这种情况下,我们就需要这样组织我的saga

...
// 这里是对于state的组织
state: {
options:[...],
selected: ...,

// 这里是saga的组织
handleIO(){......}
handleDOMSelect(){...}

// 经过saga的处理后,值会传送给reducer去更新state
updateSelect(){...}

代码的组织大概如此把。

我觉得这场景,就是变动的源头可以来自外部(io),也可以来自前端页面的情况下,统一采用dispatch一个action,把指令发送到外部,让外部的状态管理器去统一处理会比较方便。外部通过dispatch来获得内部的变化情况会比较容易,内部监听外部io的变化就会变得比较麻烦了。

我想了一下采用再组件内部监听外部Io变化的写法。

class Select extends Component {
IOhandler(){
//发送请求,等待响应,改变select值,同时改变options,
}
selectHandler(){
// 改变setState改变value值
}
}

个人觉得这种写法的复用性不够高,handler的逻辑要改变的话就要重写整个handler方法了,但是采用saga的写法,因为再saga里面可以自由组织调用其他的saga产生side-effect,这样写起来,改动就很方便了,就行rxjs那样,可以自由组织自己的管道。

再来对比一下跟rxjs写法的差别。rxjs的做法是将变化的源统一起来,merge只关心源的产出,拿到源产出的数据就交给view去消费。我觉得两种方法可类比的地方好多,rxjs的写法,首先要构造两observable对象,观察两个变动的源头,一个是dom,一个是io。而redux的写法,则是定义好handler, 变化的时候触发handler,dispatch出action指令。而rxjs的管道则个redux-saga里面的effect类似,都是对数据根据业务流程做处理。最后rxjs会merge两个chanel,交给一个subscribe处理,而saga则是交给reducer去更新state。

整个流程大致相似,但是有一点redux是不好的,就像民工叔说的。把一个选中项丢在全局state里面真的好吗……我个人更偏向是,页面流程,业务流程交给redux-saga去管理,这样做即使side-effect很多,也很清晰。而组件逻辑,就交给组件自身去处理。但是您提出的这种,恰好夹在了中间,更适合用外部状态管理器去管理,但是这样做,好像就很浪费,也很没必要,放回去组件用state管理,扩展性复用性又大大减少。

rxjs在这里,就像你说的,显得更自然,它只关注变化。并不关注变化的来源。其实,我最近也在考虑专用rxjs这类库做数据流管理。如果变化源的定义可以放在外面,而不用深入到组件逻辑。就是像不用在组件里面emmit一个信号出来,可以通过fromEevent这样外部观察组件变化,如果channle的配置性可以更强,我希望得到一个可以可以组合多变的channel去应对业务场景的变化,这样reactive program将是已给更加好的解决方案。

simdd commented 6 years ago

@xufei 场景:点击文章列表进入文章详情页,点赞后返回到文章列表 条件:列表页有分页,不能刷新页面 问题:文章列表页数据如何实时同步?

// 点赞的文章不确定在列表中哪一页,所以不能做ajax局部请求,局部替换更新,在这儿想不通有什么好的方法。

zheeeng commented 6 years ago

@simdd 谬答一下,这种情况必然要把相关的局部状态提升到上层组件中,一旦超过两层就要考虑使用状态管理的工具为它加上领域 store,文章、分页由领域中心化的数据驱动,一个比较好的实践是在路由动作触发时对分页信息做快照,当你再返回时弹出分页信息与当前数据合并。

kuitos commented 6 years ago

@nighca

@xufei 如果是用时间无关的角度去看,我们就是在维护这样两个事件监听逻辑: options 变化触发 v 改变 select 组件的用户选择行为触发 v 改变 如果引入了时序,我们就是在维护一份数据到数据的推导逻辑: v 的值为 options[0] 与 eventOfSelect.target.value 的较新者 后者明显好一点,不过我没能想清楚,后者会更好的根本原因是啥。 🤣

我觉得是因为前者是这样的(imperative):

on(optionsChange) -> setV
on(userSelect) -> setV

后者(reactive)

v := latest(options, userSelected)

rp 的方式把数据依赖图形化关注点更集中,思维负担要小

xufei commented 6 years ago

@simdd 你这个情况,我简单说下想法。

通常,你的列表是两种情况:

这两种情况,都会面临你说的问题,列表项的数据改变,没有合适方式来刷新这个列表。

无非两个路径:

如果你想要用前者的方式,那就得让数组整个动起来。粗暴地把整个数组重新生成一个,然后整个 render 下来,由虚拟 dom 去决定到底哪个项要真的更新。

如果你想要用后者的方式,就要先把数组中的每一项都升阶为流:

Array<Item>  => Array<Observable<Item>>

然后,再把每个 Item 都表达为若干局部修改的合并流,这样,单个项才有精确更新自身的能力。在这里,就是:

数组中的某个项,它都是一个流,并且,每个流都合并了“修改与当前 Item 相关数据的操作结果的其他流”,比如这里的对某个 Item 点赞了。

总结:你想要让谁能被单独更新,就要站在它的角度,把它的来源全部汇总。

这么做抽象代价是会高一些,看你觉得是否划算,偷懒的话就整个列表暴力重刷,把脏活累活扔给虚拟 dom。

Phinome commented 6 years ago

叔叔好久没写文章了