在实际开发中,我们可能会遇到这样一种场景:假设你封装了一个带有泛型的通用类型。但在全站的 100 次调用中,有 90 次传入的泛型其实都是 string,只有 10 次是其他复杂对象。如果要求业务小弟们每次调用都必须老老实实地写上 <string>,那么他们一定会抱怨这个工具库太难用了。
为了提升代码的开发体验,TypeScript 提供了一个非常贴心的功能:泛型默认值。
TypeScript 泛型默认值的定义
TypeScript 泛型默认值的语法非常简单,它和 JavaScript 中函数的默认参数是一样的。
语法:
interface 接口名<T = 默认类型> {
……
}
class 类名<T = 默认类型> {
……
}
function 函数名<T = 默认类型>(...) {
……
}说明:
我们使用等号 “=” 就可以为泛型变量指定一个用于兜底的默认类型。
示例 1:重构 API 响应接口
// 给泛型设置一个默认值:any
interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
// 场景 1:不显式传入泛型,则触发默认值
const res1: ApiResponse = {
code: 200,
message: "请求成功",
data: "欢迎来到绿叶网"
};
console.log(res1.data);
// 场景 2:显式传入具体的类型,则覆盖默认值
const res2: ApiResponse<{ id: number; name: string }> = {
code: 200,
message: "请求成功",
data: {
id: 1001,
name: "Jack"
}
};
console.log(res2.data);运行结果如下。
欢迎来到绿叶网
{ id: 1001, name: 'Jack' }分析:
如果不显式传入类型,那么 data 字段被自动推导为 any,此时该字段想赋什么值都可以。但如果显式传入具体的类型,此时 data 字段就会被严格限制为只包含 id 和 name 的对象。
此外,给接口加上 <T = any> 或 <T = string> 之后,我们在调用时就可以直接写成 ApiResponse,这样连尖括号都省了。
这种方式在编写 Vue 的 ref() 或 reactive() 等底层响应式 API 时使用频率非常高,因为它完美做到了 “常规场景极简、复杂场景可控”。
TypeScript 多泛型参数的默认值
当类或函数有多个泛型参数(比如 <K, V>)时,我们也可以给它们设置默认值。
但是,这里有一条严格的铁律:带有默认值的泛型参数,必须放在所有没有默认值的泛型参数的后面。这一点就和普通函数中 “可选参数必须放在必传参数后面” 的逻辑是一样的。
示例 2:字典类的泛型默认值
// V 有默认值,它必须排在没有默认值的 K 后面
class Dictionary<K, V = string> {
private _data: Map<K, V> = new Map();
public set(key: K, value: V): void {
this._data.set(key, value);
}
}
// 只传 1 个泛型
const dict1 = new Dictionary<number>();
dict1.set(1, "第一名");
console.log(dict1);
// 传入 2 个泛型
const dict2 = new Dictionary<string, boolean>();
dict2.set("isLogin", true);
console.log(dict2);运行结果如下。
Dictionary { _data: Map(1) { 1 => '第一名' } }
Dictionary { _data: Map(1) { 'isLogin' => true } }分析:
对于 Dictionary 这个泛型类来说,在实例化对象的时候,如果只传入 1 个泛型,那么第 2 个泛型 V 就自动使用默认值 string;如果传入 2 个泛型,那么就会完全覆盖默认值。
需要注意的是,带有默认值的泛型不能放在前面,否则 TypeScript 会报错拦截:
// 报错:必选类型参数不能在可选类型参数之后。
class BadDictionary<K = string, V> { ... } TypeScript 终极组合:extends 约束 + 默认值
在顶级的开源框架源码中,我们经常会看到泛型约束(extends)和泛型默认值(=)被组合在一起使用。其语法格式是:
<T extends 约束类型 = "默认类型">这行代码的意思是:你传进来的类型必须满足我的约束条件;如果你没有传,我就默认给你一个最常用的、且满足该约束的类型。
比如在前端开发中,获取 DOM 节点是一个非常高频的操作。不同的节点有不同的类型(比如 div 是 HTMLDivElement、canvas 是 HTMLCanvasElement),但它们都继承自顶层的 HTMLElement。
接下来,我们尝试来封装一个用于获取 DOM 元素的通用函数。
示例 3:封装获取 DOM 元素的通用函数
// 定义函数:extends 约束 + 默认值
function getDomElement<T extends HTMLElement = HTMLDivElement>(id: string): T | null {
const el = document.getElementById(id);
return (el as T) || null;
}
// 没有显式传入类型
const container = getDomElement("app");
// 显式传入类型
const myCanvas = getDomElement<HTMLCanvasElement>("my-canvas");
if (myCanvas) {
// 因为指定了具体类型,所以可以安全地调用 canvas 专属方法
const ctx = myCanvas.getContext("2d");
}分析:
上面这个例子,可以说是企业级工具类封装的典范。安全与优雅,全都要!
- 安全:通过 “extends HTMLElement” 堵住了外部乱传类型(比如传个 string 进来)的漏洞。
- 优雅:通过 “= HTMLDivElement” 免去了 80% 常见场景下的冗余代码。
TypeScript 类型推断与默认值的优先级(重点)
这里有一个经常在面试中被问到的细节:如果泛型既有默认值,我们在调用时又传入了实际参数,此时 TypeScript 到底听谁的呢?
结论是:类型推断的优先级永远高于默认类型。
如果在泛型函数中,TypeScript 可以通过你传入的 “真实参数” 完美推断出泛型类型,那么这个推断出的类型会直接覆盖掉默认值。默认类型只有在 “既没有显式传入尖括号、又无法从参数中推断出类型” 的绝境下,才会作为最后的救命稻草出场。
示例 4:类型推断覆盖默认值
// 定义函数:T 默认值为 string
function createArray<T = string>(length: number, value: T): T[] {
let result: T[] = [];
for (let i = 0; i < length; i++) {
result.push(value);
}
return result;
}
// 场景 1:完全不传参数,也无法推断。T 采用默认值 string
// (注意:这里假设允许不传参数,仅为演示默认值生效的极端情况)
// let arr1: string[]
// 场景 2:传入数字。TypeScript 能够推断出 T 是 number
// 此时 number 会无情地覆盖掉默认值 string
const arr2 = createArray(3, 2026);
console.log(arr2);运行结果如下。
[ 2026, 2026, 2026 ]分析:
在场景 2 中,虽然我们定义了 <T = string>,但由于传入的实参是 2026,TypeScript 会推断出这里需要的是 number。因此,arr2 的最终类型被推导为 number[],而不是 string[]。
