We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
没有想到之前写的一篇一张页面引起的前端架构思考还收到不少同学关注。的确,正如之前在群里所说,一个系统能有一个非常好的架构设计。但是仅仅对于前端项目页面,其实很难把架构一词搬出来聊个天花乱坠。
但是!好的代码结构的组织的确能够避免一些不必要的采坑。当然,这其中也不乏对前端工程师的工程师素养约束。
一言以蔽之,对于前端项目的架构(代码组织)而言,好,好不到哪里去。但是坏,却可以令人头皮发麻。
当然。。。我还是在尽可能的希望好这也是这篇文章的目的所在。此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流
图上的点我会在下文中挨个介绍
对于手淘和天猫的商品,一般都是多个人对多个物品。即使出了问题,也不影响购买,大不了问题修复再购买(最坏的情况)。
但是对于拍卖的拍品。对多对一、价高者得的属性。并且具有一定的法律效应。所以稳定性的要求极其之高。同时拍卖又具有非常高时效性要求,所以 apush、轮询啥的都要求实时更新拍品的状态。
综合以上因素的考虑。最终我们没有选择大黄蜂搭建页面的形式构建起详情页。就先走了源码链路的开发。至于后续是否会推进落地,可能还有待商榷。
如果你阅读过上一篇文章一张页面引起的前端架构思考,那么可能会对接下来要介绍的目录组织结构比较熟悉。下面简单介绍下改动的部分以及添加的一些东西。
目录的职责划分在之前的一篇文章中已经都介绍到了。这里就说下目前的一些改动点:
count-dow
loop
EVENTS
Count-down 和 loop 都是详情页强相关的,但是由于项目名称为 pm-detail 所以,这里就提到 pages 以外的了。其实提不提的原则很简单。该文件是否可(需)共用
Count-down
pm-detail
pages
也是秉持着上面的原则,将 EVENTS 文件夹修改到页面容器里面了。毕竟,跨页面的广播需求基本是不存在的。
关于页面容器的介绍,也在之前的一篇《Decorator+TS装饰你的代码》一文中介绍到。这里也不赘述了。
倒计时的“递归”交给 RAF 搞定。当然,这里是CountDown上的一个方法。
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 (强关联业务)里面。
消费端
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 作为状态管理。
redux
虽然 hooks 里面已经提供了 useReducer ,但是却没有周边的“原生生态”: combineReducers、Middleware 等。所以我们将轮子搬一下,取名为:useRedux
useReducer
combineReducers
Middleware
useRedux
关于 redux 的介绍可见:《从 redux 中搬个轮子给源码项目做状态管理》
这里重点介绍在这个项目中的使用契约:
浪浪额够的时候写过一篇文章react技术栈项目结构探究 ,那时候我就非常喜欢将 redux 中的 initState、actionTypes、actions以及 reducer 定义到一个文件中,的确非常的清晰方便。所以这里 reducers 文件夹也是如此。
initState
actionTypes
actions
reducers
每一个文件,对应每一个功能区域的 reducer
而 reducer 内部的组成,基本都是如下:
以上是模块的 reducer,对于开发者还需要知道的是模块的 reducer 需要插到 detail 里面:
export const detailReducer = combineReducers<ICombineItemDo>({ countDown, loop, detailCommon: globalStateReducer, });
ICombineItemDo 会在下文的 Ts 状态约束里面介绍
ICombineItemDo
所以如上的代码组成的最终页面 state 是如下结构
{ pageState:{ isLoading:boolean }, itemDo:{ countDown:ICountDown, detailCommon:IDetailCommon, loop:ILoop } }
itemDo 其实应该命名为 itemDao但是由于 itemDo 我们用了五年了。。。尊重习惯的力量,避免不必要的麻烦
itemDo
itemDao
虽然使用了中间件,但是跟 redux 还是有些不同的。具体的 applyMiddleware 就不说了,其实就是compose func 然后增强下 dispatch
applyMiddleware
compose
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 中间件
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
state
component
这里,我们在页面级别的自定义 hooks 文件夹的use-data-init.ts 中操刀。
hooks
use-data-init.ts
formatCountDownData
format
dataInit
如上所说,不同页面需要不同的模块,目前详情页还未打算接SSR 以及由于组件频繁通信和稳定性要求不能走搭建,所以目前只能通过 codeSpliting 来进行代码分割的按需加载。
SSR
codeSpliting
是的,通过 useImport
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:通用字段 传递给需要加载的模块中。
DataSource
detailCommon
然后在 index 中,通过接口是否有该模块字段去判断是否加载:
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
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 来检查的。
keyName
key
ts
所以理论上,后续的开发者,新增模块、修改模块,都不应该会修改到index.tsx 这个入口文件
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
detail.reducer
当我们 key 写错了以后,Ts 会帮我们检查出来:
当这个 type 已经拆分重组成我们想要的了时候,那么我们只需要将 config keyName 约束成 itemDo 中 componets 的某一个 key 即可。
componets
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 中,统一导出。避免模块引入太多依赖而看起来吓唬人
type/index.d.ts
编写 reducer 也分为两步:
detail
模块的编写与配置也分为两步:
虽然新增一个步骤大致有些繁琐。但是也都中规中矩。每一步分为本身模块的编写以及提供给你的注入方式。
如上所介绍,再结合之前写的前端架构文章,基本上感觉介绍的差不多了。其实前端架构感觉应该换个名字:目录组织。
而搭建的这套组织形式造成的约束其实也是为了提供更好的稳定性保障和代码的充分解耦。
现在做的远远不够:
最后,还是那句话,此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流~
The text was updated successfully, but these errors were encountered:
No branches or pull requests
前言
没有想到之前写的一篇一张页面引起的前端架构思考还收到不少同学关注。的确,正如之前在群里所说,一个系统能有一个非常好的架构设计。但是仅仅对于前端项目页面,其实很难把架构一词搬出来聊个天花乱坠。
但是!好的代码结构的组织的确能够避免一些不必要的采坑。当然,这其中也不乏对前端工程师的工程师素养约束。
一言以蔽之,对于前端项目的架构(代码组织)而言,好,好不到哪里去。但是坏,却可以令人头皮发麻。
当然。。。我还是在尽可能的希望好
这也是这篇文章的目的所在。此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流拍卖详情页
特点
对于手淘和天猫的商品,一般都是多个人对多个物品。即使出了问题,也不影响购买,大不了问题修复再购买(最坏的情况)。
但是对于拍卖的拍品。对多对一、价高者得的属性。并且具有一定的法律效应。所以稳定性的要求极其之高。同时拍卖又具有非常高时效性要求,所以 apush、轮询啥的都要求实时更新拍品的状态。
综合以上因素的考虑。最终我们没有选择大黄蜂搭建页面的形式构建起详情页。就先走了源码链路的开发。至于后续是否会推进落地,可能还有待商榷。
整体架构
如果你阅读过上一篇文章一张页面引起的前端架构思考,那么可能会对接下来要介绍的目录组织结构比较熟悉。下面简单介绍下改动的部分以及添加的一些东西。
项目级别
目录的职责划分在之前的一篇文章中已经都介绍到了。这里就说下目前的一些改动点:
count-dow
loop
EVENTS
Count-down
和loop
都是详情页强相关的,但是由于项目名称为pm-detail
所以,这里就提到pages
以外的了。其实提不提的原则很简单。该文件是否可(需)共用也是秉持着上面的原则,将
EVENTS
文件夹修改到页面容器里面了。毕竟,跨页面的广播需求基本是不存在的。关于页面容器的介绍,也在之前的一篇《Decorator+TS装饰你的代码》一文中介绍到。这里也不赘述了。
count-down 的简单抽离
倒计时的“递归”交给
RAF
搞定。当然,这里是CountDown
上的一个方法。count-down 的内部消费
消费端
是在pages/detial/count-down/customized-hooks/use-init-count-down.ts
(强关联业务)里面。pages/detail
关于文件和目录的说明都写在了上面的注释中。对于后续的开发者需要重点关注的是:
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 里面:
所以如上的代码组成的最终页面 state 是如下结构
中间件的使用
虽然使用了中间件,但是跟
redux
还是有些不同的。具体的applyMiddleware
就不说了,其实就是compose
func 然后增强下dispatch
所以这里的中间件都是根据当前 dispatch 的 action 里面的 data 来执行相关操作的。
比如
redux-mutli-action
中间件非常的简单~
然后截止目前编写了两个中间件:
模块数据分发
所谓的模块分发,存在的原因是:目前我们的详情页是有很多种不同的业务类型的,单纯的从大资产而言,就分为资产和司法、再分为变卖和拍卖、再有不同类的拍品之区分。也就是说,完整的详情页会有很多的模块,也就是说打开的某一个详情页,并不需要加载所有的模块。这也是为什么下文会有按需加载的 原因。
那么对于数据,我们当然需要根据接口返回的字段,来组织我们的
state
中我们要开发的component
这里,我们在页面级别的自定义
hooks
文件夹的use-data-init.ts
中操刀。formatCountDownData
是由对应的模块提供的format
方法。在接口返回的字段需要进行加工的时候需要dataInit
,理论上应该是最全的数据处理情况按需加载
如上所说,不同页面需要不同的模块,目前详情页还未打算接
SSR
以及由于组件频繁通信和稳定性要求不能走搭建,所以目前只能通过codeSpliting
来进行代码分割的按需加载。是的,通过
useImport
由于是自定义 hooks,所以这里我们不能够通过判断来加载模块。 不能判断,我怎么知道 if 需要?
事实的确如此。所以我们需要一个容器,来让容器去走判断逻辑~
可以看到,我会将
DataSource
:当前模块数据、以及detailCommon
:通用字段 传递给需要加载的模块中。然后在
index
中,通过接口是否有该模块字段去判断是否加载:componentConfigArr
来自我们组件componets/config.ts
keyName
是itemDo
中对应接口模块的key
的名字。这里我们用的ts
来检查的。所以理论上,后续的开发者,新增模块、修改模块,都不应该会修改到
index.tsx
这个入口文件Ts 状态约束
类型约束其实是 TS 的编码应该就塑造的类型思维的一部分 ,毕竟不是介绍 Ts,所以这里主要说下新增模块如何做到类型约束的。
先说下我们的目的是什么:
如上,我们需要在模块
config
的配置中读取到组件,并且state
中对应的模块数据注入给这个模块。重点我们还是要根据这个keyName
来进行按需加载的判断。所以我需要你填写的keyName
必须是你自己组织(combineReducers
)出来state
对应模块的key
最终的效果就如上面的截图,编码的时候会提醒你,能够填写哪些字段。那么这个约束是如何形成的呢?
如图,首先我们需要将
combineReducers
和state
通过type
进行约束。当这个约束建立的时候,那么就可以通过这个type
来进行config
字段的约束如上的
ICombineItemDo
就是我们需要拿去约束每一个组件的reducer
在detail.reducer
中汇总出来的state
当我们 key 写错了以后,Ts 会帮我们检查出来:
当这个
type
已经拆分重组成我们想要的了时候,那么我们只需要将config
keyName
约束成itemDo
中componets
的某一个 key 即可。开发契约
所谓的开发契约其实就是你不要瞎 xx 搞
然后给在这个项目中开发的同学提供的一些职业道德约束。当然,程序猿的职业素养也都是不可靠的。所以后续考虑用脚本强制起来新增模块步骤
上面的契约其实有些泛泛而谈,不如实操来的痛快。下面我们通过举例说明在这个架构下,新增一个模块需要的步骤吧。
1、新增类型
新增数据类型一定是第一步!!! 避免一些低级错误的发生。同时,不是第一步的话。。。你后面的步骤编辑器都会报错的。
拿倒计时举例:
types/count-down.d.ts
中编写对应模块的类型约束types/item-dao.d.ts
中注入2、reducer
编写
reducer
也分为两步:reducer
,上文已经介绍到了。detail
的reducer
中注入进去。3、模块编写与配置
模块的编写与配置也分为两步:
componets
目录下新建对应模块,编码componets/config.ts
中注入虽然新增一个步骤大致有些繁琐。但是也都中规中矩。每一步分为本身模块的编写以及提供给你的注入方式。
TODO
如上所介绍,再结合之前写的前端架构文章,基本上感觉介绍的差不多了。其实前端架构感觉应该换个名字:目录组织。
而搭建的这套组织形式造成的约束其实也是为了提供更好的稳定性保障和代码的充分解耦。
现在做的远远不够:
最后,还是那句话,此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流~
The text was updated successfully, but these errors were encountered: