Skip to content
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

精读民工叔叔单页数据流方案 #11

Closed
ascoders opened this issue Apr 29, 2017 · 18 comments
Closed

精读民工叔叔单页数据流方案 #11

ascoders opened this issue Apr 29, 2017 · 18 comments

Comments

@ascoders
Copy link
Owner

文章地址:单页应用的数据流方案探索

@ascoders ascoders mentioned this issue Apr 29, 2017
65 tasks
@ascoders
Copy link
Owner Author

ascoders commented May 2, 2017

文中提到的观点,主要有:

  1. Reactive 数据封装
  2. 数据源,数据变更的归一
  3. 局部与全局状态的归一
  4. 分形思想
  5. action 分散执行
  6. app级别数据处理,推荐前端 Orm

一切技术都要看业务场景,民工叔的 单页应用数据流方案 解决的是重前端的复杂业务场景,但现在前端几乎全部单页化,单页也不能代表业务数据流是复杂的,比如简单的单页展示应用就不适合杀鸡用牛刀。

此文讨论的是纯数据流方案,与 Dom 结合的方案可以参考 cyclejs,但这个库主要搭建了 Reactive -> Dom 的桥梁,使用起来还要参考此文的思路。

我认为前端数据流方案迭代至今,并不存在比如:面向对象 -> 函数式 -> 响应式,这种进化链路,不同业务场景下都有各自优势。

面向对象

以 Mobx 为代表,轻前端用的较多,因为复杂度集中在后端,前端做好数据展示即可,那么直接拥抱 js 这种基于对象的语言,结合原生 Map Proxy Reflect 将副作用进行到底,开发速度快得飞起。

数据存储方式按照视图形态来,因为视图之间几乎毫无关联,而且特别是数据产品,后端数据量巨大,把数据处理过程搬到前端是不可能的(为了推导出一个视图形态数据,需要动辄几GB的原始数据运算,存储和性能都不适合在前端做)。

函数式

以 Haskell 为代表,金融行业用的比较多,可能原因是金融对数据正确性非常敏感,不仅函数式适合分布式计算,更重要的是无副作用让数据计算更安全可靠。

个人认为最重要的原因是,金融行业本来很少有副作用,像前端天天与 Dom 打交道的,副作用完全逃不了。

响应式

以 Rxjs 为代表,重前端更适合使用。对于 React native 等 App 级别的开发,考虑到数据一致性(比如修改昵称后回退到文章详情,需同步作者修改后的昵称),优先考虑原始类型存储,更适合抽象出前端 Orm 作为数据源。

其实 Orm 作为数据源,面向对象也很适合,但响应式编程的高层次抽象,使其对数据源、数据变动的依赖可插拔,中等规模使用大对象作为数据源,App 级别使用 Orm 作为数据源,因地制宜。

本地状态的定义是什么?

文中提到将本地状态与全局状态合并后传给组件,并且组件内部展示逻辑纯化。

我认为本地状态可以考虑成全局状态的一种,拿 Dva 例子来看,将模块定义在组件内部,但数据是合并到全局的,通过 namespace 使其全局命名唯一。

将本地状态放到全局,并保证唯一,可以方便全局调试,同时对组件来说完全可以当作局部状态用,因为无论组件被何处调用,都不会与全局状态冲突,如果全局状态不存在,组件数据流就创建一个。

分形思想

分形保证了两点:

  1. 组件和数据流融为整体,与外部数据流隔离,甚至将数据处理也融合在数据管道中,便于调试。
  2. 便于组件复用,因为数据流作为组件的一部分。

如果局部数据也放在全局,就出现了第三点好处:

  1. 创建局部数据等于创建了全局数据,这样代码调试可局部,可整体,更加灵活。

根据业务场景选择合适的方案

最后,不要盲目选型,就像上面提到的,这套方案对复杂场景非常棒,但也许你的业务完全不适合。

@BlackGanglion
Copy link
Contributor

叔叔的这篇文章量好大,很多地方需要细细揣摩,我先就 组件与外置状态 这一小部分发表一些看法,抛砖引玉。

在平时开发组件中,我们无外乎会采用以下三种形式来处理组件状态,拿最简单的 Input 举例:

// 1. 完全由组件内部来控制自身状态
<Input onChange={(value) => { 
  console.log(value);
}} />

// 2. 外部仅可给予初始值,其余交由内部控制
<Input defaultValue={defaultValue} onChange={(value) => { 
  console.log(value);
}} />

// 3. 组件状态完全由外部控制
<Input value={this.state.value} onChange={(value) => { 
  this.setState({ value });
}} />

就我个人而言,我偏爱于第 3 种状态完全受外部控制的 Dumb Component,因为我更关心于组件的灵活,能否适用于不同的业务场景。例如,需要对 Input 的 value 进行校验,校验通过后才允许更新,或 Input 的 value 会受到其他组件的影响。上述情况 1,2 两种形式便无法满足。

当然在设计通用组件时,往往会兼容内部外部两种控制方式,既减少用户的使用成本,又使组件本身不丧失灵活。

@monkingxue
Copy link

民工叔出品,必属精品
叔在 tb 的时候的 blog 就已经在关注 Rx 这样的 stream 式的库了,所以这篇数据流的文章中也以这种 stream 的思想为主。我用这类的库不多,就挨个总结一下叔的观点,拾人牙慧一番。

  • 数据源变了
    数据源原本只是纯粹的数据,不论是 redux 的总 state 还是 mobx 的分 state,都没有改变数据源 数据的本质。但是 Rx 把纯数据和一系列操作的闭包结合在了一起,这些闭包所对应的操作是业务无关的,通过管道的形式链接、取用。这里安利一个 Rx 官方的项目toy-rx,算是 Rx 源码的简略版,很有启发意义。

  • 外置 or 内置?
    状态的内置外置问题是个老大难的问题了,一个比较常见的解决方案是对状态进行分类,然后对不同的数据进行分别管理。这就引出了另一个问题:内置和外置的数据怎么 merge 呢?stream 跳出来说他可以解决,在 wrapper 中对 view 的数据进行 merge,然后再传入 view。这里默认一个观点:对于组件是采用 wrapper-dumb 的组织形式。

  • model 的建模与存放方式
    这个问题我自己也很困扰。民工叔给的方案是 flat + ORM,理由是 flat 的数据更具通用性,也更适合以 stream 的形式进行修改;而 ORM 天然和后端架构亲近,更易 reacd-write 分离。这套方案完全没有实践过,就不评论了,只是觉得成本应该不低吧...

@ascoders
Copy link
Owner Author

ascoders commented May 3, 2017

@BlackGanglion 嗯,组件最好将受控和不受控两种方式同时提供,受控参数是 x,那么非受控参数就是 default${x}

同时也存在完全不必要受控的状态,比如 listView 组件会存储当前缓冲区元素显示相关的信息,通过回调的方式暴露给外部,但缓冲区元素是组件自身计算出来的,外部不需要知道,也没办法知道当前滑动到哪里了,这种组件自身计算出来的属性就需要放在内部状态里。

@ascoders
Copy link
Owner Author

ascoders commented May 3, 2017

@monkingxue XStream fold 是把数据与 reducer 聚合起来,类似 rxjs.startWith(x).scan(f),这些闭包操作是业务相关的,比如文中提到的 updateTodo

model 存放方式我认为完全看业务场景,场景适合用小的,用复杂数据流就在坑自己,如果场景适合用大的,那么用小的也是在挖坑,此时用大的不存在成本高低的问题,只有大成本的 ORM 方案才能解决问题,这时候没得选呀。

@jasonslyvia
Copy link
Contributor

MVI 说白了感觉就是推崇高内聚的组件,Model - View -Intent 都在组件内部定义好,同时应用分型的思想将组件内部的状态与全局状态有机的结合起来,最终完成整个渲染过程。Reactive Programming 的引入更大程度上是一个锦上添花的东西,对于存在多种 Intent 来源且需要频繁更新的场景可能会更为适用。

我唯一比较疑惑的就是按照这种设计模式,我们该进行哪种粒度的复用,整个 MVI 组件,还是其中的 M、V、I 分别复用?

@ascoders
Copy link
Owner Author

ascoders commented May 3, 2017

@jasonslyvia 我觉得作者意思是整个 MVI 复用。

@jasonslyvia
Copy link
Contributor

如果是整个 MVI 复用的话,不知道可复用的场景能有多少……

@arcthur
Copy link

arcthur commented May 3, 2017

reactive 思想我的理解是从一切界面或外部触发为响应源导致数据层更新再更新界面。�mobx 或 vue 就是类似理念的实现。rx 也是这个理念,但还是不同的,可以参考这篇

对于 rx,最大的优势是消除异步副作用。就算不用 rx,我的想法也是同步异步在写法上抹平差异。rx 加任何 view 库和 mobx 效果类似,只是写法上繁琐了些,必要加一些语法糖。但 rx 是 fp 的写法,我觉得在数据抽象上比 mobx 写起来得直观。

高内聚,和我们现在团队做法很像。但在我们场景还是低复用场景,我觉得从组件写法与思想上做到了归一。复用在应用级别没问题,在跨应用级别就有问题了,复用还是最好保持纯粹,像组件级别的。复用这个场景得看场景,跨什么复用。

数据建模那块,其实文章的意思是把处理都放在前端了,后端基本就是一个取数的工作。但实际上从 db 到后端,还是要处理数据的,比如过滤,权限之类,而且有很多工作放在前端也不适合。这个就局限应用场景了。

@xile611
Copy link
Contributor

xile611 commented May 4, 2017

民工叔在文章中提出:

从原始数据到视图数据的处理过程不应当放在reducer或mutation中,那很显然就应当放在视图组件的内部去做。

关于这个问题,之前我们团队内部也讨论了很多次,我自己的想法也发生过很多次改变。

  • 数据处理放在Model层。刚开始用 Backbone + jquery 模式写页面的时候,我是这么做的,当时的考虑主要是这样View 就只用关心展示,不需要任何数据处理的过程。
  • Model 层负责请求逻辑,View 负责将原始数据处理成想要的格式。这个转变主要是因为,为发现当我们处理数据的时候,其实处理数据的逻辑是和View 展示强相关的,数据处理放在View层就能够避免为了一个逻辑,同时修改 Model 和 View 的问题。
  • 部分复杂数据在reducer中处理,部分简单数据在 view 中处理。为什么这么做呢?主要考虑到:当数据比较复杂的时候,如果把数据放在 View 中做数据处理,那么原始数据没有发生任何改变,任何其他 props 变更都会导致一次新的数据处理,这种数据处理的过程本身可能是比较复杂的,也是比较慢的。并且任何一次新的数据处理,也可能会导致使用了 pure-render 的 children 重新渲染(格式化后数据的应用可能会发生变化)。

个人观点,数据处理的过程是放在 reducer 中还是 view 中,还是要看具体场景,复杂场景放在 reducer 中还是比较合适的。

@BlackGanglion
Copy link
Contributor

BlackGanglion commented May 4, 2017

@xile611 叔叔在文中提到了一个不可忽视的前提:

某一种业务数据,很可能被不同的视图使用,它们的结构未必一致

也就是说,不同的 view 依赖同一数据源,但不同 view 对同一数据源的需求是不一样。同一数据源对应的 reducer 是唯一的,如果在 reducer 里生成满足不同 view 的数据放入 store,既会造成 store 冗余,也会带来同步的问题,因此偏向于在 store 中保存原始数据,分发到不同 view 中,由各个 view 来转换为自己所需的。

如果在叔叔这个前提下,我是赞同于在 view 层处理的。如果没有这个前提呢?确实有待商榷~~,如果在 react-redux 的 connent 中处理,这是算作在 view 层中处理么?

@xile611
Copy link
Contributor

xile611 commented May 4, 2017

@BlackGanglion 民工叔的说的场景确实可能存在,不过当数据比较复杂,处理成本较高的时候,多个 view 共享数据源的可能性应该比较低吧?另外,多个 view 共享数据源其实还蛮危险,这些 view 都可能会触发 数据源的改变,那么这种改变是否是所有的 view 都需要的呢?

我想你要说的应该是 connect ,在 connect 中处理还是挺奇怪的,因为你不知道是哪些数据发生了改变,所以这个方案应该不具备可行性。

@ascoders
Copy link
Owner Author

ascoders commented May 4, 2017

@xile611 多个 view 共享数据源的场景有很多,比如做一款社交 App,我们排行榜、文章页、个人中心都会涉及用户信息,比如你上榜了,从榜单页 -> 作者头像 -> 个人中心 -> 设置 -> 修改昵称后,上述页面都没有被销毁,这时候开始回退,上述页面的作者昵称是不是都要改过来呢?

我们不可能返回页面时重新发请求刷新,太浪费资源,影响体验,所以通过数据共享的方式,改变数据仓库中用户昵称,让所有未销毁的页面使用到个人信息的刷新。

说到多个 view 同时刷新造成的性能问题,我们大可以在路由回退时再触发数据更新,也就是重新 render,通过 dom diff 几乎无感知。

@xile611
Copy link
Contributor

xile611 commented May 4, 2017

@ascoders 你说的场景,主要是个人信息的读取,一般数据处理逻辑不多,共享没啥大问题,而且在 “排行榜、文章页” 我们一般会在Layout里面个人信息(也就是一个View,就像 github的 header里面的个人信息)。

当把数据处理放在 View,导致数据源没有更新,而是其他的props更新导致 某个 children的数据更新,如果这个children刚好写了”数据更新时,触发某个动画“的逻辑,这样不就带来了不必要的动画吗?想要解决这个问题,我们又需要去做一个 deep 比较,要知道这个数据可能非常复杂... 这个例子我想说的是,放在View里面处理数据有时候会带来一些意想不到的副作用。

另外我认为把所有没必要的更新都交给 dom diff 来处理的话,对于一个dom量庞大的页面,也可能会带来页面的性能问题。

@ascoders
Copy link
Owner Author

ascoders commented May 4, 2017

dom diff 是很有用的特性,合理利用可以大大提高开发效率,至于合理指的是合理的分文件,避免大量数据导致 dom diff 的情况。去年在去哪儿分享到 Icon 组件平台的性能优化,因为没有合理编码导致 dom diff 消耗大量资源,但最终通过优化代码逻辑解决了问题。

我认为思路应该是利用 dom diff,加上合理代码逻辑,享受高性能和高编码效率,而不能因为怕 dom diff 性能问题而减少组件中的数据处理。

比如文章页,作者信息不仅仅在 Header,也会隐藏在作者头像、评论,嵌套回复以及 @ 名称中,如果不统一管理肯定会乱掉。

数据统一管理与复杂数据处理并不冲突,数据处理发生在组件外部或者内部,但数据源可以是唯一的,并且通过数据源改变,触发使用此数据的页面刷新。

在 redux-connect 中处理不算做 view 中逻辑,因为 connect 不属于 view,redux 官方 example 也把 Container 和 Component 单独抽了出来。我认为民工叔的理论在复杂场景是完全正确的,在前端数据流简短的场景并不适用。

@camsong
Copy link
Contributor

camsong commented May 5, 2017

叔叔这篇文章着重介绍了下 MVI。我感觉 MVI 和 Redux 普遍开发模式最大的区别是把事件和请求处理全部移到了 Intent 里。这样做的好处是 View 可以做到纯渲染,甚至连事件监听也不需要考虑,可以做到真正的 Pure Component。Model 纯做数据转换。如果只理解到这一步,完全按照这样的准则来执行,肯定开发效率直线下降。因为 DOM 开发中到处都是副作用,为了所谓的“纯洁”而完全纯函数或完全面向对象都是自讨苦吃。"Local state is Fine" 合理使用局部 state 绝对能提升不少效率,像前面提到的搜索框如果要维护历史状态放到内部就比外部合适。

使用局部还是全局 State 我有一个经验:始终保证 Single Source of Truth。这个 Source 如果是 Local state 的也能保证 single source,即可预见的将来不会被外部使用,那使用 Local state 会更高效。

对于分形我的看法是要视业务场景而定。分形特别适合组件和数据强绑定的场景。相当于把一个业务模块内的 MVI 单独打包到一起,整体对外输出。其实也就是充血组件的升级版。用好了还能享受贫血组件的优点,即被外部控制。我们团队恰好就是这种场景,自己开发了一套分形框架用起来很爽。

除此之外还有一种场景是组件和数据交叉绑定的场景,如复杂的聊天室或在线 IDE 这类,分形就不适合。抽象出单独的 Model 层或使用 ORM 来统一管理数据,遇到复杂的异步请求或数据处理就拿出 Rx,甚至上层展现再单独封装一层 React + Redux 渲染才是正道。

@ascoders ascoders closed this as completed May 5, 2017
@xufei
Copy link

xufei commented May 24, 2017

@ascoders @jasonslyvia MVI整体复用和单独复用V都是可以的啊,就像R-R体系里,你可以复用一个Component,也可以复用一个已经connect过的Component,这里意思一样的

@ascoders
Copy link
Owner Author

@xufei 我们是考虑到业务MVI几乎没有复用场景,而组件库里的都没有用MVI去设计才这么说的。
其实我觉得组件库可以用MVI来写,只是它不知道会被放在哪,不存在接入全局数据流的情况,所以还不如用 state。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants