实践总结|前端架构设计的一点考究(上)

简介: 本文总结了作者在日常/大促业务的“敏捷”开发过程中产生的疑惑,并尝试做出思考得到一些解决思路和方案。在前端开发和实践过程中,梳理了一些简单设计方案可以缓解当时 “头疼” 的几个敏捷迭代问题,并实践在项目迭代中。




image.png

背景


1.1 为什么会有这一篇文章?


在日常/大促业务的“敏捷”开发过程中逐渐产生的几个疑惑,尝试地做出思考并想得到一些解决思路和方案。

总的来说,在前端开发和实践过程中,梳理了一些简单设计方案可以缓解当时让我 “头疼” 的几个敏捷迭代问题,并实践在项目迭代中。


1.2 因此个人对这篇文章有三个小目的:


  1. 梳理清楚个人真正疑惑开发迭代的问题在哪,解决的核心是什么,温故而知新。
  2. 提供前端架构设计的思考&方案,来缓解日常/大促敏捷迭代问题,希望可以得到一些拍砖~
  3. 能让项目协同的同学能初步理解个人对于前端结构设计,方便他人理解这样搞的原因背景,快速磨平协同上的一些理解和开发成本 💰。



image.png

疑惑

先抛出主要的几点问题,在业务迭代过程中你为什么会疑惑(WHY),疑惑什么问题(WHAT),会以什么方式解决(HOW)?

  1. 在业务需求的敏捷迭代中,单看逻辑和视觉的变更都不难,为何结合起来迭代如此花费成本?
  2. 敏捷业务迭代中,我们能找出什么是敏捷在变,什么是敏捷不变
  3. 面向视图开发,还是面向数据开发?
  4. React 定位“是一个用于渲染用户界面 (UI) 的 JavaScript 库”,那么 UI 和逻辑怎么更好地设计结合?

image.png

示例


为了更加有体感,以一个常规的业务例子,简单描述下个人开发的规范和经验的粗浅历程。

先假设一个业务需求:核心关于【账户信息】


3.1 需求一:金额账户

需求一:一开始需求很简单 —— 展示账户信息解决一:咔咔写一个组件和 CSS 样式,在组件中定义数值并获取接口数据更新。1.Account 组件

import { useEffect, useState } from 'react';
import styles from './index.module.less';

const Account = () => {
  // 账户金额
  const [account, setAccount] = useState(0);

  useEffect(() => {
    // 模拟接口数据
    setTimeout(() => {
      setAccount(12.34);
    }, 1000)
  }, [])

  return (
    <div className={styles.stickyAccountWrap}>
      <div className={styles.stickyAccount}>
        <div className={styles.stickyAccountGoldPocketPic} />
        <div className={styles.stickyAccountTitleContainer}>
          <div className={styles.stickyAccountTitle}>
            <div>{account}</div>
            <div className={styles.unit}>元</div>
          </div>
        </div>
        <div className={styles.withdraw} />
      </div>
    </div>
  );
};

export default Account;


2.相关样式

.stickyAccountWrap {
  width: 750rpx;
  height: 104rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  position: absolute;
  left: 0;
  top: 0;

  .stickyAccount {
    width: 308rpx;
    height: 70rpx;
    background: center / contain no-repeat
      url(https://gw.alicdn.com/imgextra/i4/O1CN01harLZI1kECtyvhAPh_!!6000000004651-2-tps-308-70.png);
    display: flex;
    align-items: center;
    justify-content: center;
    .stickyAccountGoldPocketPic {
      width: 37rpx;
      height: 46rpx;
      background: center / contain no-repeat url("https://gw.alicdn.com/imgextra/i4/O1CN01RQ3Gzj1ZJjv6MlKoD_!!6000000003174-2-tps-74-92.png");
    }

    .stickyAccountTitleContainer {
      margin-left: 10rpx;
      display: flex;
      flex-direction: column;
      justify-content: flex-start;
      height: 100%;
      .stickyAccountTitle {
        margin-top: 6rpx;
        font-family: "Alibaba Sans 102";
        font-size: 42rpx;
        font-weight: bold;
        display: flex;
        align-items: baseline;
        color: #bc2b15;
        height: 60rpx;
        .unit {
          font-size: 22rpx;
          margin-left: 2rpx;
        }
      }
    }

    .withdraw {
      margin-left: 10rpx;
      background: center / contain no-repeat
        url("https://img.alicdn.com/imgextra/i4/O1CN01teiAeS1tZZvwjzqx9_!!6000000005916-2-tps-129-63.png");
      width: 86rpx;
      height: 42rpx;
    }
  }
}

实现一: 

总结:基操~ 业务仍在高速迭代中...  很快需求来了~ 🚄 ✈️ ✈️ ✈️ ✈️

3.2 需求二:互动效果


需求二:业务希望权益氛围感增强,在金额变化的同时,有金币飞入红包的氛围效果解决二:简单 😁 ~ 在组件内写一个金币组件 + 飞入状态控制即可1.金币飞入通用组件


import { CSSProperties, FC, useRef, useEffect, useCallback } from 'react';
import anime from 'animejs';
import styles from './index.module.less';

interface ICoinsFly {
  style?: CSSProperties;
  onEnd: () => void;
}

/**
 * 金币飞动画组件
 */
const CoinsFly: FC<ICoinsFly> = (props) => {
  const { style, onEnd } = props;
  const wrapRef = useRef<HTMLDivElement>(null);

  const rpx2px = useCallback((rpxNum: number) => (rpxNum / 750) * window.screen.width, []);

  useEffect(() => {
    // 金币动画
    anime({
      targets: wrapRef.current?.childNodes,
      delay: anime.stagger(90),
      translateY: [
        { value: 0 },
        {
          value: -rpx2px(334),
          easing: 'linear',
        },
      ],
      translateX: [
        { value: 0 },
        {
          value: -rpx2px(98),
          easing: 'cubicBezier(.05,.9,.8,1.5)',
        },
      ],
      scale: [
        { value: 1 },
        {
          value: 0.5,
          easing: 'linear',
        },
      ],
      opacity: [
        { value: 1 },
        {
          value: 0,
          easing: 'cubicBezier(1,0,1,0)',
        },
      ],
      duration: 900,
      complete: () => {
        onEnd();
      },
    });
  }, []);

  return (
    <div className={styles.container} style={style} ref={wrapRef}>
      {[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
        <div key={item} className={styles.coin} />
      ))}
    </div>
  );
};

export default CoinsFly;

.container {
  position: absolute;
  top: 100rpx;
  left: 100rpx;
  background-color: rgba(255, 255, 255, 0.6);
  .coin {
    width: 106rpx;
    height: 106rpx;
    background-image: url("https://gw.alicdn.com/imgextra/i4/O1CN01hVWasj25i4dZdV9sS_!!6000000007559-2-tps-160-160.png");
    background-position: center;
    background-size: contain;
    background-repeat: no-repeat;
    position: absolute;
    top: 0;
    left: 0;
  }
}


2.账户组件引入金币飞入组件 && 状态控制



import { useEffect, useState } from 'react';
import CoinsFly from '../../components/CoinsFly';
import styles from './demo1.module.less';

const Account = () => {
  // 账户金额
  const [account, setAccount] = useState(0);
  // 金币飞入动画
  const [showCoinsFly, setShowCoinsFly] = useState(false);


  useEffect(() => {
    // 模拟接口数据
    setTimeout(() => {
      setAccount(12.34);
      setShowCoinsFly(true);
    }, 1000)
  }, [])

  return (
    <div className={styles.stickyAccountWrap}>
      <div className={styles.stickyAccount}>
        <div className={styles.stickyAccountGoldPocketPic} />
        <div className={styles.stickyAccountTitleContainer}>
          <div className={styles.stickyAccountTitle}>
            <div>{account}</div>
            <div className={styles.unit}>元</div>
          </div>
        </div>
        <div className={styles.withdraw} />
      </div>
      {showCoinsFly && (
        <CoinsFly
          style={{
            top: '322rpx',
            left: '316rpx',
            zIndex: 1,
          }}
          onEnd={() => {
            setShowCoinsFly(false);
          }}
        />
      )}
    </div>
  );
};

export default Account;

实现二: 



总结:🎈🎈简简单单搞定~   代码写得清晰明了~  还沉淀了一个金币飞入的组件  当然很快需求又来了 ✈️


3.3 需求三:权益承接


需求三:业务方提出希望有一个弹窗承接,也就是点击提现按钮 => 提现成功弹窗收下 => 金币飞入再金额刷新解决三:1.写一个提现成功弹窗


import styles from './index.module.less';

export interface DialogData {
  a: string;
  /** 标题 */
  b: string;
  /** 金额 */
  c: string;
  d: string;
  e: string;
}

// 定义Props类型
interface IPopupProps {
  onClose?: () => void;
  /** 弹窗信息 */
  data: DialogData;
}

// 提现弹窗
const WithdrawDialog = (props: IPopupProps) => {
  const { onClose, data } = props;
  const {
    a,
    b,
    c,
    d
  } = data || {};

  // 关闭弹窗
  const handleClose = () => {
    typeof onClose === 'function' && onClose();
  };

  return (
    <div className={styles.popup}>
      <div className={styles.content}>
        {/* 头部提示 */}
        <div className={styles.header}>
          <div className={styles.icon} />
          <div className={styles.title}>{a}</div>
        </div>
        <div className={styles.body}>
          {/* 金额 */}
          <div className={styles.amountCon}>
            <div className={styles.amount}>{b || ''}</div>
            <div className={styles.unit}>元</div>
          </div>
          <div className={styles.dividing} />
          {/* 账户内容 */}
          <div className={styles.userContent}>
            <div className={styles.userItem}>
              <div className={styles.title}>提现账户</div>
              <div className={styles.userText}>{c || ''}</div>
            </div>
            <div className={styles.userItem}>
              <div className={styles.title}>打款方式</div>
              <div className={styles.userText}>{d || ''}</div>
            </div>
          </div>

          {/* 按钮 */}
          <div
            className={styles.btn}
            onClick={() => handleClose()}
          >开心收下</div>
        </div>
      </div>
    </div >
  );
};

export default WithdrawDialog;




.popup {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.7);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;

  .hidden {
    display: none;
  }

  .content {
    position: relative;
    background: center / contain no-repeat url("https://gw.alicdn.com/imgextra/i3/O1CN01vlcfgm1xFCpji3rv7_!!6000000006413-2-tps-596-786.png");
    border-radius: 54rpx;
    width: 590rpx;
    height: 780rpx;
    display: flex;
    flex-direction: column;

    .header {
      display: flex;
      flex-direction: column;
      align-items: center;
      margin-top: 66rpx;
      .icon {
        width: 90rpx;
        height: 90rpx;
        background: center / contain no-repeat
          url("https://gw.alicdn.com/imgextra/i4/O1CN01KSkat11aHHShz5JqV_!!6000000003304-2-tps-90-90.png");
      }
      .title {
        font-weight: 700;
        margin-top: 30rpx;
        font-family: PingFangSC-Medium;
        font-size: 32rpx;
        color: #1677ff;
      }
    }

    .body {
      display: flex;
      flex-direction: column;
      align-items: center;
      margin-top: 40rpx;
      .amountCon {
        display: flex;
        align-items: baseline;
        color: #ff0746;
        
        .amount {
          font-family: AlibabaSans102-Bd;
          font-size: 120rpx;
        }
        .unit {
          position: relative;
          top: -4rpx;
          font-size: 60rpx;
        }
      }
      .dividing {
        margin-top: 40rpx;
        width: 506rpx;
        height: 2rpx;
        background-color: #ccc;
      }
      .userContent {
        margin-top: 22rpx;
        width: 506rpx;
        height: 100%;
        .userItem {
          margin-top: 20rpx;
          width: 100%;
          display: flex;
          justify-content: space-between;
          .title {
            font-family: PingFangSC-Regular;
            font-size: 26rpx;
            color: #666;
          }
          .userText {
            font-family: PingFangSC-Medium;
            font-size: 26rpx;
            color: #111;
          }
        }
      }
    }

    .errCon {
      margin-top: 38rpx;
      display: flex;
      flex-direction: column;
      align-items: center;
      .title {
        font-weight: 700;
        margin-bottom: 14rpx;
        font-family: PingFangSC-Semibold;
        font-size: 48rpx;
        color: #111;
      }
      .des {
        font-size: 32rpx;
        line-height: 44rpx;
        color: #363636;
        .redText {
          color: #ff0d40;
        }
      }
      .img {
        margin-top: 18rpx;
        width: 300rpx;
        height: 384rpx;
        background: center / contain no-repeat url("https://gw.alicdn.com/imgextra/i3/O1CN01uMPIk91nUBd1MjN9v_!!6000000005092-2-tps-300-384.png");
      }
    }
  }
}

.btn {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: 42rpx;
  width: 518rpx;
  height: 96rpx;
  background-image: linear-gradient(100deg, #f54ea1 0%, #ff0040 100%);
  border-radius: 52rpx;
  text-align: center;
  line-height: 96rpx;
  color: #fff;
  font-family: PingFangSC-Medium;
  font-size: 38rpx;
}

Account 组件除了账户信息还添加了弹窗的信息内容,为了 Account 组件干净以及后续更好迭代,进行业务 Hook 逻辑抽象。

2.抽成账户刷新&金币的状态逻辑 Hooks


import { useCallback, useEffect, useState } from 'react';
import { DialogData } from '../../components/WithdrawDialog';

const useAccount = () => {
  // 账户金额
  const [account, setAccount] = useState(0);
  // 金币飞入动画
  const [showCoinsFly, setShowCoinsFly] = useState(false);
  // 弹窗展示
  const [showDialog, setShowDialog] = useState(false);
  const [dialogData, setDialogData] = useState<DialogData>();

  /** 模拟接口 => 刷新账户信息 */
  const refreshAccount = useCallback((account) => {
    setTimeout(() => {
      setAccount(account);
      setShowCoinsFly(true);
    }, 500)
  }, [])

  useEffect(() => {
    // 模拟初始化数据 => 接口数据
    refreshAccount(12.34)
  }, [])

  return {
    account,
    refreshAccount,
    showCoinsFly,
    setShowCoinsFly,
    showDialog,
    setShowDialog,
    dialogData,
    setDialogData
  }
}

export default useAccount;

3.抽象重构 Account 账户组件,引入提现成功弹窗


import CoinsFly from '../../components/CoinsFly';
import WithdrawDialog from '../../components/WithdrawDialog';
import useAccount from './useAccount';
import styles from './index.module.less';

const Account = () => {
  // 账户业务逻辑
  const {
    account,
    refreshAccount,
    showCoinsFly,
    setShowCoinsFly,
    showDialog,
    setShowDialog,
    dialogData,
    setDialogData
  } = useAccount()

  return (
    <div className={styles.stickyAccountWrap}>
      <div className={styles.stickyAccount}>
        <div className={styles.stickyAccountGoldPocketPic} />
        <div className={styles.stickyAccountTitleContainer}>
          <div className={styles.stickyAccountTitle}>
            <div>{account}</div>
            <div className={styles.unit}>元</div>
          </div>
        </div>
        <div
          className={styles.withdraw}
          onClick={() => {
            setDialogData({
              a: "3000",
              b: "123456789123456789",
              c: "xxxx打款",
              d: "提现成功,预计2小时到账",
              e: "0.3",
            })
            setShowDialog(true);
          }}
        />
      </div>

      {/* 金币飞入 */}
      {showCoinsFly && (
        <CoinsFly
          style={{
            top: '322rpx',
            left: '316rpx',
            zIndex: 1,
          }}
          onEnd={() => {
            setShowCoinsFly(false);
          }}
        />
      )}

      {/* 提现弹窗 */}
      {
        showDialog &&
        <WithdrawDialog
          data={dialogData}
          onClose={() => {
            refreshAccount(12.04);
            setShowDialog(false);
          }}
        />
      }
    </div>
  );
};

export default Account;

实现三:


总结:

  • 遵循着解耦以及内聚最小化的原则,将控制账户抽象为 hooks,后续可以在其他视图组件使用。
  • 这里其实稍微暴露了让我难受的一点,因为视图需要与状态和方法做逻辑交互,一来二去 hooks 要将近乎所有的状态方法都抛出...

实际上开发也可以将 Account 和 Dialog 单独做状态和逻辑的封装 hook

然而需求不会仅仅局限于 Account 账户组件中,那么需求来啦 ✈️ ✈️ ✈️ ✈️


实践总结|前端架构设计的一点考究(中)

作者介绍
目录