阅读 1017

React 编码实战 ---- 一步步实现可扩展的架构(2)

前言

React 编码实战 ---- 一步步实现可扩展的架构(1) 中,我们跟随小白一起,从一个简单的需求实现,一步步考虑 组件复用分层解耦,让代码架构更具有扩展性。今天,我们会进一步跟随小明的建议,继续提高架构的可扩展性。

小白的架构优化之旅

自从经历了小明的谆谆教诲,小白开始恶补设计原则的知识和架构的理念,明白了 上层模块不应该依赖于下层模块,并重新复习了小明最后给的架构图

从模块区分来看,Page 属于上层模块,Entity 和 Component 属于下层模块,那 Page 就应该依赖 Entity 和 Component

随即,小白便发现了一个问题:User 和 Admin 依赖了 IListEntity,这不是下层模块依赖了上层模块吗?

针对此问题,小白非常得意地向小明发起了挑战

「后生可畏啊,很棒!能发现问题,说明你是真的下了功夫去消化;发现问题后敢于质疑,这可以让你未来学到更多东西」

V3 的问题

「我们来一起捋一捋。基于之前的需求,我们是实现了 UserAdmin 两个 Entity 用于满足 UserListAdminList 两个页面的 数据存储数据获取 需求。正如你发现的,Entity 依赖了 Page 中的 interface,这带来的问题就是,Entity 只适用于具有相同 interface 的 Page,而从现实需求来看,假设我有另外一个 Page 是 UserDetail(用户详情页),现有的 User 就无法直接被复用。你看看,从这个依赖关系我们就能看出模块间的耦合以及面对新需求时的可扩展性」

小白思考了一下,得出了一个结论:那如果按照原来的思路,我们想实现 UserDetail 页面,就得再定义它的 interface - IDetailEntity,然后让 User 用 implements 的方式去实现了,依赖关系就会变成

页面越来越多的情况下,这个 user 依赖的 interface 就会越来越多,变成一个糟糕的架构

「没错,所以我们不能以 支持页面使用 这种思路来构建我们的 Entity 了」

那应该怎么做呢?

「你可以尝试使用 DDD(领域驱动设计)的思路,来构建 Entity 这一层,在前端引入 DomainEntityDomainService 的概念,由 Service 来提供页面所需要的数据和操作数据的方法,由 Entity 来作为数据的标准模型」

V4

domain/entity/User

我们先定义 User 的 interface 和数据实体类

export interface IUser {
  account?: string;
  name?: string;
}

export class User {
  public account;
  public name;

  constructor(user: IUser = {}) {
    this.account = user.account;
    this.name = user.name;
  }
}
复制代码

domain/service/User

然后定义 UserService 类,为 List 和 Detail 页提供数据服务

import { observable, action } from 'mobx';
import axios from 'axios';

import { User, IUser } from '@domain/entity/User';

export default class UserService {
  @observable loading: boolean;
  @observable list: IUser[];
  @observable detail: IUser;

  @action async fetchList() {
    this.loading = true;
    const fetchListResult = await axios.get('/apis/user');
    this.loading = false;
    if (fetchListResult.data.status === '0') {
      this.list = fetchListResult.data.data.map(item => new User(item));
    }
  }

  @action async fetchDetail(id: string) {
    this.loading = true;
    const fetchDetailResult = await axios.get(`/apis/user/${id}`);
    this.loading = false;
    if (fetchDetailResult.data.status === '0') {
      this.detail = new User(fetchDetailResult.data.data);
    }
  }
}
复制代码

domain/provider

前面的 provider 修改为注入 service

import * as React from 'react';
import { observer } from 'mobx-react';

import user from './service/User';

export interface IServices {
  user: typeof user;
}

class Provider {
  private services: IServices = {
    user
  };

  getService(name: string) {
    return this.services[name];
  }
}
const provider = new Provider();

export interface IProps {
  services: IServices;
  [propName: string]: any;
}

export function inject(params: string[]) {
  return (Component: (props: IProps) => JSX.Element) => {
    return observer(
      class WithService extends React.Component<{ [propName: string]: any }> {
        render() {
          const services: any = {};
          params.forEach(
            service => (services[service] = provider.getService(service))
          );
          return (
            <React.Fragment>
              <Component services={services} {...this.props} />
            </React.Fragment>
          );
        }
      }
    );
  };
}
复制代码

架构图

这个时候,我们的架构图就变为

这样,依赖关系更加合理了,都是上层依赖于下层,当 Page 有新的需求,则扩展 Service 即可

V5

看着兴奋的小白,小明微微一笑:「其实这个架构,还有一些问题,我们来看看更细节的依赖关系」

「发现什么没有?」

小白不是很理解:但也看出了更细节的依赖关系,即 UserService 依赖了 mobx 作为 store,依赖了 axios 作为 dataSource(数据源),这难道不正常吗?第三方库作为底层依赖是我们日常的行为啊

小明扶了扶镜框,「没错,我就是想让你关注这里」

从架构的依赖关系看,我们的 Domain 层依赖了其他的第三方库。我们都知道,当被依赖的部分有所调整时,依赖方则会有很大概率需要进行修改。假设,当某一天我们的项目不用 axios 进行数据获取了,需要更换为其他库,比如原生 fetch,或者 graphQL 客户端,甚至是 WebSocket,那么我们的 Service 层免不了进行大刀阔斧的改动」

