metroluffy / blog

用于记录平时开发中所遇到问题的解决方法、笔记等
9 stars 1 forks source link

记一次前端重构 #38

Open metroluffy opened 3 years ago

metroluffy commented 3 years ago

前言

重构用于提高代码的可读性或者改变代码内部结构与设计,增进内部的清晰性和一致性,并且移除死代码,使其在将来更容易被维护。前段时间负责的某个前端系统也进行了一次目标接近的重构。重构前系统大致如下:

old_version_f

其中主工程作为平台的核心项目,多个系统(首页、管理台、Node服务)放在一个仓库中,各种配置堆到一起,归属不清晰,每次发布子模块都需要更新整个工程,如果发布时没有按节点更新,还可能会有短暂的不可用时间。从代码层面看,成片的Ctrl+C&V,到处都是一把梭代码,文件越来越多,文件结构不受控制,业务开发变得越来越困难,bug难以追踪(vscode搜索表示也无能为力),维护起来极其痛苦,开发体验、效率持续下降。以上总结一下就是:

妥妥的技术债,该还了。

动手

物理分层

为了解决上面提及的痛点,首先要对这些应用进行拆分,即“分治”,先从物理层面拆分,主工程中的系统之间并无依赖,可拆分到不同的仓库进行维护,如下: new_version_f

这样每个工程单独部署,开发、发布都互不影响,开发效率、稳定性得以大幅提升。

拆分解耦

对于逻辑众多的管理台工程,处理则要更细致些。我们先看看一般情况下如何对组件进行代码复用、分层。

function TestComponent() {
  const [stateA, setStateA] = useState(...);
  // ...
  const handle = (data) => {...};
  // 数据获取
  useEffect(() => {
    const params = ...;
    http.post(url, params, {...config}).then(res => {
      // here may need decode server-side struct
      if (res.status !== 'suceess') // do something
      else
        const data = handle(res);
        // do more things
        setData(data);
    })
  }, []);
  // 事件订阅
  useEffect(() => {
    // add event listen
    return () => {
      // remove listener
    }
  }, []);
  return (
    <C ...state>
       ...
       // Table 1
       // Table 2
    <C/>
  );
}

以上截取了项目中很常见的组件代码组织方式,略作分析:

最后导致代码重复率极高,排查问题效率低下,加上0单测,要重构都不容易。程序不便于扩展、不便于修改,可以说它同样不是一个好的系统或者不是好的代码。另外还可以想想,如何在保证质量的前提下提供写单元测试的效率?

对症下药,在逻辑层面进行拆分,把共有逻辑抽离封装,表格、图标、表单等公共组件也类似,全局状态可以抽到顶层使用RxJS、Redux统一维护等等,同时引入TypeScript做静态类型检查等,于是可以有:

code_layer

反应到代码上,

// http.ts
export default http(){
  // 统一处理后台数据结构
}

// utils.ts
export const handleA = () => {};
export const handleB = () => {};

// service_requesterA.ts
import http from 'http'
import { handleA } from 'utils';

export default RequesterA();

// commonCompA.ts
export default CommonCompA();

// TestComponent.ts
import CommonCompA from 'commonCompA';
import RequesterA from 'service_requesterA';
import { handleB } from 'utils';

function TestComponent() {
  ...
  return (
    <C ...>
        <CommonCompA ...>
    <CommonCompA ...>
    <C/>
  );
}

这样代码结构清晰,职责明确,模块复杂度下降极多,也更易编写测试。TypeScript对于大型项目的好处,不言而喻,结合IDE简直爽到飞起。

还可以更好

上面这种拆分会带来一个问题,漫天飞舞的引用,模块间看似独立而又不独立。

那么使用枚举试试?

// for example, utils
enum Utils{
  handleA: () => {}
}
// use
import Utils from 'utils-enum';

Utils.handleA();

本质上换汤不换药,而且入口维护在数个文件集中管理,不算是一种好的实现。往具体实现看,调用方每次使用依赖仍需要先实例化,模块间还有可能存在依赖关系,这种方法对这些无能为力。

随着模块越来越多,依赖关系也会越来越复杂,维护起来很麻烦。还有办法吗?

来看看依赖注入。依赖注入DI是控制反转(IoC)的一种实现。依赖是指可被方法调用的事物,某个方法、实例。在依赖注入形式下,调用方不再直接使用“依赖”,取而代之是“注入(Inject)” ,传递依赖给调用方,而不是让调用方直接获得依赖。举个例子,

// 演示代码,常见的依赖场景,引用自附录资料
import {A} from './A';
import {B} from './B';

class C {
  consturctor() {
    this.a = new A();
    this.b = new B(this.a);
  }
}

// 在依赖注入形式下
import {A} from './A';
import {B} from './B';
// init container & bind dep
const container = new Container();
container.bind(A); // bind也可以指定一个名字,bind('A', A)
container.bind(B);

class C {
  consturctor() {
    this.a = container.get('A');
    this.b = container.get('B');
  }
}

在这个例子中调用方无需关心依赖什么时候创建,使用时直接获取实例即可,容器会自动将所有依赖的对象实例化。所谓容器可以简单理解为一个Map,或者说是为调用方搭建的一个灵活、可扩展的平台,能更好地进行依赖复用。

// 伪代码
// 绑定、获取
class container {
  pravite pool = new Map();
  public bind(identifier){
    const binding = new Binding(identifier);
    this.pool.set(identifier, binding)
  }
  public get(key) {
    return this.pool.get(key)
  } 
}

网络上有很多手写IOC的例子、文章,这里不再赘述,亦可参见InversifyJS的实现。

但如果每次使用都要像上面这样手动绑定,再获取,那使用会非常繁琐。更好的实现是借助TypeScript装饰器

装饰器(Decorators)为我们在类的声明及成员上通过元编程语法添加标注提供了一种方式。

例如以上依赖调用的场景,我们可以实现一个Inject装饰器,

function Inject(name: string): Function {
    return (target, targetKey: string, descriptor: PropertyDescriptor) => {
        if (descriptor === undefined) {
            // 获取target对象上自有属性对应的属性描述符
            descriptor = descriptorOf(target, targetKey);
        }
        if (descriptor === undefined) {
            if (!InjectorService.has(name)) {
                //对类的成员进行注入
            }
            return {
                get: () => InjectorService.get(target, name),
            };
        }
    };
}
// 可参考Typescript装饰器 > 属性装饰器 一节:https://www.tslang.cn/docs/handbook/decorators.html
// 亦可参考InversifyJS inject实现:https://github.com/inversify/InversifyJS/blob/6ef0ddf76093e97fd3d302c16905daa5c418db9c/src/annotation/inject.ts#L20

这样通过@Inject标注,上述代码C中A、B的依赖可简化如下:

@provider('A')
class A {}

class C {
  @inject('A')
  private a;
  ...
}

Provider的作用简单理解就是将类绑定到服务容器中。

依赖注入的引入,基本根除了全局引用,调用方无需关心依赖状态,随用随取,模块有了更好的可扩展性,代码健壮性进一步提升。

新架构

思路有了,那如何应用到具体的实现中呢?

先来看下我们UI的大致结构:

ui

其中顶部栏为通用模块,例如产品/业务的切换、个人信息的展示等,左侧边栏Aside为各功能的导航,用户可以点击子项切换功能,中间区域则是具体的功能呈现。

于是可以有一个基本的设计如下:

<Sider>
  <Link to="/A"> A </Link>
  <Link to="/B"> B </Link>
</Sider>
<Content>
  <Switch>
    <Route path="/A">
      <CompA />
    </Route>
    <Route path="/B">
      <CompB />
    </Route>
  </Switch>      
</Content>

但这样我们需要手动维护Aside、Router组件的内容 。所以更进一步的,可以把组件Link、Comp等抽象成配置维护在一个状态里,UI容器在加载时拉取配置生成对应的Aside组件和Router。跟着上面依赖注入的思路,可以实现一个Provider装饰器@Feature,来完成这个过程:

feature

大致实现如下:

// feature.ts
// define feature
function Feature() {
  return (target) => {
    target.prototype.toString = () => options.name ?? target.name;
      InjectorService.feature(options.name, target, ProviderScope); // save provider
    return target;
  }
}
// featureA.ts
const CompA = () => {}
const menu = {
  path: '/A',
  name: 'A',
  component: CompA,
}

@Feature(menu)
export class FeatureA {}

// UI container
class container {
  load() {
    // 实例化
    InjectorService.buildRegistry();
    // 生命周期
    InjectorService.emitOn('$beforeInit');
    // ...
  }
}

最终架构演变成如下:

structure

首屏优化

重构不总是尽善尽美,也有不如人意的地方,比如首屏时间过长......从监控系统中看到首屏时间如下,

speed_before

首屏耗时:监听页面打开5s内的 首屏 DOM 变化,并认为 DOM 变化数量最多的那一刻为首屏框架渲染完成时间,这期间如果有请求图片,则认为图片是首屏重要的组成部分,默认认为图片加载完成时间为首屏时间,如果没有请求图片,则认为首屏框架时间为首屏时间;

即使用户对加载时间不敏感,随着操作链路中大量的刷新、跳页,还是很影响效率。

要降低首屏时间,就得尽量利用浏览器的网络策略,静态资源越少越好,越小越好,数据加载越快越好。对此做了以下工作,

通过以上方法,打包体积下降了70%,首屏降至1s以下,页面打开速度大幅提升。

首屏时间也可以借助Chrome Lighthouse等工具来测量。

总结

最后再简单总结一下,

以上,重构完成后,整个系统的分工更加明确,责任更加清晰。代码质量大幅提升,自动化测试从0到整体覆盖率70%,圈复杂度降至1.5,代码重复率、规范问题、ESLint问题等等等全部搞定,一举还完了技术债,喜大普奔~

参考资料