发布于 2026-01-06 9 阅读
0

如何在 TypeScript 中思考和编写代码?语法组合、不同类别、用例,只需思考……资源

如何在 TypeScript 中思考和编写代码

语法组合

不同群体

用例

想想看……

资源

这篇文章摘自我的博客,记得去看看,那里有更多最新内容哦😉

前段时间我写了一篇关于学习 TypeScript 的 部分 教程。在教程中,我详细讲解了各种类型语法等等。但显然,学习一门编程语言远不止掌握语法那么简单。你还需要了解各个部分之间的交互方式,如何正确使用它们,以及最佳实践是什么。我认为这些都值得深入探讨。让我们开始吧!😃

语法组合

在前面提到的教程中,我甚至几次简要地提醒大家,不同类型的对象是如何协同工作的。编程结构、技术和其他要素的协作构成了我们所知的编程——这并非什么新鲜事。但是,尤其是在 TypeScript 中,这个概念被提升到了一个全新的高度。🚀 原因如下。

TypeScript 属于转译语言的范畴。这里的关键词是“转译”。它经常被当作编译的同义词,但实际上,它们的作用范围不同。你看,我们通常所说的编译语言指的是 C、C++、Java 等。即使它们的目标输出不同,例如 JVM(字节码)或机器码,它们都是从高抽象层次(例如 Java 的语法)编译成低抽象层次(例如机器码)。而 TypeScript 则被转译成 JavaScript,而 JavaScript 本身是一种 JIT 编译/解释型语言。抽象层次的平方啊!😅

我想说的是,TypeScript 受限于 JavaScript。它能做的,JS 本身就能做到。但 TypeScript 却欣然接受了这一点,这看似是个劣势,但实际上它不过是一门符合 ES-Next 规范并拥有静态类型系统的语言。是的,我知道我之前的文章里已经提到过这一点,但这可能是理解它最重要的一点,尤其对于新手而言。这就是为什么选择 TypeScript 作为你学习的第一门编程语言是个愚蠢的想法。先把 JS 学好,以后再回头学习 TypeScript。同样的道理也适用于本文。👏

我之前说过,TypeScript 的独特之处在于它能将各种功能完美地整合起来。这源于它的核心理念——为动态语言设计的语法提供静态类型系统。有时这似乎很容易,但有时却会非常困难。为了提供与动态类型语言相同的灵活性,TypeScript 的类型系统需要有良好的设计架构。而这自然会导致许多复杂的类型结构和变体。正是这些复杂性迫使你用不同的方式思考——用 TypeScript 的思维方式思考。🔥

不同群体

要充分利用 TypeScript 类型系统,首先必须理解它的内部结构……或者更确切地说,是它的各个类型组。这正是我们在 TypeScript 入门系列教程中所做的。所以,在这里我想做一个更全面的总结,从各个类型组的角度来审视所有这些类型。因为这才是我们编程和思考过程中真正重要的部分。👨‍💻

基本类型

最基本的类型称为原始类型顶级类型。这些类型包括number` int` string、` int` object、`int` any、`int` void、` nullint` 等。原始类型指的是即使在标准 JavaScript 社区中也广为人知的数据类型。它们的名称与其值相对应,例如 `int` booleannumber`int`、 `int`string可空类型。另一方面,顶级类型仅包含 TypeScript 和其他静态类型语言特有的类型,例如 `int`objectunknown`int` any。这两个类型组提供了您在基本层面上正确编写代码所需的一切。它们还可以与所有后续更复杂的类型组一起使用。

集合类型

另一种我们可以区分的类型是集合类型😅。所有允许你将其他类型分组的类型都可以归入此类。各种各样的类型都可以放在这个组里。接口枚举联合体——所有能够以某种方式收集其他类型(包括基本类型和集合类型)的类型!这使得你可以创建非常深层和复杂的结构,而这对于任何静态类型语言来说都是必不可少的。当然,你不能仅仅基于一个方面就将如此多的不同类型归为一类。这个组中的所有类型都是独一无二的。它们各自服务于不同的目的,并拥有各自独特的属性。例如,联合体与接口完全不同,等等。只有单一的、相似的分组属性将它们联系在这个特定的组中。

实用工具类型

在TypeScript 中,我们最后要区分的一组类型可以称为实用类型。这类类型,或者更准确地说是工具类型,包括 `<type>` extends、 `<type> keyof`、 `<type> typeof`、索引签名映射类型等等。它们的特殊之处在于语法上没有任何相似之处。它们彼此之间各不相同,用途也各不相同,而且在大多数情况下,它们必须与其他辅助类型一起使用才能发挥作用。这就是我称它们为实用类型的原因。此外,由于这种独特性,这类类型也可能是最难正确使用的。🤔

保持一定的秩序总是好的,尤其对于那些你每天都必须妥善使用的物品而言。上面的分组只是一个例子。你自己的排序方式可以更复杂,也可以更简单,甚至完全没有排序。现在,有了上面的结构,是时候真正检验一下我们的技能了!

用例

编写函数或创建接口很容易。但是,如果在编写出色代码的过程中遇到重大障碍该怎么办?因此,我想探讨一些你可能会遇到的真实而复杂的瓶颈。这样你就能编写出真正类型良好的代码!🎉

带有附加属性的索引签名

想象一下这样一种情况:你需要正确地定义一个对象的类型,除了自身独特的属性之外,它还存储了一些更通用的属性。例如,它可以是一个状态对象,拥有自己的 get 和 set 方法,同时也将所有值作为标准属性存储在自身内部。我们希望为此创建一个合适的接口。首先,我们必须创建一个索引签名。我们希望值都是静态的,也就是说,只有 `-` number、 ` string--`boolean或`--` 这样的值undefined

type StaticValue = number | string | boolean | undefined;

interface State {
    [key: string]: StaticValue;
}

Enter fullscreen mode Exit fullscreen mode

很简单,对吧?但是我们的方法呢?

interface State {
    [key: string]: StaticValue;
    get(key: string): StaticValue; // error
    set(key: string, value: StaticValue): void; // error
}

Enter fullscreen mode Exit fullscreen mode

但问题就在这里!索引签名要求给定结构的所有属性都必须具有已声明的类型,不出所料,这会导致在声明我们的方法时出现错误。

我知道上面的例子可能并非人人都能信服。你甚至可以争论这样做是否是一种反模式。但现实是,你很可能在不久的将来就遇到这类问题。那么,该如何解决呢?

在当前版本的 TypeScript (v.3.3) 中,使用接口无法实现这样的功能。我们需要使用类型别名交叉类型来实现所需的结果。👏 方法如下。

type State = {
    get(key: string): StaticValue;
    set(key: string, value: StaticValue): void;
} & {
    [key: string]: StaticValue;
}

Enter fullscreen mode Exit fullscreen mode

这看起来可能有点不太优雅,但却是唯一可行的方法。事实上,交叉类型这种看似不起眼的结构,却能解决许多日常问题。这就引出了下一个案例……

具有属性的函数

JS 中还有一种不常见但可行的模式,那就是带有附加属性的函数。它有时被称为可调用对象。不过,不要把它和类混淆(尽管对于这类任务,类有时可能更好)。

我们知道 TypeScript 允许这样的结构,因为它内置了用于定义函数类型的特殊接口语法。那么,让我们为我们的数据创建一个接口吧?

interface CallableObject {
    (param: string): string;
    myNumberProp: number;
    myBooleanProp: boolean;
}

Enter fullscreen mode Exit fullscreen mode

这里没有任何陷阱——只是一个简单的界面。一切应该都能正常运行,无需任何额外操作。但是,究竟该如何使用这样的界面呢?我们有两种选择。

首先,我们可以使用函数表达式(这是必要的)和类型转换。这样,我们之后就可以轻松地为之前列出的属性赋值,而不会出现任何错误。

const callableObj = <CallableObject>((param) => param);

callableObj.myNumberProp = 10;
callableObj.myBooleanProp = true;
callableObj("str");

Enter fullscreen mode Exit fullscreen mode

请注意,在上面的例子中,我们需要用括号将箭头函数括起来——普通的函数表达式则不需要。此外,您也不需要手动输入函数类型——类型转换已经为您完成了!😉

上述方法非常清晰且完全可行,但如果您想一次性定义可调用对象呢?有趣的是,还有另一种方法,或许更加健壮。它需要我们使用一些 ES6 的特性,即Object.assign()……

const callableObj = Object.assign(
    (param: string) => param, {
    myNumberProp: 10,
    myBooleanProp: true
});

Enter fullscreen mode Exit fullscreen mode

值得注意的是,与之前的方法相比,这里我们需要输入的代码量大大减少。Object.assign()它会自动返回一个正确的交集类型,其中包含了我们的函数及其属性。此外,这样一来,我们就可以在声明阶段更接近地定义属性,这真是太棒了!😄 当然,如果你想在这里创建一个特定的接口,你可以像之前的方法一样,使用类型转换来使用这个方法!但是,恕我直言,如果你要创建一个接口,第一个方法应该更适合你。🤔

类型推断与迭代

接下来我们将讨论循环和迭代,更具体地说,是对象迭代。与之前的例子不同,这里的情况更常见一些。遍历对象通常非常有用!我们先来创建对象。

const obj = {
    myNumberProp: 10,
    myStringProp: "str",
    myBooleanProp: true
};

Enter fullscreen mode Exit fullscreen mode

这里我们依赖类型推断来正确地为我们的对象创建一个合适的对象字面量类型。现在,有很多方法可以遍历对象。其中一种方法是使用for...in循环。

for (const key in obj) {
    const value = obj[key]; // error
}

Enter fullscreen mode Exit fullscreen mode

这就是问题所在!使用给定的键访问对象属性会导致值类型为 any,或者在严格模式下报错(顺便说一句,我总是使用严格模式的 TypeScript,并强烈建议大家也这样做,这样才能充分发挥 TypeScript 的优势 🙂)。让我们进一步探究这个问题!

首先,值得注意的是,我们对象的类型是对象字面量类型。它的所有属性都经过严格定义和类型化。这确保我们不会访问对象中不存在的属性。接下来,观察我们的循环,我们需要记住 TypeScript 类型推断的基本规则——最佳公共类型。它基本上表明推断出的类型应该尽可能通用且合适。这意味着我们key变量的类型是字符串。因此,使用像 `String`key这样通用的类型访问对象的属性string会导致前面提到的错误。对象的属性只能使用正确的字符串字面量来访问,例如 `String("") "myNumberProp"`。那么,如何解决这个问题呢?

最佳选择是自行正确地为变量指定类型。但是,对于像 `key` 这样的循环内变量,这行不通。因此,唯一的办法是充分了解我们操作可能带来的后果,并使用类型转换。最好的办法是创建一个正确转换类型的函数,而不是每次访问属性时都对 `key` 变量进行类型转换。以下是一个示例。

function loopObj<T extends object>(
    obj: T,
    callback: (key: keyof T, value: T[keyof T]) => void
) {
    for (const key in obj) {
        callback(key, obj[key]);
    }
}

loopObj(obj, (key, value) => {
    // code
});

Enter fullscreen mode Exit fullscreen mode

如您所见,我们充分利用了泛型和我们熟悉的 `__init__` 关键字keyof。这样,您只需将此函数添加到代码中,即可享受严格类型代码(使用联合体和字符串字面量)带来的良好开发体验,甚至可能获得更简洁的语法。

关于这个问题,还有最后一点需要说明。如果您出于某种原因使用 ` Object.keys()or` 或 ` .values()or`.entries()方法来迭代对象,上述规则仍然适用。这是 TypeScript 类型推断和最佳公共类型规则的固有特性。使用上述方法时,您仍然需要进行某种形式的类型转换才能获得最佳结果(尽管这里并不一定需要编写一个完整的函数)。此外,类型推断在处理值时也同样适用(类型推断为 `int` number|string|boolean),这使得 `or` 方法自然而然地适用,并且在这种情况下完全合理。👍

声明合并

最后,我想谈谈声明合并。这其实算不上什么问题,但由于这是一个非常复杂的话题,所以花些时间解释一下或许是值得的。

这里我又遇到了一个实际应用问题。我有一个类,允许用户注册额外的函数集合形式的函数。这些函数之后应该可以通过该类访问,包括正确的类型提示和智能感知。虽然这种高级场景可能需要一些调整,但仍然可以通过声明合并来实现。我们先来设置一下这个类。

class MyClass {
    myNumberProp: number = 10;
    myStringProp: string = "str";

    registerExtension(ext: {[key: string]: Function}) {
        for (const key in ext) {
            // @ts-ignore
            this[key] = ext[key];
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

这里发生了一件很糟糕的事情。使用了注释@ts-ignore,这会禁用 TypeScript 编译器对下一行的检查。这是必要的,因为我们需要给类添加一个指定名称的属性,而给定的类没有我们之前讨论过的索引签名。这里的情况比较特殊,除了省略检查之外别无他法。我们还可以更巧妙地suppressImplicitAnyIndexErrors在严格模式配置中使用`--index_signature`。这实际上会禁用 TypeScript 代码中所有与索引签名相关的错误。但说实话,最好还是严格模式,只忽略一行代码的检查,而不是忽略整段代码的检查。😕

现在,让我们创建扩展对象!

interface Extension {
    myNumberMethod(param: number): number;
    myStringMethod(param: string): string;
}
const ext: Extension = {
    myNumberMethod(param) {
        return param;
    },
    myStringMethod(param) {
        return param;
    }
}

Enter fullscreen mode Exit fullscreen mode

通过上面的代码,我们创建了扩展对象和相应的接口。这里一切都很简单明了。现在,是时候用下面的代码将它们全部连接起来了!

interface MyClass extends Extension {};

const instance = new MyClass();
instance.registerExtension(ext);

instance.myStringMethod("str");

Enter fullscreen mode Exit fullscreen mode

第一行代码创建了一个与类同名的接口。这会激活声明合并过程,并将我们的接口与类合并。因此,接口中的两个方法现在都属于类。接下来的两行代码通过将扩展注册到之前定义的方法,在代码层面实现了这一点。最后,我们的自动补全功能已经实现,并通过最后一行代码进行了测试。不错!👏ExtensionMyClassExtensionMyClass

关于上述情况的使用,还有最后一点需要说明this。您可以直接将扩展方法的参数设置为this `<parameter>MyClass `,这样就能轻松实现。然后,您只需记住在方法中绑定您的方法.registerExtension(),搞定!一切就绪!🎉

想想看……

我真心希望以上这些实际应用案例能对你提升 TypeScript 的思维方式和编程流程有所帮助。当然,我非常乐意在评论区听到你对 TypeScript 相关问题、解决方案以及本文整体的看法,也欢迎你用🤯表情符号表达你的想法。此外,如果你有任何 TypeScript 方面的问题需要帮助,也请在评论区留言,我们一起探讨解决之道!😁

一如既往,继续努力编程,精进JS和TS!如果你喜欢这篇文章,请考虑分享 给其他人,在TwitterFacebook上关注我并访问我的个人博客

资源

文章来源:https://dev.to/areknawo/how-to-think-and-type-in​​-typescript-5141