面试官问:如何打造高复用、可扩展的组件?

89 阅读5分钟

思考一下,咱一起来——深入剖析如何设计一款既优雅又具备强大扩展性的 Vue3 Hook 组件。

一、Hook 的核心价值:从代码复用到逻辑解耦

1.1 告别 Options API 的混沌结构

传统 Vue2 组件中,逻辑被分散在 datamethodscomputed 等选项中,随着组件复杂度提升,代码逐渐演变为难以维护的"意大利面条式结构"。例如,一个包含表单验证、异步请求、分页控制的组件,其逻辑可能散落在数十个方法中,修改一处往往牵一发而动全身。

Vue3 的 Hook 机制通过 setup() 函数将相关逻辑聚合,形成独立的逻辑单元。以分页组件为例,我们可以将分页逻辑封装为 usePagination Hook:

// usePagination.js
import { ref, watch } from 'vue'

export function usePagination(fetchData) {
  const currentPage = ref(1)
  const pageSize = ref(10)
  const total = ref(0)
  const isLoading = ref(false)

  const loadData = async () => {
    isLoading.value = true
    try {
      const result = await fetchData(currentPage.value, pageSize.value)
      total.value = result.total
      // 处理数据...
    } finally {
      isLoading.value = false
    }
  }

  // 监听页码变化自动加载数据
  watch([currentPage, pageSize], loadData, { immediate: true })

  return {
    currentPage,
    pageSize,
    total,
    isLoading,
    loadData
  }
}

1.2 逻辑复用的革命性提升

Hook 的真正威力在于其组合性。通过将复杂逻辑拆解为多个小 Hook,我们可以像搭积木一样构建组件。例如,一个完整的表格组件可能由以下 Hook 组合而成:

  • useTable:处理表格基础逻辑(分页、排序、筛选)
  • useFetch:封装数据获取逻辑
  • useSelection:管理多选状态
  • useColumnResize:实现列宽拖拽

这种模式使得每个 Hook 职责单一,既便于测试维护,又能通过组合满足不同场景需求。

二、设计高可扩展 Hook 的五大原则

2.1 单一职责原则:每个 Hook 只做一件事

优秀的 Hook 应该像 Unix 工具一样,专注于解决一个具体问题。以 useWindowSize 为例,它仅负责监听窗口尺寸变化:

// useWindowSize.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindowSize() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)

  const update = () => {
    width.value = window.innerWidth
    height.value = window.innerHeight
  }

  onMounted(() => window.addEventListener('resize', update))
  onUnmounted(() => window.removeEventListener('resize', update))

  return { width, height }
}

这种设计使得 useWindowSize 可以在任何需要响应窗口尺寸变化的组件中复用,而不会引入无关逻辑。

2.2 开放封闭原则:对扩展开放,对修改封闭

通过配置对象和插槽机制,我们可以让 Hook 支持各种定制需求。以 useTable 为例:

// useTable.js
export function useTable(options = {}) {
  const {
    columns = [],
    rowKey = 'id',
    pagination = true,
    // 更多配置...
  } = options

  // 内部实现...

  return {
    // 暴露必要方法和状态
    reload,
    setColumns,
    // 更多返回值...
  }
}

这种设计允许使用者通过配置对象自定义表格行为,而无需修改 Hook 内部实现。

2.3 依赖注入:跨组件共享状态

对于需要在多个组件间共享的状态(如全局主题、用户信息),可以使用 Vue3 的 provide/inject 机制。例如,我们可以创建一个 useTheme Hook:

// useTheme.js
import { inject, provide, ref } from 'vue'

const ThemeSymbol = Symbol()

export function provideTheme(initialTheme) {
  const theme = ref(initialTheme)
  provide(ThemeSymbol, theme)
  return theme
}

export function useTheme() {
  const theme = inject(ThemeSymbol)
  if (!theme) {
    throw new Error('No theme provided!')
  }
  return theme
}

这样,任何子组件都可以通过 useTheme() 获取并修改主题状态。

2.4 类型安全:TypeScript 的最佳实践

在 TypeScript 项目中,为 Hook 定义清晰的类型接口至关重要。以 useFetch 为例:

// useFetch.ts
import { ref, Ref } from 'vue'

interface FetchOptions<T> {
  initialData?: T
  onSuccess?: (data: T) => void
  onError?: (error: Error) => void
}

export function useFetch<T>(url: string, options: FetchOptions<T> = {}) {
  const data: Ref<T | null> = ref(options.initialData || null)
  const error: Ref<Error | null> = ref(null)
  const isLoading = ref(false)

  // 实现细节...

  return {
    data,
    error,
    isLoading,
    // 更多返回值...
  }
}

明确的类型定义不仅提升了开发体验,还能在编译时捕获潜在错误。

2.5 性能优化:避免不必要的渲染

Vue3 的响应式系统虽然强大,但不当使用可能导致性能问题。以下是几个优化技巧:

  • 使用 shallowRef/shallowReactive:对于不需要深度监听的大型对象
  • 防抖/节流:对高频触发的事件(如窗口 resize、滚动)
  • 记忆化:使用 computed 或自定义 memoization 缓存计算结果

useDebounce 为例:

// useDebounce.js
import { ref, onUnmounted } from 'vue'

export function useDebounce(fn, delay = 300) {
  const timer = ref(null)

  const debounced = (...args) => {
    if (timer.value) {
      clearTimeout(timer.value)
    }
    timer.value = setTimeout(() => fn(...args), delay)
  }

  onUnmounted(() => {
    if (timer.value) {
      clearTimeout(timer.value)
    }
  })

  return debounced
}

三、实战案例:构建一个可扩展的表格组件

让我们通过一个完整的表格组件案例,综合运用上述设计原则。

3.1 核心 Hook 设计

// useTable.js
import { ref, computed, watch, onMounted } from 'vue'
import { useDebounce } from './useDebounce'

export function useTable(options = {}) {
  const {
    columns = [],
    rowKey = 'id',
    pagination = true,
    debounceTime = 200,
    // 更多配置...
  } = options

  // 状态管理
  const tableData = ref([])
  const loading = ref(false)
  const selectedRows = ref([])
  const searchQuery = ref('')

  // 计算属性
  const filteredData = computed(() => {
    if (!searchQuery.value) return tableData.value
    return tableData.value.filter(row => 
      Object.values(row).some(
        val => String(val).toLowerCase().includes(searchQuery.value.toLowerCase())
      )
    )
  })

  // 方法
  const fetchData = async () => {
    loading.value = true
    try {
      // 实际项目中这里可能是 API 调用
      tableData.value = await mockFetchData()
    } finally {
      loading.value = false
    }
  }

  const handleSelectionChange = (rows) => {
    selectedRows.value = rows
  }

  const debouncedFetch = useDebounce(fetchData, debounceTime)

  // 生命周期
  onMounted(fetchData)

  // 监听搜索查询变化
  watch(searchQuery, debouncedFetch)

  return {
    // 状态
    tableData: filteredData,
    loading,
    selectedRows,
    searchQuery,
    // 方法
    fetchData,
    handleSelectionChange,
    // 配置
    columns,
    rowKey,
    pagination,
    // 更多返回值...
  }
}

3.2 组件实现

<!-- TableComponent.vue -->
<template>
  <div class="table-container">
    <div class="search-box">
      <el-input
        v-model="searchQuery"
        placeholder="搜索..."
        clearable
      />
    </div>

    <el-table
      :data="tableData"
      v-loading="loading"
      @selection-change="handleSelectionChange"
    >
      <el-table-column
        v-if="pagination"
        type="selection"
        width="55"
      />

      <el-table-column
        v-for="column in columns"
        :key="column.prop"
        :prop="column.prop"
        :label="column.label"
        :width="column.width"
      >
        <template #default="{ row }">
          <!-- 支持自定义列渲染 -->
          <slot name="column" :row="row" :column="column">
            {{ row[column.prop] }}
          </slot>
        </template>
      </el-table-column>
    </el-table>

    <!-- 分页组件 -->
    <el-pagination
      v-if="pagination"
      :total="filteredData.length"
      :page-size="pageSize"
      @current-change="handlePageChange"
    />
  </div>
</template>

<script setup>
import { useTable } from './hooks/useTable'

const props = defineProps({
  columns: {
    type: Array,
    required: true
  },
  // 更多 props...
})

const {
  tableData,
  loading,
  selectedRows,
  searchQuery,
  fetchData,
  handleSelectionChange,
  // 更多返回值...
} = useTable({
  columns: props.columns,
  // 其他配置...
})

const handlePageChange = (page) => {
  // 处理分页逻辑
}
</script>

3.3 扩展场景

这个表格组件可以通过以下方式轻松扩展:

  1. 自定义列渲染:通过插槽机制支持完全自定义的列内容
  2. 远程分页:修改 useTablefetchData 方法实现服务器端分页
  3. 行操作按钮:添加操作列,支持编辑、删除等操作
  4. 导出功能:添加导出 Excel 按钮

工作中大概思路就是这么封装的,个人见解!