拍賣源碼架構在拍品詳情頁上的探索

前言

原文地址:github/Nealyang前端

沒有想到以前寫的一篇一張頁面引發的前端架構思考還收到很多同窗關注。的確,正如以前在羣裏所說,一個系統能有一個很是好的架構設計。可是僅僅對於前端項目頁面,其實很難把架構一詞搬出來聊個天花亂墜。react

可是!好的代碼結構的組織的確可以避免一些沒必要要的採坑。固然,這其中也不乏對前端工程師的工程師素養約束。git

一言以蔽之,對於前端項目的架構(代碼組織)而言,,好不到哪裏去。可是,卻能夠使人頭皮發麻。github

固然。。。我仍是在儘量的但願好~這也是這篇文章的目的所在。此處權且拋個磚,若是你有更好的看法和想法,歡迎隨時交流~web

拍賣詳情頁

詳情頁
詳情頁

圖上的點我會在下文中挨個介紹redux

架構設計圖
架構設計圖

特色

  • 穩定性要求極高 (這一點區分手淘和天貓,畢竟 拍賣...你品)
  • 須要詳細的日誌打點
  • 模塊之間的通訊很是多(拍品狀態、倒計時、出價等)

對於手淘和天貓的商品,通常都是多我的對多個物品。即便出了問題,也不影響購買,大不了問題修復再購買(最壞的狀況)。api

可是對於拍賣的拍品。對多對1、價高者得的屬性。而且具備必定的法律效應。因此穩定性的要求極其之高。同時拍賣又具備很是高時效性要求,因此 apush、輪詢啥的都要求實時更新拍品的狀態。安全

綜合以上因素的考慮。最終咱們沒有選擇大黃蜂搭建頁面的形式構建起詳情頁。就先走了源碼鏈路的開發。至於後續是否會推動落地,可能還有待商榷性能優化

總體架構

若是你閱讀過上一篇文章一張頁面引發的前端架構思考,那麼可能會對接下來要介紹的目錄組織結構比較熟悉。下面簡單介紹下改動的部分以及添加的一些東西。微信

項目級別

目錄的職責劃分在以前的一篇文章中已經都介紹到了。這裏就說下目前的一些改動點:

  • 新增 count-dow
  • 新增 loop
  • 移除 EVENTS

Count-downloop 都是詳情頁強相關的,可是因爲項目名稱爲 pm-detail 因此,這裏就提到 pages 之外的了。其實提不提的原則很簡單。該文件是否可(需)共用

也是秉持着上面的原則,將 EVENTS 文件夾修改到頁面容器裏面了。畢竟,跨頁面的廣播需求基本是不存在的。

關於頁面容器的介紹,也在以前的一篇《Decorator+TS裝飾你的代碼》一文中介紹到。這裏也不贅述了。

count-down 的簡單抽離

倒計時的「遞歸」交給 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();  } 複製代碼

具體的倒計時和輪詢的編寫會在下一篇文章中介紹(內網)

count-down 的內部消費

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 (強關聯業務)裏面。

pages/detail

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 類型的約束

下面按個展開介紹

狀態管理 useRedux

由於詳情頁的狀態管理較爲複雜,模塊之間的通訊也是很是頻繁。因此這裏咱們須要引入 redux 做爲狀態管理。

雖然 hooks 裏面已經提供了 useReducer ,可是卻沒有周邊的「原生生態」: combineReducersMiddleware 等。因此咱們將輪子搬一下,取名爲:useRedux

關於 redux 的介紹可見:《從 redux 中搬個輪子給源碼項目作狀態管理》

這裏重點介紹在這個項目中的使用契約:

基本使用

浪浪額夠的時候寫過一篇文章react技術棧項目結構探究 ,那時候我就很是喜歡將 redux 中的 initStateactionTypesactions以及 reducer 定義到一個文件中,的確很是的清晰方便。因此這裏 reducers 文件夾也是如此。

每個文件,對應每個功能區域的 reducer

而 reducer 內部的組成,基本都是以下:

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);  }  } } 複製代碼

很是的簡單~

而後截止目前編寫了兩個中間件:

  • 日誌打點中間件
  • dispatch 多個 action 中間件

上面的日誌打點中間件可能後期會修改。理論上日誌的打點不該該都會改變 state,因此是否須要爲 ActionLog 提供單獨的 reducer,以及提供後如何無縫的銜接,後面作到的時候可能還須要再思考下

模塊數據分發

所謂的模塊分發,存在的緣由是:目前咱們的詳情頁是有不少種不一樣的業務類型的,單純的從大資產而言,就分爲資產和司法、再分爲變賣和拍賣、再有不一樣類的拍品之區分。也就是說,完整的詳情頁會有不少的模塊,也就是說打開的某一個詳情頁,並不須要加載全部的模塊。這也是爲何下文會有按需加載的 緣由。

那麼對於數據,咱們固然須要根據接口返回的字段,來組織咱們的 state 中咱們要開發的 component

這裏,咱們在頁面級別的自定義 hooks 文件夾的use-data-init.ts 中操刀。

useDataInit
useDataInit
  • formatCountDownData 是由對應的模塊提供的 format 方法。在接口返回的字段須要進行加工的時候須要
  • 此處做爲頁面級別的 dataInit理論上應該是最全的數據處理狀況
format func return
format func return

按需加載

如上所說,不一樣頁面須要不一樣的模塊,目前詳情頁還未打算接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')  } ]; 複製代碼

keyNameitemDo 中對應接口模塊的 key 的名字。這裏咱們用的 ts 來檢查的。

類型約束
類型約束

因此理論上,後續的開發者,新增模塊、修改模塊,都不該該會修改到index.tsx 這個入口文件

Ts 狀態約束

類型約束實際上是 TS 的編碼應該就塑造的類型思惟的一部分 ,畢竟不是介紹 Ts,因此這裏主要說下新增模塊如何作到類型約束的。

這一塊,可能解釋起來稍微有點煩

先說下咱們的目的是什麼:

如上,咱們須要在模塊 config的配置中讀取到組件,而且state 中對應的模塊數據注入給這個模塊。重點咱們仍是要根據這個 keyName 來進行按需加載的判斷。因此我須要你填寫的 keyName 必須是你本身組織(combineReducers)出來 state 對應模塊的 key

最終的效果就如上面的截圖,編碼的時候會提醒你,可以填寫哪些字段。那麼這個約束是如何造成的呢?

如圖,首先咱們須要將 combineReducersstate 經過 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就是咱們須要拿去約束每個組件的 reducerdetail.reducer 中彙總出來的state

export const detailReducer = combineReducers<ICombineItemDo>({
 countDown,  loop,  detailCommon: globalStateReducer, }); 複製代碼

當咱們 key 寫錯了之後,Ts 會幫咱們檢查出來:

當這個 type 已經拆分重組成咱們想要的了時候,那麼咱們只須要將 config keyName 約束成 itemDocomponets 的某一個 key 便可。

type IComConfigItem<T> = {
 keyName: keyof IItemComponent;  importFunc: () => Promise<T> } 複製代碼

開發契約

所謂的開發契約其實就是你不要瞎 xx 搞~而後給在這個項目中開發的同窗提供的一些職業道德約束。固然,程序猿的職業素養也都是不可靠的。因此後續考慮用腳本強制起來~

  • 充分使用 TS 註釋即文檔的功能,每個方法、屬性、都須要編寫對應註釋
  • 模塊界限清晰,業務邏輯邊界分明。不要將非此模塊的代碼寫到公共場所裏面。
  • 編寫對應 function 的單元測試(有點難)
  • any 大法好,可是不安全

新增模塊步驟

上面的契約其實有些泛泛而談,不如實操來的痛快。下面咱們經過舉例說明在這個架構下,新增一個模塊須要的步驟吧。

一、新增類型

新增數據類型必定是第一步!!! 避免一些低級錯誤的發生。同時,不是第一步的話。。。你後面的步驟編輯器都會報錯的。

拿倒計時舉例:

  • 第一步在 types/count-down.d.ts 中編寫對應模塊的 類型約束
  • 第二步,在 types/item-dao.d.ts 中注入
/**
 * 標的模塊數據  */ export interface IItemComponent { + /** + * 倒計時模塊 + */ + countDown?: IFormattedCountDown;  /**  * 倒計時模塊  */  loop?: IGetLoopInfo } 複製代碼

最好呢,在 type/index.d.ts 中,統一導出。避免模塊引入太多依賴而看起來嚇唬人

二、reducer

編寫 reducer 也分爲兩步:

  • 第一步:編寫對應 reducer,上文已經介紹到了。
  • 第二步:在 detailreducer 中注入進去。

三、模塊編寫與配置

模塊的編寫與配置也分爲兩步:

  • 第一步:在 componets 目錄下新建對應模塊,編碼
  • componets/config.ts中注入

雖然新增一個步驟大體有些繁瑣。可是也都中規中矩。每一步分爲自己模塊的編寫以及提供給你的注入方式

TODO

如上所介紹,再結合以前寫的前端架構文章,基本上感受介紹的差很少了。其實前端架構感受應該換個名字:目錄組織。

而搭建的這套組織形式形成的約束其實也是爲了提供更好的穩定性保障代碼的充分解耦

如今作的遠遠不夠:

  • 項目腳手架
  • 自動化測試
  • 編碼規則靜態檢查
  • 狀態可視化
  • 性能優化
  • 代碼覆蓋率
  • ...

最後,仍是那句話,此處權且拋個磚,若是你有更好的看法和想法,歡迎隨時交流~

學習交流

  • 關注公衆號【全棧前端精選】,每日獲取好文推薦
  • 添加微信號: is_Nealyang(備註來源) ,入羣交流
公衆號【全棧前端精選】 我的微信【is_Nealyang】
相關文章
相關標籤/搜索