前端源码架构在拍卖详情页上的探索

趣谈前端

共 17513字,需浏览 36分钟

 · 2021-06-11

关注并将「趣谈前端」设为星标

每早08:30按时推送技术干货/优秀开源/技术思维

前言

一个系统能有一个非常好的架构设计是非常重要的。但是仅仅对于前端项目页面,其实很难把「架构」一词搬出来聊个天花乱坠。

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

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

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

拍卖详情页

详情页

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

架构设计图

特点

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

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

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

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

整体架构

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

项目级别

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

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

Count-downloop 都是详情页强相关的,但是由于项目名称为 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(nullas 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 ,但是却没有周边的“原生生态”:combineReducersMiddleware 等。所以我们将轮子搬一下,取名为:useRedux

关于 redux 的介绍可见:《从 redux 中搬个轮子给源码项目做状态管理》

「这里重点介绍在这个项目中的使用契约:」

基本使用

浪浪额够的时候写过一篇文章react技术栈项目结构探究 ,那时候我就非常喜欢将 redux 中的 initStateactionTypesactions以及 reducer 定义到一个文件中,的确非常的清晰方便。所以这里 reducers  文件夹也是如此。

每一个文件,对应每一个功能区域的 reducer

而 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 中操刀。

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

按需加载

如上所说,不同页面需要不同的模块,目前详情页还未打算接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')
    }
];

keyNameitemDo 中对应接口模块的 key 的名字。这里我们用的 ts 来检查的。

类型约束

所以「理论上,后续的开发者,新增模块、修改模块,都不应该会修改到index.tsx 这个入口文件」

Ts 状态约束

「类型约束其实是 TS 的编码应该就塑造的类型思维的一部分」 ,毕竟不是介绍 Ts,所以这里主要说下新增模块如何做到类型约束的。

这一块,可能解释起来稍微有点烦

先说下我们的目的是什么:

如上,我们需要在模块 config的配置中读取到组件,并且state 中对应的模块数据注入给这个模块。重点我们还是要根据这个 keyName 来进行按需加载的判断。所以我需要你填写的 keyName 必须是你自己组织(combineReducers)出来 state 对应模块的 key

最终的效果就如上面的截图,编码的时候会提醒你,能够填写哪些字段。那么这个约束是如何形成的呢?

如图,首先我们需要将 combineReducersstate 通过 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就是我们需要拿去约束每一个组件的 reducerdetail.reducer 中汇总出来的state

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

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

当这个 type 已经拆分重组成我们想要的了时候,那么我们只需要将 config keyName 约束成 itemDocomponets 的某一个 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,上文已经介绍到了。
  • 第二步:在detailreducer 中注入进去。

3、模块编写与配置

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

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

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

TODO

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

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

现在做的远远不够:

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

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

学习交流

  • 关注公众号【趣谈前端】,每日获取好文推荐
  • 添加微信号:Mr_xuxiaoxi(备注来源) ,入群交流
个人微信 公众号【趣谈前端

❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  • 点个【在看】,或者分享转发,让更多的人也能看到这篇内容

  • 关注公众号【趣谈前端】,不定期分享 前端工程化 可视化 / 低代码 等技术文章。



10款2021年国外顶尖的lowcode开发平台

2个小时, 从学到做, 我用Dooring制作了3个电商H5

canvas图像识取技术以及智能化设计的思考


浏览 32
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报