本文只在掘金发布!年前还在写文章的作者不多了啊,还不给个赞😁
平时在开发过程中,难免会碰到重复的组件和业务逻辑,该如何封装确实是一个问题,本文就业务封装这个问题,给各位提供一个思路。
其实不论在开源库的开发,还是公司具体业务逻辑的开发中,封装的思路都是一致的,就是要保证 单一职责原则,并实现内部信息的隐藏和抽象,同时需要尽可能的解耦合,或者采用组合的方式来封装。
从设计模式上解释,就是说要使用一个工厂类或组件,批量生产相同业务逻辑的产品,同时需要支持通过不同的参数动态的切换工厂产品的类别,此外,每一个产品也可以简入监听者机制,让观察者更好的把控生产出来的产品的动向和状态变化。
好的封装的表现
- 稳定,广泛使用后不出故障
- 数据隔离,不影响其他组件和数据
- 易用,引入即可使用,不需要单独做过多适配
- 高复用性,至少3处业务逻辑中能够使用
- 性能好
- 可扩展,能够提供类似于插件功能,不改源代码的前提下增强封装
- 可测试
- 文档齐全
封装的种类
根据场景和用途可以分为数据封装、功能封装、接口封装、UI 封装等多个层面。
依据具体实现形式,又可分为 Hook封装、组件封装、普通函数封装、ES模块化提取(用好 import和export)、npm打包等。
数据封装
应用场景:
- 状态管理工具的封装(如 Redux、MobX)
- 数据格式化(如时间格式转换、货币显示)
- 本地存储封装(如对 LocalStorage 或 SessionStorage 的封装)
- 微服务里消息传递服务
- ...
功能封装
也可以叫逻辑封装,这个范畴就很广了,封装内部的所有成员都是为了实现这一功能或者业务逻辑而存在的,我们在业务中最常用。
应用场景:
- 表单验证逻辑。
- API 请求封装。
- 通用工具方法(如深拷贝、节流、防抖等)。
- 获取接口信息 (如实现一个hook,初始化时请求接口或者登录来获取用户数据)
- 大型组件开发中的拆解封装 (如x6中,一个节点、一个边,画布等都是独立的组件)
- 滚动、窗口大小等事件监听逻辑。
- ...
接口封装
一般是封装系统内的接口请求,统一调用。
这里给出一个 axios 最小使用范例:
js
// 封装 service
function services(baseConfig, headers) {
const { baseURL, timeout, method, responseType } = baseConfig;
axios.defaults.headers['Content-Type'] = 'application/json; charset=UTF-8';
axios.defaults.baseURL = baseURL;
const instance = axios.create({
timeout,
method,
responseType
});
instance.interceptors.request.use(
function (config) {
// ...
return config;
},
function (error) {
return Promise.reject(error);
}
);
instance.interceptors.response.use(
async function (response) {
let Action = parseAction(response);
if (response.data.RetCode === ErrorCodeMap.notLogged) {
// 未登录逻辑处理
return;
}
// 普通请求的错误信息
if (response.data.RetCode !== 0) {
return Promise.reject({
message: response.data.Message,
RetCode: response.data.RetCode
});
}
return response;
},
function (error) {
let Action = parseAction(error);
// 接口非200处理逻辑
return Promise.reject(error);
}
);
return instance;
}
// 导出 fetch
export const fetch = services(baseConfig, defaultHeaders);
UI 封装
此类封装侧重于样式提取和 css 的聚合。
应用场景:
- 设计系统(如 Ant Design、Material-UI 中提供 useTheme 和 designToken 配置主题)
- 开发组件库
- 复用性强的交互组件(如图表、表格、分页器等)
- ...
封装思路示例
案例讲解:axios 封装
还是上边,接口封装的例子,他只是一个简单的实现,其中有很多问题。
比如,不同的业务模块,可能baseUrl是一样的,但后缀一定是不一样的,但是一开始传入的baseConfig是定死的,这个要如何处理?
可考虑生成多分 service 单例,这个services就是一个工厂,内部怎么处理数据、怎么拦截错误,在出厂时就设置好了,对照说明书直接使用即可。
js
export const fetch = services(baseConfig, defaultHeaders);
export const fetchFile = services(fileConfig, defaultHeaders);
这里的 axios.create 产生出一个个的 service,就是为了解耦,让不同类别的请求之间的数据隔离
但是,对于同一个 fetch,baseConfig 也可能不一样,比如有 GET,有POST 等不同的方式,如何告诉 service 呢?当然可以在 baseConfig 里写method,但是这就有好几个了:
js
export const fetch = services(getBaseConfig, defaultHeaders);
export const postfetch = services(postBaseConfig, defaultHeaders);
export const putfetch = services(putBaseConfig, defaultHeaders);
你当然可以这么写,但是这么写有点想当然了。这样写产生了不必要的 axios 实例,违背了复用性原则。仅仅是 method 不一样,你完全可以再包一层 fetchData,接受 method 参数 (其他参数也一样),都统一调用这一个 fetch 即可。
再比如,接口虽然是有了,但是我有个业务需求需要轮询,我该怎么实现?
第一印象是设置定时器,当拿到数据后关掉定时器;再一想也可以使用 定时器 + axios 的 AbortController,但是需要维护一个局部的 controller 来调用:
js
function fetchData(url) {
// 创建一个 AbortController 实例
const controller = new AbortController();
// 发起请求并传递 signal
const request = fetch({
// ...
signal: controller.signal, // 传递 AbortController 的 signal
});
// 返回请求和控制器,方便调用者使用
return { request, controller };
}
// 示例用法
const { request, controller } = fetchData('/api/data');
request
.then((response) => {
console.log('Response:', response.data);
});
// 在外部的定时器中,先取消请求,再调用 fetchData
controller.abort();
如果时间紧任务重,可考虑 useRequest:
js
const { run, cancel } = useRequest(youService, {
manual: true,
pollingInterval: 5000,
onSuccess: (resp) => {
...
}
})
案例讲解:页面权限判断的功能
我先描述一下需求,这个也是最近刚做的一个业务:
描述:需要通过接口获取当前用户订阅的套餐包和余额,动态提示用户
判断条件:如果套餐包里有发邮件这个权限,就可以使用,否则的话再判断余额有没有,有的话直接扣余额也可以下发,如果余额也没有了,就弹窗提示去订阅或充值。
接到这个需求你会怎么做?
直接的做法,在需要判断的地方,页面初始化调接口分别获取套餐包和余额,再加一个判断函数,然后在点击发送的时候调用这个判断函数,不合适就弹窗。
调接口获取套餐包的功能用的比较多,可以封装起来放在 redux 里,余额的话,因为扣费的渠道太多,有可能有的是后台或者第三方直接扣费的,为了保持数据最新需要实时获取,在需要的时候直接请求就行。但是这个弹窗呢?因为发送这个动作在这个业务系统里有好几处,不能每个地方都写一遍弹窗吧。
我就单独封装到公共组件中了,但是又有个问题,判断函数和这个弹窗组件,逻辑上是割裂的,但功能上又是统一的。但若放在不同的位置,这不满足高内聚的原则,因为他们目前还是高度绑定的,这怎么办呢,我干脆一不做二不休,把判断函数和弹窗写在一起,做了一个高阶组件:
js
export default function SendGuardian(props) {
const { children: Children, product, callback } = props;
const navigate = useNavigate();
// redux 里拿套餐包
const buyPackage = useSelector((state) => state.customization.buyPackage) || {};
const [amount, setAmount] = useState();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const getBalance = useCallback(() => {
// 接口拿实时余额
}, []);
// 套餐包有这个购买权限
const current = buyPackage.flag && buyPackage.EmailLimit > 0;
// 判断函数
const canSend = () => {
return current || amount > 0;
}
// 给孩子传入一个 onClick,点击后调用
const handleClick = (e) => {
e?.stopPropagation();
if (canSend()) {
callback && callback(e);
return;
}
// 没有权限发送就弹窗提示
setOpen(true);
};
const handleCloseDialog = () => {
setOpen(false);
};
// 初始化调用接口
useEffect(() => {
getBalance();
}, []);
return (
<>
{loading ? (
<CircularProgress size={24} sx={{ ml: 4 }} />
) : (
<>
// 记得首字母大写
<Children onClick={handleClick} />
</>
)}
<Dialog open={open} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
我是弹窗提示
</Dialog>
</>
);
}
下面是使用:
js
<SendGuardian callback={() => apply(val)}>
{({ onClick }) => (
<IconButton aria-label="send" size="small" onClick={onClick}>
<SendIcon fontSize="small" />
</IconButton>
)}
</SendGuardian>
在使用时,传入一个匿名组件,这个组件会接受一个上面传入的那个 onClick,这个 onClick 就可以被内部自定义的使用了。callback 可以作为有权限时的回调使用。
是不是觉得这个封装很完美???然而不然,反而相当糟糕!!
首先,他的适用性比较差,他只能使用在页面只有一两个的地方,如果是列表就不行了。试想,一个 Table 的每一行都有个发送按钮,这个按钮需要权限,那我们在 render 时候是不是每一行都需要用这个 SendGuardian 包裹一下呀?
结果如图:
每一次使用都请求了接口,这是不能忍受的,显然这里不适用。
其次,解耦合也没有做得很好,发送守卫和获取余额本质上是没有关系的,我们还是应该拆开来写。
来看看 2.0 版:
将获取实时余额单独封装为一个 hook:
js
export const useAmount = () => {
const [loading, setLoading] = useState(false);
const [amount, setAmount] = useState();
useEffect(() => {
// GetBalance 并 setAmount
}, []);
return [amount, loading];
};
SendGuardian 就可以精简了, 新加两个 props:amount, loading:
js
export default function SendGuardian(props) {
const { children: Children, product, amount, loading, callback } = props;
const navigate = useNavigate();
// redux 里拿套餐包
const buyPackage = useSelector((state) => state.customization.buyPackage) || {};
const [open, setOpen] = useState(false);
// 套餐包有这个购买权限
const current = buyPackage.flag && buyPackage.EmailLimit > 0;
// 判断函数
const canSend = () => {
return current || amount > 0;
}
// 给孩子传入一个 onClick,点击后调用
const handleClick = (e) => {
e?.stopPropagation();
if (canSend()) {
callback && callback(e);
return;
}
// 没有权限发送就弹窗提示
setOpen(true);
};
const handleCloseDialog = () => {
setOpen(false);
};
return (
<>
{loading ? (
<CircularProgress size={24} sx={{ ml: 4 }} />
) : (
<>
// 记得首字母大写
<Children onClick={handleClick} />
</>
)}
<Dialog open={open} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
我是弹窗提示
</Dialog>
</>
);
}
这样,那个多次请求接口的问题就解决了
但是还没完,这个封装还需要考虑判断函数的适用性。
这里只是做了能不能发邮件的判断,如果有新的业务来,canSend 内部逻辑可能完全不一样,弹窗提示可能也不一样。可以考虑将所有的业务形态封装一个枚举,在这个 SendGuardian 组件里在接受一个 productType 字段来区分业务,各个具体的判断逻辑又可以从各个业务形态中获取,而不是写死在 SendGuardian 里。上面也提到了,判断函数和弹窗本身逻辑上也是没关系的,他们不应该被写在一起。
js
export const useCanSend = (amount, productType, loading) => {
const buyPackage = useSelector((state) => state.customization.buyPackage) || {};
const current = buyPackage.flag && buyPackage.EmailLimit > 0;
if (loading) {
return false;
}
// TODO: 这里可以根据 productType 来写判断,判断逻辑可以从各个业务封装中读取
return current || amount > 0;
};
此时,SendGuardian 又能减负了:
js
// 函数内不要任何的硬编码,上面的 loadingSize={24} 也要提出传参,amount参数就不要了
export default function SendGuardian(props) {
const { children: Children, productType, canSend, loading, loadingSize, callback } = props;
const navigate = useNavigate();
const [open, setOpen] = useState(false);
// 给孩子传入一个 onClick,点击后调用
const handleClick = (e) => {
e?.stopPropagation();
if (canSend) {
callback && callback(e);
return;
}
// 没有权限发送就弹窗提示
setOpen(true);
};
const handleCloseDialog = () => {
setOpen(false);
};
return (
<>
{loading ? (
<CircularProgress size={loadingSize} />
) : (
<>
// 记得首字母大写
<Children onClick={handleClick} />
</>
)}
// 弹窗提示也可以根据 productType 定制,当然也可以单独为一个组件,这里引用
<Dialog open={open} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
我是弹窗提示
</Dialog>
</>
);
}
封装的原则之一就是解耦合,不要硬编码!虽然我们不是写的 vue、antd 这种大型的公共库,但小的业务项目也不能马虎。
使用:
js
const productType = ProcuctType.Email;
const [amount, amountLoading] = useAmount();
const canSend = useCanSend(amount, productType, amountLoading);
...
<SendGuardian
productType={productType}
canSend={canSend}
loading={amountLoading}
loadingSize={16}
callback={() => apply(item)}
>
{({ onClick }) => (
<IconButton aria-label="send" size="small" onClick={onClick}>
<SendIcon fontSize="small" />
</IconButton>
)}
</SendGuardian>
这里的 loading 传入的是 amountLoading,后面有其他的请求判断时,直接都传入loading即可
这里的实现只是伪代码的形式,很多细节没有深究,比如 canSend 的判空、Children 类型检测、只是单一监听 onClick等。
如果你有代码封装的经验,还能看出上面还有不少问题,比如 这个 Dialog,写死在这里肯定不合适,是不是写一个 CommonModal 然后这里引用呢?CommonModal 内部就可以自由的传参来控制显示的文字和形态了。
上面的例子,就涵盖了功能封装、Hook封装、组件封装等,他们直接区别如下:
特性 | 封装 Hook | 封装组件 | 封装普通函数 |
---|---|---|---|
作用域 | 用于管理组件中的状态、副作用等,比如接口调用 | 用于定义 UI 组件和它的逻辑,比如弹窗 | 用于处理数据或功能逻辑,比如 canSend |
返回值 | 返回状态和操作方法 | 返回 JSX,表示组件 UI | 返回数据或结果 |
依赖关系 | 必须遵守 Hook 的规则,避免条件/循环中的调用 | 组件可以有 props,但需避免过于复杂的接口 | 一般与外部状态无关,专注于计算或操作 |
更新方式 | 通过 useState, useEffect 等管理更新 | 通过状态或 props 来驱动渲染更新 | 直接返回计算结果,无视 UI 渲染状态 |
案例讲解:类式封装
当然了,函数式编程中,类式封装也有他的一席之地。普通函数或者hook函数,本质上都是函数,只要在组件顶层中写了调用,每一次组价渲染都会被执行,会产生数据反复调用的情况。
这里举一个表单项的封装。
js
function FormItem(type, field) {
const filterItem = {
TextField: <TextField {...field} />,
Select: <Select {...field} />
}
return filterItem[type];
}
这样封装的目的就是为了只定义一遍表单项,业务消费的时候直接返回声明好的。但是这样很成问题!!
一方面,传入的 type 不可控,出现找不到的情况就会返回 undefined;更重要的是,每次调用 FormItem,filterItem 都会被重新创建,生成一个大的对象在内存里,这就意味着整个 filterItem 内部的元素都被初始化了,即使你不使用这个 key 下的元素,内存浪费都是小事,偶尔会出现数据类型错误的页面崩溃才是大问题!
此时,类式封装就很有用,可以这样:
js
class FormItemComponent {
static TextField(field) {
return (
<TextField {...field} />
}
}
static Select(field) {
return (
<Select {...field} />
}
}
}
在函数式组件中使用:
js
const FormItem = FormItemComponent['TextField'];
静态方法是独立的函数,只有在明确调用某个静态方法时,才会执行该方法的逻辑,可以按需调用。
案例讲解:大型组件的封装
一些大型的独立的模块,往往需要开发者对需求进行细分后拆解封装。
这里举一个antv x6 的例子,看下面的画布,你想要怎么布局你的文件结构呢:
下手之前要先明白 x6 的基础 API 有哪些,清楚需求和功能,然后逐步拆解:
- 画布(是否拖拽、是否点击、是否滚动等)
- 自定义节点1(单输出)
- 自定义节点2 (两个输出)
- 自定义边1 (绿色)
- 自定义边2 (红色)
- 点击加号的添加弹窗
- 点击节点的表单弹窗
- 操作 bar
- 小地图
画布应该是基底,应该最先渲染,其他的组件应该是注册在画布上的;自定义节点需要单独封装,留好插槽给画布;点击加号的添加弹窗可以与节点封装在一起,如果每一个节点点击后弹窗是一样的,可以单独封装;点击节点的表单弹窗大概率是要和对应的节点类型高度绑定的;小地图可以猜出来 需要 x6 支持;操作 bar 可以自己写一个,绝对定位上去。
然后就可以布局文件结构了:
Xflow.jsx 中初始化画布:
js
const graph = new Graph({
container: this.container,
...
})
注册小地图:
js
graph.use(
new MiniMap({
...
})
)
注册两种类型节点:
js
import { register } from '@antv/x6-react-shape';
register({
shape: 'custom-react-node',
ports: {...},
// 引入 Node.jsx 中自定义的 dom 结构
component: (props) => <CustomComponent {...props} tree={tree} readonly={this.readonly} />
})
register({
shape: 'custom-react-condition-node',
ports: {...},
component: (props) => <CustomComponent {...props} tree={tree} readonly={this.readonly} />
})
注册两种边:
js
import { Graph, NodeView } from '@antv/x6';
Graph.registerEdge('next', {
inherit: 'edge',
label: '',
router: {
name: 'manhattan',
args: {
startDirections: ['top'],
endDirections: ['bottom']
}
},
connector: { name: 'rounded' },
attrs: {
line: {
sourceMarker: 'circle',
targetMarker: 'classic',
stroke: '#80CBC4',
strokeWidth: 4
}
}
});
Graph.registerEdge('false_next', {
inherit: 'edge',
label: '',
...
});
放入初始化数据:
js
graph.fromJSON(model, { silent: true });
这样初始化封装就完成了,接下来在这个基础上就可以开发诸如 增加节点、边,删除,弹窗等操作了。
上面的描述在于讲解如何拆解大的组件,从而更好地下手,并非 x6 的使用讲解
一些基础组件的封装可以看这篇:# React项目工程化业务封装实践
案例讲解:npm 封装
封装并发布到 npm 的包在前端开发中有许多使用场景,主要是为了提高代码复用性、共享性、可维护性和可扩展性。
npm 封装一般是打包自己的类库、工具库、样式库或者是公共组件等,他的使用方式与直接使用第三方依赖差不多。
比如我想自己写一套 Eslint 校验规则,让所有子项目都用这一套规则。可以参考:# ESLint配置项详解,并将配置打包npm
然后执行打包指令:
sh
npm run publish:patch
此时在npm 仓库里(可以是你的私有仓库)就会有这个发布的版本了。
然后在项目里可以使用:
对于一些公共库,你可以直接在依赖里直接安装使用:
完!有不同的见解欢迎补充指正