关于前端数据&逻辑的思考

最近重构了一个项目,一个基于redux模型的react-native项目,目标是在混乱的代码中梳理出一个清晰的结构来,为了实现这个目标,首先须要对项目的结构作分层处理,将各个逻辑分离出来,这里我是基于典型的MVC模型,那么为了将现有代码重构为理想的模型,我须要作如下几步:javascript

  • 拆分组件
  • 逻辑处理
  • 抽象、聚合数据

组件化

这是一个老生常谈的问题了,从16年起前端除了构建工具,讨论的最多的就是组件化了,把视图按照必定规则切分为若干模块过程就是组件化,那么组件化的重点就是那个规则前端

那么这个规则又是什么呢?java

按功能?按样式?react

我以前的项目里多数这两种状况都存在,举个简单的例子,对于app的登陆模块来讲就是一个典型的按功能分组,而对于一个列表就是一个明显的按样式去组件化,他们两个对应着两种彻底不一样的写法,由于他们一个是充血模型,一个是贫血模型。在redux中,明显的区别是贫血组件中一切的状态所有外置,组件自身不去管理本身的状态,通通放到reducer;而在充血组件中,一部分状态由全局的store去管理,一部分有自身的state控制。ajax

// 充血组件 // 贫血组件
    组件A | 组件B | 组件C    组件A | 组件B | 组件C
    逻辑A | 逻辑B | 逻辑C    ---------------------
    数据A | 数据B | 数据C           逻辑层
    -------------------    ---------------------
          全局逻辑                 数据层
复制代码

在我重构的过程当中更倾向于将组件内的状态都放在reducer中,这样View就能够更纯粹的去渲染了,这样的View在我看来会更加简洁、更加清晰,对于组件的替换更是得心应手。但状态全外置这种实践带来的代价也是很大的。由于一个带交互的组件,势必须要一些事件的处理,生命周期的触发等等操做,这会带来一些问题:数据库

  • 这种组件提炼出来的状态只和本身有关,强制被放在Store中就会带来Store复杂度的上升,若是你的组件足够多,那么全局的Store会膨胀的特别明显,更重要的是若是你的状态是和组件成树形对应的话,Store中将会冗余不少重复的数据。
  • 描述组件的状态被转移到外部,致使操做组件的成本变高,对于组件内的一些简单操做将变得复杂繁琐。

对于后一点我认为并无很大的问题,得益于分层和纯渲染的设计,组件将控制自身的行为交出后能够将这些逻辑抽象为更加通用的逻辑,从而方便有相似需求的组件使用,由于逻辑应该只出如今一个地方,而不该分散在多个地方。例如控制一批组件的显示或隐藏,将组件内部控制显示的逻辑交出来反而会省去更多的重复代码。redux

而我更担忧的是因为组件中私有状态的转移致使的Store膨胀的问题,为了不这个问题首先作的即是尽量的提取公用有类似做用的状态,例如控制显示/隐藏、多个列表的页数/条数;等这些有着类似功能的字段。走到这一步就引出了另一个问题了,对于组件的状态描述是树形的仍是平行的。后端

  • 树形结构

这种结构的特色是将一个组件的状态经过一个树的形式记录下来,页面是如何嵌套的,那么状态树就是如何嵌套的,这样作的好处是组件接收到状态后直接递归的显示就好了,对于组件来讲这是最简单,效率最高的展示形式。但这样作的问题就是若是有多个类似的组件就会形成Store中冗余大量重复数据,最终形成Store的膨胀。react-native

  • 平行结构

这种结构和上面的树形结构偏偏相反,能够最大程度的避免冗余数据的产生,将每一类数据拍平保存,但这种形式对于组件的展现却很不友好,组件须要本身去消化多处数据源带来的格式化操做,在redux中connect方法就是用来处理这种多数据源聚合用的。数组

那么上面两种结构改如何取舍呢?我我的推荐第二种平行结构,既然选择了平行结构,那么该如何去处理数据聚合的问题呢?在这里我推荐利用管道的思路来解决,这借鉴了 Angular 2 Pipe的概念,固然熟悉Linux的同窗对于|操做符必定也不会陌生。在咱们的项目中,数据是流动的,如同一个管道中的水同样,Store就是一个水库,聚集了各类各样的数据(水),而页面组件就如同须要灌溉的田,而从水库到田间这段距离就须要水管的帮助了。一样的,利用pipe咱们能够将保存在Store中的数据转换成指望看到的结构,而这一切操做都是在数据的流动中完成的,而不是放在数据已经传递到组件以后去处理了。

这里引出了一个概念,就是数据流这个概念,在项目中我将全部数据的操做都成为数据的流动。举个例子,当用户在登陆框输入了用户名和密码并点击提交以后,这两个input中的value就变成了两个数据流:

input => merge(name, password) => filter(校验合法性) => post(服务器)
复制代码

这个行为变成了一条流水线,先无论post输出的结果如何,在上面的demo中咱们的输入行为被抽象成了两个参数,最后经过合并、过滤、发送,最终到达服务器,这不是一个新概念,在不少的框架中都有体现:

在Cycle.js它被称为 Intent(负责从外部的输入中,提取出所需信息),Intent实际上作的是action执行过程的高级抽象,提取了必要的信息。因为View是纯展现的,因此包括事件监听在内的行为通通被Intent抽象成数据源,这在RxJs中很常见:

var clicks = Rx.Observable.fromEvent(document, 'click');
clicks.subscribe(x => console.log(x));

// 结果:
// 每次点击 document 时,都会在控制台上输出 MouseEvent 。
复制代码

相比于从View中发出的同步数据源,咱们遇到更多的是从HTTP中获取的异步数据源。在redux中咱们经常使用redux-thunk来处理异步操做,那么在流中呢?

逻辑处理

在以前的业务中咱们有不少方式去处理异步操做,好比说最经常使用的redux-thunk(回调)、promise、async/await。如今不少人更愿意用async/await操做符去写异步逻辑,由于它让代码显得更加“同步”,我以前也很喜欢这种方式,但如今在数据流的概念中,同步/异步已经被“模糊”了,它们都是数据源,它们都是“主动”发出数据的,那么同步仍是异步就显得不那么重要了,仍是上面的例子,若是用户名变成了一个异步获取的过程,而不是用户主动输入的了:

input => merge(async(name), password) => filter(校验合法性) => post(服务器)
复制代码

这种状况下在RxJs中能够经过zip来等待所有的数据流

let age$ = Observable.of<number>(27, 25, 29);
let name$ = Observable.of<string>('Foo', 'Bar', 'Beer');
let isDev$ = Observable.of<boolean>(true, true, false);

Observable
    .zip(age$,
         name$,
         isDev$,
         (age: number, name: string, isDev: boolean) => ({ age, name, isDev }))
    .subscribe(x => console.log(x));

// 输出:
// { age: 27, name: 'Foo', isDev: true }
// { age: 25, name: 'Bar', isDev: true }
// { age: 29, name: 'Beer', isDev: false }
复制代码

经过这样的链式操做,咱们能够很方便的控制和获取数据流,这是对于数据的获取,那么数据的分发呢?在redux中,咱们一般会屡次dispatch,在redux-thunk中咱们会这样写:

const getInfo = (params) => async (dispatch, getState) => {

    // TODO...
    
    dispatch(actionaA);
    
    // TODO...
    
    dispatch(actionaA);
}
复制代码

而在redux-observable中:

const somethingEpic = (action$, store) =>
  action$.ofType(SOMETHING)
    .switchMap(() =>
      ajax('/something')
        .do(() => store.dispatch({ type: SOMETHING_ELSE }))
        .map(response => ({ type: SUCCESS, response }))
    );
复制代码

可是我认为处处dispatch是一个很差的行为,这会让一个流变得混乱,由于你在流的最后不会得完整的结果(在过程当中有一部分就已经派发出去了),这会让逻辑看起来很散乱,因此我推荐应该写成这样的形式:

const somethingEpic = action$ =>
  action$.ofType(SOMETHING)
    .switchMap(() =>
      ajax('/something')
        .mergeMap(response => Observable.of(
          { type: SOMETHING_ELSE },
          { type: SUCCESS, response }
        ))
    );

// 上面这两段demo来着redux-observable的文档
复制代码

结束了异步的处理,咱们的流模型也完成了input->output的完整闭环了。在这里没有详细说output是由于基于redux,我任然是经过redux的connect方法将Store分发注入到组件的props中去的,所以若是你熟悉redux那么会很习惯如今的改变。

在处理完了同步/异步以后咱们就来聊聊业务的逻辑该如何处理了。在redux中逻辑被分在了两个地方,action和reducer中,一个是作数据的聚合,一个是作数据的格式化。上面提到了Intent 是action的高阶抽象,实际上是对action的拆分,剥离了action中获取数据的部分逻辑,那么剩下的就是数据处理的部分了,这部分在个人实践中被叫作Service

这是一个单例的实例,整个项目中一个服务只会有一个实例,没必要将相同的代码复制一遍又一遍,只须要建立一个单一的可复用的数据服务,而且把它注入到须要它的那些组件中。而且使用单独的服务能够保持组件足够的精简,同时也更容易对组件进行单元测试。一样reducer中的数据格式化逻辑也迁到了服务中去处理,在redux中reducer兼顾着数据的格式化和数据的保存这两个功能,如今咱们将完全剥离出数据的处理部分,剩下的reducer将只作数据的保存,这就又引出了另外一个概念Model,这一层咱们一会讨论,接着业务处理来看,在数据流获取到数据并处理分发到Model中以后,input这一步基本算是结束了,接下来就是由Model到View的output了。

上文中我说道了我推荐使用平行模式,那么在平行模式到View这种树型结构该若是转化呢?这是output中最重要的一步,在CycleJS中这一步一般由filter去完成,而在Angular中则是由Pipe去处理,不管它叫什么,它们都是这条流程上的一环,就像水管中的一节同样,全部从Model通向View的数据都会进过这一环,从而被格式化。在代码中我更推荐你们尝试使用Decorator去过滤数据源:

@UserInfoPipe({ name: 'Model.UserInfo.name' })
class LoginDemo extends Component {
  constructor(props) {
    super(props);
  }

  render(){
    return (
      <View>
        <Text>{this.props.name}</Text>
      </View>
    );
  }
}

复制代码

抽象、聚合数据

如今总体的骨架已经有了,剩下的就是该如何更好的抽象整合项目中的数据了。

  • 第一阶段

最一开始的项目因为为了方便,我就按照API的结构去设计Store,那个时候一个页面对应一个接口或者不多的几个接口,这时候我将API返回的结构与本地的状态一一对应,这在初期很是的方便,不须要我作过多的转换,然而接下来为了应付接口的各类异常,不得不写不少防护性的代码(字段判空、属性变动、接口数据拼装),最后这些代码变得臃肿不堪,在其它同窗介入修改的时候老是一头雾水,老是改了这里,那里出又出了问题。而且这其中也存在很多冗余的数据。

  • 第二阶段

后来我发现既然数据都是最终给View去用的,那么我就按View的需求去设计Store好了,这个Store对于展现的组件来讲,使用起来很是方便,当前应用处于哪一种状态,就用对应状态的数组类型的数据渲染,不用作任何的中间数据转换。不过这也一样形成数据冗余的问题,而且若是我须要改动页面的某个字段的话,须要在不少地方去修改,由于这个Store树变得很深枝叶不少。

  • 第三阶段

那么我如今该如何设计状态呢?做为一个曾经作过一段时间后端的我来讲,我决定模仿数据库的结构去设计状态树。把Store当成一个数据库,每一个种类的状态看作数据库中的一张表,状态中的每个字段对应表的一个字段。

那么设计一个数据库,应该要遵循哪些原则呢?

  • 数据按照域分类,存在不一样的表中,每张表存储的字段不重复
  • 每张表中每条数据都有一个惟一主键
  • 表中除了主键外其它列,相互不存在依赖关系

而基于上面这三条原则,咱们怎么设计Store呢?

  • 把整个项目按照必定模型去分离为若干子状态,这些子状态之间不存在重复冗余的数据。

怎么理解这件事呢?举个例子,我有一个长列表,每当我点击列表中的某一列时就会有一个红框出现包裹住这列,而这个列表中真正展现的数据应该是另一个子状态,它们的关系相似:

{
    activeLine: 1,
    list: [
        {
            name: 'test1',
        },
        {
            name: 'test2',
        },
        {
            name: 'test3',
        },
        {
            name: 'test4',
        },
    ]
}
复制代码
  • 以键值对的结构存储数据,用key/ID做为记录的索引,记录中的其余字段都依赖于索引。

有了惟一的key作主键,咱们就能够很方便的去遍历/处理数据。更进一步的,若是咱们想去判断一条数据有没有变化,咱们能够单纯的去判断主键是否一致,在一些状况下,这是一个不错的思路,这避免了多层判断,或者深拷贝带来的复杂度和性能问题(这个能够参考immutable)。

  • 状态树中不保存能够经过已有数据计算出来的数据,也就是这些数据都是相互独立的,均可以被称为原子数据

什么是原子数据?页面中使用到的数据都是由这些原子数据经过计算、拼装获得的(注意:这里只有拼装,没有拆分,由于原子是最小的单位,因此是不可拆分的);这就保持了数据源的统一,不会出现一份同样的数据来自多出数据源的问题了,这会避免不少没必要要的问题,如多处数据源不一样步致使的页面展现异常等问题。

好了,数据层也设计完了,这样一个完整的结构就清晰的摆在面前了,最终总结一下这个过程:

  • 按照贫血模型分离组件
  • 经过订阅的形式采集数据源
  • 经过数据库的形式去保存数据
  • 经过流的方式去处理和分发数据
  • 经过流的形式去格式化数据

通过以上几步,咱们就初步的完成了一个业务从input到output的完整闭环。

已上这些即是我此次重构总结的一些经验,确定不全对、不完善、不许确,可是这个大方向我以为是值得去探索的。

相关文章
相关标签/搜索