背景
1.1 为什么会有这一篇文章?
在日常/大促业务的“敏捷”开发过程中逐渐产生的几个疑惑,尝试地做出思考并想得到一些解决思路和方案。
总的来说,在前端开发和实践过程中,梳理了一些简单设计方案可以缓解当时让我 “头疼” 的几个敏捷迭代问题,并实践在项目迭代中。
1.2 因此个人对这篇文章有三个小目的:
- 梳理清楚个人真正疑惑开发迭代的问题在哪,解决的核心是什么,温故而知新。
- 提供前端架构设计的思考&方案,来缓解日常/大促敏捷迭代问题,希望可以得到一些拍砖~
- 能让项目协同的同学能初步理解个人对于前端结构设计,方便他人理解这样搞的原因背景,快速磨平协同上的一些理解和开发成本 💰。
疑惑
先抛出主要的几点问题,在业务迭代过程中你为什么会疑惑(WHY),疑惑什么问题(WHAT),会以什么方式解决(HOW)?
- 在业务需求的敏捷迭代中,单看逻辑和视觉的变更都不难,为何结合起来迭代如此花费成本?
- 敏捷业务迭代中,我们能找出什么是敏捷在变,什么是敏捷不变?
- 面向视图开发,还是面向数据开发?
- React 定位“是一个用于渲染用户界面 (UI) 的 JavaScript 库”,那么 UI 和逻辑怎么更好地设计结合?
示例
为了更加有体感,以一个常规的业务例子,简单描述下个人开发的规范和经验的粗浅历程。
先假设一个业务需求:核心关于【账户信息】
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 账户组件中,那么需求来啦 ✈️ ✈️ ✈️ ✈️