hooks、mixin、hoc、普通函数封装之间的简单对比

235 阅读4分钟

在 Vue 开发中,逻辑复用是提高代码质量和开发效率的核心技巧。今天就通过对比四种主流的逻辑复用方案:Hooks(Vue3)、Mixin、HOC(高阶组件)和普通函数封装,看下有啥不一样。

场景定义

我们以一个常见的 "用户信息加载" 功能为例,该功能包含以下逻辑:

  • 加载用户数据
  • 管理加载状态(加载中 / 加载完成 / 加载失败)
  • 提供重新加载方法
  • 组件卸载时取消未完成的请求

我们将用四种不同方案实现相同功能,以便直观对比。

1. 普通函数封装

普通函数封装是最基础的逻辑复用方式,通过函数将逻辑打包,返回需要的数据和方法。

// userLoader.js
import axios from 'axios';

export function createUserLoader() {
  let user = null;
  let loading = false;
  let error = null;
  let cancelToken = null;

  const loadUser = async (userId) => {
    // 取消之前的请求
    if (cancelToken) {
      cancelToken.cancel('Previous request canceled');
    }
    
    loading = true;
    error = null;
    cancelToken = axios.CancelToken.source();

    try {
      const response = await axios.get(`/api/users/${userId}`, {
        cancelToken: cancelToken.token
      });
      user = response.data;
      return user;
    } catch (err) {
      if (!axios.isCancel(err)) {
        error = err.message;
      }
      throw err;
    } finally {
      loading = false;
    }
  };

  return {
    user,
    loading,
    error,
    loadUser,
    cancel: () => cancelToken?.cancel('Manual cancel')
  };
}

在组件中使用:

vue

<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-if="error" class="error">{{ error }}</div>
    <div v-if="user">
      <h3>{{ user.name }}</h3>
      <p>{{ user.email }}</p>
    </div>
    <button @click="loadUser(1)">加载用户</button>
  </div>
</template>

<script>
import { createUserLoader } from './userLoader';

export default {
  data() {
    const userLoader = createUserLoader();
    return {
      user: userLoader.user,
      loading: userLoader.loading,
      error: userLoader.error,
      loadUser: userLoader.loadUsercancel: userLoader.cancel,
    };
  },
  beforeUnmount() {
    this.cancel();
  }
};
</script>

缺点很明显就是缺乏响应性,但是呢,代码看上去就很简单明了。

适用场景

  • 简单的工具类逻辑复用
  • 非响应式数据处理
  • 跨框架共享的通用逻辑

2. Mixin(混入)

Mixin 是 Vue2 中官方推荐的逻辑复用方式,允许将组件选项合并到多个组件中。

// userLoaderMixin.js
import axios from 'axios';

export default {
  data() {
    return {
      user: null,
      userLoading: false,
      userError: null,
      userCancelToken: null
    };
  },
  methods: {
    loadUser(userId) {
      // 取消之前的请求
      if (this.userCancelToken) {
        this.userCancelToken.cancel('Previous request canceled');
      }
      
      this.userLoading = true;
      this.userError = null;
      this.userCancelToken = axios.CancelToken.source();

      return axios.get(`/api/users/${userId}`, {
        cancelToken: this.userCancelToken.token
      })
        .then(response => {
          this.user = response.data;
          return response.data;
        })
        .catch(err => {
          if (!axios.isCancel(err)) {
            this.userError = err.message;
          }
          throw err;
        })
        .finally(() => {
          this.userLoading = false;
        });
    },
    cancelUserLoad() {
      this.userCancelToken?.cancel('Manual cancel');
    }
  },
  beforeUnmount() {
    this.cancelUserLoad();
  }
};

在组件中使用:

vue

<template>
  <div>
    <div v-if="userLoading">加载中...</div>
    <div v-if="userError" class="error">{{ userError }}</div>
    <div v-if="user">
      <h3>{{ user.name }}</h3>
      <p>{{ user.email }}</p>
    </div>
    <button @click="loadUser(1)">加载用户</button>
  </div>
</template>

<script>
import userLoaderMixin from './userLoaderMixin';

export default {
  mixins: [userLoaderMixin],
  // 组件其他选项...
};
</script>

看上去就很简单明了,也不缺乏响应性。

但是有个缺点,如果是大的项目中去修改文件的时会有重名的问题,因为mixin是与原组件中的api是进行合并的操作,并非覆盖操作,所以一旦出现重名就会出现问题,在排查的时候也会比较难受。大的项目中要做好规范,尽量把名字啥的写成唯一的。

适用场景

  • Vue2 项目中简单的逻辑复用
  • 不需要参数配置的通用逻辑
  • 团队内部有明确规范的项目

3. HOC(高阶组件)

高阶组件是一个接收组件作为参数并返回新组件的函数,通过包装组件实现逻辑复用。

// withUserLoader.js
import Vue from 'vue';
import axios from 'axios';

