前端源码架构在拍卖详情页上的探索
关注并将「趣谈前端」设为星标
每早08:30按时推送技术干货/优秀开源/技术思维
前言
一个系统能有一个非常好的架构设计是非常重要的。但是仅仅对于前端项目页面,其实很难把「架构」一词搬出来聊个天花乱坠。
但是!好的代码结构的组织的确能够避免一些不必要的采坑。当然,这其中也不乏对前端工程师的工程师素养约束。
一言以蔽之,对于前端项目的架构(代码组织)而言,「好」,好不到哪里去。但是「坏」,却可以令人头皮发麻。
当然。。。我还是在尽可能的希望好~这也是这篇文章的目的所在。此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流~
拍卖详情页
❝图上的点我会在下文中挨个介绍
❞
特点
「稳定性要求极高」 (这一点区分手淘和天猫,毕竟拍卖...你品) 需要详细的日志打点 模块之间的通信非常多(拍品状态、倒计时、出价等)
对于手淘和天猫的商品,一般都是多个人对多个物品。即使出了问题,也不影响购买,大不了问题修复再购买(最坏的情况)。
但是对于拍卖的拍品。对多对一、价高者得的属性。并且具有一定的法律效应。所以稳定性的要求极其之高。同时拍卖又具有非常高时效性要求,所以 apush、轮询啥的都要求实时更新拍品的状态。
综合以上因素的考虑。最终我们没有选择大黄蜂搭建页面的形式构建起详情页。就先走了源码链路的开发。「至于后续是否会推进落地,可能还有待商榷」。
整体架构
如果你阅读过上一篇文章一张页面引起的前端架构思考,那么可能会对接下来要介绍的目录组织结构比较熟悉。下面简单介绍下改动的部分以及添加的一些东西。
项目级别
目录的职责划分在之前的一篇文章中已经都介绍到了。这里就说下目前的一些改动点:
新增 count-dow
新增 loop
移除 EVENTS
Count-down
和 loop
都是详情页强相关的,但是由于项目名称为 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
,但是却没有周边的“原生生态”: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);
}
}
}
非常的简单~
然后截止目前编写了两个中间件:
日志打点中间件 dispatch 多个 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 的编码应该就塑造的类型思维的一部分」 ,毕竟不是介绍 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 搞~然后给在这个项目中开发的同学提供的一些职业道德约束。当然,程序猿的职业素养也都是不可靠的。所以后续考虑用脚本强制起来~
充分使用 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
,上文已经介绍到了。第二步:在 detail
的reducer
中注入进去。
3、模块编写与配置
模块的编写与配置也分为两步:
第一步:在 componets
目录下新建对应模块,编码在 componets/config.ts
中注入
虽然新增一个步骤大致有些繁琐。但是也都中规中矩。每一步分为「本身模块的编写」以及「提供给你的注入方式」。
TODO
如上所介绍,再结合之前写的前端架构文章,基本上感觉介绍的差不多了。其实前端架构感觉应该换个名字:目录组织。
而搭建的这套组织形式造成的约束其实也是为了「提供更好的稳定性保障」和「代码的充分解耦」。
现在做的远远不够:
项目脚手架 自动化测试 编码规则静态检查 状态可视化 性能优化 代码覆盖率 ...
最后,还是那句话,此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流~
学习交流
关注公众号【趣谈前端】,每日获取好文推荐 添加微信号:Mr_xuxiaoxi(备注来源) ,入群交流
个人微信 | 公众号【趣谈前端】 |
---|---|
❤️ 看完三件事
如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:
点个【在看】,或者分享转发,让更多的人也能看到这篇内容
关注公众号【趣谈前端】,不定期分享 前端工程化 / 可视化 / 低代码 等技术文章。