返回

解决 Vue 3 Ref 类型推断问题: Type is not assignable to type UnwrapRef

vue.js

Vue 3 中 ref 类型推断问题:"Type is not assignable to type UnwrapRef"

问题的

在使用 Vue 3 Composition API 时,使用ref 创建响应式数据经常遇到 UnwrapRef 类型问题, 这个问题,常常表现在尝试对 ref 对象属性进行特定类型的赋值时, TypeScript 会提示类型不兼容,"Type is not assignable to type UnwrapRef"。 当代码变得复杂时,这种不匹配常会发生, 这就干扰了 Vue 的使用。

问题的原因

此问题的根源在于 Vue 3 的 ref 类型系统与 TypeScript 的交互机制。 ref 不仅仅是一个简单的值包装器,它还负责解包内部值,提供一个 value 属性以供访问和修改。当定义了自定义接口和类型时,并且想把 ref 与自定义的类型结合起来使用时,问题就来了。 类型断言可能与 ref 所做的实际解包之间,存在着类型的不兼容问题。

UnwrapRef 是 Vue 内部用于处理 ref 解包的类型工具。在创建复杂的响应式对象或数组时,尤其需要把这种解包机制和类型安全管理结合起来。这样就能既保持 Vue ref 的响应性,又避免出现 TypeScript 的错误。 当把一个具有特定结构的普通对象,赋值给 ref 对象的某个属性时,可能会发现 ref 对这个属性期望的类型与普通对象的类型并不完全相同。尽管两者逻辑上相同。Vue 处理的往往是这些属性值本身的响应性表示,而我们指定的可能是那些更简单且非响应式的类型。

如果对这段代码进一步追溯,还可以发现这是因为 Vue 的响应式系统, UnwrapRef 和复杂的泛型等特性造成的。 这段代码的目的就是为了构建一个既可以用于服务端数据表又可以在应用内重用的组件。这给 Vue 响应式系统带来了难题:必须在管理和更新内部 search 状态时,做到准确可靠且兼容于外部状态。 由于 Vue 的响应式系统和开发中采用的复杂泛型交织在一起,想对数据表进行处理会变得更难。这体现为管理搜索、排序以及分页参数。 TableOptionsParsed 接口的本意是为了创建通用的模式。通过此模式可以管理数据表配置。 但该模式对 ref 函数如何使用 TypeScript 类型带来影响,特别地体现在处理搜索对象时,也就是用到了 search 字段的地方。Record<keyof T, string | null>类型意味着开发中要求search对象的每一个键都需要和泛型参数T定义的类型兼容。

解决方案及最佳实践

方法一: 确保初始化与赋值类型的匹配

要彻底解决类型不兼容问题, 一个方法是保证赋值给 ref 的初始值类型与后面通过 .value 修改的值的类型完全匹配。如果一个属性被定义为泛型,必须确保所有对这个属性的赋值都满足这个泛型所指定的类型要求。

import { ref, Ref, watch } from "vue";
import { TableItemsOptions } from "@/types/TableItemsOptions";
import { useSearchableTable, Headers } from "./UseSearchableTable";

interface TableOptionsParsed<T extends Record<string, any>>
  extends Omit<TableItemsOptions, "sortBy" | "search"> {
  search?: Record<keyof T, string | null>;
  orderBy?: Partial<Record<keyof T, "asc" | "desc">>;
  direction?: "asc" | "desc";
}

export function useServerSearchableTable<T extends Record<string, any>>(
  originalItems: Ref<T[]>,
  headers: Headers[]
) {
  const {
    filteredItems,
    search,
    changeItemsToOriginal,
    filteredDataChange,
    onSearchValueChanged,
    multiSearchHeaders,
  } = useSearchableTable(originalItems, headers);

  // 直接使用 TableOptionsParsed<T> 类型初始化,并确保 search 属性的类型与预期相符
  const tableItemsOptions = ref<TableOptionsParsed<T>>({
    search: {} as Record<keyof T, string | null>, // 显式初始化 search
  });

  watch(search, searchValue => {
    const searchArray: Record<keyof T, string | null> = {} as Record<
      keyof T,
      string | null
    >;
    headers.forEach(header => {
      if (header.multiSearch !== false && searchValue) {
        searchArray[header.value as keyof T] = searchValue;
      }
    });
    tableItemsOptions.value.search = searchArray;
  });

  const onAdvancedSearchChange = (
    val: Record<keyof T, string | null> | undefined = undefined
  ) => {
    if (!val) {
      tableItemsOptions.value.search = {} as Record<keyof T, string | null>;
      return;
    }
    const searchArray: Record<keyof T, string | null> = Object.entries(val)
      .filter(([_, value]) => value !== null && value !== "")
      .reduce((acc: Record<keyof T, string | null>, [key, value]) => {
        acc[key as keyof T] = value;
        return acc;
      }, {} as Record<keyof T, string | null>);
    tableItemsOptions.value.search = searchArray;
  };

  function loadItems({ itemsPerPage, page, sortBy }: TableItemsOptions) {
    tableItemsOptions.value = {
      ...tableItemsOptions.value,
      itemsPerPage,
      page,
    };
  }

  return {
    filteredItems,
    search,
    changeItemsToOriginal,
    filteredDataChange,
    onSearchValueChanged,
    multiSearchHeaders,
    loadItems,
    tableItemsOptions,
    onAdvancedSearchChange,
  };
}

这种方法的要点是确保所有响应式变量被修改的地方,保持统一的类型约束。这样TypeScript 就不会因为 ref 值修改时的类型与初始定义时不符而产生错误。

方法二: 使用类型断言处理中间步骤

当遇到 ref 对象初始化的复杂类型,可以通过 TypeScript 类型断言来确保类型匹配, 以解决类型不兼容。如果正在使用的初始值类型和后续处理中所用类型并不完全相同,就要用断言机制保证一致。 通过这个做法,即使要为响应式数据进行多处改变,TypeScript 也将它们当成兼容的类型。

import { ref, watch, Ref } from "vue";
import { TableItemsOptions } from "@/types/TableItemsOptions";
import { useSearchableTable, Headers } from "./UseSearchableTable";

interface TableOptionsParsed<T extends Record<string, any>>
  extends Omit<TableItemsOptions, "sortBy" | "search"> {
  search?: Record<keyof T, string | null>;
  orderBy?: Partial<Record<keyof T, "asc" | "desc">>;
  direction?: "asc" | "desc";
}

export function useServerSearchableTable<T extends Record<string, any>>(
  originalItems: Ref<T[]>,
  headers: Headers[]
) {
  const {
    filteredItems,
    search,
    changeItemsToOriginal,
    filteredDataChange,
    onSearchValueChanged,
    multiSearchHeaders,
  } = useSearchableTable(originalItems, headers);

  const tableItemsOptions = ref<TableOptionsParsed<T>>({} as TableOptionsParsed<T>);

  watch(search, searchValue => {
    const searchArray = {} as Record<keyof T, string | null>;
    headers.forEach(header => {
      if (header.multiSearch !== false && searchValue) {
        searchArray[header.value as keyof T] = searchValue;
      }
    });
    // 使用类型断言确保 searchArray 的类型符合预期
    tableItemsOptions.value.search = searchArray as TableOptionsParsed<T>["search"];
  });

  const onAdvancedSearchChange = (val: Record<keyof T, string | null> | undefined = undefined) => {
    if (!val) {
      // 使用类型断言来处理空对象的情况
      tableItemsOptions.value.search = {} as Record<keyof T, string | null>;
      return;
    }
    const searchArray = Object.entries(val)
      .filter(([_, value]) => value !== null && value !== "")
      .reduce((acc, [key, value]) => {
        // 这里也可能需要类型断言
        acc[key as keyof T] = value;
        return acc;
      }, {} as Record<keyof T, string | null>);
    // 使用类型断言确保赋值的类型正确
    tableItemsOptions.value.search = searchArray as TableOptionsParsed<T>["search"];
  };

  function loadItems({ itemsPerPage, page, sortBy }: TableItemsOptions) {
    tableItemsOptions.value = {
      ...tableItemsOptions.value,
      itemsPerPage,
      page,
      // 确保 sortBy 的类型与预期相符
      sortBy: sortBy as TableOptionsParsed<T>["orderBy"],
    };
  }

  return {
    filteredItems,
    search,
    changeItemsToOriginal,
    filteredDataChange,
    onSearchValueChanged,
    multiSearchHeaders,
    loadItems,
    tableItemsOptions,
    onAdvancedSearchChange,
  };
}

通过运用类型断言 as,告诉 TypeScript,特定的值或者属性要看做兼容的类型。 这样就处理了可能存在不兼容的地方,解决了由于中间计算过程中所用的类型与 ref 的预期类型不匹配的问题。 这可以防止错误地使用不同的但功能相同的类型进行赋值。 通过这个方案就能管理和维护好代码中的复杂逻辑,而又没有静态类型检查造成的障碍。

安全建议:

尽量谨慎地进行类型断言,要仔细核对类型以保证断言正确。否则可能在程序运行时出问题。

另外在做每一个步骤前,最好通过单元测试和类型测试检验数据的准确性。确保对响应式数据进行的类型断言是符合 Vue 框架处理类型预期的,同时还要保证组件能够响应各种边界条件。 此外进行代码审查可以确保类型安全得到有效保障。这样就不会在程序逻辑和组件结构不确定的情况下出现潜在的问题。 这些实践可极大地增强响应式系统,防止常见的类型错误问题。 这有助于避免发生运行时故障或错误,还能使得项目代码保持高度一致和可预见性。

通过解决Vue 3 中出现的类型问题, 可以极大地优化项目的 TypeScript 环境, 这将显著改进类型安全问题并让团队的生产效率有所提升。使用上文的方法并把这些方案实践出来,既可享受到 TypeScript 严格的静态检查,同时还能保持代码库的灵活性和健壮性。 这使得开发者能够信心十足地管理好复杂的类型要求。 在遇到复杂代码环境或需大规模重构时,必须把类型安全摆在首位。 遵循良好的代码实践,并做细致的规划和排查, 将使应用运行平稳并便于团队内的顺畅沟通。 在此前提下项目就可以实现稳固且符合预期的开发结果。