前端分層架構實踐心得

文章首發於個人博客 https://github.com/mcuking/bl...
最近筆者在使用 DDD / Clean Architecture 思想開發公司內部使用的 CRM,以爲這種分層架構能夠解決目前遇到的問題,因此決定對目前開源的移動端最佳實踐項目進行重構,下面是該項目關於分層架構方面的說明。想了解更多內容請查看源碼:
https://github.com/mcuking/mo...

架構分層

目前前端開發主要是以單頁應用爲主,當應用的業務邏輯足夠複雜的時候,總會遇到相似下面的問題:html

  • 業務邏輯過於集中在視圖層,致使多平臺沒法共用本應該與平臺無關的業務邏輯,例如一個產品須要維護 Mobile 和 PC 兩端,或者同一個產品有 Web 和 React Native 兩端;
  • 產品須要多人協做時,每一個人的代碼風格和對業務的理解不一樣,致使業務邏輯分佈雜亂無章;
  • 對產品的理解停留在頁面驅動層面,致使實現的技術模型與實際業務模型出入較大,當業務需求變更時,技術模型很容易被摧毀;
  • 過於依賴前端框架,致使若是重構進行框架切換時,須要重寫全部業務邏輯並進行迴歸測試。

針對上面所遇到的問題,筆者學習了一些關於 DDD(領域驅動設計)、Clean Architecture 等知識,並收集了相似思想在前端方面的實踐資料,造成了下面這種前端分層架構:前端

其中 View 層想必你們都很瞭解,就不在這裏介紹了,重點介紹下下面三個層的含義:vue

Services 層

Services 層是用來對底層技術進行操做的,例如封裝 AJAX 請求,操做瀏覽器 cookie、locaStorage、indexDB,操做 native 提供的能力(如調用攝像頭等),以及創建 Websocket 與後端進行交互等。react

其中 Services 層又可細分出 request 層和 translator 層, request 層主要是實現 Services 的大部分功能。而 translator 層主要用於清洗從服務端或客戶端接口返回的數據:刪除部分數據、修改屬性名、轉化部分數據等,通常可定義成純函數形式。下面以本項目實際代碼爲例進行講解。git

從後端獲取 quote 數據:github

export class CommonService implements ICommonService {
  @m({ maxAge: 60 * 1000 })
  public async getQuoteList(): Promise<IQuote[]> {
    const {
      data: { list }
    } = await http({
      method: 'post',
      url: '/quote/getList',
      data: {}
    });

    return list;
  }
}

向客戶端日曆中同步 Note 數據:web

export class NativeService implements INativeService {
  // 同步到日曆
  @p()
  public syncCalendar(params: SyncCalendarParams, onSuccess: () => void): void {
    const cb = async (errCode: number) => {
      const msg = NATIVE_ERROR_CODE_MAP[errCode];

      Vue.prototype.$toast(msg);

      if (errCode !== 6000) {
        this.errorReport(msg, 'syncCalendar', params);
      } else {
        await onSuccess();
      }
    };

    dsbridge.call('syncCalendar', params, cb);
  }
  ...
}

從 indexDB 讀取某個 Note 詳情數據:後端

import { noteTranslator } from './translators';

export class NoteService implements INoteService {
  public async get(id: number): Promise<INotebook | undefined> {
    const db = await createDB();

    const notebook = await db.getFromIndex('notebooks', 'id', id);
    return noteTranslator(notebook!);
  }
}

其中,noteTranslator 就屬於 translator 層,用於訂正接口返回的 note 數據,定義以下:瀏覽器

export function noteTranslator(item: INotebook) {
  // item.themeColor = item.color;
  return item;
}

另外咱們能夠拓寬下思路,當後端 API 仍在開發的時候,咱們可使用 indexDB 等本地存儲技術進行模擬,創建一個 note-indexDB 服務,先提供給上層 Interactors 層進行調用,當後端 API 開發好後,就能夠建立一個 note-server 服務,來替換以前的服務。只要保證先後兩個服務對外暴露的接口一致,另外與上層的 Interactors 層沒有過分耦合,便可實現快速切換。前端框架

Entities 層

實體 Entity 是領域驅動設計的核心概念,它是領域服務的載體,它定義了業務中某個個體的屬性和方法。例如本項目中 Note 和 Notebook 都是實體。區分一個對象是不是實體,主要是看他是否有惟一的標誌符(例如 id)。下面是本項目的實體 Note:

export default class Note {
  public id: number;
  public name: string;
  public deadline: Date | undefined;
  ...

  constructor(note: INote) {
    this.id = note.id;
    this.name = note.name;
    this.deadline = note.deadline;
    ...
  }

  public get isExpire() {
    if (this.deadline) {
      return this.deadline.getTime() < new Date().getTime();
    }
  }

  public get deadlineStr() {
    if (this.deadline) {
      return formatTime(this.deadline);
    }
  }
}

經過上面的代碼能夠看到,這裏主要是以實體自己的屬性以及派生屬性爲主,固然實體自己也能夠具備方法,用於實現屬於實體自身的業務邏輯(筆者認爲業務邏輯能夠分爲兩部分,一部分業務邏輯屬於跟實體強相關的,應該經過在實體類中的方法實現。另外一部分業務邏輯則更多的是實體之間的業務,則能夠放在 Interactors 層中實現)。只是本項目中尚未涉及,在這裏就不做更多說明了,有興趣的能夠參考下面列出來的筆者翻譯的文章:可擴展的前端#2--常見模式(譯)

