業務背景
目前團隊內的開發模式多是面向組件的,UI層和邏輯層均強耦合在一起,由于業務的差異性,往往很難完全復用。
- 閑魚前端業務處在高速發展不斷嘗試的階段,如何能更快更穩定地完成需求,更好的支撐業務發展絕對是一個值得探索的問題。
- 在接手一個復雜的老業務代碼時,經過較多人的修改,往往可維護性較差,有時只想修改某個小地方卻需要較大的理解成本,所以用一套統一的組件開發規范在長期維護中顯得格外重要。
- 閑魚技術體系經歷了從weex、rax0.x到現在rax1.x的變更,中間有過一些前端資產的積累,但是由于遷移的成本后期都不再維護,如何用更小的成本讓業務層平穩過渡到新的技術體系?
對于以上的問題我們希望能用框架一并解決,對于該框架的目標主要包括:
- 提高代碼可復用性
- 規范代碼,降低長期維護成本
- 降低業務層與技術體系的關聯
思路
關于提效,其中比較重要的是相同的代碼不要重復寫,做更細的區分和提取,提高可復用的顆粒度。另一方面是解決現有開發下比較影響開發效率的問題。
組件的分層
所以我們將面向組件的開發模式分為UI層View和邏輯層Store,以Interface進行隔離和耦合。
圖一:組件構成
在UI層無需關心狀態的流轉,只負責展示和交互方法的調用,DOM相關的動畫交互等行為邏輯也會放到該層中。
圖二:組件分工
在確認了分層的邏輯后自然就引入了Interface,主要分為兩部分:一部分是IProps,申明該組件所需的Props,在使用者調用該組件時進行對應的提示和約束;另一部分則負責連接Store和View,其中包括狀態state和交互方法;見下面的Interface示例:
export interface IMultiScrollerProps {
tabs: string;
onTabChange?(i: number): void;
}
export interface IMultiScroller extends IBase {
readonly tabIndex: number;
readonly tabSource: ITabItem;
readonly children: any;
onSwiperChange(i: number): void;
}
總結一下:所有的state和交互方法都在store中管理,供View消費;View中只負責和dom相關的邏輯操作,View和store的職責分界線就是View和store分別單獨使用時其交互和效果都能保持不變;以此實現View和store分別能有更多的復用。
狀態管理
現有的業務開發中基本所有的需求都是基于hooks的狀態管理,主要存在以下問題:
- 對于較復雜的組件hooks在多次迭代后的維護成本會非常高;
- 有時候,你的useEffect依賴某個函數的不可變性,這個函數的不可變性又依賴于另一個函數的不可變性,這樣便形成了一條依賴鏈。一旦這條依賴鏈的某個節點意外地被改變了,那么useEffect就被意外地觸發了后面的情況就會變得不可控。
- 異步陷阱
- 狀態的修改是異步的 useState返回的修改函數是異步的,并不會直接生效,所以此時讀取該值獲取到的是舊值。要在下次重繪才能獲取新值。不要試圖在更改狀態之后立馬獲取狀態。
const [value, setValue] = useState(0);
setValue(100);
console.log(value); // <- 0
•timeout指向的是舊值
timeout指向的是舊值,即使在外部已經重新設置,由于閉包所有在setTimeout中獲取到的都是之前的值。
const [value, setValue] = useState(0);
window.setTimeout( => {
console.log('setAnotherValue', value) // <- 0
}, 1000);
setValue(100);
•何時使用useCallback/useMemo等對于新手來說存在一定的門檻。
關于 Hook 中的閉包:useEffect、useMemo、useCallback都是自帶閉包的。也就是說,每一次組件的渲染,其都會捕獲當前組件函數上下文中的狀態(state、props)。所以每一次這三種 Hook 的執行,反映的也都是當時的狀態,無法獲取最新的狀態。對于這種情況,應該使用 ref 來訪問。
對于狀態管理react體系中最受歡迎的應是redux與mobx
圖三:Redux Flow
redux的特點從上圖可以總結得到下面的三大原則:
•單一數據源
•state 是只讀的
•使用純函數來執行修改
但是redux的問題也是十分明顯的:開發者需要寫更多附加的樣板代碼,并且留下更多需要我們維護的代碼。
與 Redux 相似的,另一個狀態管理方案是 MobX:
圖四:Mobx Flow
相比 Redux 的強規則約定,MobX 更簡單靈活,核心原理是通過 action 觸發 state 的變化,進而觸發 state 的衍生對象(Computed value & Reactions)。開發者只需要定義需要 Observe 的數據和由此衍生的數據(Computed value)或者操作 (Reactions),剩下的更新就交給 MobX 去做就可以了。一句話總結就是:
任何源自應用狀態的東西都應該自動地獲得。
分析閑魚的業務特色并不存在5個以上同學同時維護一個項目的超大型需求,強約定的redux對我們來說收益有限,而MobX 確實比 Redux 上手更容易些,并且不需要寫很多樣板代碼,可以提供更高效的選擇。
實現
我們給框架取名:Linke,來自switch的游戲塞爾達,希望它能像林克一樣點亮一個個神廟。
基于上面的分析思路結合實際業務中的技術體系(Rax)最后我們設計了下面的研發體系:UI部分也就是View還是沿用原有的Rax,UI用到的狀態也直接在View中管理。業務邏輯部分也就是Store用Mobx的能力解決上面提到的現有hooks開發遇到的問題,兩者沒有強關聯。
Linke做為中間耦合層對他們進行約束和橋接。
圖五:基于Linke的研發體系
API
為保證開發者最低的學習成本,Linke在設計時盡可能地減少API,最終只有一個方法和4個Store內置方法,詳見:
observer(baseComponent, Store)
保證組件能響應store中的可觀察對象(observable)變更,即store更新,組件視圖響應式更新
Store內置方法
•成員方法 - $$set: 所有狀態變化必須通過$$set來完成,與微信的setData類似
•成員方法 - $$setProps:處理外部傳入的組件props,View初始化或者props發生變化時調用
•成員方法 - $$didMount:提供View的生命周期,View被插入DOM時調用
•成員方法 - $$unMount:提供View的生命周期,View被移除DOM時調用 可以看出Store內置方法中除了$$set其他三個都是生命周期方法,其調用順序為:$$setProps -> $$didMount -> $$unMount
demo
Interface.ts
import { IBase } from '@ali/idlefish-linke';
export interface IComponentProps { // 組件所需props
tabs: string;
onTabChange?(i: number): void;
}
export interface IComponent extends IBase { // 連接view和store的state&交互方法
readonly items: any;
handleLoadmore: void;
}
index.tsx
import { observer } from "@ali/idlefish-linke";
import Store from './store';
import { IComponent, IComponentProps } from './interface';
function Component({items, handleLoadmore}: IComponent) {
return (
<View>
{
items.map(item => {
return <Text>{item.title}</Text>
})
}
<View onClick={handleLoadmore}>load more</View>
</View>
)
}
export default observer<IComponentProps>(Component, Store);
store.ts
import { makeAutoObservable } from "@ali/idlefish-linke";
import { IComponent } from './interface';
export default class ComponentStore implements IComponent {
/**
* 所有狀態變化必須通過$$set來觸發Effect
* $$set賦值來自于makeAutoObservable(this);
* this.$$set('items', [])
*/
$$set;
/**
* 帶初始值的屬性會自動被觀測
*/
items: any = ;
page: 1;
constructor {
// 自動observable該類
makeAutoObservable(this);
}
$$setProps(props) {
... // 對props的處理可以放到這里
}
$$didMount { // 通過 $$didMount / $$unMount 來感知view的生命周期
this.fetch;
}
fetch {
mtop.request('mtop.xxx', {page})
.then(d => {
this.$$set('items', d.list);
})
}
handleLoadmore = => {
this.$$set('page', this.page++);
this.fetch;
}
}
上面就是一個完整的組件demo。
對比
現在的組件開發模塊模式如下圖六所示,以組件為單位所有的邏輯是耦合在一起的,相互之間沒有分界,即便是相同的樣式也很難實現復用。無論是在代碼理解還是二次開發上都存在較大的成本和不穩定性風險。
圖六:原組件的開發模式
基于Linke的組件開發模式如下圖所示:
圖七:基于Linke的組件開發模式
View和Store相對獨立沒有強耦合性,這樣的好處顯而易見:
• 通過閱讀Interface就能知道Store/View的基本邏輯,減少理解成本
• 數據邏輯和View邏輯分別在Store和View中管理,真正實現各司其職,減少維護成本。
• 最重要的一點是通過分離讓Store和View分別實現了復用,組合不同的Store/View生成不同的組件
圖八:Store分別和不同的View組合
圖九:不同的Store和同一個View組合
應用
目前Linke已經應用在了閑魚前端各個新項目中,包括2個線上項目和3個正在開發的項目收益明顯,什么功能的代碼在什么位置一目了然配合Interface中的注釋大大減少了接手項目的理解成本。
通用基礎組件和業務組件都在有序的抽離中,同時隨著View/Store庫的不斷豐富,可以復用的物料資源增加,不同業務和同一業務不同場景中可以復用的View/Store越來越多,在一定程度上大大減少開發成本提高效率。
展望
目前新財年除了現有的H5業務外,最大的特點是會對各個小程序做一些流量探索,比如淘系輕應用、微信小程序、支付寶輕應用等,這些應用的特點是與端內的H5業務及其相似,但是會有各自的細微差異。所以我們也在探索基于Linke對此類業務場景的提效。