構建離線優先的 React 應用
長久以來,在開發移動端應用(包括 Web 應用,以下簡稱「應用」)時,我們習慣性地將「離線」當作一種錯誤來對待。作為應用的創建者,我們在越來越穩定和快速的辦公網路下設計和開發著這些應用,漸漸地對那些做不到時刻在線的用戶們失掉了同理心。實際上這些「掉了線」的用戶離我們並不遠,他們也許是通勤路上不得不擠進沒有信號的地鐵中的上班族,也許是喜歡拿著手機跑進 Wi-Fi 鞭長莫及的廁所蹲馬桶的遊戲玩家。他們在我們身邊,他們就是我們。
前些年移動優先(Mobile First)的概念喊得火熱,而移動的網路往往是不穩定的,漸進式網頁應用 (Progressive Web Apps)概念順理成章地流行了起來,越來越多的應用提供了對離線的支持。這樣的事實使得我們重新思考「離線」對於應用來講到底是什麼。
擁抱離線優先
作為一款主打文檔記錄的辦公產品,提供離線支持對於石墨文檔來說意義非常大。然而不同的用戶有著不同的使用場景,每種使用場景又會產生不同的離線操作閉環需求:筆記型的用戶希望能夠在離線的情況下創建文檔、編寫內容、修改標題以及刪除文檔;協作型的用戶則想要在離線的時候閱讀文檔、劃詞留言以及回復別人的評論。為了滿足這些複雜而又瑣碎的離線需求。我們最終確定了離線優先的開發模式:
離線不是一種錯誤,在線則是一種特性。
離線優先指的是開發應用時以離線作為目標場景,同時針對聯網的情況增加額外功能。就石墨文檔應用而言,我們會預設用戶始終處於沒有網路連接的狀態,並讓用戶能夠在這個狀態下可以正常地使用大部分地功能。同時當聯網後,我們會額外為用戶提供離線同步和多人實時協作等功能。
SOS:離線優先的應用實踐
半年前石墨文檔新版應用正式立項開發,我們得以將離線優先的理念灌注到這個新生兒當中。
考慮到石墨文檔的所有應用均是基於 Redux 打造(iOS 和 Android 端採用了 React Native;移動網頁端則通過 React Native for Web 復用移動端的代碼做橋接;桌面端自然就是 React),我們的前端工程師基於 Redux 實現了一套跨平台的離線同步解決方案(內部項目名為 SOS,Shimo Offline Solution)。下圖是 SOS 的示意圖:
SOS 的核心理念是「先修改後同步」,用戶對數據的所有修改都是直接寫入到本地數據中(即使當前是聯網狀態)並實時得到反饋,應用會在後台通過獨立的組件定時將這些修改和伺服器進行同步。這個理念也使得我們在貫徹「離線優先」的同時,也自然而然地實現了「樂觀 UI」的概念,進一步提升了用戶的使用體驗。
圖中綠線以下的部分為 SOS 的內部實現:SOS 以 Redux 中的 Action 和 Selector 作為輸入輸出嚮應用提供介面:當應用需要操作數據時,向 SOS 派發相應的 Action,SOS 內部會通過 Reducer 更新相應的 Store State。此時藉助 react-redux,應用中所有的 Smart Component 都會重新調用 SOS 提供的 Selector 來實現自動視圖更新。圖中有兩個核心的概念:
1. Mapped Data Store
即 Redux 中的 Store,用戶所有的數據均存儲在其中。SOS 中的 Store 可以看作全站資料庫面向該用戶的約束(Restriction)子集,從 API 獲取數據時 SOS 會通過 Wormalize(類似 Normalizr,增加了通過 Schema 反向獲取數據的功能)對返回結果進行規範化。如獲取用戶收藏的文件列表介面的返回結果是:
[ { id: "baf6", name: "文檔一", content: "xxxx", favorited: true }, { id: "y09a", name: "文檔二", content: "xxxx", favorited: true }, { id: "z8d1", name: "文檔三", content: "xxxx", favorited: true }]
經過 Wormalize 處理後,存儲到 Mapped Data Store 中的內容就會轉換為:
{ files: { baf6: { name: "文檔一", content: "xxxx" }, y09a: { name: "文檔二", content: "xxxx" }, z8d1: { name: "文檔三", content: "xxxx" } }, favorites: ["baf6", "y09a", "z8d1"]}
數據存儲規範化有兩個最大的好處:一個是利於更新,當某個文件內容變化時,可以只修改一處,而不用把所有引用到該文件的地方都進行更新;另一個是節約內存佔用,同一個文件內容只會存儲在一處,避免了多個副本造成的空間浪費。
2. Object Diff
前面提到過用戶對數據的操作都是直接寫入到本地數據,然後非同步地和伺服器進行數據同步。完成這一同步過程的組件便是 Object Diff。
Object Diff 是一個支持追蹤 State 內容改動的數據結構和演算法套件。SOS 會在特定時候(State 發生變化且當前已經聯網或者從離線狀態變成聯網狀態)調用 Object Diff 來拿到變化的數據,然後為這些數據生成不同的 API 調用。API 調用成功後則回調給 Object Diff 來把相關差異信息標記為已完成;如果失敗了,則通過 Object Diff 回滾相應的數據。
舉個例子,假設當前本地的 State 內容為:
{ files: { baf6: { name: "文檔一" } }, favorites: ["baf6"]}
裡面包含一個 ID 為 baf6、標題為「文檔一」的文檔,同時被加入了收藏。當用戶進行一系列操作後,State 的內容變為:
{ files: { baf6: { name: "第一個文檔" }, cw12: { name: "第二個文檔" } }, favorites: ["cw12"]}
可以看到用戶將 baf6 這個文檔的標題改成了「第一個文檔」,同時將取消了對這個文檔的收藏。另外又創建了一個新文檔「第二個文檔」並將其加入收藏。在這個例子中,如果我們調用 Object Diff 的獲取兩個 State 之間的差異,那麼會拿到如下結果:
{ diff: { files: [ { action: "update", path: "baf6.name", from: "文檔一", to: "第一個文檔" }, { action: "insert", path: "cw12" } ], favorites: [ { action: "remove", value: "baf6" }, { action: "insert", value: "cw12" } ] }}
接下來,SOS 會將這個結果轉換成若干個對石墨文檔 RESTful API 的調用:
1. PATCH /files/baf6 { name: "第一個文檔" }2. POST /files { name: "第二個文檔" }3. DELETE /favorites/baf64. PUT /favorites/{第二個調用返回的文件ID}
值得注意的是第四個調用依賴第二個調用返回的文件 ID 而不是直接用 State 中的 cw12,這是因為客戶端在離線狀態下創建文件時,需要為這個文件生成一個本地 ID 來使所有依賴文件 ID 的 Action 也能處理未同步的文件,這裡的 cw12 就是一個本地 ID。但是把新創建的文件同步給伺服器會造成客戶端與服務端生成的 ID 不一致。一些框架和實踐採用了不同的方法來解決這個問題,如 UUID + Per-User Token 和 Meteor 的共享隨機 seed 方式,然而這些方式都對服務端的具體實現與 ID 的格式有所要求,無法成為一個非侵入的通用解決方案。
既然客戶端難以生成與服務端一致的 ID,我們轉而將方向調整為解決同步成功後新舊 ID 兼容的問題:每次文件(或者其他對象,如評論)同步成功時,我們都會為新舊 ID 生成一個映射。下次應用通過 Selector 取數據時,即使提供的是舊 ID,Selector 也會將其映射成新 ID 從而正確地取回內容,整個過程對應用是透明的。
懶載入模式
因為和用戶有關的數據都是存儲在 Store 中的,所以很容易導致 Store 的內存佔用過多。拿我們的場景舉例,一個用戶可能會有幾百甚至幾千個文件,每個文件的內容一般為幾 KB,這樣計算下來僅文件的內容就要佔用十幾兆的內存。這些內存如果放到桌面端當然顯得無足掛齒,但是一個跨平台的離線解決方案也需要考慮到一些低性能設備(如低配置的 Android 設備對每個應用可以使用的內存空間有相對苛刻的限制)的使用。
考慮到雖然用戶的數據總量很多,但是每次訪問網頁(或啟動 App)後用戶使用的數據相對較少(往往只會用到有限的幾個文件),我們採用了一套數據喚醒的邏輯實現了懶載入模式,保證即使磁碟上預載入了很多數據,也只有用戶需要用到的部分才會被載入到內存中。這一步藉助了 Wormalize 的 dewormalize 功能,使得 SOS 可以直接按照 Schema 來從持久化層中載入嵌套的對象。如下代碼定義了一個文件 Schema:
import { Schema } from "wormalize"export const userSchema = new Schema("users")export const fileSchema = new Schema("files")fileSchema.define({ author: userSchema, // 文件作者 lastUpdatedBy: userSchema // 文件最後更新者})
SOS 內部需要從持久化層載入數據時只需要執行:
import { restoreSchema } from "./actions"import { fileSchema } from "./schemas"import { selectFile } from "./reducers/files"store.dispatch(restoreSchema( { file1: "b8f1", file2: "c890" }, // 實際要載入的 id 列表 { file1: fileSchema, file2: fileSchema } // 與列表相對應 Schame)).then(() => { const file1 = selectFile(store.getState(), "b8f1") console.log("Author of file1 is", file1.author.name)})
這樣就會把文件、文件作者和文件最後更新者的數據從持久化層載入到 State 上了。
總結與擴展閱讀
上個月我們剛剛在應用商店上架了基於 SOS 的 iOS 應用,收到了非常多積極的反饋。未來我們會繼續對這個話題進行深度地探索,同時也會逐漸將 SOS 中核心的組件開源出來,並希望能夠與開源社區以及相關廠商一起不斷地提升用戶的應用使用體驗。
這裡推薦一些文章,有興趣的同學可以對相關的話題進行更深入的研究:
Offline First 概述Introducing Redux Offline - 另一個藉助 Redux 實現 Offline First 的實踐
Google Groups 上關於 normalizr 在的討論True Lies Of Optimistic User Interfaces - 非常全面地介紹了樂觀 UI
推薦閱讀:
※如何規模化React應用
※redux middleware 詳解
※React+AntD後台管理系統解決方案(補)
※【React/Redux/Router/Immutable】React最佳實踐的正確食用姿勢
※如何在非 React 項目中使用 Redux