React、Redux 项目工程架构实战

本文创作于 2016-1-22

本文不是 React、Redux 项目的入门教程,本文假设读者已经了解 React、Redux 项目的结构,工作原理等知识。

React 和 Redux

什么?你没听过 React?你是火星来的么?React 是 Facebook 推出的一个用来构建用户界面的 JavaScript 库。来感受一下 React 的火爆吧。

react stars.png
Figure 1. 截止到2016年1月22日,Github 上 React 项目取得的成绩。

Redux 是 JavaScript 状态容器,提供可预测化的状态管理。Redux 由 Flux 演变而来,但受 Elm 的启发,避开了 Flux 的复杂性。Redux 也是一个『火爆』的项目,Github 成绩也相当不错。

redux stars.png
Figure 2. 截止到2016年1月22日,Github 上 Redux 项目取得的成绩。

代码组织和工程结构

Redux 官方库给了一些示例,从这些示例中我们可以总结出 Redux 的代码组织方式及工程目录结构。

Redux 的官方库示例的目录结构
Figure 3. Redux 的官方库示例的目录结构

基于官方的几个 Redux 示例,我们总结出一套代码组织方式及工程目录结构,如下:

Redux 代码组织方式及工程目录结构
/root
    /actions
    /apis
    /apps
    /components
    /constants
    /containers
    /middlewares
    /models
    /reducers
    /store
    /tests
    webpack.config.js
    package.json
    .babelrc
    .eslintrc

Redux 项目采用了类似 RoR 项目的组织形式,先按照模块的作用进行分层,再按照模块的功能划分。写代码的时候,需要先对当前模块的作用做出判断,譬如是 action 还是 container,将代码文件放到对应的目录中。然后再根据模块所属的子系统或组件对文件进行命名,譬如:user.js 或 share.js 等。

这样组织的一个优点是各个层的作用比较明确,方便处理依赖关系。因为 Redux 是一个类 FLUX 框架,其数据流是单向的,所谓数据流的流动,其实就是指数据在各个层中的流动,简单说来是 action ⇒ reducer ⇒ store ⇒ container ⇒ component ⇒ DOM 这么一个过程(而触发 action 的过程是 DOM ⇒ component ⇒ container ⇒ action)。各个层直接的依赖关系也基本是这样的,这样就明确的代码的依赖,减少耦合,降低了复杂度。

Note
劣势

它的缺点也很明显,就是淡化了『子系统/组件』,一方面会导致子系统或组件代码组织分散,不内聚,另一方面也会产生子系统或组件边界模糊的问题,导致子系统或组件的相互依赖,产生耦合。譬如,一个用户子系统的文件分布可能是:

/root
    /actions
        users.js
        user.js
    /apis
        users.js
        user.js
    /components
        Users.jsx   <- 用户列表页
        User.jsx    <- 用户详情页
    /containers
        Users.js
    /models
        User.js
    /reducers
        users.js
        user.js
    /store
        store.js
    /tests
        users.js
        user.js

放眼望去,满眼的 user 文件,特别是当各个目录还有其他子系统或组件的代码时,找起代码来会更加不方便。另外,这样的组件的内聚性也比较差,需要靠自制力来把控。

Tip
所谓『鱼与熊掌不可兼得』,综合来看,还是先分层对项目更好一些。

数据结构及其组织方式

使用 Immutable 数据结构

使用 Immutable 数据结构有两个好处,不变性和方便对比。

redux store 里面的 state 使用 immutable 的数据结构

由于『永远不要修改 state 对象』的要求,state 的格式尽可能使用『immutable 的实现』。

immutable.js 是 Facebook 开源的一款 javascript 的 immutable 数据结构的实现,它提供了诸如 List、Map、Set 等等多种 immutable 容器以及 Record 等 immutable 对象。

由于 immutable 提供的数据结构是不允许修改的,因此使用 immutable 容器可以轻松做到『永远不要修改 state 对象』的要求,防止误修改 state 对象引起的 bug。

