TypeScript3-快速启动指南-全-

TypeScript3 快速启动指南(全)

原文:zh.annas-archive.org/md5/c5a71e5d0a21b1aefe718d9d54222984

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本快速入门指南是一本涵盖与 TypeScript 相关所有主题的书籍。本书将引导读者了解使用 TypeScript 的所有功能和好处。通过这本指南,你将提升你的类型化技能,这将使你的 JavaScript 知识得到应用。TypeScript 是 JavaScript 的超集。本书不会教你如何进行 Web 编程,而是说明如何在已知的并喜爱的语言之上使用 TypeScript。

本书面向对象

本书面向那些了解 JavaScript 但从未接触过 TypeScript 的开发者。

本书涵盖内容

第一章,TypeScript 入门,涵盖了如何将 TypeScript 引入新项目或现有 JavaScript 项目。我们将看到如何在当前的构建设置中设置你的环境。TypeScript 被各种构建工具支持,如 Grunt、Gulp、Webpack,或者简单地使用 CLI。我们还深入探讨了所有可用选项中的基本选项,以帮助你开始使用 TypeScript。

第二章,关于使用原始类型进行类型注册,阐述了变量的作用域、未定义变量和 null 变量之间的微妙区别,以及如何使变量成为可选或必需的。在本章结束时,读者将处于所有变量都正确声明并带有 TypeScript 准确支持的类型的情境中。原始类型和非原始类型之间的区别将不再是难题。使用枚举或符号将变得自然,每当在系统中引入新的领域对象时,创建新类型将变成一种习惯。

第三章,利用对象释放类型的力量,说明了对象、Object、对象字面量和使用构造函数创建的对象之间的区别。本章还介绍了类型联合的概念,它将允许单个值有无限类型的组合。此外,还引入了交集的概念,它允许我们以不同的方式操作类型。在本章结束时,读者将能够创建包含复杂结构的对象的复杂组合。我们将剖析如何创建具有强类型索引签名的字典,理解类型如何通过映射带来好处,以及如何使用正确的对象来尽可能精确地定义具有广泛影响的对象。

第四章,在面向对象中转换您的代码,解释了面向对象编程有其自己的术语集,TypeScript 依赖于其中许多。在本章中,我们将通过示例讨论 TypeScript 支持的面向对象编程的所有概念。我们将看到类是什么以及如何将类实例化为对象。同样,我们将看到如何使用 TypeScript 将构造函数强类型化,但也将看到如何使用简写语法直接从构造函数分配类的字段。封装的可见性原则、如何实现接口以及如何将抽象引入类都得到了涵盖。

第五章,使用不同模式作用域变量,涵盖了最基本的概念的微妙之处,即变量。从原始类型到对象的精确类型对于访问特定成员至关重要。在运行时和设计时精确作用域类型对于两个环境之间的一致性和获得关于可能或不可能的反馈至关重要。不同类型变量之间的配置多样性需要本章涵盖的许多不同模式。

第六章,通过泛型重用代码,基于前几章介绍的思想。本章通过增强类型并使其泛型化来构建这些思想。基本主题,如定义泛型类和接口,是存在的。通过本章,我们将进入更高级的主题,如泛型约束。本章的目标是使您的代码更加泛型,以增加类、函数和结构的可重用性,从而减少代码复制的负担。

第七章,掌握定义类型的艺术,涵盖了如何从我们未直接工作的库中创建类型,而是在 TypeScript 项目中导入库。主要区别在于,当使用项目外部的代码时,您不会直接使用 TypeScript 代码,而是使用它们的定义。原因是那些库中提供的是 JavaScript 代码,而不是 TypeScript 代码。我们将看到如何掌握为不提供它们的代码创建定义文件的艺术,让您能够在强大的环境中继续工作。

为了充分利用这本书

这本书是为想要开始使用 TypeScript 构建应用的 JavaScript 开发者准备的。不需要对 TypeScript 有任何先前的知识。

下载示例代码文件

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com 登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Typescript-3.0-Quick-Start-Guide。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供下载,网址为github.com/PacktPublishing/。查看它们!

下载彩色图像

我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/TypeScript3QuickStartGuide_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

let a:number = 2;
a = "two"; // Doesn't compile

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

function switchFunction(num: number) {
   let b: string = "functionb";

   switch (num) {
       case 1:
         let b: string = "case 1";
       break;
   }
}

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要说明看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们欢迎读者的反馈。

一般反馈:请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们将非常感激您能提供位置地址或网站名称。请通过copyright@packtpub.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问 packtpub.com

第一章:TypeScript 入门

在本章中,我们将探讨 TypeScript 并了解如何在新的项目或现有的 JavaScript 项目中使用 TypeScript。我们将看到如何适应你当前的构建环境。TypeScript 可以由各种构建工具支持,例如 Grunt、Gulp、webpack 或简单地通过使用 命令行界面CLI)。我们还探讨了在所有可用的选项中,开始使用 TypeScript 的最佳选项。

在配置任何构建工具之前,重要的是要理解它们都使用相同的 TypeScript 编译器,通常被称为转换器。TypeScript 编译器可以通过 npm 使用:

npm install -g typescript

Npm 可能默认没有安装在你的电脑上。如果是这种情况,之前的指令将会失败。这意味着你需要安装 Node.js。你可以通过访问官方网站 nodejs.org/ 来安装 Node.js。

在任何时候,你都可以使用以下命令来验证你是否已经安装了 Node.js、npm 和 TypeScript:

node -v
npm -v
tsc -v

TSC 是 TypeScript 编译器的可执行文件。这是所有构建工具都使用的文件。Grunt、Gulp 和 webpack 通过它们各自的插件基础设施使用 TSC,这些基础设施将 TSC 的功能映射到它们自己的平台。请注意,最近的 TSC 功能可能需要几周时间才能到达这些平台。这可能是使用这三个构建系统时编译器选项差异的原因。相比之下,使用 TSC CLI 确保你直接使用 TypeScript。

本章涵盖了以下内容:

  • Grunt

  • Gulp

  • Webpack

  • NPM/CLI

  • TypeScript 编译器

Grunt

Grunt 是一个 JavaScript 任务运行器。它可以通过 NPM 安装,NPM 用来列出所有它的插件:

npm install -g grunt-cli

在新项目的情况下,请确保你的 TypeScript 项目的根目录中存在 package.json 文件。你可以通过使用 npm init 命令来生成一个简单的 package.json

完成后,你可以在你的项目中安装 Grunt:

npm install grunt --save-dev

一旦 Grunt 在你的机器上可用并在你的项目中指定,你需要获取一个 TypeScript 插件。Grunt 有两个插件名为 grunt -TypeScriptgrunt-TS。前者已经有一段时间没有维护,并且缺少最新的 TypeScript 编译器配置。我强烈建议使用后者:

npm install grunt-ts --save-dev

最后一个包应该被安装为一个开发依赖,以便 Grunt 能够编译 TypeScript 并本地安装它。Grunt 将会在本地搜索这个包。省略 TypeScript 作为本地依赖将会在执行 Grunt 时导致以下错误。

ENOENT:找不到文件或目录,打开 '/.../node_modules/grunt-ts/node_modules/typescript/package.json' 使用 --force 继续操作。

将 TypeScript 本地作为 dev 依赖项安装很简单:

npm install typescript --save-dev

grunt-ts 版本 6 之前,TypeScript 和 Grunt 在 grunt-ts 的安装过程中一起安装。现在不再是这样了,所以它们必须手动添加。

下一步是配置 Grunt 以使用 TypeScript 插件。如果您没有使用 Grunt,您需要在项目的根目录下创建一个 Gruntfile.js 文件。否则,您可以编辑现有的一个。该插件允许您在 Gruntfile.js 中指定许多 TypeScript 选项,但一个好的做法是直接在文件中限制 TypeScript 选项,并利用 TypeScript 配置文件。通过在 Grunt 之外配置 TypeScript,这为您提供了在不重复或更改 TypeScript 首选项的情况下,编译代码而不使用 Grunt 或迁移到另一个构建工具的可能性。

一个仅用于将 TypeScript 编译成 JavaScript 的极简 Grunt 配置可能看起来像以下这样:

module.exports = function(grunt) {
 grunt.initConfig({
   ts: {
    default : {
     tsconfig: './tsconfig.json'
    }
   }
 });
 grunt.loadNpmTasks("grunt-ts");
 grunt.registerTask("default", ["ts"]);
};

Grunt 配置创建了一个默认任务,该任务执行一个自定义的 ts 任务,该任务链接到 tsconfig.json 文件,这是默认的 TypeScript 配置文件。

tsconfig.json 文件可能看起来像以下这样,它将编译所有扩展名为 .ts 的 TypeScript 文件,并将结果输出到 build 文件夹:

{
 "compilerOptions": {
   "rootDir": "src",
   "outDir": "build",
 }
}

当使用 gruntgrunt-ts 时,您必须确保 tsconfig.json 文件中的 JSON 是有效的,且没有尾随逗号。否则,您可能会遇到以下错误:

tsconfig error: "Error parsing \"./tsconfig.json\".  It may not be valid JSON in UTF-8."

要测试配置,在项目根目录下的 src 文件夹中创建一个简单的 index.ts 文件。您可以输入 console.log('test')。之后,在项目根目录的命令行中运行 grunt。这将创建一个包含相同代码行的 index.js 文件的 build 文件夹。它还将创建一个 js.map 文件,让您可以直接在浏览器中调试 TypeScript 代码。

如果出于某种原因,您不想依赖 tsconfig.json,您可以直接在 Gruntfile.js 文件中指定源和目标:

module.exports = function (grunt) {
 grunt.initConfig({
  ts: {
   default: {
    src: ["src/**/*.ts"],
     outDir: "build",
     options: {
     rootDir: "src"
    }
   }
  }
 });
 grunt.loadNpmTasks("grunt-ts");
 grunt.registerTask("default", ["ts"]);
};

最后,grunt-ts 包装了 TypeScript 命令行。它提供了如 快速编译 等选项,只编译自上次编译以来发生变化的文件。如果您已经在项目中使用 Grunt,并希望开始使用 TypeScript 而不修改您的构建过程,这也是一个有趣的选项。

Gulp

Gulp 是一个具有 TypeScript 插件的自动化工具包。NPM 中有两个插件可用,分别是 gulp-tsbgulp-typescript。后者是最受欢迎且维护得更好的。您可以使用以下命令获取 gulp 和插件:

npm install -g gulp
npm install --save-dev gulp-typescript

如果您没有 Gulp 配置文件,您需要在 gulpfile.js 项目的根目录下创建一个。

没有明确选项的配置将依赖于默认配置。这意味着配置 Gulp 可以像将源代码管道输入 TypeScript 插件,然后将结果管道输入目标文件夹一样简单,该文件夹将放置 build 文件,即 JavaScript 文件,以便消费。一旦将以下代码放置在 gulpfile.js 中,您就可以通过在命令行中使用 gulp 来执行它。这将自动执行一次 默认任务

var gulp = require("gulp");
var ts = require("gulp-typescript");

gulp.task("default", function () {
 var tsResult = gulp.src("src/**/*.ts")
 .pipe(ts());
 return tsResult.js.pipe(gulp.dest("build"));
});

在 Gulp 中有一个任务可以增量构建更改的 TypeScript 文件,而不是构建所有文件。这对于大型项目来说可能很有用,可以减少编辑和访问结果之间的时间。这与 Grunt 的 快速编译 类似。为了实现持续编译,您必须创建一个新的 Gulp 任务。在这个例子中,我们将把 Gulp 改为依赖于 tsconfig.json 文件,这将允许我们将 TypeScript 编译器选项与 Gulp 配置分开:

var gulp = require('gulp');
var ts = require('gulp-typescript');
var tsProject = ts.createProject('tsconfig.json');

gulp.task('scripts', function() {
 return gulp.src('src/**/*.ts')
 .pipe(tsProject())
 .pipe(gulp.dest('build'));
});
gulp.task('watch', ['scripts'], function() {
 gulp.watch('src/**/*.ts', ['scripts']);
});

要运行 watch 任务,您需要先执行 Gulp,然后跟随着任务的名称:gulp watch。与 Grunt 不同,Gulp 不会生成 map 文件。它需要一个额外的 Gulp 插件。因为源映射对于拥有高效的调试环境至关重要,所以强烈建议下载 gulp-sourcemap 包,并将之前的配置更改为以下内容。但首先,让我们下载 gulp-sourcemaps 包:

npm install --save-dev gulp-sourcemaps

然后创建一个新的任务:

var sourcemaps = require('gulp-sourcemaps');
gulp.task('scriptswithsourcemap', function () {
 return gulp.src('src/**/*.ts')
 .pipe(sourcemaps.init())
 .pipe(tsProject())
 .pipe(sourcemaps.write('.', { includeContent: false, sourceRoot: '.'}))
 .pipe(gulp.dest('build'));
});

配置将在与生成的 JavaScript 文件同名但扩展名不同的文件中创建源映射。扩展名将是 .jsmap。如果您想在 JavaScript 文件中直接包含映射,可以移除在 write 函数中传入的两个参数。我建议有一个单独的脚本任务,在文件中生成源映射,以将映射与生成的代码分开,并始终创建源映射。这是编译过程中的小代价,但在调试中能带来巨大的收益。

Webpack

Webpack 是当与 JavaScript 和 Web 开发一起工作时自动化工作流程最常用的方式之一。其主要目的是捆绑,但它可以执行许多连续步骤,例如编译 TypeScript。类似于 Grunt 和 Gulp,webpack 有两个用于 TypeScript 的加载器(类似于插件)。一个是ts-loader,另一个是awesome-typescript-loader。在 Grunt 和 Gulp 中,用户更喜欢哪一个很明确,但在 Webpack 中并非如此。这两个加载器在受欢迎程度方面相似。如果需要,它们之间切换也不困难。最初,awesome-typescript-loaderts-loader更快,但随着 TypeScript 的发展,差异通常很小。此外,有时一个或另一个的高级功能可能会出现问题,因此能够根据您的项目进行切换是实用的。我将介绍ts-loader,它稍微更受欢迎,仍然积极维护,并且比awesome-typescript-loader有更多的使用。

如果您尚未使用webpack,我们需要安装它:

npm install --save-dev webpack
npm install --save-dev webpack-cli

一旦安装了 webpack,您就可以安装 TypeScript 加载器:

npm install --save-dev ts-loader

一旦所有工具都安装完毕,您就可以配置 webpack 以捆绑由webpack加载器产生的 JavaScript。然而,在项目的根目录中需要webpack.config.js文件。像任何 Webpack 配置一样,入口属性必须被定义。确保您正在引用 TypeScript 文件。输出也在输出属性中指定。Webpack 需要提及要分析的扩展名。在 TypeScript 的情况下是.ts,但如果您使用 React 工作,您可能还希望在resolve:extensions下添加.tsx。最后,在module:rules下指定ts-loader。再次强调,需要 TypeScript 的扩展名和加载器的名称:

module.exports = {
 mode: "development",
 devtool: "source-map",
 entry: "./src/index.ts",
 output: {
  path: __dirname + "/build",
  filename: "bundle.js"
 },
 resolve: {
  extensions: [".ts"]
 },
 module: {
  rules: [
  { test: /\.ts$/, loader: "ts-loader" }
  ]
 }
};

您可以通过访问二进制文件来运行 webpack 命令行(cli),这将读取webpack.config.js文件:

node node-modules/webpack-cli/bin/cli.js

如果您想避免引用node_modules,您可以在全局空间中安装webpack-cli,使用npm install -g webpack-cli

关于 webpack 的一些小细节。与仅验证 TypeScript 相比,webpack 有一个额外的模块,它将包的生产和编译分开。当您的项目开始增长并且希望拥有更快的编译速度时,这些模块可能很有趣。您可以随意检查fork-ts-checker-webpack-pluginthread-loader。在深入研究其他库之前,ts-loader有一种增量构建的方法,并使用 TypeScript 的 watch API 来避免总是构建一切。这将提高每次编译的性能。要允许监视,将ts-loader的规则更改为以下内容:

rules: [
 {
 test: /\.ts$/,
 use: [
  {
   loader: 'ts-loader',
   options: {
    transpileOnly: true,
    experimentalWatchApi: true,
   },
  },
 ],
 }
]

关于 webpack 的最后一个细节是它依赖于tsconfig.json来进行所有 TypeScript 相关配置。Grunt 和 Gulp 允许您在它们的工具配置中覆盖配置,而 webpack 则不是这样。在打包时,webpack 会生成bundle.js.map,但前提是开发工具指定了配置。然而,您必须将tsconfig.json的“sourceMap”设置为true,以便生成与 TypeScript 兼容的映射。

NPM/CLI

几乎所有 Web 项目都使用 NPM。NPM 是我们用来获取 TypeScript 的机制。它会在项目根目录创建package.json,并可以直接用来启动 TypeScript。这是因为 TypeScript 有一个名为tscTypeScript 编译器)的 CLI。

NPM 配置中有一个名为scripts的部分,您可以在其中添加任何想要的命令。您可以创建一个build命令,该命令调用 tsc。如果没有任何参数,tsc 将使用项目根目录下的tsconfig.json。在下面的代码片段中,定义了build脚本。要运行该命令,需要使用 NPM 的run命令,即npm run build

"scripts": {
"build": "node_modules/typescript/bin/tsc"
},

使用指定了源映射、rootDiroutDir的 TypeScript 配置文件,结果将与 Gulp 和 Grunt 相同(与 webpack 不同,因为它不会进行打包):

{
 "compilerOptions": {
 "rootDir": "src",
 "outDir": "build",
 "sourceMap": true
 }
}

这种配置通常不是首选,因为它过于简单且有限。然而,可以使用双与号(&&)创建一系列命令,一个接一个地执行。这个选项速度快,不需要依赖 NPM 库,通常足以在基本级别上开始。

NPM 和 CLI 方法的优势在于 TypeScript 可以轻松执行。因此,如果您有一个自定义的构建系统,您可以轻松通过调用 CLI 来集成 TypeScript。

TypeScript 编译器

考虑到所有可用的工具都可以将 TypeScript 编译成 JavaScript,一个核心概念保持不变:您必须知道使用哪种配置。既然您不负责配置编译器,您可以跳过这一节——配置 TypeScript 是您很少做的事情,而且当它按预期工作后,可以长时间保持不变。然而,为了了解 TypeScript 的能力,您需要了解一些核心选项。在本节中,我们将看到您可以为项目启用和自定义的主要设置。

文件位置

这一节主要关于文件系统中的文件配置。它指导 TypeScript 在您的机器上查找不同的文件位置,以及在哪里生成 JavaScript 文件。

rootDir 和 outDir

对于您的项目,您需要设置的最基本的配置是告诉 TypeScript 从哪里获取 TypeScript 文件以及将编译结果发布到何处。TypeScript (.ts) 文件和 JavaScript (.js) 文件将生产在哪里。这是通过指定 rootDiroutDir 来完成的。避免使用 rootDir 可能会在 outDir 中给您带来惊喜。默认情况下,TypeScript 会计算它应该是什么,并尝试找到一个公共路径,即所有输入文件的最长公共前缀。这有一个缺点,即当文件结构发生变化时,它可能是不一致的。最近,TypeScript 改变了其行为,默认为 .,这缓解了问题。尽管如此,具有显式配置是最佳实践,以避免对哪种版本的 TypeScript 应用了此新规则的混淆。

示例:

rootDir:src
outDir:build

baseUrl 和 paths

当 baseUrl 和 paths 一起使用时,可能会产生混淆。baseUrl 允许使用非相对名称进行解析。Paths 与 baseUrl 密切相关,是一个键值映射,允许使用名称作为链接到库的特定路径,使用 baseUrl 作为根。

这里有一个示例:

{
 "compilerOptions": {
 "baseUrl": ".", // This must be specified if "paths" is.
 "paths": {
 "jquery": ["node_modules/jquery/dist/jquery"] // This mapping is relative to "baseUrl"
 }
 }
}

在代码中:

Import * from "jquery"

Paths 也可以用于更高级的场景,其中您可以定义后备文件夹。作为良好的实践,我建议尽可能使用相对路径,并避免复杂的结构和潜在的解析问题。

sourceRoot 和 sourceMap 以及 mapRoot

sourceMap 属性是一个布尔值,当设置为 true 时,将生成生成的 JavaScript 和 TypeScript 之间的映射。如果您在浏览器中调试并希望进入 TypeScript 代码而不是进入生成的代码,这是一个很好的选项。这简化了调试,因为您正在工作的区域完全相同。这通常是打开的。

然而,在正常情况下,sourceRoot 很少被使用。如果你将 sourceMap 移动到其他位置以在运行时指示其位置,则它可用。这将改变生成的 sourceMap 路径。以下代码展示了注释,说明了映射文件的路径。SourceRoot 会改变 index.js.map 前的部分:

const text = "Text for test1";
console.log(text);
//# sourceMappingURL=index.js.map

类似地,mapRoot 允许在映射文件位于 JavaScript 文件不同位置时更改源。sourceRoot 和 mapRoot 的区别在于这次我们更改的是映射文件而不是 JavaScript 文件。在以下映射文件代码的部分提取中,我们看到可以由 mapRoot 修改的路径:

{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"]......

使用或配置的时机取决于你如何配置你的构建文件。如果你将映射移动到其他地方,那么sourceRoot就很有趣。然而,如果你保留映射但将 JavaScript 移动到其他地方,你可能需要更改mapRoot。我提到这些配置的唯一原因是你可能已经有一个你想要迁移到 TypeScript 的 JavaScript 项目。根据你的现有配置,你可能需要调整这些配置。然而,对于任何标准项目,都不应该对这些配置进行修改。

文件和包含与排除

包含是一个数组,指定了 glob 模式,该模式指定了哪些文件/文件夹要包含在编译中。数组exclude补充了include,并移除了要编译的文件。当这两个属性都被指定时,exclude将从include中过滤掉包含的文件。默认情况下,include包括rootDir下的所有 TypeScript 文件,因此不需要添加**/*.ts的条目。

文件很少使用,因为它不如include灵活。它允许通过路径和名称指定要编译的文件,而不是使用 glob 模式。模式方法更灵活,允许你一次性配置,而不是通过在文件中添加和删除条目来不断修改配置文件。

这里有一个例子:

"include": [
 "src/**/*"
 ],
 "exclude": [
 "node_modules",
 "dont/compile/*.mock.ts"
 ]

关于这三个文件配置的最后一句话是,与大多数选项相反,这些配置不是位于compilerOptions下,而是直接设置在tsconfig.json文件的根目录。

输出文件

输出文件是一个有用的选项,如果你需要从多个 TypeScript 文件生成单个 JavaScript 文件。当使用输出文件时,你可以移除outDir并设置一个相对于项目根目录的路径,然后跟上生成文件的名称和扩展名:

{
 "compilerOptions": {
 "rootDir": "src",
 "outDir": "build",
 "target": "es6",
 "sourceMap": true,
 "outFile": "build/mySingleFile.js"
 }
}

上面的示例代码创建了一个单个文件,但也创建了sourceMap文件,这是因为配置了sourceMap

类型

本节包含有关 TypeScript 类型的说明。第一个配置为 TypeScript 提供了查找类型的提示,同时也告诉 TypeScript 在编译时是否必须生成定义文件。

typeRootstypes

默认情况下,node_modules库中提供的每个类型,包括所有特定的@types/和直接在库文件夹内的.d.ts包,都会被 TypeScript 编译器读取。然而,在某些没有定义文件可用且你需要提供一个自定义文件的场景中,你需要指定定义文件的位置。这可以通过指定一个文件夹来完成,在该文件夹中你可以定义所有你的定义文件。需要注意的是,如果你想让 TypeScript 继续在node_modules中查找定义文件,你需要指定node_modules

{
 "compilerOptions": {
 "typeRoots" : ["./typings", “./node_modules”]
 }
}

Types 配置允许 TypeScript 选择包含哪些文件。它与 typeRoots 协同工作,是一个数组。它将类型名称列入白名单。

声明和声明目录

如果你正在构建一个库而不是网站或程序,那么提供定义文件与生成的 JavaScript 文件一起可能是明智的。原因是当在 TypeScript 中构建库时,我们从不共享实际的 TypeScript(.ts)文件,而是共享 JavaScript 文件。其理由是 TypeScript 只是 JavaScript 的超集,我们希望将我们的代码暴露给尽可能广泛的受众。通过提供 JavaScript 文件,我们允许每个 JavaScript 开发者消费我们的工作。然而,TypeScript 开发者可以休息。为了解决这个问题,我们可以提供一个包含所有签名函数以及导出变量的定义文件(.d.ts)。TypeScript 编译器允许你通过使用 declaration 自动生成定义文件:

该选项是 boolean

{
 "compilerOptions": {
 "declaration" : true
 }
}

默认情况下,生成的声明文件是 TypeScript 文件,并且位于与 TypeScript 文件相同的目录。这意味着对于每个 JavaScript(.js)文件,你都会看到紧挨着它的兄弟声明文件(.d.ts):

{
 "compilerOptions": {
 "declaration" : true,
 "declarationDir": "definitionfiles/here"
 }
}

declarationDir 有一个需要注意的问题,那就是它不能与 outFile 一起使用。你将得到一个编译错误,指出这两个选项不能同时定义:

error TS5053: Option 'declarationDir' cannot be specified with option 'outFile'.

配置文件

TypeScript 有自己的配置文件,这是一种方便的方式,可以避免通过命令行参数传递每个选项。该文件位于项目的根目录。一种可能性是拥有几个配置文件,可以在不同情况下使用。这是通过向 tsc 提供带有选项 -p 后跟配置文件名的选项来实现的。以下三个命令行调用显示了一个没有任何参数的例子,它实际上与第二行执行相同的编译。尽管如此,第三条编译指令是不同的,指向一组完全不同的选项:

tsc
tsc -p tsconfig.json
tsc -p tsconfig.test.json

配置文件的一个好处是可以通过扩展配置来实现可重用性。你可以将这个原则比作面向对象中的继承——一个文件可以继承另一个文件。这可以通过使用 extends 属性作为键,以及要继承的文件作为值来实现。提供的文件必须相对于项目的根目录。它可以有或没有扩展名(.json):

{
 "extends": "./tsconfig.json",
 "compilerOptions": {
 "outDir": "buildtest",
 "sourceMap": false,
 "declaration": false
 }
}

以下示例显示了一个命令,该命令使用 tsconfig.test.json 中的选项进行编译,并包含一组小指令:

tsc -p tsconfig.test.json

第一个是要扩展的文件。在这种情况下,也可以是 ./tsconfig 而不带扩展名。该文件覆盖了 tsconfig.json 文件中提供的 outDir,并添加了额外的值。一个好的模式是有一个 base 配置,其中包含你已知将在许多配置中共享的配置。

模块和模块解析

JavaScript 中代码分离的一个关键概念是模块。模块引入了导入和导出代码的概念。通过指定特定的名称和代码的哪一部分可以导出,这种能力增加了共享代码的能力。然后,其他软件可以导入代码并利用其功能。然而,并没有一种方法可以构建模块。TypeScript 允许你以单一的方式编写代码,并在编译期间生成一个尊重不同流行模块语法的输出。以下是 TypeScript 可以解释的模块列表:

"None", "CommonJS", "AMD", "System", "UMD", "ES6", "ES2015" or "ESNext".

module 选项可以看作是 TypeScript 如何生成模块,而 moduleResolution 则是它如何读取模块。TypeScript 可以理解 import 语句的两种方式:类和节点。前者是传统的 TypeScript 方法,它有不同的规则来查找导入的文件。更受欢迎的选择是 node

无论模块解析选项如何,你应该尝试通过在导入中指定从导入文件开始的路径来使用相对解析。相对导入通过路径以单个点或双点开头来表示,以向后移动。以下是一些相对导入的例子:

import x from "./sameFolder";
import y from "../parent/folder";
import z from "../../../deeper/";

使用它的原因在于代码导入的清晰性。使用绝对解析会带来混淆,因为它依赖于其他配置,如 baseUrl 以及 moduleResolution

相反,依赖于更复杂解析的导入是非相对的,其形式如下:

import a from "module123";

不深入复杂的规则,经典示例中的最后一个例子会寻找与导入文件相邻的模块,并沿着文件夹结构向下查找,而不会尝试从 node_modules 中导入模块。然而,如果 moduleResolution 设置为 node,则第一个检查将是查找 node_modules 文件夹中的 module123。正如你所看到的,如果你使用一个常见的名称,你可能会加载一个意外的模块。

ECMAScript

本节包含与生成的 ECMAScript 类型相关的 TypeScript 配置,以及可以合并的附加包。

目标

必须指定目标选项,但很少更改。此选项指示 TypeScript 生成哪个版本的 JavaScript 文件。默认情况下,它生成 ECMAScript 3 版本,该版本没有 TypeScript 允许的所有内置功能。然而,TypeScript 仍然可以通过生成模拟这些功能的 JavaScript 代码来生成这样的旧版本 ECMAScript。这伴随着运行时性能惩罚的代价,但这是在较旧的浏览器上构建现代代码的绝佳方式。以下是你可以指定的实际目标:

"ES3", "ES5", "ES6"/"ES2015", "ES2016", "ES2017" or "ESNext"

如果你通常在网络上部署,ES5 是一个安全的选择,因为所有浏览器都对其提供 100% 的支持。但是,ES6 非常接近,Chrome 支持 98% 的其功能,Firefox 97%,Edge 96%。

Lib

TypeScript 可以将 ECMAScript 的核心库注入到生成的代码中。默认情况下,一些库会自动添加。例如,如果您指定目标为 ES5,TypeScript 会添加库:DOM、ES5 和 ScriptHost。您可以手动添加额外的库。例如,如果您想使用可迭代对象,可以在 lib 数组中添加字符串 ES2015.Iterable。您还可以使用超出您主要目标的功能。例如,您可以将目标设置为 ES2015 并使用 ES2018 的功能。将 "target" 视为编译中要包含的主要功能集,将 lib 视为可以添加到编译中的附加功能的子集。

编译器严格性

TypeScript 在如何严格分析您的代码方面有许多选项。本节向您展示了每个设置的差异,如果您来自现有的 JavaScript,允许您逐步开始。

严格

这是将每个配置转换为严格的选项。如果您从 JavaScript 开始一个新的 TypeScript 项目,这是您应该使用的。

StrictFunctionTypes

这是一个高级检查,不允许函数参数的二向变异性。它使用逆变异性。这意味着如果函数期望类型 A 作为参数,您不能设置一个具有类型 B 的函数,该类型继承自类型 A - 您必须只传递类型 A。以下在 StrictFunctionTypes 设置为 true 的情况下无法编译。严格的选项有助于避免传递比预期类型有更多成员的对象。以下示例中 BfirstName 字段,并继承自 A,因此得名:

interface A {
 name: string;
}

interface B extends A {
 firstName: string;
}

declare let f1: (x: A) => void;
declare let f2: (x: B) => void;

f1 = f2; // DOESNT COMPILE
f2 = f1;

在编译过程中,TypeScript 发现参数被传递给具有更多成员的对象,因此无法编译:

Error message :  Type 'A' is not assignable to type 'B'. Property 'firstName' is missing in type 'A'.

StrictPropertyInitialization 和 StricNullChecks

StrictPropertyInitialization 属性应始终设置为 true。这确保了类的所有属性都在声明级别或类的构造函数中与直接关联初始化。

class A {
 public field1: number;
}

示例无法编译,因为 Field1 是一个未定义的数字。该字段的值是未定义的。有许多解决方案可以保持严格性并使代码符合规范。第一个解决方案是在初始化时设置值:

class A {
 public field1: number = 1;
}

在初始化时设置默认值并不总是可能的。在某些情况下,可以在构造类型中指定值:

class A {
 public field1: number;
 constructor(p:number){
   This.field1 = p;
 }
}

编译代码的第三种方法是使用成员名称之后的感叹号运算符(!)。该运算符指示 TypeScript 该值将在以后提供。延迟初始化的场景通常是通过某些注入框架或使用函数来初始化类。

使用 strictPropertyInitialization 执行此任务的注意事项之一是依赖于另一个必须启用的严格属性——strictNullChecks。空值检查也应该始终设置为 true。如果没有这个设置,被识别为类型的字段将自动接受 null 和 undefined 作为有效的类型。仅支持具有显式类型的字段,并在 null 或/和 undefined 的情况下使用本书后面将看到的属性定义,这样更不容易混淆,也更具有声明性。

摘要

在本章中,我们设置了不同的配置,使您能够以直接的方式开始使用 TypeScript 进行编码。在设置您喜欢的开发环境后,我们简要介绍了最重要的编译器选项,以帮助您走上正确的道路。TypeScript 是一个灵活的编译器,由于设置的选择方式,您应该能够快速掌握开发技能。

在下一章中,我们将通过介绍如何将 ECMAScript 原始类型强类型化来探讨使用 TypeScript 进行编程。

第二章:使用原始类型引入类型

原始类型是所有基本支持的价值类别。每种类型代表一个值域,其中格式的一致性得到强制执行。JavaScript 有一个有限的原始类型集合,这些类型只能通过将值分配给变量来推断。例如,一个值可以是数字、日期、布尔值、字符串等。将不同模型的后继值分配给单个变量是允许的。副作用是类型的突变,这增加了任何 JavaScript 程序的复杂性。然而,TypeScript 可以强制类型不可变性,这降低了潜在错误值误导应用程序正确执行的风险。此外,TypeScript 根据附加到特定值的显式类型提供对哪些操作可以使用支持。本章说明了变量的作用域、未定义变量和空变量之间的细微差别,以及如何使变量可选或必需。在本章结束时,读者将处于所有变量都正确声明、由 TypeScript 支持准确类型的情况。原始类型和非原始类型之间的区别将不再是一个难题。使用 enumsymbol 将变得自然,每当在系统中引入新的域对象时,创建新类型将变成一种习惯。

本章将涵盖以下内容:

  • varletconst 之间的区别

  • 如何在不指定类型的情况下进行强类型声明

var、let 和 const 之间的区别

TypeScript 有多种声明变量的方式。你可以使用以下三个关键字之一在函数或全局作用域中定义变量:varletconst。此外,你还可以在类级别上使用 publicprivateprotected 来定义变量。

使用 var 声明

声明变量的最基本方式是使用关键字 var。它是最古老的声明方式,但由于一些怪癖,它是最不受欢迎的方式。var 的主要问题是它在执行上下文中声明,这意味着在函数作用域或全局作用域内。如果意外地将值分配给未显式使用 var 声明的变量,那么该变量的作用域就是全局作用域。以下是一个例子:

function f1(){
   a = 2; // No explicit "var", hence global scope instead of function scope
}

使用 var 声明可以通过 JavaScript 的严格模式变得更加严格,这样 TypeScript 可以通过编译器选项中的 alwaysStrict 自动在每一个文件上开启。否则,你必须记住 var 声明的变量是在代码执行之前创建的。没有 var 关键字的变量直到分配给它们的代码执行时才存在。在 JavaScript 中,可以不声明就分配变量,但在 TypeScript 中则不是这样:

a = 2; // Won't create a variable in TypeScript

虽然 TypeScript 可以防止未声明的变量,但它不能防止 var 声明受到 提升 的影响。这个问题源于 JavaScript,其中使用 var 的声明会在其他代码片段之前处理,将变量声明带到作用域(函数或全局)的顶部。微妙之处在于声明被提升,但初始化没有。也就是说,TypeScript 不允许你使用在其使用下定义的变量:

console.log("Test", a); // Won’t allow to use the variable in TypeScript
var a = 2;

最后,var 允许你多次定义变量,以覆盖初始声明或初始化:

var a = 2;
var a = 23;

通常,使用 var 来声明变量是一种过时的方法。在 TypeScript 中,有很大动力依赖 letconst,因为你可以生成一个较旧的 ECMAScript 版本,该版本会生成 var,但格式正确且有效。

使用 let 声明

let 声明是作用域相关的。在同一个作用域内不能多次声明,并且它不会提升变量。它简化了代码的可读性,并避免了意外的错误。使用 let 声明也不会设置任何全局值。当你期望变量被多次设置时,依赖 let 是声明变量的方式。在以下代码中,变量 a 被定义了三次。代码是合法的,即使有多个声明。原因是每个声明,使用 let,都在不同的作用域中定义,使用花括号。第一个作用域是函数作用域。第二个作用域使用了一种不寻常的语法,但它反映了 whileif 或其他作用域功能的工作方式。第三个作用域在 if 语句内:

function letFunction() {
   let a: number = 1;
   { // Scope start
     let a: number = 2;
   } // Scope end
   console.log(a); // 1

   if(true){ // Scope start
     let a: number = 3;
    } // Scope end
    console.log(a); // 1
}
letFunction()

此外,TypeScript 确保一旦完成声明,与变量关联的类型是不可变的。这意味着一个定义为数字的变量将保持为数字,直到变量的整个生命周期:

let a:number = 2;
a = "two"; // Doesn’t compile

switch 情况中使用 let 可能很棘手。原因是作用域不是由 case 决定的,而是由包含所有情况的 switch 决定的。然而,通过在每个 case 中召唤一个花括号,可以构想一个作用域。以下代码是有效的,即使声明了两个变量 b

function switchFunction(num: number) {
   let b: string = "functionb";

   switch (num) {
       case 1:
         let b: string = "case 1";
       break;
   }
}

然而,添加一个随后也声明变量 b 的情况会导致编译失败:

function switchFunction(num: number) {
 let b: string = "functionb";

  switch (num) {
    case 1:
     let b: string = "case 1";
    break;
    case 2:
     let b: string = "case 2";
    break;
  }
}

switch 的默认作用域中找到的解决方案是为每个情况创建一个人工作用域。作用域的构建可以通过添加花括号来完成,如下面的代码所示:

function switchFunction(num: number) {
  let b: string = "functionb";

  switch (num) {
    case 1: {
      let b: string = "case 1";
    break;
    } // After break
    case 2: {
      let b: string = "case 2";
    } // Before break
    break;
  }

}

let 是最常用的声明之一,并且当 const 不是一个有效选项时,应该始终使用 let 而不是 var

const

如果你知道变量只设置一次且不会改变,那么使用const是一个更好的选择。原因是它向代码的读者表明该值不能被设置超过一次——它是声明和初始化的。TypeScript 尊重letconst,如果变量被多次定义或当变量是常量时值被两次赋值,代码将无法编译:

将变量约束为保持单一值可能看起来很限制,但在许多情况下,这是正确的事情。使用const声明的原始类型阻止了使用等号(=)进行赋值,这意味着它不允许你更改变量的引用。然而,你可以更改变量的内容。例如,原始类型的数组可以添加和从数组中删除值,但不能分配新的值列表:

const arr: number[] = [1, 2, 3];
arr.push(4);

以下代码显示即使对象被声明为常量,成员也可以被编辑。然而,myObj是不可赋值的。这意味着引用将始终保持不变:

const myObj: { x: number } = { x: 1 };
myObj.x = 2;

最后,TypeScript 通过使用letconst确保分配给变量的值与期望的变量相关联,任何错误的赋值都会导致编译器返回错误。在下面的代码中,两个变量在全局作用域和函数作用域中都被明确定义,毫无疑问,它们是两个具有任何值冲突的独立变量:

const a = 2;
function z() {
   let a = 3;
}

使用 TypeScript 增强原始类型

TypeScript 具有与 JavaScript 相同的原始变量。可以声明一个变量来存储数字、字符串、布尔值和符号。此外,还有两个原始类型可用于没有值的情况:undefinednull。最后,使用这些原始类型,可以有一个包含它们的数组。

所有原始类型都必须使用之前讨论过的唯一变量名进行声明,并使用冒号后跟单词number。然而,当用作函数的参数时,应避免使用declaration关键字。没有必要指定变量的作用域,因为这个变量是为函数准备的。同样,可见性也仅限于接收参数的函数:

function noNeedConstLetvar(parameter1: number) { }

Number

TypeScript 遵循 JavaScript 如何通过单一类型:number 来操作和携带数字的原始类型。一个数字可以是整数浮点数双精度浮点数、负数、正数甚至是NaN

数字不能直接使用布尔值(无论是true还是false)。需要通过解析布尔值来进行转换:

let boolean: number = true; // Won't compile

将布尔值转换为数字的方法有很多。你可以使用Number构造函数,它接受任何值并将其转换为true1false0的数字:

 let boolean1: number = Number(true);
 let boolean2: number = Number(false);

你可以使用三元运算符并手动选择所需的值,这些值可以超出10

let boolean3: number = true ? 1 : 0;
let boolean4: number = false ? 1 : 0;

您可以使用 + 符号来开始对数值的添加,这会自动将 boolean 值转换为数字:

  let boolean5: number = +true;
  let boolean6: number = +false;

数字也不能直接使用字符串。许多从 JavaScript 借用的技术可用。第一种是使用 Number,类似于 boolean 的情况,会将字符串解析为数字:

  let string1: number = Number("123.5");
  let string2: number = Number("-123.5");

第二种方法是使用 parseInt 函数。parse 函数有一个可选的第二个参数,允许指定基数。需要注意的是,这应该始终指定,以避免与八进制或十六进制发生错误:

  let string3: number = parseInt("123.5", 10);
  let string4: number = parseInt("-123.5", 10);

您可以使用 + 符号来向值添加,这会自动将字符串值转换为数字:

  let string5: number = +"123.5";
  let string6: number = +"-123.5";

如果字符串是用 数字分隔符 编写的,将字符串转换为数字可能会很棘手。数字分隔符允许通过在数字之间放置下划线以人类方式编写数字,从而提高可读性。例如,这里是九千一百万:

let numeric_separator: number = 9_000_100;

使用数字分隔符解析字符串会失败,但这也适用于使用 Number 方法以及 + 符号方法的情况。结果是矛盾的,可以是 NaN 或仅解析第一个下划线之前的值。在这种情况下,替换所有下划线并使用之前提到的一种技术将是解决方案。

数字可以用不同的基数表示。与 JavaScript 一样,TypeScript 使用 0x 字面量表示十六进制,0b 表示二进制,0o 表示八进制:

  let number1: number = 0x10;
  let number2: number = 0b10;
  let number3: number = 0o10;

字符串

TypeScript 对于字符串与 JavaScript 相同。您可以使用单引号、双引号或反引号定义字符串。单引号和双引号具有将引号之间的字符串分配给变量的相同功能:

  let string1: string = 's1';
  let string2: string = "s2";
  let string3: string = `s3`;

反引号,或称为反引号,有一个特殊的名称,字符串插值,它允许在字符串中注入代码。这是通过使用带美元符号和大括号的特殊语法来实现的:

let interpolation1: string = `This contains the variable s1: ${string1} as well as ${string2}`;
let interpolation2: string = `Can invoke variable function: ${string1.substr(0, 1)} as well as any code like this addition: ${1 + 1}`;

console.log(interpolation2);

最后一个例子产生以下输出:可以调用变量函数:s,以及任何类似这样的代码:2。

插值不仅限于注入其他值,还可以运行任何 TypeScript 代码。前面的例子在字符串中执行了加法。字符串插值的另一个特性是,您可以在不出现任何编译问题时添加换行符。使用单引号和双引号,字符串必须在同一行上,或者可以分成几个字符串,并用 + 符号连接:

  let multipleLine1: string = "Line1" +
      "Line2";

  let multipleLine2: string = `Line1
      Line2`;

布尔

boolean 类型只允许小写的 truefalse。不允许使用数字,也不允许值的不同的首字母大小写。可以通过将其与 1 进行比较来转换数值,即 10

  let bool1: boolean = true; // true
  let bool2: boolean = false; // false
  let bool3: boolean = 1 === 1; // true

还可以使用 JavaScript 的 Boolean 构造函数进行转换。TypeScript 不会移除解析过程中出现的怪癖,但返回构造函数的强类型 bool 值。以下是一些几乎对具有 false 值的字符串情况不起作用的例子:

let bool4: boolean = Boolean("true"); // true
let bool5: boolean = Boolean("TRUE"); // true
let bool6: boolean = Boolean("false"); // true
let bool7: boolean = Boolean("FALSE"); // true
let bool8: boolean = Boolean(NaN); // false

let bool9: boolean = new Boolean("true").valueOf(); // true
let bool10: boolean = new Boolean("false").valueOf(); // true

let bool11: boolean = "true" as any as boolean; // true
let bool12: boolean = "false" as any as boolean; // false

只有最后两行是 TypeScript 特有的解决方案,其中我们将字符串转换为类型,然后再转换回boolean。这也是除了直接比较字符串之外,唯一可行的解决方案之一,如下面的代码所示:

let bool13 = isTrue("true"); // true
let bool14 = isTrue("false"); // false

function isTrue(s: string): boolean {
    return s.toLocaleLowerCase() === "true";
}

最佳解决方案是避免进行任何类型的转换。在大多数情况下,转换会打开潜在意外错误的大门,即使在这个特定的例子中,转换的值是受控的,但在实际场景中可能并非如此。使用boolean构造函数很有吸引力,但必须谨慎使用,因为值false将导致boolean值为true。如果值不受控制且是字符串的一部分,最安全的方式是使用本书本节提供的isTrue函数进行比较。

空值

当主要值不可用时,可以将Null值分配给变量。如果没有strictNullChecks编译器选项,TypeScript 允许有nullundefined。作为一个最佳实践,始终设置严格的空值检查并手动指定哪些变量可以同时具有这两个值会更好。原因是你可以仔细构建每个变量和类型,围绕特定类型、nullundefined的灵活性进行适当的调整,而不留下一个敞开的大门。每次一个变量可以是null时,在使用对象属性之前都需要进行空值检查:

  let n1: string | null = Math.random() > 0.5 ? null : "test";
  // console.log(n1.substring(0, 1)); // Won't compile since can be null

  if (n1 !== null) {
      console.log(n1.substring(0, 1));
  }

在 TypeScript 中,应该限制null的使用,而倾向于使用undefined。原因将在未定义部分解释。

strictNullChecks被激活时,null值只能分配给允许null或任何类型的值。要有一个接受null的类型,必须使用联合类型:

let primitiveWithNull: number | null = null;

未定义

当主要值不可用时,undefined可以被分配给变量,类似于null

  let n2: string | undefined = Math.random() > 0.5 ? undefined : "test";
  // console.log(n2.substring(0, 1)); // Won't compile since can be null

  if (n2 !== null) {
      console.log(n2.substring(0, 1));
  }

然而,还有更多的情况。例如,当一个可选参数(我们将在本书后面讨论)在 TypeScript 中没有提供时,它会被自动设置为undefined。原因是当 JavaScript 中的属性不存在时,它是undefined,而不是null

function f1(optional?: string): void {
    if (optional === undefined) {
        // Optional parameter was not provided OR was set to undefined
    } else {
        // The optional parameter is for sure a string (not undefined)
    }
}

如前所述,如果你使用方括号通过字符串访问一个不存在的对象属性,返回的值也是undefined

  let obj = { test: 1 };
  console.log(obj["notInObject"]);

当类尚未从构造函数设置初始值时,undefined会被分配给类的字段变量。这只有在编译器选项strictPropertyInitialization设置为false时才会发生,这是一种不好的做法。为了避免因为初始化不足而导致字段未明确提及undefined而默认为undefined,编译器选项应始终设置为true

strictNullChecks 被激活时,未定义的值只能分配给允许 undefined 的类型、可选类型或 any 类型的值。要使一个类型接受 undefined,必须使用原始类型和 undefined 的联合。对于可选类型,函数可以在指定原始类型的冒号前使用问号:

let primitiveWithUndefined: number | undefined = undefined;

function functOptionalArg(primitiveOptional?: number): void {
    // ...
}

functOptionalArg();
functOptionalArg(undefined);
functOptionalArg(1);

undefined 也可以在类或接口中使用可选标记,如下所示:

interface InterfaceWithUndefined {
    m1?: number;
}

undefined 在联合类型中的类型一样,可选值可以通过与 undefined 进行比较来验证:

let i1: InterfaceWithUndefined = {};
let i2: InterfaceWithUndefined = { m1: undefined };

console.log(i1.m1 === undefined); // True
console.log(i2.m1 === undefined); // True

Symbol

Symbol 允许创建一个唯一的值。Symbol 与常量不同,因为具有相同值的两个常量是相等的,而具有相同值的两个符号则不是。常量变量像任何变量一样工作,通过比较值。与 Symbol 的比较则不同。每个 Symbol 都是唯一的,因此即使值相同,它们也不是相同的。让我们看看一些例子:

const s2 = Symbol("s");
const c1 = "s";
const c2 = "s";

if(isSymbolEqualS(s2)){
    console.log("Symbols are equal"); // Won’t print
}

if(c1 === c2){
    console.log("Constants are equal");
}

function isSymbolEqualS(p1:Symbol): boolean{
    return Symbol("s") === p1;
}

使用 Symbol 可以确保提供的值确实是期望的那个。它不能传递具有相同值的另一个常量,也不能传递具有相同值的字符串。必须使用完全相同的符号:

let s100 = Symbol("same");
let s101 = Symbol("same");

if (s100 === s101) {
 console.log("Same"); // Won't print
}

最后,Symbol 可以在定义对象字段时用作保险。有了这个符号,你可以确信每个字段只定义了一次。Symbol 本质上是不可变的:

const field1 = Symbol("field");
const obj = {
    [field1]: "field1 value"
};

console.log(obj[field1]); // Print "field1 value"

TypeScript 需要知道在 ES2015 中引入的 Symbol 功能。在使用 Symbol 关键字之前,tsconfig.json 必须将 lib 添加到数组中:

"lib": [
 "es2015",
 "es2015.symbol"
 ]

非原始类型

除了原始类型之外,还有更多更高级的变量类型。非原始类型组包括 voidstring literaltupleanyunknownnever;我们现在将讨论这些变量。

什么是 void

void 是一个特殊类型,主要用于返回没有值的函数。当明确返回 void 时,函数不能接受一个带有值的返回语句;因此,它充当了返回值的潜在错误的保护。void 函数仍然可以有空的返回,以便在到达闭合花括号之前离开函数。void 变量只能分配给 undefined

let a: void = undefined;
console.log(a);

这可能没有太大用处,但它解释了如果你向 void 函数返回一个没有值的函数会发生什么:

function returnNothing():void{
      return;
}
console.log(returnNothing()); // undefined

总是标记一个函数为void而不是使用隐式返回值是一个好习惯。函数的隐式返回类型是一个 void,因为函数允许返回任何东西。以下函数没有返回类型,最初什么也不返回。然而,在其生命周期中,函数发生了变化(你将在下面看到),现在返回三个不同的值,这些值与之前的值不同。隐式返回的值不再是 void。有一个显式返回类型定义了一个合同,并指示任何接触该函数的人预期的返回类型是什么,应该遵守。在这个示例代码中,函数返回一个布尔值、数字和字符串的联合类型:

function returnWithoutType(i: number) {
    if (i === 0) {
        return false;
    } else if (i < 0) {
        return -1;
    } else {
        return "positive";
    }
}

避免使用任何类型的理由

any是一个通配符类型,它不仅允许any类型,还可以随意更改类型。any有很多问题。第一个问题是很难跟踪变量的类型;我们又回到了 JavaScript 的编写方式:

let changeMe: any;
changeMe = 1;
changeMe = "string too";
changeMe = false;

应该避免使用any,因为它可以持有不符合预期的值,并且仍然可以编译,因为 TypeScript 不知道类型并且无法执行验证:

let anyDangerous: any = false; // still not a boolean, neither a string
console.log(changeMe.subString(0, 1)); // Compile, but crash at runtime

使用any的唯一原因是在两种情况下。第一种情况是你正在将代码从 JavaScript 迁移到 TypeScript。迁移代码可能需要很长时间,TypeScript 自然地以这种方式构建,你可以保持混合模式一段时间。这意味着你不仅可以降低编译器选项的严格性,还可以通过允许any来创建在类型上不完全详细的函数、变量和类型。

any可能被接受的第二种情况是你处于一个无法在某些高级场景中确定类型的情境,你必须继续前进。后者应该是一个路标,必须有一个后续行动,因为我们不希望让它成为一种习惯。

never类型的用法

never是一个不应该被设置的变量。一开始这可能听起来没有用,但在你想要确保没有任何东西落入特定的代码路径时,它可能是有用的。一个函数很少返回never,但这种情况是可能发生的。如果你有一个不允许你完成方法执行或返回任何变量的函数;因此,它永远不会完全返回。这可以通过异常来编码:

function returnNever(i: number): never {

  // Logic here

  if (i === 0) {
      throw Error("i is zero");
  } else {
      throw Error("i is not zero");
  }

  // Will never reach the end of the function

}

当你在编写代码时,你编写了一个不可能发生的条件,TypeScript 通过你的代码使用推断出类型。这可能发生在你有几个条件,其中一个包含另一个,使得某些变量落入never场景。如果所有变量值都被条件覆盖,并且有一个else语句(或带有switch case的默认值),也可能发生这种情况。值不能有任何其他值,只能是从未被分配的,因为所有值都被检查了。以下是一个说明这种可能性的示例:

type myUnion = "a" | "b";

function elseNever(value: myUnion) {
    if (value === "a") {
        value; // type is “a”
    } else if (value === "b") {
        value; // type is “b”
    } else {
        value; // type is never
    }
}

在实践中,never类型用于检查枚举或联合的所有值是否都处理了所有值。这允许在开发者在枚举或联合中添加值时忘记添加条件时创建验证。缺少条件会导致代码在详尽检查中失败。TypeScript 足够智能,可以验证所有情况,并理解代码可能会进入接受never参数的函数,这是不允许的,因为不能将任何内容赋值给never

type myUnion = "a" | "b";
let c: myUnion = Math.random() > 0.5 ? "a" : "b";

if (c == "a") {
    console.log("Union a");
} else {
    exhaustiveCheck(c); //”b” will fallthrough
}

function exhaustiveCheck(x: never): never {
    throw new Error("");
}

unknown类型与更严格的any类型结合

TypeScript 的unknown类型是新增的功能,旨在减少any的使用。当一个变量是unknown类型时,可以将任何内容赋值给该变量。然而,unknown值只能赋值给另一个unknown类型或any类型。以下是关于unknown类型的几个规则:

  • unknown类型只能与等号运算符一起使用;其他运算符将无法编译。

  • 返回unknown类型的函数不需要返回任何内容。

  • unknown类型的交集是没有用的,因为与unknown类型相交的类型将占主导地位。然而,当在联合中使用时,它将始终具有优先级并覆盖联合中的任何其他类型。

  • unknown类型的键总是never

unknown类型的一个用例是,当不知道类型时可以使用它。而不是依赖于可以接受任何内容并通过任何代码传递的any,使用unknown限制了变量的流动。由于未知变量不能被设置到另一个变量中,这迫使开发者正确地将值缩小到其类型,并继续进一步。如果没有unknown类型,any将是唯一的选择。它打开了广泛接受任何内容的大门,并:

function f1(x: any): string {
 return x;
}

function f2(x: unknown): string {
 return x; // Does not compile
}

在这个例子中,第一个函数可以接受任何类型并期望返回一个字符串。然而,不需要进行类型转换或任何操作,因为any类型的变量可以返回一个字符串。但是,对于unknown类型,必须进行处理。正如之前提到的,原因是unknown类型不能被赋值给除了unknownany之外的任何类型。

在列表中强制类型

数组可以通过两种不同的方式创建。第一种是使用方括号和Array泛型对象。它们可以互换使用,并且都可以进行类型注解。

方括号格式更紧凑,允许在方括号之前指定类型。Array泛型对象在较小/较大的符号之间指定类型:

let arrayWithSquareBrackets: number[] = [1, 2, 3];
let arrayWithObject: Array<number> = [1, 2, 3];
let arrayWithObjectNew: Array<number> = new Array<number>(1, 2 ,3);

可以通过将数组类型与联合结合来创建包含多种类型的数组:

let arrayWithSquareBrackets2: (number | string)[] = [1, 2, "one", "two"];
let arrayWithObject2: Array<number | string> = [1, 2, "one", "two"];

TypeScript 的行为与 JavaScript 相同,除了指定数组的类型。您可以通过使用索引位置和所有自动使用指定数组类型的方法来访问内容:

const position1 = arrayWithObject2[0]; // 1
const unexisting = arrayWithObject2[100]; // undefined

如果一个位置不包含值,返回的类型是undefined

TypeScript 允许循环数组并检索每个位置的强类型元素。类型是可选的,因为 TypeScript 可以推断类型。这意味着以下代码可以带有或不带有number类型:

arrayWithSquareBrackets.forEach(function (element: number){
  console.log(element);
});

使用enum定义一组常量

TypeScript 有一个关键字enum,允许你指定一组可能的值,其中只能选择一个项。定义一个enum可以通过提供潜在的键来完成,这些键将自动从0开始分配给enum的第一个潜在选择,依此类推:

  enum Weather {
       Sunny,
       Cloudy,
       Rainy,
       Snowy
   }

可以指定一个值给键以获得更精细的控制。任何缺失的值将是下一个序列值。在下面的代码示例中,Sunny被设置为100,而Cloudy自动为101Rainy102,依此类推:

  enum Weather {
    Sunny = 100,
    Cloudy,
    Rainy,
    Snowy
}

在这种情况下,可以跳过,你只能提供一个更大的值,分配的值是顺序的。在下面的代码示例中,值是100101200201

  enum Weather {
    Sunny = 100,
    Cloudy,
    Rainy = 200,
    Snowy
}

enum也可以支持字符串或字符串和数字的混合:

enum Weather {
    Sunny = "Sun",
    Cloudy = "Cloud",
    Rainy = 200,
    Snowy
}

enum可以通过enum或通过值来访问。通过enum访问需要直接从enum使用点符号。返回的值是enum。这是在 TypeScript 中分配enum的常见方式。也可以分配值。当数据来自 JSON 时,值分配很有用。例如,值是从 Ajax 响应中返回的。它将非 TypeScript 与 TypeScript 连接起来:

let today: Weather = Weather.Cloudy;
let tomorrow: Weather = 200;

console.log("Today value", today); // Today value Cloud
console.log("Today key", Weather[today]); // Today key undefined
console.log("Tommorow value", tomorrow); // Tommorow value 200
console.log("Tommorow key", Weather[tomorrow]); // Tommorow key Rainy

在之前的代码中,只有当方括号中的值是类型而不是值时,使用方括号访问值才有效。

除了numberstring之外,enum在位运算符的帮助下还支持位值。它允许使用和符号(&)检查值是否包含单个值或值的聚合。原因是,使用管道|可以创建包含多个值的变量。堆叠的值也可以位于enum中,用于重用目的,但不是必需的:

  enum Weather {
    Sunny = 0,
    Cloudy = 1 << 0,
    Rainy = 1 << 1,
    Snowy = 1 << 2,
    Stormy = Cloudy | Rainy // Can reside inside
}

let today: Weather= Weather.Snowy | Weather.Cloudy; // Can be outside as well

if (today & Weather.Rainy) { // Check
    console.log("Bring an umbrella");
}

一个值可以保存多个值。如果我们想完整地保留现有值,并且需要使用符号|=,这很有用。要删除特定的状态,你需要使用&= ~。使用这些运算符将在其二进制格式中交换右位置的值,而不会影响数字的其余部分:

today |= Weather.Rainy;
today &= ~Weather.Snowy;
console.log(today); // 3 -> 011 = Cloudy and Rainy

最后,为了检查变量是否具有特定的状态,你必须使用三个等号与你想检查的值旁边的和符号。使用单个和符号进行比较是一个错误。和符号返回一个数字,而不是boolean。比较需要与我们要检查的值进行比较。通过创建一个组合值在比较中,可以检查多个值:

if (Weather.Rainy === (today & Weather.Rainy)) { // Check
  console.log("Rainy");
}

if (Weather.Cloudy === (today & Weather.Cloudy)) { // Check
  console.log("Cloudy");
}

if ((Weather.Cloudy & Weather.Rainy) === (today & Weather.Cloudy & Weather.Rainy)) { // Check
  console.log("Cloudy and Rainy");
}

enum 是定义变量可能值的特定域集合的绝佳方式。它具有通过命名选择来清晰表达的优势,并在需要时允许你决定每个条目的值。

字符串字面量及其与字符串的区别

string 是一种允许任何类型字符的类型。字符串字面量 是将特定的 string 与类型关联。当 string 被设置为类型时,可以分配一个值并在以后更改它。唯一可以设置到 字符串字面量 的值是在声明时打印的精确字符串:

let x: string = "Value1";
x = "Value2";

let y: "Literal";
y = "Literal";
y = "sdasd"; // Won't compile

TypeScript 将代码编译成纯 JavaScript,不包含类型。一个 字符串字面量 确保在 TypeScript 中编写代码时,只能将单个字符串值与变量关联,并且这个值会被编译成具有此强制值的 JavaScript 对象。这种特性的本质在于,我们在两种语言中都有确保值是唯一的保证。这在需要条件化一个在编译后不会存在的类型的情况下非常有用。例如,接口和一段必须根据接口不同而采取不同行动的代码的情况。在接口之间共享一个具有唯一字符串字面量的字段,可以在设计时和运行时进行比较。在设计时,TypeScript 将能够缩小类型范围,从而为指定的类型提供更好的支持,在运行时能够在正确的位置执行流程:

interface Book {
    type: "book";
    isbn: string;
    page: number;
}

interface Movie {
    type: "movie";
    lengthMinutes: number;
}

let hobby: Movie = { type: "movie", lengthMinutes: 120 };

function showHobby(hobby: Book | Movie): void {
    if (hobby.type === "movie") {
        console.log("Long movie of " + hobby.lengthMinutes);
    } else {
        console.log("A book of few pages: " + hobby.page);
    }
}

代码示例显示,两个接口共享一个 字符串字面量 类型的类型。为了能够在函数中访问一个或另一个 interface 的唯一属性,需要进行一个判别器的比较。如果没有比较,接受联合作为参数的函数不知道传递的是两种类型中的哪一种。然而,TypeScript 分析这两个接口,并识别出一个共同字段,允许你在缩小类型之前使用这个字段。一旦 TypeScript 能够找到处理哪种类型,它就允许使用该类型的特定字段。在示例中,在条件内部,所有电影的 interface 字段都是可用的。另一方面,else 允许仅使用书籍的 interface 字段。

字面量字符串是 TypeScript 支持的三种可能的字面量中的一种。TypeScript 在字符串之上还支持 numberboolean。最后,当使用 字符串字面量 时,始终使用冒号提供类型:

let myLiteral: "onlyAcceptedValue" = "onlyAcceptedValue";

与依赖于 let(它打开了多种赋值的大门)不同,使用 const 可以确保单个赋值;因此,它将自动为这三种类型推断出 字面量 类型:

const myLiteral = "onlyAcceptedValue"; // Not a string

有可能通过省略类型来创建一个字面量,但前提是必须使用const声明,因为值不能改变;因此,TypeScript 将范围缩小到其最窄的表达式。然而,从constlet的未来变化会将类型恢复为string。我建议尽可能明确,以避免不希望的类型变化。

构建一个类型化函数

函数在 JavaScript 中是一等公民。自从 ECMAScript 的早期版本以来,函数一直是执行代码和创建作用域的主要概念。TypeScript 以相同的方式使用函数,但提供了额外的类型化功能。

一个函数有一个主要签名,包含该函数的名称、参数列表和返回类型。参数在括号中定义,就像 JavaScript 一样,但每个参数后面都会使用冒号语法跟随其类型:

function funct1(param1: number): string { return ""; }

一个函数可以拥有多个不同类型的参数:

function funct2(param1: boolean, param2: string): void { }

它还可以有一个具有多个类型的参数,使用联合:

function funct3(param1: boolean | string): void { }

一个函数只有一个返回声明,但该类型可以使用联合来允许类型:

function funct4(): string | number | boolean { return ""; }

一个函数可以有一个互补的签名来指示消费者哪些参数匹配在一起以及与返回类型。为同一主体提供多个函数签名是重载函数的概念。当使用重载函数时,所有签名必须从上到下从最具体到最一般地编写。所有定义都需要以分号结束,除了最后一个。最后一个签名总是有一个联合,覆盖了每个位置的所有可能类型。在以下代码示例中,我们指定如果参数是boolean,则函数返回一个字符串。如果参数是Date,则返回类型是number。最后一个签名包含一个第一个参数,即两个可能值(boolean和日期)的联合,以及返回类型在stringnumber之间的联合):

function funct5(param1: boolean): string;
function funct5(param1: Date): number;
function funct5(param1: boolean | Date): string | number {
    if (typeof param1 === "boolean") {
        return param1 ? "Yes" : "No";
    } else {
        return 0;
    }
}

const expectedString: string = funct5(true); // Yes
const expectedNumber: number = funct5(new Date()); // 0

一个函数可以是匿名的。以下是一个使用胖箭头格式和一个返回Function构造函数的示例:

function returnAnAnonymousFunction(): () => number {
    return () => 1;
}

function returnAnAnonymousFunction2(): Function {
    return function () { return 1 };
}

一个函数可以是变量函数或典型函数。以下是在变量中设置的三个函数。变量可以通过使用括号和所需的参数来调用。代码示例还展示了两种使用胖箭头格式返回数据的方式。如果代码直接返回而不执行任何多个语句,则不需要大括号和返回语句:

const variable = (message: string) => message + " world";
const variable2 = (message: string) => { return message + " world" };
const variable3 = function (message: string) { return message + " world" };

variable("Hello");

一个函数可以有一个可选参数和一个默认值参数。可选参数通过在参数名称后使用问号表示。可选参数允许避免传递值。TypeScript自动将参数设置为undefined

function functWithOptional(param1?: boolean): void { }
functWithOptional();
functWithOptional(undefined);
functWithOptional(true);

Optional与将变量与undefined的联合不同,因为联合需要传递值或undefined,而可选允许传递值、undefined或无:

function functWithUndefined(param1: boolean | undefined): void { }
functWithUndefined(true);
functWithUndefined(undefined);

Optional 只能在非可选参数之后设置。原因是其他参数是必需的,但如果在 Optional 之前或中间,就会很难映射 哪个 参数是 哪个。以下代码示例展示了由于该规则而导致函数无法编译的情况。然而,可以有多个可选参数:

function functWithOptional2(param1?: boolean, param2: string): void { } // Doesn't compile
function functWithOptional3(param1?: boolean, param2?: string): void { }

函数可以位于类中(面向对象的内容将在下一章中介绍)。当这种情况发生时,语法会有所不同。它不使用 function 关键字。相反,提供了可见性,即 publicprivateprotectedTypeScript 允许避免使用访问修饰符,这将导致函数为 public。至于类变量,省略可见性默认使用 public

  class ClassFullOfFunctions {
      public f1() { }
      private f2(p1: number): string { return ""; }
      protected f3(): void { }
      f4(): boolean { return true; }
      f5(): void { } // Public
}

参数和强类型返回类型的基仍然相同。也可以创建一个变量,它包含类中的函数,如本章所示。以下是三个将 private 函数定义为 variable 的示例。第一个示例很长且非常明确。第二个示例在函数级别没有定义类型,因为它已经在声明中定义了。最后一个示例没有定义变量的类型,但由于它从初始化中推断其签名,变量仍然是强类型的:

private long: (p1: number) => string = (p1: number) => { return ""; }
private short: (p1: number) => string = (p1) => "";
private tiny = (p1: number) => "";

变量中的 function 技术上称为 函数表达式,而更传统的 function 语法称为 函数声明。在 TypeScript 中,使用一个或另一个的用法与 JavaScript 相同。因为它遵循 JavaScript 的规则,这意味着表达式函数不会被提升。

如何在不指定类型的情况下进行强类型化

TypeScript 可以显式指定类型,或者你可以让 TypeScript 确定类型。后者称为 隐式类型 或由推断定义的类型。推断的动作由 TypeScript 根据变量在声明时的初始化或函数返回类型返回的内容来执行。

变量的推断仅在声明时赋值的情况下才可能。这意味着在使用 var/let/const 时,你必须设置一个值:

const x = 1;
let y = 1;
let z;
// ...
z = 1;

在前面的代码示例中,值 1 在初始化时被分配给变量 xy。即使没有使用冒号,这也是有效的。TypeScript 将为这两个变量推断类型。在未指定值的情况下,只有 varlet 可以编译,因为它允许在未来某个时刻进行赋值。值未指定,这意味着类型回退到 any。即使在变量的作用域内设置了值,这也是正确的。

在之前的代码示例中,值1被分配给了一个常量和变量。这两个声明符的类型不同。常量类型不是一个数字,它是一个1的数字字面量。这意味着类型是1,仅1,而不是任何其他数字。然而,使用let声明的变量的类型是number。原因是,对于常量,TypeScript 知道它只能初始化一次,并且其值不能改变。它会缩小到它能够找到的最简单类型,即原始值的类型。相反,使用let声明的变量可以在变量的生命周期内改变其值。TypeScript 会为每个数字类型进行类型缩小。这对于numberstringboolean都是成立的:

const d1 = new Date();
let d2 = new Date();

const b1 = true;
let b2 = false;

const c1 = {
 m1: 1
};

let c2 = {
 m1: 1
};

然而,日期将保持为日期类型,无论声明符如何,任何类或接口也是如此,因为它是较小的分母。在之前的代码示例中,c1c2都是必须具有名为m1的数字类型成员的对象类型。这个例子说明了 TypeScript 也可以在类型内部推断类型。m1是通过推断得到的数字类型。

推断也适用于函数。然而,它有一些限制。第一个限制是参数必须是显式的。原因是,没有潜在的错误空间,你不能通过使用来推断。在下面的代码中,参数a是隐式的any

function f1(a) {
   return a;
}

然而,返回可以是隐式的。通过返回一个已知类型,可以定义返回类型:

function f2(a: number) {
   return a;
}

在返回多个值的情况下,TypeScript 会创建所有潜在类型的联合。在下面的代码示例中,有两个返回语句。TypeScript 会查找每个返回的值,并得出结论,返回了两个不同的值。生成的返回类型是number | string

function f3() {
   if (true) {
       return 1;
   } else {
       return "1";
   }
}

摘要

在本章中,我们看到了 TypeScript 如何声明变量,以及根据情况选择哪种声明符是最好的。我们看到了 TypeScript 通过在变量的生命周期内强制类型来改进 JavaScript 的原始类型。我们解释了如何通过联合的概念将变量转换为一个多类型容器。TypeScript 将类型引入到函数中,我们看到了如何通过重载函数来提高接受许多参数组合和返回类型的函数的可读性。TypeScript 通过从像 Java 和 C#这样的流行语言中借用的流行enum引入了一种新的变量类型。最后,我们简要地了解了 TypeScript 在不同情况下如何智能地推断类型,这可以有利于减少冗长的定义。

在下一章中,我们将详细探讨许多不同对象类型之间的差异,并查看我们如何通过操作类型来拥有足够灵活的强类型代码,以满足我们使用 TypeScript 定义业务模型的需求。

第三章:利用对象释放类型的力量

TypeScript 中有众多不同的对象,可能会让人感到不知所措。在本章中,我们将阐述 objectObjectobject literal 以及通过构造函数构建的对象之间的区别。本章还讨论了类型联合的概念,这将允许单个值有无限种类型的组合。此外,交集的概念也浮现出来,它为不同地操作类型提供了可能性。在本章结束时,读者将能够创建包含复杂结构的对象组合。我们将剖析如何创建具有强类型索引签名的字典,理解类型如何通过映射带来益处,以及如何使用正确的对象在定义具有广泛适用性的对象时尽可能准确。

本章涵盖了以下主题:

  • 如何使用索引签名将集合/字典强类型化

  • TypeScript 与映射

  • 索引签名与映射的区别

  • objectObject 的区别

  • 何时使用 objectObject 或任何

  • 什么是 object literal

  • 如何创建一个构造对象

  • 显式类型或转换的区别

  • 多类型变量

  • 将类型与交集结合

  • 与非类型相交

  • 与可选类型相交

  • 将类型与继承合并

  • 类型与接口的区别

  • 解构类型和数组

  • 元组

  • declarelet/const/var 的区别

如何使用索引签名将集合/字典强类型化

除了数组之外,集合字典 是一种常见的结构,用于存储无结构数据以便快速访问。在 JavaScript 中,使用能够将成员分配给对象的动态概念创建字典。每个对象的属性成为字典的键。TypeScript 在此模式之上引入了 索引签名。它允许你指定键的类型(在数字和字符串之间)以及任何类型的值:

interface Person {
 [id: string]: string;
}

向字典写入就像使用方括号并分配必须遵守定义右侧的值一样简单。在下面的代码示例中,键和值都是字符串:

const p: Person = {};

p["id-1"] = "Name1";
p["string-2"] = "Name12";

console.log(p["string-2"]); // Name12 

索引签名可能因为历史原因而变得复杂。例如,如果索引被定义为接受字符串作为键,你将能够传递字符串和数字。反之则不成立:数字类型的键不接受字符串:

const c: Person = {
  "test": "compile", 
  123: "compile too" // Key is number even if Person requires string: it compiles
};

interface NotAPerson {
  [id: number]: string;
}

// DOES NOT COMPILE:
const c2: NotAPerson = {
  "test": "compile", // THIS LINE DOES NOT COMPILE
  123: "compile too"
};

最后一个示例展示了关于键类型的多个问题。代码使用一种语法,通过使用所有成员都是键且其值是索引签名值的 object literal 直接定义索引签名的值。这是初始化默认值的语法,而另一种方式,即使用方括号,是动态添加和快速访问值的方式。

此外,TypeScript 允许你通过提供成员名称作为字符串来使用方括号访问对象的成员。与索引签名的区别在于,TypeScript 不允许你在定义中没有提供索引签名的情况下读取或添加成员:

interface NotIndexSignatureObject {
    name: string;
    count: number;
}

const obj: NotIndexSignatureObject = {
    name: "My Name",
    count: 123
};

console.log(obj["doesNotExist"]); // Does not compile
console.log(obj["name"]); // Compile

索引签名的一个奇怪之处在于,当它与具有其他成员的对象结合时。索引签名的键只能是一个字符串,其成员返回一个字符串。这意味着在大多数情况下,你将不得不退回到使用数字键。以下代码无法编译:

 interface ObjWithMembersAndIndexSignature {
    name: string;
    count: number;
    when: Date;
    [id: string]: string; // DOES NOT COMPILE
}

相比之下,以下代码可以编译,但很脆弱。它之所以可以编译,是因为在某些非常罕见的情况下,TypeScript 会根据其用法自动将类型转换回字符串。在这种情况下,成员 countwhennumberDate 被接受为字符串。然而,添加一个具有对象的成员的微小变化将破坏这一规则。以下两个代码块说明了这种变化。这个后续块包含了一个原始值:

interface ObjWithMembersAndIndexSignature {
 name: string;
 count: number;
 when: Date;
 [id: number]: string; // COMPILE
}

这个后续块包含了一个在定义索引签名时不允许的额外对象:

interface ObjWithMembersAndIndexSignature2 {
 name: string;
 count: number;
 when: Date;
 obj: { s: string }; // DOES NOT COMPILE
 [id: number]: string | number | Date;
}

你可能遇到的另一个编译问题是向具有数字键的索引签名的对象添加一个字符串键:

const obj2: ObjWithMembersAndIndexSignature = {
    name: "My Name",
    count: 123,
    when: new Date(),
    "more": "nooo" // DOES NOT COMPILE
};

你可以通过提供一个具有字符串值的数字类型的成员来转换对象定义:

  const obj3: ObjWithMembersAndIndexSignature = {
    name: "My Name",
    count: 123,
    when: new Date(),
    12: "string only" // Good if number->string
};

然而,如果你想有一个字符串作为键,你将需要将你的索引签名中允许的值类型更改为每个成员的联合:

  interface ObjWithMembersAndIndexSignature2 {
    name: string;
    count: number;
    when: Date;
    [id: string]: string | number | Date;
}

要总结索引签名,明智的做法是使你的映射对象小且成员不多,以便能够访问一个无需缩小类型的索引签名。例如,最后一个代码示例返回一个字符串、一个数字或一个日期。这意味着每次访问对象时,都需要在消费其属性之前检查其类型。然而,具有仅索引签名的接口可以用作对象的属性,并具有所有快速访问,而无需缩小。以下代码演示了这种模式:

interface MyMap<T> {
    [index: string]: T;
}

interface YourBusinessLogicObject {
    oneProps: string;
    secondProps: number;
    thirdProps: Date;
    yourDictionary: MyMap<string>;
}

TypeScript 和映射

我们讨论了使用索引签名创建字典/集合,该索引签名利用了对象的灵活性。另一种选择是使用 map 类。map 是一个可以带或不带值实例化的类,它是一种在 TypeScript 中不独特的对象类型。ECMAScript 定义了映射的结构和行为;TypeScript 在类之上提供了类型。

一个映射具有与索引签名相同的快速访问能力。以下代码使用两个键值条目实例化了 Map。键和值可以是任何东西。在构造函数中重要的是,当提供值时,这个值必须是可迭代的:

let map = new Map([["key1", "value1"], ["key2", "value2"]]);

let value1: string | undefined = map.get("key1");

之前的代码不仅创建了一个映射,还通过使用与构造函数中定义的相同类型的键来访问值。如果映射中不存在该键,则返回一个未定义的值,类似于索引签名。接下来的代码示例创建了两个映射,没有提供初始值。第一个没有使用泛型定义;因此,回退到键和值的类型为any。然而,第二行显示了一个初始化,指定泛型类型具有字符串键和数值值。即使映射在初始化时没有指定值,后者仍然为未来由函数set设置的值提供了强类型约束:

let map2 = new Map(); // Key any, value any
let map3 = new Map<string, number>(); // Key string, value number

以下代码无法编译,因为键类型必须相同。在代码示例中,它有一个数字和一个字符串:

let map4 = new Map([[1, "value1"], ["key2", "value2"]]); // Doesn't compile

映射除了get函数之外还有许多其他功能。它可以设置值,这在没有所有映射创建的值时非常有用。映射还可以通过返回truefalse来查找映射中是否存在键。最后,可以通过函数而不是依赖于索引签名的delete关键字来删除条目:

map.set("key3", "value3");
map.has("key1");
map.delete("key1"); // Similar to delete obj.key1 (index signature)

索引签名和映射之间的区别

使用映射或对象索引签名模式与对象之间的区别很小。以下是每个结构的优点列表:

对象

  • 可以有不仅仅是键值对。它还可以有函数和其他成员。

  • 来自 JSON 的对象自动与索引签名兼容,而映射则需要手动映射。

  • 对象模式在访问数据方面比映射更快,对于小数据集和中等数据集,它使用的内存更少。这在 Chrome 中是正确的,但不同浏览器之间的基准测试并不一致,以及映射/对象的整体大小。

映射

  • 当进行大量添加和删除操作时,映射表现更好。它使用底层的哈希函数。

  • 当添加元素时,它保留了顺序。这可能是一个优势,因为映射自然可迭代。

  • 映射在大数据集上表现更好。

  • 映射的键不仅限于数字或字符串键。

对象和 Object 之间的区别

TypeScript 中有许多对象类型。有Objectobjectclass objectobject literal。在本节中,我们将介绍大写Object(大写字母)和小写object(小写字母)之间的区别。

以大写字母开头的Object类型,或大写字母,或大写字母O代表一种普遍存在的东西,是一个与每个类型和对象都可用交叉类型。大写字母Object携带一组常用函数。以下是其可用函数列表:

toString(): string;
toLocaleString(): string;
valueOf(): Object;
hasOwnProperty(v: string): boolean;
isPrototypeOf(v: Object): boolean;
propertyIsEnumerable(v: string): boolean;

许多类型都包含在Object类型的范畴之下。将几个不同的值分配给Object类型对象显示了类型的灵活性以及潜在类型范围的广泛性:

let bigObject: Object;
bigObject = 1;
bigObject = "1";
bigObject = true;
bigObject = Symbol("123");
bigObject = { a: "test" };
bigObject = () => { };
bigObject = [1, 2, 3];
bigObject = new Date();
bigObject = new MyClass();
bigObject = Object.create({});

小写的object将所有不是数字、字符串、booleannullundefinedSymbol的值转换为对象。小写的object是大写Object的子集。它包含object literals、日期、函数、数组和用newcreate创建的对象实例:

let littleObject: object;

littleObject = { a: "test" };
littleObject = new Date();
littleObject = () => { };
littleObject = [1, 2, 3];
littleObject = new MyClass();
littleObject = Object.create({});

nullundefined的情况下,它们既不是object也不是Object。它们属于一个特殊类别,并且是所有其他类型的子类型。TypeScript 的编译器必须配置为具有严格的选项"strictNullCheck",这是默认配置值,这意味着即使nullundefined是所有类型的子集,只有主类型和nullundefined的联合才能允许将赋值给这两个特殊值之一:

let acceptNull: number | null = null;
acceptNull = 1;

let acceptUndefined: number | undefined = 1;
acceptUndefined = null;

何时使用objectObjectany

使用哪种类型的对象是之前讨论objectObjectany之间差异的问题的一个子问题。一般规则是始终使用更约束的类型。这意味着尽可能避免使用objectObject。然而,在需要覆盖更广泛的类型且无法使用联合定义的情况下,如果你不需要原始类型,使用object更好,因为它具有更少的潜在值。

objectObject都比any好,因为any允许访问任何类型的任何成员,而object将限制你只能访问以下内容:

let obj1: Object = {};
let obj2: object = {};
let obj3: {} = {};
let obj4: any = {};

obj1.test = "2"; // Does not compile
obj2.test = "2"; // Does not compile
obj3.test = "2"; // Does not compile
obj4.test = "2";

obj1.toString();
obj2.toString();
obj3.toString();
obj4.toString();

如果你不知道类型并且需要取一个对象,如果你不允许原始类型,你应该使用小写的object。如果你支持原始类型,你应该回退到大写的Object,最后不得已使用any。然而,一个更好的潜在方法是,如果可能的话,使用一个泛型类型,这样可以避免进行类型检查和转换,这通常是使用诸如objectObject之类的类型时的一个陷阱。

什么是object literal

一个object literal是用花括号创建的对象。一个object literal既是object也是Object。你可以使用typeinterfaceobject literal定义类型。这是一种快速地将数据放入具有类型结构的结构中的方法。object literal继承自大写的Object

type ObjLiteralType = { x: number, y: number };
interface ObjLiteralType2 {
  x: number;
  y: number;
}

我们可以用四个函数进行快速测试,看看object literal是否被所有函数接受,即使参数的类型在所有函数的签名中不同:

function uppercaseObject(obj: Object): void { }
function lowercaseObject(obj: object): void { }
function anyObject(obj: any): void { }
function objectLiteral(obj: {}): void { }

uppercaseObject({ x: 1 });
lowercaseObject({ x: 1 });
anyObject({ x: 1 });
objectLiteral({ x: 1 });

两个(或更多)具有相同结构的对象可以互换。你可以定义一个object literal并将其设置在变量中,该变量定义了接口中相同的结构。如果你匿名或推断类型,也可以这样做。这里有四种创建类型化的object literal的方法。它们都可以相互赋值,因为它们具有相同的结构。这是 TypeScript 的一个优点,因为它是一种结构化语言,而不是名义语言:

let literalObject: ObjLiteralType = { x: 1, y: 2 };
let literalObject2: ObjLiteralType2 = { x: 1, y: 2 };
let literalObject3: { x: number, y: number } = { x: 1, y: 2 };
let literalObject4 = { x: 1, y: 2 };

literalObject = literalObject2 = literalObject3 = literalObject4;

如何创建构造对象

最后,类对象是通过关键字class定义并通过关键字new实例化的对象。每个类都可以被实例化多次。每次实例化都始于new的使用,并且对象中设置的每个值都保留在该对象中,除了静态字段,这些字段在相同类的每个实例之间共享。我们将在后面的章节中看到面向对象和对象的一些特性:

class ClassWithConstructor {
    constructor(){
        console.log("Object instantiated");
    }
}
const cwc = new ClassWithConstructor();

创建类的调用会调用构造函数。在之前的代码示例中,console.log将在类实例化为对象时被调用。

显式类型和类型转换之间的区别

如果你想要构建一个接口,你可以使用冒号设置变量类型并指定字段。如果一个字段缺失,TypeScript 将不会编译;如果定义的内容过多,TypeScript 也不会编译:

interface MyObject {
    a: number;
    b: string;
}
const newObject1: MyObject = { a: 1, b: "2" };

另一种方式是不在冒号后指定类型,而是使用as进行类型转换:

  const newObject2 = { a: 1, b: "2" } as MyObject;

问题在于类型转换会强制 TypeScript 相信对象是指定的类型,即使它不遵守契约。类型转换永远不应该用来定义变量。原因是即使最初遵守契约,如果对象发生变化,转换仍然会强制类型分配,但对象的结构不会正确。以下两行代码可以编译,但在逻辑上是不合法的。第一行有一个不在接口中的额外成员,而第二行缺失了一个字段:

const newObject3 = { a: 1, b: "2", c: "OH NO" } as MyObject;
const newObject4 = { a: 1 } as MyObject;

多类型变量

在许多情况下,一个变量需要拥有多个字段。例如,你可以有一个只接受几个字符串值的字段:

type MyType = "a" | "b" | "c";
const m1: MyType = "a";
const m2: MyType = "no"; // Doesn’t compile

使用关键字type创建类型不是必需的,但允许类型的重用。第一个例子是创建一个允许多个字符串的类型。使用新类型声明的变量将只接受列表中的字符串。然而,大多数情况下,联合使用更复杂类型,例如接口、类型和原始类型之间的联合:

type AcceptedOption = number | string | { option1: string, option2: number };
const myOption1: AcceptedOption = "run";
const myOption2: AcceptedOption = 123;
const myOption3: AcceptedOption = { option1: "run", option2: 123 };

函数可以接受联合类型作为参数,也可以返回返回类型。联合通常用于接受类型以及undefined

function functWithUnion(p: boolean | undefined): undefined | null{
      return undefined;
}

当使用联合时,只有共同的字段是可访问的。在以下代码中,TypeA有两个字段,ab,而TypeBbc。唯一的共同字段是b,这意味着它是函数中唯一可用和可访问的字段。这直到我们将类型缩小到联合中的一个类型才会成立。我们将在后面看到类型缩小是如何工作的:

interface TypeA {
    a: string;
    b: string;
}

interface TypeB {
    b: string;
    c: string;
}

function functionWithUnion(param1: TypeA | TypeB): void {
    console.log(param1.b);
}

将类型与交集结合

你构建的类型越多,你就越需要一个功能来处理它们。交集功能是 TypeScript 工具箱中的一个工具,它允许你合并类型。交集符号是&。以下代码示例显示我们正在通过两个接口的组合创建第三个类型。共同的字段合并为一个,差异累加:

interface TypeA {
    a: string;
    b: string;
}
interface TypeB {
    b: string;
    c: string;
}

type TypeC = TypeA & TypeB;
const m: TypeC = {
    a: "A",
    b: "B",
    c: "C",
};

也可以相交泛型和原始类型。后者较少使用,因为它几乎不实用。然而,前者(泛型)有助于将自定义类型合并到定义的契约中:

interface TypeA {
    a: string;
    b: string;
}
function intersectGeneric<TT1>(t1: TT1): TT1 & TypeA {
    const x: TypeA = { a: "a", b: "b" };
    return (<any>Object).assign({}, x, t1);
}

const result = intersectGeneric({ c: "c" });
console.log(result); // { a: 'a', b: 'b', c: 'c' }

相交中类型的顺序并不重要。这里创建的两个类型完全相同:

type TypeD1 = TypeA & TypeB;
type TypeD2 = TypeB & TypeA;

然而,即使它们相同,具有相同的值,每次初始化都会创建一个独特的对象,这意味着比较它们将会是错误的。关于比较具有不同名称的相同类型,它们都是Object类型,原因是typeOf是 JavaScript 运算符,类型在运行时被移除;因此,它在设计时表现相同。要比较类型,我们需要一个将在后面讨论的判别器:

 let d1: TypeD1 = { a: "a", b: "b", c: "c" };
 let d2: TypeD2 = { a: "a", b: "b", c: "c" };

 console.log(typeof d1); // Object
 console.log(typeof d2); // Object
 console.log(d1 === d2); // False

 d2 = d1;
 console.log(d1 === d2); // True

括号的使用不会影响类型的声明。以下代码是多余的,因为联合是无用的。这里有四种不同的类型,它们具有相同的值:

type TypeD3 = (TypeA & TypeB) | (TypeB & TypeA);
type TypeD4 = TypeA & TypeB | TypeB & TypeA;
type TypeD5 = (TypeA & TypeB);

type TypeD6 = TypeA & TypeB;

let d3: TypeD3 = { a: "a", b: "b", c: "c" };
let d4: TypeD4 = { a: "a", b: "b", c: "c" };
let d5: TypeD5 = { a: "a", b: "b", c: "c" };
let d6: TypeD6 = { a: "a", b: "b", c: "c" };

与非类型相交

可以与类型、原始类型和接口相交。与原始类型相交是没有用的,因为一个数字不能同时是布尔值。然而,与接口相交与与类型相交一样有效。对于类型或接口,关于相交的规则是相同的:

interface InterfaceA {
 m1: string;
}

interface InterfaceB {
 m2: string;
}

type TypeAB = InterfaceA & InterfaceB;

虽然相交最常见的案例是类型和接口,但也可以相交类。与类的相交很少见,它创建了一个不能实例化的类型。只从每个类中提取公共字段来创建字段:

type ClassAb = ClassA & ClassB;
const classAb: ClassAb = { m1: "test", m2: "test2" };

与可选类型相交

可以相交两个具有不同规则的交叉属性的类型。一个类型中的一个字段提到了可选成员,可以与一个非可选的类型合并。结果是该字段变为非可选:

 interface InterfaceSameField1 {
    m1: string;
 }

 interface InterfaceSameField2 {
    m1?: string;
 }

 type Same = InterfaceSameField1 & InterfaceSameField2;
 let same: Same = { m1: "This is required" };

之前的代码示例显示了相交和字段m1是必需的。如果省略或设置为undefined,则代码无法编译。

使用继承合并类型

如果这些类型是接口或类,则可以使用extends合并两个类型。使用另一个接口扩展一个接口是使用连字符的替代方案。在下面的代码示例中,合并的接口包含其自身的成员,以及InterfaceAInterfaceB的成员:

interface InterfaceA {
  m1: string;
}

interface InterfaceB {
  m2: string;
}

interface InterfaceMergeAB extends InterfaceA, InterfaceB {
  m3: string;
}

类型与接口之间的差异

类型与接口并不完全相同。例如,你可以合并两个接口,但不能将接口与原始类型合并,这可以通过类型完成。你可以在许多允许未来扩展外部主模块的定义中定义一个接口。在许多领域增强接口的可能性允许许多插件或合同版本化模式发生。这个功能的术语是开放的

interface IA {
  m1: string;
}

interface IA {

  m2: string;
}

const ia: IA = { m1: "m1", m2: "m2" };

一个类可以扩展一个类型或一个接口。后者更常见,因为类型有一些注意事项。例如,包含原始值的类型不会是一个好的类选择,因为实现将不会工作。TypeScript 足够智能,可以分析类型的内 容并确定实现无法发生:

type TPrimitive1 = string;
type TPrimitive2 = { m1: string };

class ExtendPrimitiv1 implements TPrimitive1 { // Does not compile
}

class ExtendPrimitiv2 implements TPrimitive2 { // Compile
 public m1: string = "Compile";
}

类型和接口可以有索引签名:

type TypeWithIndex = {
  [key: string]: string;
  m1: string;
}

const c: TypeWithIndex = {
  m1: "m1"
};

c["m2"] = "m2";

常规做法是尽可能依赖接口,因为其开放性特征,减少了关于类型是否可以具有原始值的混淆,并且因为它们可以被扩展或交叉。type关键字用于创建原始值的联合,或动态交叉对象字面量。

解构类型和数组

解构是 JavaScript 的一个特性,TypeScript 通过类型保护支持它。解构将对象分解成不同的部分。TypeScript 将这些类型带到这些部分。

一种情况是你需要将对象成员提取到变量中。这可以不使用解构来完成,但需要几行代码:

interface MyObject {
  id: number;
  time: number;
  name: string;
  startTime: Date;
  stopTime: Date;
  category: string;
}

const obj: MyObject = {
  id: 1,
  time: 100,
  name: "MyName",
  startTime: new Date(),
  stopTime: new Date(),
  category: "Cat5"
};

const id = obj.id;
const name = obj.name;
const category = obj.category;

使用解构,可以在一行内完成。所有变量都是对象的类型。这意味着id是类型为 number 的新变量,name是类型为 string,同样category

const { id, name, category } = obj;

解构可以使用剩余操作符来获取未指定的属性剩余部分。剩余的语法是变量名之前的三点,该变量将持有剩余成员:

const { id, name, category, ...remainingMembers } = obj;

remainingMembers.startTime;
remainingMembers.stopTime;
remainingMembers.time;

如您所见,变量remainingMember有三个成员,这是在其余部分之前没有明确指出的三个成员。这意味着remainingMember的类型是一个具有startTimestopTimetime成员的对象字面量,它们的类型分别是DateDatenumber

解构和剩余参数也可以与数组一起使用。你可以指定一个变量名,它的类型将是数组的类型。剩余参数允许创建一个新的数组,该数组包含具有初始数组类型的值的剩余部分。在下面的代码示例中,value1包含数字1(不是字符串,而是作为数字),value2包含2,而value3To9是一个包含值3456789的数组:

 const values = [1, 2, 3, 4, 5, 6, 7, 8, 9];
 const [value1, value2, ...value3To9] = values;

也可以通过使用逗号而不指定变量来跳过位置。在下面的代码示例中,value_1value_2之间有一个空格,这意味着第二个位置的值2既不在任何单个变量(value1value2)中,也不在变量value4To9中:

const values = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const [value1, value2, ...value3To9] = values;
const [value_1, , value_3, ...value4To9] = values;

元组

元组是对象的一种替代方案,可以在单个变量中存储多个值。通常用于通过函数传递信息,它利用数组来携带不同类型。使用元组进行赋值是通过在数组的特定索引处设置所需值来完成的,消费者必须知道以检索相关信息。在 JavaScript 中,使用数组是足够的。然而,在 TypeScript 中这样做会导致弱类型。以下代码显示 TypeScript 将推断类型为数字或字符串的数组,这在某些情况下是有意义的,但不符合代码想要强类型的要求。原因是数组可以在数组的任何位置是数字或类型,而在元组的情况下,我们希望数组中的每个位置都有特定的类型:

let tuple1 = ["First name", "Last Name", 1980]; // Infered as (string | number)[]
tuple1[2] = "Not a number";
let tuple2: [string, string, number] = ["First name", "Last Name", 1980]; // Tuple
tuple2[2] = "Not a number"; // Does not compile

为了克服数组推断,我们需要为每个位置指定类型。然而,即使元组由于通过索引指定类型而比具有多个类型的数组更具体,但这仍然不如使用对象安全:

let tuple3: [string, string, number]
tuple3 = ["Only One"]; // Does not compile
tuple3 = ["One", "Two", 3, "four"]; // Does not compile
tuple3[5] = "12"; // Compile

之前的代码说明了在实例化过程中,既有类型的验证也有大小的验证。这意味着在赋值时,你必须尊重预期的类型和值的数量。然而,代码的最后一行显示了数组表面的本质,并且无论元组声明指定了三个位置,都可以在定义的位置之后添加任何类型的值。在代码示例中,前三个位置(索引012)是强类型的,但位置四及以上可以是任何类型。尽管如此,使用方括号更改值时,将验证类型:

tuple3[5] = "12"; // Compile, do not mind the type
tuple3[1]= 2; // Does not compile, require to be a string

元组支持扩展操作符,可以将函数参数解构为多个变量。以下代码示例显示单个元组参数可以被展开。函数restFunctionresultFunction等价。代码示例显示可以传递一个元组但不能传递一个数组:

function restFunction(...args: [number, string, boolean]): void{
 const p1:number = args[0];
 const p2:string = args[1];
 const p3:boolean = args[2];
 // Code
}

function resultFunction(p1: number, p2: string, p3: boolean): void{
 // Code
}

let tuple4: [number, string, boolean] = [0, "one", false];
let array4: (number | string | boolean )[] = [0, "one", false];

restFunction(...tuple4);
restFunction(...array4); // Doesn't compile

restFunction(tuple4[0],tuple4[1],tuple4[2]);
restFunction(array4[0],array4[1],array4[2]); // Does not compile

元组支持可选参数。语法类似于具有可选参数的函数或具有可选成员的类型。没有值的位自动设置为undefined

let tuple5: [number, string?, boolean?];
tuple5 = [1];
tuple5 = [1, "two"];
tuple5 = [1, "two", false];

之前的声明类似于以下声明,其中可选位置也可以设置为undefined

let tuple5: [number, (string | undefined)?, (boolean | undefined)?]

在多个地方重用元组时,在类型中设置元组定义可能是有利的。语法与使用关键字type定义类型时相同:

type MyTuple = [number, string];
let tuple6:MyTuple = [1, "Test"];

总结来说,元组是传递函数信息以及快速返回多个值的便捷方式。然而,一个更好的选择是定义一个带有所需成员的快速接口。这不仅不依赖于位置,而且可以通过允许扩展和交集在许多情况下轻松重用。此外,对象更容易阅读,因为赋值和可读性依赖于名称而不是数字。

declarelet/const/var之间的区别

有可能使用declare代替三个声明符之一letconstvar。然而,declare在编译期间不会生成任何 JavaScript 代码,并且必须与letconstvar一起使用。declare的作用是向 TypeScript 编译器指示变量存在,但定义在别处:

declare let myVariable: string;

declare的主要作用是在需要定义签名时。在定义文件中使用declare是有意义的,因为它只定义类型,实际上并没有声明变量。

declare可以用来声明一个模块。声明一个模块用于在 JavaScript 或 TypeScript 的实际代码实现之外编写定义文件:

declare module "messageformat" {
}

摘要

在本章中,我们讨论了许多使用多个对象的概念来存储泛型信息的方法。我们通过对象字面量和实例化对象来消除了大写和小写对象之间的差异。我们澄清了使用索引签名和映射来存储快速访问数据的两种不同结构。章节继续讨论了如何使用联合和交集来操作几种类型。最后,我们看到了如何解构,以及declare与第二章中提到的三种先前声明(使用原始类型进行类型注册)的不同之处。

在下一章中,我们将看到如何与面向对象编程一起工作。下一章涵盖了如何使用继承、封装和静态函数。接口的概念以及如何在接口中定义构造函数签名将会被解释。下一章将深入 TypeScript 中强大的面向对象世界。

第四章:将您的代码转换为面向对象

面向对象有其自己的术语集,TypeScript 依赖于其中很多。在本章中,我们将使用示例讨论 TypeScript 支持的所有面向对象的概念。我们将看到类是什么以及如何将类实例化为对象。我们还将看到如何使用 TypeScript 强类型化构造函数,以及如何使用简写语法直接从构造函数分配类的字段。我们将涵盖封装的可见性原则,如何实现接口,以及如何将抽象引入类。

在本章中,我们将涵盖以下主题:

  • 类是什么以及我们如何定义一个?

  • 类型如何与类的构造函数相互作用

  • 使用 publicprivateprotected 的封装是什么

  • 在构造时设置值的字段定义的简化

  • 什么是静态?

  • 非公共构造函数的使用场景

  • 从类或对象字面量中使用对象

  • 接口如何在面向对象中发挥作用

  • 使用抽象类引入抽象

  • 如何拥有只读属性

  • 强制从接口实现特定构造函数

类是什么以及我们如何定义一个?

面向对象的核心是类。类是对一个对象一旦实例化后可用的定义。类包含开发者认为具有凝聚力的变量和函数。类可以共享同一类的所有实例的信息,或者拥有其自己的数据,这些数据从对象生命周期的开始到结束都是独一无二的。

类的创建从关键字 class 开始,后面跟着类的名称。它与创建接口类似:

export class MyClass {
}

类可以定义变量和函数。默认情况下,它们都是 public 的,这意味着可以通过实例的名称从类外部访问:

export class Variables {
 public a: number = 1;
  private b: number = 2;
  protected c: number = 3;
  d: number = 4; // Also public
}

一旦定义了类,就可以实例化它。实例化意味着类变得具体,其内容的生命周期开始。要创建类的实例,必须使用 new 关键字。在 new 之后是类的名称,后面跟着括号:

const d = new Variables();

类型如何与类的构造函数相互作用

类的实例化调用类的构造函数。如果没有定义,则不调用任何内容。当没有定义构造函数时,括号没有任何参数。构造函数的目的是为类提供数据初始化:

const d = new Variables();

在参数已定义的情况下,初始化必须提供所有非可选参数。在以下代码中,实例化调用具有两个参数的构造函数:

class MyClass {
   constructor(param1: number, param2: string) {
   }
}
const myClassInstance = new MyClass(1, "Must be present");

构造函数类似于函数但不能有重写。只能定义一个构造函数。如果没有提供可见性,默认情况下它是 public 的:

class MyClass {
   private m1: number;
   private m2: string;
   private m3: number;
   constructor(param1: number, param2: string) {
     this.m1 = param1;
     this.m2 = param2;
     this.m3 = 123;
 }
}

然而,构造函数可以通过多个签名进行重载。类似于函数,可以使用分号使用多个定义。在下面的例子中,你可以看到可以使用单个数字或数字和字符串来实例化类:


class ClassWithConstructorOverloaded {
 private p1: number;
 private p2: string;

 constructor(p1: number);
 constructor(p1: number, p2?: string) {
   this.p1 = p1;
   if (p2 === undefined) {
     p2 = "default";
   }
   this.p2 = p2;
 }
}

如果一个类扩展了另一个类,它必须使用正确的类型参数调用super。继承第二个类的类不需要有相同数量的构造函数参数,也不需要相同的类型。重要的是super调用要尊重扩展类的类型。在下面的例子中,MyClass有一个接受数字和字符串的构造函数。扩展MyClass的类MyClass2必须使用数字和字符串调用super。这个例子说明了值可以来自类的构造函数,也可以直接计算:

class MyClass2 extends MyClass {
   constructor(p1: number) {
     super(p1, "Here");
   }
}

使用publicprivateprotected进行封装是什么意思

在类级别上不可使用varletconst。类通过使用publicprivateprotected封装可见性关键字来声明。作用域被限制在类中,三者之间有一些细微的差别。

使用public关键字声明的类变量允许变量在类内外都可用。public修饰符将类的实例化,允许它在外部读取和写入值:

export class Variables {
   public a: number = 1;
}
const d = new Variables();
d.a = 100;
console.log(d.a);

另一方面,private声明限制了对其本身的访问。这在读取和写入时都适用:

export class Variables {
   private b: number = 2;
}
const d = new Variables();
d.b = 100; // Not allowed, won’t compile
console.log(d.b); // Not allowed, won’t compile

最后,protected封装类似于private,但它允许我们在类外部本身读取和写入protected变量的值。然而,访问被限制在声明protected的类以及扩展这个类的类。protected变量或函数在类层次结构中共享访问权限。这意味着所有继承具有protected成员的类的类都可以访问protected成员:

class BaseClass {
  public a: number = 1;
  private b: number = 2;
  protected c: number = 3;
}

class ChildClass extends BaseClass {
  public d: number = 1;
  private e: number = 2;
  protected f: number = 3;
  public f1():void{
   super.a;
   super.c;
   this.a;
   this.c;
 }
}

const child = new ChildClass();
console.log(child.a);
console.log(child.d);

在示例中,子类可以访问基类的publicprotected成员。一旦子类被实例化,只有public变量可用。私有成员仅在其定义的类中可用。这对于变量和函数都适用。

在构造时将字段定义的值集减小的操作

总是从构造函数设置值到字段可能会很麻烦。一种可能性是直接从构造函数签名将字段赋值到类中:

class MyClass3{
 public constructor(private p1:number, public p2:string){}
}
const myClass3 = new MyClass3(1, "2");
console.log(myClass3.p2);

通过设置构造函数中每个参数的封装可见性,创建了一个具有相同类型的成员。在先前的例子中,为类创建了两个字段,分别对应参数的名称和类型。下面的代码与先前的例子完全相同,但在定义和赋值方面更为复杂:

class MyClass3Same {
  private p1: number;
  public p2: string;
  public constructor(p1: number, p2: string) {
   this.p1 = p1;
   this.p2 = p2;
 }
}

const myClass3Same = new MyClass3Same (1, "2");
console.log(myClass3.p2);

什么是静态的?

静态成员是可以不实例化类即可访问的成员。所有静态成员在整个系统生命周期内都是共享的。静态变量和函数与类相关联,而不是与类的实例或特定对象相关联。如果你来自 JavaScript,你可以将静态视为与实例的原型链实例关联的成员。

与许多其他语言相反,TypeScript 不允许我们拥有静态类。它并没有减少多少,因为如果你需要一个静态类,你只需要将函数直接放在模块内部,而不是放在类中。如果你想将所有静态类放在一个类中,并防止你的库的用户实例化该类,你可以将类标记为抽象。抽象类不能在不被扩展的情况下实例化:

abstract class FakeStaticClass {
 public static m1: number;
 public static f1(): void { }
}

console.log(FakeStaticClass.m1);
FakeStaticClass.f1();

const instance1 = new FakeStaticClass(); // Doesn't compile

静态成员可以是所有封装可见性:publicprotectedprivate。然而,只有 public 可见性可以从类外部访问。privateprotected 在定义状态成员的类内部可用。受保护的成员可以从扩展了具有静态成员的另一个类的类中访问:

class StaticClass {
  public static ps: number;
  private static privateStatic: number;
  protected static protecStatic: number;
}

StaticClass.ps = 1;

只有 public static 成员可以从类外部访问。privateprotected 在类内部可访问。要使用任何静态成员,必须在成员使用之前指定类的名称。这个规则在类内部也是必要的。this 指针仅在实例内部可用,由于静态不是任何实例的一部分,而是 class 的一部分,因此不能通过实例的 this 指针访问:

class StaticClass {
  public static ps: number;
  private static privateStatic: number;
  protected static protecStatic: number;

  public nonStaticFunction():void{
    StaticClass.ps;
    StaticClass.privateStatic;
    StaticClass.protecStatic;
  }
}

非公共构造函数的使用场景

private 构造函数取消了从外部实例化类的可能性。以下代码无法编译,因为构造函数是 private 的。如果构造函数是 protected 的,情况也会相同:

class PrivateConstructor{
 private constructor(){
 }
}

const pc = new PrivateConstructor(); // Does not compile

在这种情况下,实例化类的唯一方法是通过使用创建类型为 class 的对象的 public static 函数,并返回它。在以下代码中,private 构造函数创建了一个实例;要访问此实例,使用 GetInstance,它是一个静态方法,不需要实例即可调用:

class SingletonClass {
 private static instance: SingletonClass;
 private constructor() {
 SingletonClass.instance = new SingletonClass();
 }

 public static GetInstance(): SingletonClass {
   return SingletonClass.instance;
 }
}
const singletonClass = SingletonClass.GetInstance();

一个已知的模式是拥有一个 SingletonClass。该类只有一个实例存在,并且可以通过使用一次 new 并始终返回相同实例的单个函数来管理这种控制。另一个用例是拥有一个创建所有实例的工厂。

使用类对象与对象字面量

对象字面量构建快速,不需要通过构造函数或 public 成员传递数据来填充对象。对象字面量是移动数据的高效方式。你可以读取 JSON 文件,从 Ajax 调用或许多其他来源接收数据,并将其转换为所需类型:

ajax.then((response:any)=>{
   return response as DataTyped;
};

然而,如果您有许多函数或更复杂的逻辑需要封装,类更实用。原因是使用对象字面量需要手动在每个实例上分配函数。此外,类可以包含您可能不想公开的private函数,提供private/protected和接口的封装。对象字面量的字段是public且可访问的。在以下示例中,我们看到从 Ajax 调用返回的数据。预期的类型是ObjectDefinition,它有一个函数。这个函数不像类在初始化期间那样免费提供。因此,它必须附加到对象上。在这种情况下,我们需要引用一个具有函数的变量。对于复杂的定义,这可能会很繁琐:

interface ObjectDefinition {
  m1: string;
  funct1: () => void;
}

let ajax: any;
const funct1 = () => { };

ajax.then((response: any) => {
  const r = response as ObjectDefinition;
  r.funct1 = funct1;
  return r;
});

可以通过有一个function来构建每个对象字面量,通过附加函数来减轻前面示例的负担。在这种情况下,function返回对象字面量的类型。这个function充当构造函数:

function createObj(m1: string): ObjectDefinitionClass {
  return {
   m1: m1,
   funct1: () => { }
  }
}

ajax.then((response: any) => {
 const r = response as Model;
 return createObj(r.m1);
});

使用类编写的相同代码如下所示:

class ObjectDefinitionClass implements ObjectDefinition {
 public m1: string;
 public funct1(): void { }

 constructor(param1: string) {
  this.m1 = param1;
 }
}

ajax.then((response: any) => {
 const r = response as ObjectDefinition;
 return new ObjectDefinitionClass(r.m1);
});

在那个特定的情况下,有趣的是将字段从函数中分离出来,并通过构造函数使用接口传递所有字段。以下是使用接口变量和函数的第三个版本:

interface Model {
 m1: string;
}

interface Funct {
 funct1: () => void;
}

class ObjectDefinitionClass2 implements Model, Funct {
 public m1: string;
 public funct1(): void { }
 constructor(param1: Model) {
   this.m1 = param1.m1;
 }
}

ajax.then((response: any) => {
 const r = response as Model;
 return new ObjectDefinitionClass2(r);
});

在可测试性的方面,类具有允许任何成员容易被模拟的优势。以下是一个使用 Jest 库的简单示例:

const forTesting = new ObjectDefinitionClass("1");
forTesting.funct1 = jest.fn();

接口在面向对象中的用途

接口扮演着许多角色。我们看到了您可以使用接口为特定对象定义一个合同。然而,接口可以做更多的事情。

接口定义了您希望库的消费者看到和使用哪些成员。publicprivateprotected可见性关键字服务于相同的目的。然而,在某些情况下,您可能需要具有public成员,同时又不允许每个人使用它们。一个原因可能是您希望对单元测试有深入访问,因此将大多数成员设置为public允许您进行黑盒函数测试。然而,这可能会暴露太多。因此,接口可以定义所有可访问的成员,并由类实现。您可以直接传递类的引用,而接口则在外部分布,您可以在内部使用类:

class ClassA {
 public mainFunction(): void 
{
  this.subFunction1();
  this.subFunction2();
 }

 private subFunction1(): void { }
 private subFunction2(): void { }
}

类有两个由主函数mainFunction调用的private函数。然而,封装不允许我们不使用一些黑客手段来访问这些函数而进行单元测试。我们希望避免将主对象强制转换为任何类型来访问函数,因为这些函数如果发生变化,测试可能会失败,不是因为逻辑错误,而是因为 TypeScript 无法重构函数,因为类型被强制转换为任何类型。更好的做法是使用接口来保持类型始终存在:

interface IClassA {
 mainFunction(): void;
}

class ClassA2 implements IClassA {
 public mainFunction(): void {
   this.subFunction1();
   this.subFunction2();
 }

 public subFunction1(): void { }
 public subFunction2(): void { }
}

虽然一切都是公共的,但整个系统都在使用IClassA而不是直接提供所需封装的类。单元测试可以使用该类并访问原始的私有函数。

第二个案例中,接口的亮点在于它允许我们有许多特定类型的具体实现。你可以定义一个接口,该接口将被函数消费,并且有多个此类实现。在以下示例中,我们有一个consume函数,它接受IElement作为输入。IElement有两个具体实现,这使代码具有许多元素实现的灵活性。这有助于通过具有表示执行任务所需的最小成员集的类型来减少消费函数中的自定义代码:

interface IElement {
 m1: string;
}

class E1 implements IElement { m1: string = "E1->m1"; a: number = 1; }
class E2 implements IElement { m1: string = "E2->m1"; b: boolean = true; }

class ClassB {
 public consume(element: IElement): void { }
}

使用抽象类引入抽象

抽象是一种面向对象的概念,它允许我们有一个基类,该类将函数的实现委托给扩展抽象类的类。

以下示例通过实例化自定义逻辑类来创建主类。它调用主函数,该函数将执行抽象函数。对于MainClass类,抽象函数是一个黑盒。它只知道其名称、参数类型和返回类型。示例按照特定的顺序执行注释块代码 A-C-B:

abstract class MainClass {
 public mainCoreLogic(): void {
   // Code here [A]
   this.delegatedLogic();
   // Code here [B]
 }

 public abstract delegatedLogic(): void;

}

class CustomLogic extends MainClass {

 public delegatedLogic(): void {
   // Do some custom logic here [C]
 }

}

const c: MainClass = new CustomLogic();
c.mainCoreLogic();

当将计算值传递给抽象类并且该类也返回自定义计算的结果时,抽象功能非常强大。这里有一个第二版本,展示了在主类保持不变的情况下,如何出现两种不同的实现。主类现在命名为Calculus,有一个公共函数,该函数接受两个数字并返回一个布尔值。它对参数进行一些操作并调用委托逻辑。对值的处理对主类来说是未知的。操作的重要部分是随后使用的那个结果。在通过扩展类消费抽象类的类的一侧。它必须提供所有抽象函数或字段。每个抽象成员在扩展级别都成为公共字段。在示例中,逻辑将两个值相乘并返回指定的类型:

abstract class Calculus {
 public isAboveZero(a: number, b: number): boolean {
   const positiveA = Math.abs(a);
   const positiveB = Math.abs(b);
   const result = this.delegatedLogic(positiveA, positiveB);
   return result > 0;
 }

 public abstract delegatedLogic(a: number, b: number): number;
 }

 class AddLogic extends Calculus {
   public delegatedLogic(a: number, b: number): number {
     return a * b;
   }
 }

 const multi: Calculus = new AddLogic();
 multi.isAboveZero(1, 2);

代码可以通过提供执行逻辑的参数以非面向对象的方式编写。以下是相同代码的版本,没有使用抽象

 class CalculusWithoutAbstract {
   public constructor(private delegatedLogic: (a: number, b: number) => number) {
   }

   public isAboveZero(a: number, b: number): boolean {
     const positiveA = Math.abs(a);
     const positiveB = Math.abs(b);
     const result = this.delegatedLogic(positiveA, positiveB);
     return result > 0;
   }
}

const multi2: CalculusWithoutAbstract = new CalculusWithoutAbstract((a, b) => a * b);
multi2.isAboveZero(1, 2);

没有使用抽象的版本将函数调用传递给类的构造函数,而不是传递抽象函数。这两种方法之间的替换是个人偏好的问题。两者之间的主要区别在于,使用抽象强制抽象实现为公共的,而委托函数可以保持私有。然而,解决可见性问题的一种方法是用基类而不是子类进行初始化:

const multi: Calculus = new AddLogic(); // Expose only the main function
const multi: AddLogic = new AddLogic(); // Expose the delegate function

如何拥有只读属性

只读字段只能初始化一次,在实例的生命周期内不需要更改。可以使用readonly关键字在接口中指定一旦字段被设置,值就不会改变:

interface I1 {
 readonly id: string;
 name: string;
}

let i1: I1 = {
 id: "1",
 name: "test"
}

i1.id = "123"; // Does not compile

它可以在类级别,此时值只能直接在声明或构造函数中设置。当值在字段声明旁边初始化时,这个值仍然可以被构造函数重新定义。以下示例展示了这种边缘情况。然而,它只可以在声明或仅在constructor级别设置,这通常是情况:

class C1 {
 public readonly id: string = "C1";

 constructor() {
   this.id = "Still can change";
 }

 public f1(): void {
   this.id = 1; // Doesn't compile
 }
}

使用static的只读可以用来为特定类创建一个常量。在类级别不允许使用const。如果你想在特定类的上下文中集中一个值,使用publicstaticreadonly是一个可接受的模式:

class C2 {
 public static readonly MY_CONST: string = "TEST";
 public codeHere(): void {
   C2.MY_CONST;
 }
}

从接口强制执行特定的构造函数

这很棘手,因为你不能通过接口强制执行构造函数的形状。然而,你可以使用接口来确保通过参数传递的类具有特定的构造函数。这个过程需要两个接口。一个是构造的返回类型,另一个是用于参数的接口:

interface ConstructorReturnType {    
  member1: number;
  funct(): void;
}
interface EntityConstructor {
  new(value: number): ConstructorReturnType;
}

在这个示例中,第一个接口有两个成员:一个字段和一个函数。接口的定义无关紧要,它可以是你想要获取函数实例的任何内容。第二个接口有一个构造函数,称为newable。它使用new关键字和输入参数以及它需要创建的内容。类型应该是创建的第一个接口:

function entityFactory(ctor: EntityConstructor, value: number): ConstructorReturnType {
   return new ctor(value);
}

下一步是创建一个函数,该函数接受newable函数并将其设置为参数类型。可选地,你可以有更多参数。在示例中,传递了一个构造函数的值。函数的返回类型必须是newable函数返回的类型。在这个函数中,你可以调用new后跟具有newable函数定义的接口参数。代码实例化了通过参数传递的类的实例。只有遵守newable函数类型合同的类才被接受:

class Implementation1 implements ConstructorReturnType {

 public member1: number;

 constructor(value: number) {
   this.member1 = value;
 }

 public funct(): void {
 }

}
let impl1 = entityFactory(Implementation1, 1);

在前面的代码中,Implementation1类实现了返回的实现,因此将成为此函数的候选者。它还有一个接受单个数字参数的构造函数,该参数将由函数调用。

另一方面,以下代码无法编译,因为该类没有继承由newable函数定义的返回类型:

class Implementation2 {
   constructor(value: number) { }
}

let impl2 = entityFactory(Implementation2, 1);

以下是一个可能看起来不合法但可以编译的示例代码。它扩展了返回的类,但不尊重需要它具有值的newable函数参数。这是有效的,因为定义仅关于返回的对象,而不是构造函数。构造函数使用参数调用,但类不必处理它。在下面的代码中,打印arguments变量显示它具有作为第一个参数传递的1值,即使类没有明确要求:

class Implementation3 implements ConstructorReturnType {
  public member1: number = 1;
  constructor() {
    console.log(arguments);
  }

  public funct(): void {
  }
}

let impl3 = entityFactory(Implementation3, 1);

摘要

在本章中,我们探讨了众多面向对象特性。TypeScript 正在缩小与已知面向对象编程语言(如 C#或 Java)的差距。作为 JavaScript 的超集,TypeScript 必须弥补 JavaScript 在这方面的一些弱点,但最终凭借其众多特性,以面向对象的方式编写专业应用程序成为可能。

我们学习了如何使用封装,这使我们能够控制字段和函数的可见性。我们讨论了如何强类型化构造函数,以及如何使用接口让类实现。在一个类内部,我们看到了如何拥有静态函数和抽象函数。下一章将介绍我们如何精确地识别我们正在操作的类型、对象或变量,从而利用它们各自独特的特定成员。我们将看到如何使用 JavaScript 类型检查器,如typeofinstanceof,以及如何对结构化类型使用判别器和定义守卫。

第五章:使用不同模式的作用域变量

在本章中,我们看到最基本的概念是变量。知道确切类型,从原始类型到对象,对于访问特定成员至关重要。在运行时和设计时缩小确切类型至关重要,以确保两个环境之间的一致性,并了解可能性和不可能性。不同类型变量之间的配置多样性需要许多不同的模式,这些模式在本章中都有介绍。

本章将涵盖以下内容:

  • 如何使用 typeof 在运行时和设计时进行比较

  • 如何确保 undefinednull 的检查

  • 我是否需要检查联合的所有可能性以获得正确的类型?

  • instanceof 的局限性是什么?

  • 为什么区分器对于类型识别是必不可少的

  • 为什么使用用户定义的 guard

  • 如何以及为什么进行类型转换

  • 什么是类型断言?

  • 如何比较类

  • 如何在签名中具有联合类型的函数中缩小类型

与 typeof 在运行时和设计时的比较

TypeScript 将类型引入 JavaScript,但这主要是在设计类型时才成立。TypeScript 在编译过程中移除所有类型。这就是生成的代码是纯 JavaScript 并且不包含任何接口或类型痕迹的原因。对 JavaScript 的纯粹尊重使得类型比较更加困难,因为它不能依赖于类型的名称来执行类型检查。然而,我们可以使用所有 JavaScript 的技巧来知道一个值是否来自不同的类型。第一个特性回答了本节关于如何比较运行时和设计类型的主要问题。JavaScript 中存在的 typeof 操作符在 TypeScript 中也是以相同的方式工作的。typeof 操作符返回原始类型的类型,或者返回 object

使用很简单:调用 typeof 后跟您想要比较的变量。大多数时候,您会将其与需要以字符串形式编写的类型名称进行比较:

const a = "test";
let b: number = 2;
const c: boolean = true;
let d: number | string = "test";
console.log(typeof a); // string
console.log(typeof b); // number
console.log(typeof c); // boolean
console.log(typeof d); // string

typeof 操作符在具有联合类型时特别有用,其中对象可以来自多个原始类型。实际上,它甚至可以与具有复杂对象(接口或类型)的联合一起使用,因为 typeof 返回 object

const e: number | string | { complex: string, obj: number } = { complex: "c", obj: 1 };
console.log(typeof e); // object

要知道对象是哪种类型,将需要使用我们在本章中将要介绍的其他机制。在继续之前,即使 typeOf 可以与字符串比较,操作的结果可以设置一个类型:

let f: number = 2;
if (typeof f === "number") {
console.log("This is for sure a number");
}
type MyNewType = typeof f;

注意,typeOf 在原始类型上工作,但与 undefinednull 交互时表现得很奇怪。然而,undefined 将返回 undefined,而 null 将返回 object。检查 undefinednull 的最佳方法是不使用 typeof

let g: number | undefined = undefined;
let h: number | undefined | null = null;
console.log(typeof g);
console.log(typeof h);

区分 undefined 和 null

typeof 对未定义类型执行时,它返回 undefined 字符串,而当它对 null 执行时,它返回 object。这种不一致性在你忘记哪个情况可以使用 typeof 通过对错误的 no type 类型执行错误操作时成为一个问题。然而,undefinednull 不需要使用 typeof 来进行类型检查。可以直接将变量与 undefinednull 进行比较:

let g: number | undefined = undefined;
let h: number | undefined | null = null;
console.log(typeof g); // undefined
console.log(typeof h); // object
console.log(g === undefined); // true
console.log(g === null); // false
console.log(h === undefined); // false
console.log(h === null); // true

在一个变量可以是未定义、null 或任何其他原始类型的情况下,最好的方法是检查类型的可空性,并继续进行进一步的类型比较。

获取联合类型中的元素类型

TypeScript 的推理系统随着每个版本的更新而变得更好。在最新版本中,TypeScript 使用控制流以智能的方式根据代码的编写方式找出类型。如果一个检查在一个代码路径中执行,TypeScript 就知道对于类型验证的闭包,类型就是检查过的。如果一个 else 代码路径存在到类型检查,它知道它是类型比较的反面。

以下代码示例显示,根据执行的位置,类型会发生变化。它最初是数字或未定义。对 undefined 的值进行检查使得值缩小到 if 范围内的未定义值。else 只能在联合类型中是除了未定义之外的所有值。在特定情况下,它只能是一个数字。在 ifelse 之后,TypeScript 无法知道类型是什么;因此,值又回到了两种潜在类型:

function myFunction(value: number | undefined): void {
 console.log("Value is number or undefined");
 if (value === undefined) {
 console.log("Value is undefined");
 } else {
 console.log("Value is NOT undefined, hence a number");
 }
 console.log("Value is number or undefined");
}

TypeScript 理解代码流。它很聪明,可以从特定的类型检查中冻结类型。在以下代码示例中,一个等于 undefined 的值迫使函数返回。这意味着通过那个点,就不可能有一个未定义的值。从潜在值的集合中减去 undefined 减少了只有数字的可能性:

function myFunction2(value: number | undefined): void {
 console.log("Value is number or undefined");
 if (value === undefined) {
 return;
 }
 console.log("Value is NOT undefined, hence a number");
}

TypeScript 会根据你的条件检查缩小联合类型,不仅仅是原始类型。你还可以使用这个行为与一个区分器和用户定义的类型守卫一起使用,这两种模式我们将在本章中看到。

instanceof 的限制

typeof 类似,JavaScript 中有 instanceof 操作符。instanceof 的限制是它只能用于具有原型链的类型:一个类。像 typeof 一样,instanceof 在设计和运行时工作,并且是 JavaScript 的原生功能:

class MyClass1 {
 member1: string = "default";
 member2: number = 123;
}
class MyClass2 {
 member1: string = "default";
 member2: number = 123;
}
const a = new MyClass1();
const b = new MyClass2();
if (a instanceof MyClass1) {
 console.log("a === MyClass1");
}
if (b instanceof MyClass2) {
 console.log("b === MyClass2");
}

typeof 不同,instanceof 的结果不是一个字符串,不能用于 console.log 函数;它只能用于设置值在类型或变量中。它只能用于比较目的。下一个示例无法编译:

type MyType = instanceOf MyClass1;

instanceOf 限制不仅限于关注类。instanceOf 操作符也不区分在继承情况下确切使用了哪个类。在下一个代码示例中,变量  c 的类型是 MyClass3,它继承自 MyClass2。  InstanceOf 识别变量为两种类型。在以下代码中,两个 if 都被进入:

class MyClass3 extends MyClass2 {
 member3: boolean = true;
}
const c = new MyClass3();
if (c instanceof MyClass2) {
 console.log("c === MyClass2");
}
if (c instanceof MyClass3) {
 console.log("c === MyClass3");
}

使用判别器进行类型识别

TypeScript 是一种结构化语言,这意味着它不像命名语言那样依赖于类型的名称。JavaScript 没有类型;因此,它是一种结构化语言。C# 或 Java 都是命名语言。这种差异很重要,因为它意味着 TypeScript 不会检查接口或类型的名称来做出任何决定。当我们思考 TypeScript 如何编译代码时,这一点是有意义的。在编译过程中,所有类型都会从代码中剥离,以生成干净的 JavaScript。这种共生关系是针对 JavaScript 的;因此,赋予 TypeScript 作为 JavaScript 超集的荣誉。然而,在 TypeScript 的运行时和 JavaScript 的设计时,我们需要知道我们正在操作哪种类型。在结构化代码中,方法是通过分析、比较和通过观察结构来推断类型。如果存在特定的成员,它将给出我们正在处理的内容的提示。以下代码示例显示了两个具有相同主体的相同接口,一个与相同结构相同的类型,以及一个具有匿名类型的第一个变量。该对象可以是每种类型,因为它尊重每种类型的契约:

interface Type1 {
 m1: string;
}
interface Type2 {
 m1: string;
}
type Type3 = { m1: string };
const v0 = { m1: "AllTheSame" };
const v1: Type1 = v0;
const v2: Type2 = v0;
const v3: Type3 = v0;

在前面的示例中,使每种类型不同的方法是使用判别器的概念。判别器是在需要区分的一组共同类型之间具有共享名称的成员。这个组通常是一个联合。想法是每个类型都有一个具有相同名称的唯一 string literal。将 string literal 作为类型成员需要实现实现相同的 string。这意味着特定类型的每个实例都将具有相同的 string。TypeScript 可以通过查看 string literal 来推断类型。以下代码示例应用了这个原则。共同的成员被命名为 kind每个接口和类型都有一个独特的 kind。匿名类型试图模仿 Type1,但失败了,因为推断的类型是 string 而不是 string literal

interface Type1 {
 kind: "Type1";
 m1: string;
}

interface Type2 {
 kind: "Type2";
 m1: string;
}

type Type3 = { kind: "Type3"; m1: string };
const v0 = { kind: "Type1", m1: "AllTheSame" };
const v1: Type1 = v0; // Does not compile
const v2: Type2 = v0; // Does not compile
const v3: Type3 = v0; // Does not compile

判别器证明不仅对于避免交叉类型有用,而且对于缩小类型范围也很有用。在许多类型的联合中,当与判别器进行比较时,TypeScript 将确切知道类型以及比较的范围。以下代码允许减少到确切类型。在特定情况下,m1 成员是所有三种类型中都有的成员,因此不需要缩小到单个类型来使用:

function threeLogic(param: Type1 | Type2 | Type3): void {
 switch (param.kind) {
  case "Type1":
   console.log(param.m1); // param is type Type1
  break;
  case "Type2":
   console.log(param.m1); // param is type Type2
  break;
  case "Type3":
   console.log(param.m1); // param is type Type3
  break;
 }
}

如果我们有一个具有完全不同成员的接口,区分它们对于访问一个仅属于一个接口或另一个接口的成员是至关重要的。以下代码缩小了接口的范围,使其能够根据比较使用适当类型的成员:

interface Alpha { kind: "Alpha", alpha: string }
interface Beta { kind: "Beta", beta: string }

function AlphaBeta(param: Alpha | Beta): void {
 switch (param.kind) {
  case "Alpha":
   console.log(param.alpha);
  break;
  case "Beta":
   console.log(param.beta);
  break;
 }
}

将字符串字面量用作判别器的用法通常被称为字面量类型守卫标记 联合体。它在函数式编程中非常强大,提供了一种快速识别类型的方法,而无需像其他技术(如用户定义守卫)那样根据需要开发特定的条件。

用户定义守卫模式

确定接口或类型的类型可能具有挑战性。在本章中,我们看到了判别器的使用。然而,对于通常称为字符串字面量的方法,存在一个缺点,即与继承和交集相关。以下代码无法编译:

interface Type1 extends Type2 {
  kind: "Type1"; // Does not compile, expect “Type2”
  m1: number;
}

interface Type2 {
  kind: "Type2";
  m2: string;
}

对于交集来说,情况也是如此:

interface Type2 {
 kind: "Type2";
 m2: string;
}

interface Type3 {
 kind: "Type3";
 m3: string;
}

type Type4 = Type2 & Type3;
const type4: Type4 = { kind: ???, m2: "1", m3: "2" }; // Does not compile

最后一个代码示例为成员类型创建了一个类型,这个类型要求同时是字符串字面量,这在实际操作中既不可能实现,也不实用。有了这些信息在手,我们可以看到,当避免继承和交集时,判别器模式工作得很好。这个想法是使用每个类型的自定义用户定义守卫。虽然创建起来可能有些繁琐,但可以确保你在设计和运行时都能得到该类型。这个想法是检查字段,看它们是否被定义。这种技术对于没有可选字段的类型工作得很好,因为你需要检查字段是否存在。作为函数和类型的作者,你不需要检查每个字段。你应该知道哪个字段足以识别类型。在下面的代码中,存在两种类型,其中一种类型扩展了另一种类型。创建了两个类型用户定义守卫——一个用于每个接口:


interface Type1 extends Type2 {
 m1: number;
}

interface Type2 {
 m2: string;
 m3: number;
}

function checkInterfaceICheck1(obj: any): obj is Type1 {
 const type1WithMaybeManyUndefinedMembers = (obj as Type1);
 return type1WithMaybeManyUndefinedMembers.m1 !== undefined
 && type1WithMaybeManyUndefinedMembers.m2 !== undefined
 && type1WithMaybeManyUndefinedMembers.m3 !== undefined
}

function checkInterfaceICheck2(obj: any): obj is Type2 {
 const type1WithMaybeManyUndefinedMembers = (obj as Type2);
 return type1WithMaybeManyUndefinedMembers.m2 !== undefined
 && type1WithMaybeManyUndefinedMembers.m3 !== undefined;
}

function codeWithUnionParameter(obj: Type1 | Type2): void {
 if (checkInterfaceICheck1(obj)) {
 console.log("Type1", obj.m1);
 }

 if (checkInterfaceICheck2(obj)) {
 console.log("Type2", obj.m2);
 }
}

函数必须知道传递了两种类型中的哪一种,并且通过使用用户定义守卫来检查。定义守卫的返回类型是唯一的。它使用参数名称后跟is和如果我们期望的值是true,则期望的类型。它允许通过比较结构自动缩小到期望的类型。如果一切都在场并且已定义,它将返回true,但函数不会返回实际的boolean值。它返回将对象转换为该类型的对象。

类型转换的原因

类型转换是将一个类型转换到另一个类型上的行为。这是危险的,应该很少使用。类型转换可能产生副作用的原因是你正在手动控制将变量强制转换为另一种类型。类型可能创建不兼容和意外的结果。类型转换对于任何类型的变量都是可能的,从原始类型到更复杂的对象。

最基本的类型转换场景是从 any 获取一个值并将其类型化。下面的代码显示了一个在 any 中设置的数字,然后将其转换为数字类型的变量。你可以注意到两种不同的转换方式。一种使用较小的和较大的符号 <>,另一种使用 as。后者是推荐的方式,因为它不会混淆使用 TSX 语法(它使用组件的符号)的代码:

let something: any = 1;
let variable1: number;
variable1 = <number>something;
variable1 = something as number;

之前的代码之所以有效,是因为转换是从 anynumber。将数字转换为字符串是不行的。原因是转换仅在您与子类型一起工作时才有效。此外,any 是所有类型的子类型,这允许将类型转换为任何类型。然而,下面的代码无法编译,因为 variable1 是一个被转换为字符串的数字:

let variable1: number = 1;
let variable2: string = variable1 as string;

TypeScript 也存在以避免在缺少字段的对象之间进行类型转换。在下面的代码中,这两种类型不能相互转换。TypeScript 在 Type1 中找不到 m2,而在下面的代码中第二次转换找不到 m1

interface Type1 {
 m1: number;
}

interface Type2 {
 m2: string;
 m3: number;
}

let t1: Type1 = { m1: 123 };
let t2: Type2 = t1 as Type2; // Property 'm2' is missing in type 'Type1'
let t3: Type2 = { m2: "2", m3: 3 };
let t4: Type1 = t2 as Type1;// Property 'm1' is missing in type 'Type2'

然而,将 m1 添加到 Type2 中会改变整个情况,并允许在两侧进行类型转换而不产生任何编译错误。原因是 Type1 通过其结构是 Type2 的子类型,这是 TypeScript 中最重要的:

interface Type1 {
 m1: number;
}

interface Type2 {
 m1: number;
 m2: string;
 m3: number;
}

let t1: Type1 = { m1: 123 };
let t2: Type2 = t1 as Type2;
let t3: Type2 = { m1: 1, m2: "2", m3: 3 };
let t4: Type1 = t2 as Type1;

最后一段代码有趣的地方在于最后的转换是无用的。原因是 Type2 拥有 Type1 的所有结构,而 Type1Type2 的子类型。这意味着它们在结构上的最小点是结构上等效的:

let t3: Type2 = { m1: 1, m2: "2", m3: 3 };
let t4: Type1 = t2;

类型转换对于 t1Type2 是必需的,因为 t1 不满足合同(它缺少 m2m3)。转换产生了一个 falseType2,因为 m2m3 不存在,这意味着它们是未定义的。Type2 对于这些成员没有未定义的类型,这使得它在未来的使用中变得有问题,因为 TypeScript 将允许 m2 使用任何字符串的函数,而此处的 m2 是未定义的。类型转换伴随着巨大的责任,而篡改类型将使 TypeScript 无法执行安全验证。

当类型转换影响一个 any 类型的对象时,滑坡效应会更陡峭。很难避免所有的 any。例如,当数据在系统之间传递时。一个 Ajax 请求返回一个 JSON 对象,这是不可避免的,因为它是一个 any。响应未进行类型化,要将值引入 TypeScript,必须执行一个关键的类型转换。

将类型转换为 any 然后转换为所需类型是一个坏的模式。这是绕过 TypeScript 的方法,发现转换不是有效的。任何东西都可以转换为 any,并且可以从任何类型转换到其他任何类型:

let a: number = 1;
let b: string = "2";
a = b as number; // Doesn't compile
a = b as any as number; //Shortcircuit with any

类型断言是什么?

有一些场景,你知道一个类型不是未定义或 null,但 TypeScript 会暗示它可能是。当这种情况发生时,你可以对 undefinednull 进行检查,并在条件的作用域内将保证类型不是可空的。然而,有三种场景可以从更短的语法中受益。

第一个场景是深层嵌套的对象。在这种情况下,您可能有多个级别的可空字段,如果您确信它们不是 undefined 或 null,这将有助于避免嵌套的if结构:

interface T1 {
 myNumber: number | undefined;
}

interface T2 {
 t1: T1 | undefined;
}

interface T3 {
 t2: T2 | undefined;
}

const myObject: T3 | undefined = {
 t2: {
 t1: {
  myNumber: 1
 }
 }
}

if (myObject !== undefined) {
 if (myObject.t2 !== undefined) {
  if (myObject.t2.t1 !== undefined) {
   if (myObject.t2.t1.myNumber !== undefined) {
    console.log("My number is :", myObject.t2.t1.myNumber);
   }
  }
 }
}

条件检查是确保没有未定义内容的最安全方式。然而,在某些情况下,检查可能是在访问数据之前进行的,但需要在检查范围之外访问值,这使得 TypeScript 对if语句感到不安,同时状态已发生变化。如果我们试图在之前的代码之后立即访问myNumber,就会发生这种情况。这就是断言类型发挥作用的地方。断言类型是成员后面的感叹号(或称为叹号),它指定该成员不是 null 或 undefined。您正在断言这是事实,并承担起将字段未定义可空的责任。

这意味着您可以使用单行来访问成员:

console.log("My number is :", myObject!.t2!.t1!.myNumber);

理解这一点至关重要,因为如果在不恰当的时间使用,可能会导致潜在的运行时错误。由于某种原因,任何可空字段如果在错误的时间或位置应用,都可能变为可空。无法保证执行将成功,但会缓解 TypeScript 错误,指出您在未将其缩小到特定类型的情况下访问了可空字段。

使用类型断言的第二种情况是在类中定义字段时。如果 TypeScript 设置为具有编译严格性以避免未初始化的字段,则在定义字段且未在声明或构造函数中指定值时,您将遇到错误。这是一个很好的验证,但在某些罕见情况下,值可能在initialize函数中稍后出现。在这种情况下,您可以断言类的字段,表明您正在处理该值:

class LateInitialization {
 m1!: number; // Not initialized (use type assertion)
 constructor() {
   // No initializing here
 }
 public init(): void {
   this.m1 = 1;
 }
}

再次强调,这应该谨慎使用,因为它可能会带来一些问题。例如,现在您可以访问成员并使用它,而无需 TypeScript 验证在访问值之前值已被分配:

constructor() {
   this.m1 + 1; // This will fail
}

这可能看起来微不足道,因为您知道您不会这样做。但有时可能不那么明显。一个错误的情况是,您从另一个可能在init函数之前被调用的公共函数中访问成员,这可能导致对变量的任何使用都未定义。类型断言迫使 TypeScript 对未初始化的值视而不见。

最后一种情况也是危险的,应该非常小心地编码。你可以在任何时候使用感叹号来消除可空性。这意味着它也适用于简单变量。以下代码声明了一个类型为字符串或 undefined 的变量。它使用一个立即调用的函数来设置其值。该函数的返回类型也是 string | undefined。TypeScript 推断这个函数可能返回一个或两个类型,因此可以合法地返回 undefined。然而,我们知道情况并非如此,因此可以使用感叹号来消除 undefined 的可能性,并使用字符串的函数:

let var1: string | undefined;
var1 = ((): string | undefined => "Not undefined but type is still both")();
console.log(var1!.substr(0, 5));

再次强调,这是危险的,可以采用更好的方法来避免。首先,要避免使用包含 undefinednull 的联合。如果这超出了你的控制范围,应避免使用像最后一个代码示例中那样也返回 undefined 的函数。如果将返回类型改为字符串,问题就可以优雅地解决。

类的比较

类与接口、类型或原始类型不同。它们有一个原型链并遵循不同的规则。例如,如果两个不同的类具有相同的结构,它们可以互换。以下类 C1C2 在结构上是相同的,可以在需要 C1 的函数中互换使用。你甚至可以在 C1 变量中实例化 C2

class C1 {
 public a: number = 1;
 public funct(): void { }
}

class C2 {
 public a: number = 1;
 public funct(): void { }
}

const c1: C1 = new C1();
const c2: C2 = new C2();
const c12: C1 = new C2();

function executeClass1(c1: C1): void {
 c1.funct();
}

executeClass1(c1);
executeClass1(c2);
executeClass1(c12);

如果我们在 C1C2 中添加 private 字段,那么它们就不会相同:

class C1 {
 public a: number = 1;
 public funct(): void { }
 private p: string = "p";
}

class C2 {
 public a: number = 1;
 public funct(): void { }
 private p: string = "p";
}

const c1: C1 = new C1();
const c2: C2 = new C2();
const c12: C1 = new C2(); // Does not compile

function executeClass1(c1: C1): void {
 c1.funct();
}

executeClass1(c1);
executeClass1(c2); // Does not compile
executeClass1(c12);

privateprotected 字段使每个类独一无二。TypeScript 继续比较结构,但在这些两个可见性修饰符方面会做出例外。

原因是当使用继承并将子类赋值给基类时,它必须来自相同的层次结构,而不是来自不同层次结构但形状相似的东西。以下代码展示了如果没有 privateprotected 字段,基类可以被具有子类和基类结构的单个类所替代:

class B1 {
  public baseFunct(): void { }
}

class C1 extends B1 {
  public a: number = 1;
  public funct(): void { }
}

class C2 {
  public a: number = 1;
  public funct(): void { }
  public baseFunct(): void { }
}

const c1: B1 = new C1();
const c2: B1 = new C2();

然而,在基类 B1 中添加一个 private 字段,并在 C2 中添加相同的字段,这使得它们变得不同,阻止了 C2 被赋值给类型为 B1 的变量 C2

class B1 {
 private name: string = "b1";
 public baseFunct(): void { }
}

class C1 extends B1 {
 public a: number = 1;
 public funct(): void { }
}

class C2 {
 private name: string = "c2";
 public a: number = 1;
 public funct(): void { }
 public baseFunct(): void { }
}

const c1: B1 = new C1();
const c2: B1 = new C2(); // Does not compile

在函数签名中使用联合类型的类型收缩

复杂的函数可能难以处理。这种情况通常发生在函数有一个或多个不同类型的参数,并且可以返回一个或多个类型时。TypeScript 允许将任何类型拼接在一起:

function f(p: number | string): boolean | Date {
 if (typeof p === "number") {
  return true;
 }
 return new Date();
}

const r1: boolean = f(1); // Does not compile
const r2: Date = f("string"); // Does not compile

在前面的代码中,代码无法编译。原因是函数返回一个必须缩小的联合类型。然而,如果我们在上面的函数中添加重载,我们可以将联合类型与单一返回类型的特定参数集匹配。前面的代码无法编译,因为它将联合类型返回到一个单一类型变量中。通过指定当参数是数字时函数返回boolean,而当它是字符串时返回日期,就不需要任何类型转换或其它操作:

function f(p: number): boolean;
function f(p: string): Date;
function f(p: number | string): boolean | Date {
 if (typeof p === "number") {
  return true;
 }
 return new Date();
}

const r1: boolean = f(1);
const r2: Date = f("string");

这不仅仅是将单个参数关联到返回类型。例如,在下面的代码中,我们确保只能一起发送所有数字参数或所有字符串参数:

function g(p: number, q: number): boolean;
function g(p: string, q: string): Date;
function g(p: number | string, q: number | string): void {
}
g(1, "123"); // Doesn't compile
g(1, 2);
g("1", "2");

摘要

在本章中,我们介绍了如何更好地理解变量的类型。这不仅有助于做出决策,而且可以将变量缩小到单一类型,从而有机会访问特定类型特有的特定成员。

在下一章中,我们将看到如何通过使用泛型变量来泛化类型。泛型变量增加了你代码中对象和变量的可重用性,从而减少了创建平凡类型的必要性。

第六章:通过泛型复用代码

本章是基于前几章引入的概念构建的。本章通过增强类型使其泛型化来构建在概念之上。本章涵盖了基本主题,如定义泛型类和接口。通过本章,我们进入更高级的主题,如泛型约束是内容的一部分。本章的目标是使你的代码更加泛型,以增加类、函数和结构的可复用性,减少代码复制的负担。

本章内容涵盖以下内容:

  • 泛型如何使你的代码可复用

  • 泛型类型可接受的数据结构类型

  • 如何约束泛型类型

  • 泛型和交集

  • 默认泛型

  • 泛型可选类型

  • 使用联合类型进行泛型约束

  • 使用 keyof 限制字符串选择

  • 限制对泛型类型成员的访问

  • 使用映射类型减少类型创建

  • TSX 文件中的泛型类型

使用泛型代码提高复用性

泛型几乎在所有类型语言中都是可用的。它允许以可复用的方式转换你的代码,而无需依赖于不安全的类型转换来检索对象中存储的值。没有泛型,有不同方式来实现复用。例如,你可以有一个具有 any 类型的接口。

这样会使字段能够接收任何类型的对象,因此可以在许多场景中复用:

interface ReusableInterface1 {
    entity: any;
}

一种稍微好一点的方法是指定我们是否想要接受原始类型或仅接受对象:

interface ReusableInterface2 {
   entity: object;
}

const ri2a: ReusableInterface2 = { entity: 1 }; // Does not compile
const ri2b: ReusableInterface2 = { entity: { test: "" } };

在这两种情况下,问题出现在我们想要使用可复用字段的时候。使用 anyobject 也会出现相同的结果,即我们无法访问原始变量的成员,因为我们没有方法知道原始类型是什么:

const value = ri2b.entity; // value -> "object"

在这段代码中,如果不将实体转换回原始类型,就无法使用实体的 .test 方法。在这个特定的类型中,它是一个匿名类型,但仍然可能:

const valueCasted = value as { test: string };
console.log(valueCasted.test);

然而,泛型可以通过将对象的类型引入类型的定义中,而不干扰要隔离的单个类型,来消除访问原始类型的障碍。要创建泛型函数、接口或类,你需要使用较小的或较大的符号 < >

interface MyCustomTypeA {
   test: string;
}

interface MyCustomTypeB {
   anything: boolean;
}

interface ReusableInterface3<T> {
   entity: T;
}

中括号之间的名称并不重要。在下面的代码中,我们使用两个自定义接口来使用实体,并将它们用作类型 T。我们还在直接使用一个数字。我们可以使用所有可能的类型,因为我们还没有设置泛型约束:

const ri3a: ReusableInterface3<MyCustomTypeA> = { entity: { test: "yes" } };
const ri3b: ReusableInterface3<MyCustomTypeB> = { entity: { anything: true } };
const ri3c: ReusableInterface3<number> = { entity: 1 };

最大的优势是,如果我们访问对象,字段实体是 T 类型,这取决于对象是如何创建的:

console.log(ri3a.entity.test); // "yes" -> string
console.log(ri3b.entity.anything); // true -> boolean
console.log(ri3c.entity); // 1 -> number

泛型类型可接受的数据结构类型

通用类型的概念不仅限于接口。通用类型不仅适用于类型,还适用于类,并且可以转换函数。定义通用类型的括号的位置紧接在接口名称、类型或类名称之后。我们稍后将会看到它也必须紧跟在函数名称之后。通用类型可以用于具有通用字段、通用参数、通用返回类型和通用变量:

type MyTypeA<T> = T | string; // Type

interface MyInterface<TField, YField> { // Interface wiht two types
  entity1: TField;
  myFunction(): YField;
}

class MyClass<T>{ // Class
 list: T[] = [];
 public displayFirst(): void {
   const first: T = this.list[0]; // Variable
   console.log(first);
 }
}

通用类型可以同时具有多个通用类型,允许多个字段或函数参数具有不同类型的类型:

function extractFirstElement<T, R>(list: T[], param2: R): T {
  console.log(param2);
  return list[0];
}

限制通用类型

在本章之前的代码示例中,我们使用了类型对象来确保在接口中没有传递原始类型。使用对象的问题在于,当你从实体返回时,你无法获得初始类型。以下代码说明了这个问题:

interface ReusableInterface2 {
  entity: object;
}

const a = {
  what: "ever"
};

const c: ReusableInterface2 = { entity: a };
console.log(c.entity.what); // Does not compile because "what" is not of object

有可能保持原始类型,并通过extends关键字约束不允许使用原始类型:

interface AnyKindOfObject {
  what: string;
}

interface ReusableInterface3<T extends object> {
  entity: T;
}

const d: ReusableInterface3<AnyKindOfObject> = { entity: a };
console.log(d.entity.what); // Compile

extends关键字允许指定传递给通用类型的对象必须存在的最小结构。在这种情况下,我们传递了一个对象。然而,我们可以扩展任何最小结构、接口或类型:

interface ObjectWithId {
 id: number;
 what: string;
}

interface ReusableInterface4<T extends { id: number }> {
 entity: T;
}

const e: ReusableInterface4<AnyKindOfObject> = { entity: a }; // Doesn't compile
const f: ReusableInterface4<ObjectWithId> = { entity: { id: 1, what: "1" } }; // Compile
const g: ReusableInterface4<string> = { entity: "test" }; // Doesn't compile

之前的代码示例中有两个变量无法编译。第一个变量被设置为一个错误的对象。第三个变量被设置为一个字符串,但由于字符串没有id:number字段,因此无法满足通用约束。第二个变量可以编译,因为实体遵守了约束。最后,这里有一个具有约束的通用类型的示例:

interface ReusableInterface5<T extends ObjectWithId> {
   entity: T;
}

除了可以访问完整的原始类型外,具有约束的通用类型还允许从实现通用类型的类或函数中访问约束字段。第一个代码示例,使用function,在函数签名中直接具有约束。它允许仅访问约束的字段:

function funct1<T extends ObjectWithId>(p: T): void {
   console.log(`Access to ${p.what} and ${p.id}`);
}

类似地,一个类允许在其任何函数中使用通用约束的字段。在下面的代码中,函数遍历T的通用列表。由于T扩展了具有what属性和idObjectWithId,因此两者都是可访问的:

class ReusableClass<T extends ObjectWithId>{
 public list: T[] = [];
 public funct1(): void {
   this.list.forEach((p) => {
    console.log(`Access to ${p.what} and ${p.id}`);
    });
 }
}

通用类型和交集

通用类型和交集工作得很好。它允许使用未确定类型,并通过已知类型或另一个通用类型的组合创建第二个类型。在下面的代码中,有一个通用函数,它接受一个必须至少符合User对象结构的类型T。函数的返回类型是参数传递的通用类型与新的WithId结构交集后的通用类型。这意味着传递的任何类型都将通过新的结构得到增强。在代码中,将Developer类型传递给函数,函数返回Developer+WithId。这是一个新类型,没有在任何地方定义,但仍然是强类型的:

interface WithId {
 id: number;
}

interface User {
 name: string;
}

interface Developer extends User {
 favoriteLanguage: string;
}

function identifyUser<T extends User>(user: T): T & WithId {
 const newUser = (<any>Object).assign({}, user, { id: 1 });
 return newUser;
}

const user: Developer = { name: "Patrick", favoriteLanguage: "TypeScript" };
const userWithId = identifyUser(user);
console.log(`${userWithId.name} (${userWithId.id}) favorite language
 is ${userWithId.favoriteLanguage}`);

代码显示我们可以使用返回类型,并且所有三个成员都可用。

可以将许多通用类型组合在一起,例如:

function merge<T, U>(obj1: T, obj2: U): T & U {
   return Object.assign({}, obj1, obj2);
}

merge函数接受两种不同的类型,并使用 JavaScript 的assign函数将它们合并。该函数返回两种类型的交集。如果我们深入研究Object.assign函数的定义,我们会意识到它也在利用泛型的交集。以下是 ES2015 的Object.assign定义文件:

assign<T, U, V>(target: T, source1: U, source2: V): T & U & V;

默认泛型

越来越多地使用泛型,你可能会发现,对于系统中的特定情况,你总是使用相同的类型。它几乎可以不是泛型,而是一个特定类型。在这种情况下,为你的泛型使用默认类型是有趣的。默认泛型类型允许避免必须指定类型。默认泛型也被称为可选类型。

TypeScript 使用等于号之后泛型签名中指定的类型:

interface BaseType<T = string> {
 id: T;
}
let entity1: BaseType;
let entity2: BaseType<string>;
let entity3: BaseType<number>;

声明了三个变量。第一个和第二个完全相同:它们期望一个具有string类型id的对象。最后一个是一个数字。第一个和第二个完全相同的原因是第一个声明依赖于默认类型。默认类型在接口的泛型定义中指定在类型名称T之后,使用等号允许赋值。

在多个默认值的情况下,如果没有后续的可选类型,则只能使用可选类型。以下代码显示了相同的接口,第一个无法编译,因为它在所需类型之前有一个可选泛型类型:

interface User<T = string, U> { // Does not compile
  id: T;
  name: U;
}

interface User<U, T = string> {
  id: T;
  name: U;
}

默认泛型可以有约束,并且它似乎尊重其默认类型的约束。以下代码无法工作,因为默认类型被设置为数字。然而,约束指出结构必须有一个类型为numberid

interface WithId {
 id: number;
}

interface UserWithDefault<T extends WithId = number> { } // Does not compile

然而,如果我们将默认类型更改为User<number>,则可以编译。原因是用户界面有一个类型为Tid字段。默认类型与扩展约束不兼容,该约束要求id为数字类型。这意味着如果没有在默认签名中明确提及User的泛型类型,代码将无法编译:

interface User<T = string> {
 id: T;
}

interface WithId {
 id: number;
}

interface UserWithDefault<T extends WithId = User<number>> { }
// Does not compile because User<string>
interface UserWithDefault<T extends WithId = User { }

当类型不是显式指定或 TypeScript 无法推断类型时,使用默认类型。

泛型可选类型

泛型类型可以在函数或类中是可选的。当可选且泛型时,类型变为一个空对象或未定义:

function shows<T>(p1?: T): void {
 console.log(p1);
}

shows(); // p1 is {} | undefined
shows("123");
shows(123);

为可选类型提供一个默认值将参数从空对象更改为默认类型:

function shows<T = number>(p1?: T): void {
 console.log(p1);
}
shows(); // p1 is number | undefined

联合类型的泛型约束

在泛型定义的extends子句中,使用联合类型有一些空间。虽然不能使用discriminator,但可以与数组进行比较。以下对象允许一个类型和相同类型的数组。您可以使用instanceOf缩小到任何两种类型之一,并操作参数值:

interface ObjectWithAge {
 kind: "ObjectWithAge";
 age: number;
}

function funct2<T extends ObjectWithAge | ObjectWithAge[]>(p: T): T {
 if (p instanceof Array) {
   return p[0];
 }
 return p;
}

尝试使用区分符扩展两个不同的对象目前不起作用。

使用 keyof 限制字符串选择

在 JavaScript 中,字符串的使用无处不在。一种模式是使用方括号动态访问对象的成员:

interface Human {
 name: string;
 birthdate: Date;
 isHappy: boolean;
}

const me: Human = {
 name: "Patrick",
 birthdate: new Date(1912, 0, 1),
 isHappy: true
}

console.log(me["name"]);

代码的问题在于 name 是一个字符串,可以是任何内容。我们可以在括号中设置 firstname,代码就会编译。在运行时,控制台会显示 undefined。为了避免陷入选择不存在成员的陷阱,我们可以使用 keyof,它将返回一个对象所有成员的联合。联合是所有成员名称的 字符串字面量

在使用 keyof 之前,创建一个尝试通过字符串访问属性失败的 function,而不定义索引签名(参见本书中的 索引签名):

function showMe1(obj: Human, field: string): void {
  console.log(obj[field]); // Does not compile
}

然而,使用 keyof 的相同函数在没有索引签名的情况下也能工作。原因是 TypeScript 知道你并没有尝试访问可能不存在的字段。使用方括号访问的目标是访问存在的成员:

function showMe2(obj: Human, field: keyof Human): void {
  console.log(obj[field]);
}

keyof 允许在 keyof 关键字之后以字符串格式指定类型中的唯一字段。在之前的代码示例中,只有字符串 namebirthdateisHappy 可以输入,而不会让编译器显示错误:

showMe2(me, "name"); // Compile
showMe2(me, "NOname"); // Does not compile

限制泛型类型的成员访问

可以在约束中使用泛型与 keyof 来仅指定泛型对象中的字符串格式成员名称。

在以下代码中,我们在第一个参数中传递一个对象,在第二个参数中只接受第一个参数对象的成员名称。约束语法与使用 extends 后跟 keyof 和第一个泛型类型相同。返回类型是所选成员的返回类型,可以通过使用第一个泛型与第二个泛型的索引签名来访问:

function prop<TObject, TMember extends keyof TObject>(
 obj: TObject,
 key: TMember
): TObject[TMember] {
 return obj[key];
}

interface ObjectWithName {
 name: string;
 age: number;
}

const obj1: ObjectWithName = { name: "Patrick", age: 212 };
const result1: string = prop(obj1, "name");
const result2: number = prop(obj1, "age");

语法提供了良好的类型安全性,在指定对象成员方面,这些成员可以是各种潜在类型,同时也提供了关于返回类型的安全性。如果 namestring 更改为一个具有许多成员的丰富对象,那么消耗此函数返回值的代码将在编译时中断。如果名称更改,重构工具也会更改它。然而,如果更改没有使用任何重构工具,编译器将捕获名称不是有效的情况。

以下代码展示了如何使用 keyof 确保函数返回所需成员的名称。函数第一次被调用时返回 name;然而,第二次调用不会编译,因为成员的名称不在泛型类型中:

function nameof<T>(instance: T, key: keyof T): keyof T {
   return key;
}

const name1 = nameof(obj1, "name");
const name2 = nameof(obj1, "nasme"); // Does not compile
console.log(name1); // "name"

使用映射类型减少类型创建

当你开始编写所有对象时,你可能会遇到需要几乎相同类型但有一些细微差异的情况。你可能想要完全相同的属性但所有可读的,当主要类型有几个只读类型时。你可能想要所有字段都是可选的,以便允许部分对象更新,或者你可能想要通过将所有属性设置为只读来密封对象。你可能甚至想要所有属性都是字符串,因为你的表单只处理字符串值,但后来有实际的接口或类型,具有良好的类型。TypeScript 允许从现有类型创建动态类型。这种类型的转换被称为映射类型。映射类型允许减少复制对象以更改类型属性的负担,同时保持定义的结构相同。

TypeScript 自带了许多可以使用的映射类型,无需自己构建映射类型。以下有两个常见的例子:

type Readonly<T> = {
 readonly [P in keyof T]: T[P];
}

type Partial<T> = {
 [P in keyof T]?: T[P];
}

第一个,Readonly,接受一个泛型类型并遍历其所有成员,添加readonly。它还返回带有T[P]的相同类型。第二个,Partial,在名称后添加*?*字符,这意味着每个字段都变为可选:

interface MyEntity {
 readonly id: number;
 name: string;
}

const e1: MyEntity = { id: 1, name: "Name1" };

如果我们想让变量被密封且完全不可编辑,我们可以使用Readonly

const e1: MyEntity = { id: 1, name: "Name1" };
const e2: Readonly<MyEntity> = e1;
e1.name = "I can change";
e2.name = "I cannot change"; // Does not compile

如果你想让某人只能修改你的实体的一部分,然后合并结果,你可以使用Partial

function edit<T>(original: T, obj: Partial<T>): T {
 const returnObject: T = Object.assign({}, original, obj);
 return returnObject;
}

edit(e1, { name: "Super" }); // The returned object is: {id: 1, name: "Super"}
edit(e1, { memberNoExist: "Super" }); // Does not compile

你可以通过使用类型关键字并创建一个带有in操作符的名称来创建自己的映射类型,以遍历成员并定义转换。重要的是要注意,我们不是操作数据,而是操作类型。这意味着如果你正在更改类型,你仍然需要操作对象以获得预期的形状,以满足映射类型。以下有两个自定义类型的例子。第一个为所有成员返回一个字符串。第二个移除了Readonly。你可以看到属性前的减号,这表示 TypeScript 知道成员的修饰符被移除了:

type Stringify<T> = { [P in keyof T]: string; };
type UnReadonly<T> = { -readonly [P in keyof T]: T[P]; };

映射类型可以堆叠以创建一个最终类型,该类型结合了所有映射类型。在以下示例中,我们堆叠了两个映射类型:

const e3: UnReadonly<Stringify<MyEntity>> = e1;

代码是合法的,但不起作用。原因是 TypeScript 推断出e1.id是数字类型,而有人试图将其自动转换为字符串,这是不会发生的。正如提到的,映射类型仅作为转换使用,并要求你有适当的代码。

这里有一个快速且简单的函数可以完成这个任务。不要在生产环境中使用此代码,因为它不涵盖转换为字符串属性(特别是对于objectarray),但它说明了所需的转换:

function castAllFieldToString<T>(obj: T): Stringify<T> {
 let returnValue: any = {};
 for (var property in obj) {
   if (obj.hasOwnProperty(property)) {
     returnValue[property] = obj[property].toString();
   }
 }
 return returnValue as Stringify<T>;
}

const e3: UnReadonly<Stringify<MyEntity>> = castAllFieldToString(e1);
e3.id = "123";

TSX 文件中的泛型类型

TSX 是 JSX(JavaScript XML 扩展语言)的等价物。在很长一段时间里,TypeScript 支持 TSX,但对泛型并不友好。主要原因在于 TSX 和泛型语法共享了尖括号,这导致在 TSX 文件中编译器会错误地解释泛型类型。然而,情况已经改变,TypeScript 区分了何时使用方括号来显式定义泛型组件的类型。在下面的代码片段中,你可以看到 CallGenericComponent 尝试渲染一个泛型组件。返回值使用一个初始的左尖括号来初始化 TSX 组件。接下来的第二个左尖括号是用来定义类型的:

interface MyTsxProps<T> {
 item: T;
}

class CallGenericComponent extends React.Component<{}>{
  public render(): JSX.Element {
    return <MyTsxComponent<string> item={"123"} />
  }
}

class MyTsxComponent<T> extends React.Component<MyTsxProps<T>>{
  // ...
}

使用尖括号可以避免将组件定义为变量并实例化它,这不仅需要多行代码,还需要使用 any 类型进行类型转换。此外,可读性受损,外部人员难以理解其意图。

摘要

在本章中,我们了解了如何通过使用泛型来转换代码。TypeScript 提供了约束来限制可以传递给泛型的内容,我们也看到了如何利用约束来指导用户传递什么内容。我们还看到了使用 keyof 的强大之处,它允许我们动态地从类型中获取成员。我们还看到了如何以泛型方式操作类型,使用映射类型。

第七章:掌握定义类型艺术

在本章中,我们将看到如何从我们未直接工作但导入到 TypeScript 项目中的库中创建类型。主要区别在于,当我们消费项目外部的代码时,我们不会直接使用 TypeScript 代码,而是使用其定义。原因是那些库中提供的是 JavaScript,而不是 TypeScript 代码。我们将看到如何掌握为不提供它们的代码创建定义文件的艺术,使我们能够在强环境中继续工作。

本章涵盖以下内容:

  • 如何使用第三方库定义文件

  • TypeScript 如何生成定义文件

  • 如何手动为 JavaScript 项目添加定义文件

  • 如何将类型合并到现有的定义文件中

  • 如何为 JavaScript 项目创建定义文件

  • 不需要强类型但想使用 JavaScript 库

  • 需要使用另一个模块

  • 如何将定义文件添加到现有模块的扩展中

如何使用第三方库定义文件

当 TypeScript 知道每个变量和函数的类型时,它运行得很好。然而,当使用用 JavaScript 编写的第三方库时,您没有定义文件。TypeScript 很智能,它通过利用标准文档 JSDoc 尽可能多地推断类型,但没有任何东西能比得上用 TypeScript 规则编写的签名。然而,有许多用 JavaScript 编写的有用库没有 TypeScript 的类型。定义文件填补了 JavaScript 和 TypeScript 之间的差距。对于第三方库,想法是使用定义文件。如果原始库是用 JavaScript 编写的,定义文件源可以来自手动编辑;如果用 TypeScript 编写,则可以由 TypeScript 自动生成。

要使用第三方库定义文件,您需要将文件放在我们的项目中。TypeScript 定义文件具有 .d.ts 扩展名。TypeScript 将在 node_modules 文件夹以及我们的项目中搜索定义文件。因为 TypeScript 使用 node_modules,这意味着它可以从 NPM 获取定义文件。TypeScript 拥有最活跃的 GitHub 仓库之一,该仓库有超过 4,200 个由社区支持的定义文件。它们都可以通过 NPM 下的 @types 访问。以下是如何获取 JQuery 定义文件的示例:

npm install @types/jquery --save-dev

TypeScript 日益增长的流行度使得许多库直接将定义文件集成到它们的主 npm 包中。例如,Redux 在主npm包的根目录下有index.d.ts。这意味着您可能已经拥有了定义文件而没有注意到。库将定义文件直接带到 NPM 包中的原因是类型的版本始终与代码同步。这也使使用 JavaScript 并使用能够读取定义文件的代码编辑器的人受益。一些代码编辑器可以利用定义文件提供自动完成功能。

除了node_modules之外,TypeScript 读取tsconfig.json文件中的typestyperoots配置。有关更多详细信息,请参阅第一章,TypeScript 入门

如果缺少第三方定义文件,您可以创建一个;创建一个类型,将主要导出设置为any,这将移除类型安全但能够访问任何内容。还有将新定义合并到现有第三方库中以提高其功能的选项。我们将在本章中介绍这一领域。

TypeScript 如何生成定义文件

即使代码是用 TypeScript 编写的,当需要与世界分享时,也只发布 JavaScript 文件。这样做的原因是让所有人,包括 JavaScript 和 TypeScript 开发者,都能使用您的代码。最好以只提供类型能力的格式发布 TypeScript 的类型,而不是使用完整的 TypeScript 代码。另一方面,TypeScript 可以生成允许浏览器完美解释代码的 JavaScript 文件。TypeScript 生成两种类型的文件,即定义文件和 JavaScript 文件,这为开发者和浏览器打开了兼容性。虽然定义文件可以手动制作,这对于想要提供 TypeScript 支持的 JavaScript 库来说很方便,但自动生成它既快又少出错。也就是说,TypeScript 是生成定义的最佳选择,因为它存在于.ts 文件中。这就是为什么 TypeScript 有一个在编译时生成定义的编译选项,称为declaration,其路径可以通过另一个选项declarationDir来控制。这两个选项已经在第一章,TypeScript 入门中讨论过。以下是允许从 TypeScript 编译生成definition文件的行:

"declaration": true

如何手动为 JavaScript 项目添加定义文件

许多项目是用 JavaScript 编写的,但仍然希望利用 TypeScript 的类型优势。或者,一些 JavaScript 项目生成 TypeScript 的定义文件,以便在代码编辑器中获得良好的支持。最后,一些位于 JavaScript 库主仓库之外的人为每个 TypeScript 用户开发手动定义文件,以便使用该库。

要从一个不属于你的项目中创建一个定义文件,你需要创建一个以你想要添加类型的模块命名的文件夹,并添加一个 index.d.ts 文件。然而,如果你拥有这个库,你可以将 typestyping(它们是同义词)设置为 definition 文件的路径和文件名。在下面的代码示例中,定义文件被设置为 lib 文件夹下的 main.d.ts。如果没有提供 typestyping,定义文件必须在包文件夹的根目录下命名为 index.d.ts

使用 index.d.ts 是最佳实践,因为 TypeScript 在进行模块解析时已经优化了搜索 index.d.ts,以及具有与模块名称相同的文件名(后跟 .d.ts):

{
"name": "your-library",
"main": "./lib/main.js",
"types": "./lib/main.d.ts"
}

library 一样,所有依赖项都必须指定。这次,所有定义文件库都必须在 package.json 中提及。重要的是要注意,我们不是在提及 dev 依赖项中的定义文件,因为我们希望所有类型都由我们的 definition 文件库的消费者下载和安装。

如何将类型合并到现有的定义文件中

类型可以写在不同地方,并合并成 TypeScript 可以依赖的单一定义集。原则是,你可能能够通过自己的定义扩展现有定义。合并能力在你有可以增强的 JavaScript 代码(通过插件或扩展)时非常有用。例如,Redux 库在其仓库和 NPM 包中都有自己的定义文件。名为 Redux-thunk 的库也有自己的定义文件,它为 Redux 添加了一个新的 dispatch 函数签名,覆盖了 redux 中定义的签名。定义文件依赖于合并类型来将其自己的 dispatch 定义添加到 redux 模块中:

declare module "redux" {
   export interface Dispatch<S> {
   <R, E>(asyncAction: ThunkAction<R, S, E>): R;
 }
}

合并类型需要了解 TypeScript 允许的方式。第一条规则是,所有命名空间可以在一个或多个文件中定义多次。这意味着你可以在多个命名空间作用域内定义代码,而 TypeScript 会将其视为同一个命名空间中的代码。

命名空间的内容只有在标记为导出元素时才会共享:

namespace Merge {
 export interface I1 { m1: string; }
}

namespace Merge {
 export interface I2 { m2: string; }
}

这可以写在一个命名空间中:

namespace Merge {
   export interface I1 { m1: string; }
   export interface I2 { m2: string; }
}

同样,接口也可以合并:

interface Mergeable {
 m1: string;
}

interface Mergeable {
 m2: string;
}
const mergeInterface: Mergeable = { m1: "", m2: "" }

然而,type 并不作为一个接口,并且不允许合并。

一个类可以通过具有相同名称的接口来增强其定义。这意味着你可以定义一个与具体类(在 JavaScript 中)具有相同名称的接口,并能够定义一个强类型定义。这也意味着如果需要,你可以在定义中提供类的扩展成员:

export interface Album { m1: string; m2: number; }
export class Album {
public m2: number = 12;
}
const a = new Album();
a.m1; // Not implemented but compile.
a.m2;

可以使用命名空间来定义一个函数变量。在 JavaScript 中,可以通过使用函数名和点符号来将变量赋给一个函数。要定义此函数的类型,需要指定不仅参数名和返回类型,还要指定变量。这可以通过使用函数名定义一个命名空间来实现:

function functionInJavaScript(param: string): string {
  return functionInJavaScript.variableOfFunction + param;
}
namespace functionInJavaScript {
  export let variableOfFunction = "";
}

全局作用域中可以声明一个接口函数:

declare global {
   interface Array<T> {
       toObservable(): Observable<T>;
   }
}

为 JavaScript 项目创建定义文件

当前的开源世界降低了示例的门槛。TypeScript 拥有最活跃的仓库之一,这是所有那些在主仓库中没有定义文件的第三方库的类型。快速查看几个库会显示出在编写定义文件方面的碎片化。这是由于大量不同的库结构。JavaScript 有全局的、模块化的、UMD、插件和全局修改的。

全局结构库的定义文件

全局库的典范是带有流行美元符号的 JQuery。全局库将它们的函数和变量添加到 window 作用域。这可以通过使用window或通过定义一个变量来显式完成。它不使用任何导入、导出或require函数。

要为全局结构库创建一个定义文件,你可以使用许多 TypeScript 关键字来定义一个类型。在函数的情况下,你可以使用declare前缀的function,并像在接口中那样编写函数签名,但不包含函数体:

declare function myGlobalFunction(p1: string): string;

关键字declare的存在是为了表明函数存在于其他地方:

Let var1:string = myGlobalFunction(“test”);

如果你全局作用域中声明的是一个类型而不是函数,你可以使用一个接口。关键字declare被省略了:

interface myGlobalType{
   name: string;
}

全局接口允许声明一个全局类型的变量,无需在类型前添加任何前缀:

Let var1: myGlobalType={name:”test”};

全局接口允许在一系列连贯元素中指定一个类型。它通常代表一个函数作用域:

declare namespace myScope{
  let var1: number;
  class MyClass{
  }
}

let x: number = myScope.var1;
let y: myScope.MyClass = new myScope.MyClass();

命名空间可以包括一个接口来定义一个对象,一个类型来定义特定类型的变量,以及一个function来定义函数:

declare namespace myScope{
 interface MyObject{
    x: number;
 }

 type data = string;
 function myFunction():void{};
}

对象、变量和函数的使用总是使用命名空间名称,因为它通过变量名在全局范围内暴露:

let s: myScope.MyObject = { x: 5 };
let x: myScope.data = “test”;
myScope.myFunction();

在本节中,我们看到了如何定义一个全局库,它可以有一个全局函数或变量,也可以有一个全局变量,它可以包含一个对象、一个函数的原始类型。

模块库的定义文件

库的 definition 文件类似但也有一些不同。如果你需要提供定义文件,建议使用以下规则命名一个 index.d.ts 文件。首先,有一个可选的导出声明,如果库支持 UMD,则需要这个声明。这发生在库导出一个变量时。以下代码示例中暴露的变量是 myScope,整个模块都驻留在其中:

export as namespace myScope;

下一步是将每个函数直接添加到定义文件中。没有必要将函数包含在命名空间中。对于对象也是同样的:

export function myFunction(): void;
export interface MyObject{
 x: number;
}
export let data: string;

函数、接口和变量在真实代码中的使用方式如下:

import {myFunction, MyObject, data} from “myScope”;
myFunction();
x:MyObject = {x:1};
console.log(data);

剩余的功能是在你的模块中有一个对象:

export namespace myProperty{
   export function myFunction2(): void;
}

这里是命名空间的实际使用方式,它像函数、对象和数据一样,有两种格式。第一种是明确调用模块中要检索的元素,第二种是使用星号,它将整个内容定义导入到别名中:

Import {myProperty} from “myScope”;
myProperty.myFunction2();
//or
Import * from my from “myScope”
my.myProperty.myFunction2();

然而,在某些情况下,这不会起作用。这取决于 JavaScript 模块的编写方式。以下代码适用于现代模块创建。它使用带有模块的声明语句。模块名称必须是引号中的库名称。在模块内部,你可以使用 export = 定义你的 CommonJs/Amd 导出,然后跟随着你想要默认导出的内容。在以下代码中,类 MessageFormat 是默认导出。你也可以不进行 CommonJs/Amd 导出,并导出每一个类型。你也可以导出一个包含许多类型的命名空间:

declare module "modulenamehere" {
  type Msg = (params: {}) => string;
  type SrcMessage = string | SrcObject;
  interface SrcObject {
  m1: SrcMessage;
}

class MessageFormat {
  constructor(message: string);
  constructor();
  compile: (messages: SrcMessage, locale?: string) => Msg;
}

export = MessageFormat ; // CommonJs/AMD export syntax
}

// Usage:

import MessageFormat from "modulenamehere";
const mf = new MessageFormat("en");
const thing = mf.compile("blarb");

没有定义文件的 JavaScript 库

如果你需要使用没有定义文件的第三方库,你可以从一个单行声明开始。你需要创建一个具有模块名称的文件,并添加这个单行:

declare module "*";

这不会给你任何智能提示、自动完成,但文件可以在你的 TypeScript 文件中无任何问题地导入。你可以从这种单行方法开始,然后逐渐过渡到一个更详细的定义。

使用定义文件中的另一个模块

你可能需要从你的定义文件中消费另一个模块。原因可能是为了从另一个库中获取类型。为了能够使用另一个库的类型,你可以使用 import * 并从你想要引用的类型中用 as 分配一个别名到你的 definition 文件中:

declare module "react-summernote" {
  import * as React from "react";
  let ReactSummernote: React.ComponentClass<any>;
  export default ReactSummernote;
}

将定义文件添加到现有模块的扩展中

想法是使用 declare 和模块名称来扩展。一个系统可以有多个相同模块的声明,允许添加导出的 typefunctioninterfaceclass。以下代码还展示了另一个模块的使用,该模块被模块的扩展所使用:

import * as extendMe from "moduleToExtend";
import * as other from "anotherModule";

declare module "moduleToExtend" {
export function theNewMethod(x: extendMe.aTypeInsideModuleToExtend): other.anotherTypeFromAnotherModule;
export interface ExistingInterfaceFromModuleToExtend {
newMember: string;
}

export interface NewTypeForModuleToExtend {
size: number;
}
}

摘要

在本章中,我们介绍了如何使用定义文件。我们解释了如何使用定义文件的多方面内容,以及许多细节,以便根据 JavaScript 代码的编写方式来促进定义的创建。

在这本书中,我们总结了开始使用 TypeScript 所必需的一切。这本快速入门指南的目标是为一场美味的餐宴做好准备,你可以用 TypeScript 来准备这场餐宴。书中讨论了如何从基本概念(原始类型)开始编码,到更高级的概念(泛型)。希望你能像我一样,对使用接近 JavaScript 但更强大(在可维护性方面)且更容易阅读的强类型语言感到高兴。感谢类型和 TypeScript,Web 开发变得更加安全、高效和愉快。

posted @ 2025-10-25 10:29  绝不原创的飞龙  阅读(12)  评论(0)    收藏  举报