劍指immer,更快更強的limu

發表於2024-02-26

image.png

前言

歡迎瞭解和關注limu,訪問文件並點選右鍵調出控制檯後可實時體驗 limu api 和 immer api做對比(全域性已繫結limu和immer物件)。

接下來讓我們一起深入瞭解limu的誕生歷程吧 ^\_^

不可變資料的現狀

不可變資料由於擁有結構共享的特性,讓一些嚴重依賴淺比較的框架快速獲得效能收益(如react),同時也讓一些需要使用嚴格不可變資料的場景避免了深克隆帶來的冗餘效能開銷,而當下除了immutablejs 和 immer 這兩款非常流行的工具庫之外,有沒有一款比它們的效能和易用性都更好的不可變資料工具庫呢?在回答此問題之前,我們先看下immutablejsimmer陷入的困境。

immutablejs作為一個先驅者,最早的git提交記錄可以追溯到2014年4月,伴隨著react的不可變狀態程式設計理念在2015年之後開始越來越走紅,現已達到30K+ star數量,它在js語言世界裡擁有為不可變資料指引方向般的重要地位,帶領大家認識到了不可變資料在某些特定程式設計領域的重要性。

不過它的問題也比較突出,主要歸結為2點

  • 1 api 複雜,與原始js操作處理隔離的狀態,有很重的學習成本和記憶負擔
  • 2 內建了一套自己的資料結構,需要透過fromJstoJs做普通json和不可變資料直接的相互轉換,帶來了額外的開銷。
// 額外的學習成本和記憶負擔
immutableA = Immutable.fromJS([0, 0, [1, 2]]);
immutableB = immutableA.set 1, 1;
immutableC = immutableB.update 1, (x) -> x + 1;
immutableC = immutableB.updateIn [2, 1], (x) -> x + 1;

而 2018誕生的 immer 則完美的解決了以上兩點問題,它巧妙的使用Proxy代理了原始資料,讓使用者可以像原始js一樣完成所有不可變資料的操作(不支援的環境自動降級為 defineProperty),這樣一來使用者沒有了任何學習成本和記憶負擔.

const { produce } = limu;
const baseState = {
  a: 1,
  b: [ 1, 2, 3 ],
  c: {
    c1: { n: 1 },
    c2: { m: 2 },
  }
};
// 像原始js一樣絲滑的操作不可變資料
const nextState = produce(baseState, (draft)=>{
  draft.a = 2;
  draft.b['2'] = 100;
});

console.log(nextState === baseState); // false
console.log(nextState.a === baseState.a); // false
console.log(nextState.b === baseState.b); // false
console.log(nextState.c === baseState.c); // true

immer真的就是終極答案了麼,在大陣列和深層次物件場景immer的效能問題較為突出,見此問題描述,社群開始有不少作者另起爐灶嘗試突破,留意到這裡面較為突出的有structuramutative,經我實測發現確實如它們所說的快過immer較多倍,但依然未能解決既要速度快又要開發體驗好的問題,這兩個問題我將在下面一一具體意義剖析。

limu誕生

在2021年底我開始為狀態庫concent構思v3版本,其中一個重點是支援深度依賴收集(v2只支援收集狀態的第一層讀依賴),那麼就需要深度使用Proxy來完成此動作,在深度使用immer是發現除錯模式下檢視草稿非常糟心,需要藉助JSON.parse(JSON.stringify(draft))來完成,儘管後來發現current介面可以匯出草稿副本並檢視資料結構,但漫天插入額外的current然後在編譯時擦除真的讓我比較煩惱,且current本身也有不小的開銷,再加上透過issue發現immer的如下類似的效能問題後

const demo = { info: Array.from(Array(10000).keys()) };
produce(demo, (draft) => {
  draft.info[2000] = 0; // take long time
});

開始嘗試設計並實現limu,期望保持像immer一樣的api,但能夠更快且更好用,於是在經歷經過無數個小迭代後,摸索出了一些提速關鍵技巧(下面將會介紹到),解決了記憶體洩露問題,並達成了保證質量的兩個關鍵點:

  • 跑通了 370+ 測試用例

image.png

  • 測試覆蓋率到達了97%

image.png

同時也讓效能和易用性均達到我的理想後,終於可以正式宣佈穩定版釋出,且已開始作為基礎元件服務於新聞門戶,接下來將重點介紹limu的3大優勢。

更快

image.png

區別於immer的寫時複製機制,limu採用讀時淺克隆寫時標記修改機制,具體操作流程我們將以下圖為例來講解,使用produce介面生成草稿資料後,limu只會對草稿資料讀取路徑上經過的相關資料節點做淺克隆

image.png

修改了目標節點下的值的時候,則會回溯該節點到跟節點的所有途徑節點並標記這些節點為已修改

image.png

最後結束草稿生成final物件時,limu只需要從根節點把所有標記修改的節點的副本替換到對應位置即可,沒有標記修改的節點則不使用副本(注:生成副本不代表已被修改)

這樣的機制在物件的原始層級關係較為複雜且修改路徑不廣的場景下,且不需要凍結原始物件時,效能表現異常優異,可達到比 immer 快 5 倍或更多,只有在修改資料逐漸遍及整個物件所有節點時,limu的效能才會呈線性下載趨勢,逐步接近immer,但也要比immer快很多。

測試驗證

為驗證上述結論,使用者可按照以下流程獲得針對limuimmer效能測試對比資料

git clone https://github.com/tnfe/limu
cd limu
npm i
cd benchmark
npm i
node opBigData.js // 觸發測試執行,控制檯回顯結果
# or
node caseReadWrite.js

我們準備兩個用例,一個改編自 immer 官方的效能測試案例(注:跳轉後見頭部標註的連結)

執行 node opBigData.js 得到如下結果 (柱條越短代表越快)

image.png

注:以上是v9版本,immer 23年4月釋出了v10版本,經測試發現結果變化不大,效能提升不明顯

一個是我們自己準備的深層次 json 讀寫案例,結果如下 (柱條越短代表越快)

image.png

可透過注入ST值調整不同的測試策略,例如 ST=1 node caseReadWrite.js,不注入時預設為 1

  • ST=1,關閉凍結,不運算元組
  • ST=2,關閉凍結,運算元組
  • ST=3,開啟凍結,不運算元組
  • ST=4,開啟凍結,運算元組

更強

image.png

limu利用Symbol和原型鏈隱藏代理後設資料,讓後設資料始終跟隨草稿節點,在草稿結束後才擦除,讓使用者不僅可以像操作原生js一樣操作不可變資料,還能像檢視原生json一樣檢視草稿資料(僅需展開一層代理即可),且始終讓用使用者對草稿的修改資料實時同步到可檢視節點上,極大的提高了除錯體驗。

這裡我們將分別列舉limuimmermutativestructura在除錯狀態下對草稿展開的圖示:

  • limu 可任意檢視草稿所有節點,且資料始終同步為修改後的資料

    limu

  • structura 可檢視草稿的原始結構,但草稿資料是過期的(注:但log的資料是正確的)

    structura

  • mutative 保持了和immer類似的結構,無法快速檢視

    image.png

  • immer 利用Proxy層層代理,無法快速檢視
    immer

輕量

image.png

imu設計為面向現代瀏覽器的不可變資料js庫,只執行於支援proxy特性的js環境,原生支援根物件為MapSetArrayObject,相比immer 6.3kb大小容量接近減少1/3。

同時提供了更多實用的api

image.png

immut

生成一個不可修改的物件im,但原始物件的修改將同步會影響到im

import { immut } from 'limu';

const base = { a: 1, b: 2, c: [1, 2, 3], d: { d1: 1, d2: 2 } };
const im = immut(base);

im.a = 100; // 修改無效
base.a = 100; // 修改會影響 im

合併後依然可以讀到最新值

const base = { a: 1, b: 2, c: [1, 2, 3], d: { d1: 1, d2: 2 } };
const im = immut(base);
const draft = createDraft(base);
draft.d.d1 = 100;

console.log(im.d.d1); // 1,保持不變
const next = finishDraft(draft);
Object.assign(base, next);
console.log(im.d.d1); // 100,im和base始終保持資料同步
immut 採用了讀時淺代理的機制,相比deepFreeze會擁有更好效能,適用於不暴露原始物件出去,只暴露生成的不可變物件出去的場景,並利用onOperate收集讀依賴

onOperate

支援對createDraftproduceimmt 配置 onOperate回撥監聽所有讀寫變化(注:immut只能監聽到讀變化)

例如以下程式碼:

const { createDraft, finishDraft } = limu;
const base = new Map([
  ['nick', { list: [1,2,3], info: { age: 1, grade: 4, money: 1000 } }],
  ['fancy', { list: [1,2,3,4,5], info: { age: 2, grade: 6, money: 100000000 } }],
  ['anonymous', { list: [1,2], info: { age: 0, grade: 0, money: 0 } }],
]);
const draft = createDraft(base, { onOperate: console.log });
draft.delete('anonymous');
draft.get('fancy').info.money = 200000000;
const final = finishDraft(draft);

將產生以下監聽結果,非常有利於上層框架做讀寫依賴的收集

image.png

即將釋出的helux v3基於limu驅動後完成了非常多有意思的功能,盡請期待。

結語

2年磨礪,讓一個最初有點玩具性質的作品最終落地(融入concent、helux)是我意料之外的結果,結合最近爆火的室溫超導的韓國團隊做類比,他們的LK-99一燒就是20多年,不管結果是否如意,至少擁有一顆摯愛科學的心才能夠堅持下來,想起在無數個深夜一遍遍npm run test並最佳化程式碼,何嘗又不是因為保持一顆摯愛的心而沉溺進去煉程式碼丹呢?

不管 limu 是否會被淹沒在歷史的星辰大海里,穩定版的釋出算是給自己一個交代了,願各位碼客也保持源源不斷的求知慾煉出心中的丹藥。

友鏈

歡迎關注這些有趣的專案 👇

  • 一個基於node.js的高速影片製作庫 ffcreator
  • 工具鏈無關sdk化模組聯邦 hel-micro
  • 即將釋出的具有深淺依賴收集雙策略和有向圖架構的全新狀態庫 helux v3
  • vscode外掛結合chatgpt實現的工程化工具 smart-ide
  • 一個讓webpack專案支援vite的前端專案的轉換工具 wp2vite

相關文章