拍卖源码架构在拍品详情页上的探索

前言

原文地址: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】
相关文章
相关标签/搜索