此外,immutable.js 还提供了大量的 immutable 更新方法,如 updateInsetIn 等,这些方法使更新容器内的对象变得非常方便,可以不再使用诸如 Object.assign 的实现。

reducer 的例子
function users(state, action) {
  const user = action.user;
  return state.updateIn(user.id, user)
}

React 组件的 props 采用 immutable 数据结构及容器

React 组件的 props 一个重要的作用是用来比对组件是否需要更新,immutable.js 提供的数据结构可以很好实现『对比』功能,它会将容器内的数据根据一定的规则计算一个 hashCode,若两个 immutable 的数据对象一模一样,其 hashCode 是相等的,反正则反。

采用 immutable 数据结构会帮助 React 组件快速地对比组件是否需要更新,例如,一个组件的 props 接收一个 Immutable.List 对象,当这个对象发生变化时,不需要去比对 List 对象里面每一个值是否有变化,只需要计算一下 hashCode,比对 hashCode 是否一致即可。

Note
React 组件的 props 都应该设计为采用 immutable 数据结构,不论是否使用 Redux。

数据的组织方式

设计 state 结构

在 Redux 应用中,所有的数据都被保存在一个单一容器 state 中。这个数据容器就是整个应用的状态,包括但不限于:领域实体、实体之间的关系、锁、UI 相关的 state 等。提前设计 state 的结构非常重要。

Tip
尽可能地把 state 范式化

开发复杂的应用时,不可避免会有一些数据相互引用。建议你尽可能地把 state 范式化,不存在嵌套。把所有数据放到一个对象里,每个数据以 ID 为主键,不同数据相互引用时通过 ID 来查找。把应用的 state 想像成数据库。

state 的设计可以借鉴《企业应用架构模式》一书里提到的『Identity Map』模式的思想。我们将实体对象存储在 state 中统一管理,它就是整个 redux 应用程序的 Identity Map。

当然,state 里不仅仅有实体对象,还包括它们之间的关系。这些数据需要以一种最合适的方式来存储,范式可能不太适合存储关系,使用起来是不方便的。一些 NoSQL 的数据组织方式对这类数据更加合适。

Note
不论如何,提前规划并文档化 state 的结构是非常有必要的。

React 组件的 props 采用非范式化的嵌套式结构

如果把 state 作为 props 注入到组件里,组件就具备了访问当前内存中所有的领域实体对象及其关系的能力,对于组件来说会非常方便。事实上,虽然我们推荐采用范式的形式来组织 state 的数据,但对于组件而已,『非范式化的嵌套式结构』却是更合适的数据组织方式。

如果将 state 作为 props 注入到组件里,组件的依赖将会非常不明确。state 是一个大杂烩,其数据组成会时时刻刻都在变化。当一个组件只依赖 state 的时候,我们已经说不清这个组件实际上依赖哪些数据,也就是说我们将无法控制 state 内部的数据是否满足这个组件的需要。这个组件已经丧失了复用的能力。

另一个原因是组件的 props 还有一个非常重要的目的就是比对组件是否有变化需要更新。包含了所有数据的 state 时时刻刻都在变化,是不能作为组件是否需要更新的依据。另外,父组件和子组件之间是嵌套的关系,子组件如果需要更新,父组件必须也要更新(兄弟组件不一定需要更新),如果采用了嵌套式的组织方式,数据结构与组件间的结构一致,传递值和对比变化的工作将会非常简单。

使用『面向领域实体及其关系的范式 API 设计风格』

在 state 中专门开辟一块空间存储实体及它们的关系,并尽可能的统一地管理它们,不仅仅是客户端,还包括服务端 API。

《面向领域实体及其关系的范式 API 设计风格》中提到,API 返回的风格最好也是范式化的,这样可以非常方便地与客户端进行对接。

譬如:有两个 API,/topics/<int:topic_id>/tweets//users/<int:user_id>/tweets/,它们分别表示『某个话题下的微博』和『某个用户发布的微博』。如果我们将它们的返回风格设计为范式化的,相关的业务处理会非常的简单。

返回结果示例
{
    "entities": {    // 所有的 API 都将实体封装到 entities 下。
        "users": {
            "1": {
                "name": "张三"
                ......
            }
        },
        "topics": {
            "1": {
                "name": "热点微博"
                ......
            }
        },
        "tweets": {
            "1": {
                "user_id": 1,
                "topic_id": 1,
                "content": "呵呵"
            }
        }
    },
    "relationships": {  // 根据实际业务需要,返回本节的内容。
        "user_tweets": {
            "1": [1]
        },
        "topic_tweets": {
            "1": [1]
        }
    }
}
reducer 的代码
// 统一处理范式化的 entity
export function entities(state, action) {
    switch(action.type) {
        case RECEIVE_USER_TWEETS:
        case RECEIVE_TOPIC_TWEETS:
            return merge(state, action.result.entities)
        default:
            return state
    }
}

// 各个业务 API 处理其业务相关的关系更新
export function userTweets(state, action) {
    switch(action.type) {
        case RECEIVE_USER_TWEETS:
            return merge(state, action.result.user_tweets)
        default:
            return state

    }
}

export function topicTweets(state, action) {
    switch(action.type) {
        case RECEIVE_TOPIC_TWEETS:
            return merge(state, action.result.topic_tweets)
        default:
            return state

    }
}

Containers

「Container」只是一种称呼,事实上,代码中不会出现 container 这个后缀。 React 项目与 Redux 结合时,需要将组件连接到 Redux 并且让它能够 dispatch actions 以及从 Redux store 读取到 state。这一部分逻辑通常放在一个叫「containers」的文件下,于是我们就称呼这些包装过的组件为 Container。

我们是通过 react-redux 提供的 connect() 方法将组件连接到 Redux。尽量只做一个顶层的组件,或者 route 处理。从技术上来说你可以将应用中的任何一个组件 connect() 到 Redux store 中,但尽量避免这么做,因为这个数据流很难追踪。

任何一个从 connect() 包装好的组件都可以得到一个 dispatch 方法作为组件的 props,以及得到全局 state 中所需的任何内容。 connect() 的唯一参数是 selector。此方法可以从 Redux store 接收到全局的 state,然后返回组件中需要的 props。最简单的情况下,可以返回一个初始的 state (例如,返回认证方法),但最好先将其进行转化。

// 基于全局 state ,哪些是我们想注入的 props ?
function select(state) {
  return {
    visibleTodos: selectTodos(state.todos, state.visibilityFilter),
    visibilityFilter: state.visibilityFilter
  };
}

// 包装 component ,注入 dispatch 和 state 到其默认的 connect(select)(App) 中;
export default connect(select)(App);

所有的 dispatch action 均由 container 注入 props 方式实现

与其他框架结合

有些时候,仅仅采用 React 无法满足需求,譬如:开发钉钉微应用,需要调用钉钉的 JSAPI

其他

使用 ES2015

ES2015 在语言层面引入大量的新特性,使 Javascript 终于有了脱胎换骨的感觉。

  1. 箭头函数

  2. 模板字符串

  3. rest 参数,扩展运算符(spread),函数默认值

  4. 变量的解构赋值

  5. generator 和 promises

  6. maps,sets 和 symbols

  7. ……

项目中使用 ES2015 的新语言特性可以使开发效率更高,写出的代码更好维护。譬如:低版本 Javascript 本身的 prototype 原型继承,之前几乎每个人都有自己实现一套 OO 模拟,现在有原生的 class extends 语法,从语言层面进行统一;函数的参数结构和默认值,避免了手动的默认值分配和参数为 0 的坑;箭头函数避免了 this 上下文的坑;块级的 let/const 避免了『var hoisting』 的坑;『模板字符串』避免繁琐的手动字符串拼接;更好的 Unicode 支持;ES2015 模块[1];还有 asyncawait 对于异步流程处理本质上的改善[2]