export function withUserLoader(WrappedComponent) {
  return Vue.extend({
    data() {
      return {
        user: null,
        userLoading: false,
        userError: null,
        userCancelToken: null
      };
    },
    methods: {
      loadUser(userId) {
        // 取消之前的请求
        if (this.userCancelToken) {
          this.userCancelToken.cancel('Previous request canceled');
        }
        
        this.userLoading = true;
        this.userError = null;
        this.userCancelToken = axios.CancelToken.source();

        return axios.get(`/api/users/${userId}`, {
          cancelToken: this.userCancelToken.token
        })
          .then(response => {
            this.user = response.data;
            return response.data;
          })
          .catch(err => {
            if (!axios.isCancel(err)) {
              this.userError = err.message;
            }
            throw err;
          })
          .finally(() => {
            this.userLoading = false;
          });
      },
      cancelUserLoad() {
        this.userCancelToken?.cancel('Manual cancel');
      }
    },
    beforeUnmount() {
      this.cancelUserLoad();
    },
    render(h) {
      // 将状态和方法通过 props 传递给被包装组件
      return h(WrappedComponent, {
        props: {
          ...this.$props,
          user: this.user,
          userLoading: this.userLoading,
          userError: this.userError,
          loadUser: this.loadUser,
          cancelUserLoad: this.cancelUserLoad
        },
        on: this.$listeners
      });
    }
  });
}

在组件中使用:

vue

<template>
  <div>
    <div v-if="userLoading">加载中...</div>
    <div v-if="userError" class="error">{{ userError }}</div>
    <div v-if="user">
      <h3>{{ user.name }}</h3>
      <p>{{ user.email }}</p>
    </div>
    <button @click="loadUser(1)">加载用户</button>
  </div>
</template>

<script>
import { withUserLoader } from './withUserLoader';

export default withUserLoader({
  props: ['user', 'userLoading', 'userError', 'loadUser', 'cancelUserLoad'],
  // 组件其他选项...
});
</script>

这样看来其实也没啥毛病,但是真的不建议去用,首先就是太绕啦,在使用的时候还要再包裹一层组件,不建议用。

适用场景

  • 需要对组件进行增强的场景
  • 逻辑相对独立,参数配置简单的情况
  • 基于 props 通信的组件复用

4. Hooks(Vue3 Composition API)

Vue3 的 Hooks 基于 Composition API,通过函数封装逻辑并返回响应式数据和方法。

// useUserLoader.js
import { ref, onUnmounted } from 'vue';
import axios from 'axios';

export function useUserLoader() {
  const user = ref(null);
  const loading = ref(false);
  const error = ref(null);
  let cancelToken = null;

  const loadUser = async (userId) => {
    // 取消之前的请求
    if (cancelToken) {
      cancelToken.cancel('Previous request canceled');
    }
    
    loading.value = true;
    error.value = null;
    cancelToken = axios.CancelToken.source();

    try {
      const response = await axios.get(`/api/users/${userId}`, {
        cancelToken: cancelToken.token
      });
      user.value = response.data;
      return response.data;
    } catch (err) {
      if (!axios.isCancel(err)) {
        error.value = err.message;
      }
      throw err;
    } finally {
      loading.value = false;
    }
  };

  const cancelUserLoad = () => {
    cancelToken?.cancel('Manual cancel');
  };

  // 组件卸载时取消请求
  onUnmounted(cancelUserLoad);

  return {
    user,
    loading,
    error,
    loadUser,
    cancelUserLoad
  };
}

在组件中使用:

vue

<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-if="error" class="error">{{ error }}</div>
    <div v-if="user">
      <h3>{{ user.name }}</h3>
      <p>{{ user.email }}</p>
    </div>
    <button @click="loadUser(1)">加载用户</button>
  </div>
</template>

<script setup>
import { useUserLoader } from './useUserLoader';

// 直接调用 Hooks 获取状态和方法
const { user, loading, error, loadUser } = useUserLoader();
</script>

👏👏👏👏👏👏,看这个就很好用,简答明了,可惜就是vue项目中只有vue3能用。

综合对比表格

特性普通函数封装MixinHOCHooks
响应式支持需手动处理自动支持自动支持自动支持
命名冲突无(显式引用)严重无(显式传递)无(显式引用)
逻辑来源清晰模糊较清晰清晰
参数传递支持不支持支持支持
生命周期手动处理自动合并内部管理自动关联
代码组织分散分散包装式聚合式
组合能力
学习成本
Vue2 支持支持原生支持支持需通过插件
Vue3 支持支持支持(兼容性)支持原生支持

实际项目中的选择

  1. 优先选择 Hooks:在 Vue3 项目中,Hooks 几乎是所有逻辑复用场景的最佳选择,它解决了其他方案的大部分痛点,提供了清晰的逻辑组织和灵活的组合能力。
  2. Mixin 谨慎使用:在 Vue2 项目中,Mixin 可以解决简单的复用问题,但要注意规范命名(如添加统一前缀),避免多层嵌套和命名冲突。
  3. HOC 适用于特定场景:当需要对组件进行增强或包装时(如权限控制、日志记录),HOC 是一个不错的选择,但要避免过度使用导致的组件层级过深。
  4. 普通函数封装作为补充:对于与组件逻辑无关的通用工具类逻辑,普通函数封装仍然是最简单高效的方式。
  5. 迁移策略:从 Vue2 迁移到 Vue3 时,可以优先将复杂的 Mixin 重构为 Hooks,提高代码可维护性。