Skip to content

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

Description

@Nealyang

前言

没有想到之前写的一篇一张页面引起的前端架构思考还收到不少同学关注。的确,正如之前在群里所说,一个系统能有一个非常好的架构设计。但是仅仅对于前端项目页面,其实很难把架构一词搬出来聊个天花乱坠。

但是!好的代码结构的组织的确能够避免一些不必要的采坑。当然,这其中也不乏对前端工程师的工程师素养约束。

一言以蔽之,对于前端项目的架构(代码组织)而言,,好不到哪里去。但是,却可以令人头皮发麻。

当然。。。我还是在尽可能的希望好这也是这篇文章的目的所在。此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流

拍卖详情页

详情页

图上的点我会在下文中挨个介绍

架构设计图

特点

  • 稳定性要求极高 (这一点区分手淘和天猫,毕竟拍卖...你品)
  • 需要详细的日志打点
  • 模块之间的通信非常多(拍品状态、倒计时、出价等)

对于手淘和天猫的商品,一般都是多个人对多个物品。即使出了问题,也不影响购买,大不了问题修复再购买(最坏的情况)。

但是对于拍卖的拍品。对多对一、价高者得的属性。并且具有一定的法律效应。所以稳定性的要求极其之高。同时拍卖又具有非常高时效性要求,所以 apush、轮询啥的都要求实时更新拍品的状态。

综合以上因素的考虑。最终我们没有选择大黄蜂搭建页面的形式构建起详情页。就先走了源码链路的开发。至于后续是否会推进落地,可能还有待商榷

整体架构

如果你阅读过上一篇文章一张页面引起的前端架构思考,那么可能会对接下来要介绍的目录组织结构比较熟悉。下面简单介绍下改动的部分以及添加的一些东西。

项目级别

目录的职责划分在之前的一篇文章中已经都介绍到了。这里就说下目前的一些改动点:

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

Count-downloop 都是详情页强相关的,但是由于项目名称为 pm-detail 所以,这里就提到 pages 以外的了。其实提不提的原则很简单。该文件是否可(需)共用

也是秉持着上面的原则,将 EVENTS 文件夹修改到页面容器里面了。毕竟,跨页面的广播需求基本是不存在的。

关于页面容器的介绍,也在之前的一篇《Decorator+TS装饰你的代码》一文中介绍到。这里也不赘述了。

count-down 的简单抽离

倒计时的“递归”交给 RAF 搞定。当然,这里是CountDown上的一个方法。

/** * 开启倒计时 */start(){letthat=this;functionrafCallback(){that.time-=newDate().getTime()-that.lastTime;that.lastTime=newDate().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 的内部消费

exportconstuseInitCountDown=(countDownData: IFormattedCountDown,countEndCallback: ()=>any)=>{letcountDownRef=useRef(null)asany;const[leftTime,setFormattedTime]=useState(countDownData.leftSwitchTime);useEffect(()=>{if(countDownData.countDownSwitch){// 开启显示倒计时countDownRef.current=startCountDown(leftTime,setFormattedTime,countEndCallback);}elseif(countDownData.implicitCountDownSwitch){// 开启隐藏倒计时countDownRef.current=startImplicitCountDown(leftTime,countEndCallback,(err)=>{console.log(err);});}},[]);useEffect(()=>{countDownRef.current?.setTime(countDownData.leftSwitchTime);},[countDownData.leftSwitchTime])returnleftTime;};

具体的代码就不解释了,涉及到太多的业务。后面单独写一篇记录

消费端是在 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 需要插到 detail 里面:

exportconstdetailReducer=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

exportconstuseRedux=(reducer: Reducer, ...middleWares: Function[])=>{const[state,dispatch]=useReducer(reducer,{});letnewDispatch;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 */exportconstreduxMultiAction=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

  • formatCountDownData 是由对应的模块提供的 format 方法。在接口返回的字段需要进行加工的时候需要
  • 此处作为页面级别的 dataInit理论上应该是最全的数据处理情况

format func return

按需加载

如上所说,不同页面需要不同的模块,目前详情页还未打算接SSR 以及由于组件频繁通信和稳定性要求不能走搭建,所以目前只能通过 codeSpliting 来进行代码分割的按需加载。

是的,通过 useImport

由于是自定义 hooks,所以这里我们不能够通过判断来加载模块不能判断,我怎么知道 if 需要?

事实的确如此。所以我们需要一个容器,来让容器去走判断逻辑~

interfaceIWrapperProps{/** * 动态导入的模块 eg:()=>import('xxx') */path:()=>void;/** * 导入的模块所对应的 itemDo 中模块的数据 */dataSource:{[key:string]:any};/** * 详情通用字段 */detailCommon:IDetailCommon;[key: string]: any}/** * 按需按需加载容器组件 * * @export * @param {*} props 按需加载的组件 props+path * @returns 需按需加载的子组件 */exportdefaultfunction(props:IWrapperProps){const{ path, ...otherProps}=props;const[Com,error]=useImport(path);if(Com){return<Com{...otherProps}/>;}elseif(error){console.log(error);returnnull;}else{returnnull;}}

可以看到,我会将 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

typeIComConfigItem<T>={keyName: keyofIItemComponent;importFunc: ()=>Promise<T>}/** * 模块的导出配置,用于模块按需加载 */exportconstcomConfig: 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 字段的约束

/** * 标的模块数据 */exportinterfaceIItemComponent{/** * 倒计时模块 */countDown?: IFormattedCountDown;/** * 倒计时模块 */loop?: IGetLoopInfo}/** * 详情页通用字段 */exportinterfaceIDetailCommon{/** * 标的 id */itemId?: string;/** * 标的类型 */itemType?: string;}/** * detailReducer 返回类型 */exportinterfaceICombineItemDoextendsIItemComponent{detailCommon:IDetailCommon}

如上的ICombineItemDo就是我们需要拿去约束每一个组件的 reducerdetail.reducer 中汇总出来的state

exportconstdetailReducer=combineReducers<ICombineItemDo>({
countDown,
loop,detailCommon: globalStateReducer,});

当我们 key 写错了以后,Ts 会帮我们检查出来:

当这个 type 已经拆分重组成我们想要的了时候,那么我们只需要将 configkeyName 约束成 itemDocomponets 的某一个 key 即可。

typeIComConfigItem<T>={keyName: keyofIItemComponent;importFunc: ()=>Promise<T>}

开发契约

所谓的开发契约其实就是你不要瞎 xx 搞然后给在这个项目中开发的同学提供的一些职业道德约束。当然,程序猿的职业素养也都是不可靠的。所以后续考虑用脚本强制起来

  • 充分使用 TS 注释即文档的功能,每一个方法、属性、都需要编写对应注释
  • 模块界限清晰,业务逻辑边界分明。不要将非此模块的代码写到公共场所里面。
  • 编写对应 function 的单元测试(有点难)
  • any 大法好,但是不安全

新增模块步骤

上面的契约其实有些泛泛而谈,不如实操来的痛快。下面我们通过举例说明在这个架构下,新增一个模块需要的步骤吧。

1、新增类型

新增数据类型一定是第一步!!! 避免一些低级错误的发生。同时,不是第一步的话。。。你后面的步骤编辑器都会报错的。

拿倒计时举例:

  • 第一步在types/count-down.d.ts 中编写对应模块的类型约束

  • 第二步,在 types/item-dao.d.ts 中注入
/**
* 标的模块数据
*/
export interface IItemComponent {
+ /**+ * 倒计时模块+ */+ countDown?: IFormattedCountDown;
/**
* 倒计时模块
*/
loop?: IGetLoopInfo
}

最好呢,在 type/index.d.ts 中,统一导出。避免模块引入太多依赖而看起来吓唬人

2、reducer

编写 reducer 也分为两步:

  • 第一步:编写对应 reducer,上文已经介绍到了。
  • 第二步:在detailreducer 中注入进去。

3、模块编写与配置

模块的编写与配置也分为两步:

  • 第一步:在 componets 目录下新建对应模块,编码
  • componets/config.ts中注入

虽然新增一个步骤大致有些繁琐。但是也都中规中矩。每一步分为本身模块的编写以及提供给你的注入方式

TODO

如上所介绍,再结合之前写的前端架构文章,基本上感觉介绍的差不多了。其实前端架构感觉应该换个名字:目录组织。

而搭建的这套组织形式造成的约束其实也是为了提供更好的稳定性保障代码的充分解耦

现在做的远远不够:

  • 项目脚手架
  • 自动化测试
  • 编码规则静态检查
  • 状态可视化
  • 性能优化
  • 代码覆盖率
  • ...

最后,还是那句话,此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流~

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions