本系列分三部曲：《框架实现》 《框架使用》 与 《跳出框架看哲学》，这三篇是我对数据流阶段性的总结，正好补充之前过时的文章。

本篇是 《框架使用》。

## 1 引言

现在我们团队也在重新思考数据流的价值，在业务不断发展，业务场景增多时，一个固定的数据流方案可能难以覆盖所有场景，在所有业务里都用得爽。特别在前端数据层很薄的场景下，在数据流治理上花功夫反倒是本末倒置。

业务场景通常很复杂，但是对技术的探索往往只追求理想情况下的效果，所以很多人草草阅读完别人的经验，给自己业务操刀时，会听到一些反对的声音，而实际效果也差强人意。

所以在阅读文章之前，应该先认识到数据流只是项目中非常微小的一环，而且每个具体方案都很看场景，就算用对了路子，带来的提效也不一定很明显。

2017 年 Redux 依然是主流，可能到 18 年还是。大家吐槽归吐槽，最终活还是得干，Redux 还是得用，就算分析出 js 天生不适合函数式，也依然一条路走到黑，因为谁也不知道未来会如何发展，redux 生态虽然用得繁琐，但普适性强，忍一忍，生活也能继续过。

Dob 和 Mobx 类似，也只是数据流中响应式方案的一个分支，思考也是比较理想化的，因此可能也摆脱不了中看不中用的命运，谁叫业务场景那么多呢。

不过相对而言，应该算是接地气一些，它既没有要求纯函数式和分离副作用，也没有 cyclejs 那么抽象，只要入门的面向对象，就可以用好。

## 2 精读 dob 框架使用

使用 redux 时，很多时候是傻傻分不清要不要将结构化数据拍平，再分别订阅，或者分不清订阅后数据处理应该放在组件上还是全局。这是因为 redux 破坏了 react 分形设计，在 [最近的一次讨论记录](https://zhuanlan.zhihu.com/p/32273365) 有说到。而许多基于 redux 的分形方案都是 “伪” 分形的，偷偷利用 `replaceReducer` 做一些动态 reducer 注册，再绑定到全局。

讨论理想数据流方案比较痛苦，而且引言里说到，很多业务场景下收益也不大，所以可以考虑结合工程化思维解决，将组件类型区分开，分为普通组件与业务组件，普通组件不使用数据流，业务组件绑定全局数据流，可以避免纠结。

### Store 如何管理

> 使用 Mobx 时，文档告诉我们它具有依赖追踪、监听等许多能力，但没有好的实践例子做指导，看完了 todoMvc 觉得学完了 90%，在项目中实践后发现无从下手。

所谓最佳实践，是基于某种约定或约束，让代码可读性、可维护性更好的方案。约定是活的，不遵守也没事，约束是死的，不遵守就无法运行。约束大部分由框架提供，比如开启严格模式后，禁止在 Action 外修改变量。然而纠结最多的地方还是在约定上，我在写 [dob](https://github.com/dobjs/dob) 框架前后，总结出了一套使用约定，可能仅对这种响应式数据流管用。

使用数据流，第一要做的事情就是管理数据，要解决 Store 放在哪，怎么放的问题。其实还有个前置条件：要不要用 Store 的问题。

#### 要不要用 store

首先，最简单的组件肯定不需要用数据流。那么组件复杂时，如果数据流本身具有分形功能，那么可用可不用。所谓具有分形功能的数据流，是贴着 react 分形功能，将其包装成任具有分形能力的组件：

```typescript
import { combineStores, observable, inject, observe } from 'dob'
import { Connect } from 'dob-react'

@observable
class Store { name = 123 }

class Action {
  @inject(Store) store: Store

  changeName = () => { this.store.name = 456 }
}

const stores = combineStores({ Store, Action })

@Connect(stores)
class App extends React.Component<typeof stores, any> {
  render() {
    return <div onClick={this.props.Action.changeName}>{this.props.Store.name}</div>
  }
}

ReactDOM.render(<App /> , document.getElementById('react-dom'))
```

dob 就是这样的框架，上面例子中，点击文字可以触发刷新，**即便根 dom 节点没有 `Provider`**。这意味着这个组件不论放到任何环境，都可以独立运行，成为任何项目中的一部分。这种组件虽然用了数据流，但是和普通 React 组件完全无区别，可以放心使用。

如果是伪分形的数据流，可能在 `ReactDOM.render` 需要特定的 `Provider` 配合才可使用，那么这个组件就不具备可迁移能力。如果别人不幸安装了这种组件，就需要在项目根目录安装一个全家桶。

问：虽然数据流+组件具备完全分形能力，但若此组件对 props 有响应式要求，那还是有对该数据流框架的隐形依赖。

答：是的，如果组件要求接收的 props 是 `observable` 化的，以便在其变化时自动 rerender，那当某个环境传递了普通 props，这个组件的部分功能将失效。其实 props 属于 react 的通用连接桥梁，因此组件只应该依赖普通对象的 props，内部可以再对其 `observable` 化，以具备完备的可迁移能力。

#### 怎么用 store

React 虽然可以完全模块化，但实际项目中模块一定分为通用组件与业务组件，页面模块也可以当作业务组件。复杂的网站由数据驱动比较好，既然是数据驱动，那么可以将业务组件与数据的连接移到顶层管理，一般通过页面顶层包裹 `Provider` 实现：

```typescript
import { combineStores, observable, inject, observe } from 'dob'
import { Connect } from 'dob-react'

@observable
class Store { name = 123 }

class Action {
  @inject(Store) store: Store

  changeName = () => { this.store.name = 456 }
}

const stores = combineStores({ Store, Action })

ReactDOM.render(
  <Provider {...store}>
    <App />
  </Provider>  
, document.getElementById('react-dom'))
```

本质上只是改变了 Store 定义的位置，而组件使用方式依然不变：

```typescript
@Connect
class App extends React.Component<typeof stores, any> {
  render() {
    return <div onClick={this.props.Action.changeName}>{this.props.Store.name}</div>
  }
}
```

有一个区别是 `@Connect` 不需要带参数了，因为如果全局注册了 `Provider`，会默认透传到 `Connect` 中。与分形相反，这种设计会导致组件无法迁移到其他项目单独运行，但好处是可以在本项目中任意移动。

分形的组件对结构强依赖，只要给定需要的 props 就可以完成功能，而全局数据流的组件几乎可以完全不依赖结构，所有 props 都从全局 store 获取。

其实说到这里，可以发现这两点是难以合二为一的，我们可以预先将组件分为业务耦合与非业务耦合两种，让业务耦合的组件依赖全局数据流，让非业务耦合组件保持分形能力。

如果有更好的 Store 管理方式，可以在我的 [github](https://github.com/ascoders) 和 [知乎](https://www.zhihu.com/people/huang-zi-yi-83/activities) 深入聊聊。

### 每个组件都要 Connect 吗

> 对于 Mvvm 思想的库，Connect 概念不仅仅在于注入数据（与 redux 不同），还会监听数据的变化触发 rerender。那么每个组件需要 Connect 吗？

从数据流功能来说，没有用到数据流的组件当然不需要 Connect，但业务组件保持着未来不确定性（业务不确定），所以保持每个业务组件的 Connect 便于后期维护。

而且 Connect 可能还会做其他优化工作，比如 dob 的 Connect 不仅会注入数据，完成组件自动 render，还会保证组件的 `PureRender`，如果对 dob 原理感兴趣，可以阅读 [精读《dob - 框架实现》](https://github.com/dt-fe/weekly/blob/master/35.%E7%B2%BE%E8%AF%BB%E3%80%8Adob%20-%20%E6%A1%86%E6%9E%B6%E5%AE%9E%E7%8E%B0%E3%80%8B.md)。

其实个议题只是非常微小的点，不过现实就是讽刺的，很多时候多会纠结在这种小点子上，所以单独花费篇幅说几句。

### 数据流是否要扁平化

Store 扁平化有很大原因是 js 对 immutable 支持力度不够，导致对深层数据修改非常麻烦导致的，虽然 `immutable.js` 这类库可以通过字符串快速操作，但这种使用方式必然会被不断发展的前端浪潮所淹没，我们不可能看到 js 标准推荐我们使用字符串访问对象属性。

通过字符串访问对象属性，和 lodash 的 `_.get` 类似，不过对于安全访问属性，也已经有 [proposal-optional-chaining](https://github.com/tc39/proposal-optional-chaining) 的提案在语法层面解决，同样 immutable 的便捷操作也需要一种标准方式完成。实际上不用等待另一个提案，利用 js 现有能力就可以模拟原生 immutable 支持的效果。

[dob-redux](https://github.com/dobjs/dob-redux) 可以通过类似 `this.store.articles.push(article)` 的 mutable 写法，实现与 `react-redux` 的对接，内部自然做掉了类似 `immutable.set` 的事情，感兴趣可以读读我的这篇文章：[Redux 使用可变数据结构](https://github.com/ascoders/blog/issues/21)，介绍了这个黑魔法的实现原理。

有点扯远了，那么数据流扁平化本质解决的是数据格式规范问题。比如 [normalizr](https://github.com/paularmstrong/normalizr) 就是一种标准数据规范的推进，很多时候我们都将冗余、或者错误归类的数据存入 Store，那维护性自然比较差，Redux 推崇的应当是正确的数据格式化，而不是一昧追求扁平化。

对于前端数据流很薄的场景，也不是随便处理数据就完事了。还有许多事可做，比如使用 node 微服务对后端数据标准化、封装一些标准格式处理组件，把很薄的数据做成零厚度，业务代码可以对简单的数据流完全无感知等等。

### 异步与副作用

> Redux 自然而然用 action 隔离了副作用与异步，那在只有 action 的 Mvvm 开发模式中，异步需要如何隔离？Mvvm 真的完美解决了 Redux 避而远之的异步问题吗？

在使用 dob 框架时，异步后赋值需要非常小心：

```typescript
@Action async getUserInfo() {
  const userInfo = await fetchUser()
  this.store.user.data = userInfo // 严格模式下将会报错，因为脱离了 Action 作用域。
}
```

原因是 `await` 只是假装用同步写异步，当一个 `await` 开始时，当前函数的栈已经退出，因此后续代码都不在一个 `Action` 中，所以一般的解法是显示申明 `Action` 的显示申明大法：

```typescript
@Action async getUserInfo() {
  const userInfo = await fetchUser()
  Action(() => {
    this.store.user.data = userInfo
  })
}
```

这说明了异步需要当心！Redux 将异步隔离到 `Reducer` 之外很正确，只要涉及到数据流变化的操作是同步的，外面 `Action` 怎么千奇百怪，`Reducer` 都可以高枕无忧。

其实 redux 的做法与下面代码类似：

```typescript
@Action async getUserInfo() { // 类 redux action
  const userInfo = await fetchUser()
  this.setUserInfo(userInfo)
}

@Action async setUserInfo(userInfo) { // 类 redux reduer
  this.store.user.data = userInfo
}
```

所以这是 dob 中对异步的另一种处理方法，称作隔离大法吧。所以在响应式框架中，显示申明大法与隔离大法都可以解决异步问题，代码也显得更加灵活。

### 请求自动重发

响应式框架的另一个好处在于可以自动触发，比如自动触发请求、自动触发操作等等。

比如我们希望当请求参数改变时，可以自动重发，一般的，在 react 中需要这么申明：

```typescript
componentWillMount() {
  this.fetch({ url: this.props.url, userName: this.props.userName })
}

componentWillReceiveProps(nextProps) {
  if (
    nextProps.url !== this.props.url ||
    nextProps.userName !== this.props.userName
  ) {
    this.fetch({ url: nextProps.url, userName: nextProps.userName })
  }
}
```

在 dob 这类框架中，以下代码的功能是等价的：

```typescript
import { observe } from 'dob'

componentWillMount() {
  this.signal = observe(() => {
    this.fetch({ url: this.props.url, userName: this.props.userName })
  })
}
```

其神奇地方在于，`observe` 回调函数内用到的变量（observable 后的变量）改变时，会重新执行此回调函数。而 `componentWillReceiveProps` 内做的判断，其实是利用 react 的生命周期手工监听变量是否改变，如果改变了就触发请求函数，然而这一系列操作都可以让 `observe` 函数代劳。

`observe` 有点像更自动化的 `addEventListener`：

```typescript
document.addEventListener('someThingChanged', this.fetch)
```

所以组件销毁时不要忘了取消监听：

```typescript
this.signal.unobserve()
```

最近我们团队也在探索如何更方便的利用这一特性，正在考虑实现一个自动请求库，如果有好的建议，也非常欢迎一起交流。

### 类型推导

如果你在使用 redux，可以参考 [你所不知道的 Typescript 与 Redux 类型优化](https://zhuanlan.zhihu.com/p/32112508) 优化 typescript 下 redux 类型的推导，如果使用 dob 或 mobx 之类的框架，类型推导就更简单了：

```typescript
import { combineStores, Connect } from 'dob'

const stores = combineStores({ Store, Action })

@Connect
class Component extends React.PureComponent<typeof stores, any> {
  render() {
    this.props.Store // 几行代码便获得了完整类型支持
  }
}
```

这都得益于响应式数据流是基于面向对象方式操作，可以自然的推导出类型。

### Store 之间如何引用

复杂的数据流必然存在 Store 与 Action 之间相互引用，比较推荐依赖注入的方式解决，这也是 dob 推崇的良好实践之一。

当然依赖注入不能滥用，比如不要存在循环依赖，虽然手握灵活的语法，但在下手写代码之前，需要对数据流有一套较为完整的规划，比如简单的用户、文章、评论场景，我们可以这么设计数据流：

分别建立 `UserStore` `ArticleStore` `ReplyStore`：

```typescript
import { inject } from 'dob'

class UserStore {
  users
}

class ReplyStore {
  @inject(UserStore) userStore: UserStore

  replys // each.user
}

class ArticleStore {
  @inject(UserStore) userStore: UserStore
  @inject(ReplyStore) replyStore: ReplyStore

  articles // each.replys each.user
}
```

每个评论都涉及到用户信息，所以 `ReplyStore` 注入了 `UserStore`，每个文章都包含作者与评论信息，所以 `ArticleStore` 注入了 `UserStore` 与 `ReplyStore`，可以看出 Store 之间依赖关系应当是树形，而不是环形。

最终 Action 对 Store 的操作也是通过注入来完成，而由于 Store 之间已经注入完了，Action 可以只操作对应的 Store，必要的时候再注入额外 Store，而且也不会存在循环依赖：

```typescript
class UserAction {
  @inject(UserStore) userStore: UserStore
}

class ReplyAction {
  @inject(ReplyStore) replyStore: ReplyStore
}

class ArticleAction {
  @inject(ArticleStore) articleStore: ArticleStore
}
```

最后，不建议在局部 Store 注入全局 Store，或者局部 Action 注入全局 Store，因为这会破坏局部数据流的分形特点，切记保证非业务组件的独立性，把全局绑定交给业务组件处理。

### Action 的错误处理

比较优雅的方式，是编写类级别的装饰器，统一捕获 Action 的异常并抛出：

```typescript
const errorCatch = (errorHandler?: (error?: Error) => void) => (target: any) => {
    Object.getOwnPropertyNames(target.prototype).forEach(key => {
        const func = target.prototype[key]
        target.prototype[key] = async (...args: any[]) => {
            try {
                await func.apply(this, args)
            } catch (error) {
                errorHandler && errorHandler(error)
            }
        }
    })
    return target
}

const myErrorCatch = errorCatch(error => {
    // 上报异常信息 error
})

@myErrorCatch
class ArticleAction {
  @inject(ArticleStore) articleStore: ArticleStore
}
```

当任意步骤触发异常，await 之后的代码将停止执行，并将异常上报到前端监控平台，比如我们内部的 [clue](https://zhuanlan.zhihu.com/p/32262716) 系统。关于异常处理更多信息，可以访问我较早的一篇文章：[Callback Promise Generator Async-Await 和异常处理的演进](https://github.com/ascoders/blog/issues/14)。

## 3 总结

准确区分出业务与非业务组件、写代码前先设计数据流的依赖关系、异步时注意分离，就可以解决绝大部分业务场景的问题，实在遇到特殊情况可以使用 `observe` 监听数据变化，由此可以拓展出比如请求自动重发的功能，运用得当可以解决余下比较棘手的特殊需求。

虽然数据流只是项目中非常微小的一环，但如果想让整个项目保持良好的可维护性，需要把各个环节做精致。

这篇文章写于 2017 年最后一天，祝大家元旦快乐！

### 更多讨论

> 讨论地址是：[精读《dob - 框架使用》 · Issue #53 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/53)

**如果你想参与讨论，请[点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，每周五发布。**