「从 DDD 的指导思想出发,我们发现这种依赖关系也是不合理的。DDD 要求 Domain 层是稳定的,也即是认为,我们的业务逻辑应该是最核心的部分,当业务逻辑不变的时候,不应该因为外部依赖而导致需要进行修改,不然会导致依赖于 Domain 的其他层级也变得不稳定起来。还是刚刚的假设,我们一定希望,我们获取数据的方式的调整,不会影响我们的 Page 层,它们只是通过 Service 来实现业务逻辑,而 Service 此时是稳定的」

小白听了不由得佩服,自己的思考跟小明不在同一个层次,但心里又有更大的疑虑出现了:那咋整咧?

「你再回忆一下我们上次提到的 DIP 原则」

DIP,依赖倒置原则,上层模块不应该依赖底层模块,它们都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象

「我们可以利用 interface 实现 控制反转(Inversion of Control,即 IoC),将 使用什么底层库 的控制权交给 Provider,通过 依赖注入(Dependency Injection,即 DI)的方式注入给 Service,而 Service 中只包含纯粹的逻辑」

架构图

我们来对 UserService 改造一下,去除第三方库的依赖

domain/service/User

import { User, IUser } from '@domain/entity/User';

export interface IStore {
  loading: boolean;
  list: IUser[];
  detail: IUser;
  set: (key: string, value: any) => void;
  get: (key: string) => any;
}

export interface IDataSource {
  fetchList(): any;
  fetchDetail(id: string): any;
}

export default class UserService {
  store: IStore;
  dataSource: IDataSource;
  constructor(IStore, IDataSource) {
    this.store = IStore;
    this.dataSource = IDataSource;
  }

  async fetchList() {
    this.store.set('loading', true);
    const data = await this.dataSource.fetchList();
    this.store.set('loading', false);
    if (data) {
      this.store.set(
        'list',
        data.map(item => new User(item))
      );
    }
  }

  async fetchDetail(id: string) {
    this.store.set('loading', true);
    const data = await this.dataSource.fetchDetail(id);
    this.store.set('loading', false);
    if (data) {
      this.store.set('detail', new User(data));
    }
  }
}

复制代码

然后实现一下 IStore 和 IDataSource

store/User

import { observable } from 'mobx';
import { IUser } from '../domain/entity/User';
import { IStore } from '../domain/service/User';

export default class Store implements IStore {
  @observable loading: boolean;
  @observable list: IUser[];
  @observable detail: IUser;

  get(key: string) {
    return this[key];
  }

  set(key: string, value: any) {
    this[key] = value;
  }
}

复制代码

dataSource/User

import axios from 'axios';
import { IDataSource } from '../domain/service/User';

export default class DataSource implements IDataSource {
  async fetchList(): Promise<any> {
    const result = await axios.get('/apis/user');
    if (result.data.status === '0') {
      return result.data.data;
    }
    return null;
  }

  async fetchDetail(id: string): Promise<any> {
    const result = await axios.get(`/apis/user/${id}`);
    if (result.data.status === '0') {
      return result.data.data;
    }
    return null;
  }
}

复制代码

最后,修改一下 Provider,将 Store 和 DataSource 注入到 Service 中

import * as React from 'react';
import { observer } from 'mobx-react';

import User from './service/User';
import UserStore from '../store/User';
import UserDataSource from '../dataSource/User';

export interface IServices {
  user: User;
}

class Provider {
  private services: IServices = {
    user: new User(new UserStore(), new UserDataSource())
  };

  getService(name: string) {
    return this.services[name];
  }
}
const provider = new Provider();

export interface IProps {
  services: IServices;
  [propName: string]: any;
}

export function inject(params: string[]) {
  return (Component: (props: IProps) => JSX.Element) => {
    return observer(
      class WithService extends React.Component<{ [propName: string]: any }> {
        render() {
          const services: any = {};
          params.forEach(
            service => (services[service] = provider.getService(service))
          );
          return (
            <React.Fragment>
              <Component services={services} {...this.props} />
            </React.Fragment>
          );
        }
      }
    );
  };
}

复制代码

「改造完毕,我们的 Page 不需要做任何改动。未来,假设获取数据的方式需要调整,只需要创建一个类实现一下 IDataSource,并由 Provider 注入到 Service 即可,保证了 Service 中业务逻辑的稳定」

「小白,如果未来我们不用 mobx 了,换成 redux 或其他的 Store 库,是不是也能较为方便地做到呢」

小白仔细看了一下代码和架构,眼前一亮:原来以前一直觉得不可能做到或者工作量巨大的事情,通过前期架构的设计,是可以变得简单许多的,只需要重新实现 IStore,再由 Provider 注入到 Service,并修改一下 injector 使得它能够响应 Store 的变化变为 props 传递到 Page,那么我们的 Page 可以不用做任何改动了!

「是的,其实从架构图就可以看出可扩展性,我们的 Service 层没有任何指向外部的箭头,即没有对外的依赖,它就成了我们系统中最稳定的部分了」

结语

我们通过感受小白的整个程序实现和和在小明指导下的架构改造过程,也能发现 架构设计 对前端应用的可扩展性带来的意义和价值。无论是做什么开发,都应该重视 设计,而其中最核心的就是 SOLID 设计原则,优秀的代码都在践行它们。

让我们一起重拾架构设计的知识,让前端开发摒弃 升级技术栈就需要重写 的思路,至少,我们可以构建 稳定的领域层