New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
xufei opened this Issue Apr 21, 2017 · 38 comments

Comments

Projects
None yet
@xufei
Owner

xufei commented Apr 21, 2017

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

  • 组件化
  • MDV(Model Driven View)

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

而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)

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

需要注意的是:

  • 管道是懒执行的。一个拼接起来的数据管道,只有最末端被订阅的时候,附加在管道上的所有逻辑才会被执行。
  • 一般情况下,管道的执行过程可以被共享,比如b$c$两个管道,都从a$变形得出,它们就共享了a$之前的所有执行过程。

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

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、正常数据、异常数据都统一成一种,视图直接订阅处理就行了。

高度抽象的数据来源

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

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

  • 应用的默认配置
  • HTTP请求
  • 本地存储
  • ...等等

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

  • 用户从视图发起的操作
  • 来自WebSocket的推送消息
  • 来自Worker的处理消息
  • 来自其它窗体的postMessage调用
  • ...等等

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

用传统的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设置进去,但它不仅仅是一个转发器。

我们可以看到如下事实:

  • 转发器在组件树之外
  • 部分数据在组件树之外
  • 对这部分数据的修改过程在组件树之外
  • 修改完数据之后,通知组件树更新

所以:

  • 组件可以通过中转器修改其他组件的状态
  • 组件可以通过中转器修改自身的状态
  • 组件可以通过中转器修改全局的其他状态

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

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

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

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

  • 一切状态外置,组件不管理自己状态
  • 部分内置,由组件自己管理,另外一些由全局Store管理

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

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

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

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

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

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

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

  • 这样的状态只跟某组件自己有关,放出去到全局Store,会增加Store的不必要的复杂度
  • 组件的自身形态状态被外置,将导致组件与状态的距离变远,从而对这些状态的读写变得比原先繁琐
  • 带交互的组件,无法独立、完整地描述自身的行为,必须借助外部管理器

如果是一种单独提供的组件库,比如像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这篇文章中,我们可以看到一组理念:

  • 一切都是事件源
  • 使用Reactive的理念构建程序的骨架
  • 使用sink来定义应用的逻辑
  • 使用driver来隔离有副作用的行为(网络请求、DOM渲染)

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

  • 从driver中获取action
  • 把action映射成数据流
  • 处理数据流,并且渲染成界面
  • 从界面的事件中,派发action去进行后续事项的处理

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

  • Intent,负责从外部的输入中,提取出所需信息
  • Model,负责从Intent生成视图展示所需的数据
  • View,负责根据视图数据渲染视图

整体结构可以这样描述:

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

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

  • Intent实际上做的是action执行过程的高级抽象,提取了必要的信息
  • Model做的是reducer的事情,把action的信息转换之后合并为状态对象
  • View跟其他框架没什么区别,从状态对象渲染成视图。

此外,在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)
  })
}

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

  • View还是纯渲染,接受的唯一参数就是一个表达视图状态的数据流
  • Model的返回结果就是上面那个流,不分内外状态,全部合并起来
  • Model所合并的东西的来源,是从Intent中来的

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

因此,我们只需在把待传入视图的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结构上检出变化的部分,拿给对应的组件去重绘。

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

  • 在触发reducer的时候,我们是精确知道要修改state的什么位置的
  • 合并完reducer之后,输出结果是个完整state对象,已经不知道state的什么位置被修改过了
  • 视图组件必须精确地拿到变更的部分,才能排除无效的渲染

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

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

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

Model的结构

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

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

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

通常,会有两种选择:

  • 打平了的数据,尽可能以id这样的key去索引
  • 贴近视图的数据,比如树形结构

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

将处理过后的视图状态存放在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

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

  • 一个action实际上还是对应到一个reducer,然后发起对state的更改,但因为state已经不是简单结构了,所以我们不能直接改,而是通过这层类似ORM的关系去改。
  • 对ORM的一次修改,可能会产生对state的若干处改动,比如说,改了一个数据,可能会推导出业务上与之有关系的一块关联数据的变更。
  • 如果是基于react-redux这样基于diff的机制,同时修改state的多个位置是可以的,但在我们这套机制里,因为没有了先合并修改再diff的过程,所以很可能多个位置的修改需要通过ORM的关联,延伸出不同的管道来。
  • 视图订阅的state变更,只能组合运算,不应当再干别的事情了。

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

下面是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之后的高阶组件。

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

  • 视图隔离,纯化为展示组件
  • 内部状态的定义清晰化
  • 描述出内部状态的来源关系:state := actions.reduce(reducer, initState)
  • 将内部的动作以action的方式输出到上面那个表达式关系中

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

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

  • 不需要显式定义整个应用的state结构
  • 全局状态和本地状态可以良好地统一起来
  • 可以存在非显式的action,并且action可以不集中解析,而是分散执行
  • 可以存在非显式的reducer,它附着在数据管道的运算中
  • 异步操作先映射为数据,然后通过单向联动关系组合计算出视图状态

回顾整个操作过程:

  • 数据的写入部分,都是通过类似Redux的action去做
  • 数据的读取部分,都是通过数据管道的组合订阅去做

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

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

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

  • 对事件的高度抽象
  • 同步和异步的统一化处理
  • 数据变更的持续订阅(订阅模式)
  • 数据的连续变更(管道拼接)
  • 数据变更的的组合运算(管道组合)
  • 懒执行(无订阅者,则不执行)
  • 缓存的中间结果
  • 可重放的历史记录
    ……等等
@xufei

This comment has been minimized.

Owner

xufei commented Apr 21, 2017

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

@sprying

This comment has been minimized.

sprying commented Apr 21, 2017

前排占座

@nighca

This comment has been minimized.

nighca commented Apr 24, 2017

借楼请教 & 讨论。

背景是一个常见的业务场景:表单页上,一个单选输入(比如 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

This comment has been minimized.

nighca commented Apr 24, 2017

自己补充下,在 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

This comment has been minimized.

codering commented Apr 24, 2017

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

@cdll

This comment has been minimized.

cdll commented Apr 24, 2017

Cycle和Rx貌似没啥区别啊?

@xufei

This comment has been minimized.

Owner

xufei commented Apr 25, 2017

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

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

@xufei

This comment has been minimized.

Owner

xufei commented Apr 25, 2017

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

@xufei

This comment has been minimized.

Owner

xufei commented Apr 25, 2017

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

@hax

This comment has been minimized.

hax commented Apr 25, 2017

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

@xufei

This comment has been minimized.

Owner

xufei commented Apr 25, 2017

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

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

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

@nighca

This comment has been minimized.

nighca commented Apr 25, 2017

@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

This comment has been minimized.

nighca commented Apr 25, 2017

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

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

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

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

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

@xufei

This comment has been minimized.

Owner

xufei commented Apr 25, 2017

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

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

@nighca

This comment has been minimized.

nighca commented Apr 25, 2017

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

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

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

  • v 的值为 options[0]eventOfSelect.target.value 的较新者

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

@xufei

This comment has been minimized.

Owner

xufei commented Apr 25, 2017

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

@codering

This comment has been minimized.

codering commented Apr 25, 2017

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

@xufei

This comment has been minimized.

Owner

xufei commented Apr 25, 2017

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

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

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

  return (<button onClick={click}>test</button>)
}
@nighca

This comment has been minimized.

nighca commented Apr 25, 2017

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

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

@Huxpro

This comment has been minimized.

Huxpro commented Apr 25, 2017

@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

This comment has been minimized.

NE-SmallTown commented Apr 25, 2017

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

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

@Galen-Yip

This comment has been minimized.

Galen-Yip commented Apr 25, 2017

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

@xufei

This comment has been minimized.

Owner

xufei commented Apr 26, 2017

@nighca

This comment has been minimized.

nighca commented Apr 26, 2017

@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 的数据流背景下,暂时没有找到很好的逻辑实现形式(#47 (comment) #47 (comment) ),不知道这点有没有什么看法。

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

@nighca

This comment has been minimized.

nighca commented Apr 26, 2017

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

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

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

@Huxpro

This comment has been minimized.

Huxpro commented Apr 26, 2017

@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

This comment has been minimized.

Huxpro commented Apr 26, 2017

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

@Huxpro

This comment has been minimized.

Huxpro commented Apr 26, 2017

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

@marswong

This comment has been minimized.

marswong commented May 23, 2017

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

@yozman

This comment has been minimized.

yozman commented Jun 27, 2017

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

@Vincent1993

This comment has been minimized.

Vincent1993 commented Jun 28, 2017

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

@yozman

This comment has been minimized.

yozman commented Jun 28, 2017

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

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

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

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

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

@hopperhuang

This comment has been minimized.

hopperhuang commented Aug 15, 2017

@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

This comment has been minimized.

simdd commented May 9, 2018

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

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

@zheeeng

This comment has been minimized.

zheeeng commented May 9, 2018

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

@kuitos

This comment has been minimized.

kuitos commented May 10, 2018

@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

This comment has been minimized.

Owner

xufei commented May 10, 2018

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

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

  • 直接是来的数组数据
  • 先处理过一轮,把数据用 k-v 方式缓存了,然后拿 Array 再去 map 出来的数组

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

无非两个路径:

  • 整个列表响应数据
  • 列表中的某项单独响应数据

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

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

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

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

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

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

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

@Phinome

This comment has been minimized.

Phinome commented Jul 11, 2018

叔叔好久没写文章了

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment