靈魂拷問:我們該如何寫一個適合自己的狀態管理庫?

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/ce/ce70577ae8eea8a9cbc7371cb3293c4f.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"作者|李駿(涅塵)","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"來源|","attrs":{}},{"type":"link","attrs":{"href":"https://mp.weixin.qq.com/s/4wPNm1iu3CFQLQBjBzPATQ","title":"","type":null},"content":[{"type":"text","text":"爾達Erda公衆號","attrs":{}}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"引言","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"大家好,這裏是 ","attrs":{}},{"type":"link","attrs":{"href":"https://link.zhihu.com/?target=https%3A//github.com/erda-project/erda","title":null,"type":null},"content":[{"type":"text","text":"Erda 開源項目","attrs":{}}]},{"type":"text","text":"前端技術團隊,今天聊一聊前端的狀態管理。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"說到狀態管理庫,想必前端同學隨口都能說出好幾個來,社區裏的輪子一個接一個數不勝數。今天不是講某個庫的技術細節,而是跟大家聊一聊實現一個狀態管理庫的過程,以及我在這個過程中的一些思考。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"背景","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Erda 項目的前端狀態管理,從最開始的 redux,到 dva,再到現在的 ","attrs":{}},{"type":"link","attrs":{"href":"https://github.com/daskyrk/cube-state","title":null,"type":null},"content":[{"type":"text","text":"cube-state","attrs":{}}]},{"type":"text","text":",也在逐漸跟着社區的趨勢發展。redux 就不說了,dva 在我看來是一個優秀的庫,設計思想挺符合個人口味。之所以會拋棄它轉爲自研的狀態管理庫,是由於 dva 是基於 redux 做的封裝,而 redux 的字符串匹配形式的 dispatch action,天然就很難支持類型。在項目發展到幾百個頁面,近 2000 文件時,","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"如果沒有完整可靠的類型定義,對於後面的開發維護絕對是一場災難","attrs":{}},{"type":"text","text":"。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"舉個栗子,dva 中一個常見的 reducer 長這樣:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"dispatch({ type: 'products/delete', payload: id });","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"靈魂拷問來了:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"怎麼確定 type 沒寫錯呢?","attrs":{}}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"怎麼知道 products 下的 delete 這個 reducer 是否還存在呢?","attrs":{}}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"怎麼知道 payload 這個數據的類型是匹配 reducer 的呢?","attrs":{}}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"connect 到組件的數據怎麼知道是符合組件需要的類型呢?","attrs":{}}]}]}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"目標","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因爲上述問題在沒有類型定義時無解,且對於廢棄代碼不敢刪除,擔心哪裏還在用,所以當時我們迫切地想找一個支持類型定義的狀態管理庫,同時爲了避免改造太大影響正常業務開發,需要能平滑漸進地改造。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們的目標很明確:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"有完整的類型定義鏈路,從 API 獲取數據 -> 數據放入 store -> 組件從 store 取數據 -> 組件調用 store 的 effect 或 reducer,整個鏈路都有類型。","attrs":{}}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"兼容 dva,做漸進式改造,最好架構和 API 也很像,沒有額外的學習成本。","attrs":{}}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"易於擴展,把項目相關的邏輯放在擴展中,保持庫本身簡單可靠。","attrs":{}}]}]}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"過程","attrs":{}}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"探索開源庫","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"當時調研了許多庫,但基本沒有支持完整的類型定義鏈路,或者要切換到另一個體繫上,改造難度很大,業務上的風險太高。我們也想過不如自研一個輪子,但在嘗試過程中遇到些問題沒解決,直到某一天發現了 stamen 這個庫,裏面利用 React Hooks 做監聽和取消監聽的方式啓發了我們。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/46/46c1d8397de288ebfb020116df94b59f.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":"center","origin":null},"content":[{"type":"text","text":"stamen 架構圖","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上圖是 stamen 倉庫中的架構圖,可以看到結構和 dva 很相似,每個 store 裏分 state、effects、reducers 三部分,通過 hooks 方式調用,在組件 mount 的時候註冊 state 的監聽,unmount 時移除監聽。因爲 hooks 就是普通的函數,很容易定義類型,對 Typescript 非常友好。所以 store 裏的 state 結構類型、effects 和 reducers 函數的類型都可以很容易的獲取到,如果組件也是函數形式,那整個類型鏈路就已經通了。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"爲什麼我們沒有直接用,而是基於這個又做了改造呢?因爲在我們項目中基於 dva 做了一些增強功能,stamen 無法滿足,同時這些邏輯並不適用於每個團隊,所以不適合放在別人的庫中。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"無法滿足的有以下幾點:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"沒有 key 或 name 屬性,必須具名引入,某些場景下不方便。","attrs":{}}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"沒有提供類似 dva 裏的 subscriptions 能力,而路由監聽是我們項目裏很常用的功能。","attrs":{}}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用 dispatch(\"action\", payload) 的形式其實 payload 類型是確定不了的,造成鏈路類型中斷,而且不如直接調用 effect 或 reducer 直觀方便。","attrs":{}}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"不支持在 effect 和 reducer 的前後加鉤子函數,所以也沒法支持中間件,比如 loading。","attrs":{}}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"不支持對 store 做擴展,比如加一個自定義字段,或者對 effect 做些定製增強。","attrs":{}}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"沒有提供 state 的類型和類組件配合使用,類組件的 props 類型需要重新定義一遍。","attrs":{}}]}]}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"基於開源改造","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因此,在 stamen 的實現思想上,我們結合自身項目需要做了改造。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"首先,結構和 API 上向 dva 靠攏,添加 name、subscriptions 字段,name 的作用後文會講到,加了 subscriptions 後可以把路由監聽、ws 連接等放在這裏面,例如我們項目中常見的路由監聽:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"API 上,dva 把 payload 放在第一個參數裏,把 call、put 等方法放在第二個參數裏,這樣限制了只能把所有數據都放在 payload 中傳遞,但有些其實可能不是接口需要的數據,比如 API 路徑參數、特殊邏輯標記等,透傳時還需要抽離出來會比較麻煩,如下圖所示:","attrs":{}}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/1c/1c57c452e749fc58ce21c1a9ae82aa63.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"所以在 cube-state 中,我們把 call、select 等方法放在了第一個參數,第二個參數是 payload,後面還可以繼續傳其他參數,但調用時還是普通的形式,如下圖所示:","attrs":{}}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/1d/1d55d45cbb13b800dc27b7b13fbbba65.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"然後,我們從 dispatch 方式改造爲能夠直接調用的形式,比如 countStore.effects.addLater(payload),這樣類型定義就完美了,執行的地方必須傳入 effect 定義的類型,而且 effect 內部也能直接調用 store 自身的 reducer。同時爲了方便調用,我們還支持了展平形式的創建方式,把 effects 和 reducers 作爲根屬性,即 countStore.addLater(payload) 的形式。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接着,我們添加了 effect 和 reducer 的 hook,支持在前後執行一些邏輯,由此能夠支持中間件系統。比如 loading 中間件,就是在每個 effect 執行前後自動更新 loadingStore 裏的狀態。這裏就用到了 store 的 name 字段。","attrs":{}}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/d1/d189690335a16efd334791737e0923da.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下來,支持 store 的擴展。這個是爲了支持一些自定義的邏輯,在我們項目中,前後端對於請求返回結構做了如下封裝:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"{ \n success: true,\n data: {},\n err: {}\n}","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"一般只需要處理 data 字段,如果每個 effect 都從結構體裏提取 data 會有很多冗餘代碼,最好在調用 service 過程中默認處理掉。因此支持了 extendEffect 用來擴展或覆寫默認提供的 effet 第一個參數,比如我們項目中擴展了 getParams、getQuery 兩個方法,覆蓋了原有的 call 方法,在內部處理請求返回結構體,以及做提示的一些邏輯。","attrs":{}}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/e1/e16105947e1547cd70d563e5f356f331.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"擴展後可以在 effect 中方便地獲取路徑參數、query 參數,以及一些成功、錯誤提示,比如下圖中,獲取路徑上的 appId 作爲請求參數,請求成功後給用戶提示:","attrs":{}}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/6d/6d925db42f48704373c0f0813045b66d.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"最後,把 state 類型暴露出來,在和類組件配合時不用再定義一遍:","attrs":{}}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/db/db4ef8ad13e14a6bee0763ce9df3ecb0.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"至此,大致結構就已經差不多了,後面增加的基本是一些深入配合具體業務場景的需求,比如支持基於一個 store 擴展另一個 store,支持全局單例模式等等。整體的架構圖如下所示:","attrs":{}}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/34/349532b23939d051d0179833c29787bc.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"完善測試及文檔","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"做一個穩定可靠的庫,一方面是儘量簡單,另一方面測試用例是必不可少的,所以我們也補充了比較全面的測試,基本覆蓋到了每一個邏輯。並且,在後來發現 bug 時,我們也不斷補充新的測試用例,這塊是另一個話題,此處暫不細講。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"至於文檔,因爲本身很簡單, 總共沒幾個 API,所以直接放在 README 裏了。文檔中提供了基礎、進階用法說明,也提供了在線 demo 供體驗。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"結語","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在 cube-state 初版完成後,我們就逐漸開始在項目中做遷移改造,因爲用法類似,除了補充大量的類型定義外,很多時候是比較機械的勞動。在打通了類型定義的完整鏈路後,項目的開發維護終於不再像以前那樣,唯恐牽一髮而動全身,能夠避免很多因類型導致的錯誤。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"當然現在也依然有些問題沒有解決,比如擴展的 getParams 等方法沒有類型定義,必須直接用 createStore 方法包裝源對象的方式在某些場景下不適合等,我們也希望後面能逐漸解決這些問題,或者找到更好的升級方案。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"後來也看到一些很簡單優秀並且很相似的庫,不過","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"只有自己才知道自己的項目適合什麼","attrs":{}},{"type":"text","text":",這不是爲了造輪子而造輪子,而是爲了更好地支持項目的開發維護。所以,我們不做無意義的事。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"之前聽玉伯在分享時提到:其實在前端領域,還有很多基礎的東西有待深入去做,比如像 webpack 這種打包工具,雖然已經很完善了,但臃腫難用的問題很難解決,如果誰能繼續去“造輪子”,過程中探索出不一樣的路,就是很有意義的。最後,願各位在前端之路上,也能探索出自己的精彩。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"歡迎參與開源","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Erda 作爲開源的一站式雲原生 PaaS 平臺,具備 DevOps、微服務觀測治理、多雲管理以及快數據治理等平臺級能力。","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"點擊下方鏈接即可參與開源","attrs":{}},{"type":"text","text":",和衆多開發者一起探討、交流,共建開源社區。","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"歡迎大家關注、貢獻代碼和 Star!","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"Erda Github 地址:","attrs":{}},{"type":"link","attrs":{"href":"https://github.com/erda-project/erda","title":null,"type":null},"content":[{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"https://github.com/erda-project/erda","attrs":{}}]}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"Erda Cloud 官網:","attrs":{}},{"type":"link","attrs":{"href":"https://www.erda.cloud/","title":null,"type":null},"content":[{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"https://www.erda.cloud/","attrs":{}}]}]}]}],"attrs":{}}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章