实践总结|前端架构设计的一点考究
阿里妹导读
一、背景
二、疑惑
1.在业务需求的敏捷迭代中,单看逻辑和视觉的变更都不难,为何结合起来迭代如此花费成本?
2.敏捷业务迭代中,我们能找出什么是敏捷在变,什么是敏捷不变?
3.面向视图开发,还是面向数据开发?
三、示例
先假设一个业务需求:核心关于【账户信息】
3.1 需求一:金额账户
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;
.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-repeaturl(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-repeaturl("https://img.alicdn.com/imgextra/i4/O1CN01teiAeS1tZZvwjzqx9_!!6000000005916-2-tps-129-63.png");width: 86rpx;height: 42rpx;}}}
实现一:
3.2 需求二:互动效果
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;}}
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 && (<CoinsFlystyle={{top: '322rpx',left: '316rpx',zIndex: 1,}}onEnd={() => {setShowCoinsFly(false);}}/>)}</div>);};export default Account;
实现二:
3.3 需求三:权益承接
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>{/* 按钮 */}<divclassName={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-repeaturl("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;}
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><divclassName={styles.withdraw}onClick={() => {setDialogData({a: "3000",b: "123456789123456789",c: "xxxx打款",d: "提现成功,预计2小时到账",e: "0.3",})setShowDialog(true);}}/></div>{/* 金币飞入 */}{showCoinsFly && (<CoinsFlystyle={{top: '322rpx',left: '316rpx',zIndex: 1,}}onEnd={() => {setShowCoinsFly(false);}}/>)}{/* 提现弹窗 */}{showDialog &&<WithdrawDialogdata={dialogData}onClose={() => {refreshAccount(12.04);setShowDialog(false);}}/>}</div>);};export default Account;
实现三:
遵循着解耦以及内聚最小化的原则,将控制账户抽象为 hooks,后续可以在其他视图组件使用。
这里其实稍微暴露了让我难受的一点,因为视图需要与状态和方法做逻辑交互,一来二去 hooks 要将近乎所有的状态方法都抛出...
3.4 需求四:任务体系
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>{/* 按钮 */}<divclassName={styles.taskBtn}onClick={() => {}}>{TASK_INFO_MAP[state]?.btn || ''}</div></div>);};export default Task;
.taskWrap {position: relative;top: 200rpx;border-radius: 30rpx;padding: 25rpx 30rpx 25rpx 24rpx;background: #fae3e4;display: flex;align-items: center;position: relative;.taskImg {width: 92rpx;height: 92rpx;background-image: url("https://gw.alicdn.com/imgextra/i4/O1CN01VqCtbK1vhU0PZUTzR_!!6000000006204-2-tps-80-80.png");background-size: 100% 100%;position: relative;}.taskDesc {flex: 1;width: 370rpx;padding: 4rpx 0 2rpx;margin-left: 20rpx;display: flex;flex-direction: column;justify-content: space-between;.action {margin-bottom: 20rpx;font-weight: bold;display: flex;align-items: center;font-size: 28rpx;color: #942703;}.detailText {color: #a07466;font-size: 24rpx;height: 32rpx;display: flex;align-items: center;overflow: hidden;.detailTextDetail {display: flex;flex-direction: row;justify-content: center;align-items: center;}}}.taskBtn {width: 144rpx;height: 68rpx;display: flex;justify-content: center;align-items: center;border-radius: 34rpx;background-image: linear-gradient(145deg, #ff5d83, #ff2929);font-size: 28rpx;color: #fff;}}
import Account from "./Account";import Task from "./Task";/** 主入口 */const Architecture = () => {return (<>{/* 账户信息 */}<Account />{/* 任务 */}<Task /></>)};export default Architecture;
🔥 前面为了业务逻辑整洁,封装账户信息 hooks。但这时候在 Task 组件中,在完成任务后也要处理弹窗数据以及展示提现弹窗,也就是 setDialogData 和 setShowDialog。
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 (<>{/* 账户信息 */}<Accountaccount={account}refreshAccount={refreshAccount}showCoinsFly={showCoinsFly}setShowCoinsFly={setShowCoinsFly}showDialog={showDialog}setShowDialog={setShowDialog}dialogData={dialogData}setDialogData={setDialogData}/>{/* 任务 */}<TasksetShowDialog={setShowDialog}setDialogData={setDialogData}/></>)};export default Architecture;
4.Account和 Task 组件改造数据状态分发逻辑
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 (<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><divclassName={styles.withdraw}onClick={() => {setDialogData({a: "3000",b: "123456789123456789",c: "支付宝打款",d: "提现成功,预计2小时到账",e: "0.3",})setShowDialog(true);}}/></div>{/* 金币飞入 */}{showCoinsFly && (<CoinsFlystyle={{top: '322rpx',left: '316rpx',zIndex: 1,}}onEnd={() => {setShowCoinsFly(false);}}/>)}{/* 提现弹窗 */}{showDialog &&<WithdrawDialogdata={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>{/* 按钮 */}<divclassName={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等),便于统一地状态分发以及观状态管理,这时候通常也会造成全局状态数据滥用的问题。
3.5 思考
点击任务按钮 => 弹出抽屉面板进而选择权益&门槛 => 面板组件与账户交互
提现成功后后续权益的引导 => 账户刷新信息后 => 引导用户每日提现订阅 ......
四、设计
4.1 设计思路
4.1.1【干净架构】设计
分层标准:(内)抽象 ==> 具体(外) 数据的依赖关系:(外)消费数据(内) ==> (内)不能消费(外) 分层责任独立:(外)不能影响(内)
【实体/模型(Entities / Models)】:业务的实体/对象,封装了最通用和抽象的规则。当某些外部因素发生变化时,它们最不可能改变。
【用例(Cases / Server)】:特定的业务逻辑规则,也就是业务逻辑在这一层
【适配器(Adapters / Application)】:具体的逻辑与视图的控制器,通常是 MVC & MVVM 的 V & VM 角色,具体地处理 用例 层数据返回与视图 UI 的数据结构。
【框架和驱动程序(Frameworks and Drivers)】:具体的框架 / 工具等层面的内容。
4.1.2【显式架构】设计
【应用层】:项目架构的第一层直接对接用户,可以直接触发一系列核心的业务流程。例如应用程序服务、命令处理程序、展开用例 / 业务流程的逻辑等。
- 操作领域库 - 查找/更改项目领域实体/模型的具体逻辑 - 操作领域层 - 实体/模型执行对应的领域逻辑 - 执行逻辑 - 具体执行业务实际逻辑,并处理额外的副作用
【领域层】:独立于应用层,是各个【业务领域】实体/模型对象包含数据和操作数据的逻辑。
这个层级细分了两个主要职责:1、领域服务 2、领域模型
【领域服务】:操作不同数据实体/模型的具体逻辑,独立于应用层且抽象可复用。
【领域模型】:代表业务领域具体实体/模型的数据,通常更新时会触发额外的逻辑操作。
- 声明具体领域实体/模型的组合逻辑 - 操作实体/模型具体的数据,独立且抽象
例如:任务组件 ComA 依赖于账户组件 ComB 组件,那么可以通过【任务应用层】来组合【账户领域层】的形式,共享账户的领域实体进而触发不同组件在业务逻辑上的副作用,消除组件直接的依赖耦合。
4.2 设计思考
4.2.1 抽象设计
视图
视图组件 (变) 视图交互 变
逻辑
业务逻辑 变 接口协议一般只增 不变 基础服务逻辑 不变
例如上述示例的需求四
4.2.2 具体设计
pages├─UI // 视图组件dataArea // 数据领域├─newHome // newHome 页面挂钩的业务领域| ├─models // 数据模型 => 业务数据/状态模型 & 约定| | ├─Account // 🌰 账户业务领域| | | └index.tsx| ├─server // 数据服务 => 业务底层数据服务| | └Account.ts // 🌰 账户业务领域| ├─applications // 应用服务 => 上层业务逻辑| | └Account.ts // 🌰 账户业务领域├─common| ├─zustand // 统一的全局数据管理库| ├─mtop // 数据请求库
3.视图组件尽可能无状态、抽象可复用,视图组件内聚状态,通信交互则通过数据逻辑触发应用服务 applications,消费数据模型 models 即可。
五、实践
一样是核心关于【账户信息】的业务需求
5.1 需求一:金额账户
import { create } from 'zustand';interface IAccountModel {account: number;}/*** 账户模型*/const accountModel = create<IAccountModel>(() => ({account: undefined,}));export default accountModel;
b.server 业务服务
import accountModel from '../models/account';/** 账户服务 */const accountServer = {/*** @des 获取数据*/getData: () => {setTimeout(() => {// 模拟接口请求accountModel.setState({ account: 12.34 });}, 1000);},};export default accountServer;
import accountServer from '../server/account';/*** @des 账户应用*/const accountApplication = {/*** @des 初始化*/init: () => {accountServer.getData();},};export default accountApplication;
2.视图组件:Account
b.消费业务模型数据
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;
完成一:
5.2 需求二:互动效果
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.金币组件由账户组件消费,直接在组件内控制展示 state 状态即可。
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 && (<CoinsFlystyle={{top: '322rpx',left: '316rpx',zIndex: 1,}}onEnd={() => {setShowCoinsFly(false);}}/>)}</div>);};export default Account;
实现二:
5.3 需求三:权益承接
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;
b.server 业务服务
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;
c.application 业务应用
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;
2.抽象弹窗视图组件,而非【提现弹窗】的定制化视图组件
a.消费弹窗数据模型 popModel
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>{/* 按钮 */}<divclassName={styles.btn}onClick={() => close()}>开心收下</div></div></div></div >);};export default Dialog;
3.账户视图组件,通过弹窗的应用逻辑交互
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><divclassName={styles.withdraw}onClick={() => {openWithdrawDialog({a: '3000',b: '123456789123456789',c: '支付宝打款',d: '提现成功,预计2小时到账',e: '0.3',});}}/></div>{showCoinsFly && (<CoinsFlystyle={{top: '322rpx',left: '316rpx',zIndex: 1,}}onEnd={() => {setShowCoinsFly(false);}}/>)}</div>);};export default Account;
实现三:
5.4 需求四:任务体系
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;
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>{/* 按钮 */}<divclassName={styles.taskBtn}onClick={() => btnCallback()}>{TASK_INFO_MAP[state]?.btn || ''}</div></div>);};export default Task;
实现四:
5.5 思考
领域驱动设计(Domain-Driven Design):将软件系统的核心逻辑和业务规则放在领域模型中,通过领域模型来驱动项目设计和开发,领域驱动设计强调对业务领域的深入理解和模型化。
分层架构(Layered Architecture):将系统划分为多个层次,每个层次具有不同的关注点和责任,分层架构提供了松耦合、可测试和可维护的系统结构。
pages // 视图组件├─components| ├─Account // 账户视图组件| ├─CoinsFly // 互动视图组件| ├─Dialog // 弹窗视图组件| ├─Task // 任务视图组件├─homedataArea // 数据领域├─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.关注 / 分享 / 订阅
一般来说会在项目入口处调用初始化的主逻辑: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;
3.看视图组件是怎么抽象的,也就能知道整个页面的结构布局。
可以看一下这一篇文章hhh,这就是写这个的一点 🤏 意义了。
七、规划
Ref
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