最近筆者在使用 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 層是用來對底層技術進行操做的,例如封裝 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 層沒有過分耦合,便可實現快速切換。
實體 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 層是負責處理業務邏輯的層,主要是由業務用例組成。下面是本項目中 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,或者可能會推出一個移動版,在這些狀況下,領域層這一層都是能夠直接複用)
- 爲了領域模型的持續衍進(模型存在的目的是讓人們聚焦,聚焦的好處是增強了前端團隊對於業務的理解,思考業務的過程才能讓業務前進)
推薦幾個相關的類庫:
推薦幾篇相關文章:
PS:
移動 web 最佳實踐項目接下來的計劃:
實踐 APP 離線包技術,即將前端靜態資源提早集成到客戶端中,能夠將網頁的網絡加載時間變爲 0,極大提高應用的用戶體驗。
計劃在今年年末以前完成,因這個方案會涉及到前端、客戶端以及後端,尤爲是客戶端工做量較大,因此會花費較長週期,屆時會開源整個方案的全部端代碼,敬請期待。