另外筆者認爲並非全部的實體都應該按上面那樣封裝成一個類,若是某個實體自己業務邏輯很簡單,就沒有必要進行封裝,例如本項目中 Notebook 實體就沒有作任何封裝,而是直接在 Interactors 層調用 Services 層提供的 API。畢竟咱們作這些分層最終的目的就是理順業務邏輯,提高開發效率,因此沒有必要過於死板。

Interactors 層

Interactors 層是負責處理業務邏輯的層,主要是由業務用例組成。通常狀況下 Interactor 是一個單例,它使咱們可以存儲一些狀態並避免沒必要要的 HTTP 調用,提供一種重置應用程序狀態屬性的方法(例如:在失去修改記錄時恢復數據),決定何時應該加載新的數據。

下面是本項目中 Common 的 Interactors 層提供的公共調用的業務:

class CommonInteractor {
  public static getInstance() {
    return this._instance;
  }

  private static _instance = new CommonInteractor(new CommonService());

  private _quotes: any;

  constructor(private _service: ICommonService) {}

  public async getQuoteList() {
    // 單例模式下,將一些基本固定不變的接口數據保存在內存中,避免重複調用
    // 但要注意避免內存泄露
    if (this._quotes !== undefined) {
      return this._quotes;
    }

    let response;

    try {
      response = await this._service.getQuoteList();
    } catch (error) {
      throw error;
    }

    this._quotes = response;
    return this._quotes;
  }
}

經過上面的代碼能夠看到,Sevices 層提供的類的實例主要是經過 Interactors 層的類的構造函數獲取到,這樣就能夠達到兩層之間解耦,實現快速切換 service 的目的了,固然這個和依賴注入 DI 仍是有些差距的,不過已經知足了咱們的需求。

另外 Interactors 層還能夠獲取 Entities 層提供的實體類,將實體類提供的與實體強相關的業務邏輯和 Interactors 層的業務邏輯融合到一塊兒提供給 View 層,例如 Note 的 Interactors 層部分代碼以下:

class NoteInteractor {
  public static getInstance() {
    return this._instance;
  }

  private static _instance = new NoteInteractor(
    new NoteService(),
    new NativeService()
  );

  constructor(
    private _service: INoteService,
    private _service2: INativeService
  ) {}

  public async getNote(notebookId: number, id: number) {
    try {
      const note = await this._service.get(notebookId, id);
      if (note) {
        return new Note(note);
      }
    } catch (error) {
      throw error;
    }
  }
}

固然這種分層架構並非銀彈,其主要適用的場景是:實體關係複雜,而交互相對模式化,例如企業軟件領域。相反實體關係簡單而交互複雜多變就不適合這種分層架構了。

在具體業務開發實踐中,這種領域模型以及實體通常都是有後端同窗肯定的,咱們須要作的是,和後端的領域模型保持一致,但不是同樣。例如同一個功能,在前端只是一個簡單的按鈕,而在後端則可能至關複雜。

另外須要明確的是,架構和項目文件結構並非等同的,文件結構是你從視覺上分離應用程序各部分的方式,而架構是從概念上分離應用程序的方式。你能夠在很好地保持相同架構的同時,選擇不一樣的文件結構方式。沒有完美的文件結構,所以請根據項目的不一樣選擇適合你的文件結構。

最後引用螞蟻金服數據體驗技術的《前端開發-領域驅動設計》文章中的總結做爲結尾:

要明白,驅動領域層分離的目的並非頁面被複用,這一點在思想上必定要轉化過來。領域層並非由於被多個地方複用而被抽離。它被抽離的緣由是:

  • 領域層是穩定的(頁面以及與頁面綁定的模塊都是不穩定的)
  • 領域層是解耦的(頁面是會耦合的,頁面的數據會來自多個接口,多個領域)
  • 領域層具備極高複雜度,值得單獨管理(view 層處理頁面渲染以及頁面邏輯控制,複雜度已經夠高,領域層解耦能夠輕 view 層。view 層儘量輕量是咱們架構師 cnfi 主推的思路)
  • 領域層以層爲單位是能夠被複用的(你的代碼可能會拋棄某個技術體系,從 vue 轉成 react,或者可能會推出一個移動版,在這些狀況下,領域層這一層都是能夠直接複用)
  • 爲了領域模型的持續衍進(模型存在的目的是讓人們聚焦,聚焦的好處是增強了前端團隊對於業務的理解,思考業務的過程才能讓業務前進)

推薦幾個相關的類庫:

react-clean-architecture

business-rules-package

ddd-fe-demo

推薦幾篇相關文章:

前端架構-讓重構不那麼痛苦(譯)

可擴展的前端#1--架構基礎(譯)

可擴展的前端#2--常見模式(譯)

領域驅動設計在互聯網業務開發中的實踐

前端開發-領域驅動設計

領域驅動設計在前端中的應用

相關文章
相關標籤/搜索