前端的狀態管理
為什麼重要:軟體的複雜性有一大部分取決於狀態,有良好的狀態管理可以降低軟體的複雜性(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)的欄位拆分出來,假設 department 和 job_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