【分析 `useCascaderHelper`: Vue 3 Composable 函数在级联选择器场景的应用】

分析 useCascaderHelper: Vue 3 Composable 函数在级联选择器场景的应用

背景与痛点:

在使用 Element Plus 的 el-cascader 组件时,开发者经常遇到以下痛点:

  1. 数据回显: 后端通常只存储叶子节点的 ID,但 el-cascader 可能需要完整的 ID 路径数组 (emitPath: true) 或特定的格式才能正确回显。开发者需要在加载数据后手动查找路径并转换格式。
  2. 提交值处理:emitPath: true 时,el-cascaderv-model 会绑定一个 ID 数组。但在提交表单时,后端通常只需要最终的叶子节点 ID。开发者需要在提交前手动从数组中提取这个 ID。
  3. 逻辑分散: 回显逻辑(prepareCascaderValue)和提交值处理逻辑(prepareCascaderValueForSubmit)分散在组件的不同生命周期或方法中(例如 onMounted/getDetaildoSave),使得代码不够内聚。
  4. 响应式处理: 当级联选项 (options) 或后端 ID (backendValue) 发生变化时,需要手动重新计算用于 v-model 的显示值,增加了复杂性。

设计思路 (引入 useCascaderHelper):

useCascaderHelper 的核心思想是利用 Vue 3 的 Composition API 来封装和管理 el-cascader 相关的所有状态和逻辑,将复杂性从组件中抽离出来。

  1. 封装状态: 将用于 el-cascader v-modelcascaderDisplayValue 和最终用于提交的 submitValue 作为内部状态进行管理。
  2. 响应式: 使用 ref 创建响应式引用,使用 computed 创建计算属性 (submitValue),使用 watch 自动监听 optionsbackendValue 的变化。
  3. 自动同步: 当外部传入的 optionsbackendValue 变化时,watch 会自动触发,调用内部的 findNodePath 逻辑来更新 cascaderDisplayValue
  4. 简化接口: 对外只暴露两个核心变量:
    • cascaderDisplayValue: 一个响应式引用,直接绑定到 el-cascaderv-model
    • submitValue: 一个计算属性,包含了从 cascaderDisplayValue 中提取出的、适合提交给后端的值。
  5. 配置驱动: 通过 UseCascaderHelperConfig 接口接收必要的配置(ID 字段名、子节点字段名、emitPath),使 Composable 更加灵活。

useCascaderHelper 的优势 (Pros):

  1. 代码内聚性: 将与 el-cascader 回显和提交值处理相关的逻辑集中在一个地方,提高了代码的可读性和可维护性。
  2. 简化组件逻辑: Vue 组件不再需要手动调用 prepareCascaderValueprepareCascaderValueForSubmit,也不需要手动 watch 依赖项来更新显示值。组件只需与 cascaderDisplayValuesubmitValue 交互。
  3. 更好的响应式: 自动处理了 optionsbackendValue 的变化,确保 cascaderDisplayValue 总是最新的。
  4. 可复用性: Composable 函数本身就是为了复用逻辑而设计的,可以在项目中的任何需要处理 el-cascader 的地方轻松复用。
  5. 类型安全: 结合 TypeScript,可以提供更好的类型推断和检查。

useCascaderHelper 的劣势 (Cons):

  1. 强依赖 Vue 3 Composition API: 这个 Composable 函数只能在 Vue 3 项目中使用,并且要求开发者对 Composition API ( ref, watch, computed) 有一定的了解。它不再是一个可以在任何 JavaScript 环境下使用的纯工具函数。
  2. 抽象层级增加: 对于非常简单的场景,引入 Composable 可能感觉有点“过度设计”。但对于需要处理回显和提交值转换的典型场景,其带来的好处通常大于这点劣势。
  3. 配置略有不同: Composable 使用 UseCascaderHelperConfig,其字段名 (valueKey) 与旧的 CascaderConfig (value) 不同,需要注意区分。

应用场景:

useCascaderHelper 特别适用于以下场景:

  • Vue 3 项目中使用了 el-cascader 组件。
  • 需要处理从后端加载单个 ID 并回显到 el-cascader 的情况(无论 emitPathtrue 还是 false)。
  • 需要将 el-cascaderv-model 值(可能是数组)转换为单个 ID 再提交给后端。
  • 希望将相关的处理逻辑从 Vue 组件中分离出来,使组件更专注于视图和业务流程。

使用方法:

import { useCascaderHelper, type UseCascaderHelperConfig } from './utils/cascader-helper';
import { ref } from 'vue';

// 1. 准备响应式引用
const options = ref([...]); // 级联选项
const backendValue = ref<string | number | undefined>(/* 从后端获取的 ID */);

// 2. 定义配置
const config: UseCascaderHelperConfig = {
  valueKey: 'id',     // 你的 ID 字段名
  emitPath: true      // 根据 el-cascader 的 props 设置
};

// 3. 调用 Composable
const { cascaderDisplayValue, submitValue } = useCascaderHelper(options, backendValue, config);

// 4. 在模板中使用
// <el-cascader v-model="cascaderDisplayValue" :options="options.value" :props="{ ..., emitPath: config.emitPath }" />

// 5. 在提交时使用
function submitForm() {
  const formData = {
    // ... 其他字段
    fieldId: submitValue.value // 直接使用计算好的值
  };
  // 发送请求...
}

// 6. 更新后端值 (例如加载详情后)
backendValue.value = /* 新的后端 ID */; // cascaderDisplayValue 会自动更新

优化重构前后对比:

特性重构前 (纯工具函数)重构后 (引入 useCascaderHelper)
主要用法手动调用 prepareCascaderValue, prepareCascaderValueForSubmit调用 useCascaderHelper 获取 cascaderDisplayValue, submitValue
组件逻辑分散在加载、保存等不同方法中,需手动 watch(如果需要)集中调用 Composable,组件逻辑简化
响应式需要手动处理依赖变化内部自动处理响应式更新
代码内聚逻辑分散逻辑封装在 Composable 中,内聚性高
复用性函数可在任何 JS 环境复用Composable 只能在 Vue 3 中复用
依赖无框架依赖强依赖 Vue 3 Composition API
易用性需要理解两个函数的用法和调用时机接口更简洁,只需关注 v-model 绑定和提交值

结论:

引入 useCascaderHelper 是利用 Vue 3 Composition API 优化前端开发体验的典型实践。它通过封装状态和逻辑,显著简化了在 Vue 组件中使用 el-cascader 处理回显和提交值的复杂性,提高了代码的可读性、可维护性和内聚性。虽然它带来了对 Vue 3 的强依赖,但在 Vue 3 项目中,这种方式通常是更优的选择。保留旧的工具函数则确保了对现有代码的兼容性。

修改后源码

import { ref, watch, computed, type Ref } from 'vue';

/**
 * 级联选择器辅助工具
 * 用于解决级联选择器数据回显和提交值处理问题
 */

/**
 * 级联选择器节点接口
 */
export interface CascaderNode {
  // 节点ID
  [idKey: string]: any;
  // 子节点
  children?: CascaderNode[];
}

/**
 * 级联选择器配置接口
 */
export interface CascaderConfig {
  // ID字段名
  value: string;
  // 子节点字段名 (可选, 默认为 'children')
  children?: string;
  // 是否返回完整路径
  emitPath: boolean;
  // 是否可选任意级别
  checkStrictly?: boolean;
}

/**
 * 内部核心递归查找函数
 * @param nodes 数据源
 * @param targetId 目标ID
 * @param idKey ID字段名
 * @param childrenKey 子节点字段名
 * @param findPath 是否查找路径 (true: 返回路径, false: 返回节点)
 * @param path 当前路径 (递归用)
 * @returns 找到的节点路径、节点或null
 */
function _findInTree<T extends CascaderNode>(
  nodes: T[] | undefined,
  targetId: string | number,
  idKey: string,
  childrenKey: string,
  findPath: boolean,
  path: T[] = []
): T[] | T | null {
  if (!nodes?.length) return null;

  for (const node of nodes) {
    const currentPath = [...path, node];
    // 检查当前节点是否匹配
    if (String(node[idKey]) === String(targetId)) {
      return findPath ? currentPath : node;
    }

    // 递归检查子节点
    const children = node[childrenKey] as T[] | undefined;
    if (children?.length) {
      const found = _findInTree(children, targetId, idKey, childrenKey, findPath, currentPath);
      if (found) return found;
    }
  }

  return null;
}

/**
 * 查找节点在树形结构中的完整路径 (兼容旧接口)
 * @param nodes 级联数据源
 * @param targetId 目标节点ID
 * @param configOrIdKey 级联选择器配置 或 ID字段名 (新)
 * @param childrenKey 子节点字段名 (新, 可选, 默认 'children')
 * @returns 找到的节点路径或null
 */
function findNodePath<T extends CascaderNode>(
  nodes: T[] | undefined,
  targetId: string | number,
  configOrIdKey: CascaderConfig | string,
  childrenKey: string = 'children' // 新增参数
): T[] | null {
  if (!nodes?.length || !targetId) return null;

  // 兼容旧的 config 对象 或直接传入 idKey
  const idKey = typeof configOrIdKey === 'string' ? configOrIdKey : configOrIdKey.value;
  const effectiveChildrenKey =
    typeof configOrIdKey === 'string' ? childrenKey : configOrIdKey.children || 'children';

  return _findInTree(nodes, targetId, idKey, effectiveChildrenKey, true) as T[] | null;
}

/**
 * 递归查找并返回节点 (兼容旧接口)
 * @param nodes 级联数据源
 * @param targetId 目标节点ID
 * @param configOrIdKey 级联选择器配置 或 ID字段名 (新)
 * @param childrenKey 子节点字段名 (新, 可选, 默认 'children')
 * @returns 找到的节点或undefined
 */
export function findCascaderNode<T extends CascaderNode>(
  nodes: T[] | undefined,
  targetId: string | number,
  configOrIdKey: CascaderConfig | string,
  childrenKey: string = 'children' // 新增参数
): T | undefined {
  if (!nodes?.length || !targetId) return undefined;

  // 兼容旧的 config 对象 或直接传入 idKey
  const idKey = typeof configOrIdKey === 'string' ? configOrIdKey : configOrIdKey.value;
  const effectiveChildrenKey =
    typeof configOrIdKey === 'string' ? childrenKey : configOrIdKey.children || 'children';

  return _findInTree(nodes, targetId, idKey, effectiveChildrenKey, false) as T | undefined;
}

/**
 * 准备级联选择器回显数据 (保持向后兼容)
 * 解决级联选择器无法正确回显嵌套数据的问题
 * @param options 级联选择器数据源
 * @param targetId 后端返回的目标ID
 * @param config 级联选择器配置 (必需, 用于获取 value 和 emitPath)
 * @returns 适合级联选择器回显的值 (string | number | array | undefined)
 */
export function prepareCascaderValue<T extends CascaderNode>(
  options: T[] | undefined,
  targetId: string | number | undefined,
  config: CascaderConfig
): string | number | (string | number)[] | undefined {
  // 确保 options 和 targetId 有效
  if (!targetId || !options?.length) return undefined;

  const idKey = config.value;
  const childrenKey = config.children || 'children';

  // 查找节点路径
  const nodePath = findNodePath<T>(options, targetId, idKey, childrenKey); // 使用内部增强的查找

  if (!nodePath) {
    // console.warn(`[Cascader Helper] 未找到ID为 ${targetId} 的节点路径,返回 undefined。`);
    return undefined; // 未找到路径时明确返回 undefined
  }

  // 根据emitPath配置决定返回形式
  if (config.emitPath) {
    // 返回完整ID路径
    return nodePath.map((node) => node[idKey]);
  } else {
    // 只返回最后一个节点的ID (即目标ID)
    return targetId;
  }
}

/**
 * 准备级联选择器值用于表单提交 (保持向后兼容)
 * 处理可能是数组的级联选择器值,确保返回单一ID用于后端提交
 * @param cascaderValue 级联选择器的当前值 (来自 v-model)
 * @returns 适合提交到后端的单一ID值 (string | number | undefined)
 */
export function prepareCascaderValueForSubmit(
  cascaderValue: string | number | (string | number)[] | undefined
): string | number | undefined {
  if (cascaderValue === null || cascaderValue === undefined) return undefined;

  // 如果是数组,取最后一个值(叶子节点ID)
  if (Array.isArray(cascaderValue)) {
    // 处理空数组的情况
    return cascaderValue.length > 0 ? cascaderValue[cascaderValue.length - 1] : undefined;
  }

  // 否则直接返回原值 (已经是 string 或 number)
  return cascaderValue;
}

// --- 新增 Composable 函数 ---

/**
 * useCascaderHelper Composable 配置接口
 */
export interface UseCascaderHelperConfig {
  valueKey: string; // ID 字段名 (替代旧 config.value)
  childrenKey?: string; // 子节点字段名 (可选, 默认 'children')
  emitPath: boolean; // 是否返回完整路径 (替代旧 config.emitPath)
}

/**
 * Vue 3 Composable for Cascader Helper
 * 简化 el-cascader 的值处理 (推荐新用法)
 *
 * @param options Ref<T[]> - 级联选择器的选项数据源 (响应式引用)
 * @param backendValue Ref<string | number | undefined> - 后端存储/加载的原始 ID (响应式引用)
 * @param config UseCascaderHelperConfig - Composable 的配置
 * @returns Object { cascaderDisplayValue: Ref, submitValue: ComputedRef }
 */
export function useCascaderHelper<T extends CascaderNode>(
  options: Ref<T[] | undefined>,
  backendValue: Ref<string | number | undefined>,
  config: UseCascaderHelperConfig
) {
  // 用于 el-cascader v-model 的响应式引用
  const cascaderDisplayValue = ref<string | number | (string | number)[] | undefined>();

  // 计算最终提交给后端的值 (只读计算属性)
  const submitValue = computed(() => prepareCascaderValueForSubmit(cascaderDisplayValue.value));

  // 侦听后端值或选项的变化,自动更新用于显示的 cascaderDisplayValue
  watch(
    [backendValue, options],
    ([newBackendValue, newOptions]) => {
      if (newBackendValue === undefined || newBackendValue === null || !newOptions?.length) {
        cascaderDisplayValue.value = undefined; // 如果后端值无效或选项为空,清空显示值
        return;
      }

      // 使用内部增强的查找函数来准备显示值
      const path = findNodePath(
        newOptions,
        newBackendValue,
        config.valueKey, // 使用新配置的 key
        config.childrenKey || 'children'
      );

      if (!path) {
        // console.warn(`[useCascaderHelper] 未找到ID为 ${newBackendValue} 的节点路径。`);
        cascaderDisplayValue.value = config.emitPath ? [] : undefined; // 根据 emitPath 返回空数组或 undefined
      } else {
        cascaderDisplayValue.value = config.emitPath
          ? path.map((node) => node[config.valueKey]) // 完整路径
          : newBackendValue; // 仅叶子节点 ID
      }
    },
    { immediate: true, deep: true } // 立即执行一次,并深度侦听 options 的变化
  );

  // 返回给组件使用的响应式引用和计算属性
  return {
    /**
     * @description 用于绑定到 el-cascader 的 v-model
     */
    cascaderDisplayValue,
    /**
     * @description 计算得出的、适合提交到后端的值 (通常是叶子节点 ID)
     */
    submitValue
  };
}

// 保持旧函数导出以实现向后兼容
export { findNodePath };

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Gazer_S

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值