前端的狀態管理

為什麼重要:軟體的複雜性有一大部分取決於狀態,有良好的狀態管理可以降低軟體的複雜性(see: 軟體的複雜性)。

以前端來說,思考要如何管理狀態可以分成二個部分,第一部分是關於 UI(頁面) 的狀態:

  • App Config:這包含了整個應用的設定,例如語系、主題或是字體大小,這些都算是涵蓋在 App Config 中
  • Element State:例如 Button 的 disabled、下拉選單的所需資料或是顯示出的 Dialog,可以想成與使用者進行互動時的這些狀態
  • Server Data:從後端拿到的資料,由前端要將它渲染呈現在頁面中

第二部分則是延續第一部分的這些狀態所擁有的特性:

  • 存取 State 的層級(Access Level)
  • 讀寫的頻率
  • 大小

Access Level 會引響到我們應該將狀態儲存在哪裡,以 React 來說若是 Global 的可能就會儲存在 Redux 之類的全局狀態管理中;若是是局部的可能會使用 Context;某 Component 則在內部使用 useState 進行管理。

在讀寫頻率中,若常進行更新的資料可能又不適合在 Context 中,並且需要思考是否需要進行非同步的讀寫,避免頁面阻塞。或許還會再思考是否要使用到 Worker 對資料進行處理。

資料的大小也會影響我們該如何處理資料,太大的資料一直儲存在變數中的話,則會非常佔用記憶體,會考慮將他先存放在使用者的硬碟中(IndexDB, Storage),需要使用的時候才會再拿出來進行使用。

對於狀態的優化,會以三個目標去達成:最小的存取成本、最小的搜尋成本、最小的記憶體用量

最小的存取成本

為了達成最小的存取成本,最常見的方式就是將資料進行正規化(Normal Forms)的處理。

Normal Forms 是為了擁有:

  • 更好的讀取效能
  • 優化儲存結構
  • 有更好的可讀性和維護性

首先會進行 1NF,關鍵是將資料攤平,並拆分成 Atomicity(原子性:意思為無法再進行分割),這是 Non NF 的資料:

{
  "employees": [
    {
      "id": "E001",
      "name": "Alice Wong",
      "job": {
        "title": "Developer",
        "department": "RD",
        "start_date": "2020-01-15"
      },
      "email": "alice@example.com"
    },
    {
      "id": "E002",
      "name": "Bob Chen",
      "job": {
        "title": "UI Designer",
        "department": "Design",
        "start_date": "2021-03-10"
      },
      "email": "bob@example.com"
    }
  ]
}

經過 1NF 後:

{
  "employees": [
    {
      "id": "E001",
      "name": "Alice Wong",
      "email": "alice@example.com",
      "job_title": "Developer",
      "department": "RD",
      "start_date": "2020-01-15"
    },
    {
      "id": "E002",
      "name": "Bob Chen",
      "email": "bob@example.com",
      "job_title": "UI Designer",
      "department": "Design",
      "start_date": "2021-03-10"
    }
  ]
}

以常見的前端例子,會將欄位避免層層嵌套,將每個欄位獨立出來,例子就是將 job 內的欄位抽出來,維持只有一層的資料格式。

2NF 著重點在於移除非主鍵(PK)之間的依賴,將非完全依賴主鍵(PK)的欄位拆分出來,假設 departmentjob_title 存在著依賴的關係(job_title 是依據所在的 department 所決定的),job_title 和 Employ Id 並非直接的依賴 ,所以會將提移出:

const employess = {
  E001: {
     name: "Alice Wong",
     email: "alice@example.com",
     job_id: "DEV"
   },
  E002: {
     name: "Bob Chen",
     email: "bob@example.com",
     job_id: "UID"
   }
}
 
const jobs = {
  DEV: {
     title: "Developer",
     department: "RD"
   },
  UID: {
    title: "UI Designer",
    department: "Design"
  }
}

3NF 主要會移除傳遞依賴(Transitive Dependency)的欄位,所有欄位的值應該直接與主鍵進行依賴:

const employess = {
  E001: {
     name: "Alice Wong",
     email: "alice@example.com",
     job_id: "DEV"
   },
  E002: {
     name: "Bob Chen",
     email: "bob@example.com",
     job_id: "UID"
   }
}
 
const job_titles = {
  DEV: "Developer",
  UID: "UI Designer"
}
 
const job_department = {
  DEV: "RD",
  UID: "Design"
}

有 4NF、5NF,不過通常在前端只需要達到 2NF(包含)以上就很符合需求了。
現在當我們查找資料時,可以直接透過 ID 的方式直接獲取我們需要的值。

最小的搜尋成本

假設我們所開發的應用是一款聊天室產品,我們會擁有對話內容交談的訊息陣列,可能像是:

const messages = [
  {
    id: "1",
    content: "Hey, meeting tomorrow at 3pm",
    from: "111223",
    timestamp: 1630000000
  },
  {
    id: "2",
    content: "No, meeting today",
    from: "111224",
    timestamp: 1640000000
  },
  {
    id: "3",
    content: "See you later",
    from: "111223",
    timestamp: 1650000000
  }, 
  // ...
]

當我們嘗試要搜尋關鍵字 meeting 時,我們會需要遍歷所有的 messages,然後將所擁有 meeting 的訊息篩出來:

messages.filter((message) => message.content.search(/[meeting]/) !== -1)

另一種方式是使用 Inverted Index Table 來提高我們的搜尋效率。

與一般的傳統搜尋不太一樣,並非透過 ID 去尋找目標內容,而是透過內容去尋找到對應的 ID。

像是可以建立 Table(Map) 達成範例中所需的搜尋,我們將每個字與在出現的 Message ID 進行 Mapping:

{
    hey: [1],
    meeting: [1,2],
    tomorrow: [1],
    //...
}

當搜尋某個 keyword 的時候就可以立刻獲得是哪些訊息有出現過,若是希望輸入 prefix 也可以查詢到的話,也可以將文字的部分當成 Key 進行 Mapping:

{
    m: [1,2],
    me: [1,2],
    //...
}

最小的記憶體用量

當我們將所有的資料都儲存在變數中時,會佔用使用者的記憶體使用量,當記憶體不夠使用就會造成卡頓、當機的問題。

可以透過 Memory Offloading 的方式將暫時還用不到的資料先下放到硬碟中,當需要使用時再從硬碟來回來進行使用。

在瀏覽器中有多種的儲存方式,IndexDB 在此時會是最合適的,它幾乎擁有和使用者硬碟的空間一樣大,並且是進行非同步的操作,並不會造成前端的阻塞。
另外像是 Inverted Index Table 這種可能會比較需要複雜處理的,可以透過 Web Worker 進行,避免影響 Main Thread。

Reference

Frontend System Design - Evgenii Ray