前端控制反转实践

118 阅读6分钟
  • 参考表单模块和系列录入组件模块来理解控制反转概念
  • 基于控制反转规则改造应用中页面模块的管理(有完整源码见 github)

此内容非常基础,会对项目代码整体起到一定的优化作用,也会促进团队代码规范的落地。

控制反转(IoC, Inversion of Control)

控制反转一种基础的软件设计原则,主要涉及到模块间依赖关系的管理,主要用于降低模块间的耦合。

formily 通过 schema 数据定义表单的思路就是一个典型的控制反转实践,具体可以参考相关开源代码。以下通过伪代码简单分析和对比表单内常规实现和控制反转改造后实现的变化。

常规实现

// ComponentInput.ts
export class ComponentInput { render() {} }
// ComponentInputNumber.ts
export class ComponentInputNumber { render() {} }
// ComponentInputPassword.ts
export class ComponentInputPassword { render() {} }
// ComponentTextarea.ts
export class ComponentTextarea { render() {} }
// ComponentHtmlarea.ts
export class ComponentHtmlarea { render() {} }
// FormModule.ts
import { ComponentInput } from './ComponentInput';
import { ComponentInputNumber } from './ComponentInputNumber';
import { ComponentInputPassword } from './ComponentInputPassword';
import { ComponentTextarea } from './ComponentTextarea';
import { ComponentHtmlarea } from './ComponentHtmlarea';

export class Form {
  render() {
    new ComponentInput('FieldName0').render();
    new ComponentInput('FieldName1').render();
    new ComponentInputPassword('FieldName2').render();
    new ComponentHtmlarea('FieldName3').render();
    new ComponentInput('FieldName4').render();
    new ComponentInputNumber('FieldName5').render();
    new ComponentTextarea('FieldName6').render();
  }
}
  • 可见 FormModule 直接依赖 ComponentInputComponentInputNumber 等模块,存在很强的耦合。
  • 对组件的修改可能需要在全局同步适配,一旦遗漏一处,就意味着修改引入缺陷的可能
    • 如组件接受的属性增加了一个必填项,调用方未全部适配,传入数据缺少,则很可能导致部分程序崩溃
    • 如组件新增时,需要改替换两个字段,实际只替换一个,则会导致功能部分未生效
  • 团队开发中,不同伙伴开发增加不同组件,在调用方如 FormModule 内很容易导致 git merge 失败
// ComponentInputEmail.ts
export class ComponentInputEmail { render() {} }
// FormModule.ts
// ...
import { ComponentInputEmail } from './ComponentInputEmail';
// ...

export class Form {
  render() {
    // ...
    new ComponentInputEmail('FieldName1').render();
    // ...
    new ComponentInputEmail('FieldName4').render();
    // ...
  }
}

基于 IoC 原则的改造

// ComponentAbstract.ts
export abstract class ComponentAbstract {
  static name = '/abstract';
  static init() {
    return new this();
  }
  static match(name: string) {
    return this.constructor.name === name;
  }
  render() {
    throw new Error('Render not implemented');
  }
}
// ComponentManager.ts
import { ComponentAbstract } from './PageModuleAbstract';

const componentCtors: Array<ComponentAbstract> = [];
export function regComponent(component: ComponentAbstract) {
  componentCtors.push(component);
}
export function getComponent(name: string) {
  return componentCtors.find(it => it.match(name));
}
// ComponentInput.ts
import { ComponentAbstract } from './ComponentAbstract';
import { regComponent } from './ComponentManager';
export class ComponentInput extends ComponentAbstract {
  static name = 'ComponentInput';
  render() {}
}
regComponent(ComponentInput);
// FormModule.ts
import { ComponentAbstract } from './ComponentAbstract';
import { regComponent } from './ComponentManager';

const fields = [
  { name: 'FieldName0', comp: 'ComponentInput' }
];

export class Form {
  render() {
    for (const { name, comp } of fields) {
      const Ctor = getComponent(comp);
      if (Ctor) {
        new Ctor(name).render();
      } else {
        // Exception handle
      }
    }
  }
}
// entry.ts
import './ComponentInput'; // 耦合最小化,另可以考虑异步导入

import { Form } from './FormModule';

export { Form };

  • 外层模块(如 FormModule)不再依赖具体的内层模块(如 ComponentInputModule 等)
  • 具体模块(如 FormModule、ComponentInputModule 等)依赖抽象模块
  • 模块间通过接口交互

基于控制反转原则改造页面模块

以 React Router 为例,引入一个页面管理器,每个页面通过管理器的注册接口将自己注册到明确的路径上,在外围组件中通过管理器获取已经注册的组件清单并用于匹配渲染路径。

模块管理中心

// src/routes.ts
import type { RouteObject } from 'react-router';

const routes: Record<string, RouteObject[]> = {};
export function regRoute(group: string, route: RouteObject) {
  if (!routes[group]) {
    routes[group] = [];
  }
  routes[group].push(route);
}
export function getRoutesInGroup(group: string) {
  return [
    {
      path: '/',
      children: routes[group] || [],
    },
  ];
}

页面实现

// src/pages/TestPage/index.tsx
import React from 'react';

function TestPage() {
  return <div>Test Page</div>;
}

export default TestPage;

页面注册

// src/pages/TestPage/route.tsx
import React, { Suspense } from 'react';

import { regRoute } from '../../routes';

const Page = React.lazy(() => import('.'));

regRoute('root', {
  path: 'test-page',
  element: (
    <Suspense fallback={null}>
      <Page />
    </Suspense>
  ),
});

页面启用

// src/App.tsx
import React, { Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { Route, RouteObject, Routes, useRoutes } from 'react-router';
import { Link } from 'react-router-dom';

import Paths from './constants/path';
import { getRoutesInGroup } from './routes';

function App() {
  const { t } = useTranslation();

  const routes = useRoutes(getRoutesInGroup('root'));

  return (
    <div>
      {t('home.description')}

      <Link to={Paths.TEST_PAGE}>Test Page</Link>

      {routes}
    </div>
  );
}

export default App;

入口

// src/index.tsx
import React, { lazy, Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom';

import Loader from './components/loader/Loader';
import './i18next';

// 最低耦合
import './pages/Home/route';
import './pages/NotFound/route';
import './pages/TestPage/route';

const root = document.getElementById('root');

const App = lazy(() => import('./App'));

if (root) {
  const app = createRoot(root);

  app.render(
    <Router>
      <Suspense fallback={<Loader />}>
        <App />
      </Suspense>
    </Router>
  );
}

完整源码见 github

关于 React Router

  • 每套 <Routes><Route><Route/>...</Route>...</Routes> 定义一个路由表
  • 一个应用可以有多套 <Routes><Route><Route/>...</Route>...</Routes> 路由表定义
  • 每次导航,重新生成所有路由表,每个路由表是一个树形数据结构
  • 路径匹配前,每个路由表会被转换为一套路径数组,并排序
  • 首个匹配路径内,逐段渲染绑定组件

具体见 React Router 开源代码实现

总结

控制反转是非常基础的软件设计原则,优势很明显,劣势也被各种实践规避的差不多了,推荐在实际代码中使用。

前端 JS/TS 代码中,控制反转的核心实现仅仅需要引入一个闭包做管理器,投入产出比十分可观。

优势:高内聚

  • 功能相关代码基本在同一模块目录内
  • 主要通过注册接口依赖管理器模块
    • 不需接口修改的情况下,模块内功能修改不影响其他模块
    • 需要接口修改的情况下,一般编译和构建阶段即可识别问题
  • 模块相关功能代码更容易维护
    • 代码在同一目录
    • 通过接口管理模块更容易维护

优势:松耦合

外围模块对内部模块的调用通过模块管理器实现

  • 模块间没有直接耦合
  • 管理器通过接口严格控制间接耦合的细节

模块的初始化通过简单的导入语句实现,与架构整体平台耦合度较低

优势:规范化

管理器通过接口注册和调用模块,统一的接口意味着统一的规范

优势:错误易处理

  • 模块通过管理器接口调用,内部错误更容易通过管理器统一拦截和处理
  • 模块不可用等问题可以降级处理,前置发现,友好提醒,并避免程序崩溃

优势:扩展性强

  • 新增模块在注册前与其他模块完全无关,可以独立测试甚至作为彩蛋发布
  • 模块增减功能时可以以管理器相关接口定义为规范,不易发生错漏

优势:减少 git merge 冲突

团队不同伙伴修改不同模块时,对同一文件对修改次数和代码量都很少,可以明显避免此方面的合并冲突

特性:模块分散

  • 同类模块可以在任意目录中实现,灵活度高的同时需要更多约定做好规范化
  • 管理器很容易实现动态模块注册,同时模块的跟踪需要特殊处理,且易忽略

劣势:模块顺序需要另行控制

模块注册一般分散在各个模块自己的代码中,调用时顺序可能需要另行安排,如果要跟踪反馈实际的运行时的调用详情也需要通过调用方或管理器编写程序另行支持。