
▐ 为什么会有这一篇文章?
在日常/大促业务的“敏捷”开发过程中逐渐产生的几个疑惑,尝试地做出思考并想得到一些解决思路和方案。
总的来说,在前端开发和实践过程中,梳理了一些简单设计方案可以缓解当时让我 “头疼” 的几个敏捷迭代问题,并实践在项目迭代中。
▐ 因此个人对这篇文章有三个小目的:
梳理清楚个人真正疑惑开发迭代的问题在哪,解决的核心是什么,温故而知新。
提供前端架构设计的思考&方案,来缓解日常/大促敏捷迭代问题,希望可以得到一些拍砖~
能让项目协同的同学能初步理解个人对于前端结构设计,方便他人理解这样搞的原因背景,快速磨平协同上的一些理解和开发成本 💰。

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

示例
先假设一个业务需求:核心关于【账户信息】
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.相关样式
{
width: 750rpx;
height: 104rpx;
display: flex;
center; :
center; :
position: absolute;
left: 0;
top: 0;
{
width: 308rpx;
height: 70rpx;
background: center / contain no-repeat
//gw.alicdn.com/imgextra/i4/O1CN01harLZI1kECtyvhAPh_!!6000000004651-2-tps-308-70.png); :
display: flex;
center; :
center; :
{
width: 37rpx;
height: 46rpx;
background: center / contain no-repeat url("https://gw.alicdn.com/imgextra/i4/O1CN01RQ3Gzj1ZJjv6MlKoD_!!6000000003174-2-tps-74-92.png");
}
{
10rpx; :
display: flex;
column; :
flex-start; :
height: 100%;
{
6rpx; :
"Alibaba Sans 102"; :
42rpx; :
bold; :
display: flex;
baseline; :
color: #bc2b15;
height: 60rpx;
{
22rpx; :
2rpx; :
}
}
}
{
10rpx; :
background: center / contain no-repeat
//img.alicdn.com/imgextra/i4/O1CN01teiAeS1tZZvwjzqx9_!!6000000005916-2-tps-129-63.png"); :
width: 86rpx;
height: 42rpx;
}
}
}

总结:基操~ 业务仍在高速迭代中... 很快需求来了~ 🚄 ✈️ ✈️ ✈️ ✈️
▐ 需求二:互动效果
需求二:业务希望权益氛围感增强,在金额变化的同时,有金币飞入红包的氛围效果
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;
{
position: absolute;
top: 100rpx;
left: 100rpx;
rgba(255, 255, 255, 0.6); :
{
width: 106rpx;
height: 106rpx;
url("https://gw.alicdn.com/imgextra/i4/O1CN01hVWasj25i4dZdV9sS_!!6000000007559-2-tps-160-160.png"); :
center; :
contain; :
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;

总结:🎈🎈简简单单搞定~ 代码写得清晰明了~ 还沉淀了一个金币飞入的组件 当然很快需求又来了 ✈️
▐ 需求三:权益承接
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;
{
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
rgba(0, 0, 0, 0.7); :
display: flex;
column; :
center; :
center; :
{
display: none;
}
{
position: relative;
background: center / contain no-repeat url("https://gw.alicdn.com/imgextra/i3/O1CN01vlcfgm1xFCpji3rv7_!!6000000006413-2-tps-596-786.png");
54rpx; :
width: 590rpx;
height: 780rpx;
display: flex;
column; :
{
display: flex;
column; :
center; :
66rpx; :
{
width: 90rpx;
height: 90rpx;
background: center / contain no-repeat
//gw.alicdn.com/imgextra/i4/O1CN01KSkat11aHHShz5JqV_!!6000000003304-2-tps-90-90.png"); :
}
{
700; :
30rpx; :
PingFangSC-Medium; :
32rpx; :
color: #1677ff;
}
}
{
display: flex;
column; :
center; :
40rpx; :
{
display: flex;
baseline; :
color: #ff0746;
{
AlibabaSans102-Bd; :
120rpx; :
}
{
position: relative;
top: -4rpx;
60rpx; :
}
}
{
40rpx; :
width: 506rpx;
height: 2rpx;
#ccc; :
}
{
22rpx; :
width: 506rpx;
height: 100%;
{
20rpx; :
width: 100%;
display: flex;
space-between; :
{
PingFangSC-Regular; :
26rpx; :
color: #666;
}
{
PingFangSC-Medium; :
26rpx; :
color: #111;
}
}
}
}
{
38rpx; :
display: flex;
column; :
center; :
{
700; :
14rpx; :
PingFangSC-Semibold; :
48rpx; :
color: #111;
}
{
32rpx; :
44rpx; :
color: #363636;
{
color: #ff0d40;
}
}
{
18rpx; :
width: 300rpx;
height: 384rpx;
background: center / contain no-repeat url("https://gw.alicdn.com/imgextra/i3/O1CN01uMPIk91nUBd1MjN9v_!!6000000005092-2-tps-300-384.png");
}
}
}
}
{
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 42rpx;
width: 518rpx;
height: 96rpx;
linear-gradient(100deg, #f54ea1 0%, #ff0040 100%); :
52rpx; :
center; :
96rpx; :
color: #fff;
PingFangSC-Medium; :
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;
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 账户组件中,那么需求来啦 ✈️ ✈️ ✈️ ✈️
▐ 需求四:任务体系
import { useEffect, useState } from 'react';
import styles from './index.module.less';
// 任务状态枚举
enum TASK_STATUS {
PROGRESS = 'progress',
COMPLETE = 'complete',
}
// 任务信息
const TASK_INFO_MAP = {
[TASK_STATUS.PROGRESS]: {
btn: '进行中',
},
[TASK_STATUS.COMPLETE]: {
btn: '已完成',
}
}
/** 任务组件 */
const Task = () => {
// 任务状态
const [state, setState] = useState<TASK_STATUS>(TASK_STATUS.PROGRESS)
useEffect(() => {
setTimeout(() => {
alert('完成任务');
setState(TASK_STATUS.COMPLETE);
}, 3000);
}, [])
return (
<div className={styles.taskWrap}>
{/* icon */}
<div className={styles.taskImg} />
{/* 详情 */}
<div className={styles.taskDesc}>
<div className={styles.action}> 完成任务节即可提现 </div>
<div className={styles.detailText}>
<div className={styles.detailTextDetail}>完成后可提现 0.6 元</div>
</div>
</div>
{/* 按钮 */}
<div
className={styles.taskBtn}
onClick={() => {}}
>
{TASK_INFO_MAP[state]?.btn || ''}
</div>
</div>
);
};
export default Task;
{
position: relative;
top: 200rpx;
30rpx; :
padding: 25rpx 30rpx 25rpx 24rpx;
background: #fae3e4;
display: flex;
center; :
position: relative;
{
width: 92rpx;
height: 92rpx;
url("https://gw.alicdn.com/imgextra/i4/O1CN01VqCtbK1vhU0PZUTzR_!!6000000006204-2-tps-80-80.png"); :
100% 100%; :
position: relative;
}
{
flex: 1;
width: 370rpx;
padding: 4rpx 0 2rpx;
20rpx; :
display: flex;
column; :
space-between; :
{
20rpx; :
bold; :
display: flex;
center; :
28rpx; :
color: #942703;
}
{
color: #a07466;
24rpx; :
height: 32rpx;
display: flex;
center; :
overflow: hidden;
{
display: flex;
row; :
center; :
center; :
}
}
}
{
width: 144rpx;
height: 68rpx;
display: flex;
center; :
center; :
34rpx; :
linear-gradient(145deg, #ff5d83, #ff2929); :
28rpx; :
color: #fff;
}
}
因为 Task 任务组件与 Account 账户信息组件在业务是同层关系,需要将抽象到视图组件外层 Architecture 中。这里是成本不大~ 单纯 UI 层面的处理。
2.将 Dialog 和 Task 组件提到组件 Architecture最外层
import Account from "./Account";
import Task from "./Task";
/** 主入口 */
const Architecture = () => {
return (
<>
{/* 账户信息 */}
<Account />
{/* 任务 */}
<Task />
</>
)
};
export default Architecture;
import Account from "./Account";
import Task from "./Task";
import useAccount from "./Account/useAccount";
主入口 */
const Architecture = () => {
账户业务逻辑
const {
account,
refreshAccount,
showCoinsFly,
setShowCoinsFly,
showDialog,
setShowDialog,
dialogData,
setDialogData
useAccount() =
return (
<>
账户信息 */}
<Account
account={account}
refreshAccount={refreshAccount}
showCoinsFly={showCoinsFly}
setShowCoinsFly={setShowCoinsFly}
showDialog={showDialog}
setShowDialog={setShowDialog}
dialogData={dialogData}
setDialogData={setDialogData}
/>
任务 */}
<Task
setShowDialog={setShowDialog}
setDialogData={setDialogData}
/>
</>
)
};
export default Architecture;
import CoinsFly from '../components/CoinsFly';
import WithdrawDialog from '../components/WithdrawDialog';
import { DialogData } from '../components/WithdrawDialog';
import styles from './index.module.less';
interface IAccount {
account: number;
showCoinsFly: boolean;
showDialog: boolean;
dialogData: DialogData;
setDialogData: React.Dispatch<React.SetStateAction<DialogData>>;
setShowDialog: React.Dispatch<React.SetStateAction<boolean>>;
setShowCoinsFly: React.Dispatch<React.SetStateAction<boolean>>;
refreshAccount: (account: number) => void;
}
账户组件 */
const Account = (props: IAccount) => {
const {
account,
refreshAccount,
showCoinsFly,
setShowCoinsFly,
showDialog,
setShowDialog,
dialogData,
setDialogData
props; =
return (
className={styles.stickyAccountWrap}>
className={styles.stickyAccount}>
className={styles.stickyAccountGoldPocketPic} />
className={styles.stickyAccountTitleContainer}>
className={styles.stickyAccountTitle}>
<div>{account}</div>
className={styles.unit}>元</div>
</div>
</div>
<div
className={styles.withdraw}
onClick={() => {
setDialogData({
a: "3000",
b: "123456789123456789",
c: "支付宝打款",
d: "提现成功,预计2小时到账",
e: "0.3",
})
setShowDialog(true);
}}
/>
</div>
金币飞入 */}
&& (
<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;
import { useCallback, useEffect, useState } from 'react';
import { DialogData } from '../components/WithdrawDialog';
import styles from './index.module.less';
// 任务状态枚举
enum TASK_STATUS {
PROGRESS = 'progress',
COMPLETE = 'complete',
}
// 任务信息
const TASK_INFO_MAP = {
[TASK_STATUS.PROGRESS]: {
btn: '进行中',
},
[TASK_STATUS.COMPLETE]: {
btn: '已完成',
}
}
interface ITask {
setDialogData: React.Dispatch<React.SetStateAction<DialogData>>;
setShowDialog: React.Dispatch<React.SetStateAction<boolean>>;
}
/** 任务组件 */
const Task = (props: ITask) => {
const {
setDialogData,
setShowDialog
} = props;
// 任务状态
const [state, setState] = useState<TASK_STATUS>(TASK_STATUS.PROGRESS)
useEffect(() => {
setTimeout(() => {
alert('完成任务');
setState(TASK_STATUS.COMPLETE);
}, 3000);
}, [])
const btnCallback = useCallback(() => {
if (state === TASK_STATUS.COMPLETE) {
setDialogData({
a: "3000",
b: "123456789123456789",
c: "支付宝打款",
d: "提现成功,预计2小时到账",
e: "0.3",
})
setShowDialog(true);
}
}, [state])
return (
<div className={styles.taskWrap}>
{/* icon */}
<div className={styles.taskImg} />
{/* 详情 */}
<div className={styles.taskDesc}>
<div className={styles.action}> 完成任务节即可提现 </div>
<div className={styles.detailText}>
<div className={styles.detailTextDetail}>完成后可提现 0.6 元</div>
</div>
</div>
{/* 按钮 */}
<div
className={styles.taskBtn}
onClick={() => btnCallback()}
>
{TASK_INFO_MAP[state]?.btn || ''}
</div>
</div>
);
};
export default Task;

总结:
逻辑成本:上述业务需求能稍微看出,其实业务主逻辑账户 useAccount 并没有改动,只是新增了一个 Task 任务玩法但就需要对账户主逻辑进行 1/3 的迁移改造。
深入思考下在业务迭代过程中,业务逻辑其实不跟视图挂钩。
我理解业务逻辑只是操作数据与状态,而视图只是业务逻辑的一种呈现以及交互。
但是因为前端架构设计的因素,只要视图稍微变化,就会在业务逻辑没有改动时不断地 "重构" 代码,开发成本也就随之产生了。
Hooks:在通常业务迭代中,项目为了主逻辑的数据状态可以让所有组件可监听使用和操作, hooks 难免会暴露在最外层入口组件(Home)中。这时候隐喻会带来两个不好的地方:一个是全局重渲染,一个是逻辑与视图会越来越杂糅难以维护。
全局管理:复杂的业务会进行全局状态管理(redux/mobx/ustands/vuex等),便于统一地状态分发以及观状态管理,这时候通常也会造成全局状态数据滥用的问题。
▐ 思考
需求...:简单往后举几个真实的业务例子
-
点击任务按钮 => 弹出抽屉面板进而选择权益&门槛 => 面板组件与账户交互
-
提现成功后后续权益的引导 => 账户刷新信息后 => 引导用户每日提现订阅 -
......

▐ 设计思路
由于前端 JS 语言的灵活性,导致代码实现路径【条条大路通罗马】,但没有一个良好地架构通常导致维护成本、理解成本线型巨增。
【干净架构】设计

-
分层标准:(内)抽象 ==> 具体(外) -
数据的依赖关系:(外)消费数据(内) ==> (内)不能消费(外) -
分层责任独立:(外)不能影响(内)
-
【实体/模型(Entities / Models)】:业务的实体/对象,封装了最通用和抽象的规则。当某些外部因素发生变化时,它们最不可能改变。
-
【用例(Cases / Server)】:特定的业务逻辑规则,也就是业务逻辑在这一层
-
【适配器(Adapters / Application)】:具体的逻辑与视图的控制器,通常是 MVC & MVVM 的 V & VM 角色,具体地处理 用例 层数据返回与视图 UI 的数据结构。
-
【框架和驱动程序(Frameworks and Drivers)】:具体的框架 / 工具等层面的内容。
例:React / Vue3 框架 & Mysql & Webpack 构建工具
【显式架构】设计
这篇文档从【应用架构系统】的角度进行设计,我梳理重点拆分了两个核心概念【应用核心代码】、【组件】。
1、应用核心代码:主要设计项目的核心代码如何通过 DDD 层来组织逻辑架构
在软件工程中,DDD(Domain-Driven Design,领域驱动设计)层指的是软件系统中的一个重要组成部分。旨在将领域逻辑(Domain Logic)与应用程序的其他部分分离开来,使得系统的设计更加易于理解、维护和扩展。
【应用层】:项目架构的第一层直接对接用户,可以直接触发一系列核心的业务流程。例如应用程序服务、命令处理程序、展开用例 / 业务流程的逻辑等。
- 操作领域库 - 查找/更改项目领域实体/模型的具体逻辑 - 操作领域层 - 实体/模型执行对应的领域逻辑 - 执行逻辑 - 具体执行业务实际逻辑,并处理额外的副作用
-
【领域层】:独立于应用层,是各个【业务领域】实体/模型对象包含数据和操作数据的逻辑。
这个层级细分了两个主要职责:1、领域服务 2、领域模型
-
【领域服务】:操作不同数据实体/模型的具体逻辑,独立于应用层且抽象可复用。 -
【领域模型】:代表业务领域具体实体/模型的数据,通常更新时会触发额外的逻辑操作。
- 声明具体领域实体/模型的组合逻辑 - 操作实体/模型具体的数据,独立且抽象

例如:任务组件 ComA 依赖于账户组件 ComB 组件,那么可以通过【任务应用层】来组合【账户领域层】的形式,共享账户的领域实体进而触发不同组件在业务逻辑上的副作用,消除组件直接的依赖耦合。
🌟结果:一个项目有着多个低耦合高内聚的抽象组件,可独立触发核心应用逻辑,并消费所需的业务模型数据。
▐ 设计思考
上述一顿库库输出后,大概率还是一脸懵,直接用可不行,得要梳理下适合开发实践的设计思路来才行。
抽象设计
先具体分析上述开发示例,回到我们最初的几个疑惑,尝试解答:
Q:React 定位是一个用于渲染用户界面 (UI) 的 JavaScript 库,那么数据状态和业务逻辑与视图和交互无关怎么更好地设计结合?
A:React 是一个 UI 组件库,作为 UI 组件应该只消费业务状态/数据和 UI 视图交互的逻辑。本质上不应该在组件内部关心业务的生产逻辑。
Q:敏捷业务迭代中,不断变化的部分导致开发成本,能找出什么是敏捷在变,什么是敏捷在不变?
A:以视图和逻辑来抽象拆分
视图
视图组件 (变)
视图交互 变
逻辑
业务逻辑 变
接口协议一般只增 不变
基础服务逻辑 不变
Q:在敏捷迭代中,单看逻辑和视觉的变更都不难,为何结合起来迭代如此花费成本?
A:独立的逻辑/视觉变更开发成本不大,但因为业务逻辑通常耦合在视图组件中,通常在迭代过程需花大量改造和兼容成本,修改组件与业务状态/数据的结构,甚至需重构整体项目,
Q:面向视图开发,还是面向数据开发?
A:一般在业务迭代过程中都面向视图开发,基本开发流程大概如下:
1.绘制 UI 结构 + 样式 => 前端组件
2.组件中初始化业务主逻辑 => 声明接口 data 数据 state
3.根据视图动态数据 => 消费 data 数据
4.根据视图交互事件 => 声明并绑定交互逻辑(新的控制 state 状态)
面向视图开发,通常会导致视图组件与数据逻辑强耦合。
例如上述示例的需求四
具体设计
基于上述的构架思路以及抽象开发过程中的关注点,在于如何将视图组件和数据逻辑解耦,这是一个老生常谈的问题,重点在于如何通过合理的架构模式将其优雅地分层。
1.定义清楚两者的关系,在视图组件中只做数据的消费者,而数据逻辑只作为视图组件的生产者。
2.数据逻辑需关注点分离出独立的业务领域,并按照分层约定项目目录,做好职责划分。
目录约定
pages
├─UI // 视图组件
dataArea // 数据领域
├─newHome // newHome 页面挂钩的业务领域
| ├─models // 数据模型 => 业务数据/状态模型 & 约定
| | ├─Account // 🌰 账户业务领域
| | | └index.tsx
| ├─server // 数据服务 => 业务底层数据服务
| | └Account.ts // 🌰 账户业务领域
| ├─applications // 应用服务 => 上层业务逻辑
| | └Account.ts // 🌰 账户业务领域
├─common
| ├─zustand // 统一的全局数据管理库
| ├─mtop // 数据请求库

▐ 需求一:金额账户
需求一:展示账户信息
import { create } from 'zustand';
interface IAccountModel {
account: number;
}
/**
* 账户模型
*/
const accountModel = create<IAccountModel>(() => ({
account: undefined,
}));
export default accountModel;
import accountModel from '../models/account';
/** 账户服务 */
const accountServer = {
/**
* @des 获取数据
*/
getData: () => {
setTimeout(() => {
// 模拟接口请求
accountModel.setState({ account: 12.34 });
}, 1000);
},
};
export default accountServer;
c.application 业务应用
import accountServer from '../server/account';
/**
* @des 账户应用
*/
const accountApplication = {
/**
* @des 初始化
*/
init: () => {
accountServer.getData();
},
};
export default accountApplication
import accountApplication from '@/dataArea/home/applications/account';
import accountModel from '@/dataArea/home/models/account';
import styles from './index.module.less';
const Account = () => {
// 账户应用初始化
accountApplication.init();
// 消费响应式数据
const account = accountModel((state) => state.account);
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;
▐ 需求二:互动效果
需求二:【权益氛围感】金币飞入动效
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;
{
position: absolute;
top: 100rpx;
left: 100rpx;
rgba(255, 255, 255, 0.6); :
{
width: 106rpx;
height: 106rpx;
url("https://gw.alicdn.com/imgextra/i4/O1CN01hVWasj25i4dZdV9sS_!!6000000007559-2-tps-160-160.png"); :
center; :
contain; :
no-repeat; :
position: absolute;
top: 0;
left: 0;
}
}
import { useEffect, useState } from 'react';
import CoinsFly from '../CoinsFly';
import accountApplication from '@/dataArea/home/applications/account';
import accountModel from '@/dataArea/home/models/account';
import styles from './index.module.less';
const Account = () => {
// 账户应用初始化
accountApplication.init();
// 消费响应式数据
const account = accountModel((state) => state.account);
// 金币飞入动画
const [showCoinsFly, setShowCoinsFly] = useState(false);
// 响应式更新金币飞入动效
useEffect(() => {
account && setShowCoinsFly(true);
}, [account]);
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;
实现二:
▐ 需求三:权益承接
import { create } from 'zustand';
interface IPopModel {
show: boolean; // 是否展示
popData?: Record<string, string | any>; // POP 数据
onCloseCallback?: () => void;
}
/** 初始化数据 */
export const DEFAULT_MODEL = {
show: false,
popData: undefined,
onCloseCallback: () => {},
};
/**
* pop 模型
*/
const popModel = create<IPopModel>(() => ({
...DEFAULT_MODEL,
}));
export default popModel;
import popModel, { DEFAULT_MODEL } from '../models/pop';
/** 弹窗服务 */
const popServer = {
setPopData: (data) => {
popModel.setState({ popData: data });
},
openPop: () => {
popModel.setState({ show: true });
},
closePop: () => {
popModel.setState({ show: false });
},
/**
* @des 重置数据
*/
resetModel: () => {
popModel.setState(DEFAULT_MODEL);
},
};
export default popServer;
import popServer from '../server/pop';
import popModel from '../models/pop';
/**
* @des 弹窗应用
*/
const popApplication = {
open: (data) => {
popServer.setPopData(data);
popServer.openPop();
},
/**
* @des 关闭弹窗 => 触发自定义关闭回调 & 重置弹窗数据
*/
close: () => {
popServer.closePop();
popModel.getState().onCloseCallback?.();
popServer.resetModel();
},
setCustomCloseCallback: (callback) => {
popModel.setState({ onCloseCallback: callback });
},
};
export default popApplication;
import popApplication from '@/dataArea/home/applications/pop';
import popModel from '@/dataArea/home/models/pop';
import styles from './index.module.less';
// 弹窗
const Dialog = () => {
const { close } = popApplication;
const show = popModel((state) => state.show);
const popData = popModel((state) => state.popData);
const {
a,
b,
c,
d,
} = popData || {};
if (!show) return null;
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={() => close()}
>开心收下</div>
</div>
</div>
</div >
);
};
export default Dialog;
import { useEffect, useState } from 'react';
import CoinsFly from '../CoinsFly';
import accountApplication from '@/dataArea/home/applications/account';
import accountModel from '@/dataArea/home/models/account';
import styles from './index.module.less';
import popApplication from '@/dataArea/home/applications/pop';
const Account = () => {
// 账户应用初始化
accountApplication.init();
const { open: openWithdrawDialog } = popApplication;
// 消费响应式数据
const account = accountModel((state) => state.account);
// 金币飞入动画
const [showCoinsFly, setShowCoinsFly] = useState(false);
// 响应式更新金币飞入动效
useEffect(() => {
account && setShowCoinsFly(true);
}, [account]);
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={() => {
openWithdrawDialog({
a: '3000',
b: '123456789123456789',
c: '支付宝打款',
d: '提现成功,预计2小时到账',
e: '0.3',
});
}}
/>
</div>
{showCoinsFly && (
<CoinsFly
style={{
top: '322rpx',
left: '316rpx',
zIndex: 1,
}}
onEnd={() => {
setShowCoinsFly(false);
}}
/>
)}
</div>
);
};
export default Account;
总结:至此,我们已经有了【account】和【pop】的业务领域数据逻辑,并且做到了【账户】和【弹窗】视图组件的完全解耦,而不需要内置 hook 状态来耦合视图组件。
▐ 需求四:任务体系
import accountModel from '../models/account';
import accountServer from '../server/account';
/**
* @des 账户应用
*/
const accountApplication = {
/**
* @des 初始化
*/
init: () => {
accountServer.getData();
},
reFreshData: (account) => {
accountModel.setState({ account });
},
};
export default accountApplication;
2.沉浸式写一个任务组件 Task
a.通过 pop 业务应用 open 更新数据并打开弹窗
b.关闭时调用 reFreshData 刷新账户信息
import { useCallback, useEffect, useState } from 'react';
import popApplication from '@/dataArea/home/applications/pop';
import accountApplication from '@/dataArea/home/applications/account';
import styles from './index.module.less';
// 任务状态枚举
enum TASK_STATUS {
PROGRESS = 'progress',
COMPLETE = 'complete',
}
// 任务信息
const TASK_INFO_MAP = {
[TASK_STATUS.PROGRESS]: {
btn: '进行中',
},
[TASK_STATUS.COMPLETE]: {
btn: '已完成',
},
};
/** 任务组件 */
const Task = () => {
const { open: openDialog, setCustomCloseCallback } = popApplication;
const { reFreshData } = accountApplication;
// 任务状态
const [state, setState] = useState<TASK_STATUS>(TASK_STATUS.PROGRESS);
const btnCallback = useCallback(() => {
if (state === TASK_STATUS.COMPLETE) {
openDialog({
a: '3000',
b: '123456789123456789',
c: '支付宝打款',
d: '提现成功,预计2小时到账',
e: '0.3',
});
setCustomCloseCallback(() => {
reFreshData(12.04);
});
}
}, [openDialog, state]);
useEffect(() => {
setTimeout(() => {
alert('完成任务');
setState(TASK_STATUS.COMPLETE);
}, 3000);
}, []);
return (
<div className={styles.taskWrap}>
{/* icon */}
<div className={styles.taskImg} />
{/* 详情 */}
<div className={styles.taskDesc}>
<div className={styles.action}> 完成任务节即可提现 </div>
<div className={styles.detailText}>
<div className={styles.detailTextDetail}>完成后可提现 0.6 元</div>
</div>
</div>
{/* 按钮 */}
<div
className={styles.taskBtn}
onClick={() => btnCallback()}
>
{TASK_INFO_MAP[state]?.btn || ''}
</div>
</div>
);
};
export default Task;

▐ 思考
整体的架构一定有设计者核心的逻辑和目的,而上层的设计只是过程中的关键路径/方法论。
现阶段我粗浅地认为核心的逻辑和目的在于:【找出业务迭代的关键因子,以最短路径 & 最小影响达到目的】
综上实践过程有几点关键路径/方法论可以梳理总结下:
1.以业务需求定义前端页面为导向,抽象定义其业务领域模型。
领域驱动设计(Domain-Driven Design):将软件系统的核心逻辑和业务规则放在领域模型中,通过领域模型来驱动项目设计和开发,领域驱动设计强调对业务领域的深入理解和模型化。
2.关注点分离,以业务领域模型来驱动通用服务 / 应用逻辑 / UI 组件等分层处理。
分层架构(Layered Architecture):将系统划分为多个层次,每个层次具有不同的关注点和责任,分层架构提供了松耦合、可测试和可维护的系统结构。
3.自内向外的通信处理,数据流和业务逻辑清晰可见。
完整架构分层:
pages // 视图组件
├─components
| ├─Account // 账户视图组件
| ├─CoinsFly // 互动视图组件
| ├─Dialog // 弹窗视图组件
| ├─Task // 任务视图组件
├─home
dataArea // 数据领域
├─newHome
| ├─models
| | └account.ts // 🌰 账户业务模型
| | └common.ts // 🌰 基础能力模型(如关注、订阅、全局状态数据)
| ├─server
| | └account.ts // 🌰 账户业务服务
| | └common.ts // 🌰 基础能力服务
| ├─applications
| | └account.ts // 🌰 账户业务应用
| | └common.ts // 🌰 基础能力应用

如:预告页项目 - (项目注释很重要~ 👀 ) liveReplayModel:播回放模型 previewInfoModel:主播预告信息模型 1.主播 Header 信息、2.预告条信息、3.预告商品信息 commonModel:通用业务能力模型 1.全局配置、2.关注 / 分享 / 订阅
2.看主逻辑在哪调用,业务应用的初始化都做了什么~
一般来说会在项目入口处调用初始化的主逻辑:initApplication.init(data) 顺着主应用的初始化方法,顺着往下看就能知道具体的主逻辑了~
/**
* @des 初始化应用
*/
const initApplication = {
/**
* @des 预告页初始化
* - 初始化机型状态数值
* - 兜底主接口 mtop(可选)
* - 页面整体兜底状态(可选)
* - 初始化业务模型数据
* - 初始化回放 Feeds 无尽流服务(可选)
* - ALive 服务
* @param initData
*/
init: async (initData) => {
const { isInitFlag } = commonModel.getState();
if (isInitFlag) return;
commonModel.setState({ statusBarHeight: initData?.statusBarHeight || getNavbarHeight().statusBarHeight });
let data;
if (initData) {
data = initData;
} else {
// 兜底请求
try {
data = await initApplication.getData();
} catch (error) {
console.error('🔥 初始化主接口失败', error);
data = null;
}
}
const { broadCaster, preLives, modules, onlineLiveId } = data || {};
const { liveReplay } = modules || {};
/** 初始化业务模型数据 */
commonApplication.setCommonData({ liveDetail: data, isInitFlag: true });
previewInfoApplication.setData({
onlineLiveId,
anchorInfo: broadCaster,
preLives,
});
liveReplayApplication.setData({
hasMore: liveReplay?.hasMore,
data: liveReplay?.data,
});
/** 更多回放,则初始化无尽流服务 */
if (isTrue(liveReplay?.hasMore)) {
liveReplayApplication.initFeedsServer();
}
/** 初始化 ALive 配置 */
const aLiveConfigRes = await commonApplication.initAlive();
commonApplication.setCommonData({ targetLandingDetail: aLiveConfigRes });
},
/** 页面销毁 */
remove: () => {
commonServer.resetData();
liveReplayServer.resetData();
previewInfoServer.resetData();
},
};
export default initApplication;
可以看一下这一篇文章hhh,这就是写这个的一点 🤏 意义了。


▐ 架构理念
-
Yet another timer use React -
What I wish I knew about React | bitsofcode:https://bitsofco.de/what-i-wish-i-knew-about-react/ -
Clean Coder Blog:https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html -
DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together:https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/ -
《我们是否对现代前端开发框架过于崇拜了》
▐ 架构实践
-
https://feature-sliced.design/ -
https://github.com/bespoyasov/refactor-like-a-superhero/tree/main
本文分享自微信公众号 - 大淘宝技术(AlibabaMTT)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。
发表评论: