原文地址: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】 |
---|---|
![]() |
![]() |