函数式编程中各种封装的对比以及封装思路解析

本文只在掘金发布!年前还在写文章的作者不多了啊,还不给个赞😁

平时在开发过程中,难免会碰到重复的组件和业务逻辑,该如何封装确实是一个问题,本文就业务封装这个问题,给各位提供一个思路。

其实不论在开源库的开发,还是公司具体业务逻辑的开发中,封装的思路都是一致的,就是要保证 单一职责原则,并实现内部信息的隐藏和抽象,同时需要尽可能的解耦合,或者采用组合的方式来封装。

从设计模式上解释,就是说要使用一个工厂类或组件,批量生产相同业务逻辑的产品,同时需要支持通过不同的参数动态的切换工厂产品的类别,此外,每一个产品也可以简入监听者机制,让观察者更好的把控生产出来的产品的动向和状态变化。

好的封装的表现

  • 稳定,广泛使用后不出故障
  • 数据隔离,不影响其他组件和数据
  • 易用,引入即可使用,不需要单独做过多适配
  • 高复用性,至少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 仓库里(可以是你的私有仓库)就会有这个发布的版本了。

然后在项目里可以使用:

对于一些公共库,你可以直接在依赖里直接安装使用:


完!有不同的见解欢迎补充指正

相关推荐
zhanggongzichu几秒前
npm常用命令
前端·npm·node.js
anyup_前端梦工厂7 分钟前
从浏览器层面看前端性能:了解 Chrome 组件、多进程与多线程
前端·chrome
chengpei14716 分钟前
chrome游览器JSON Formatter插件无效问题排查,FastJsonHttpMessageConverter导致Content-Type返回不正确
java·前端·chrome·spring boot·json
我命由我1234524 分钟前
NPM 与 Node.js 版本兼容问题:npm warn cli npm does not support Node.js
前端·javascript·前端框架·npm·node.js·html5·js
每一天,每一步34 分钟前
react antd点击table单元格文字下载指定的excel路径
前端·react.js·excel
浪浪山小白兔35 分钟前
HTML5 语义元素详解
前端·html·html5
晚秋贰拾伍37 分钟前
设计模式的艺术-命令模式
运维·设计模式·运维开发·命令模式·开闭原则
小魔女千千鱼1 小时前
【真机调试】前端开发:移动端特殊手机型号有问题,如何在电脑上进行调试?
前端·智能手机·真机调试
16年上任的CTO1 小时前
一文大白话讲清楚webpack基本使用——11——chunkIds和runtimeChunk
前端·webpack·node.js·chunksid·runtimechunk
Orange3015111 小时前
【自己动手开发Webpack插件:开启前端构建工具的个性化定制之旅】
前端·javascript·webpack·typescript·node.js