前端分層架構實踐心得

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

應用介紹

首先介紹下本項目的應用,是一個交互簡潔的 Todo 應用,應用取名叫 Memo,Memory 的簡寫,參考了微軟的 To Do 以及 Listify、Trello 等應用。不過最大的不一樣是,項目並不依賴後端,而是使用瀏覽器提供的 indexDB 進行數據的存儲,能夠保證數據的絕對安全。另外更新應用也不會清除原來的數據,除非將應用卸載。效果圖以下:前端

體驗平臺 二維碼 連接
Web 點擊體驗
Android 點擊體驗

架構分層

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

  • 業務邏輯過於集中在視圖層,致使多平臺沒法共用本應該與平臺無關的業務邏輯,例如一個產品須要維護 mobile 和 PC 兩端,或者同一個產品有 web 和 react native 兩端;react

  • 產品須要多人協做時,每一個人的代碼風格和對業務的理解不一樣,致使業務邏輯分佈雜亂無章;git

  • 對產品的理解停留在頁面驅動層面,致使實現的技術模型與實際業務模型出入較大,當業務需求變更時,技術模型很容易被摧毀;github

  • 過於依賴前端框架,致使若是重構進行框架切換時,須要重寫全部業務邏輯並進行迴歸測試。web

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

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

Services 層

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

其中又可細分出來一個 translator 層,主要是對後端提供的接口進行數據的轉換修正,例如接口返回的數據命名不規範或格式有問題等等,通常以純函數形式存在。下面以本項目實際代碼爲例進行講解。

向後端獲取 quote 數據:

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;
  }
}
複製代碼

向客戶端日曆中同步任務數據:

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 存儲任務數據:

export class NoteService implements INoteService {
  public async create(payload: INote, notebookId: number): Promise<void> {
    const db = await createDB();

    const notebook = await db.getFromIndex('notebooks', 'id', notebookId);
    if (notebook) {
      notebook.notes.push(payload);
      await db.put('notebooks', notebook);
    }
  }
  ...
}
複製代碼

這裏咱們能夠拓寬下思路,當後端 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);
    }
  }
}
複製代碼

經過上面的代碼能夠看到,這裏主要是以實體自己的屬性以及派生屬性爲主,固然實體自己也能夠具備方法,只是本項目中尚未涉及。至於 DDD 中的聚合等概念,也因爲項目業務沒有涉及,在這裏就不做說明了,有興趣的能夠參考下面列出來的筆者翻譯的文章:可擴展的前端#2--常見模式(譯)

另外筆者認爲並非全部的實體都應該按上面那樣封裝成一個類,若是某個實體自己業務邏輯很簡單,就沒有必要進行封裝,例如本項目中 Notebook 實體就沒有作任何封裝,而是直接在 Interactors 層調用 Services 層提供的 API。

Interactors 層

Interactors 層是負責處理業務邏輯的層,主要是由業務用例組成。下面是本項目中 Note 的 Interactors 層提供的對 Note 的增刪改查以及同步到日曆等業務:

class NoteInteractor {
  constructor( private noteService: INoteService, private nativeService: INativeService ) {}

  public async saveNote(payload: INote, notebookId: number, isEdit: boolean) {
    try {
      if (isEdit) {
        await this.noteService.edit(payload, notebookId);
      } else {
        await this.noteService.create(payload, notebookId);
      }
    } catch (error) {
      throw error;
    }
  }

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

  ...

  public async changeSyncStatus(
    notebookId: number,
    id: number,
    status: boolean
  ) {
    try {
      const note = await this.getNote(notebookId, id);
      if (note) {
        note.isSync = status;
        await this.saveNote(note, notebookId, true);
      }
    } catch (error) {
      throw error;
    }
  }

  public async syncCalendar(params: SyncCalendarParams, notebookId: number) {
    const noteId = params.id;
    try {
      await this.nativeService.syncCalendar(params, async () => {
        await this.changeSyncStatus(notebookId, noteId, true);
      });
    } catch (error) {
      throw error;
    }
  }
}
複製代碼

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

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

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

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

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

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

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

推薦幾個相關的類庫:

react-clean-architecture

business-rules-package

ddd-fe-demo

推薦幾篇相關文章:

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

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

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

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

前端開發-領域驅動設計

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

PS:

移動 web 最佳實踐項目接下來的計劃:

實踐 APP 離線包技術,即將前端靜態資源提早集成到客戶端中,能夠將網頁的網絡加載時間變爲 0,極大提高應用的用戶體驗。

計劃在今年年末以前完成,因這個方案會涉及到前端、客戶端以及後端,尤爲是客戶端工做量較大,因此會花費較長週期,屆時會開源整個方案的全部端代碼,敬請期待。

相關文章
相關標籤/搜索