sorrycc / blog

💡
4.48k stars 325 forks source link

支付宝前端应用架构的发展和选择 #6

Open sorrycc opened 8 years ago

sorrycc commented 8 years ago

对 Roof 不感兴趣的同学可以直接从 Redux 段落读起。

下文说说我理解的支付宝前端应用架构发展史,从 roof 到 redux,再到 dva

Roof 应该是从 0.4 开始在项目里大范围推广的。

Roof 0.4

Roof 0.4 接触不多,时间久了已经没有太多印象了,记忆中很多概念是从 baobab 里来的,通过 cursor 订阅数据,并基于此设计了很多针对复杂场景的解决方案。

这种方式灵活且强大,现在想想如果这条路一走到底,或许比现在要好一些。但由于概念比较多,当时大家都比较难理解 cursor 这类的概念。并且 redux 越来越流行。。

Roof 0.5

然后有了 Roof 0.5,提供 createRootContainer 和 createContainer,实现类似 react-redux 里 Provider 和 connect 的功能,并隐藏了 cursor 的概念。

// 定义 state
createRootContainer({
  user: { name: 'chris', age: 30 }
})(App);

// 绑定 state
createContainer({
  myUser: 'user',
})(UserInfo);

这在一定程度上迎合了 redux 用户的习惯。但 redux 用户却并不满足,就算不能用 redux,也希望能在 roof 上使用上更多 redux 相关的特性。

还有个在这一阶段讨论较多的另一个问题是没有最佳实践,大家针对同一个问题通常有不同的解法。最典型的是异步请求的处理,有些人直接写从 Component 生命周期里,有些好一点的提取成 service/api,但还是在 Component 里调,还有些提取成 Controller 。

这是 library 相对于 framework 的略势,Roof 本质上是一个 library,要求他去解决所有开发中能想到的问题其实是不公平的。那么如何做的? 目前看起来有两种方案,1) boilerplate 2) framework 。这在之后会继续探讨。

Roof 0.5.5

在经历了几个 bugfix 版本之后,Roof 0.5.5 却是个有新 feature 的更新。感觉从这个版本起已经不是原作者的本意了,而是对于用户的妥协。

这个版本引入了一个新的概念:action

这也是从 redux (或者说 flux) 里而来的,所有用户操作都可以被理解成是一个 action,这样在 Component 里就不用直接调 Controller 或者 api/service 里的接口了,一定程度上做了解耦。

createActionContainer({
  myUser: 'user',
}, {
  // 绑定 actions
  userActions,
})(UserInfo);

这让 Roof 越来越像 redux,但由于没有引入 dispatch,在实际项目中遇到了不少坑。比较典型的是 action 之间的互相调用。

function actionA() {
  actionB();
}
function actionB() {}

还有 action 里更新数据之前必须重新从 state 里拉最新的进行更新之类的问题,记得当时还写过 issue 来记录踩过的坑。这是想引入 redux,但却只引入一半的结果。

Roof 0.5.6@beta

然后是 Roof 0.5.6@beta,这个版本的内核已经换成了 redux,引入 reducerdispatch 来解决上个版本遇到的问题。所以本质上他等同于 react-redux,看下 import 语句应该就能明白。

import { createStore, combineReducers } from 'redux';
import { createDispatchContainer, createRootContainer } from 'roof';

大家可能注意到这个版本有个 @beta,这也是目前 Roof 的最终版本。因为大家意识到既然已经这样了,为啥不用 redux 呢?

Redux

然后就有不少项目开始用 redux,但是 redux 是一个 library,要在团队中使用,就需要有最佳实践。那么最佳实践是什么呢?

理解 Redux

Redux 本身是一个很轻的库,解决 component -> action -> reducer -> state 的单向数据流转问题。

按我理解,他有两个非常突出的特点是:

  1. predictable,可预测性
  2. 可扩展性

可预测性是由于他大量使用 pure function 和 plain object 等概念(reducer 和 action creator 是 pure function,state 和 action 是 plain object),并且 state 是 immutable 的。这对于项目的稳定性会是非常好的保证。

可扩展性则让我们可以通过 middleware 定制 action 的处理,通过 reducer enhancer 扩展 reducer 等等。从而有了丰富的社区扩展和支持,比如异步处理、Form、router 同步、redu/undo、性能问题(selector)、工具支持。

Library 选择

但是那么多的社区扩展,我们应该如何选才能组成我们的最佳实践? 以异步处理为例。(这也是我觉得最重要的一个问题)

用地比较多的通用解决方案有这些:

redux-thunk 是支持函数形式的 action,这样在 action 里就可以 dispatch 其他的 action 了。这是最简单应该也是用地最广的方案吧,对于简单项目应该是够的。

