聊聊中後臺前端應用:模組相關的一些事

發表於2023-09-20

在《聊聊中後臺前端應用:目錄結構劃分模式》中講述了「野生」、「分層」和「模組化」這三種劃分目錄結構的模式,本文就在假定專案中已經採用內聚性相對最高的「模組化」模式進行目錄結構劃分的基礎上,聊聊模組相關的一些事兒——

模組邊界

模組必須有其清晰的職責邊界和有效的約束手段,否則它將會迅速變得臃腫且難以維護,進而在某個時機失控並爆炸,使得應用變成無法收拾的爛攤子。

要爆炸的沙魯

如何確定模組的邊界雖然可以在一定程度上給出一些指導原則和指標,但更多的是依賴拆分模組的人根據個人所掌握的知識和經驗所進行的主觀判斷。

模組拆分準則

無論是用「模組化」模式還是「分層」模式去劃分目錄結構,都是按領域或業務拆分模組的,這是最基本的準則。

那麼,「領域」和「業務」到底是指啥?它們有啥區別呢?

一般來說,在相對簡單的語境中「領域」和「業務」大多可以劃等號,無需嚴格區分這兩者,理解為某一塊業務的邏輯;而在較為複雜的語境中就可能要釐清它們之間的關係了——「業務」是相對具體的,與企業實際的業務活動密不可分;「領域」則是更為泛化的,可以是多個「業務」的底層,是「業務」中立的。

在業務系統中,「領域」和「業務」都可以包含由實體、關係和規則所構成的模型(領域模型或業務模型),這些是業務需求在架構或程式碼中的體現。

一個模組代表一個領域或業務,對應一個模型;同一個模組中的各個元素之間的關係要緊密,即內聚性要高,儘可能沒有不太相關的東西——這是第二個基本準則。

提升模組內聚性或者說提煉模型是個隨著對業務的理解和相關知識的增長而循序漸進的長期工作,不是一成不變的,也不可能一下就做得很好。

每個模組都是業務系統的子系統,同時它們又分別由實體/模型定義、請求服務、UI 元件等子系統所組成;各部分之間相互獨立又有所聯絡——模組內部分層是第三個基本準則。

可以說,關注點分離和單一職責原則始終貫穿著模組拆分的全過程。

更為深入的探討,可看由陶師傅組織的《業務邏輯拆分模式》。

檔案引用方式

對於個人來說,尋求「方便」是人的本性(懶惰),同時「方便」有時會造成錯誤和混亂,在追求有序和穩定的多人協作中必然要限制「方便」的存在。

因此,為了保障模組的功能邊界和依賴關係是清晰的、易理解的,有時需要刻意提高引用模組外資源的成本,比如檔案引用路徑的約束。

在有構建的前端應用中,通常會配置 @ 作為原始碼資料夾的別名,之後在開發時只要有用到其他檔案的資源,就 @/*,表面上是方便了,實際給理解系統和維護功能造成了很多麻煩——就像濫用繼承機制一樣。

鑑於此,給引用模組內、外檔案的路徑加上約束進行限制——

模組內可以檔案間引用,需用相對路徑;模組外只能對 shared 之類存放通用資源、基礎設施等的進行檔案引用,要用 @/* 的形式;得用框架提供的或自定義的(見下文)而不是 ES Modules 和 CommonJS 等「標準」的模組系統去引用其他模組的資源,這時並不一定是檔案間引用。

模組系統

在講「模組化」模式時有提到——

每個領域/業務模組下有一個 index.ts 檔案,用於描述該模組依賴哪些模組的什麼資源(請求服務、部件/業務元件等),以及它向其他模組提供什麼資源。

為了提高靈活性,最好設計並實現一套模組註冊與查詢機制,以替代常規的 importexport。理想狀況下,每個模組都可以跨應用使用。

歐雷《聊聊中後臺前端應用:目錄結構劃分模式

並且上文也說在引用其他模組的資源時要用框架提供的或自定義的模組系統。按照當前的開發模式,不太有符合專案或架構需求的模組系統,幾乎要去自定義或自己設計。

一個模組系統可以很簡單也可以很複雜,但其基本功能無非是依賴管理,即依賴的收集與載入。

模組註冊

模組的註冊分為兩步——

先設計用來描述模組資訊的模組描述器,最簡單的只需包含模組名、依賴模組的資源、提供給其他模組的資源以及模組會用到的 UI 元件:

export default {
  name: 'module-name',
  imports: ['[module-name].[resource-type].[resource-name]'],
  exports: {
    '[resource-type]': {
      '[resource-name]': 'foo',
    },
  },
  components: {
    '[LocalComponentName]': '[DependencyRefName]',
  },
};

其中,[module-name] 是模組名;[resource-type] 是資源型別,可以是 services(請求服務)、utils(工具函式)、 widgets(部件/業務元件)和其他任意類別的資源;[resource-name] 是資源名稱。

components 是特殊的依賴資源,宣告模組會用到的 UI 元件——既可以是控制元件/基礎元件又可以是部件/業務元件。[LocalComponentName] 是在模組中使用 UI 元件時的名字,[DependencyRefName] 則是所依賴的 UI 元件的引用標識。

接下來,需要設計並實現一個傳入模組描述器的模組註冊函式。一般是用 Map 將處理後的模組資訊儲存在記憶體中:

const moduleMap = new Map();

function resolveModule(descriptor) {
  // 解釋模組描述器並返回處理後的資訊
}

function registerModule(descriptor) {
  moduleMap.set(descriptor.name, resolveModule(descriptor));
}

resolveModule() 中不僅要解釋模組描述器,最好再檢測下是否存在迴圈依賴和做些其他更「高階」點的功能。

然後,在前端應用的入口檔案(如 Vue 應用的 main.ts)中統一註冊模組。

模組查詢

查詢模組是為了獲取指定模組的依賴資源和構造模組上下文(後文會講)。

獲取透過模組描述器的 imports 宣告的依賴資源比較簡單,直接根據依賴引用從儲存在 moduleMap 上的模組資訊中去取就可以了;而想要得到 components 所宣告的 UI 元件在規則上就相對繁瑣了一點——

[DependencyRefName][module-name].widgets.[resource-name] 形式的字串時為引用其他模組定義的部件/業務元件,否則是控制元件/基礎元件;若 [DependencyRefName]true,按照 [LocalComponentName] 去找控制元件/基礎元件,不然以 [DependencyRefName] 去找。

從理論上來說,模組的查詢要在使用模組時進行,理想狀況下是在模組全部註冊完畢的應用初始化之後;然而實際情況很可能是某個模組在還沒註冊時就去獲取它本身的資訊了。

比如,在模組描述器中宣告瞭要提供給其他模組一個部件/業務元件,而這個部件/業務元件中又用到其他模組的資源,這時就需要查詢到當前模組並載入其依賴:

// `animation/index.ts` 檔案

import AnimationTable from './widgets/animation-table/AnimationTable.vue';

export default {
  name: 'animation',
  imports: [
    'common.widgets.TableView', // 依賴其他模組的 `TableView` 部件/業務元件
  ],
  exports: {
    widgets: {
      AnimationTable, // 提供給其他模組 `AnimationTable` 部件/業務元件
    },
  },
  components: {
    DataTable: 'common.widgets.TableView', // 本模組中用到的 `DataTable` 元件是 `common` 模組提供的 `TableView` 部件/業務元件
  },
};
<!-- `animation/widgets/animation-table/AnimationTable.vue` 檔案 -->

<template>
  <div class="AnimationTable">
    <data-table />
  </div>
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';

import context from '../../context'; // 模組上下文

@Component({
  components: context.getComponents(),
})
export default class AnimationTable extends Vue {}
</script>

<style lang="scss" src="./style.scss" scoped></style>

邏輯上來講,這種情況是找不到具體依賴的資源的。

造成這個問題的原因是,常規的 import 是靜態的、同步的,那個提供給其他模組的部件/業務元件的引入先於模組的註冊,也就是時序問題。

目前有三種解決方案——

第一種是將靜態且同步的 import '*' 改為動態且非同步的 import('*'),前提是執行環境或構建工具支援:

// `animation/index.ts` 檔案

export default {
  name: 'animation',
  imports: [
    'common.widgets.TableView',
  ],
  exports: {
    widgets: {
      AnimationTable: () => import('./widgets/animation-table/AnimationTable.vue'),
    },
  },
  components: {
    DataTable: 'common.widgets.TableView',
  },
};

第二種是在渲染時再去獲取那個部件/業務元件的依賴資源:

<!-- `animation/widgets/animation-table/AnimationTable.vue` 檔案 -->

<script lang="ts">
import { CreateElement, VNode } from 'vue';
import { Vue, Component } from 'vue-property-decorator';

import context from '../../context'; // 模組上下文

@Component
export default class AnimationTable extends Vue {
  private render(h: CreateElement): VNode {
    const { DataTable } = context.getComponents();

    return h('div', { staticClass: 'AnimationTable' }, [h(DataTable)]);
  }
}
</script>

<style lang="scss" src="./style.scss" scoped></style>

最後一種比較 tricky,在獲取依賴資源時如果指定模組不存在,就先在 moduleMap 上建立一個相應的空物件作為佔位符並將其返回,這樣一來,部件/業務元件就擁有了依賴資源在記憶體中的引用地址;由於依賴資源是在部件/業務元件渲染時才會真正使用/呼叫,那時模組已經早早註冊好了,所以能夠順利找到依賴資源。

模組上下文

在程式語言中,「上下文」一般是指讓程式能夠正常執行的一組環境變數,如執行上下文;而在應用開發中,通常衍生為用來維護作用於一定範圍的狀態的物件。

構造並傳入或注入「上下文」是一種比較好的讓 UI 元件變「瘦」的實踐——

在 UI 元件樹中從某一層往下的幾層所包含的 UI 元件是一個相對獨立的子系統,它們要協作完成同一個任務,與這個任務相關的狀態和操作無需分散在各個 UI 元件中,經由「上下文」集中管理可讓狀態更好維護,狀態變化更容易追蹤。

另外,因為核心邏輯被隔離到了 UI 元件之外,前端的自動化測試會更好做。

本系列文章所闡述的體系中,主要有模組上下文和檢視上下文。這裡只說說模組上下文,檢視上下文將在後續文章中說明。

「模組上下文」是模組級或者說模型級的上下文,相對來說它不是很重要,只是一個輔助角色,更為重要的是日後要講的「檢視上下文」。

模組上下文的主要功能就是獲取依賴資源和傳送請求:

interface ModuleContext<R> {
  getModuleName: () => string;
  getDependencies: (refPath?: string) => ModuleDependencies | ModuleResources | undefined;
  getComponents: () => { [key: string]: VueConstructor };
  execute: RepositoryExecutor<keyof R>;
}

除此之外,還可以結合 Vuex 做模組級的狀態管理,提供將名稱空間封裝了的 commitdispatch 方法等。

總結

「模組化」就是分治或者說還原論在人造物方面的應用,這是處理複雜問題的基本手段。

然而,在軟體開發中很多時候並沒有真的很好地解決複雜問題,這說明單純的形式上的「模組化」是沒什麼用的,必須要圍繞著「模組」採取一系列措施。

幾個月前我突然有個疑問——為什麼不能像硬體一樣設計軟體?

當前的結論是——軟體開發門檻和各種成本都低是導致質量差和可複用程度低的原因之一。

你們認為呢?


本文其他閱讀地址:個人網站微信公眾號

相關文章