05月05, 2017

【译】如何组织真正的 Redux/React 应用程序

原文:http://www.zcfy.cc/article/1614

最近我在用 Redux/React 重写 Web 应用程序 Flow,这个工具用于帮助开发者更好地理解他们的应用程序的结构和行为。它提供了一个交互式的 Web 界面,将 Java 程序的执行流可视化。我面临如何组织工程的问题。

是按文件性质组织,还是按功能/数据域组织?

在大多数 Redux/React 工程,包括官方的示例和教程中,常见的文件结构是通过文件性质来组织的:actions、reducers、selectors、(presentational) componentscontainers

- app/
  - actions/
      CallActions.js
      ArtifactActions.js
      FilterActions.js
      ...
  - reducers/
      CallReducer.js
      ArtifactReducer.js
      FilterReducer.js
      ...
  - containers/
      CallGraph.jsx
      FlameChart.jsx
      FilterPanel.jsx
  - components/
      ...
  index.js
  rootReducer.js

所以,开始我也是用这种结构,但是我很快就发现了这种方式的局限:没法扩展。这很容易看出来,因为文件性质总是保持不变,而实际应用中的功能和数据域是发展的。于是,这些文件夹中的文件就会越来越多。此外,当我开发一个新功能时,为了找到所有与同一功能相关的文件,我会浪费很多时间在工程中滚动和导航。

然后我试着通过功能来对文件分组。但是我发现了一些新问题。在我正在开发的应用程序中,我们要从一个 Java 应用程序的执行中获取数据,比如调用栈、正在运行的线程、构件(包、类、方法)等等。并且我们要基于这些数据来创建不同的可视化图形/视图,从而帮助开发者更好地理解他们的程序。比如,我们要创建一个调用图,来表示构件及其之间的关系。我们还要提供一个火焰图,来展示在执行期间的所有方法调用。你可以在所有可视化图形中搜索、过滤以及选择构件和调用。

你可以到这里的演示地址去看看这些可视化图形是啥样子,或者阅读这篇文章看看我是如何用可视化图形来理解 Junit runner 的。

可视化一个 Junit 测试的执行流

我意识到,一方面,一个功能/视图经常包含多个数据域。比如,火焰图的渲染会需要线程、调用以及最终的过滤器。

另一个方面,一个数据域可以被多个功能共享,它不必成为一个功能或者一个视图本身。调用图和火焰图都需要过滤数据。对于我来说,reducers、actions 和 selectors 控制数据和业务逻辑,而容器和组件构建视图。

最后,我把 containers 和 components 从功能文件夹中提取出来,最终得到如下的结构:

-app/
  - data/
    - artifacts/
        ArtifactReducer.js
        ArtifactActions.js
        ArtifactSelectors.js 
    - calls/
        CallsReducer.js
        CallsActions.js
        CallsSelectors.js
    - filters/
        ...
    - threads/
        ...
    - ...
  - containers/
      FlameChartContainer.jsx // depends on calls, threads, filters
      FilterContainer.jsx     // depends on filters
      CallGraphContainer.jsx  // depends on artifacts and filters
  - components/
      ...
  index.js
  rootReducer.js

我认为这种结构的主要优点是:

  • 存在于 action、reducer 和 selector 中的不同数据域和业务逻辑是独立的封装的
  • 功能和视图可以基于数据创建,并且依赖可以被清晰地确定
  • React 组件的分离提醒你要让组件在功能和视图之上保持高可重用

模块导入和输出

使用 ES6 模块系统,每个文件就是一个模块,我们需要从每个文件中输出函数和变量,以及导入它们以供使用。所以,当一个容器依赖于数据域时,我们可能需要导入它的 reducer、action 和 selector。比如:

import CallReducer from '../data/calls/CallReducer.js';
import * as CallActions from '../data/calls/CallActions.js';
import * as CallSelectors from '../data/calls/CallSelectors.js';

这需要很多带有相对路径的 import,数据域的内部结构 /data/calls 被暴露出来了。更好的方法是通过创建一个输出本模块内部文件的 index.js 文件,来封装数据域。

`// data/calls/index.js`
import CallReducer from './CallReducer';
import * as CallSelectors from './CallSelectors';
import * as CallActions from './CallActions';
export {
  CallReducer,
  CallSelectors,
  CallActions
}

这个 index.js 成了该数据域的公共 API,对外隐藏了它的内部文件结构。于是,容器中该模块的依赖就被减少到只有一个 import:

`import { CallReducer, CallSelectors, CallActions } from '../data/calls'`

这是不是看起来更好?这样你就可以改变数据域模块的内部结构,而不用担心破坏任何依赖。

测试放在哪里?

起初我把测试放在源文件夹 src/app 之外的一个单独的文件夹 src/test 中。因为我写过很多 Java 代码,对我来说这看起来不错,因为 Maven 工程中就是这么干的(源代码放在 src/main/java 中,测试放在 src/test/java 中)。

我很快就意识到这一点都不方便。测试离源代码太远了。所以每次修改一个源文件时,比如 src/app/data/calls/CallReducer.js ,我就得导航到相关的测试文件 src/test/calls/CallReducer.spec.js 。如果要做点重构,import 经常被破坏,不幸的是,我们不会从 Java IDE 的重构功能上得到好处。

因此,我决定将测试移到 src/app 文件夹,这样测试文件就与源文件靠近了。此时,我就不那么容易将 import 搞得一团糟。

- app/
  - data/
    - spec/
        CallReducer.spec.js
        CallSelectors.spec.js
      CallReducer.js
      CallActions.js
      CallSelectors.js

如果是用 Karma,Karma 会扫描,并通过扩展名轻松找到所有测试文件。所以,测试文件的移动是很透明的。

`// karma.conf.js`
files:[
 'src/app/**/*.spec.js'
]

总结

我很想在本文中分享我是如何组织工程的文件结构,以及我这样做的原因。我认为,如果你是从一个很简单的 Redux/React 工程着手,那么通过文件性质来组织文件结构,对你来说还是不错的。这可能对你学习 Redux 的主要概念有帮助。但是当事情变得复杂时,你会发现这种做法的局限,并采用其它方式。

毕竟,如何在数据域、业务逻辑、视图,以及所有这些组件的依赖和可重用性等方面考虑你的工程,才是最重要的。

英文原文:https://medium.com/@yiquanzhou/how-to-structure-real-world-redux-react-application-d61e66a7dd36#.6nsfud25p

本文链接:http://www.xiaojichao.com/post/how-to-structure-real-world-redux-react-application-medium.html

-- EOF --

Comments