redux-promise 和上面的类似,支持 promise 形式的 action,这样 action 里就可以通过看似同步的方式来组织代码。

但 thunk 和 promise 都有的问题是,他们改变了 action 的含义,使得 action 变得不那么纯粹了。

然后出现的 redux-saga 让我眼前一亮,具体不多说了,可以看他的文档。总之给我的感觉是优雅而强大,通过他可以把所有的业务逻辑都放到 saga 里,这样可以让 reducer, action 和 component 都很纯粹,干他们原本需要干的事情。

所以在异步处理这一环节,我们选择了 redux-saga

最终通过一系列的选择,我们形成了基于 redux 的最佳实践

新的问题

但就像之前所有的 Roof 版本一样,每个时代的应用架构都有自己的问题。Redux 这套虽然已经比较不错,但仍避免不了在项目中暴露自己的问题。

  1. 文件切换问题

    redux 的项目通常要分 reducer, action, saga, component 等等,我们需要在这些文件之间来回切换。并且这些文件通常是分目录存放的:

    + src
     + sagas
       - user.js
     + reducers
       - user.js
     + actions
       - user.js

    所以通常我们需要在这三个 user.js 中来回切换。(真实项目中通常还有 services/user.js 等) 不知大家是否有感觉,这样的频繁切换很容易打断编码思路?

  2. saga 创建麻烦

    我们在 saga 里监听一个 action 通常需要这样写:

    function *userCreate() {
     try {
       // Your logic here
     } catch(e) {}
    }
    function *userCreateWatcher() {
     takeEvery('user/create', userCreate);
    }
    function *rootSaga() {
     yield fork(userCreateWatcher);
    }

    对于 redux-saga 来说,这样设计可以让实现更灵活,但对于我们的项目而言,大部分场景只需要用到 takeEvery 和 takeLatest 就足够,每个 action 的监听都需要这么写就显得非常冗余。

  3. entry 创建麻烦

    可以看下这个 redux entry 的例子,除了 redux store 的创建,中间件的配置,路由的初始化,Provider 的 store 的绑定,saga 的初始化,还要处理 reducer, component, saga 的 HMR 。这就是真实的项目应用 redux 的例子,看起来比较复杂。

    dva

基于上面的这些问题,我们封装了 dva 。dva 是基于 redux 最佳实践 实现的 framework,api 参考了 choo,概念来自于 elm 。详见 dva 简介

并且除了上面这些问题,dva 还能解决 domain model 组织和团队协作的问题。

来看个简单的例子:(这个例子没有异步逻辑,所以并没有包含 effects 和 subscriptions 的使用,感兴趣的可以看 Popular Products 的 Demo)

import React from 'react';
import dva, { connect } from 'dva';
import { Route } from 'dva/router';

// 1. Initialize
const app = dva();

// 2. Model
app.model({
  namespace: 'count',
  state: 0,
  reducers: {
    ['count/add'  ](count) { return count + 1 },
    ['count/minus'](count) { return count - 1 },
  },
});

// 3. View
const App = connect(({ count }) => ({
  count
}))(function(props) {
  return (
    <div>
      <h2>{ props.count }</h2>
      <button key="add" onClick={() => { props.dispatch({type: 'count/add'})}}>+</button>
      <button key="minus" onClick={() => { props.dispatch({type: 'count/minus'})}}>-</button>
    </div>
  );
});

// 4. Router
app.router(
  <Route path="/" component={App} />
);

// 5. Start
app.start(document.getElementById('root'));

5 步 4 个接口完成单页应用的编码,不需要配 middleware,不需要初始化 saga runner,不需要 fork, watch saga,不需要创建 store,不需要写 createStore,然后和 Provider 绑定,等等。但却能拥有 redux + redux-saga + ... 的所有功能。

更多 dva 的详解,后面会逐步补充。

最后

从 Roof 到 Redux 再到 dva 一路走来,每个方案都有自己的优点和缺陷,后一个总是为了解决前一个方案的问题而生,感觉上是在逐步变好的过程中,这让我觉得踏实。

另外,感叹坚持走自己的路是件很困难的事情,尤其是积累了一定用户量之后。在害怕失去用户和保留本心之间需要有个权衡和坚守。

jaredleechn commented 8 years ago

另外,感叹坚持走自己的路是件很困难的事情,尤其是积累了一定用户量之后。在害怕失去用户和保留本心之间需要有个权衡和坚守。

👏

soda-x commented 8 years ago

我来点赞的

bobodeng commented 8 years ago

好文点赞

codering commented 8 years ago

感谢分享

SMbey0nd commented 8 years ago

点赞

dqaria commented 8 years ago

ziluo commented 8 years ago

赞啊

concefly commented 8 years ago

wsw commented 8 years ago

厉害

ghost commented 8 years ago

这也太imba了!

fengzhu1131 commented 8 years ago

值得学习,一步一步的前进

dont-see-big-shark commented 8 years ago

d.v.a都来了,怎么跟redux其他的插件redux-form配合

sorrycc commented 8 years ago

@wee911 目前还不行,正式发布会支持,通过配置额外的 reducers 。详见:https://github.com/sorrycc/dva/issues/7

dont-see-big-shark commented 8 years ago

@sorrycc 不错,支持

jetango commented 8 years ago

大神问个问题,Redux中如何共享数据啊?我现在有这么个案例,(列表页1 -> 详情页 -> 列表页2),列表2中的数据把列表1的数据覆盖了,返回的时候列表1就是列表2的数据。这个问题有没有好的办法?

sorrycc commented 8 years ago

@jetango , 我能想到的几种解决方案:

  1. 列表 1 和列表 2 在全局 state 上存不同的 key
  2. 返回列表 1 的时候重新加载一次数据 (不想重复加载,可以在 fetch 层做缓存)
jianhuifan commented 8 years ago

赞👍

yxqme commented 8 years ago

赞👍

luoh commented 8 years ago

赞👍

RealDeanZhao commented 8 years ago

玩游戏就是要赢.

linkgod commented 8 years ago

同样遇到了类似的问题,dva是改进,很不错

1kr commented 8 years ago

我是来点赞的~nice share

u0x01 commented 8 years ago

@sorrycc 期待有一天出一个 react-native 的 starter

sorrycc commented 8 years ago

@u0x01 https://github.com/sorrycc/dva-example-react-native

lxd90 commented 8 years ago

太过于精简了

yipanbo commented 7 years ago

不错!这很D.Va

xuanxiao2013 commented 7 years ago

👍

evolutionjay commented 7 years ago

D.va : 这也太IMBA了.

(( BOOM ))

879479119 commented 7 years ago

看名字才进来了解,好东西

bobodeng commented 7 years ago

·~·

Liyuk commented 7 years ago

给赞

yurizhang commented 7 years ago

很好,没有新创造语法~

nilyang commented 7 years ago

:+1: 我是来点赞的,外行需要这样简化的工具来入门 :P

DickyT commented 7 years ago

看了一下框架 也动手捣鼓了一晚上 发现最终webpack还是全站打一个bundle 请问下dva其实支不支持像ant.design这样子 分页打包 跳页的时候再加载需要的js文件呢?

nerf this

sorrycc commented 7 years ago

@DickyT 参考这个例子,https://github.com/dvajs/dva/tree/master/examples/dynamic-load

DickyT commented 7 years ago

明白了,谢谢!没想到里面还有一个例子!

chencheng (云谦) notifications@github.com 於 2016年11月7日星期一 寫道:

@DickyT https://github.com/DickyT 参考这个例子,https://github.com/ dvajs/dva/tree/master/examples/dynamic-load

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/sorrycc/blog/issues/6#issuecomment-259028627, or mute the thread https://github.com/notifications/unsubscribe-auth/AEU2JCg5Z_mXNxrrAEPcatiqqhp9vBzUks5q7-AGgaJpZM4JDmZk .

zackyang000 commented 7 years ago

有2个问题,

  1. 如果一个页面很复杂, 会导致这个 js 文件代码行数猛增. React 可以用组件的方式进行隔离, 但是这个就必须写在一个文件中了.
  2. dispatch 这样直接调用方便吗? 似乎 bindAction 之后直接调用 action 更方便一点. 直接 HardCode 用来用去的很容易出错的.
jaredleechn commented 7 years ago

@TossShinHwa

  1. 同一个 model 相关的内容本来就是高内聚,放在一起更合理一些
  2. 如果 action 增多以后也可以考虑增加 const 目录
zackyang000 commented 7 years ago

Hi @jaredleechn

感谢回复, 对于第一个问题, 这种将 action / sagas / reducer 合并方式, 代码超过 1000 行可能是无法避免的, 这可能给多人开发带来一定的干扰(潜在的合并冲突). 你们这时候是怎么处理的呢?

再想想 SOLID 中的 open / close 原则, 根据这个原则来看, 是否拆分成更多的文件才是更好的解决方案呢?

benjycui commented 7 years ago

@TossShinHwa 首先你得确认 model 的设计没问题。。

yuzhouisme commented 7 years ago

厉害,是时候尝试下dva了

Frank1e0927 commented 7 years ago

点赞

jaredleechn commented 7 years ago

@TossShinHwa 我对一个 model 的理解是同一块数据的相关操作集合体,是面向数据的一个拆分,一个 model 一个文件在我看来已经是很细的粒度了,而且在这个粒度上应该很少有会导致冲突的并发协作修改

而且 reducer 自身的逻辑通常都比较简单,如果还有疑问的话结合个例子说说具体什么问题?

zackyang000 commented 7 years ago

@jaredleechn 也就是说, 在 dva 中, 一个 model 其实对应的只是一个 entity, 而不是一个 module 的概念对吗.

我们这边的话, 一个 model 其实对应的是一个更大的概念.

比如 Blog 系统, Article 这个模块不仅仅包含 title / content 之类的, 也包含下面所有的 Comments. 按我们现在的设计, 其实他们都是在一个模块里面的, 也就是一个 model, 因为他们之间属于包含关系. Comments 独立存在是没有意义的. 但是按你所说, 在 dva 里面似乎他们就属于2个 model 了?

nilyang commented 7 years ago

@TossShinHwa 我也发现了,一个container 容器组件(Route)对应一个model,没办法处理多个,但是一个model可以将多个相关功能集合在一起,只是路径不一样。 比如, user model,'user/login' , 'user/logout', 'user/register', 'user/findpassword', 'user/profile' 等都可以放到一个model中。但是要用不同的路由组件来connect。 但是,这样就会有一个问题,如果一个页面包含了全部这些组件,怎么组合比较好呢? 不知 @sorrycc 如何看?

jaredleechn commented 7 years ago

@TossShinHwa

看怎样来设计,如果评论需要更高的拓展性,那就是 entity acticle + entity comment + link,这样在前端用两个 model 来操作就会比较方便;但是如果你把 comment 直接存在了 article 里面,就没什么必要拆成两个 model 了,因为在这样的前提下,如你所说,comment 就不再是 entity,也就没有单独更新的场景

我觉得仍然是那句话,面向 state 操作的一个拆分

@nilyang

一个container 容器组件(Route)对应一个model,没办法处理多个

RouteComponent 和 model 没有必然的关系,一个 RC 可以订阅多个 model 对应的 state

liyswei commented 7 years ago

@sorrycc,你好。我的npm为2.15.9,node为4.6.0,操作系统是win10。遇到点问题,求教下。 我把 https://github.com/dvajs/dva 项目拉下来后,进入examples/popular-products目录,执行npm install后,执行npm start时到一半就报错了,中间会打开C:\Users\admin.anyproxy_certs文件夹。错误内容如下: ERROR in ./index.js Module build failed: ReferenceError: Unknown plugin "add-module-exports" specifi ed in "E:\tools\node_workspace\dva\.babelrc" at 0, attempted to resolve rela tive to "E:\tools\node_workspace\dva" at E:\tools\node_workspace\dva\examples\popular-products\node_modules\atool- build\node_modules\babel-core\lib\transformation\file\options\option-manager.js: 176:17 at Array.map (native) at Function.normalisePlugins (E:\tools\node_workspace\dva\examples\popular-p roducts\node_modules\atool-build\node_modules\babel-core\lib\transformation\file \options\option-manager.js:154:20) at OptionManager.mergeOptions (E:\tools\node_workspace\dva\examples\popular- products\node_modules\atool-build\node_modules\babel-core\lib\transformation\fil e\options\option-manager.js:229:36) at OptionManager.init (E:\tools\node_workspace\dva\examples\popular-products \node_modules\atool-build\node_modules\babel-core\lib\transformation\file\option s\option-manager.js:374:12) at File.initOptions (E:\tools\node_workspace\dva\examples\popular-products\n ode_modules\atool-build\node_modules\babel-core\lib\transformation\file\index.js :216:65) at new File (E:\tools\node_workspace\dva\examples\popular-products\node_modu les\atool-build\node_modules\babel-core\lib\transformation\file\index.js:139:24) at Pipeline.transform (E:\tools\node_workspace\dva\examples\popular-products \node_modules\atool-build\node_modules\babel-core\lib\transformation\pipeline.js :46:16) at transpile (E:\tools\node_workspace\dva\examples\popular-products\node_mod ules\atool-build\node_modules\babel-loader\lib\index.js:38:20) at E:\tools\node_workspace\dva\examples\popular-products\node_modules\atool- build\node_modules\babel-loader\lib\fs-cache.js:78:18 @ multi index

sorrycc commented 7 years ago

@liysw 学习 dva 请参考这个最新教程, #18

DickyT commented 7 years ago

@liysw 我觉得是墙的问题导致npm install出异常了

sorrycc commented 7 years ago

试试 cnpm