Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dob - 框架使用 #24

Open
ascoders opened this issue Jan 6, 2018 · 1 comment
Open

dob - 框架使用 #24

ascoders opened this issue Jan 6, 2018 · 1 comment

Comments

@ascoders
Copy link
Owner

ascoders commented Jan 6, 2018

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

本篇是 《框架使用》。

1 引言

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

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

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

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

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

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

2 精读 dob 框架使用

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

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

Store 如何管理

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

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

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

要不要用 store

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

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 实现:

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 定义的位置,而组件使用方式依然不变:

@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知乎 深入聊聊。

每个组件都要 Connect 吗

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

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

而且 Connect 可能还会做其他优化工作,比如 dob 的 Connect 不仅会注入数据,完成组件自动 render,还会保证组件的 PureRender,如果对 dob 原理感兴趣,可以阅读 精读《dob - 框架实现》

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

数据流是否要扁平化

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

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

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

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

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

异步与副作用

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

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

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

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

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

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

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

@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 中需要这么申明:

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 这类框架中,以下代码的功能是等价的:

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

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

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

this.signal.unobserve()

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

类型推导

如果你在使用 redux,可以参考 你所不知道的 Typescript 与 Redux 类型优化 优化 typescript 下 redux 类型的推导,如果使用 dob 或 mobx 之类的框架,类型推导就更简单了:

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

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 注入了 UserStoreReplyStore,可以看出 Store 之间依赖关系应当是树形,而不是环形。

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

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

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

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

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

Action 的错误处理

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

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 系统。关于异常处理更多信息,可以访问我较早的一篇文章:Callback Promise Generator Async-Await 和异常处理的演进

3 总结

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

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

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

@jasonslyvia
Copy link

预感在不远的未来,当 React 生态的作者想要给社区提供一个状态复杂,但是又不希望和任何数据流框架耦合的解决方案时,基于 dob 等分型友好的解决方案会逐渐流行起来!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants