React 的新范式 - DI, RxJS & Hooks

Wenzhao,direactChinese

Introduction

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

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

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

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

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

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

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

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

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

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

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

第四个问题是代码组织

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

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

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

vscode

之所以要学习 vscode 是因为它和我们的项目存在很多相似之处:都是编辑器应用、需要处理复杂的数据和状态、架构复杂、需要支持多平台等等。

vscode 的代码组织和运行机制都明显突出了依赖注入在这个项目中的核心位置:

想要详细了解可阅读我在阅读 vscode 源码时写的两篇笔记。 (opens in a new tab) (opens in a new tab)

Angular

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

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

依赖注入

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

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

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

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

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

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

如何解决平台差异

要解决平台差异问题,就要将依赖于平台的代码和其他代码区别开来,让其他代码不需要知道当前在什么平台上。

这里以 vscode 的“拖拽文件到新目录前进行提示”功能为例,这一功能在应用中和在浏览器中的 UI 是不同的。

在 vscode 应用内它看起来是这样:

Snipaste_2020-01-21_11-13-01

在浏览器端看起来像是这样:

Snipaste_2020-01-21_11-14-27

实现方式就是将“弹出对话框”功能抽象成一个依赖项,在不同的平台注入不同的依赖项,这样调用者就不用关心当前的平台是什么了。

对于这段代码 (opens in a new tab)中的调用者来说,它并不知道也不需要知道目前的环境:

const confirmation = await this.dialogService.confirm({
  message,
  detail
  // ...
})

因为 dialogService 是被注入的,而桌面端 (opens in a new tab)浏览器端 (opens in a new tab)分别注入了不同的 service。

详情请看第二篇关于 vscode 的博客。

如何实现代码复用

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

如何解决状态管理

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

如何解决代码组织问题

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

wedi

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

GitHub (opens in a new tab) / npm (opens in a new tab)

wedi 具有如下特性:

接下来我们结合几个具体的例子来讲解如何使用 wedi 。

在函数式组件中使用

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

import { useCollection } from 'wedi'
 
function FunctionProvider() {
  const collection = useCollection([FileService])
 
  return (
    <Provider collection={collection}>
      {/* children 可访问 collection 中的依赖项 */}
    </Provider>
  )
}
import { useDependency } from 'wedi';
 
function FunctionConsumer() {
  const fileService = useDependency(FileService);
 
  return (
    /* 从这里开始可以调用 FileService 上的属性和方法 */
  );
}

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

可选依赖

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

import { useDependency } from 'wedi'
 
function FunctionConsumer() {
  const nullable: NullableService | null = useDependency(NullableService, true)
  const required: NullableService = useDependency(NullableService) // Error!
}

在类组件中使用

当然 wedi 也支持传统的类组件。

当需要在某个组件及其子组件中注入依赖项时,使用 Provide 装饰器传递这些依赖项。

import { Inject, InjectionContext, Provide } from 'wedi';
import { IPlatformService } from 'services/platform';
 
@Provide([
  FileService,
  IPlatformService, { useClass: MobilePlatformService });
])
class ClassComponent extends Component {
  static contextType = InjectionContext;
 
  @Inject(IPlatformService) platformService!: IPlatformService;
 
  @Inject(NullableService, true) nullableService?: NullableService;
}

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

多种多样的依赖项

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

有两种方法将一个类声明为依赖项,一是传递类本身,二是使用 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]
  }
]

注入组件

wedi 甚至可以注入组件:

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
  }
}

这种方式可以满足在不同平台展现不同的 UI 的需求。

层次化的依赖注入

wedi 能够构建起层次化的依赖注入体系,wedi 在获取依赖项时,采取“就近原则”:

@Provide([
  [IConfig, { useValue: 'A' }],
  [IConfigRoot, { useValue: 'inRoot' }]
])
class ParentProvider extends Component {
  render() {
    return <ChildProvider />
  }
}
 
@Provide([[IConfig, { useValue: 'B' }]])
class ChildProvider extends Component {
  render() {
    return <Consumer />
  }
}
 
function Consumer() {
  const config = useDependency(IConfig)
  const rootConfig = useDependency(IConfigRoot)
 
  return (
    <div>
      {config}, {rootConfig}
    </div> // <div>B, inRoot</div>
  )
}

这样你就可以使某些依赖项全局可用,而使另外一些依赖项范围可用,你可以利用这个特性方便地管理全局状态和局部状态。这对于界面上存在大量相同组件的应用特别合适。

你甚至可以通过 React Dev Tools 可视化地查看依赖项的可用范围(也就意味着状态的范围)。

Snipaste_2020-01-22_17-44-12

这个截图来自用 wedi 构建的 TodoMVC (opens in a new tab)(开发环境下)。

结合 RxJS

依赖注入模式可以很方便地与响应式编程相结合,用于状态管理。当一些组件之间需要共享状态时,你就可以把状态提取到各个组件都能访问到的依赖项当中,然后去订阅该状态的改变。

下面是一个计时器的例子。

import { Provide, useDependency, useDependencyValue, Disposable } from 'wedi'
 
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 ...
}

更多关于 wedi 的 API 可关注 README (opens in a new tab)

, CC BY-NC 4.0 © Wenzhao.