React 的新范式 - DI, RxJS & Hooks

·  阅读 4429

从去年 12 月起我一直在做在线文档的开发工作,工作中遇到了如下问题:

第一个问题是:如何处理不同平台之间的差异

我们的产品需要兼容多个平台,而且同一个功能在不同的平台上要调用不同的 API,之前为了处理这些平台差异性,写了很多这样的代码:

if (isMobile) {
  // ... mobile 的特殊逻辑
} else if (isDesktop) {
  // ... desktop 的特殊逻辑
} else {
  // ... browser 的逻辑
}
复制代码

这样的写法不仅很难维护,而且由于无法 tree shake,会导致仅在 B 平台上运行的代码被 ship 到 A 平台用户的设备上,无端增加了包大小。

有一种比较 hacky 的方案是通过 uglify 来消除不会被执行的分支,但这仍然无法解决可维护性低的问题。

第二个问题是:如何在多个产品之间复用代码

我们的项目有两个文档与表格两个子产品,这两个产品在 UI、逻辑上既有相同之处又有不同之处。例如,两个产品标题栏下拉框的行为一致,但是下拉框内的菜单项不一致,比如文档有页面设置的菜单项,但是表格没有。又例如,两个产品鉴权、网络方面的逻辑一致,但是对文档模型处理方法不一致。

第三个问题是前端开发的老大难问题,即如何优雅地做状态管理和逻辑复用

目前对于这个问题,社区已经提出了很多方案:

  • mixin,Vue 社区比较多地采用这种方案,但是现在看来 mixin 并不是一种好的方案,它会导致隐式依赖、命名冲突等问题,React 官方已不推荐它,详细请看 Dan Abramov 的这篇文章 Mixins Considered Harmful
  • HOC,这是 React 之前推荐的方案,但这种方案同样不够理想,它会导致过多的标签嵌套,同样也会导致命名冲突。
  • Hooks,这是现在 React 社区的主流方案,它解决了 mixin 和 HOC 的问题,但也有其局限性,例如只能用于函数式组件、一不留神就会导致多余的重复渲染等等。

当然,并没有银弹可以完美地解决这个问题,但是我们仍然需要量体裁衣,针对我们项目的情况和需求来探索新的模式。

第四个问题是代码组织

产品逐渐复杂、代码量自然水涨船高,项目也随之腐化——比如大量的复制粘贴的代码、模块边界不清晰、文件和方法过长等等——结果导致维护成本剧增。

总结来说,我们需要一种机制:

  • 分离平台相关代码,并以统一的接口给业务代码调用;
  • 尽可能多地复用相同的 UI 和逻辑,并且能够方便地处理不一致的部分;
  • 提供一种新的状态管理和逻辑复用方式;
  • 组织代码,让各个模块之间尽量解耦,提升代码可维护性。

在寻找解决方案的过程中,我从 vscode 和 Angular 这两个项目中获得了许多灵感,它们的共性是都使用了依赖注入 (DI) 。

Angular#

Angular 框架本身和使用 Angular 开发的应用都基于依赖注入:

  • 依赖注入可以被用于装态管理和逻辑复用。逻辑上相关联的状态和方法被划分在各个类中,称为 service,service 可被注入到组件或其他 service 中。组件可以订阅 service 当中的状态(结合 RxJS),这样当 service 中的状态变更时,组件就会响应式地重渲染;当需要执行业务逻辑的时候,组件就可以调用 service 的方法。
  • 组件可以通过依赖注入访问父组件或者同元素上其他指令的属性和方法。
  • 框架提供的 HTTP 拦截器、路由鉴权接口也基于依赖注入。

那么,在 vscode 和 Angular 中大放异彩的依赖注入究竟是什么,为什么依赖注入可以解决文章开头提到的四个问题?

依赖注入#

软件工程中,依赖注入是种实现控制反转用于解决依赖性设计模式。一个依赖关系指的是可被利用的一种对象(即服务提供端) 。依赖注入是将所依赖的传递给将使用的从属对象(即客户端)。该服务是将会变成客户端的状态的一部分。 传递服务给客户端,而非允许客户端来建立或寻找服务,是本设计模式的基本要求。

以上定义来自维基百科,而维基百科的特点就是不爱说人话。让我们换一种简单的表达:

依赖注入就是不自行构造想要的东西(即依赖),而是声明自己想要的东西,让别人来构造。当这个构造过程发生在自己的构造阶段时,就叫做依赖注入。

如果你深入挖掘的话,你会发现依赖注入和依赖倒置、控制反转等概念相关。可以参考这个知乎回答。

在依赖注入系统中,有三种主要角色:

  • 依赖项  (dependency):任何能被消费者——主要是类(在前端框架中要加上组件)——所使用的东西,这些东西可能意味着类、值、函数、组件等等,依赖项通常会有一个标识符以和其他依赖项相区别,这个标识符可能是接口、类,也可能是某些特定的数据结构。和一个标识符绑定的依赖项应该具有相同的接口,这样消费者才能无差别(无感知)地使用。
  • 提供者 (provider):或称注入器 (injector),它们会根据消费者的需要实例化和提供依赖项。
  • 消费者 (consumer):它们通过标识符从提供者获得依赖项,然后使用它们。一个消费者可能同时也是别的消费者的依赖项。

现在,你应该对依赖注入模式有一个大致的理解了,让我们来看看依赖注入如何解决文章开头提到的那些问题。

如何实现代码复用#

解决第二个问题的思路其实和解决第一个的是一致的。我们只需要将只要将不一样的部分抽象成依赖项,然后让其余代码依赖它就可以了。

如何解决状态管理#

依赖注入可以管理共享状态。将多个组件共享的状态提取到依赖项中并结合发布-订阅模式,就能实现直观的单项数据流;将能改变状态的方法放到依赖项中,就能直观地知道这些状态会如何改变;另外还可以方便地结合 RxJS 管理更复杂的数据流。这种方案

  • 和 mixin 方案相比:

    • 它的依赖是显式的;
    • 不会导致命名冲突问题。
  • 和 HOC 相比:

    • 不存在多重嵌套导致的 wrapper hell 问题;
    • 很容易追踪状态的位置和变更。
  • 和 Hooks 相比:

    • 不用考虑 memorize 问题;
    • 用类来保存状态比 setState 等 API 更符合人类的思维直觉。
  • 实现的状态管理是 scoped 的,如果你在界面上有很多个相似的模块(比如 Trello 的看板),依赖注入模式可以让你方便地管理各个模块的状态,确保它们之间不会错误地共享某些状态。

[如何解决代码组织问题]

依赖注入模式中的“依赖项”概念会强迫开发者思考哪些代码在逻辑上是相关联的,应该放到同一个类当中,从而让各个功能模块解耦;也会强迫开发者思考哪些是 UI 代码,哪些是业务代码,让 UI 和业务分开。并且,由于在依赖注入系统中,类的实例化过程(甚至包括销毁过程)是依赖注入框架完成的,因此开发者只需要关心功能应该划分到哪些模块中、模块之间的依赖关系如何,无需自己实例化一个个类,这样就降低了编码时的心智负担。最后,由于依赖注入使得类不用再负责构造自己的依赖,这样就能很方便地进行单元测试。

为了能在 React 中方便地使用依赖注入模式,在重构的过程中,我实现了一个轻量的依赖注入库以及一组 React binding。

具有如下特性:

  • 非侵入式:不像 Angular 那样一切都基于 DI,wedi 完全是 opt-in 的,你可以自己决定何时何处使用 DI。
  • 简单易用:没有引入任何新概念。
  • 同时支持 React 类组件和函数式组件。
  • 支持层次化依赖注入
  • 支持注入类、值(实例)、工厂函数三种类型的依赖项。
  • 支持延迟实例化。
  • 基于 TypeScript ,提供了良好的类型支持。

[在函数式组件中使用]

当你需要提供依赖项的时候,只需要调用 useCollection 生成 collection,然后塞给 Provider 组件即可,Provider 的 children 就可以访问它们。

function FunctionProvider() {
  const collection = useCollection([FileService])


  return (
    <Provider collection={collection}>
      {/* children 可访问 collection 中的依赖项 */}
    </Provider>
  )
}
复制代码
function FunctionConsumer() {
  const fileService = useDependency(FileService);


  return (
    /* 从这里开始可以调用 FileService 上的属性和方法 */
  );
}
复制代码

保证在函数式组件的 Provider 重渲染时不会重新构建依赖项,这样你就不会丢失依赖项里保存的状态。

[可选依赖]

可以通过给 useDependency 传入第二个参数 true 来声明该依赖是可选的,TypeScript 会推断出返回值可能为 null 。如果未声明依赖项可选且获取不到该依赖项,wedi 会抛出错误。

function FunctionConsumer() {
  const nullable: NullableService | null = useDependency(NullableService, true)
  const required: NullableService = useDependency(NullableService) // Error!
}
复制代码

[在类组件中使用]

@Provide([
  FileService,
  IPlatformService, { useClass: MobilePlatformService });
])
class ClassComponent extends Component {
  static contextType = InjectionContext;


  @Inject(IPlatformService) platformService!: IPlatformService;


  @Inject(NullableService, true) nullableService?: NullableService;
}
复制代码

当需要使用这些依赖项时,需要将组件的默认 context 设置为 InjectionContext,然后就可以通过 Inject 装饰器获取依赖项了。同样,可以传入 true 给 Inject 声明依赖是可选的。

支持各种各样的依赖项,包括类,值、实例和工厂函数。

[类]

有两种方法将一个类声明为依赖项,一是传递类本身,二是使用 useClass API 结合 identifier 。

const classDepItems = [
  FileService, // 直接传递类
  [IPlatformService, { useClass: MobilePlatformService }] // 结合 identifier
]
复制代码

[值、实例]

使用 useValue 注入值或实例。

const valueDepItem = [IConfig, { useValue: '2020' }]
复制代码

[工厂函数]

使用 useFactory 注入工厂方法。

const factorDepItem = [
  IUserService,
  {
    useFactory: (http: IHTTPService): IUserService => new TimeSerialUserService(http, TIME),
  deps: [IHTTPService]
  }
]
复制代码

甚至可以注入组件:

const IDropdown = createIdentifier<any>('dropdown')
const IConfig = createIdentifier<any>('config')


const WebDropdown = function () {
  const dep = useDependency(IConfig)
  return <div>WeDropdown, {dep}</div>
}


@Provide([
  [IDropdown, { useValue: WebDropdown }],
  [IConfig, { useValue: 'wedi' }]
])
class Header extends Component {
  static contextType = InjectionContext


  @Inject(IDropdown) private dropdown: any


  render() {
    const Dropdown = this.dropdown
    return <Dropdown></Dropdown> // WeDropdown, wedi
  }
}
复制代码

[结合 RxJS]

class CounterService implements Disposable {
  counter$ = interval(1000).pipe(
    startWith(0),
    scan((acc) => acc + 1)
  )


  // 如果有 dispose 函数,wedi 就会在组件销毁的时候调用它,这里你可以做一些 clean up 的工作
  dispose(): void {
    this.counter$.complete()
  }
}


function App() {
  const collection = useCollection([CounterService])


  return (
    <Provide collection={collection}>
      <Display />
    </Provide>
  )
}


function Display() {
  const counter = useDependency(CounterService)
  const count = useDependencyValue(counter.counter$)


  return <div>{count}</div> // 0, 1, 2, 3 ...
}
复制代码
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改