ES2015 是一个已经正式发布的标准,并不是半成品或者玩具。有了 ES2015,什么 CoffeeScript、TypeScript 等基本上可以拜拜了。node 从 v4 版本起支持大部分的 ES2015 新语法[3],现代的浏览器(如 Chrome)也支持大部分语法,不兼容的浏览器可以使用 Babel 预处理器将代码编译成 ES5(具体做法可参考:《基于 Webpack 的前端资源构建方案》)。

Note
更高的要求

使用 ES2015 另外一个层面是要抛弃或替换现有的一些做法:

  1. 异步函数的回调惯例

    在过去,异步函数支持回调惯例是导出 error-first callback 的接口形式,而现在请使用 Promise。

  2. 新的异步模式

    在过去,一般有两种方式来管理异步流:callback 回调和 streams 流;而现在请使用 generatorpromiseawait/async [4]

使用 Fetch API

JavaScript 通过 XMLHttpRequest(XHR)来执行异步请求,这个方式已经存在了很长一段时间。虽说它很有用,但它不是最佳API。它在设计上不符合职责分离原则,将输入、输出和用事件来跟踪的状态混杂在一个对象里。而且,基于事件的模型与最近 JavaScript 流行的 Promise 以及基于生成器的异步编程模型不太搭。

新的 Fetch API [5] 打算修正上面提到的那些缺陷。 它向 JS 中引入和 HTTP 协议中同样的原语。具体而言,它引入一个实用的函数 fetch() 用来简洁捕捉从网络上检索一个资源的意图。

Fetch 规范 [6] 的 API 明确了用户代理获取资源的语义。[7]

Tip

在 Github 上,有基于低版本浏览器的兼容实现。[8]

简单的fetching示例
fetch("/data.json").then(function(res) {
  // res instanceof Response == true.
  if (res.ok) {
    res.json().then(function(data) {
      console.log(data.entries);
    });
  } else {
    console.log("Looks like the response wasn't perfect, got status", res.status);
  }
}, function(e) {
  console.log("Fetch failed!", e);
});
Note
抛弃 jQuery 的 $.ajax 吧!

使用 Webpack 构建项目

Webpack 是德国开发者 Tobias Koppers 开发的模块加载器,是一款开源的模块化构建工具。

模块化的重要性想必不必多言了,解决模块问题的开源方案也非常多,如:RequireJS,SeaJS,Webpack,Browserify,SystemJS。node 的 npm 已经成了基于模块规范的包管理方案的事实标准,RequireJS,SeaJS 等会渐渐地退出历史舞台。Webpack 支持多种包管理方案,方便兼容各种开源项目及迁移旧代码,可谓是新旧通吃。

Webpack带来的一种新的前端打包思路:不仅仅是 JavaScript,而是将 HTML、CSS 和其他静态资源统统作为『模块』来看待。因为在实际开发中,不仅仅是 JavaScript 的模块之间存在依赖关系,HTML、CSS 和其他静态文件之间也会有依赖关系。实际开发中,开发环境和生产环境中这些静态资源之间的相对路径关系经常是不一样的,这就导致我们以往在开发环境到生产环境的上线过程中有很多繁琐的步骤,比如改写静态资源引用的 URL(版本戳,静态资源域名/CDN),图片优化,根据文件大小做成内联、模块的切分和按需加载等等。

Webpack 提供了更好的开发体验,『热重载』特性,在修改代码后不重载页面的情况下替换单一模块,对开发体验带来质的提升。举例来说,在修改一个打开应用后需要 N 次操作才能看到的组件,如果每改一次就要重复这些操作,那样效率实在太低。

更详细的 Webpack 的构建方案请参考:《基于 Webpack 的前端资源构建方案》。

results matching ""

    No results matching ""