前言
❝原文地址:github/Nealyang ❞
没有想到之前写的一篇一张页面引起的前端架构思考还收到不少同学关注。的确,正如之前在群里所说,一个系统能有一个非常好的架构设计。但是仅仅对于前端项目页面,其实很难把「架构」一词搬出来聊个天花乱坠。
但是!好的代码结构的组织的确能够避免一些不必要的采坑。当然,这其中也不乏对前端工程师的工程师素养约束。
一言以蔽之,对于前端项目的架构(代码组织)而言,「好」,好不到哪里去。但是「坏」,却可以令人头皮发麻。
当然。。。我还是在尽可能的希望好~这也是这篇文章的目的所在。此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流~
拍卖详情页
详情页
❝图上的点我会在下文中挨个介绍 ❞
架构设计图
特点
- 「稳定性要求极高」 (这一点区分手淘和天猫,毕竟拍卖...你品)
- 需要详细的日志打点
- 模块之间的通信非常多(拍品状态、倒计时、出价等)
对于手淘和天猫的商品,一般都是多个人对多个物品。即使出了问题,也不影响购买,大不了问题修复再购买(最坏的情况)。
但是对于拍卖的拍品。对多对一、价高者得的属性。并且具有一定的法律效应。所以稳定性的要求极其之高。同时拍卖又具有非常高时效性要求,所以 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 的内部消费
代码语言:javascript复制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
代码语言:javascript复制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,对于开发者还需要知道的是模块的 reducer 需要插到 detail 里面:
代码语言:javascript复制export const detailReducer = combineReducers<ICombineItemDo>({
countDown,
loop,
detailCommon: globalStateReducer,
});
❝
ICombineItemDo
会在下文的 Ts 状态约束里面介绍 ❞
所以如上的代码组成的最终页面 state 是如下结构
代码语言:javascript复制{
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
formatCountDownData
是由对应的模块提供的format
方法。在接口返回的字段需要进行加工的时候需要- 此处作为页面级别的
dataInit
,「理论上应该是最全的数据处理情况」
format func return
按需加载
如上所说,不同页面需要不同的模块,目前详情页还未打算接SSR
以及由于组件频繁通信和稳定性要求不能走搭建,所以目前只能通过 codeSpliting
来进行代码分割的按需加载。
是的,通过 useImport
「由于是自定义 hooks,所以这里我们不能够通过判断来加载模块」。不能判断,我怎么知道 if 需要?
事实的确如此。所以我们需要一个容器,来让容器去走判断逻辑~
代码语言:javascript复制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
如上所介绍,再结合之前写的前端架构文章,基本上感觉介绍的差不多了。其实前端架构感觉应该换个名字:目录组织。
而搭建的这套组织形式造成的约束其实也是为了「提供更好的稳定性保障」和「代码的充分解耦」。
现在做的远远不够:
- 项目脚手架
- 自动化测试
- 编码规则静态检查
- 状态可视化
- 性能优化
- 代码覆盖率
- ...
最后,还是那句话,此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流~