原文地址:github/Nealyang前端
沒有想到以前寫的一篇一張頁面引發的前端架構思考還收到很多同窗關注。的確,正如以前在羣裏所說,一個系統能有一個很是好的架構設計。可是僅僅對於前端項目頁面,其實很難把架構一詞搬出來聊個天花亂墜。react
可是!好的代碼結構的組織的確可以避免一些沒必要要的採坑。固然,這其中也不乏對前端工程師的工程師素養約束。git
一言以蔽之,對於前端項目的架構(代碼組織)而言,好,好不到哪裏去。可是壞,卻能夠使人頭皮發麻。github
固然。。。我仍是在儘量的但願好~這也是這篇文章的目的所在。此處權且拋個磚,若是你有更好的看法和想法,歡迎隨時交流~web
圖上的點我會在下文中挨個介紹redux
對於手淘和天貓的商品,通常都是多我的對多個物品。即便出了問題,也不影響購買,大不了問題修復再購買(最壞的狀況)。api
可是對於拍賣的拍品。對多對1、價高者得的屬性。而且具備必定的法律效應。因此穩定性的要求極其之高。同時拍賣又具備很是高時效性要求,因此 apush、輪詢啥的都要求實時更新拍品的狀態。安全
綜合以上因素的考慮。最終咱們沒有選擇大黃蜂搭建頁面的形式構建起詳情頁。就先走了源碼鏈路的開發。至於後續是否會推動落地,可能還有待商榷。性能優化
若是你閱讀過上一篇文章一張頁面引發的前端架構思考,那麼可能會對接下來要介紹的目錄組織結構比較熟悉。下面簡單介紹下改動的部分以及添加的一些東西。微信
目錄的職責劃分在以前的一篇文章中已經都介紹到了。這裏就說下目前的一些改動點:
count-dow
loop
EVENTS
Count-down
和 loop
都是詳情頁強相關的,可是因爲項目名稱爲 pm-detail
因此,這裏就提到 pages
之外的了。其實提不提的原則很簡單。該文件是否可(需)共用
也是秉持着上面的原則,將 EVENTS
文件夾修改到頁面容器裏面了。畢竟,跨頁面的廣播需求基本是不存在的。
關於頁面容器的介紹,也在以前的一篇《Decorator+TS裝飾你的代碼》一文中介紹到。這裏也不贅述了。
倒計時的「遞歸」交給 RAF
搞定。固然,這裏是CountDown
上的一個方法。
/** * 開啓倒計時 */ start() { let that = this; function rafCallback() { that.time -= new Date().getTime() - that.lastTime; that.lastTime = new Date().getTime(); if (that.time < 0) { that.time = 0; } that.updateCallback(that.time); that.countDownRaf = window.requestAnimationFrame(rafCallback); if (that.time <= 0) { window.cancelAnimationFrame(that.countDownRaf); if (that.endCallback) { that.endCallback(); } } } rafCallback(); } 複製代碼
具體的倒計時和輪詢的編寫會在下一篇文章中介紹(內網)
export const useInitCountDown = (
countDownData: IFormattedCountDown, countEndCallback: () => any ) => { let countDownRef = useRef(null) as any; const [leftTime, setFormattedTime] = useState(countDownData.leftSwitchTime); useEffect(() => { if (countDownData.countDownSwitch) { // 開啓顯示倒計時 countDownRef.current = startCountDown( leftTime, setFormattedTime, countEndCallback ) ; } else if (countDownData.implicitCountDownSwitch) { // 開啓隱藏倒計時 countDownRef.current = startImplicitCountDown( leftTime, countEndCallback, (err) => { console.log(err); } ); } }, []); useEffect(()=>{ countDownRef.current?.setTime(countDownData.leftSwitchTime); },[countDownData.leftSwitchTime]) return leftTime; }; 複製代碼
具體的代碼就不解釋了,涉及到太多的業務。後面單獨寫一篇記錄
消費端
是在 pages/detial/count-down/customized-hooks/use-init-count-down.ts
(強關聯業務)裏面。
detail ├─ components // 頁面級別的 componets │ ├─ bottom-action // 底部按鈕模塊 │ │ ├─ index.less │ │ └─ index.tsx │ ├─ config.ts // 模塊的配置文件 │ ├─ count-down // 倒計時模塊 │ │ ├─ customized-hooks // 倒計時模塊的自定義 hooks │ │ ├─ index.less │ │ ├─ index.tsx │ │ └─ utils // 倒計時模塊 │ └─ loop // 倒計時模塊 │ └─ index.tsx ├─ constants // 頁面級別的常量定義 │ ├─ api.ts │ ├─ common.ts │ └─ spm.ts ├─ customized-hooks // 頁面級別的自定義 hooks │ └─ use-data-init.ts ├─ index.less ├─ index.tsx // 頁面的入口文件 ├─ reducers // reducer 目錄(文件組織關聯到 state 的設計) │ ├─ count-down.reducer.ts // count-down 模塊對應的 reducer │ ├─ detail.reducer.ts // 彙總全部的組件的 reducer 到 detail 裏面,而且包含一個公共的狀態 │ ├─ index.ts // 整個頁面的state │ └─ loop.reducer.ts // 對應 ├─ redux-middleware // redux 的中間件 │ ├─ redux-action-log // actionLog 中間件 │ │ └─ index.ts │ └─ redux-mutli-action // 支持發送多個 action 的中間件 │ └─ index.ts ├─ types // 數據類型統必定義 │ ├─ count-down.d.ts │ ├─ index.d.ts │ ├─ item-dao.d.ts │ ├─ loop.d.ts │ └─ reducer-types.d.ts ├─ use-redux // 頁面的狀態管理 │ ├─ combineReducers.ts │ ├─ compose.ts │ ├─ redux.ts │ ├─ types │ │ ├─ actions.d.ts │ │ └─ reducers.d.ts │ └─ utils │ ├─ actionTypes.ts │ └─ warning.ts └─ utils // 頁面的工具函數 ├─ demand-load-wrapper.tsx // 按需加載容器 └─ index.ts // 工具函數 複製代碼
關於文件和目錄的說明都寫在了上面的註釋中。對於後續的開發者須要重點關注的是:
components
(包括
config
)模塊的組織
reducer
狀態的組織
type
類型的約束
下面按個展開介紹
由於詳情頁的狀態管理較爲複雜,模塊之間的通訊也是很是頻繁。因此這裏咱們須要引入 redux
做爲狀態管理。
雖然 hooks 裏面已經提供了 useReducer
,可是卻沒有周邊的「原生生態」: combineReducers
、Middleware
等。因此咱們將輪子搬一下,取名爲:useRedux
關於 redux 的介紹可見:《從 redux 中搬個輪子給源碼項目作狀態管理》
這裏重點介紹在這個項目中的使用契約:
浪浪額夠的時候寫過一篇文章react技術棧項目結構探究 ,那時候我就很是喜歡將 redux
中的 initState
、actionTypes
、actions
以及 reducer
定義到一個文件中,的確很是的清晰方便。因此這裏 reducers
文件夾也是如此。
每個文件,對應每個功能區域的 reducer
而 reducer 內部的組成,基本都是以下:
以上是模塊的 reducer,對於開發者還須要知道的是模塊的 reducer 須要插到 detail 裏面:
export const detailReducer = combineReducers<ICombineItemDo>({
countDown, loop, detailCommon: globalStateReducer, }); 複製代碼
ICombineItemDo
會在下文的 Ts 狀態約束裏面介紹
因此如上的代碼組成的最終頁面 state 是以下結構
{ pageState:{ isLoading:boolean }, itemDo:{ countDown:ICountDown, detailCommon:IDetailCommon, loop:ILoop } } 複製代碼
itemDo
其實應該命名爲itemDao
可是因爲itemDo
咱們用了五年了。。。尊重習慣的力量,避免沒必要要的麻煩
雖然使用了中間件,可是跟 redux
仍是有些不一樣的。具體的 applyMiddleware
就不說了,其實就是compose
func 而後加強下 dispatch
export const useRedux = (reducer: Reducer, ...middleWares: Function[]) => {
const [state, dispatch] = useReducer(reducer, {}); let newDispatch; if (middleWares.length > 0) { newDispatch = compose(...middleWares)(dispatch); } useEffect(() => { dispatch({ type: ActionTypes.INIT }); }, []); return { state, dispatch: newDispatch } } 複製代碼
因此這裏的中間件都是根據當前 dispatch 的 action 裏面的 data 來執行相關操做的。
好比 redux-mutli-action
中間件
/** * 支持 dispatch 多個 action dispatch([action1,action2,action3]) * @param next dispatch */ export const reduxMultiAction = next => action => { if(action){ if (Array.isArray(action)) { action.map((item) => next(item)) } else { next(action); } } } 複製代碼
很是的簡單~
而後截止目前編寫了兩個中間件:
上面的日誌打點中間件可能後期會修改。理論上日誌的打點不該該都會改變 state,因此是否須要爲 ActionLog 提供單獨的 reducer,以及提供後如何無縫的銜接,後面作到的時候可能還須要再思考下
所謂的模塊分發,存在的緣由是:目前咱們的詳情頁是有不少種不一樣的業務類型的,單純的從大資產而言,就分爲資產和司法、再分爲變賣和拍賣、再有不一樣類的拍品之區分。也就是說,完整的詳情頁會有不少的模塊,也就是說打開的某一個詳情頁,並不須要加載全部的模塊。這也是爲何下文會有按需加載的 緣由。
那麼對於數據,咱們固然須要根據接口返回的字段,來組織咱們的 state
中咱們要開發的 component
這裏,咱們在頁面級別的自定義 hooks
文件夾的use-data-init.ts
中操刀。
formatCountDownData
是由對應的模塊提供的
format
方法。在接口返回的字段須要進行加工的時候須要
dataInit
,
理論上應該是最全的數據處理狀況
如上所說,不一樣頁面須要不一樣的模塊,目前詳情頁還未打算接SSR
以及因爲組件頻繁通訊和穩定性要求不能走搭建,因此目前只能經過 codeSpliting
來進行代碼分割的按需加載。
是的,經過 useImport
因爲是自定義 hooks,因此這裏咱們不可以經過判斷來加載模塊。 不能判斷,我怎麼知道 if 須要?
事實的確如此。因此咱們須要一個容器,來讓容器去走判斷邏輯~
interface IWrapperProps{
/** * 動態導入的模塊 eg:()=>import('xxx') */ path:()=>void; /** * 導入的模塊所對應的 itemDo 中模塊的數據 */ dataSource:{[key:string]:any}; /** * 詳情通用字段 */ detailCommon:IDetailCommon; [key: string]: any } /** * 按需按需加載容器組件 * * @export * @param {*} props 按需加載的組件 props+path * @returns 需按需加載的子組件 */ export default function(props:IWrapperProps) { const { path, ...otherProps } = props; const [Com, error] = useImport(path); if (Com) { return <Com {...otherProps} />; } else if (error) { console.log(error); return null; } else { return null; } } 複製代碼
能夠看到,我會將 DataSource
:當前模塊數據、以及 detailCommon
:通用字段 傳遞給須要加載的模塊中。
而後在 index
中,經過接口是否有該模塊字段去判斷是否加載:
const renderCom = (componentConfigArr, itemDo, dispatch) => {
return componentConfigArr.map((item, index) => (
<StoreContext.Provider value={{ itemDo, dispatch }} key={index + 1}>
<DemandLoadWrapper
x-if={objHasKeys(itemDo[item.keyName])}
path={item.importFunc}
dataSource={itemDo[item.keyName]}
detailCommon={itemDo?.detailCommon}
/>
</StoreContext.Provider>
));
};
複製代碼
componentConfigArr
來自咱們組件 componets/config.ts
type IComConfigItem<T> = {
keyName: keyof IItemComponent; importFunc: () => Promise<T> } /** * 模塊的導出配置,用於模塊按需加載 */ export const comConfig: IComConfigItem<Rax.RaxNode>[] = [ { keyName: 'countDown', importFunc: () => import('./count-down') }, { keyName: "loop", importFunc: () => import('./loop') } ]; 複製代碼
keyName
是 itemDo
中對應接口模塊的 key
的名字。這裏咱們用的 ts
來檢查的。
因此理論上,後續的開發者,新增模塊、修改模塊,都不該該會修改到index.tsx
這個入口文件
類型約束實際上是 TS 的編碼應該就塑造的類型思惟的一部分 ,畢竟不是介紹 Ts,因此這裏主要說下新增模塊如何作到類型約束的。
這一塊,可能解釋起來稍微有點煩
先說下咱們的目的是什麼:
如上,咱們須要在模塊 config
的配置中讀取到組件,而且state
中對應的模塊數據注入給這個模塊。重點咱們仍是要根據這個 keyName
來進行按需加載的判斷。因此我須要你填寫的 keyName
必須是你本身組織(combineReducers
)出來 state
對應模塊的 key
最終的效果就如上面的截圖,編碼的時候會提醒你,可以填寫哪些字段。那麼這個約束是如何造成的呢?
如圖,首先咱們須要將 combineReducers
和 state
經過 type
進行約束。當這個約束創建的時候,那麼就能夠經過這個 type
來進行 config
字段的約束
/** * 標的模塊數據 */ export interface IItemComponent { /** * 倒計時模塊 */ countDown?: IFormattedCountDown; /** * 倒計時模塊 */ loop?: IGetLoopInfo } /** * 詳情頁通用字段 */ export interface IDetailCommon { /** * 標的 id */ itemId?: string; /** * 標的類型 */ itemType?: string; } /** * detailReducer 返回類型 */ export interface ICombineItemDo extends IItemComponent{ detailCommon:IDetailCommon } 複製代碼
如上的ICombineItemDo
就是咱們須要拿去約束每個組件的 reducer
在detail.reducer
中彙總出來的state
export const detailReducer = combineReducers<ICombineItemDo>({
countDown, loop, detailCommon: globalStateReducer, }); 複製代碼
當咱們 key 寫錯了之後,Ts 會幫咱們檢查出來:
當這個 type
已經拆分重組成咱們想要的了時候,那麼咱們只須要將 config
keyName
約束成 itemDo
中 componets
的某一個 key 便可。
type IComConfigItem<T> = {
keyName: keyof IItemComponent; importFunc: () => Promise<T> } 複製代碼
所謂的開發契約其實就是你不要瞎 xx 搞~而後給在這個項目中開發的同窗提供的一些職業道德約束。固然,程序猿的職業素養也都是不可靠的。因此後續考慮用腳本強制起來~
上面的契約其實有些泛泛而談,不如實操來的痛快。下面咱們經過舉例說明在這個架構下,新增一個模塊須要的步驟吧。
新增數據類型必定是第一步!!! 避免一些低級錯誤的發生。同時,不是第一步的話。。。你後面的步驟編輯器都會報錯的。
拿倒計時舉例:
types/count-down.d.ts
中編寫對應模塊的
類型約束
types/item-dao.d.ts
中注入
/**
* 標的模塊數據 */ export interface IItemComponent { + /** + * 倒計時模塊 + */ + countDown?: IFormattedCountDown; /** * 倒計時模塊 */ loop?: IGetLoopInfo } 複製代碼
最好呢,在
type/index.d.ts
中,統一導出。避免模塊引入太多依賴而看起來嚇唬人
編寫 reducer
也分爲兩步:
reducer
,上文已經介紹到了。
detail
的
reducer
中注入進去。
模塊的編寫與配置也分爲兩步:
componets
目錄下新建對應模塊,編碼
componets/config.ts
中注入
雖然新增一個步驟大體有些繁瑣。可是也都中規中矩。每一步分爲自己模塊的編寫以及提供給你的注入方式。
如上所介紹,再結合以前寫的前端架構文章,基本上感受介紹的差很少了。其實前端架構感受應該換個名字:目錄組織。
而搭建的這套組織形式形成的約束其實也是爲了提供更好的穩定性保障和代碼的充分解耦。
如今作的遠遠不夠:
最後,仍是那句話,此處權且拋個磚,若是你有更好的看法和想法,歡迎隨時交流~
is_Nealyang
(備註來源) ,入羣交流
公衆號【全棧前端精選】 | 我的微信【is_Nealyang】 |
---|---|