TypeScript超详细入门教程(上)

 

01 开篇词:Hello~TypeScript

01 开篇词:Hello~TypeScript

更新时间:2019-10-30 13:49:46

 

既然我已经踏上这条道路,那么,任何东西都不应妨碍我沿着这条路走下去。——康德

 

同学你好,我是Lison。很高兴你对TypeScript感兴趣,或许你对TypeScript了解还不多,或许还有很多疑问,比如:

  • 学 TypeScript 是不是就不需要学 JavaScript 了?

  • Vue 用 TypeScript 改写发布 3.0 后是不是不用 TypeScript 不行?

  • TypeScript 靠谱吗?

 

诸如此类疑惑,导致你一直对它犹豫不决,那么本节我将代替 TypeScript 向你做一个自我介绍。

同学你好,我是 TypeScript,如果你觉得我是 JavaScript 的孪生兄弟,或者觉得我是前端圈新扶持起来的太子,那你可能对我是有点误解了。其实我并不是一个新的语言,用大家公认的说法,我是JavaScript的超集,你可以理解为,我是加了一身装备铭文的进化版 JavaScript。JavaScript 有的,我都有,而且做得更好。JavaScript 没有的,我也有,而且我是在很长一段时间内不会被 JavaScript 赶上的。

虽然我作为超集,但是我始终紧跟 ECMAScript 标准,所以 ES6/7/8/9 等新语法标准我都是支持的,而且我还在语言层面上,对一些语法进行拓展。比如新增了枚举(Enum)这种在一些语言中常见的数据类型,对类(Class)实现了一些ES6标准中没有确定的语法标准等等。

如果你是一个追赶技术潮流的开发者,那你应该已经将 ES6/7/8/9 语法用于开发中了。但是要想让具有新特性的代码顺利运行在非现代浏览器,需要借助Babel这种编译工具,将代码转为ES3/5版本。而我,可以完全不用 Babel,就能将你的代码编译为指定版本标准的代码。这一点,我可以说和 JavaScript 打了个平手。

另外我的优势,想必你也略有耳闻了那就是我强大的类型系统。这也是为什么造世主给我起名TypeScript。如果你是一名前端开发者,或者使用过 JavaScript 进行开发,那么你应该知道,JavaScript 是在运行的时候,才能发现一些错误的,比如:

  • 访问了一个对象没有的属性;

  • 调用一个函数却少传了参数;

  • 函数的返回值是个字符串你却把它当数值用了;

这些问题在我这里都不算事。我强大的类型系统可以在你编写代码的时候,就检测出你的这些小粗心。先来简单看下我工作的样子:

interface 定义的叫接口,它定义的是对结构的描述。下面的 info 使用 ES6 的新关键字 const 定义,通过 info: Info 指定 info 要实现 Info 这个结构,那 info 必须要包含 name 和 age 这两个字段。实际代码中却只有 name 字段,所以你可以看到 info 下面被红色波浪线标记了,说明它有问题。当你把鼠标放在 info 上时,VSCode 编辑器会做出如下提示:

如果上面这个小例子中你有很多概念都不了解,没关系,Lison 在后面的章节都会讲到。

配合VSCode这类编辑器,你可以借助编辑器的提示愉快地使用 TypeScript。另外值得一提的是,深受前端开发者喜爱的 VSCode 也是使用 TypeScript 开发的哦。

很多后端开发者,在做了很久的后端开发,习惯了 C++、Java 这些语言后,可能对我会有很多误解。就拿一个函数重载来说吧,在别的这些语言里,你可以定义多个同名函数,然后不同点在于参数个数、参数类型和函数体等,你可以给同一个函数传入不同参数,编译器就会知道你要调用的是哪个函数体;而我,也是有函数重载的概念的,只不过,我的重载是为了帮助编译器知道,你给同一个函数传入不同参数,返回值是什么情况;在 JavaScript 中,我们如果有一个函数,要根据传入参数不同,执行不同的逻辑,是需要自己在函数体内自行判断的。比如下面这个JavaScript 书写的例子:

const getResult = input => {
    if (typeof input === 'string') return input.length
    else if (typeof input === 'number') return input.toString()
    else return input
}

这个例子很简单。如果输入的值是字符串类型,返回这个字符串的长度;如果是数值类型,返回这个数值变成字符串的结果;如果都不是,原样返回。可以看到,输入不同类型的值,返回的结果类型是不一样的。所以如果你要使用这个函数的返回值,就可能一不小心用错,比如输入123,应该返回字符串 ‘123’。如果你在结果上调用 toFixed 方法,如 getResult(123).toFixed(),运行后就会报错,因为字符串是没有这个方法的。如果你使用我来书写,结果就不同了,我会在你写代码的时候就告诉你。来看怎么使用我来书写上面的例子:

function getResult (input: string): number
function getResult (input: number): string
function getResult <T>(input: T): T
function getResult (input: any): any {
  if (typeof input === 'string') return input.length
  else if (typeof input === 'number') return input.toString()
  else return input
}

前三行组成了函数重载,第四行开始是实际函数体,之后你再调用 getResult 来看下效果:
这里输入123结果应该是字符串’123’,结果访问 toFixed 方法,字符串是没有这个方法的。

这里输入字符串’abc’,返回应该是他的长度数值3,结果访问它的length属性,数值是没有length属性的。

这里你传入一个对象,既不是字符串也不是数值,所以原样返回这个对象,编译器就知道你的res是对象 { a: ‘a’, b: ‘b’ } 啦。所以当你输入res然后输入 . 后,VSCode 就会给你列出有哪些属性可以访问。

是不是和你理解的函数重载有点不一样?所以一定要注意哦,不要用学习其他语言的思维来认识我哦,否则你会钻牛角尖的。上面例子的语法你可以不用在意,因为 Lison 都会详详细细地给你讲哒。

对了,另外还有一个我的好搭档,TSLint,也是追求极致的你不可或缺的。它和 ESLint 相似,都是能够对你的代码起到约束和提示作用,特别是团队协作的项目中,使用它可以让你们多个开发者都能够遵循相同的代码规范——大到各种语法,小到标点空格。搭配使用 VSCode 和 TSLint,再加上我强大的类型系统,写代码简直不要太嗨~

好了,向你介绍得差不多了,相信你对我已经有了一个大致的了解。下面让 Lison 向你客观地介绍下,我的发展趋势以及你为什么要与我为伴。

相信你在听完 TypeScript 的自我介绍之后,它的亮点你已经了解一二了。或许你还有些顾虑,毕竟学习 TypeScript 是需要时间的,你可能会担心 TypeScript 像 CoffeeScript 一样,随着 ES标准 的不断更新,渐渐退出大家的视线。下面让我们来看下 TypeScript 的发展趋势,来打消你的顾虑,同时让你对它有进一步的了解。

我们都知道 TypeScript 最主要的亮点是它的类型系统,这使得在编写代码的时候就能够检测到一些错误。而 JavaScript 是一门动态脚本语言,它不需要编译成二进制代码运行。Node 服务端代码也不需编译即可在服务器起一个服务,你甚至可以直接在服务器修改你的服务代码然后重启就可以,不需要编译等操作。这一切特点使得 JavaScript 的所有调试都需要在运行时才能进行,在编写代码的时候很多问题是无法提前知晓的,而且就JavaScript目前的使用场景来看,它在至少很长一段时间内会保持这样的特点。

而 TypeScript 和 JavaScript 不同的就是,它可以在你编写代码的时候,就对一些错误进行提示,还能在你使用某个数据的时候,为你列出这个数据可以访问的属性和方法。在 TypeScript 的自我介绍中我们已经看过几个简单的例子,想必你也知道它实现这些的效果了。当我们的项目较为庞大,需要由多人合作开发时,多人协作是需要沟通成本和 review 成本的。一些接口的定义,一些方法的使用,都可能因为个人习惯或沟通不畅导致逻辑实现的差异。而如果引入TypeScript,则会对一些实现进行强校验。如果不按接口实现,编译就没法通过,如果对代码质量要求较高,可以将严格检查全部打开,效果更好。

那么哪些项目适合用 TypeScript 开发呢,我总结了几类:

  • 需要多人合作开发的项目

  • 开源项目,尤其是工具函数或组件库

  • 对代码质量有很高要求的项目

来看几个广为人知的使用 TypeScript 开发的经典项目:

  • VSCode:开源的高质量代码编辑器VSCode使用TypeScript开发,所以它天生就支持 TypeScript;

  • Angular & React & Vue3.0:现在三足鼎立的三个前端框架,Angular 和 React 已经使用 TypeScript编写,而在我编写专栏的同时,Vue3.0 将使用 TypeScript 进行重构,届时三个前端框架都使用TypeScript编写,如果使用TypeScript开发将会得到很好的类型支持。也可以看出,TypeScript 已经被广为接受。当然了,你依然可以使用JavaScript来开发前端项目,但是相信随着 Vue3.0 发布,TypeScript将会被越来越多的开发者所接受;

  • Ant Design:使用 React 开发项目的开发者大多应该都知道蚂蚁金服开源UI组件库Ant Design,同样使用TypeScript进行编写。保证了代码质量的同时,也能很好地支持开发者使用TypeScript进行React项目的开发。如果你使用 Vue 进行开发,Ant Design 也提供了Vue 版的组件库,风格和功能和 React 版的保持一致,共享单元测试和设计资源,对TypeScript的支持也一样很好。

TypeScript 在实现新特性的同时,时刻保持对ES标准的对齐。一些ECMAScript标准没有确定的内容,在 TypeScript 中已经率先支持了。所以在语法标准方面,可以说TypeScript是略微领先的,比如类的私有属性和方法。ES6标准对类的相关概念的定义中,并没有私有属性的概念,如果想实现私有属性,需要使用一些方法hack(可以参考阮一峰的《ECMAScript 6 入门》- 私有方法和私有属性);但是TypeScript是支持私有属性的,可以直接使用 private 指定一个私有属性。虽然ECMAScript新的提案提供了定义私有属性的方式,就是使用 # 来指定一个属性是私有的,但是到目前为止现在还没有编译器支持这种语法。

以上便是对 TypeScript 的大致介绍,接下来我们来看下本小册有哪些内容。

本小册共7大章节,7个章节的内容主要为:

  1. 入门准备:讲解学习 TypeScript 和使用 TypeScript 进行开发的一些方法和技巧,是授你以鱼之前的授你以渔,虽然后面章节会学习 TypeScript 的所有语法,但是掌握自学TypeScript的方法技巧,可以帮助你更好更快地学习 TypeScript,也方便你遇到问题时能够快速找到解决方案。

  2. 基础部分:这一章都是一些较为基础的知识,只要你有JavaScript的基础就能上手,学习起来不会有太大压力;学习完本章后,你就可以自己使用 TypeScript 写一些基本的日常开发中使用的逻辑了。

  3. 进阶部分:这一章你要做好心理准备了。作为进阶知识,不仅内容多一些,而且有些知识较为抽象,不好理解,需要你紧跟着 Lison 多练习多思考。这一章的知识有一些在平常的业务开发中很少用到,但是你也不可以掉以轻心,以免以后需要用到了,都不知道还有这高级内容。

  4. 知识整合:这一章是对前面学习的基础和进阶部分的知识的整合。学习这一章,需要前面章节的知识作为铺点,所以一定要把前面章节的知识掌握好哦。

  5. 项目配置及书写声明文件:这一章会详细讲解项目的配置项,也就是对 tsconfig.json 里的配置逐条讲解它的作用。之所以放到后面讲,是因为我们前面学习不需要用到这么多配置,但是学习所有配置,可以帮助你在开发自己项目时满足自己的开发需求。书写声明文件需要用到前面的语法知识,学会书写声明文件,我们就可以在使用了一些冷门的没有声明文件的JS库时,自行为它们书写声明文件,以便我们开发使用。

  6. 项目实战:这一章是实战部分,通过使用 TypeScript+Vue 开发一个简单后台。我会带着你从零创建一个项目,并实现目录中列出的功能,帮助你将学到的知识在实际开发中进行运用。即是对前面知识的复习,也是对理论知识到实践的转化,做完这个项目,相信你会对TypeScript项目开发有一个新的认识,再去独立开发项目,会轻松很多。

  7. 写在最后:这一章是一个总结。相信学到这一章的时候,你已经对 TypeScript 有了整体认知了。我将会在本章分享一些我的开发经验,帮助你在项目开发中少走弯路。

好了,在听完 TypeScript 的自我介绍和发展趋势的了解之后,让我们一起愉快地进入TypeScript 的学习中去吧。

 

 

02 TypeScript应该怎么学

02 TypeScript应该怎么学

更新时间:2019-11-26 09:50:53

 

理想必须要人们去实现它,它不但需要决心和勇敢而且需要知识。——吴玉章

 

如果你看过了本专栏的大纲,那你应该会有一种,哇,官方文档里列出的知识基本都讲了,这个专栏太细了的感觉。这一个小节我会教给大家如何去自学TypeScript。虽然你在学习本专栏的时候,Lison会手把手的带着你学习TypeScript的语法和实战。但我还是想给你讲讲如何自学TypeScript,在授你以鱼之前也会授你以渔的,这样TypeScript即使更新了,你也能毫无压力地迎接它的新特性。好,接下来让我们开始吧。

 

1.2.1 学会看文档

英文官方文档始终是及时更新的。但即便是官方的文档,有一些更新在更新日志里写了,而新手指南里却没有及时同步更新,所以有时看指南也会遇到困惑,就是文档里写的和你实际验证的效果不一样。遇到这种问题,首先确定你使用的TypeScript版本,然后去更新日志里根据不同版本找对这部分知识的更新记录。如果找到了,看下这是在哪个版本做的升级;如果你不放心,可以把TypeScript版本降到这个版本之前的一个版本,再验证一下。

TypeScript 是有一个中文文档的,但是这个文档只是对英文文档的翻译。官方文档中的小疏漏,这个文档也没有做校验,而且更新是有点滞后的。在写本专栏的时候,TypeScript最新发布的版本为3.4,但是中文文档还是在3.1。所以想了解TypeScript的最新动态,还是要看英文官方文档的。不过我们还是要感谢提供中文文档的译者,这对于英文不是很好的开发者帮助还是很大的。

1.2.2 学会看报错

我们在前面的例子中展示了 TypeScript 在编写代码的时候如何对错误进行提示。后面我们讲到项目搭建的时候,会使用 TSLint 对代码风格进行规范校验,根据 TSLint 配置不同,提示效果也不同。如果我们配置当书写的代码不符合规范,使用 error 级别来提示时,会和 TypeScript 编译报错一样,在问题代码下面用红色波浪线标出,鼠标放上去会有错误提示。所有如果我们使用了TSLint,遇到报错的时候,首先要区分是 TSLint 报错还是 TS 报错,来看下如何区分:
上面这个报错可以从红色方框中看到,标识了 tslint,说明它是TSLint的报错。后面括号里标的是导致这条报错的规则名,规则可以在 tslint.json 文件里配置。关于 TSLint的使用,我们会在搭建开发环境一节讲解。示例中这条报错是因为 no-console 这个规则,也就是要求代码中不能有 console 语句,但是我们在开发时使用 console 来进行调试是很常见的,所以你可以通过配置 TSLint 关闭这条规则,这样就不会报错了。但我们应该遵守规范,当我们决定引入 TSLint 的时候,就说明这个项目对代码质量有更高的要求,我们不应该在书写代码遇到TSLint报错就修改规则,而是应该根据规则去修改代码。
上面这个报错可以从红色方框中看到,标识了 ts,说明它是 TypeScript 编译器报的错误。在我们书写代码的时候,通过强类型系统,编译器可以在这个阶段就检测到我们的一些错误。后面括号里跟着的 2322 是错误代码,所有的错误代码你可以在文档的错误信息列表中查看。不过你一般并不需要去看文档,因为这里都会给你标出这个错误码对应的错误提示,而且这个错误信息根据你的编辑器语言可以提示中文错误信息。很明显这个错误是因为我们给 name 指定了类型为 string字符串 类型,而赋给它的值是123数值类型。

上面两种是在编写代码的时候就会遇到的错误提示。还有一种就是和 JavaScript 一样的,在运行时的报错,这种错误需要在浏览器控制台查看。如果你调试的是 node 服务端项目,那你要在终端查看。来看这个例子:

当我在代码中打印一个没有定义的变量时,在书写代码的时候会做提示,且当程序运行起来时,在浏览器控制台也可以看到报错。你可以打开浏览器的开发者工具(Windows系统按F12,Mac系统按control+option+i),在 Console 栏看到错误提示:
红色语句即错误信息。下面红色at后面有个文件路径main.ts,蓝色框中圈出的也是个文件路径,表示这个错误出现在哪个文件。这里是出现在main.ts中,问号后面的cd49:12表示错误代码在12行,点击这个路径即可跳到一个该文件的浏览窗口:

在这里我们就能直接看到我们的错误代码被红色波浪线标记了,这样你修改起错误来就很明确知道是哪里出错了。

 

1.2.3 学会看声明文件

声明文件我们会在后面讲。我们知道原来没有 TypeScript 的时候,有很多的 JS 插件和 JS 库,如果使用 TypeScript 进行开发再使用这些 JS 编写的插件和库,就得不到类型提示等特性的支持了,所以 TypeScript 支持为 JS 库添加声明文件,以此来提供声明信息。我们使用 TypeScript 编写的库和插件编译后也是 JS 文件,所以在编译的时候可以选择生成声明文件,这样再发布,使用者就依然能得到 TypeScript 特性支持。一些 JS 库的作者已经使用 TypeScript 进行了重写,有些则是提供了声明文件,一些作者没有提供声明文件的,大部分库都有社区的人为他们补充了声明文件,如果使用了自身没有提供声明文件的库时,可以使用npm install @types/{模块名}来安装,或者运用我们后面讲到的知识自行为他们补充。

看这些库的声明文件能够帮你提高对 TypeScript 的了解程度。因为可能你在实际开发中所接触的场景不是很复杂,运用到的 TypeScript 语法点也不是很全面,所以就会导致经常用的你很熟悉,不经常用的慢慢就忘掉了,甚至有的自始至终你都没有使用过。很多知识你只看理论知识,或者看简单的例子,是没法真正理解并深刻记忆的,只有在实际场景中去使用一下,才能加深理解。所以我们可以从这些库的声明文件入手,还有就是从 TypeScript 内置的 lib 声明文件入手。

安装好 TypeScript 后,我们可以在 node_modules 文件夹下找到 typescript 文件夹,里面有个 lib 文件夹,lib 文件夹根目录下有很多以 lib. 开头的 .d.ts 文件。这些文件,就是我们在开发时如果需要用到相关内容,需要在 tsconfig.json 文件里配置引入的库的声明文件,这个配置我们后面会讲到。先简单举个例子,比如我们要使用 DOM 操作相关的语法,比如我们获取了一个 button 按钮的节点,那么我们就可以指定它的类型为 HTMLButtonElement,那么我们再访问这个节点的属性的时候,编辑器就会给你列出 button 节点拥有的所有属性方法了;如果我们要用到这个类型接口,那我们就需要引入 lib.dom.d.ts 也就是dom这个 lib。这里如果你对一些提到的概念不明白,你可以先忽略,因为后面都会讲到。这里我要告诉你的就是,你应该学着看这些声明文件,看看它们对于一些内容的声明是如何定义的,能够帮你见识到各种语法的运用。

1.2.4 学会搜问题和提问

实际开发中,有时候你难免会遇到一些文档里没有提到的各种各样的奇怪问题。解决问题的途径有很多,请教有经验的人是最简单的啦,但前提是你身边有个随叫随到的大神,可这样的人一般很少有,所以还是看看我推荐给你的解决问题的途径吧:

途径1:百度 or Google

一般来说大众的问题都能在百度找到,但是开发问题 Google 能够帮你找到一些高质量的国外答疑帖,所以这两个搜索引擎你都可以试试,这个途径是你遇到问题之后的首选。

途径2:看issue

TypeScript 的问答确实要比很多框架或者基础知识的少很多。如果搜索引擎找不到,你可以到 github 上 TypeScript 的官方仓库,在issues里可以通过问题关键字搜索,看看有没有人反馈过这个问题。这里要注意,搜索的是关键字,而不是把你的报错信息完整输进去,这样基本很难搜到。你应该挑选出错误信息中比较具有代表性的单词进行搜索,因为这和搜索引擎不一样,issues 提供的搜索还不是很强大。

途径3:去提问

如果上面两个途径都没找到,你只能自行提问了,这也是一个造福后人的方法。比较受欢迎的提问网站:国内你可以试试 segmentFault,国外可以试试stackOverflow,还有就是 TypeScript的issues 了。但要注意如果在 stackOverflow 和 issues 中提问,最好最好用英文。

1.2.5 看优秀项目源码

这个学习方法是比较高阶的了,看一些优秀的开源项目源码可以为你提供思路。你还可以借鉴到同一个逻辑不同人的实现方式。源码去哪里找呢,当然首选是 Github 了,进入 Github 后,你可以在顶部的搜索栏搜索你想要找的项目关键字,比如你想找个Todo应用的项目源码,那就搜"todo"。然后在语言栏选择 TypeScript,这样就会筛选出使用 TypeScript 编写的项目:


最后选择 star 较多的项目,说明这个项目受到了更多人的认可:
好了,以上就是自学 TypeScript 的一些方法途径。当然了,我们的 TypeScript 知识还是会由我带着大家一起学习哒,所以只要跟紧了别掉队,不怕你学不会。

 

03 VSCode揭秘和搭建开发环境

03 VSCode揭秘和搭建开发环境

更新时间:2019-11-26 09:50:59

 

宝剑锋从磨砺出,梅花香自苦寒来。——佚名

 

这节课我们要做的就是在砍柴之前先磨刀,学习如何借助VSCode愉快高效地开发TypeScript项目,我们来一步一步让VSCode对TypeScript的支持更强大。如果你已经习惯了使用别的编辑器,那你也可以自行搜索下,本节课提到的内容在你使用的编辑器是否有对应的替代品。

 

1.3.1 安装和基本配置

如果你还没有使用过VSCode,当然先要去官网下载了,下载安装我就不多说了,安装好之后,我们先来配置几个基本的插件。

(1)汉化

如果你英语不是很好,配置中文版界面是很有必要的,安装个插件就可以了。打开VSCode之后在编辑器左侧找到这个拓展按钮,点击,然后在搜索框内搜索关键字"Chinese",这里图中第一个插件就是。直接点击install安装,安装完成后重启VSCode即可。

(2)编辑器配置

有一些编辑器相关配置,需要在项目根目录下创建一个.vscode文件夹,然后在这个文件夹创建一个settings.json文件,编辑器的配置都放在这里,并且你还需要安装一个插件EditorConfig for VS Code这样配置才会生效。配置文件里我们来看几个简单而且使用的配置:

{
    "tslint.configFile": "./tslint.json",
    "tslint.autoFixOnSave": true,
    "editor.formatOnSave": true
}

tslint.configFile用来指定tslint.json文件的路径,注意这里是相对根目录的;

tslint.autoFixOnSave设置为true则每次保存的时候编辑器会自动根据我们的tslint配置对不符合规范的代码进行自动修改;

tslint.formatOnSave设为true则编辑器会对格式在保存的时候进行整理。

(3)TypeScript相关插件

TSLint(deprecated)是一个通过tslint.json配置在你写TypeScript代码时,对你的代码风格进行检查和提示的插件。关于TSLint的配置,我们会在后面讲解如何配置,它的错误提示效果在我们之前的例子已经展示过了。

TSLint Vue加强了对Vue中的TypeScript语法语句进行检查的能力。如果你使用TypeScript开发Vue项目,而且要使用TSLint对代码质量进行把控,那你应该需要这个插件。

(4)框架相关

如果你使用Vue进行项目开发,那Vue相关的插件也是需要的,比如Vue 2 Snippets。

Vetur插件是Vue的开发辅助工具,安装它之后会得到代码高亮、输入辅助等功能。

(5)提升开发体验

Auto Close Tag插件会自动帮你补充HTML闭合标签,比如你输完<button>的后面的尖括号后,插件会自动帮你补充</button>

Auto Rename Tag插件会在你修改HTML标签名的时候,自动帮你把它对应的闭标签同时修改掉;

Bracket Pair Colorizer插件会将你的括号一对一对地用颜色进行区分,这样你就不会被多层嵌套的括号搞晕了,来看看它的样子:

Guides插件能够帮你在代码缩进的地方用竖线展示出索引对应的位置,而且点击代码,它还会将统一代码块范围的代码用统一颜色竖线标出,如图:

1.3.2 常用功能

(1)终端

在VSCode中有终端窗口,点击菜单栏的【查看】-【终端】,也可以使用快捷键 ”control+`“ 打开。这样可以直接在编辑器运行启动命令,启动项目,边写代码边看报错。

(2)用户代码片段

一些经常用到的重复的代码片段,可以使用用户代码片段配置,这样每次要输入这段代码就不用一行一行敲了,直接输入几个标示性字符即可。在VSCode左下角有个设置按钮,点击之后选择【用户代码片段】,在弹出的下拉列表中可以选择【新建全局代码片段文件】,这样创建的代码片段是任何项目都可用的;可以选择【新建"项目名"文件夹的代码片段文件】,这样创建的代码片段只在当前项目可用。创建代码片段文件后它是一个类似于json的文件,文件有这样一个示例:

{
    // Place your global snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and 
    // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope 
    // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is 
    // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: 
    // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. 
    // Placeholders with the same ids are connected.
    // Example:
    // "Print to console": {
    // 	"scope": "javascript,typescript",
    // 	"prefix": "log",
    // 	"body": [
    // 		"console.log('$1');",
    // 		"$2"
    // 	],
    // 	"description": "Log output to console"
    // }
}

我们来看一下其中的几个关键参数:

  • Print to console是要显示的提示文字

  • scope是代码片段作用的语言类型

  • prefix是你输入这个名字之后,就会出现这个代码片段的选项回车即可选中插入

  • body就是你的代码片段实体

  • $1是输入这个片段后光标放置的位置,这个$1不是内容,而是一个占位

  • description是描述。如果你要输入的就是字符串lt;$

好了,暂时VSCode的相关介绍就是这么多,剩下的一些配置tslint等工作,我们会在搭建开发环境和后面的开发中讲到。

1.3.3 搭建开发环境

接下来我们开始从零搭建一个开发环境,也就是一个基础前端项目。后面课程中讲到的语法知识,你都可以在这个项目中去尝试,接下来我们就一步一步来搭建我们的开发环境啦。

在开始之前,你要确定你的电脑有node的环境,如果你没有安装过node,先去Node.js下载地址下载对应你系统的node.js安装包,下载下来进行安装。我在专栏中使用的是v10.15.3版本,你可以尝试最新稳定版本。如果发现启动项目遇到问题,可能是一些安装的依赖不兼容新版本,那你可以安装和我一样的版本。

node安装好之后,可以在命令行运行node -v来查看node的版本号。如果正确打印出版本号说明安装成功。npm是node自带的包管理工具,会在安装node的时候自动进行安装,可以使用npm -v来查看npm的版本,检验是否安装成功。我们会使用npm来安装我们所需要的模块和依赖,如果你想全局安装一个tslint模块,可以这样进行安装:

npm install -g tslint

如果这个模块要作为项目依赖安装,去掉-g参数即可。更多关于node的知识,你可以参考node官方文档或node中文文档,更多关于npm的使用方法,可以参考npm官方文档或npm中文文档。

(1)初始化项目

新建一个文件夹“client-side”,作为项目根目录,进入这个文件夹:

mkdir client-side
cd client-side

我们先使用 npm 初始化这个项目:

# 使用npm默认package.json配置
npm init -y
# 或者使用交互式自行配置,遇到选项如果直接敲回车即使用括号内的值
npm init
package name: (client-side) # 可敲回车即使用client-side这个名字,也可输入其他项目名
version: (1.0.0) # 版本号,默认1.0.0
description: # 项目描述,默认为空
entry point: (index.js) # 入口文件,我们这里改为./src/index.ts
test command: # 测试指令,默认为空
git repository: # git仓库地址,默认为空
keywords: # 项目关键词,多个关键词用逗号隔开,我们这里写typescript,client,lison
author: # 项目作者,这里写lison<lison16new@163>
license: (ISC) # 项目使用的协议,默认是ISC,我这里使用MIT协议
# 最后会列出所有配置的项以及值,如果没问题,敲回车即可。

这时我们看到了在根目录下已经创建了一个 package.json 文件,接下来我们创建几个文件夹:

  • src:用来存放项目的开发资源,在 src 下创建如下文件夹:

    • utils:和业务相关的可复用方法

    • tools:和业务无关的纯工具函数

    • assets:图片字体等静态资源

    • api:可复用的接口请求方法

    • config:配置文件

  • typings:模块声明文件

  • build:webpack 构建配置

接下来我们在全局安装typescript,全局安装后,你就可以在任意文件夹使用tsc命令:

npm install typescript -g

如果全局安装失败,多数都是权限问题,要以管理员权限运行。

安装成功后我们进入项目根目录,使用typescript进行初始化:

tsc --init

注意:运行的指令是tsc,不是typescript。

这时你会发现在项目根目录多了一个 tsconfig.json 文件,里面有很多内容。而且你可能会奇怪,json 文件里怎么可以使用///**/注释,这个是 TS 在 1.8 版本支持的,我们后面课程讲重要更新的时候会讲到。

tsconfig.json 里默认有 4 项没有注释的配置,有一个需要提前讲下,就是"lib"这个配置项,他是一个数组,他用来配置需要引入的声明库文件,我们后面会用到ES6语法,和DOM相关内容,所以我们需要引入两个声明库文件,需要在这个数组中添加"es6"和"dom",也就是修改数组为[“dom”, “es6”],其他暂时不用修改,接着往下进行。

然后我们还需要在项目里安装一下typescript,因为我们要搭配使用webpack进行编译和本地开发,不是使用tsc指令,所以要在项目安装一下:

npm install typescript

(2)配置TSLint

接下来我们接入TSLint,如果你对代码的风格统一有要求,就需要用到TSLint了,另外TSLint会给你在很多地方起到提示作用,所以还是建议加入的。接下来我们来接入它。

首先需要在全局安装TSLint,记着要用管理员身份运行:

npm install tslint -g

然后在我们的项目根目录下,使用TSLint初始化我们的配置文件:

tslint -i

运行结束之后,你会发现项目根目录下多了一个tslint.json文件,这个就是TSLint的配置文件了,它会根据这个文件对我们的代码进行检查,生成的tslint.json文件有下面几个字段:

{
  "defaultSeverity": "error",
  "extends": [
    "tslint:recommended"
  ],
  "jsRules": {},
  "rules": {},
  "rulesDirectory": []
}
  • defaultSeverity是提醒级别,如果为error则会报错,如果为warning则会警告,如果设为off则关闭,那TSLint就关闭了;

  • extends可指定继承指定的预设配置规则;

  • jsRules用来配置对.js.jsx文件的校验,配置规则的方法和下面的rules一样;

  • rules是重点了,我们要让TSLint根据怎样的规则来检查代码,都是在这个里面配置,比如当我们不允许代码中使用eval方法时,就要在这里配置"no-eval": true

  • rulesDirectory可以指定规则配置文件,这里指定相对路径。

以上就是我们初始化的时候TSLint生成的tslint.json文件初始字段,如果你发现你生成的文件和这里看到的不一样,可能是TSLint版本升级导致的,你可以参照TSLint配置说明了解他们的用途。如果你想要查看某条规则的配置及详情,可以参照TSLint规则说明。

(3)配置webpack

接下来我们要搭配使用 webpack 进行项目的开发和打包,先来安装 webpack、webpack-cli 和 webpack-dev-server:

npm install webpack webpack-cli webpack-dev-server -D

我们将它们安装在项目中,并且作为开发依赖(-D)安装。接下来添加一个 webpack 配置文件,放在 build 文件夹下,我们给这个文件起名 webpack.config.js,然后在 package.json 里指定启动命令:

{
  "scripts": {
    "start": "cross-env NODE_ENV=development webpack-dev-server --mode=development --config build/webpack.config.js"
  }
}

这里我们用到一个插件"cross-env",并且后面跟着一个参数 NODEENV=development,这个用来在 webpack.config.js 里通过 process.env.NODEENV 来获取当前是开发还是生产环境,这个插件要安装:

npm install cross-env

紧接着我们要在 webpack.config.js 中书写配置:

const HtmlWebpackPlugin = require("html-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");

module.exports = {
  // 指定入口文件
  // 这里我们在src文件夹下创建一个index.ts
  entry: "./src/index.ts",
  // 指定输出文件名
  output: {
    filename: "main.js"
  },
  resolve: {
    // 自动解析一下拓展,当我们要引入src/index.ts的时候,只需要写src/index即可
    // 后面我们讲TS模块解析的时候,写src也可以
    extensions: [".tsx", ".ts", ".js"]
  },
  module: {
    // 配置以.ts/.tsx结尾的文件都用ts-loader解析
    // 这里我们用到ts-loader,所以要安装一下
    // npm install ts-loader -D
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader",
        exclude: /nodemodules/
      }
    ]
  },
  // 指定编译后是否生成source-map,这里判断如果是生产打包环境则不生产source-map
  devtool: process.env.NODEENV === "production" ? false : "inline-source-map",
  // 这里使用webpack-dev-server,进行本地开发调试
  devServer: {
    contentBase: "./dist",
    stats: "errors-only",
    compress: false,
    host: "localhost",
    port: 8089
  },
  // 这里用到两个插件,所以首先我们要记着安装
  // npm install html-webpack-plugin clean-webpack-plugin -D
  plugins: [
    // 这里在编译之前先删除dist文件夹
    new CleanWebpackPlugin({
      cleanOnceBeforeBuildPatterns: ["./dist"]
    }),
    // 这里我们指定编译需要用模板,模板文件是./src/template/index.html,所以接下来我们要创建一个index.html文件
    new HtmlWebpackPlugin({
      template: "./src/template/index.html"
    })
  ]
};

这里我们用到了两个webpack插件,第一个clean-webpack-plugin插件用于删除某个文件夹,我们编译项目的时候需要重新清掉上次打包生成的dist文件夹,然后进行重新编译,所以需要用到这个插件将上次打包的dist文件夹清掉。
第二个html-webpack-plugin插件用于指定编译的模板,这里我们指定模板为"./src/template/index.html"文件,打包时会根据此html文件生成页面入口文件。

接下来我们创建这个 index.html 模板:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>TS-Learning</title>
  </head>

  <body></body>
</html>

现在我们运行如下命令来启动本地服务:

npm run start

我们看到启动成功了,接下来我们在 index.ts 文件里写一点逻辑:

// index.ts
let a: number = 123;

const h1 = document.createElement("h1");
h1.innerHTML = "Hello, I am Lison";
document.body.appendChild(h1);

当我们保存代码的时候,开发服务器重新编译了代码,并且我们的浏览器也更新了。

我们再来配置一下打包命令,在 package.json 的 scripts 里增加 build 指令:

{
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "cross-env NODEENV=development webpack-dev-server --mode=development --config ./build/webpack.config.js",
    "build": "cross-env NODEENV=production webpack --mode=production --config ./build/webpack.config.js"
  }
}

同样通过cross-env NODE_ENV=production传入参数。现在我们运行如下命令即可执行打包:

npm run build

现在我们前端项目的搭建就大功告成了,我们后面的课程都会在这个基础上进行示例的演示。大家最好都自己操作一遍,把开发环境的搭建流程走一下,如果中间遇到了报错仔细看一下报错信息。下节课开始我们就正式的步入TypeScript的学习中了,我们下节课见。

 

04 八个JS中你见过的类型

04 八个JS中你见过的类型

更新时间:2019-07-01 14:16:50

 

生活永远不像我们想像的那样好,但也不会像我们想像的那样糟。 ——莫泊桑

 

这小节你学习起来会很轻松,这是你正式接触 TypeScript 语法的第一节课,是最最基础的语法单元。这节课我们将学习在 JavaScript 中现有的八个数据类型,当然这并不是 JavaScript 中的所有数据类型,而是现在版本的 TypeScript 支持的基本类型,在学习基础类型之前,我们先来看下如何为一个变量指定类型:

为一个变量指定类型的语法是使用"变量: 类型"的形式,如下:

let num: number = 123

如果你没有为这个变量指定类型,编译器会自动根据你赋给这个变量的值来推断这个变量的类型:

let num = 123
num = 'abc' // error 不能将类型“"123"”分配给类型“number”

当我们给num赋值为123但没有指定类型时,编译器推断出了num的类型为number数值类型,所以当给num再赋值为字符串"abc"时,就会报错。

这里还有一点要注意,就是numberNumber的区别:TS中指定类型的时候要用number,这个是TypeScript的类型关键字。而Number为JavaScript的原生构造函数,用它来创建数值类型的值,它俩是不一样的。包括你后面见到的stringboolean等都是TypeScript的类型关键字,不是JavaScript语法,这点要区分开。接下来我们来看本节课的重点:八个JS中你见过的类型

2.1.1 布尔类型

类型为布尔类型的变量的值只能是 true 或 false,如下:

let bool: boolean = false;
bool = true;
bool = 123; // error 不能将类型"123"分配给类型"boolean"

当然了,赋给 bool 的值也可以是一个计算之后结果是布尔值的表达式,比如:

let bool: boolean = !!0
console.log(bool) // false

2.1.2 数值类型

TypeScript 和 JavaScript 一样,所有数字都是浮点数,所以只有一个number类型,而没有int或者float类型。而且 TypeScript 还支持 ES6 中新增的二进制和八进制数字字面量,所以 TypeScript 中共支持二、八、十和十六四种进制的数值。

let num: number;
num = 123;
num = "123"; // error 不能将类型"123"分配给类型"number"
num = 0b1111011; //  二进制的123
num = 0o173; // 八进制的123
num = 0x7b; // 十六进制的123

2.1.3 字符串

字符串类型中你可以使用单引号和双引号包裹内容,但是可能你使用的 tslint 规则会对引号进行检测,使用单引号还是双引号可以在 tslint 规则里配置。你还可以使用 ES6 语法——模板字符串,拼接变量和字符串更为方便。

let str: string = "Lison";
str = "Li";
const first = "Lison";
const last = "Li";
str = ${first} ${last};
console.log(str) // 打印结果为:Lison Li

另外还有个和字符串相关的类型:字符串字面量类型。即把一个字符串字面量作为一种类型,比如上面的字符串"Lison",当你把一个变量指定为这个字符串类型的时候,就不能再赋值为其他字符串值了,如:

let str: 'Lison'
str = 'haha' // error 不能将类型“"haha"”分配给类型“"Lison"”

2.1.4 数组

在 TypeScript 中有两种定义数组的方式:

let list1: number[] = [1, 2, 3];
let list2: Array<number> = [1, 2, 3];

第一种形式通过number[]的形式来指定这个类型元素均为number类型的数组类型,这种写法是推荐的写法,当然你也可以使用第二种写法。注意,这两种写法中的number指定的是数组元素的类型,你也可以在这里将数组的元素指定为任意类型。如果你要指定一个数组里的元素既可以是数值也可以是字符串,那么你可以使用这种方式:number|string[],这种方式我们在后面学习联合类型的时候会讲到。

当你使用第二种形式定义时,tslint 可能会警告让你使用第一种形式定义,如果你就是想用第二种形式,可以通过在 tslint.json 的 rules 中加入"array-type": [false]关闭 tslint 对这条的检测。

后面我们讲接口的时候,还会讲到数组的一个特殊类型:ReadonlyArray,即只读数组。

2.1.5 null 和 undefined

null 和 undefined 有一些共同特点,所以我们放在一起讲。说它们有共同特点,是因为在 JavaScript 中,undefined 和 null 是两个基本数据类型。在 TypeScript 中,这两者都有各自的类型即 undefined 和 null,也就是说它们既是实际的值,也是类型,来看实际例子:

let u: undefined = undefined;// 这里可能会报一个tslint的错误:Unnecessary initialization to 'undefined',就是不能给一个值赋undefined,但我们知道这是可以的,所以如果你的代码规范想让这种代码合理化,可以配置tslint,将"no-unnecessary-initializer"设为false即可
let n: null = null; 

默认情况下 undefined 和 null 可以赋值给任意类型的值,也就是说你可以把 undefined 赋值给 void 类型,也可以赋值给 number 类型。当你在 tsconfig.json 的"compilerOptions"里设置了"strictNullChecks": true时,那必须严格对待。undefined 和 null 将只能赋值给它们自身和 void 类型,void类型我们后面会学习。

2.1.6 object

object 在 JS 中是引用类型,它和 JS 中的其他基本类型不一样,像 number、string、boolean、undefined、null 这些都是基本类型,这些类型的变量存的是他们的值,而 object 类型的变量存的是引用,看个简单的例子:

let strInit = "abc";
let strClone = strInit;
strClone = "efg";
console.log(strInit); // 'abc'

let objInit = { a: "aa" };
let objClone = objInit;
console.log(objClone) // {a:"aa"}
objInit.a = "bb";
console.log(objClone); // { a: 'bb' }

通过例子可以看出,我们修改 objInit 时,objClone 也被修改了,是因为 objClone 保存的是 objInit 的引用,实际上 objInit 和 objClone 是同一个对象。

当我们希望一个变量或者函数的参数的类型是一个对象的时候,使用这个类型,比如:

let obj: object
obj = { name: 'Lison' }
obj = 123 // error 不能将类型“123”分配给类型“object”

这里有一点要注意了,你可能会想到给 obj 指定类型为 object 对象类型,然后给它赋值一个对象,后面通过属性访问操作符访问这个对象的某个属性,实际操作一下你就会发现会报错:

let obj: object
obj = { name: 'Lison' }
console.log(obj.name) // error 类型“object”上不存在属性“name”

这里报错说类型 object 上没有 name 这个属性。如果你想要达到这种需求你应该使用我们后面章节要讲到的接口,那 object 类型适合什么时候使用呢?我们前面说了,当你希望一个值必须是对象而不是数值等类型时,比如我们定义一个函数,参数必须是对象,这个时候就用到object类型了:

function getKeys (obj: object) {
    return Object.keys(obj) // 会以列表的形式返回obj中的值
}
getKeys({ a: 'a' }) // ['a']
getKeys(123) // error 类型“123”的参数不能赋给类型“object”的参数

这里涉及到的函数的相关知识,我们会在后面章节介绍的,你只要在这里明白object类型的使用就可以了。

2.1.6 symbol

Symbol 是 ES6 加入的新的基础数据类型,因为它的知识比较多,所以我们单独在后面的一节进行讲解。

本节小结

本小节我们学习了八个在JavaScript中我们就见过的数据类型,它们是:布尔类型、数值类型、字符串、数组、null、undefined、object以及ES6中新增的symbol。在TypeScript中它们都有对应的类型关键字,对应关系为:

  • 布尔类型:boolean

  • 数值类型:number

  • 字符串类型:string

  • 数组:Array<type>或type[]

  • 对象类型:object

  • Symbol类型:symbol

  • null和undefined:null 和 undefined,这个比较特殊,它们自身即是类型

这些类型是基础,我们后面的高级类型很多都是它们的组合或者变形,所以一定要把这些基础先学会。下个小节我们将学习 TypeScript 中新增的几种类型,了解更多基本类型。

 

05 TS中补充的六个类型

05 TS中补充的六个类型

更新时间:2019-07-15 18:58:20

 

衡量一个人的真正品格,是看他在知道没人看见的时候干些什么。 ——孟德斯鸠

 

上个小节我们学习了八个JavaScript中常见的数据类型,你也学会了如何给一个变量指定类型。本小节我们将接触几个TypeScript中引入的新类型,这里面可能有你在其他强类型语言中见过的概念,接下来让我们一起来学习。

 

2.2.1 元组

元组可以看做是数组的拓展,它表示已知元素数量和类型的数组。确切地说,是已知数组中每一个位置上的元素的类型,来看例子:

let tuple: [string, number, boolean];
tuple = ["a", 2, false];
tuple = [2, "a", false]; // error 不能将类型“number”分配给类型“string”。 不能将类型“string”分配给类型“number”。
tuple = ["a", 2]; // error Property '2' is missing in type '[string, number]' but required in type '[string, number, boolean]'

可以看到,上面我们定义了一个元组 tuple,它包含三个元素,且每个元素的类型是固定的。当我们为 tuple 赋值时:各个位置上的元素类型都要对应,元素个数也要一致。

我们还可以给单个元素赋值:

tuple[1] = 3;

这里我们给元组 tuple 的索引为 1 即第二个元素赋值为 3,第二个元素类型为 number,我们赋值给 3,所以没有问题。

当我们访问元组中元素时,TypeScript 会对我们在元素上做的操作进行检查:

tuple[0].split(":"); // right 类型"string"拥有属性"split"
tuple[1].split(":"); // error 类型“number”上不存在属性“split”

上面的例子中,我们访问的 tuple 的第二个元素的元素类型为 number,而数值没有 split 方法,所以会报错。

在 2.6 版本之前,TypeScript 对于元组长度的校验和 2.6 之后的版本有所不同,我们来看下面的例子,前后版本对于该情况的处理:

let tuple: [string, number];
tuple = ["a", 2]; // right 类型和个数都对应,没问题
// 2.6版本之前如下也不会报错
tuple = ["a", 2, "b"];
// 2.6版本之后如下会报错
tuple = ["a", 2, "b"]; // error 不能将类型“[string, number, string]”分配给类型“[string, number]”。 属性“length”的类型不兼容。

这个赋给元组的值有三个元素,是比我们定义的元组类型元素个数多的:

  • 在 2.6 及之前版本中,超出规定个数的元素称作越界元素,但是只要越界元素的类型是定义的类型中的一种即可。比如我们定义的类型有两种:string 和 number,越界的元素是 string 类型,属于联合类型 string | number,所以没问题,联合类型的概念我们后面会讲到。

  • 在 2.6 之后的版本,去掉了这个越界元素是联合类型的子类型即可的条件,要求元组赋值必须类型和个数都对应。

在 2.6 之后的版本,[string, number]元组类型的声明效果上可以看做等同于下面的声明:

interface Tuple extends Array<number | string> {
  0: string;
  1: number;
  length: 2;
}

上面这个声明中,我们定义接口Tuple,它继承数组类型,并且数组元素的类型是 number 和 string 构成的联合类型,这样接口Tuple 就拥有了数组类型所有的特性。并且我们明确指定索引为0的值为string类型,索引为1的值为number类型,同时我们指定 length 属性的类型字面量为 2,这样当我们再指定一个类型为这个接口Tuple的时候,这个值必须是数组,而且如果元素个数超过2个时,它的length就不是2是大于2的数了,就不满足这个接口定义了,所以就会报错;当然,如果元素个数不够2个也会报错,因为索引为0或1的值缺失。接口我们后面会在后面专门的一节来讲,所以暂时不懂也没关系。

如果你想要和 2.6 及之前版本一样的元组特性,那你可以这样定义接口:

interface Tuple extends Array<number | string> {
  0: string;
  1: number;
}

也就是去掉接口中定义的length: 2,这样Tuple接口的length就是从Array继承过来的number类型,而不用必须是2了。

2.2.2 枚举

enum类型在 C++这些语言中比较常见,TypeScript 在 ES 原有类型基础上加入枚举类型,使我们在 TypeScript 中也可以给一组数值赋予名字,这样对开发者来说较为友好。比如我们要定义一组角色,每一个角色用一个数字代表,就可以使用枚举类型来定义:

enum Roles {
  SUPER_ADMIN,
  ADMIN,
  USER
}

上面定义的枚举类型 Roles 里面有三个值,TypeScript 会为它们每个值分配编号,默认从 0 开始,依次排列,所以它们对应的值是:

enum Roles {
  SUPER_ADMIN = 0,
  ADMIN = 1,
  USER = 2
}

当我们使用的时候,就可以使用名字而不需要记数字和名称的对照关系了:

const superAdmin = Roles.SUPER_ADMIN;
console.log(superAdmin); // 0

你也可以修改这个数值,比如你想让这个编码从 1 开始而不是 0,可以如下定义:

enum Roles {
  SUPER_ADMIN = 1,
  ADMIN,
  USER
}

这样当你访问Roles.ADMIN时,它的值就是 2 了。

你也可以为每个值都赋予不同的、不按顺序排列的值:

enum Roles {
  SUPER_ADMIN = 1,
  ADMIN = 3,
  USER = 7
}

通过名字 Roles.SUPER_ADMIN 可以获取到它对应的值 1,同时你也可以通过值获取到它的名字,以上面任意数值这个例子为前提:

console.log(Roles[3]); // 'ADMIN'

更多枚举的知识我们会在后面专门的一节讲解,在这里我们只是先有个初步的认识即可。

2.2.3 Any

JavaScript 的类型是灵活的,程序有时也是多变的。有时,我们在编写代码的时候,并不能清楚地知道一个值到底是什么类型,这时就需要用到 any 类型,即任意类型。我们来看例子:

let value: any;
value = 123;
value = "abc";
value = false;

你可以看到,我们定义变量 value,指定它的类型为 any,接下来赋予任何类型的值都是可以的。

我们还可以在定义数组类型时使用 any 来指定数组中的元素类型为任意类型:

const array: any[] = [1, "a", true];

但是请注意,不要滥用 any,如果任何值都指定为 any 类型,那么 TypeScript 将失去它的意义。

所以如果类型是未知的,更安全的做法是使用unknown类型,我们本小节后面会讲到。

2.2.4 void

void 和 any 相反,any 是表示任意类型,而 void 是表示没有任意类型,就是什么类型都不是,这在我们定义函数,函数没有返回值时会用到:

const consoleText = (text: string): void => {
  console.log(text);
};

这个函数没有返回任何的值,所以它的返回类型为 void。现在你只需知道 void 表达的含义即可,后面我们会用专门的一节来学习函数。

void 类型的变量只能赋值为 undefined 和 null其他类型不能赋值给 void 类型的变量

2.2.5 never

never 类型指那些永不存在的值的类型,它是那些总会抛出异常或根本不会有返回值的函数表达式的返回值类型,当变量被永不为真的类型保护(后面章节会详细介绍)所约束时,该变量也是 never 类型。

这个类型比较难理解,我们先来看几个例子:

const errorFunc = (message: string): never => {
  throw new Error(message);
};

这个 errorFunc 函数总是会抛出异常,所以它的返回值类型是 never,用来表明它的返回值是永不存在的。

const infiniteFunc = (): never => {
  while (true) {}
};

infiniteFunc也是根本不会有返回值的函数,它和之前讲 void 类型时的consoleText函数不同,consoleText函数没有返回值,是我们在定义函数的时候没有给它返回值,而infiniteFunc是死循环是根本不会返回值的,所以它们二者还是有区别的。

never 类型是任何类型的子类型,所以它可以赋值给任何类型;而没有类型是 never 的子类型,所以除了它自身没有任何类型可以赋值给 never 类型,any 类型也不能赋值给 never 类型。我们来看例子:

let neverVariable = (() => {
  while (true) {}
})();
neverVariable = 123; // error 不能将类型"number"分配给类型"never"

上面例子我们定义了一个立即执行函数,也就是"let neverVariable = "右边的内容。右边的函数体内是一个死循环,所以这个函数调用后的返回值类型为 never,所以赋值之后 neverVariable 的类型是 never 类型,当我们给 neverVariable 赋值 123 时,就会报错,因为除它自身外任何类型都不能赋值给 never 类型。

2.2.6 unknown

unknown类型是TypeScript在3.0版本新增的类型,它表示未知的类型,这样看来它貌似和any很像,但是还是有区别的,也就是所谓的“unknown相对于any是安全的”。怎么理解呢?我们知道当一个值我们不能确定它的类型的时候,可以指定它是any类型;但是当指定了any类型之后,这个值基本上是“废”了,你可以随意对它进行属性方法的访问,不管有的还是没有的,可以把它当做任意类型的值来使用,这往往会产生问题,如下:

let value: any
console.log(value.name)
console.log(value.toFixed())
console.log(value.length)

上面这些语句都不会报错,因为value是any类型,所以后面三个操作都有合法的情况,当value是一个对象时,访问name属性是没问题的;当value是数值类型的时候,调用它的toFixed方法没问题;当value是字符串或数组时获取它的length属性是没问题的。

而当你指定值为unknown类型的时候,如果没有通过基于控制流的类型断言来缩小范围的话,是不能对它进行任何操作的,关于类型断言,我们后面小节会讲到。总之这里你知道了,unknown类型的值不是可以随便操作的。

我们这里只是先来了解unknown和any的区别,unknown还有很多复杂的规则,但是涉及到很多后面才学到的知识,所以需要我们学习了高级类型之后才能再讲解。

2.2.7 拓展阅读

这要讲的不是TypeScript中新增的基本类型,而是高级类型中的两个比较常用类型:联合类型和交叉类型。我们之所以要提前讲解,是因为它俩比较简单,而且很是常用,所以我们先来学习下。

(1) 交叉类型

交叉类型就是取多个类型的并集,使用 & 符号定义,被&符链接的多个类型构成一个交叉类型,表示这个类型同时具备这几个连接起来的类型的特点,来看例子:

const merge = <T, U>(arg1: T, arg2: U): T & U => {
  let res = <T & U>{}; // 这里指定返回值的类型兼备T和U两个类型变量代表的类型的特点
  res = Object.assign(arg1, arg2); // 这里使用Object.assign方法,返回一个合并后的对象;
                                   // 关于该方法,请在例子下面补充中学习
  return res;
};
const info1 = {
  name: "lison"
};
const info2 = {
  age: 18
};
const lisonInfo = merge(info1, info2);

console.log(lisonInfo.address); // error 类型“{ name: string; } & { age: number; }”上不存在属性“address”

补充阅读:Object.assign方法可以合并多个对象,将多个对象的属性添加到一个对象中并返回,有一点要注意的是,如果属性值是对象或者数组这种保存的是内存引用的引用类型,会保持这个引用,也就是如果在Object.assign返回的的对象中修改某个对象属性值,原来用来合并的对象也会受到影响。

可以看到,传入的两个参数分别是带有属性 name 和 age 的两个对象,所以它俩的交叉类型要求返回的对象既有 name 属性又有 age 属性。

(2) 联合类型

联合类型在前面课时中几次提到,现在我们来看一下。联合类型实际是几个类型的结合,但是和交叉类型不同,联合类型是要求只要符合联合类型中任意一种类型即可,它使用 | 符号定义。当我们的程序具有多样性,元素类型不唯一时,即使用联合类型。

const getLength = (content: string | number): number => {
  if (typeof content === "string") return content.length;
  else return content.toString().length;
};
console.log(getLength("abc")); // 3
console.log(getLength(123)); // 3

这里我们指定参数既可以是字符串类型也可以是数值类型,这个getLength函数的定义中,其实还涉及到一个知识点,就是类型保护,就是typeof content === “string”,后面进阶部分我们会学到。

补充说明

有一个问题我需要在这里提前声明一下,以免你在自己联系专栏中例子的时候遇到困惑。在讲解语法知识的时候,会有很多例子,在定义一些类型值,比如枚举,或者后面讲的接口等的时候,对于他们的命名我并不会考虑重复性,比如我这里讲枚举的定义定义了一个名字叫Status的枚举值,在别处我又定义了一个同名的接口,那这个时候你可能会看到如下这种错误提示:

枚举声明只能与命名空间或其他枚举声明合并

正如你看到的,这里这个错误,是因为你在同一个文件不同地方、或者不同文件中,定义了相同名称的值,而由于TypeScript的声明合并策略,他会将同名的一些可合并的声明进行合并,当同名的两个值或类型不能合并的时候,就会报错;或者可以合并的连个同名的值不符合要求,也会有问题。关于声明合并和哪些声明可以合并,以及声明需要符合的条件等我们会在后面章节学到。这里你只要知道,类似于这种报错中提到“声明合并”的或者无法重新声明块范围变量,可能都是因为有相同名称的定义。

小结

本小节我们学习了六个TypeScript中新增的数据类型,它们是:元组、枚举、Any、void、never和unknown,其中枚举我们会在后面一个单独的小节进行详细学习,unknown会在我们学习了高级类型之后再补充。我们还学习了两个简单的高级类型:联合类型和交叉类型。我们还学习了any类型与never类型和unknown类型相比的区别,简单来说,any和never的概念是对立的,而any和unknown类型相似,但是unknown与any相比是较为安全的类型,它并不允许无条件地随意操作。我们学习的联合类型和交叉类型,是各种类型的结合,我们可以使用几乎任何类型,来组成联合类型和交叉类型。

下个小节我们将详细学习Symbol的所有知识,Symbol是ES6标准提出的新概念,TypeScript已经支持了该语法,下节课我们将进行全面学习。

 

06 Symbol-ES6新基础类型

06 Symbol-ES6新基础类型

更新时间:2019-07-29 12:26:55

 

你若要喜爱你自己的价值,你就得给世界创造价值。——歌德

 

symbol是 ES6 新增的一种基本数据类型,它和 number、string、boolean、undefined 和 null 是同类型的,object 是引用类型。它用来表示独一无二的值,通过 Symbol 函数生成。

本小节代码都是纯JavaScript代码,建议在非TypeScript环境练习,你可以在浏览器开发者工具的控制台里练习。但是因为TypeScript也支持Symbol,所以如果需要特别说明的地方,我们会提示在TypeScript中需要注意的内容。

 

我们先来看例子:

const s = Symbol();
typeof s; // 'symbol'

 

我们使用Symbol函数生成了一个 symbol 类型的值 s。

注意:Symbol 前面不能加new关键字,直接调用即可创建一个独一无二的 symbol 类型的值。

我们可以在使用 Symbol 方法创建 symbol 类型值的时候传入一个参数,这个参数需要是字符串的。如果传入的参数不是字符串,会先调用传入参数的 toString 方法转为字符串。先来看例子:

const s1 = Symbol("lison");
const s2 = Symbol("lison");
console.log(s1 === s2); // false
// 补充:这里第三行代码可能会报一个错误:This condition will always return 'false' since the types 'unique symbol' and 'unique symbol' have no overlap.
// 这是因为编译器检测到这里的s1 === s2始终是false,所以编译器提醒你这代码写的多余,建议你优化。

上面这个例子中使用 Symbol 方法创建了两个 symbol 值,方法中都传入了相同的字符串’lison’,但是s1 === s2却是 false,这就是我们说的,Symbol 方法会返回一个独一无二的值,这个值和任何一个值都不等,虽然我们传入的标识字符串都是"lison",但是确实两个不同的值。

你可以理解为我们每一个人都是独一无二的,虽然可以有相同的名字,但是名字只是用来方便我们区分的,名字相同但是人还是不同的。Symbol 方法传入的这个字符串,就是方便我们在控制台或程序中用来区分 symbol 值的。我们可以调用 symbol 值的toString方法将它转为字符串:

const s1 = Symbol("lison");
console.log(s1.toString()); // 'Symbol(lison)'

你可以简单地理解 symbol 值为字符串类型的值,但是它和字符串有很大的区别,它不可以和其他类型的值进行运算,但是可以转为字符串和布尔类型值:

let s = Symbol("lison");
console.log(s.toString()); // 'Symbol(lison)'
console.log(Boolean(s)); // true
console.log(!s); // false

通过上面的例子可以看出,symbol 类型值和对象相似,本身转为布尔值为 true,取反为 false。

2.3.1 作为属性名

在 ES6 中,对象的属性名支持表达式,所以你可以使用一个变量作为属性名,这对于一些代码的简化很有用处,但是表达式必须放到方括号内:

let prop = "name";
const obj = {
  [prop]: "Lison"
};
console.log(obj.name); // 'Lison'

了解了这个新特性后,我们接着学习。symbol 值可以作为属性名,因为 symbol 值是独一无二的,所以当它作为属性名时,不会和其他任何属性名重复:

let name = Symbol();
let obj = {
  [name]: "lison"
};
console.log(obj); // { Symbol(): 'lison' }

你可以看到,打印出来的对象有一个属性名是 symbol 值。如果我们想访问这个属性值,就只能使用 name 这个 symbol 值:

console.log(obj[name]); // 'lison'
console.log(obj.name); // undefined

通过上面的例子可以看到,我们访问属性名为 symbol 类型值的 name 时,我们不能使用点’.‘号访问,因为obj.name这的name实际上是字符串’name’,这和访问普通字符串类型的属性名一样。你必须使用方括号的形式,这样obj[name]这的 name 才是我们定义的 symbol 类型的变量name,之后我们再访问 obj 的[name]属性就必须使用变量 name。

等我们后面学到 ES6 的类(Class)的时候,会利用此特性实现私有属性和私有方法。

2.3.2 属性名的遍历

使用 Symbol 类型值作为属性名,这个属性不会被for…in遍历到,也不会被Object.keys()Object.getOwnPropertyNames()JSON.stringify()获取到:

const name = Symbol("name");
const obj = {
  [name]: "lison",
  age: 18
};
for (const key in obj) {
  console.log(key);
}
// => 'age'
console.log(Object.keys(obj));
// ['age']
console.log(Object.getOwnPropertyNames(obj));
// ['age']
console.log(JSON.stringify(obj));
// '{ "age": 18 }'

虽然这么多方法都无法遍历和访问到 Symbol 类型的属性名,但是 Symbol 类型的属性并不是私有属性。我们可以使用Object.getOwnPropertySymbols方法获取对象的所有symbol类型的属性名:

const name = Symbol("name");
const obj = {
  [name]: "lison",
  age: 18
};
const SymbolPropNames = Object.getOwnPropertySymbols(obj);
console.log(SymbolPropNames);
// [ Symbol(name) ]
console.log(obj[SymbolPropNames[0]]);
// 'lison'
// 如果最后一行代码这里报错提示:元素隐式具有 "any" 类型,因为类型“{ [name]: string; age: number; }”没有索引签名。 那可能是在tsconfig.json里开启了noImplicitAny。因为这里我们还没有学习接口等高级类型,所以你可以先忽略这个错误,或者关闭noImplicitAny。

除了Object.getOwnPropertySymbols这个方法,还可以用 ES6 新提供的 Reflect 对象的静态方法Reflect.ownKeys,它可以返回所有类型的属性名,所以 Symbol 类型的也会返回。

const name = Symbol("name");
const obj = {
  [name]: "lison",
  age: 18
};
console.log(Reflect.ownKeys(obj));
// [ 'age', Symbol(name) ]

2.3.3 Symbol.for()和 Symbol.keyFor()

Symbol 包含两个静态方法,for 和 keyFor

(1) Symbol.for()

我们使用 Symbol 方法创建的 symbol 值是独一无二的,每一个值都不和其他任何值相等,我们来看下例子:

const s1 = Symbol("lison");
const s2 = Symbol("lison");
const s3 = Symbol.for("lison");
const s4 = Symbol.for("lison");
s3 === s4; // true
s1 === s3; // false
// 这里还是会报错误:This condition will always return 'false' since the types 'unique symbol' and 'unique symbol' have no overlap.还是我们说过的,因为这里的表达式始终是true和false,所以编译器会提示我们。

直接使用 Symbol 方法,即便传入的字符串是一样的,创建的 symbol 值也是互不相等的。**而使用 Symbol.for方法传入字符串,会先检查有没有使用该字符串调用 Symbol.for 方法创建的 symbol 值,如果有,返回该值,如果没有,则使用该字符串新创建一个。**使用该方法创建 symbol 值后会在全局范围进行注册。

注意:这个注册的范围包括当前页面和页面中包含的 iframe,以及 service sorker,我们来看个例子:

const iframe = document.createElement("iframe");
iframe.src = String(window.location);
document.body.appendChild(iframe);

iframe.contentWindow.Symbol.for("lison") === Symbol.for("lison"); // true
// 注意:如果你在JavaScript环境中这段代码是没有问题的,但是如果在TypeScript开发环境中,可能会报错:类型“Window”上不存在属性“Symbol”。
// 因为这里编译器推断出iframe.contentWindow是Window类型,但是TypeScript的声明文件中,对Window的定义缺少Symbol这个字段,所以会报错,所以你可以这样写:
// (iframe.contentWindow as Window & { Symbol: SymbolConstructor }).Symbol.for("lison") === Symbol.for("lison")
// 这里用到了类型断言和交叉类型,SymbolConstructor是内置的类型。

上面这段代码的意思是创建一个 iframe 节点并把它放到 body 中,我们通过这个iframe 对象的 contentWindow 拿到这个 iframe 的 window 对象,在iframe.contentWindow上添加一个值就相当于你在当前页面定义一个全局变量一样,我们看到,在iframe 中定义的键为’lison’的 symbol 值在和在当前页面定义的键为’lison’的 symbol 值相等,说明它们是同一个值。

(2) Symbol.keyFor()

该方法传入一个 symbol 值,返回该值在全局注册的键名:

const sym = Symbol.for("lison");
console.log(Symbol.keyFor(sym)); // 'lison'

2.3.4 11 个内置 symbol 值

ES6 提供了 11 个内置的 Symbol 值,指向 JS 内部使用的属性和方法。看到它们第一眼你可能会有疑惑,这些不是 Symbol 对象的一个属性值吗?没错,这些内置的 Symbol 值就是保存在 Symbol 上的,你可以把Symbol.xxx看做一个 symbol 值。接下来我们来挨个学习一下:

(1) Symbol.hasInstance

对象的 Symbol.hasInstance 指向一个内部方法,当你给一个对象设置以 Symbol.hasInstance 为属性名的方法后,当其他对象使用 instanceof 判断是否为这个对象的实例时,会调用你定义的这个方法,参数是其他的这个对象,来看例子:

const obj = {
  [Symbol.hasInstance](otherObj) {
    console.log(otherObj);
  }
};
console.log({ a: "a" } instanceof obj); // false
// 注意:在TypeScript中这会报错,"instanceof" 表达式的右侧必须属于类型 "any",或属于可分配给 "Function" 接口类型的类型。
// 是要求你instanceof操作符右侧的值只能是构造函数或者类,或者类型是any类型。这里你可以使用类型断言,将obj改为obj as any

可以看到当我们使用 instanceof 判断{ a: ‘a’ }是否是 obj 创建的实例的时候,Symbol.hasInstance 这个方法被调用了。

(2) Symbol.isConcatSpreadable

这个值是一个可读写布尔值,其值默认是undefined,当一个数组的 Symbol.isConcatSpreadable 设为 true或者为默认的undefined 时,这个数组在数组的 concat 方法中会被扁平化。我们来看下例子:

let arr = [1, 2];
console.log([].concat(arr, [3, 4])); // 打印结果为[1, 2, 3, 4],length为4
let arr1 = ["a", "b"];
console.log(arr1[Symbol.isConcatSpreadable]); // undefined
arr1[Symbol.isConcatSpreadable] = false;
console.log(arr1[Symbol.isConcatSpreadable]); // false
console.log([].concat(arr1, [3, 4])); // 打印结果如下:
/
 [ ["a", "b", Symbol(Symbol.isConcatSpreadable): false], 3, 4 ]
 最外层这个数组有三个元素,第一个是一个数组,因为我们设置了arr1[Symbol.isConcatSpreadable] = false
 所以第一个这个数组没有被扁平化,第一个元素这个数组看似是有三个元素,但你在控制台可以看到这个数组的length为2
 Symbol(Symbol.isConcatSpreadable): false不是他的元素,而是他的属性,我们知道数组也是对象,所以我们可以给数组设置属性
 你可以试试如下代码,然后看下打印出来的效果:
  let arr = [1, 2]
  arr.props = 'value'
  console.log(arr)
 /

(3) Symbol.species

这里我们需要提前使用类的知识来讲解这个 symbol 值的用法,类的详细内容我们会在后面课程里全面讲解。这个知识你需要在纯JavaScript的开发环境中才能看出效果,你可以在浏览器开发者工具的控制台尝试。在TypeScript中,下面两个例子都是一样的会报a.getName is not a function错误。

首先我们使用 class 定义一个类 C,使用 extends 继承原生构造函数 Array,那么类 C 创建的实例就能继承所有 Array 原型对象上的方法,比如 map、filter 等。我们先来看代码:

class C extends Array {
  getName() {
    return "lison";
  }
}
const c = new C(1, 2, 3);
const a = c.map(item => item + 1);
console.log(a); // [2, 3, 4]
console.log(a instanceof C); // true
console.log(a instanceof Array); // true
console.log(a.getName()); // "lison"

这个例子中,a 是由 c 通过 map 方法衍生出来的,我们也看到了,a 既是 C 的实例,也是 Array 的实例。但是如果我们想只让衍生的数组是 Array 的实例,就需要用 Symbol.species,我们来看下怎么使用:

class C extends Array {
  static get [Symbol.species]() {
    return Array;
  }
  getName() {
    return "lison";
  }
}
const c = new C(1, 2, 3);
const a = c.map(item => item + 1);
console.log(a); // [2, 3, 4]
console.log(a instanceof C); // false
console.log(a instanceof Array); // true
console.log(a.getName()); // error a.getName is not a function

就是给类 C 定义一个静态 get 存取器方法,方法名为 Symbol.species,然后在这个方法中返回要构造衍生数组的构造函数。所以最后我们看到,a instanceof C为 false,也就是 a 不再是 C 的实例,也无法调用继承自 C 的方法。

(4) Symbol.match、Symbol.replace、Symbol.search 和 Symbol.split

这个 Symbol.match 值指向一个内部方法,当在字符串 str 上调用 match 方法时,会调用这个方法,来看下例子:

let obj = {
  [Symbol.match](string) {
    return string.length;
  }
};
console.log("abcde".match(obj)); // 5

相同的还有 Symbol.replace、Symbol.search 和 Symbol.split,使用方法和 Symbol.match 是一样的。

(5) Symbol.iterator

数组的 Symbol.iterator 属性指向该数组的默认遍历器方法:

const arr = [1, 2, 3];
const iterator = arr[Symbol.iterator]();
console.log(iterator);
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());

这个 Symbol.iterator 方法是可写的,我们可以自定义遍历器方法。

(6) Symbol.toPrimitive

对象的这个属性指向一个方法,当这个对象被转为原始类型值时会调用这个方法,这个方法有一个参数,是这个对象被转为的类型,我们来看下:

let obj = {
  [Symbol.toPrimitive](type) {
    console.log(type);
  }
};
// const b = obj++ // number
const a = abc${obj}; // string

(7) Symbol.toStringTag

Symbol.toStringTag 和 Symbol.toPrimitive 相似,对象的这个属性的值可以是一个字符串,也可以是一个存取器 get 方法,当在对象上调用 toString 方法时调用这个方法,返回值将作为"[object xxx]"中 xxx 这个值:

let obj = {
  [Symbol.toStringTag]: "lison"
};
obj.toString(); // "[object lison]"
let obj2 = {
  get [Symbol.toStringTag]() {
    return "haha";
  }
};
obj2.toString(); // "[object haha]"

(8) Symbol.unscopables

这个值和 with 命令有关,我们先来看下 with 怎么使用:

const obj = {
  a: "a",
  b: "b"
};
with (obj) {
  console.log(a); // "a"
  console.log(b); // "b"
}
// 如果是在TypeScript开发环境中,这段代码可能with会报错:不支持 "with" 语句,这是因为在严格模式下,是不允许使用with的。

可以看到,使用 with 传入一个对象后,在代码块中访问对象的属性就不需要写对象了,直接就可以用它的属性。对象的 Symbol.unscopables 属性指向一个对象,该对象包含了当使用 with 关键字时,哪些属性被 with 环境过滤掉:

console.log(Array.prototype[Symbol.unscopables]);
/
{
    copyWithin: true
    entries: true
    fill: true
    find: true
    findIndex: true
    includes: true
    keys: true
    values: true
}
/

2.3.5 在TypeScript中使用symbol类型

2.3.5.1 基础

学习完ES6标准中Symbol的所有内容后,我们来看下在TypeScript中使用symbol类型值,很简单。就是制定一个值的类型为symbol类型:

let sym: symbol = Symbol()

2.3.5.2 unique symbol

TypeScript在2.7版本对Symbol做了补充,增加了unique symbol这种类型,他是symbols的子类型,这种类型的值只能由Symbol()或Symbol.for()创建,或者通过指定类型来指定一个值是这种类型。这种类型的值仅可用于常量的定义和用于属性名。另外还有一点要注意,定义unique symbol类型的值,必须用const不能用let。我们来看个在TypeScript中使用Symbol值作为属性名的例子:

const key1: unique symbol = Symbol()
let key2: symbol = Symbol()
const obj = {
    [key1]: 'value1',
    [key2]: 'value2'
}
console.log(obj[key1])
console.log(obj[key2]) // error 类型“symbol”不能作为索引类型使用。

小结

本小节我们详细学习了Symbol的全部知识,本小节的内容较多:我们学习了Symbol值的基本使用,使用Symbol函数创建一个symbol类型值,可以给它传一个字符串参数,来对symbol值做一个区分,但是即使多次Symbol函数调用传入的是相同的字符串,创建的symbol值也是彼此不同的。

我们还学习了Symbol的两个静态方法:Symbol.forSymbol.keyFor,Symbol.for调用时传入一个字符串,使用此方式创建symbol值时会先在全局范围搜索是否有用此字符串注册的symbol值。如果没有创建一个新的;如果有返回这个symbol值,Symbol.keyFor则是传入一个symbol值然后返回该值在全局注册时的标志字符串。我们还学习了11个内置的symbol值,在设计一些高级逻辑时,可能会用到,大部分业务开发很少用到,你可以了解这些值的用途,日后如果遇到这个需求可以想到这有这些内容。

下个小节我们将对第二个前面大致介绍的知识点——枚举Enum进行详细学习,学完后你将全面了解枚举。

 

07 深入学习枚举

07 深入学习枚举

更新时间:2019-06-12 16:36:54

 

 

立志是事业的大门,工作是登堂入室的旅程。——巴斯德

 

枚举是 TypeScript 新增加的一种数据类型,这在其他很多语言中很常见,但是 JavaScript 却没有。使用枚举,我们可以给一些难以理解的常量赋予一组具有意义的直观的名字,使其更为直观,你可以理解枚举就是一个字典。枚举使用 enum 关键字定义,TypeScript 支持数字和字符串的枚举。

2.4.1. 数字枚举

我们先来通过数字枚举的简单例子,来看下枚举是做什么的:

enum Status {// 这里你的TSLint可能会报一个:枚举声明只能与命名空间或其他枚举声明合并。这样的错误,这个不影响编译,声明合并的问题我们在后面的小节会讲。
  Uploading,
  Success,
  Failed
}
console.log(Status.Uploading); // 0
console.log(Status["Success"]); // 1
console.log(Status.Failed); // 2

我们使用enum关键字定义了一个枚举值 Status,它包含三个字段,每个字段间用逗号隔开。我们使用枚举值的元素值时,就像访问对象的属性一样,你可以使用’.‘操作符和’[]'两种形式访问里面的值,这和对象一样。

再来看输出的结果,Status.Uploading 是 0,Status['Success']是 1,Status.Failed 是 2,我们在定义枚举 Status 的时候,并没有指定索引号,是因为这是默认的编号,我们也可以自己指定:

// 修改起始编号
enum Color {
  Red = 2,
  Blue,
  Yellow
}
console.log(Color.Red, Color.Blue, Color.Yellow); // 2 3 4
// 指定任意字段的索引值
enum Status {
  Success = 200,
  NotFound = 404,
  Error = 500
}
console.log(Status.Success, Status.NotFound, Status.Error); // 200 404 500
// 指定部分字段,其他使用默认递增索引
enum Status {
  Ok = 200,
  Created,
  Accepted,
  BadRequest = 400,
  Unauthorized
}
console.log(Status.Created, Status.Accepted, Status.Unauthorized); // 201 202 401

数字枚举在定义值的时候,可以使用计算值和常量。但是要注意,如果某个字段使用了计算值或常量,那么该字段后面紧接着的字段必须设置初始值,这里不能使用默认的递增值了,来看例子:

const getValue = () => {
  return 0;
};
enum ErrorIndex {
  a = getValue(),
  b, // error 枚举成员必须具有初始化的值
  c
}
enum RightIndex {
  a = getValue(),
  b = 1,
  c
}
const Start = 1;
enum Index {
  a = Start,
  b, // error 枚举成员必须具有初始化的值
  c
}

2.4.2. 反向映射

我们定义一个枚举值的时候,可以通过 Enum[‘key’]或者 Enum.key 的形式获取到对应的值 value。TypeScript 还支持反向映射,但是反向映射只支持数字枚举,我们后面要讲的字符串枚举是不支持的。来看下反向映射的例子:

enum Status {
  Success = 200,
  NotFound = 404,
  Error = 500
}
console.log(Status["Success"]); // 200
console.log(Status[200]); // 'Success'
console.log(Status[Status["Success"]]); // 'Success'

TypeScript 中定义的枚举,编译之后其实是对象,我们来看下上面这个例子中的枚举值 Status 编译后的样子:

我们可以直接使用tsc指定某个文件或者不指定文件直接编译整个目录,运行后就会产生相应的编译后的JavaScript文件,你也可以到TypeScript官方文档提供的在线练习场,在这里你可以编写TypeScript代码,它会同步进行编译。实时编译为JavaScript代码,是你了解编译后结果的好方式。

{
    200: "Success",
    404: "NotFound",
    500: "Error",
    Error: 500,
    NotFound: 404,
    Success: 200
}

可以看到,TypeScript 会把我们定义的枚举值的字段名分别作为对象的属性名和值,把枚举值的字段值分别作为对象的值和属性名,同时添加到对象中。这样我们既可以通过枚举值的字段名得到值,也可以通过枚举值的值得到字段名。

2.4.3. 字符串枚举

TypeScript2.4 版本新增了字符串枚举,字符串枚举值要求每个字段的值都必须是字符串字面量,或者是该枚举值中另一个字符串枚举成员,先来看个简单例子:

enum Message {
  Error = "Sorry, error",
  Success = "Hoho, success"
}
console.log(Message.Error); // 'Sorry, error'

再来看我们使用枚举值中其他枚举成员的例子:

enum Message {
  Error = "error message",
  ServerError = Error,
  ClientError = Error
}
console.log(Message.Error); // 'error message'
console.log(Message.ServerError); // 'error message'

注意,这里的其他枚举成员指的是同一个枚举值中的枚举成员,因为字符串枚举不能使用常量或者计算值,所以也不能使用其他枚举值中的成员。

2.4.4. 异构枚举

简单来说异构枚举就是枚举值中成员值既有数字类型又有字符串类型,如下:

enum Result {
  Faild = 0,
  Success = "Success"
}

但是这种如果不是真的需要,不建议使用。因为往往我们将一类值整理为一个枚举值的时候,它们的特点是相似的。比如我们在做接口请求时的返回状态码,如果是状态码都是数值,如果是提示信息,都是字符串,所以在使用枚举的时候,往往是可以避免使用异构枚举的,重点是做好类型的整理。

2.4.5. 枚举成员类型和联合枚举类型

如果枚举值里所有成员的值都是字面量类型的值,那么这个枚举的每个成员和枚举值本身都可以作为类型来使用,先来看下满足条件的枚举成员的值有哪些:

  • 不带初始值的枚举成员,例如enum E { A }

  • 值为字符串字面量,例如enum E { A = ‘a’ }

  • 值为数值字面量,或者带有-符号的数值字面量,例如enum E { A = 1 }enum E { A = -1 }

当我们的枚举值的所有成员的值都是上面这三种情况的时候,枚举值和成员就可以作为类型来用:

(1) 枚举成员类型

我们可以把符合条件的枚举值的成员作为类型来使用,来看例子:

enum Animal {
  Dog = 1,
  Cat = 2
}
interface Dog {
  type: Animal.Dog; // 这里使用Animal.Dog作为类型,指定接口Dog的必须有一个type字段,且类型为Animal.Dog
}
interface Cat {
  type: Animal.Cat; // 这里同上
}
let cat1: Cat = {
  type: Animal.Dog // error [ts] 不能将类型“Animal.Dog”分配给类型“Animal.Cat”
};
let dog: Dog = {
  type: Animal.Dog
};

(2) 联合枚举类型

当我们的枚举值符合条件时,这个枚举值就可以看做是一个包含所有成员的联合类型,先来看例子:

enum Status {
  Off,
  On
}
interface Light {
  status: Status;
}
enum Animal {
  Dog = 1,
  Cat = 2
}
const light1: Light = {
  status: Animal.Dog // error 不能将类型“Animal.Dog”分配给类型“Status”
};
const light2: Light = {
  status: Status.Off
};
const light3: Light = {
  status: Status.On
};

上面例子定义接口 Light 的 status 字段的类型为枚举值 Status,那么此时 status 的属性值必须为 Status.Off 和 Status.On 中的一个,也就是相当于status: Status.Off | Status.On

2.4.6. 运行时的枚举

枚举在编译成 JavaScript 之后实际是一个对象。这个我们前面讲过了,既然是对象,那么就可以当成对象来使用,我们来看个例子:

enum E {
  A,
  B
}
const getIndex = (enumObj: { A: number }): number => {
  return enumObj.A;
};
console.log(getIndex(E)); // 0

上面这个例子要求 getIndex 的参数为一个对象,且必须包含一个属性名为’A’的属性,其值为数值类型,只要有这个属性即可。当我们调用这个函数,把枚举值 E 作为实参传入是可以的,因为它在运行的时候是一个对象,包含’A’这个属性,因为它在运行的时候相当于下面这个对象:

{
    0: "A",
    1: "B",
    A: 0,
    B: 1
}

2.4.7. const enum

我们定义了枚举值之后,编译成 JavaScript 的代码会创建一个对应的对象,这个对象我们可以在程序运行的时候使用。但是如果我们使用枚举只是为了让程序可读性好,并不需要编译后的对象呢?这样会增加一些编译后的代码量。所以 TypeScript 在 1.4 新增 const enum(完全嵌入的枚举),在之前讲的定义枚举的语句之前加上const关键字,这样编译后的代码不会创建这个对象,只是会从枚举里拿到相应的值进行替换,来看我们下面的定义:

enum Status {
  Off,
  On
}
const enum Animal {
  Dog,
  Cat
}
const status = Status.On;
const animal = Animal.Dog;

上面的例子编译成 JavaScript 之后是这样的:

var Status;
(function(Status) {
  Status[(Status["Off"] = 0)] = "Off";
  Status[(Status["On"] = 1)] = "On";
})(Status || (Status = {}));
var status = Status.On;
var animal = 0; / Dog /

我们来看下 Status 的处理,先是定义一个变量 Status,然后定义一个立即执行函数,在函数内给 Status 添加对应属性,首先Status[“Off”] = 0是给Status对象设置Off属性,并且值设为 0,这个赋值表达式的返回值是等号右边的值,也就是 0,所以Status[Status[“Off”] = 0] = "Off"相当于Status[0] = “Off”。创建了这个对象之后,将 Status 的 On 属性值赋值给 status;再来看下 animal 的处理,我们看到编译后的代码并没有像Status创建一个Animal对象,而是直接把Animal.Dog的值0替换到了const animal = Animal.Dog表达式的Animal.Dog位置,这就是const enum的用法了。

小结

本小节我们学习了两种基本的枚举:数字枚举和字符串枚举,它俩的最主要的区别就是枚举成员值的类型了,数字枚举成员的值必须都是数值类型,而字符串枚举成员的值必须都是字符串。枚举还有一个概念叫反向映射,就是当我们定义了枚举值后,不仅定义了字段到值的映射,同时编译器根据反向映射定义了值到字段的映射。我们还学习了数字枚举和字符串枚举的杂交体——异构枚举,但是很少用,原因也解释过了;枚举值和枚举成员在作为值使用的同时,还可以作为类型使用,但是有三个条件,可以回顾下;最后我们还学习了枚举值在编译后是一个对象,可以在运行时使用,如果我们在运行时用不到,可以在定义枚举时在前面加上const来选择不生成对象,而是直接将值替换到响应位置。

下个小节我们将学习类型断言,通过类型断言,可以在一些情况告诉 TypeScript 编译器,我们的逻辑是对的,不是类型错误,从而达到预期。

 

 

08 使用类型断言达到预期

08 使用类型断言达到预期

更新时间:2019-06-12 16:37:05

书是人类进步的阶梯。

——高尔基

学完前面的小节,你已经学习完了TypeScript的基本类型。从本小节开始,你将开始接触逻辑。在这之前,先来学习一个概念:类型断言

虽然 TypeScript 很强大,但有时它还是不如我们了解一个值的类型,这时候我们更希望 TypeScript 不要帮我们进行类型检查,而是交给我们自己来,所以就用到了类型断言。类型断言有点像是一种类型转换,它把某个值强行指定为特定类型,我们先看个例子:

const getLength = target => {
  if (target.length) {
    return target.length;
  } else {
    return target.toString().length;
  }
};

这个函数能够接收一个参数,并返回它的长度,我们可以传入字符串、数组或数值等类型的值。如果有 length 属性,说明参数是数组或字符串类型,如果是数值类型是没有 length 属性的,所以需要把数值类型转为字符串然后再获取 length 值。现在我们限定传入的值只能是字符串或数值类型的值:

const getLength = (target: string | number): number => {
  if (target.length) { // error 报错信息看下方
    return target.length; // error 报错信息看下方
  } else {
    return target.toString().length;
  }
};

当 TypeScript 不确定一个联合类型的变量到底是哪个类型的时候,我们只能访问此联合类型的所有类型里共有的属性或方法,所以现在加了对参数target和返回值的类型定义之后就会报错:

// 类型"string | number"上不存在属性"length"
// 类型"number"上不存在属性"length"

很显然,我们是要做判断的,我们判断如果 target.length 不为 undefined, 说明它是有 length 属性的,但我们的参数是string | number联合类型,所以在我们开始做判断的时候就会报错。这个时候就要用类型断言,将tagrget的类型断言成string类型。它有两种写法,一种是<type>value,一种是value as type,下面例子中我们用两种形式都写出来:

const getStrLength = (target: string | number): number => {
  if ((<string>target).length) { // 这种形式在JSX代码中不可以使用,而且也是TSLint不建议的写法
    return (target as string).length; // 这种形式是没有任何问题的写法,所以建议大家始终使用这种形式
  } else {
    return target.toString().length;
  }
};

例子的函数体用到了三次target,前两次都是访问了 target.length 属性,所以都要用类型断言来表明这个地方是 string 类型;而最后的 target 调用了 toString方法,因为 number 和 string 类型的值都有 toString 方法,所以没有报错。

这样虽然没问题了,但是每一处不同值会有不同情况的地方都需要用类型断言,后面讲到高级类型的时候会讲如何使用自定义类型保护来简化这里。

注意了,这两种写法都可以,但是 tslint 推荐使用as关键字,而且在 JSX 中只能使用as这种写法。

 

小结

本小节我们学习了类型断言的使用。使用类型断言,我们可以告诉编译器某个值确实是我们所认为的值,从而让编译器进行正确的类型推断,让类型检查符合我们的预期。下个小节我们将学习接口,学习了接口后,我们就可以定义几乎所有的数据结构了。

 

09 使用接口定义几乎任意结构

09 使用接口定义几乎任意结构

更新时间:2019-06-12 16:37:17

 

低头要有勇气,抬头要有底气。——韩寒

 

本小节我们来学习接口,正如题目所说的,你可以使用接口定义几乎任意结构,本小节我们先来学习下接口的基本使用方法。

 

2.6.1. 基本用法

我们需要定义这样一个函数,参数是一个对象,里面包含两个字段:firstName 和 lastName,也就是英文的名和姓,然后返回一个拼接后的完整名字。来看下函数的定义:

// 注:这段代码为纯JavaScript代码,请在JavaScript开发环境编写下面代码,在TypeScript环境会报一些类型错误
const getFullName = ({ firstName, lastName }) => {
  return ${firstName} ${lastName};
};

使用时传入参数:

getFullName({
  firstName: "Lison",
  lastName: "Li"
}); // => 'Lison Li'

没有问题,我们得到了拼接后的完整名字,但是使用这个函数的人如果传入一些不是很理想的参数时,就会导致各种结果:

getFullName(); // Uncaught TypeError: Cannot destructure property a of 'undefined' or 'null'.
getFullName({ age: 18, phone: "13312345678" }); // 'undefined undefined'
getFullName({ firstName: "Lison" }); // 'Lison undefined'

这些都是我们不想要的,在开发时难免会传入错误的参数,所以 TypeScript 能够帮我们在编译阶段就检测到这些错误。我们来完善下这个函数的定义:

const getFullName = ({
  firstName,
  lastName,
}: { // 指定这个参数的类型,因为他是一个对象,所以这里来指定对象中每个字段的类型
  firstName: string; // 指定属性名为firstName和lastName的字段的属性值必须为string类型
  lastName: string;
}) => {
  return ${firstName} ${lastName};
};

我们通过对象字面量的形式去限定我们传入的这个对象的结构,现在再来看下之前的调用会出现什么提示:

getFullName(); // 应有1个参数,但获得0个
getFullName({ age: 18, phone: 123456789 }); // 类型“{ age: number; phone: number; }”的参数不能赋给类型“{ firstName: string; lastName: string; }”的参数。
getFullName({ firstName: "Lison" }); // 缺少必要属性lastName

这些都是在我们编写代码的时候 TypeScript 提示给我们的错误信息,这样就避免了在使用函数的时候传入不正确的参数。接下来我们用这节课要讲的接口来书写上面的规则,我们使用interface来定义接口:

interface Info {
  firstName: string;
  lastName: string;
}
const getFullName = ({ firstName, lastName }: Info) =>
  ${firstName} ${lastName};

注意在定义接口的时候,你不要把它理解为是在定义一个对象,而要理解为{}括号包裹的是一个代码块,里面是一条条声明语句,只不过声明的不是变量的值而是类型。声明也不用等号赋值,而是冒号指定类型。每条声明之前用换行分隔即可,或者也可以使用分号或者逗号,都是可以的。

2.6.2.可选属性

当我们定义一些结构的时候,一些结构对于某些字段的要求是可选的,有这个字段就做处理,没有就忽略,所以针对这种情况,typescript为我们提供了可选属性。

我们先定义一个描述传入蔬菜信息的句子的函数:

const getVegetables = ({ color, type }) => {
  return A ${color ? color + " " : ""}${type};
};

我们可以看到这个函数中根据传入对象中的 color 和 type 来进行描述返回一句话,color 是可选的,所以我们可以给接口设置可选属性,在属性名后面加个?即可:

interface Vegetables {
  color?: string;
  type: string;
}

这里可能 tslint 会报一个警告,告诉我们接口应该以大写的i开头,如果你想关闭这条规则,可以在 tslint.json 的 rules 里添加"interface-name": [true, “never-prefix”]来关闭。

2.6.3.多余属性检查

getVegetables({
  type: "tomato",
  size: "big" // 'size'不在类型'Vegetables'中
});

我们看到,传入的参数没有 color 属性,但也没有错误,因为它是可选属性。但是我们多传入了一个 size 属性,这同样会报错,TypeScript 会告诉你,接口上不存在你多余的这个属性。只要接口中没有定义这个属性,就会报错,但如果你定义了可选属性 size,那么上面的例子就不会报错。

这里可能 tslint 会报一个警告,告诉我们属性名没有按开头字母顺序排列属性列表,如果你想关闭这条规则,可以在 tslint.json 的 rules 里添加"object-literal-sort-keys": [false]来关闭。

2.6.4.绕开多余属性检查

有时我们并不希望 TypeScript 这么严格地对我们的数据进行检查,比如我们只需要保证传入getVegetables的对象有type属性就可以了,至于实际使用的时候传入对象有没有多余的属性,多余属性的属性值是什么类型,这些都无所谓,那就需要绕开多余属性检查,有如下三个方法:

(1) 使用类型断言

我们在基础类型中讲过,类型断言就是用来明确告诉 TypeScript,我们已经自行进行了检查,确保这个类型没有问题,希望 TypeScript 对此不进行检查,所以最简单的方式就是使用类型断言:

interface Vegetables {
  color?: string;
  type: string;
}
const getVegetables = ({ color, type }: Vegetables) => {
  return A ${color ? color + " " : ""}${type};
};
getVegetables({
  type: "tomato",
  size: 12,
  price: 1.2
} as Vegetables);

(2) 添加索引签名

更好的方式是添加字符串索引签名,索引签名我们会在后面讲解,先来看怎么实现:

interface Vegetables {
  color: string;
  type: string;
  [prop: string]: any;
}
const getVegetables = ({ color, type }: Vegetables) => {
  return A ${color ? color + " " : ""}${type};
};
getVegetables({
  color: "red",
  type: "tomato",
  size: 12,
  price: 1.2
});

(3) 利用类型兼容性

这种方法现在还不是很好理解,也是不推荐使用的,先来看写法:

interface Vegetables {
  type: string;
}
const getVegetables = ({ type }: Vegetables) => {
  return A ${type};
};

const option = { type: "tomato", size: 12 };
getVegetables(option);

上面这种方法完美通过检查,我们将对象字面量赋给一个变量option,然后getVegetables传入 option,这时没有报错。是因为直接将对象字面量传入函数,和先赋给变量再将变量传入函数,这两种检查机制是不一样的,后者是因为类型兼容性。我们后面会有专门一节来讲类型兼容性。简单地来说:如果 b 要赋值给 a,那要求 b 至少需要与 a 有相同的属性,多了无所谓。

在上面这个例子中,option的类型应该是Vegetables类型,对象{ type: ‘tomato’, size: 12 }要赋值给 optionoption中所有的属性在这个对象字面量中都有,所以这个对象的类型和option(也就是Vegetables类型)是兼容的,所以上面例子不会报错。如果你现在还想不明白没关系,我们还会在后面详细去讲。

2.6.5.只读属性

接口也可以设置只读属性,如下:

interface Role {
  readonly 0: string;
  readonly 1: string;
}

这里我们定义了一个角色字典,有 0 和 1 两种角色 id。下面我们定义一个实际的角色 数据,然后来试图修改一下它的值:

const role: Role = {
  0: "superadmin",
  1: "admin"
};
role[1] = "superadmin"; // Cannot assign to '0' because it is a read-only property

我们看到 TypeScript 告诉我们不能分配给索引0,因为它是只读属性。设置一个值只读,我们是否想到ES6里定义常量的关键字const?使用const定义的常量定义之后不能再修改,这有点只读的意思。那readonlyconst在使用时该如何选择呢?这主要看你这个值的用途,如果是定义一个常量,那用const,如果这个值是作为对象的属性,那请用readonly。我们来看下面的代码:

const NAME: string = "Lison";
NAME = "Haha"; // Uncaught TypeError: Assignment to constant variable

const obj = {
  name: "lison"
};
obj.name = "Haha";

interface Info {
  readonly name: string;
}
const info: Info = {
  name: "Lison"
};
info["name"] = "Haha"; // Cannot assign to 'name' because it is a read-only property

我们可以看到上面使用const定义的常量NAME定义之后再修改会报错,但是如果使用const定义一个对象,然后修改对象里属性的值是不会报错的。所以如果我们要保证对象的属性值不可修改,需要使用readonly

2.6.6.函数类型

接口可以描述普通对象,还可以描述函数类型,我们先看写法:

interface AddFunc {
  (num1: number, num2: number): number;
}

这里我们定义了一个AddFunc结构,这个结构要求实现这个结构的值,必须包含一个和结构里定义的函数一样参数、一样返回值的方法,或者这个值就是符合这个函数要求的函数。我们管花括号里包着的内容为调用签名,它由带有参数类型的参数列表和返回值类型组成。后面学到类型别名一节时我们还会学习其他写法。来看下如何使用:

const add: AddFunc = (n1, n2) => n1 + n2;
const join: AddFunc = (n1, n2) => ${n1} ${n2}; // 不能将类型'string'分配给类型'number'
add("a", 2); // 类型'string'的参数不能赋给类型'number'的参数

上面我们定义的add函数接收两个数值类型的参数,返回的结果也是数值类型,所以没有问题。而join函数参数类型没错,但是返回的是字符串,所以会报错。而当我们调用add函数时,传入的参数如果和接口定义的类型不一致,也会报错。

你应该注意到了,实际定义函数的时候,名字是无需和接口中参数名相同的,只需要位置对应即可。

 

小结

本小节我们学习了接口的一些基本定义和用法,通过使用接口,我们可以定义绝大部分的数据结构,从而限定值的结构。我们可以通过修饰符来指定结构中某个字段的可选性和只读性,以及默认情况下必选性。而接口的校验是严格的,在定义一个实现某个接口的值的时候,对于接口中没有定义的字段是不允许出现的,我们称这个为多余属性检查;同时我们讲了三种绕过多余属性检查的方法,来满足程序的灵活性。最后我们学习了如何通过接口,来定义函数类型,当然我们后面还会学习其他定义函数类型的方法。

下个小节,我们将学习接口的高级用法,学习完之后,除了涉及到的知识的部分外,你就掌握了接口的所有知识。


 

 

10 接口的高阶用法

10 接口的高阶用法

更新时间:2019-07-01 14:22:49

 

受苦的人,没有悲观的权利。——尼采

 

学习了上个小节接口的基础用法后,相信你已经能够使用接口来描述一些结构了。本小节我们来继续学习接口,学习接口的高阶用法。接口有一小部分知识与类的知识相关,所以我们放在讲解类的小节后面补充讲解,我们先来学习除了这一小部分之外剩下的接口的知识。

 

2.7.1 索引类型

我们可以使用接口描述索引的类型和通过索引得到的值的类型,比如一个数组[‘a’, ‘b’],数字索引0对应的通过索引得到的值为’a’。我们可以同时给索引和值都设置类型,看下面的示例:

interface RoleDic {
  [id: number]: string;
}
const role1: RoleDic = {
  0: "superadmin",
  1: "admin"
};
const role2: RoleDic = {
  s: "superadmin",  // error 不能将类型"{ s: string; a: string; }"分配给类型"RoleDic"。
  a: "admin"
};
const role3: RoleDic = ["super_admin", "admin"];

上面的例子中 role3 定义了一个数组,索引为数值类型,值为字符串类型。

你也可以给索引设置readonly,从而防止索引返回值被修改。

interface RoleDic {
  readonly [id: number]: string;
}
const role: RoleDic = {
  0: "super_admin"
};
role[0] = "admin"; // error 类型"RoleDic"中的索引签名仅允许读取

这里有的点需要注意,你可以设置索引类型为 number。但是这样如果你将属性名设置为字符串类型,则会报错;但是如果你设置索引类型为字符串类型,那么即便你的属性名设置的是数值类型,也没问题。因为 JS 在访问属性值的时候,如果属性名是数值类型,会先将数值类型转为字符串,然后再去访问。你可以看下这个例子:

const obj = {
  123: "a", // 这里定义一个数值类型的123这个属性
  "123": "b" // 这里在定义一个字符串类型的123这个属性,这里会报错:标识符“"123"”重复。
};
console.log(obj); // { '123': 'b' }

如果数值类型的属性名不会转为字符串类型,那么这里数值123和字符串123是不同的两个值,则最后对象obj应该同时有这两个属性;但是实际打印出来的obj只有一个属性,属性名为字符串"123",而且值为"b",说明数值类型属性名123被覆盖掉了,就是因为它被转为了字符串类型属性名"123";又因为一个对象中多个相同属性名的属性,定义在后面的会覆盖前面的,所以结果就是obj只保留了后面定义的属性值。

2.7.2.继承接口

接口可以继承,这和类(类的相关知识,我们会在后面全面详细的学习)一样,这提高了接口的可复用性。来看一个场景:

我们定义一个Vegetables接口,它会对color属性进行限制。再定义两个接口,一个为Tomato,一个为Carrot,这两个类都需要对color进行限制,而各自又有各自独有的属性限制,我们可以这样定义:

interface Vegetables {
  color: string;
}
interface Tomato {
  color: string;
  radius: number;
}
interface Carrot {
  color: string;
  length: number;
}

三个接口中都有对color的定义,但是这样写很繁琐,所以我们可以用继承来改写:

interface Vegetables {
  color: string;
}
interface Tomato extends Vegetables {
  radius: number;
}
interface Carrot extends Vegetables {
  length: number;
}
const tomato: Tomato = {
  radius: 1.2 // error  Property 'color' is missing in type '{ radius: number; }'
};
const carrot: Carrot = {
  color: "orange",
  length: 20
};

上面定义的 tomato 变量因为缺少了从Vegetables接口继承来的 color 属性,从而报错。

一个接口可以被多个接口继承,同样,一个接口也可以继承多个接口,多个接口用逗号隔开。比如我们再定义一个Food接口,Tomato 也可以继承 Food

interface Vegetables {
  color: string;
}
interface Food {
  type: string;
}
interface Tomato extends Food, Vegetables {
  radius: number;
}

const tomato: Tomato = {
  type: "vegetables",
  color: "red",
  radius: 1.2
};  // 在定义tomato变量时将继承过来的color和type属性同时声明

2.7.3.混合类型接口

JS 的类型是灵活的。在 JS 中,函数是对象类型。对象可以有属性,所以有时我们的一个对象,它既是一个函数,也包含一些属性。比如我们要实现一个计数器函数,比较直接的做法是定义一个函数和一个全局变量:

let count = 0;
const countUp = () => count++;

但是这种方法需要在函数外面定义一个变量,更优一点的方法是使用闭包:

// javascript
const countUp = (() => {
  let count = 0;
  return () => {
    return ++count;
  };
})();
console.log(countUp()); // 1
console.log(countUp()); // 2

在 TypeScript3.1 版本之前,我们需要借助命名空间来实现。但是在 3.1 版本,TypeScript 支持直接给函数添加属性,虽然这在 JS 中早就支持了:

// javascript
let countUp = () => {
  return ++countUp.count;
};
countUp.count = 0;
console.log(countUp()); // 1
console.log(countUp()); // 2

我们可以看到,我们把一个函数赋值给countUp,又给它绑定了一个属性count,我们的计数保存在这个 count 属性中。

我们可以使用混合类型接口来指定上面例子中 countUp 的类型:

interface Counter {
  (): void; // 这里定义Counter这个结构必须包含一个函数,函数的要求是无参数,返回值为void,即无返回值
  count: number; // 而且这个结构还必须包含一个名为count、值的类型为number类型的属性
}
const getCounter = (): Counter => { // 这里定义一个函数用来返回这个计数器
  const c = () => { // 定义一个函数,逻辑和前面例子的一样
    c.count++;
  };
  c.count = 0; // 再给这个函数添加一个count属性初始值为0
  return c; // 最后返回这个函数对象
};
const counter: Counter = getCounter(); // 通过getCounter函数得到这个计数器
counter();
console.log(counter.count); // 1
counter();
console.log(counter.count); // 2

上面的例子中,getCounter函数返回值类型为Counter,它是一个函数,无返回值,即返回值类型为void,它还包含一个属性count,属性返回值类型为number

 

小结

本小节我们在接口基础知识的基础上,学习了接口的高阶用法。我们学习了如何限定索引的类型,即使用[]将索引名括起来,然后使用: type来指定索引的类型;还学习了一种复用现有接口的接口定义方式,即继承,使用extends关键字实现继承;最后我们通过计数器的例子,学习了如何使用混合类型接口实现更复杂的数据结构。还有一些涉及到类的关于接口的知识,我们会在讲了类之后做一个补充。

下个小节我们将学习函数的相关内容。函数是代码里的重头戏,而且内容较多,我们会分两个小节来讲解,跟紧别掉队哈。

 

11 为函数和函数参数定义类型

11 为函数和函数参数定义类型

更新时间:2019-06-12 16:37:39

 

机遇只偏爱那些有准备的头脑。 ——巴斯德

 

本小节我们来学习函数类型的定义,以及对函数参数的详细介绍。前面我们在讲object例子的时候见过简单的函数定义,在那个例子中我们学习了如何简单地为一个参数指定类型。在本小节你将学习三种定义函数类型的方式,以及关于参数的三个知识——即可选参数、默认参数和剩余参数。接下来我们开始学习。

 

2.8.1. 函数类型

(1) 为函数定义类型

我们可以给函数定义类型,这个定义包括对参数和返回值的类型定义,我们先来看简单的定义写法:

function add(arg1: number, arg2: number): number {
  return x + y;
}
// 或者
const add = (arg1: number, arg2: number): number => {
  return x + y;
};

在上面的例子中我们用function和箭头函数两种形式定义了add函数,以展示如何定义函数类型。这里参数 arg1 和 arg2 都是数值类型,最后通过相加得到的结果也是数值类型。

如果在这里省略参数的类型,TypeScript 会默认这个参数是 any 类型;如果省略返回值的类型,如果函数无返回值,那么 TypeScript 会默认函数返回值是 void 类型;如果函数有返回值,那么 TypeScript 会根据我们定义的逻辑推断出返回类型。

(2) 完整的函数类型

一个函数的定义包括函数名、参数、逻辑和返回值。我们为一个函数定义类型时,完整的定义应该包括参数类型和返回值类型。上面的例子中,我们都是在定义函数的指定参数类型和返回值类型。接下来我们看下,如何定义一个完整的函数类型,以及用这个函数类型来规定一个函数定义时参数和返回值需要符合的类型。先来看例子然后再进行解释:

let add: (x: number, y: number) => number;
add = (arg1: number, arg2: number): number => arg1 + arg2;
add = (arg1: string, arg2: string): string => arg1 + arg2; // error

上面这个例子中,我们首先定义了一个变量 add,给它指定了函数类型,也就是(x: number, y: number) => number,这个函数类型包含参数和返回值的类型。然后我们给 add 赋了一个实际的函数,这个函数参数类型和返回类型都和函数类型中定义的一致,所以可以赋值。后面我们又给它赋了一个新函数,而这个函数的参数类型和返回值类型都是 string 类型,这时就会报如下错误:

不能将类型"(arg1: string, arg2: string) => string"分配给类型"(x: number, y: number) => number"。
  参数"arg1"和"x" 的类型不兼容。
    不能将类型"number"分配给类型"string"。


函数中如果使用了函数体之外定义的变量,这个变量的类型是不体现在函数类型定义的。

(3) 使用接口定义函数类型

我们在前面的小节中已经学习了接口,使用接口可以清晰地定义函数类型。还拿上面的 add 函数为例,我们为它使用接口定义函数类型:

interface Add {
  (x: number, y: number): number;
}
let add: Add = (arg1: string, arg2: string): string => arg1 + arg2; // error 不能将类型“(arg1: string, arg2: string) => string”分配给类型“Add”

这里我们通过接口的形式定义函数类型,这个接口Add定义了这个结构是一个函数,两个参数类型都是number类型,返回值也是number类型。然后我们指定变量add类型为Add时,再要给add赋值,就必须是一个函数,且参数类型和返回值类型都要满足接口Add,显然例子中这个函数并不满足条件,所以报错了。

(4) 使用类型别名

我们可以使用类型别名来定义函数类型,类型别名我们在后面讲到高级类型的时候还会讲到。使用类型别名定义函数类型更直观易读,我们来看一下具体的写法:

type Add = (x: number, y: number) => number;
let add: Add = (arg1: string, arg2: string): string => arg1 + arg2; // error 不能将类型“(arg1: string, arg2: string) => string”分配给类型“Add”

使用type关键字可以为原始值、联合类型、元组以及任何我们定义的类型起一个别名。上面定义了 Add 这个别名后,Add就成为了一个和(x: number, y: number) => number一致的类型定义。例子中定义了Add类型,指定add类型为Add,但是给add赋的值并不满足Add类型要求,所以报错了。

2.8.2. 参数

(1) 可选参数

TypeScript 会帮我们在编写代码的时候就检查出调用函数时参数中存在的一些错误,先看下面例子:

type Add = (x: number, y: number) => number;
let add: Add = (arg1: string, arg2: string): string => arg1 + arg2;

add(1, 2); // right
add(1, 2, 3); // error 应有 2 个参数,但获得 3 个
add(1); // error 应有 2 个参数,但获得 1 个

在 JS 中,上面例子中最后两个函数调用都不会报错, 只不过add(1, 2, 3)可以返回正确结果3add(1)会返回NaN

但有时候,我们的函数有些参数不是必须的,是可选的。在学习接口的时候我们学习过,可选参数只需在参数名后跟随一个?即可。但是接口形式的定义和今天学到的函数类型定义有一点区别,那就是参数位置的要求:

接口形式定义的函数类型必选参数和可选参数的位置前后是无所谓的,但是今天学到的定义形式,可选参数必须放在必选参数后面,这和在 JS 中定义函数是一致的。

来看下面的例子:

type Add = (x?: number, y: number) => number; // error 必选参数不能位于可选参数后。

在TypeScript中,可选参数放到最后才行,上面例子中把可选参数x放到了必选参数y前面,所以报错了;但是在 JavaScript 中,其实是没有可选参数这个概念的,只不过是我们在写逻辑的时候,我们可能会判断某个参数是否为undefined,如果是则说明调用该函数的时候没有传这个参数,要做下兼容处理;而如果几个参数中,前面的参数是可不传的,后面的参数是需要传的,就需要在该可不传的参数位置传入一个 undefined 占位才行。

(2) 默认参数

在 ES6 标准出来之前,我们的默认参数实现起来比较繁琐:

// javascript
var count = 0;
function countUp(step) {
  step = step || 1;
  count += step;
}

上面我们定义了一个计数器增值函数,这个函数有一个参数 step,即每次增加的步长,如果不传入参数,那么 step 接受到的就是 undefined,undefined 转换为布尔值是 false,所以step || 1这里取了 1,从而达到了不传参数默认 step === 1 的效果。

在 ES6 中,我们定义函数时给参数设默认值就很方便了,直接在参数后面使用等号连接默认值即可:

// javascript
const count = 0;
const countUp = (step = 1) => {
  count += step;
};

你会发现,可选参数和带默认值的参数在函数调用时都是可以不传实参的,但是区别在于定义函数的时候,可选参数必须放在必选参数后面,而带默认值的参数则可放在必须参数前后都可。

当我们为参数指定了默认参数的时候,TypeScript 会识别默认参数的类型;当我们在调用函数时,如果给这个带默认值的参数传了别的类型的参数则会报错:

const add = (x: number, y = 2) => {
  return x + y;
};
add(1, "a"); // error 类型"string"的参数不能赋给类型"number"的参数

当然了,你也可以显式地给 y 设置类型:

const add = (x: number, y: number = 2) => {
  return x + y;
};

(3) 剩余参数

在 JS 中,如果我们定义一个函数,这个函数可以输入任意个数的参数,那么我们就无法在定义参数列表的时候挨个定义。在 ES6 发布之前,我们需要用到 arguments 来获取参数列表。arguments 是每一个函数都包含的一个类数组对象,它包含在函数调用时传入函数的所有实际参数(简称实参),它还包含一个 length 属性,记录参数个数。来看下面的例子,我们来模拟实现函数的重载:

// javascript
function handleData() {
  if (arguments.length === 1) return arguments[0]  2;
  else if (arguments.length === 2) return arguments[0]  arguments[1];
  else return Array.prototype.slice.apply(arguments).join("");
}
handleData(2); // 4
handleData(2, 3); // 6
handleData(1, 2, 3, 4, 5); // '1234_5'
// 这段代码如果在TypeScript环境中,三个对handleData函数的调用都会报错,因为handleData函数定义的时候没有参数。

上面这个函数通过判断传入实参的个数,做出不同的处理并返回结果。else 后面的逻辑是如果实参个数不为 1 和 2,那么将这些参数拼接成以"_"连接的字符串。

你应该注意到了我们使用Array.prototype.slice.apply(arguments)对 arguments 做了处理,前面我们讲过 arguments 不是数组,而是类数组对象,如果直接在 arguments 调用 join 方法,它是没有这个方法的。所以我们通过这个处理得到一个包含 arguments 中所有元素的真实数组。

在 ES6 中,加入了"…"拓展运算符,它可以将一个函数或对象进行拆解。它还支持用在函数的参数列表中,用来处理任意数量的参数:

const handleData = (arg1, ...args) => {
  // 这里省略逻辑
  console.log(args);
};
handleData(1, 2, 3, 4, 5); // [ 2, 3, 4, 5 ]

可以看到,args 是除了 arg1 之外的所有实参的集合,它是一个数组。

补充:"…"运算符可以拆解数组和对象,比如:arr1 = [1, 2],arr2 = [3, 4],那么[…arr1, …arr2]的结果就是[1, 2, 3, 4],他还可以用在方法的参数中:如果使用 arr1.push(arr2),则 arr1 结果是[1, 2, [3, 4]],如果你想让他们合并成一个函数而不使用 concat 方法,就可以使用 arr1.push(…arr2)。还有对象的使用方法:obj1 = { a: ‘aa’ },obj2 = { b: ‘bb’ },则{ …obj1, …obj2 }的结果是{ a: ‘aa’, b: ‘bb’ }。

在 TypeScript 中你可以为剩余参数指定类型,先来看例子:

const handleData = (arg1: number, ...args: number[]) => {
  //
};
handleData(1, "a"); // error 类型"string"的参数不能赋给类型"number"的参数

2.8.3 函数重载,此重载vs彼重载

在其他一些强类型语言中,函数重载是指定义几个函数名相同,但参数个数或类型不同的函数,在调用时传入不同的参数,编译器会自动调用适合的函数。但是 JavaScript 作为一个动态语言是没有函数重载的,只能我们自己在函数体内通过判断参数的个数、类型来指定不同的处理逻辑。来看个简单的例子:

const handleData = value => {
  if (typeof value === "string") {
    return value.split("");
  } else {
    return value
      .toString()
      .split("")
      .join("_");
  }
};

这个例子中,当传入的参数为字符串时,将它进行切割,比如传入的是’abc’,返回的将是数组[‘a’, ‘b’, ‘c’];如果传入的是一个数值类型,则将数字转为字符串然后切割成单个数字然后拼接成字符串,比如传入的是123,则返回的是’123’。你可以看到传入的参数类型不同,返回的值的类型是不同的,

在 TypeScript 中有函数重载的概念,但并不是定义几个同名实体函数,然后根据不同的参数个数或类型来自动调用相应的函数。TypeScript的函数重载是在类型系统层面的,是为了更好地进行类型推断。TypeScript的函数重载通过为一个函数指定多个函数类型定义,从而对函数调用的返回值进行检查。来看例子:

function handleData(x: string): string[]; // 这个是重载的一部分,指定当参数类型为string时,返回值为string类型的元素构成的数组
function handleData(x: number): string; // 这个也是重载的一部分,指定当参数类型为number时,返回值类型为string
function handleData(x: any): any { // 这个就是重载的内容了,他是实体函数,不算做重载的部分
  if (typeof x === "string") {
    return x.split("");
  } else {
    return x
      .toString()
      .split("")
      .join("");
  }
}
handleData("abc").join("");
handleData(123).join("_"); // error 类型"string"上不存在属性"join"
handleData(false); // error 类型"boolean"的参数不能赋给类型"number"的参数。

首先我们使用function关键字定义了两个同名的函数,但不同的是,函数没有实际的函数体逻辑,而是只定义函数名、参数及参数类型以及函数的返回值类型;而第三个使用function定义的同名函数,是一个完整的实体函数,包含函数名、参数及参数类型、返回值类型和函数体;这三个定义组成了一个函数——完整的带有类型定义的函数,前两个function定义的就称为函数重载,而第三个function并不算重载;

然后我们来看下匹配规则,当调用这个函数并且传入参数的时候,会从上而下在函数重载里匹配和这个参数个数和类型匹配的重载。如例子中第一个调用,传入了一个字符串"abc",它符合第一个重载,所以它的返回值应该是一个字符串组成的数组,数组是可以调用join方法的,所以这里没问题;

第二个调用传入的是一个数值类型的123,从上到下匹配重载是符合第二个的,返回值应该是字符串类型。但这里拿到返回值后调用了数组方法join,这肯定会报错了,因为字符串无法调用这个方法;

最后调用时传入了一个布尔类型值false,匹配不到重载,所以会报错;

最后还有一点要注意的是,这里重载只能用 function 来定义,不能使用接口、类型别名等。

 

小结

本小节我们学习了函数类型的三种定义方式:

  • 基本方式:直接在定义函数实体语句中,指定参数和返回值类型;

  • 接口形式:这种方式我们在讲接口的时候已经学习过了;

  • 类型别名:这种方式是比较推荐的写法,比较简洁清晰。

我们还详细学习了函数参数的三个知识点:

  • 可选参数:可选参数在JavaScript中可以实现,TypeScript中需要在该参数后面加个?,且可选参数必须位于必选参数后面;;

  • 默认参数:这是在ES6标准中添加的语法,为函数参数指定默认参数,写法就是在参数名后面使用=连接默认参数

  • 剩余参数:这也是在ES6中添加的语法,可以使用...参数名来获取剩余任意多个参数,获取的是一个数组。

最后我们学习了函数重载。着重强调的是,这里的函数重载区别于其他语言中的重载,TypeScript中的重载是为了针对不同参数个数和类型,推断返回值类型。

下个小节我们将学习泛型,来弥补你使用any时丢失的类型校验。

 

12 使用泛型拯救你的any

12 使用泛型拯救你的any

更新时间:2019-06-12 16:37:49

 

人的一生可能燃烧也可能腐朽,我不能腐朽,我愿意燃烧起来!——奥斯特洛夫斯基

 

在前面的小节中我们学习了any类型,当我们要表示一个值可以为任意类型的时候,则指定它的类型为any,比如下面这个例子:

const getArray = (value: any, times: number = 5): any[] => {
  return new Array(times).fill(value);
};

这个函数接受两个参数。第一个参数为任意类型的值,第二个参数为数值类型的值,默认为 5。函数的功能是返回一个以 times 为元素个数,每个元素都是 value 的数组。这个函数我们从逻辑上可以知道,传入的 value 是什么类型,那么返回的数组的每个元素也应该是什么类型。

接下来我们实际用一下这个函数:

getArray([1], 2).forEach(item => {
  console.log(item.length);
});
getArray(2, 3).forEach(item => {
  console.log(item.length);
});

我们调用了两次这个方法,使用 forEach 方法遍历得到的数组,在传入 forEach 的函数中获取当前遍历到的数组元素的 length 属性。第一次调用这个方法是没问题的,因为我们第一次传入的值为数组,得到的会是一个二维数组[ [1], [1] ]。每次遍历的元素为[1],它也是数组,所以打印它的 length 属性是可以的。而我们第二次传入的是一个数字 2,生成的数组是[2, 2, 2],访问 2 的 length 属性是没有的,所以应该报错,但是这里却不会报错,因为我们在定义getArray函数的时候,指定了返回值是any类型的元素组成的数组,所以这里遍历其返回值中每一个元素的时候,类型都是any,所以不管做任何操作都是可以的,因此,上面例子中第二次调用getArray的返回值每个元素应该是数值类型,遍历这个数组时我们获取数值类型的length属性也没报错,因为这里item的类型是any。

所以要解决这种情况,泛型就可以搞定,接下来我们来学习泛型。

2.9.1. 简单使用

要解决上面这个场景的问题,就需要使用泛型了。泛型(Generics)是指在定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。

还拿上面这个例子中的逻辑来举例,我们既要允许传入任意类型的值,又要正确指定返回值类型,就要使用泛型。我们先来看怎么改写:

const getArray = <T>(value: T, times: number = 5): T[] => {
  return new Array(times).fill(value);
};

我们在定义函数之前,使用<>符号定义了一个泛型变量 T,这个 T 在这次函数定义中就代表某一种类型,它可以是基础类型,也可以是联合类型等高级类型。定义了泛型变量之后,你在函数中任何需要指定类型的地方使用 T 都代表这一种类型。比如当我们传入 value 的类型为数值类型,那么返回的数组类型T[]就表示number[]。现在我们再来调用一下这个 getArray 函数:

getArray<number[]>([1, 2], 3).forEach(item => {
  console.log(item.length);
});
getArray<number>(2, 3).forEach(item => {
  console.log(item.length); // 类型“number”上不存在属性“length”
});

我们在调用 getArray 的时候,在方法名后面使用<>传入了我们的泛型变量 T 的类型number[],那么在定义 getArray 函数时使用 T 指定类型的地方,都会使用number[]指定。但是你也可以省略这个<number[]>,TypeScript 会根据你传入函数的 value 值的类型进行推断:

getArray(2, 3).forEach(item => {
  console.log(item.length); // 类型“number”上不存在属性“length”
});

2.9.2. 泛型变量

当我们使用泛型的时候,你必须在处理类型涉及到泛型的数据的时候,把这个数据当做任意类型来处理。这就意味着不是所有类型都能做的操作不能做,不是所有类型都能调用的方法不能调用。可能会有点绕口,我们来看个例子:

const getLength = <T>(param: T): number => {
  return param.length; // error 类型“T”上不存在属性“length”
};

当我们获取一个类型为泛型的变量 param 的 length 属性值时,如果 param 的类型为数组 Array 或字符串 string 类型是没问题的,它们有 length 属性。但是如果此时传入的 param 是数值 number 类型,那这里就会有问题了。

这里的T并不是固定的,你可以写为AB或者其他名字,而且还可以在一个函数中定义多个泛型变量。我们来看个复杂点的例子:

const getArray = <T, U>(param1: T, param2: U, times: number): [T, U][] => {
  return new Array(times).fill([param1, param2]);
};
getArray(1, "a", 3).forEach(item => {
  console.log(item[0].length); // error 类型“number”上不存在属性“length”
  console.log(item[1].toFixed(2)); // error 属性“toFixed”在类型“string”上不存在
});

这个例子中,我们定义了两个泛型变量TU。第一个参数的类型为 T,第二个参数的类型为 U,最后函数返回一个二维数组,函数返回类型我们指定是一个元素类型为[T, U]的数组。所以当我们调用函数,最后遍历结果时,遍历到的每个元素都是一个第一个元素是数值类型、第二个元素是字符串类型的数组。

2.9.3. 泛型函数类型

我们可以定义一个泛型函数类型,还记得我们之前学习函数一节时,给一个函数定义函数类型,现在我们可以使用泛型定义函数类型:

// ex1: 简单定义
const getArray: <T>(arg: T, times: number) => T[] = (arg, times) => {
  return new Array(times).fill(arg);
};
// ex2: 使用类型别名
type GetArray = <T>(arg: T, times: number) => T[];
const getArray: GetArray = <T>(arg: T, times: number): T[] => {
  return new Array(times).fill(arg);
};

当然了,我们也可以使用接口的形式来定义泛型函数类型:

interface GetArray {
  <T>(arg: T, times: number): T[];
}
const getArray: GetArray = <T>(arg: T, times: number): T[] => {
  return new Array(times).fill(arg);
};

你还可以把接口中泛型变量提升到接口最外层,这样接口中所有属性和方法都能使用这个泛型变量了。我们先来看怎么用:

interface GetArray<T> {
  (arg: T, times: number): T[];
  tag: T;
}
const getArray: GetArray<number> = <T>(arg: T, times: number): T[] => {
  // error 不能将类型“{ <T>(arg: T, times: number): T[]; tag: string; }”分配给类型“GetArray<number>”。
  // 属性“tag”的类型不兼容。
  return new Array(times).fill(arg);
};
getArray.tag = "a"; // 不能将类型“"a"”分配给类型“number”
getArray("a", 1); // 不能将类型“"a"”分配给类型“number”

上面例子中将泛型变量定义在接口最外层,所以不仅函数的类型中可以使用 T,在属性 tag 的定义中也可以使用。但在使用接口的时候,要在接口名后面明确传入一个类型,也就是这里的GetArray<number>,那么后面的 arg 和 tag 的类型都得是 number 类型。当然了,如果你还是希望 T 可以是任何类型,你可以把GetArray<number>换成GetArray<any>

2.9.4 泛型约束

当我们使用了泛型时,就意味着这个这个类型是任意类型。但在大多数情况下,我们的逻辑是对特定类型处理的。还记得我们前面讲泛型变量时举的那个例子——当访问一个泛型类型的参数的 length 属性时,会报错"类型“T”上不存在属性“length”",是因为并不是所有类型都有 length 属性。

所以我们在这里应该对 T 有要求,那就是类型为 T 的值应该包含 length 属性。说到这个需求,你应该能想到接口的使用,我们可以使用接口定义一个对象必须有哪些属性:

interface ValueWithLength {
  length: number;
}
const v: ValueWithLength = {}; // error Property 'length' is missing in type '{}' but required in type 'ValueWithLength'

泛型约束就是使用一个类型和extends对泛型进行约束,之前的例子就可以改为下面这样:

interface ValueWithLength {
  length: number;
}
const getLength = <T extends ValueWithLength>(param: T): number => {
  return param.length;
};
getLength("abc"); // 3
getLength([1, 2, 3]); // 3
getLength({ length: 3 }); // 3
getLength(123); // error 类型“123”的参数不能赋给类型“ValueWithLength”的参数

这个例子中,泛型变量T受到约束。它必须满足接口ValueWithLength,也就是不管它是什么类型,但必须有一个length属性,且类型为数值类型。例子中后面四次调用getLength方法,传入了不同的值,传入字符串"abc"、数组[1, 2, 3]和一个包含length属性的对象{ length: 3 }都是可以的,但是传入数值123不行,因为它没有length属性。

2.9.4 在泛型约束中使用类型参数

当我们定义一个对象,想要对只能访问对象上存在的属性做要求时,该怎么办?先来看下这个需求是什么样子:

const getProps = (object, propName) => {
  return object[propName];
};
const obj = { a: "aa", b: "bb" };
getProps(obj, "c"); // undefined

当我们访问这个对象的’c’属性时,这个属性是没有的。这里我们需要用到索引类型keyof结合泛型来实现对这个问题的检查。索引类型在高级类型一节会详细讲解,这里你只要知道这个例子就可以了:

const getProp = <T, K extends keyof T>(object: T, propName: K) => {
  return object[propName];
};
const obj = { a: "aa", b: "bb" };
getProp(obj, "c"); // 类型“"c"”的参数不能赋给类型“"a" | "b"”的参数

这里我们使用让K来继承索引类型keyof T,你可以理解为keyof T相当于一个由泛型变量T的属性名构成的联合类型,在这里 K 就被约束为了只能是"a"或"b",所以当我们传入字符串"c"想要获取对象obj的属性"c"时就会报错。

小结

本小节我们学习了泛型的相关知识;学习了使用泛型来弥补使用any造成的类型信息缺失;当我们的类型是灵活任意的,又要准确使用类型信息时,就需要使用泛型来关联类型信息,其中离不开的是泛型变量;泛型变量可以是多个,且命名随意;如果需要对泛型变量的类型做进一步的限制,则需要用到我们最后讲的泛型约束;使用泛型约束通过extends关键字指定要符合的类型,从而满足更多场景的需求。

下个小节我们将学习的知识,学习TypeScript中的类的知识之前,你需要先详细学习ES6标准中新增的类的知识,建议你先学习下阮一峰的《ECMAScript 6 入门》中类的部分。之所以要学习ES6中的类,是因为TypeScript中类的语法基本上是遵循ES6标准的,但是有一些区别,我们会在下个小节学习。

 

13 TS中的类,小心它与ES标准的差异

13 TS中的类,小心它与ES标准的差异

更新时间:2019-06-14 14:43:32

 

人生的价值,并不是用时间,而是用深度去衡量的。——列夫·托尔斯泰

 

虽然说类是 ES6 中新增的概念,但是在这里讲 TS 中的类,是因为在语法的实现上 TS 和 ES6 规范的,还是有点区别。在学习本节课之前,你要确定你已经详细学习了ES6标准的类的全部知识,如果没有学习,建议你先学习下阮一峰的《ECMAScript 6 入门》,学习完后再来学习本节课你会发现,一些同样的功能写法上却不同。

 

2.10.1. 基础

类的所有知识我们已经在 ES6 中的类两个课时学过了,现在我们先来看下在 TS 中定义类的一个简单例子:

class Point {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
  getPosition() {
    return (${this.x}, ${this.y});
  }
}
const point = new Point(1, 2);

我们首先在定义类的代码块的顶部定义两个实例属性,并且指定类型为 number 类型。构造函数 constructor 需要传入两个参数,都是 number 类型,并且把这两个参数分别赋值给两个实例属性。最后定义了一个定义在类的原型对象上的方法 getPosition。

同样你也可以使用继承来复用一些特性:

class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}
class Child extends Parent {
  constructor(name: string) {
    super(name);
  }
}

这些和 ES6 标准中的类没什么区别,如果大家不了解ES6标准中类关于这块的内容,建议大家先去学习ES6类的知识。

2.10.2. 修饰符

在 ES6 标准类的定义中,默认情况下,定义在实例的属性和方法会在创建实例后添加到实例上;而如果是定义在类里没有定义在 this 上的方法,实例可以继承这个方法;而如果使用 static 修饰符定义的属性和方法,是静态属性和静态方法,实例是没法访问和继承到的;我们还通过一些手段,实现了私有方法,但是私有属性的实现还不好实现。

接下来我们来看下 TS 中的公共、私有和受保护的修饰符:

(1) public

public表示公共的,用来指定在创建实例后可以通过实例访问的,也就是类定义的外部可以访问的属性和方法。默认是 public,但是 TSLint 可能会要求你必须用修饰符来表明这个属性或方法是什么类型的。

class Point {
  public x: number;
  public y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
  public getPosition() {
    return (${this.x}, ${this.y});
  }
}

(2) private

private修饰符表示私有的,它修饰的属性在类的定义外面是没法访问的:

class Parent {
  private age: number;
  constructor(age: number) {
    this.age = age;
  }
}
const p = new Parent(18);
console.log(p); // { age: 18 }
console.log(p.age); // error 属性“age”为私有属性,只能在类“Parent”中访问
console.log(Parent.age); // error 类型“typeof ParentA”上不存在属性“age”
class Child extends Parent {
  constructor(age: number) {
    super(age);
    console.log(super.age); // 通过 "super" 关键字只能访问基类的公共方法和受保护方法
  }
}

这里你可以看到,age 属性使用 private 修饰符修饰,说明他是私有属性,我们打印创建的实例对象 p,发现他是有属性 age 的,但是当试图访问 p 的 age 属性时,编译器会报错,告诉我们私有属性只能在类 Parent 中访问。

这里我们需要特别说下 super.age 这里的报错,我们在之前学习 ES6 的类的时候,讲过在不同类型的方法里 super 作为对象代表着不同的含义,这里在 constructor 中访问 super,这的 super 相当于父类本身,这里我们看到使用 private 修饰的属性,在子类中是没法访问的。

(3) protected

protected修饰符是受保护修饰符,和private有些相似,但有一点不同,protected修饰的成员在继承该类的子类中可以访问,我们再来看下上面那个例子,把父类 Parent 的 age 属性的修饰符 private 替换为 protected:

class Parent {
  protected age: number;
  constructor(age: number) {
    this.age = age;
  }
  protected getAge() {
    return this.age;
  }
}
const p = new Parent(18);
console.log(p.age); // error 属性“age”为私有属性,只能在类“ParentA”中访问
console.log(Parent.age); // error 类型“typeof ParentA”上不存在属性“age”
class Child extends Parent {
  constructor(age: number) {
    super(age);
    console.log(super.age); // undefined
    console.log(super.getAge());
  }
}
new Child(18)

protected还能用来修饰 constructor 构造函数,加了protected修饰符之后,这个类就不能再用来创建实例,只能被子类继承,这个需求我们在讲 ES6 的类的时候讲过,需要用new.target来自行判断,而 TS 则只需用 protected 修饰符即可:

class Parent {
  protected constructor() {
    //
  }
}
const p = new Parent(); // error 类“Parent”的构造函数是受保护的,仅可在类声明中访问
class Child extends Parent {
  constructor() {
    super();
  }
}
const c = new Child();

2.10.4. readonly 修饰符

在类里可以使用readonly关键字将属性设置为只读。

class UserInfo {
  readonly name: string;
  constructor(name: string) {
    this.name = name;
  }
}
const user = new UserInfo("Lison");
user.name = "haha"; // error Cannot assign to 'name' because it is a read-only property

设置为只读的属性,实例只能读取这个属性值,但不能修改。

2.10.5. 参数属性

之前的例子中,我们都是在类的定义的顶部初始化实例属性,在 constructor 里接收参数然后对实力属性进行赋值,我们可以使用参数属性来简化这个过程。参数属性简单来说就是在 constructor 构造函数的参数前面加上访问限定符,也就是前面讲的 public、private、protected 和 readonly 中的任意一个,我们来看例子:

class A {
  constructor(name: string) {}
}
const a = new A("aaa");
console.log(a.name); // error 类型“A”上不存在属性“name”
class B {
  constructor(public name: string) {}
}
const b = new B("bbb");
console.log(b.name); // "bbb"

可以看到,在定义类 B 时,构造函数有一个参数 name,这个 name 使用访问修饰符 public 修饰,此时即为 name 声明了参数属性,也就无需再显示地在类中初始化这个属性了。

2.10.6. 静态属性

和 ES6 的类一样,在 TS 中一样使用static关键字来指定属性或方法是静态的,实例将不会添加这个静态属性,也不会继承这个静态方法,你可以使用修饰符和 static 关键字来指定一个属性或方法:

class Parent {
  public static age: number = 18;
  public static getAge() {
    return Parent.age;
  }
  constructor() {
    //
  }
}
const p = new Parent();
console.log(p.age); // error Property 'age' is a static member of type 'Parent'
console.log(Parent.age); // 18

如果使用了 private 修饰道理和之前的一样:

class Parent {
  public static getAge() {
    return Parent.age;
  }
  private static age: number = 18;
  constructor() {
    //
  }
}
const p = new Parent();
console.log(p.age); // error Property 'age' is a static member of type 'Parent'
console.log(Parent.age); // error 属性“age”为私有属性,只能在类“Parent”中访问。

2.10.7. 可选类属性

TS 在 2.0 版本,支持可选类属性,也是使用?符号来标记,来看例子:

class Info {
  name: string;
  age?: number;
  constructor(name: string, age?: number, public sex?: string) {
    this.name = name;
    this.age = age;
  }
}
const info1 = new Info("lison");
const info2 = new Info("lison", 18);
const info3 = new Info("lison", 18, "man");

2.10.8. 存取器

这个也就 ES6 标准中的存值函数和取值函数,也就是在设置属性值的时候调用的函数,和在访问属性值的时候调用的函数,用法和写法和 ES6 的没有区别:

class UserInfo {
  private fullName: string;
  constructor() {}
  get fullName() {
    return this.fullName;
  }
  set fullName(value) {
    console.log(setter: ${value});
    this._fullName = value;
  }
}
const user = new UserInfo();
user.fullName = "Lison Li"; // "setter: Lison Li"
console.log(user.fullName); // "Lison Li"

2.10.9. 抽象类

抽象类一般用来被其他类继承,而不直接用它创建实例。抽象类和类内部定义抽象方法,使用abstract关键字,我们先来看个例子:

abstract class People {
  constructor(public name: string) {}
  abstract printName(): void;
}
class Man extends People {
  constructor(name: string) {
    super(name);
    this.name = name;
  }
  printName() {
    console.log(this.name);
  }
}
const m = new Man(); // error 应有 1 个参数,但获得 0 个
const man = new Man("lison");
man.printName(); // 'lison'
const p = new People("lison"); // error 无法创建抽象类的实例

上面例子中我们定义了一个抽象类 People,在抽象类里我们定义 constructor 方法必须传入一个字符串类型参数,并把这个 name 参数值绑定在创建的实例上;使用abstract关键字定义一个抽象方法 printName,这个定义可以指定参数,指定参数类型,指定返回类型。当我们直接使用抽象类 People 实例化的时候,就会报错,我们只能创建一个继承抽象类的子类,使用子类来实例化。

我们再来看个例子:

abstract class People {
  constructor(public name: string) {}
  abstract printName(): void;
}
class Man extends People {
  // error 非抽象类“Man”不会实现继承自“People”类的抽象成员"printName"
  constructor(name: string) {
    super(name);
    this.name = name;
  }
}
const m = new Man("lison");
m.printName(); // error m.printName is not a function

通过上面的例子我们可以看到,在抽象类里定义的抽象方法,在子类中是不会继承的,所以在子类中必须实现该方法的定义。

2.0 版本开始,abstract关键字不仅可以标记类和类里面的方法,还可以标记类中定义的属性和存取器:

abstract class People {
  abstract name: string;
  abstract get insideName(): string;
  abstract set insideName(value: string);
}
class Pp extends People {
  name: string;
  insideName: string;
}

但是要记住,抽象方法和抽象存取器都不能包含实际的代码块。

2.10.10. 实例类型

当我们定义一个类,并创建实例后,这个实例的类型就是创建他的类:

class People {
  constructor(public name: string) {}
}
let p: People = new People("lison");

当然了,创建实例的时候这指定 p 的类型为 People 并不是必须的,TS 会推断出他的类型。虽然指定了类型,但是当我们再定义一个和 People 类同样实现的类 Animal,并且创建实例赋值给 p 的时候,是没有问题的:

class Animal {
  constructor(public name: string) {}
}
let p = new Animal("lark");

所以,如果你想实现对创建实例的类的判断,还是需要用到instanceof关键字。

2.10.11. 对前面跳过知识的补充

现在我们把之前因为没有学习类的使用,所以暂时跳过的内容补回来。

(1) 类类型接口

使用接口可以强制一个类的定义必须包含某些内容,先来看个例子:

interface FoodInterface {
  type: string;
}
class FoodClass implements FoodInterface {
  // error Property 'type' is missing in type 'FoodClass' but required in type 'FoodInterface'
  static type: string;
  constructor() {}
}

上面接口 FoodInterface 要求使用该接口的值必须有一个 type 属性,定义的类 FoodClass 要使用接口,需要使用关键字implementsimplements关键字用来指定一个类要继承的接口,如果是接口和接口、类和类直接的继承,使用extends,如果是类继承接口,则用implements。

有一点需要注意,接口检测的是使用该接口定义的类创建的实例,所以上面例子中虽然定义了静态属性 type,但静态属性不会添加到实例上,所以还是报错,所以我们可以这样改:

interface FoodInterface {
  type: string;
}
class FoodClass implements FoodInterface {
  constructor(public type: string) {}
}

当然这个需求你也可以使用本节课学习的抽象类实现:

abstract class FoodAbstractClass {
  abstract type: string;
}
class Food extends FoodAbstractClass {
  constructor(public type: string) {
    super();
  }
}

(2) 接口继承类

接口可以继承一个类,当接口继承了该类后,会继承类的成员,但是不包括其实现,也就是只继承成员以及成员类型。接口还会继承类的privateprotected修饰的成员,当接口继承的这个类中包含这两个修饰符修饰的成员时,这个接口只可被这个类或他的子类实现。

class A {
  protected name: string;
}
interface I extends A {}
class B implements I {} // error Property 'name' is missing in type 'B' but required in type 'I'
class C implements I {
  // error 属性“name”受保护,但类型“C”并不是从“A”派生的类
  name: string;
}
class D extends A implements I {
  getName() {
    return this.name;
  }
}

(3) 在泛型中使用类类型

这里我们先来看个例子:

const create = <T>(c: { new (): T }): T => {
  return new c();
};
class Info {
  age: number;
}
create(Info).age;
create(Info).name; // error 类型“Info”上不存在属性“name”

在这个例子里,我们创建了一个一个 create 函数,传入的参数是一个类,返回的是一个类创建的实例,这里有几个点要讲:

  • 参数 c 的类型定义中,new()代表调用类的构造函数,他的类型也就是类创建实例后的实例的类型。

  • return new c()这里使用传进来的类 c 创建一个实例并返回,返回的实例类型也就是函数的返回值类型。

所以通过这个定义,TS 就知道,调用 create 函数,传入的和返回的值都应该是同一个类类型。

小结

本小节我们详细学习了类的知识,因为TypeScript中类的概念是遵循ES6标准的同时,添加了新语法的,所以学习完本小节后,你应该记住ES6标准和TypeScript中类的区别,避免在纯JavaScript中使用了TypeScript的语法。我们学习了三个类的修饰符:

  • public:公有属性方法修饰符,这是默认修饰符;

  • private:私有修饰符,它修饰的属性在类的定义外面无法访问;

  • protected:和private相似,区别在于他修饰的成员在继承该类的子类中可以访问。

还有一个readonly修饰符,他在讲前面知识的时候就遇到过,只读修饰符。我们还学习了如何使用参数属性来简化实例属性的初始化过程,还有使用定义函数可选参数同样的方式来定义构造函数可选参数。我们学习了如何定义抽象类,使用abstract关键字修饰类定义,抽象类一般用来被其他类继承,而不直接用它创建实例。我们还学习了,类既是值,也是类型,当我们使用类创建一个实例的时候,这个实例的类型也就是这个创建这个实例的类。最后我们对前面讲接口和泛型时涉及到类跳过的知识进行补充讲解:类类型接口、接口继承类和在泛型中使用类类型。

学习完本小节后,第二章基础部分就学习完了,这部分知识是学习后面章节的重要基础,所以你一定要多看多练多理解,下一章我们开始学习进阶部分,别掉队哦。

 

14 类型推论,看TS有多懂你

14 类型推论,看TS有多懂你

更新时间:2019-06-17 19:41:31

 

构成我们学习最大障碍的是已知的东西,而不是未知的东西。—— 贝尔纳

 

在学习基础部分的章节时,我们讲过,在一些定义中如果你没有明确指定类型,编译器会自动推断出适合的类型;比如下面的这个简单例子:

let name = "lison";
name = 123; // error 不能将类型“123”分配给类型“string”

我们看到,在定义变量 name 的时候我们并没有指定 name 的类型,而是直接给它赋一个字符串。当我们再给 name 赋一个数值的时候,就会报错。在这里,TypeScript 根据我们赋给 name 的值的类型,推断出我们的 name 的类型,这里是 string 类型,当我们再给 string 类型的 name 赋其他类型值的时候就会报错。

这个是最基本的类型推论,根据右侧的值推断左侧变量的类型,接下来我们看两个更复杂的推论。

3.1.1 多类型联合

当我们定义一个数组或元组这种包含多个元素的值的时候,多个元素可以有不同的类型,这种时候 TypeScript 会将多个类型合并起来,组成一个联合类型,来看例子:

let arr = [1, "a"];
arr = ["b", 2, false]; // error 不能将类型“false”分配给类型“string | number”

可以看到,此时的 arr 的元素被推断为string | number,也就是元素可以是 string 类型也可以是 number 类型,除此两种类型外的类型是不可以的。再来看个例子:

let value = Math.random() * 10 > 5 ? 'abc' : 123
value = false // error 不能将类型“false”分配给类型“string | number”

这里我们给value赋值为一个三元操作符表达式,Math.random() * 10的值为0-10的随机数。这里判断,如果这个随机值大于5,则赋给value的值为字符串’abc’,否则为数值123,所以最后编译器推断出的类型为联合类型string | number,当给它再赋值为false的时候就会报错。

3.1.2 上下文类型

我们上面讲的两个例子都是根据=符号右边值的类型,推断左侧值的类型。现在要讲的上下文类型则相反,它是根据左侧的类型推断右侧的一些类型,先来看例子:

window.onmousedown = function(mouseEvent) {
  console.log(mouseEvent.a); // error 类型“MouseEvent”上不存在属性“a”
};

我们可以看到,表达式左侧是 window.onmousedown(鼠标按下时发生事件),因此 TypeScript 会推断赋值表达式右侧函数的参数是事件对象,因为左侧是 mousedown 事件,所以 TypeScript 推断 mouseEvent 的类型是 MouseEvent。在回调函数中使用 mouseEvent 的时候,你可以访问鼠标事件对象的所有属性和方法,当访问不存在属性的时候,就会报错。

以上便是我要讲的三种常见的类型推论。在我们日常开发中,必写的类型还是要明确指定的,这样我们才能更准确地得到类型信息和开发辅助。

本节小结

本小节我们学习了TypeScript编译器进行类型推断的论据,其中有两种是由右推左的,也就是在赋值时根据右侧要赋的具体值,推断左侧要赋值的目标的类型,包括基本推论和多类型联合推论。基础推论是最基础的推论,多类型联合推论是根据数组、代码逻辑等,推断出多个符合的类型,然后组成联合类型的推论。还有一种由左推右的推论,我们是通过给元素绑定事件来讲解的,根据左侧要赋值的目标,来推断出右侧要赋的值中的一些类型信息。

下个小节我们将学习类型兼容性,我们知道JavaScript是灵活的,所以TypeScript通过类型兼容性来满足它的灵活特点,下个小节我们将介绍多种情况的兼容性表现。

 

15 类型兼容性,开放心态满足灵活的JS

15 类型兼容性,开放心态满足灵活的JS

更新时间:2019-07-22 11:50:52

 

每个人都是自己命运的主宰。——斯蒂尔斯

 

我们知道JavaScript是弱类型语言,它对类型是弱校验,正因为这个特点,所以才有了TypeScript这个强类型语言系统的出现,来弥补类型检查的短板。TypeScript在实现类型强校验的同时,还要满足JavaScript灵活的特点,所以就有了类型兼容性这个概念。本小节我们就来全面学习一下TypeScript的类型兼容性。

 

3.2.1 函数兼容性

函数的兼容性简单总结就是如下六点:

(1) 函数参数个数

函数参数个数如果要兼容,需要满足一个要求:如果对函数 y 进行赋值,那么要求 x 中的每个参数都应在 y 中有对应,也就是 x 的参数个数小于等于 y 的参数个数,来看例子:

let x = (a: number) => 0;
let y = (b: number, c: string) => 0;

上面定义的两个函数,如果进行赋值的话,来看下两种情况的结果:

y = x; // 没问题

将 x 赋值给 y 是可以的,因为如果对函数 y 进行赋值,那么要求 x 中的每个参数都应在 y 中有对应,也就是 x 的参数个数小于等于 y 的参数个数,而至于参数名是否相同是无所谓的。

x = y; // error Type '(b: number, s: string) => number' is not assignable to type '(a: number) => number'

这个例子中,y 要赋值给 x,但是 y 的参数个数要大于 x,所以报错。

这可能不好理解,我们用另一个例子来解释下:

const arr = [1, 2, 3];
arr.forEach((item, index, array) => {
  console.log(item);
});
arr.forEach(item => {
  console.log(item);
});

这个例子中,传给 forEach 的回调函数的参数是三个,但是可以只用一个,这样就只需写一个参数。我们传入的 forEach 的函数是 forEach 的参数,它是一个函数,这个函数的参数列表是定义在 forEach 方法内的,我们可以传入一个参数少于等于参数列表的函数,但是不能传入一个比参数列表参数个数还多的函数。

(2)函数参数类型

除了参数个数,参数的类型需要对应:

let x = (a: number) => 0;
let y = (b: string) => 0;
let z = (c: string) => false;
x = y; // error 不能将类型“(b: string) => number”分配给类型“(a: number) => number”。
x = z; // error 不能将类型“(c: string) => boolean”分配给类型“(a: number) => number”。

我们看到 x 和 y 两个函数的参数个数和返回值都相同,只是参数类型对不上,所以也是不行的。

如果函数 z 想要赋值给 x,要求 y 的返回值类型必须是 x 的返回值类型的子类型,这个例子中 x 函数的返回值是联合类型,也就是返回值既可以是 string 类型也可以是 number 类型。而 y 的返回值类型是 number 类型,参数个数和类型也没问题,所以可以赋值给 x。而 z 的返回值类型 false 并不是 string 也不是 number,所以不能赋值。

(3)剩余参数和可选参数

当要被赋值的函数参数中包含剩余参数(…args)时,赋值的函数可以用任意个数参数代替,但是类型需要对应。来看例子:

const getNum = ( // 这里定义一个getNum函数,他有两个参数
  arr: number[], // 第一个参数是一个数组
  callback: (...args: number[]) => number // 第二个参数是一个函数,这个函数的类型要求可以传入任意多个参数,但是类型必须是数值类型,返回值必须是数值类型
): number => {
  return callback(...arr); // 这个getNum函数直接返回调用传入的第二个参数这个函数,以第一个参数这个数组作为参数的函数返回值
};
getNum(
  [1, 2],
  (...args: number[]): number => args.length // 这里传入一个函数,逻辑是返回参数的个数
);

剩余参数其实可以看做无数个可选参数,所以在兼容性方面是差不多的,我们来看个可选参数和剩余参数结合的例子:

const getNum = (
  arr: number[],
  callback: (arg1: number, arg2?: number) => number // 这里指定第二个参数callback是一个函数,函数的第二个参数为可选参数
): number => {
  return callback(...arr); // error 应有 1-2 个参数,但获得的数量大于等于 0
};

这里因为arr可能为空数组或不为空,如果为空数组则…arr不会给callback传入任何实际参数,所以这里报错。如果我们换成return callback(arr[0], …arr)就没问题了。

(4) 函数参数双向协变

函数参数双向协变即参数类型无需绝对相同,来看个例子:

let funcA = function(arg: number | string): void {};
let funcB = function(arg: number): void {};
// funcA = funcB 和 funcB = funcA都可以

在这个例子中,funcA 和 funcB 的参数类型并不完全一样,funcA 的参数类型为一个联合类型 number | string,而 funcB 的参数类型为 number | string 中的 number,他们两个函数也是兼容的。

注:要允许双向协变兼容,需要配置tsconfig.json文件的"strictFunctionTypes"选项为false,默认为false,但是如果你设置了"strict"为true,需要显式设置"strictFunctionTypes"为false。

(5) 函数返回值类型

let x = (a: number): string | number => 0;
let y = (b: number) => "a";
let z = (c: number) => false;
x = y;
x = z; // 不能将类型“(c: number) => boolean”分配给类型“(a: number) => string | number”

(6) 函数重载

带有重载的函数,要求被赋值的函数的每个重载都能在用来赋值的函数上找到对应的签名,来看例子:

function merge(arg1: number, arg2: number): number; // 这是merge函数重载的一部分
function merge(arg1: string, arg2: string): string; // 这也是merge函数重载的一部分
function merge(arg1: any, arg2: any) { // 这是merge函数实体
  return arg1 + arg2;
}
function sum(arg1: number, arg2: number): number; // 这是sum函数重载的一部分
function sum(arg1: any, arg2: any): any { // 这是sum函数实体
  return arg1 + arg2;
}
let func = merge;
func = sum; // error 不能将类型“(arg1: number, arg2: number) => number”分配给类型“{ (arg1: number, arg2: number): number; (arg1: string, arg2: string): string; }”

上面例子中,sum函数的重载缺少参数都为string返回值为string的情况,与merge函数不兼容,所以赋值时会报错。

3.2.2 枚举

数字枚举成员类型与数字类型互相兼容,来看例子:

enum Status {
  On,
  Off
}
let s = Status.On;
s = 1;
s = 3;

虽然Status.On的值是0,但是这里数字枚举成员类型和数值类型互相兼容,所以这里给s赋值为3也没问题。

但是不同枚举值之间是不兼容的:

enum Status {
  On,
  Off
}
enum Color {
  White,
  Black
}
let s = Status.On;
s = Color.White; // error Type 'Color.White' is not assignable to type 'Status'

可以看到,虽然 Status.On 和 Color.White 的值都是 0,但它们是不兼容的。

字符串枚举成员类型和字符串类型是不兼容的,来看例子:

enum Status {
  On = 'on',
  Off = 'off'
}
let s = Status.On
s = 'Lison' // error 不能将类型“"Lison"”分配给类型“Status”

这里会报错,因为字符串字面量类型'Lison'和Status.On是不兼容的。

3.2.3 类

基本比较

比较两个类类型的值的兼容性时,只比较实例的成员,类的静态成员和构造函数不进行比较:

class Animal {
  static age: number;
  constructor(public name: string) {}
}
class People {
  static age: string;
  constructor(public name: string) {}
}
class Food {
  constructor(public name: number) {}
}
let a: Animal;
let p: People;
let f: Food;
a = p; // right
a = f; // error Type 'Food' is not assignable to type 'Animal'

上面例子中,Animal类和People类都有一个age静态属性,它们都定义了实例属性name,且name的类型都是string。我们看到把类型为People的p赋值给类型为Animal的a没有问题,因为我们讲了,类类型比较兼容性时,只比较实例的成员,这两个变量虽然类型是不同的类类型,但是它们都有相同字段和类型的实例属性name,而类的静态成员是不影响兼容性的,所以它俩兼容。而类Food定义了一个实例属性name,类型为number,所以类型为Food的f与类型为Animal的a类型不兼容,不能赋值。

类的私有成员和受保护成员

类的私有成员和受保护成员会影响兼容性。当检查类的实例兼容性时,如果目标(也就是要被赋值的那个值)类型(这里实例类型就是创建它的类)包含一个私有成员,那么源(也就是用来赋值的值)类型必须包含来自同一个类的这个私有成员,这就允许子类赋值给父类。先来看例子:

class Parent {
  private age: number;
  constructor() {}
}
class Children extends Parent {
  constructor() {
    super();
  }
}
class Other {
  private age: number;
  constructor() {}
}

const children: Parent = new Children();
const other: Parent = new Other(); // 不能将类型“Other”分配给类型“Parent”。类型具有私有属性“age”的单独声明

可以看到,当指定 other 为 Parent 类类型,给 other 赋值 Other 创建的实例的时候,会报错。因为 Parent 的 age 属性是私有成员,外界是无法访问到的,所以会类型不兼容。而children的类型我们指定为了Parent类类型,然后给它赋值为Children类的实例,没有问题,是因为Children类继承Parent类,且实例属性没有差异,Parent类有私有属性age,但是因为Children类继承了Parent类,所以可以赋值。

同样,使用 protected 受保护修饰符修饰的属性,也是一样的。

class Parent {
  protected age: number;
  constructor() {}
}
class Children extends Parent {
  constructor() {
    super();
  }
}
class Other {
  protected age: number;
  constructor() {}
}
const children: Parent = new Children();
const other: Parent = new Other(); // 不能将类型“Other”分配给类型“Parent”。属性“age”受保护,但类型“Other”并不是从“Parent”派生的类

3.2.4 泛型

泛型包含类型参数,这个类型参数可能是任意类型,使用时类型参数会被指定为特定的类型,而这个类型只影响使用了类型参数的部分。来看例子:

interface Data<T> {}
let data1: Data<number>;
let data2: Data<string>;

data1 = data2;

在这个例子中,data1 和 data2 都是 Data 接口的实现,但是指定的泛型参数的类型不同,TS 是结构性类型系统,所以上面将 data2 赋值给 data1 是兼容的,因为 data2 指定了类型参数为 string 类型,但是接口里没有用到参数 T,所以传入 string 类型还是传入 number 类型并没有影响。我们再来举个例子看下:

interface Data<T> {
  data: T;
}
let data1: Data<number>;
let data2: Data<string>;

data1 = data2; // error 不能将类型“Data<string>”分配给类型“Data<number>”。不能将类型“string”分配给类型“number”

现在结果就不一样了,赋值时报错,因为 data1 和 data2 传入的泛型参数类型不同,生成的结果结构是不兼容的。

本节小结

本小节我们学习了TypeScript的类型兼容性,学习了各种情况下赋值的可行性。这里面函数的兼容性最为复杂,能够影响函数兼容性的因素有:

  • 函数参数个数: 如果对函数 y 进行赋值,那么要求 x 中的每个参数都应在 y 中有对应,也就是 x 的参数个数小于等于 y 的参数个数;

  • 函数参数类型: 这一点其实和基本的赋值兼容性没差别,只不过比较的不是变量之间而是参数之间;

  • 剩余参数和可选参数: 当要被赋值的函数参数中包含剩余参数(…args)时,赋值的函数可以用任意个数参数代替,但是类型需要对应,可选参数效果相似;

  • 函数参数双向协变: 即参数类型无需绝对相同;

  • 函数返回值类型: 这一点和函数参数类型的兼容性差不多,都是基础的类型比较;

  • 函数重载: 要求被赋值的函数每个重载都能在用来赋值的函数上找到对应的签名。

枚举较为简单,数字枚举成员类型与数值类型兼容,字符串枚举成员与字符串类型不兼容。类的兼容性比较的主要依据是实例成员,但是私有成员和受保护成员也会影响兼容性。最后是涉及到泛型的类型兼容性,一定要记住一点的是使用时指定的特定类型只会影响使用了类型参数的部分

下个小节我们学习类型保护,还记得前面讲TS中补充的六个类型和类型断言的时候,都提到过类型保护,使用类型保护,可以明确告诉编译器某个值是某种类型,虽然听起来和类型断言一样,但是它要比类型断言更便捷,我们下节课来进行详细学习。

 

16 使用类型保护让TS更聪明

16 使用类型保护让TS更聪明

更新时间:2019-07-10 17:38:05

 

世界上最宽阔的是海洋,比海洋更宽阔的是天空,比天空更宽阔的是人的胸怀。——雨果

 

这个小节我们来学习类型保护,在学习前面知识的时候我们有遇到过需要告诉编译器某个值是指定类型的场景,当时我们使用的是类型断言,这一节我们来看一个不同的场景:

const valueList = [123, "abc"];
const getRandomValue = () => {
  const number = Math.random() * 10; // 这里取一个[0, 10)范围内的随机值
  if (number < 5) return valueList[0]; // 如果随机数小于5则返回valueList里的第一个值,也就是123
  else return valueList[1]; // 否则返回"abc"
};
const item = getRandomValue();
if (item.length) {
  // error 类型“number”上不存在属性“length”
  console.log(item.length); // error 类型“number”上不存在属性“length”
} else {
  console.log(item.toFixed()); // error 类型“string”上不存在属性“toFixed”
}

上面这个例子中,getRandomValue 函数返回的元素是不固定的,有时返回数值类型,有时返回字符串类型。我们使用这个函数生成一个值 item,然后接下来的逻辑是通过是否有 length 属性来判断是字符串类型,如果没有 length 属性则为数值类型。在 js 中,这段逻辑是没问题的,但是在 TS 中,因为 TS 在编译阶段是无法知道 item 的类型的,所以当我们在 if 判断逻辑中访问 item 的 length 属性的时候就会报错,因为如果 item 为 number 类型的话是没有 length 属性的。

这个问题我们可以先采用类型断言的方式来解决。类型断言我们学习过,就是相当于告诉 TS,这个值就是制定的类型,我们只需要修改判断逻辑即可,来看怎么写:

if ((<string>item).length) {
  console.log((<string>item).length);
} else {
  console.log((<number>item).toFixed());
}

3.3.1 自定义类型保护

上面的代码不报错是因为我们通过使用类型断言,告诉 TS 编译器,if 中的 item 是 string 类型,而 else 中的是 number 类型。这样做虽然可以,但是我们需要在使用 item 的地方都使用类型断言来说明,显然有些繁琐,所以我们就可以使用类型保护来优化。

我们先来看,本小节开头这个问题,如何使用自定义类型保护来解决:

const valueList = [123, 'abc'];
const getRandomValue = () => {
  const value = Math.random() * 10; // 这里取一个[0, 10)范围内的随机值
  if (value < 5) { return valueList[0]; } else { return valueList[1]; } // 否则返回"abc"
};
function isString(value: number | string): value is string {
  return typeof value === 'string';
}
const item = getRandomValue();
if (isString(item)) {
  console.log(item.length); // 此时item是string类型
} else {
  console.log(item.toFixed()); // 此时item是number类型
}

我们看到,首先定义一个函数,函数的参数 value 就是要判断的值,在这个例子中 value 的类型可以为 number 或 string,函数的返回值类型是一个结构为 value is type 的类型谓语,value 的命名无所谓,但是谓语中的 value 名必须和参数名一致。而函数里的逻辑则用来返回一个布尔值,如果返回为 true,则表示传入的值类型为is后面的 type。

使用类型保护后,if 的判断逻辑和代码块都无需再对类型做指定工作,不仅如此,既然 item 是 string 类型,则 else 的逻辑中,item 一定是联合类型两个类型中另外一个,也就是 number 类型。

3.3.2 typeof 类型保护

但是这样定义一个函数来用于判断类型是字符串类型,难免有些复杂,因为在 JavaScript 中,只需要在 if 的判断逻辑地方使用 typeof 关键字即可判断一个值的类型。所以在 TS 中,如果是基本类型,而不是复杂的类型判断,你可以直接使用 typeof 来做类型保护:

if (typeof item === "string") {
  console.log(item.length);
} else {
  console.log(item.toFixed());
}

这样直接写也是可以的,效果和自定义类型保护一样。但是在 TS 中,对 typeof 的处理还有些特殊要求:

  • 只能使用=!两种形式来比较

  • type 只能是numberstringbooleansymbol四种类型

第一点要求我们必须使用这两种形式来做比较,比如你使用(typeof item).includes(‘string’)也能做判断,但是不行的。

第二点要求我们要比较的类型只能是这四种,但是我们知道,在 JS 中,typeof xxx的结果还有objectfunction和 undefined 。但是在 TS 中,只会把对前面四种类型的 typeof 比较识别为类型保护,你可以使用typeof {} === ‘object’,但是这里它只是一条普通的 js 语句,不具有类型保护具有的效果。我们可以来看例子:

const valueList = [{}, () => {}];
const getRandomValue = () => {
  const number = Math.random() * 10;
  if (number < 5) {
    return valueList[0];
  } else {
    return valueList[1];
  }
};
const res = getRandomValue();
if (typeof res === "object") {
  console.log(res.toString());
} else {
  console.log(ress()); // error 无法调用类型缺少调用签名的表达式。类型“{}”没有兼容的调用签名
}

3.3.3 instanceof 类型保护

instanceof操作符是 JS 中的原生操作符,它用来判断一个实例是不是某个构造函数创建的,或者是不是使用 ES6 语法的某个类创建的。在 TS 中,使用 instanceof 操作符同样会具有类型保护效果,来看例子:

class CreateByClass1 {
  public age = 18;
  constructor() {}
}
class CreateByClass2 {
  public name = "lison";
  constructor() {}
}
function getRandomItem() {
  return Math.random() < 0.5 ? new CreateByClass1() : new CreateByClass2(); // 如果随机数小于0.5就返回CreateByClass1的实例,否则返回CreateByClass2的实例
}
const item = getRandomItem();
if (item instanceof CreateByClass1) { // 这里判断item是否是CreateByClass1的实例
  console.log(item.age);
} else {
  console.log(item.name);
}

这个例子中 if 的判断逻辑中使用 instanceof 操作符判断了 item 。如果是 CreateByClass1 创建的,那么它应该有 age 属性,如果不是,那它就有 name 属性。

本节小结

本小节我们学习了类型保护,通过使用类型保护可以更好地指定某个值的类型,可以把这个指定理解为一种强制转换,这样编译器就能知道我们这个值是我们指定的类型,从而符合我们的预期。typeof 和 instanceof 是JavaScript 中的两个操作符,用来判断某个值的类型和一个值是否是某个构造函数的实例,它们在 TypeScript 中会被当做类型保护。我们也可以自定义类型保护,通过定义一个返回值类型是"参数名 is type"的语句,来指定传入这个类型保护函数的某个参数是什么类型。如果只是简单地要判断某个值是什么类型,使用 typeof 类型保护就可以了。

 

17 使用显式复制断言给TS一个你一定会赋值的承诺

17 使用显式复制断言给TS一个你一定会赋值的承诺

更新时间:2019-06-24 17:58:39

 

人不可有傲气,但不可无傲骨。——徐悲鸿

 

在讲解本小节的主要内容之前,我们先来补充两个关于null和undefined的知识点:

 

(1) 严格模式下null和undefined赋值给其它类型值

当我们在 tsconfig.json 中将 strictNullChecks 设为 true 后,就不能再将 undefined 和 null 赋值给除它们自身和void 之外的任意类型值了,但有时我们确实需要给一个其它类型的值设置初始值为空,然后再进行赋值,这时我们可以自己使用联合类型来实现 null 或 undefined 赋值给其它类型:

let str = "lison";
str = null; // error 不能将类型“null”分配给类型“string”
let strNull: string | null = "lison"; // 这里你可以简单理解为,string | null即表示既可以是string类型也可以是null类型
strNull = null; // right
strNull = undefined; // error 不能将类型“undefined”分配给类型“string | null”

注意,TS 会将 undefined 和 null 区别对待,这和 JS 的本意也是一致的,所以在 TS 中,string|undefinedstring|nullstring|undefined|null是三种不同的类型。

(2) 可选参数和可选属性

如果开启了 strictNullChecks,可选参数会被自动加上|undefined,来看例子:

const sum = (x: number, y?: number) => {
  return x + (y || 0);
};
sum(1, 2); // 3
sum(1); // 1
sum(1, undefined); // 1
sum(1, null); // error Argument of type 'null' is not assignable to parameter of type 'number | undefined'

可以根据错误信息看出,这里的参数 y 作为可选参数,它的类型就不仅是 number 类型了,它可以是 undefined,所以它的类型是联合类型number | undefined

TS 对可选属性和对可选参数的处理一样,可选属性的类型也会被自动加上|undefined

interface PositionInterface {
  x: number;
  b?: number;
}
const position: PositionInterface = {
  x: 12
};
position.b = "abc"; // error
position.b = undefined; // right
position.b = null; // error

3.4.1 显式赋值断言

接下来我们来看显式赋值断言。当我们开启 strictNullChecks 时,有些情况下编译器是无法在我们声明一些变量前知道一个值是否是 null 的,所以我们需要使用类型断言手动指明该值不为 null。这可能不好理解,接下来我们就来看一个编译器无法推断出一个值是否是null的例子:

function getSplicedStr(num: number | null): string {
  function getRes(prefix: string) { // 这里在函数getSplicedStr里定义一个函数getRes,我们最后调用getSplicedStr返回的值实际是getRes运行后的返回值
    return prefix + num.toFixed().toString(); // 这里使用参数num,num的类型为number或null,在运行前编译器是无法知道在运行时num参数的实际类型的,所以这里会报错,因为num参数可能为null
  }
  num = num || 0.1; // 但是这里进行了赋值,如果num为null则会将0.1赋给num,所以实际调用getRes的时候,getRes里的num拿到的始终不为null
  return getRes("lison");
}

这个例子中,因为有嵌套函数,而编译器无法去除嵌套函数的 null(除非是立即调用的函数表达式),所以我们需要使用显式赋值断言,写法就是在不为 null 的值后面加个!。来看上面的例子该怎么改:

function getSplicedStr(num: number | null): string {
  function getLength(prefix: string) {
    return prefix + num!.toFixed().toString();
  }
  num = num || 0.1;
  return getLength("lison");
}

这样编译器就知道了,num 不为 null,即便 getSplicedStr 函数在调用的时候传进来的参数是null,在 getLength函数中的 num 也不会是 null。

本节小结

本小节我们补充学习了两个关于null和undefined的知识点。一个是如何在严格模式,也就是在tsconfig.json中将strictNullChecks设为true的情况下,将null或undefined赋值给除它们自身和void之外的类型的值;另一个知识点是当将strictNullChecks设为true后,编译器对可选参数和可选属性类型定义的处理,效果相当于在我们指定的类型后面加上|undefined。最后我们学习了如何使用显式赋值断言,它的作用就是告诉编译器某个值确实不为null,这个我们在实际开发中常会用到,我们在实战章节中用到时会再次学习。

下个小节我们将学习类型别名和字面量类型。类型别名我们在前面简单接触过,它的语法类似赋值语句,只不过赋的不是具体的值,而是一个类型;字面量类型我们称它为单一的类型,它包含数字字面量类型和字符串字面量类型两种,下个小节我们来进行详细学习。

 

 

18 类型别名和字面量类型—单调的类型

18 类型别名和字面量类型—单调的类型

更新时间:2019-06-25 17:21:46

 

学习从来无捷径,循序渐进登高峰。—— 高永祚

本小节我们来学习类型别名和字面量类型。类型别名我们之前在讲泛型的时候接触过,现在来详细学习下。

 

3.5.1 类型别名

类型别名就是给一种类型起个别的名字,之后只要使用这个类型的地方,都可以用这个名字作为类型代替,但是它只是起了一个名字,并不是创建了一个新类型。这种感觉就像 JS 中对象的赋值,你可以把一个对象赋给一个变量,使用这个对象的地方都可以用这个变量代替,但你并不是创建了一个新对象,而是通过引用来使用这个对象。

我们来看下怎么定义类型别名,使用 type 关键字:

type TypeString = string;
let str: TypeString;
str = 123; // error Type '123' is not assignable to type 'string'

类型别名也可以使用泛型,来看例子:

type PositionType<T> = { x: T; y: T };
const position1: PositionType<number> = {
  x: 1,
  y: -1
};
const position2: PositionType<string> = {
  x: "right",
  y: "top"
};

使用类型别名时也可以在属性中引用自己:

type Child<T> = {
  current: T;
  child?: Child<T>;
};
let ccc: Child<string> = {
  current: "first",
  child: {
    // error
    current: "second",
    child: {
      current: "third",
      child: "test" // 这个地方不符合type,造成最外层child处报错
    }
  }
};

但是要注意,只可以在对象属性中引用类型别名自己,不能直接使用,比如下面这样是不对的:

type Child = Child[]; // error 类型别名“Child”循环引用自身

另外要注意,因为类型别名只是为其它类型起了个新名字来引用这个类型,所以当它为接口起别名时,不能使用 extends 和 implements 。

接口和类型别名有时可以起到同样作用,比如下面这个例子:

type Alias = {
  num: number;
};
interface Interface {
  num: number;
}
let alias: Alias = {
  num: 123
};
let interface: Interface = {
  num: 321
};
alias = interface;

可以看到用类型别名和接口都可以定义一个只包含 num 属性的对象类型,而且类型是兼容的。那么什么时候用类型别名,什么时候用接口呢?可以通过两点来选择:

  • 当你定义的类型要用于拓展,即使用 implements 等修饰符时,用接口。

  • 当无法通过接口,并且需要使用联合类型或元组类型,用类型别名。

3.5.2. 字面量类型

字面量类型其实比较基础,但是它又不适合放到基本类型里讲,因为字符串字面量类型和字符串类型其实并不一样,所以接下来我们来学习两种字面量类型。

(1) 字符串字面量类型

字符串字面量类型其实就是字符串常量,与字符串类型不同的是它是具体的值。

type Name = "Lison";
const name1: Name = "test"; // error 不能将类型“"test"”分配给类型“"Lison"”
const name2: Name = "Lison";

你还可以使用联合类型来使用多个字符串:

type Direction = "north" | "east" | "south" | "west";
function getDirectionFirstLetter(direction: Direction) {
  return direction.substr(0, 1);
}
getDirectionFirstLetter("test"); // error 类型“"test"”的参数不能赋给类型“Direction”的参数
getDirectionFirstLetter("east");

(2) 数字字面量类型

另一个字面量类型就是数字字面量类型,它和字符串字面量类型差不多,都是指定类型为具体的值。

type Age = 18;
interface Info {
  name: string;
  age: Age;
}
const info: Info = {
  name: "Lison",
  age: 28 // error 不能将类型“28”分配给类型“18”
};

这里补充一个比较经典的逻辑错误,来看例子:

function getValue(index: number) {
  if (index !== 0 || index !== 1) {
    // error This condition will always return 'true' since the types '0' and '1' have no overlap
    // ...
  }
}

这个例子中,在判断逻辑处使用了 || 符,当 index !== 0 不成立时,说明 index 就是 0,则不应该再判断 index 是否不等于 1;而如果 index !== 0 成立,那后面的判断也不会再执行;所以这个地方会报错。

本节小结

本小节我们学习了类型别名和字面量类型,类型别名就是给一个类型起个别名,以后我们可以使用类型别名将较为复杂的类型抽离出来,这样任何需要使用这个类型的地方都可以使用这个别名代替;使用类型别名的好处有时和使用变量一样,我们可以将复杂的逻辑判断语句赋给一个变量,然后再进行判断,只需要判断这个变量的true或false即可;我们使用类型别名也可以起到简化代码的作用。我们还学习了两种字面量类型:数字字面量类型和字符串字面量类型,它们都是使用具体的字面量值来作为一种类型,所以我们叫它单调类型。

下个小节我们将学习可辨识联合类型,我们可以使用可辨识联合并保证每个case都被处理。

 

 

19 使用可辨识联合并保证每个case都被处理

19 使用可辨识联合并保证每个case都被处理

更新时间:2019-06-26 10:13:26

 

我好像是一只牛,吃的是草,挤出的是牛奶。 ——鲁迅

 

我们可以把单例类型、联合类型、类型保护和类型别名这几种类型进行合并,来创建一个叫做可辨识联合的高级类型,它也可称作标签联合代数数据类型

 

所谓单例类型,你可以理解为符合单例模式的数据类型,比如枚举成员类型,字面量类型。

可辨识联合要求具有两个要素:

  • 具有普通的单例类型属性(这个要作为辨识的特征,也是重要因素)。

  • 一个类型别名,包含了那些类型的联合(即把几个类型封装为联合类型,并起一个别名)。

来看例子:

interface Square {
  kind: "square"; // 这个就是具有辨识性的属性
  size: number;
}
interface Rectangle {
  kind: "rectangle"; // 这个就是具有辨识性的属性
  height: number;
  width: number;
}
interface Circle {
  kind: "circle"; // 这个就是具有辨识性的属性
  radius: number;
}
type Shape = Square | Rectangle | Circle; // 这里使用三个接口组成一个联合类型,并赋给一个别名Shape,组成了一个可辨识联合。
function getArea(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size  s.size;
    case "rectangle":
      return s.height  s.width;
    case "circle":
      return Math.PI  s.radius * 2;
  }
}

上面这个例子中,我们的 Shape 即可辨识联合,它是三个接口的联合,而这三个接口都有一个 kind 属性,且每个接口的 kind 属性值都不相同,能够起到标识作用。

这里有个 ES7 的新特性:** 运算符,两个*符号组成的这个运算符就是求幂运算符,2 ** 3 ==> 8

看了上面的例子,你可以看到我们的函数内应该包含联合类型中每一个接口的 case。但是如果遗漏了,我们希望编译器应该给出提示。所以我们来看下两种完整性检查的方法:

3.6.1 利用 strictNullChecks

我们给上面的例子加一种接口:

interface Square {
  kind: "square";
  size: number;
}
interface Rectangle {
  kind: "rectangle";
  height: number;
  width: number;
}
interface Circle {
  kind: "circle";
  radius: number;
}
interface Triangle {
  kind: "triangle";
  bottom: number;
  height: number;
}
type Shape = Square | Rectangle | Circle | Triangle; // 这里我们在联合类型中新增了一个接口,但是下面的case却没有处理Triangle的情况
function getArea(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size  s.size;
    case "rectangle":
      return s.height  s.width;
    case "circle":
      return Math.PI  s.radius * 2;
  }
}

上面例子中,我们的 Shape 联合有四种接口,但函数的 switch 里只包含三个 case,这个时候编译器并没有提示任何错误,因为当传入函数的是类型是 Triangle 时,没有任何一个 case 符合,则不会有 return 语句执行,那么函数是默认返回 undefined。所以我们可以利用这个特点,结合 strictNullChecks(详见3.4小节) 编译选项,我们可以开启 strictNullChecks,然后让函数的返回值类型为 number,那么当返回 undefined 的时候,就会报错:

function getArea(s: Shape): number {
  // error Function lacks ending return statement and return type does not include 'undefined'
  switch (s.kind) {
    case "square":
      return s.size  s.size;
    case "rectangle":
      return s.height  s.width;
    case "circle":
      return Math.PI  s.radius * 2;
  }
}

这种方法简单,但是对旧代码支持不好,因为strictNullChecks这个配置项是2.0版本才加入的,如果你使用的是低于这个版本的,这个方法并不会有效。

3.6.2 使用 never 类型

我们在学习基本类型时学习过,当函数返回一个错误或者不可能有返回值的时候,返回值类型为 never。所以我们可以给 switch 添加一个 default 流程,当前面的 case 都不符合的时候,会执行 default 后的逻辑:

function assertNever(value: never): never {
  throw new Error("Unexpected object: " + value);
}
function getArea(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size  s.size;
    case "rectangle":
      return s.height  s.width;
    case "circle":
      return Math.PI  s.radius * 2;
    default:
      return assertNever(s); // error 类型“Triangle”的参数不能赋给类型“never”的参数
  }
}

采用这种方式,需要定义一个额外的 asserNever 函数,但是这种方式不仅能够在编译阶段提示我们遗漏了判断条件,而且在运行时也会报错。

本节小结

本小节我们学习了可辨识联合类型,定义一个可辨识联合类型有两个要素:具有普通的单例类型属性,和一个类型别名。第一个要素是最重要的一点,因为编译器要根据这个属性来判断当前分支是什么类型,而第二个要素并不影响使用,你完全可以指定上面例子中的s为Square | Rectangle | Circle而不使用Shape。最后我们讲了两种避免遗忘处理某个case的方法:利用strictNullChecks和使用never类型,都能够帮我们检查遗漏的case,第二种方法的提示更为全面,推荐大家使用。

下个小节我们将学习this类型,我们知道this是JavaScript中的关键字,可以用来获取全局对象、类实例对象、构造函数实例等的引用,但是在TypeScript中,它也是一种类型,我们下节课再来细讲。

 

 

20 this,类型?

20 this,类型?

更新时间:2019-07-09 15:16:37

 

每个人都是自己命运的主宰。

——斯蒂尔斯

 

在 JavaScript 中,this 可以用来获取对全局对象、类实例对象、构建函数实例等的引用,在 TypeScript 中,this 也是一种类型,我们先来看个计算器 Counter 的例子:

class Counter {
  constructor(public count: number = 0) {}
  add(value: number) { // 定义一个相加操作的方法
    this.count += value;
    return this;
  }
  subtract(value: number) { // 定义一个相减操作的方法
    this.count -= value;
    return this;
  }
}
let counter = new Counter(10);
console.log(counter.count); // 10
counter.add(5).subtract(2);
console.log(counter.count); // 13

我们给 Counter 类定义几个方法,每个方法都返回 this,这个 this 即指向实例,这样我们就可以通过链式调用的形式来使用这些方法。这个是没有问题的,但是如果我们要通过类继承的形式丰富这个 Counter 类,添加一些方法,依然返回 this,然后采用链式调用的形式调用,在过去版本的 TypeScript 中是有问题的,先来看我们继承的逻辑:

class PowCounter extends Counter {
  constructor(public count: number = 0) {
    super(count);
  }
  pow(value: number) { // 定义一个幂运算操作的方法
    this.count = this.count ** value;
    return this;
  }
}
let powCounter = new PowCounter(2);
powCounter
  .pow(3)
  .subtract(3)
  .add(1);
console.log(powCounter.count); // 6

我们定义了 PowCounter 类,它继承 Counter 类,新增了 pow 方法用来求值的幂次方,这里我们使用了 ES7 新增的幂运算符**。我们使用 PowCounter 创建了实例 powcounter,它的类型自然是 PowCounter,在该实例上调用继承来的 subtract 和 add 方法。如果是在过去,就会报错,因为创建实例 powcounter 的类 PowCounter 没有定义这两个方法,所以会报没有这两个方法的错误。但是在 1.7 版本中增加了 this 类型,TypeScript 会对方法返回的 this 进行判断,就不会报错了。

对于对象来说,对象的属性值可以是一个函数,那么这个函数也称为方法,在方法内如果访问this,默认情况下是对这个对象的引用,this类型也就是这个对象的字面量类型,如下:

// 例3.7.1
let info = {
  name: 'Lison',
  getName () {
      return this.name // "Lison" 这里this的类型为 { name: string; getName(): string; }
  }
}

但是如果显式地指定了this的类型,那么this的类型就改变了,如下:

// 例3.7.2
let info = {
  name: "Lison",
  getName(this: { age: number }) {
    this; // 这里的this的类型是{ age: number }
  }
};

如果我们在 tsconfig.json 里将 noImplicitThis 设为 true,这时候有两种不同的情况:

(1) 对象字面量具有 ThisType<T> 指定的类型,此时 this 的类型为 T,来看例子:

type ObjectDescriptor<D, M> = { // 使用类型别名定义一个接口,这里用了泛型,两个泛型变量D和M
  data?: D; // 这里指定data为可选字段,类型为D
  // 这里指定methods为可选字段,类型为M和ThisType<D & M>组成的交叉类型;  
  // ThisType是一个内置的接口,用来在对象字面量中键入this,这里指定this的类型为D & M  
  methods?: M & ThisType<D & M>;  
}

// 这里定义一个mackObject函数,参数desc的类型为ObjectDescriptor<D, M>
function makeObject<D, M>(desc: ObjectDescriptor<D, M>): D & M { 
  let data: object = desc.data || {};
  let methods: object = desc.methods || {};
  // 这里通过...操作符,将data和methods里的所有属性、方法都放到了同一个对象里返回,这个对象的类型自然就      是D & M,因为他同时包含D和M两个类型的字段  
  return { ...data, ...methods } as D & M; 
}

let obj = makeObject({
  data: { x: 0, y: 0 }, // 这里data的类型就是我们上面定义ObjectDescriptor<D, M>类型中的D
  methods: { // 这里methods的类型就是我们上面定义ObjectDescriptor<D, M>类型中的M
    moveBy(dx: number, dy: number) {
      this.x += dx;  // 所以这里的this是我们通过ThisType<D & M>指定的,this的类型就是D & M
      this.y += dy;
    }
  }
});

obj.x = 10;
obj.y = 20;
obj.moveBy(5, 5);

(2) 不包含 ThisType<T> 指定的上下文类型,那么此时 this 具有上下文类型,也就是普通的情况。你可以试着把上面使用了 ThisType<T> 的例子中,ObjectDescriptor<D, M>类型中指定methods的类型中的 & ThisType<D & M> 去掉,你会发现 moveBy 方法中 this.x 和 this.y 报错了,因为此时 this 的类型是methods 这个对象字面量的类型。

本节小结

本小节我们学习了this类型的相关知识,我们通过计数器的例子,学习了在1.7版本之后,编译器对有继承行为的类中this的类型的推断。还学习了对于对象的方法中,this指向的相关知识。更多的关于this类型的知识,可以看一下这个PR中的介绍及例子,这里面完整地写了this的类型的规则。不过我们上面都举例学习了,总结一下:

  • 如果该方法具有显式声明的此参数,则该参数具有该参数的类型,也就是我们刚刚讲的例3.7.2;

  • 否则,如果该方法由具有此参数的签名进行上下文类型化,则该参数具有该参数的类型,也就是我们讲的例3.7.1;

  • 否则,如果在 tsconfig.json 里将 noImplicitThis 设为 true,且包含的对象文字具有包含 ThisType<T> 的上下文类型,则其类型为T,例子看我们讲的第(1)小点.

  • 否则,如果启用了 --noImplicitThis 并且包含的对象文字具有不包含 ThisType<T> 的上下文类型,则它具有上下文类型,具体看我们讲的第(2)小点。

  • 否则,this 的类型为 any 任何类型。

下个小节我们将学习索引类型,这里说的索引类型,并不是前面我们讲接口的时候,给接口中字段名设置类型,我们将学习获取索引类型和索引值类型。

 

21 索引类型:获取索引类型和索引值类型

21 索引类型:获取索引类型和索引值类型

更新时间:2019-06-28 11:48:42

横眉冷对千夫指,俯首甘为孺子牛。

——鲁迅

我们这里要讲的,可不是前面讲接口的时候讲的索引类型。在学习接口内容的时候,我们讲过可以指定索引的类型。而本小节我们讲的索引类型包含两个内容:索引类型查询索引访问操作符。

3.8.1 索引类型查询操作符

keyof操作符,连接一个类型,会返回一个由这个类型的所有属性名组成的联合类型。来看例子:

interface Info {
  name: string;
  age: number;
}
let infoProp: keyof Info;
infoProp = "name";
infoProp = "age";
infoProp = "no"; // error 不能将类型“"no"”分配给类型“"name" | "age"”

通过例子可以看到,这里的keyof Info其实相当于"name" | “age”。通过和泛型结合使用,TS 就可以检查使用了动态属性名的代码:

function getValue<T, K extends keyof T>(obj: T, names: K[]): T[K][] { // 这里使用泛型,并且约束泛型变量K的类型是"keyof T",也就是类型T的所有字段名组成的联合类型
  return names.map(n => obj[n]); // 指定getValue的返回值类型为T[K][],即类型为T的值的属性值组成的数组
}
const info = {
  name: "lison",
  age: 18
};
let values: string[] = getValue(info, ["name"]);
values = getValue(info, ["age"]); // error 不能将类型“number[]”分配给类型“string[]”

3.8.2 索引访问操作符

索引访问操作符也就是[],其实和我们访问对象的某个属性值是一样的语法,但是在 TS 中它可以用来访问某个属性的类型:

interface Info {
  name: string;
  age: number;
}
type NameType = Info["name"];
let name: NameType = 123; // error 不能将类型“123”分配给类型“string”

再来看个例子:

function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
  return o[name]; // o[name] is of type T[K]
}

这个函数中,两个参数的类型分别为泛型 T 和 K,而函数的返回值类型为T[K],只要函数的返回值也是这种形式,即访问参数 o 的参数 name 属性,即可。

最后我们来看个结合接口的例子:

interface Obj<T> {
  [key: number]: T;
}
const key: keyof Obj<number>; // keys的类型为number

这里需要注意,在讲接口一节时,讲索引类型的时候我们讲过,如果索引类型为 number,那么实现该接口的对象的属性名必须是 number 类型;但是如果接口的索引类型是 string 类型,那么实现该接口的对象的属性名设置为数值类型的值也是可以的,因为数值最后还是会先转换为字符串。这里一样,如果接口的索引类型设置为 string 的话,keyof Obj<number>等同于类型number | string

interface Obj<T> {
  [key: string]: T;
}
let key: keyof Obj<number>; // keys的类型为number | string
key = 123; // right

也可以使用访问操作符,获取索引签名的类型:

interface Obj<T> {
  [key: string]: T;
}
const obj: Obj<number> = {
  age: 18
};
let value: Obj<number>["age"]; // value的类型是number,也就是name的属性值18的类型

还有一点,我们在讲后面知识的时候会遇到,就是当tsconfig.json里strictNullChecks设为false时,通过Type[keyof Type]获取到的,是除去never & undefined & null这三个类型之后的字段值类型组成的联合类型,来看例子:

interface Type {
  a: never;
  b: never;
  c: string;
  d: number;
  e: undefined;
  f: null;
  g: object;
}
type test = Type[keyof Type];
// test的类型是string | number | object

这个例子中接口 Type 有几个属性,通过索引访问操作符和索引类型查询操作符可以选出类型不为 never & undefined & null 的类型。

本节小结

本小节我们学习了两个类型操作符:索引类型查询操作符keyof,和索引访问操作符[]。通过keyof我们能够获取一个类型的所有属性名组成的联合类型,通过[]我们可以获取某个类型定义中指定字段值的类型。我们还学习了它们的组合使用方法,当tsconfig.json里strictNullChecks设为false时,我们可以通过[keyof Type]获取一个类型定义的所有除去never & undefined & null的字段值的类型组成的联合类型。

下个小节我们将学习一种新的复用现有类型定义,产生新类型定义的一种类型——映射类型。

 

22 使用映射类型得到新的类型

22 使用映射类型得到新的类型

更新时间:2019-07-01 11:43:19

知识是一种快乐,而好奇则是知识的萌芽。

——培根

3.9.1 基础

TS 提供了借助旧类型创建一个新类型的方式,也就是映射类型,它可以用相同的形式去转换旧类型中每个属性。来看个例子:

interface Info {
  age: number;
}

我们可以使用这个接口实现一个有且仅有一个 age 属性的对象,但如果我们想再创建一个只读版本的同款对象,那我们可能需要再重新定义一个接口,然后让 age 属性 readonly。如果接口就这么简单,你确实可以这么做,但是如果属性多了,而且这个结构以后会变,那就比较麻烦了。这种情况我们可以使用映射类型,下面来看例子:

interface Info {
  age: number;
}
type ReadonlyType<T> = { readonly [P in keyof T]: T[P] }; // 这里定义了一个ReadonlyType<T>映射类型
type ReadonlyInfo = ReadonlyType<Info>;
let info: ReadonlyInfo = {
  age: 18
};
info.age = 28; // error Cannot assign to 'age' because it is a constant or a read-only property

这个例子展示了如何通过一个普通的接口创建一个每个属性都只读的接口,这个过程有点像定义了一个函数,这个函数会遍历传入对象的每个属性并做处理。同理你也可以创建一个每个属性都是可选属性的接口:

interface Info {
  age: number;
}
type ReadonlyType<T> = { readonly [P in keyof T]?: T[P] };
type ReadonlyInfo = ReadonlyType<Info>;
let info: ReadonlyInfo = {};

注意了,我们在这里用到了一个新的操作符 in,TS 内部使用了 for … in,定义映射类型,这里涉及到三个部分:

  • 类型变量,也就是上例中的 P,它就像 for…in 循环中定义的变量,用来在每次遍历中绑定当前遍历到的属性名;

  • 属性名联合,也就是上例中keyof T,它返回对象 T 的属性名联合;

  • 属性的结果类型,也就是 T[P]。

因为这两个需求较为常用,所以 TS 内置了这两种映射类型,无需定义即可使用,它们分别是ReadonlyPartial。还有两个内置的映射类型分别是PickRecord,它们的实现如下:

type Pick<T, K extends keyof T> = { [P in K]: T[P] };
type Record<K extends keyof any, T> = { [P in K]: T };

先来使用一下 Pick,官方文档的例子并不完整,我们来看完整的例子:

interface Info {
  name: string;
  age: number;
  address: string;
}
const info: Info = {
  name: "lison",
  age: 18,
  address: "beijing"
};
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> { // 这里我们定义一个pick函数,用来返回一个对象中指定字段的值组成的对象
  let res = {} as Pick<T, K>;
  keys.forEach(key => {
    res[key] = obj[key];
  });
  return res;
}
const nameAndAddress = pick(info, ["name", "address"]); // { name: 'lison', address: 'beijing' }

另外一个就是 Record,它适用于将一个对象中的每一个属性转换为其他值的场景,来看例子:

function mapObject<K extends string | number, T, U>(
  obj: Record<K, T>,
  f: (x: T) => U
): Record<K, U> {
  let res = {} as Record<K, U>;
  for (const key in obj) {
    res[key] = f(obj[key]);
  }
  return res;
}

const names = { 0: "hello", 1: "world", 2: "bye" };
const lengths = mapObject(names, s => s.length); // { 0: 5, 1: 5, 2: 3 }

我们输入的对象属性值为字符串类型,输出的对象属性值为数值类型。

讲完这四个内置的映射类型之后,我们需要讲一个概念——同态。同态在维基百科的解释是:两个相同类型的代数结构之间的结构保持映射。这四个内置映射类型中,Readonly、Partial 和 Pick 是同态的,而 Record 不是,因为 Record 映射出的对象属性值是新的,和输入的值的属性值不同。

3.9.2 由映射类型进行推断

我们学习了使用映射类型包装一个类型的属性后,也可以进行逆向操作,也就是拆包,先来看我们的包装操作:

type Proxy<T> = { // 这里定义一个映射类型,他将一个属性拆分成get/set方法
  get(): T;
  set(value: T): void;
};
type Proxify<T> = { [P in keyof T]: Proxy<T[P]> }; // 这里再定义一个映射类型,将一个对象的所有属性值类型都变为Proxy<T>处理之后的类型
function proxify<T>(obj: T): Proxify<T> { // 这里定义一个proxify函数,用来将对象中所有属性的属性值改为一个包含get和set方法的对象
  let result = {} as Proxify<T>;
  for (const key in obj) {
    result[key] = {
      get: () => obj[key],
      set: value => (obj[key] = value)
    };
  }
  return result;
}
let props = {
  name: "lison",
  age: 18
};
let proxyProps = proxify(props);
console.log(proxyProps.name.get()); // "lison"
proxyProps.name.set("li");

我们来看下这个例子,这个例子我们定义了一个函数,这个函数可以把传入的对象的每个属性的值替换为一个包含 get 和 set 两个方法的对象。最后我们获取某个值的时候,比如 name,就使用 proxyProps.name.get()方法获取它的值,使用 proxyProps.name.set()方法修改 name 的值。

接下来我们来看如何进行拆包:

function unproxify<T>(t: Proxify<T>): T { // 这里我们定义一个拆包函数,其实就是利用每个属性的get方法获取到当前属性值,然后将原本是包含get和set方法的对象改为这个属性值
  let result = {} as T;
  for (const k in t) {
    result[k] = t[k].get(); // 这里通过调用属性值这个对象的get方法获取到属性值,然后赋给这个属性,替换掉这个对象
  }
  return result;
}
let originalProps = unproxify(proxyProps);

3.9.3 增加或移除特定修饰符

TS 在 2.8 版本为映射类型增加了增加或移除特定修饰符的能力,使用+-符号作为前缀来指定增加还是删除修饰符。首先来看我们如何通过映射类型为一个接口的每个属性增加修饰符,我们这里使用+前缀:

interface Info {
  name: string;
  age: number;
}
type ReadonlyInfo<T> = { +readonly [P in keyof T]+?: T[P] };
let info: ReadonlyInfo<Info> = {
  name: "lison"
};
info.name = ""; // error

这个例子中,经过 ReadonlyInfo 创建的接口类型,属性是可选的,所以我们在定义 info 的时候没有写 age 属性也没问题,同时每个属性是只读的,所以我们修改 name 的值的时候报错。我们通过+前缀增加了 readonly 和?修饰符。当然,增加的时候,这个+前缀可以省略,也就是说,上面的写法和type ReadonlyInfo = { readonly [P in keyof T]?: T[P] }是一样的。我们再来看下怎么删除修饰符:

interface Info {
  name: string;
  age: number;
}
type RemoveModifier<T> = { -readonly [P in keyof T]-?: T[p] };
type InfoType = RemoveModifier<Readonly<Partial<Info>>>;
let info1: InfoType = {
  // error missing "age"
  name: "lison"
};
let info2: InfoType = {
  name: "lison",
  age: 18
};
info2.name = ""; // right, can edit

这个例子我们定义了去掉修饰符的映射类型 RemoveModifier,Readonly<Partial<Info>>则是返回一个既属性可选又只读的接口类型,所以 InfoType 类型则表示属性必含而且非只读。

TS 内置了一个映射类型Required<T>,使用它可以去掉 T 所有属性的?修饰符。

3.9.4 keyof 和映射类型在 2.9 的升级

TS 在 2.9 版本中,keyof 和映射类型支持用 number 和 symbol 命名的属性,我们先来看 keyof 的例子:

const stringIndex = "a";
const numberIndex = 1;
const symbolIndex = Symbol();
type Obj = {
  [stringIndex]: string;
  [numberIndex]: number;
  [symbolIndex]: symbol;
};
type keys = keyof Obj;
let key: keys = 2; // error
let key: keys = 1; // right
let key: keys = "b"; // error
let key: keys = "a"; // right
let key: keys = Symbol(); // error
let key: keys = symbolIndex; // right

再来看个映射类型的例子:

const stringIndex = "a";
const numberIndex = 1;
const symbolIndex = Symbol();
type Obj = {
  [stringIndex]: string;
  [numberIndex]: number;
  [symbolIndex]: symbol;
};
type ReadonlyType<T> = { readonly [P in keyof T]?: T[P] };
let obj: ReadonlyType<Obj> = {
  a: "aa",
  1: 11,
  [symbolIndex]: Symbol()
};
obj.a = "bb"; // error Cannot assign to 'a' because it is a read-only property
obj[1] = 22; // error Cannot assign to '1' because it is a read-only property
obj[symbolIndex] = Symbol(); // error Cannot assign to '[symbolIndex]' because it is a read-only property

3.9.5 元组和数组上的映射类型

TS 在 3.1 版本中,在元组和数组上的映射类型会生成新的元组和数组,并不会创建一个新的类型,这个类型上会具有 push、pop 等数组方法和数组属性。来看例子:

type MapToPromise<T> = { [K in keyof T]: Promise<T[K]> };
type Tuple = [number, string, boolean];
type promiseTuple = MapToPromise<Tuple>;
let tuple: promiseTuple = [
  new Promise((resolve, reject) => resolve(1)),
  new Promise((resolve, reject) => resolve("a")),
  new Promise((resolve, reject) => resolve(false))
];

这个例子中定义了一个MapToPromise映射类型。它返回一个将传入的类型的所有字段的值转为Promise,且Promise的resolve回调函数的参数类型为这个字段类型。我们定义了一个元组Tuple,元素类型分别为number、string和boolean,使用MapToPromise映射类型将这个元组类型传入,并且返回一个promiseTuple类型。当我们指定变量tuple的类型为promiseTuple后,它的三个元素类型都是一个Promise,且resolve的参数类型依次为number、string和boolean。

本节小结

本小节我们学习了映射类型的相关知识,我们学习了映射类型的基础应用,它的定义和使用像极了函数的定义和使用。函数是处理实际的值,而映射类型处理的是类型。我们还通过一个例子学习了由映射类型进行推断,根据映射类型推断出处理前的类型,也就是拆包操作。通过增加或移除特定修饰符"+“和”-“可以实现给字段添加或移除一些readonly等修饰符,但用的最多的是”-"。因为如果需要给某个字段加修饰符,"+"是可以省略不写的。最后我们补充了两个TypeScript在后面升级中对映射类型的更新。

下个小节我们将对前面讲TypeScript中补充的六个类型中跳过的unknown类型进行详细补充学习。

 

28 对声明合并的爱与恨

28 对声明合并的爱与恨

更新时间:2019-07-12 09:24:27

理想的书籍是智慧的钥匙。

——列夫·托尔斯泰

 

声明合并是指 TypeScript 编译器会将名字相同的多个声明合并为一个声明,合并后的声明同时拥有多个声明的特性。我们知道在 JavaScrip 中,使用var关键字定义变量时,定义相同名字的变量,后面的会覆盖前面的值。使用let 定义变量和使用 const 定义常量时,不允许名字重复。在 TypeScript 中,接口、命名空间是可以多次声明的,最后 TypeScript 会将多个同名声明合并为一个。我们下来看个简单的例子:

interface Info {
    name: string
}
interface Info {
    age: number
}
let info: Info
info = { // error 类型“{ name: string; }”中缺少属性“age”
    name: 'lison'
}
info = { // right
    name: 'lison',
    age: 18
}

可以看到,我们定义了两个同名接口Info,每个接口里都定义了一个必备属性,最后定义info类型为Info时,info的定义要求同时包含name和age属性。这就是声明合并的简单示例,接下来我们详细学习。

4.3.1. 补充知识

TypeScript的所有声明概括起来,会创建这三种实体之一:命名空间、类型

  • 命名空间的创建实际是创建一个对象,对象的属性是在命名空间里export导出的内容;

  • 类型的声明是创建一个类型并赋给一个名字;

  • 值的声明就是创建一个在JavaScript中可以使用的值。

下面这个表格会清晰的告诉你,每一种声明类型会创建这三种实体中的哪种,先来说明一下,第一列是指声明的内容,每一行包含4列,表明这一行中,第一列的声明类型创建了后面三列哪种实体,打钩即表示创建了该实体:

声明类型

创建了命名空间

创建了类型

创建了值

Namespace

 

Class

 

Enum

 

Interface

 

 

Type Alias类型别名

 

 

Function

 

 

Variable

 

 

可以看到,只要命名空间创建了命名空间这种实体。Class、Enum两个,Class即是实际的值也作为类使用,Enum编译为JavaScript后也是实际值,而且我们讲过,一定条件下,它的成员可以作为类型使用;Interface和类型别名是纯粹的类型;而Funciton和Variable只是创建了JavaScript中可用的值,不能作为类型使用,注意这里Variable是变量,不是常量,常量是可以作为类型使用的。

4.3.2. 合并接口

我们在本节课一开始的例子中,简单示范了一下接口声明的合并,下面我们来补充一些内容。

多个同名接口,定义的非函数的成员命名应该是不重复的,如果重复了,类型应该是相同的,否则将会报错。

interface Info {
    name: string
}
interface Info {
    age: number
}
interface Info {
    age: boolean // error 后续属性声明必须属于同一类型。属性“age”的类型必须为“number”,但此处却为类型“boolean”
}

对于函数成员,每个同名函数成员都会被当成这个函数的重载,且合并时后面的接口具有更高的优先级。来看下多个同名函数成员的例子:

interface Res {
    getRes(input: string): number
}
interface Res {
    getRes(input: number): string
}
const res: Res = {
    getRes: (input: any): any => {
        if (typeof input === 'string') return input.length
        else return String(input)
    }
}
res.getRes('123').length // error 类型“number”上不存在属性“length”

4.3.3. 合并命名空间

同名命名空间最后会将多个命名空间导出的内容进行合并,如下面两个命名空间:

namespace Validation {
    export const checkNumber = () => {}
}
namespace Validation {
    export const checkString = () => {}
}

上面定义两个同名命名空间,效果相当于:

namespace Validation {
    export const checkNumber = () => {}
    export const checkString = () => {}
}

在命名空间里,有时我们并不是把所有内容都对外部可见,对于没有导出的内容,在其它同名命名空间内是无法访问的:

namespace Validation {
    const numberReg = /^[0-9]+$/
    export const stringReg = /^[A-Za-z]+$/
    export const checkString = () => {}
}
namespace Validation {
    export const checkNumber = (value: any) => {
        return numberReg.test(value) // error 找不到名称“numberReg”
    }
}

上面定义的两个命名空间,numberReg没有使用export导出,所以在第二个同名命名空间内是无法使用的,如果给 const numberReg 前面加上 export,就可以在第二个命名空间使用了。

4.3.4. 不同类型合并

命名空间分别和类、函数、枚举都可以合并,下面我们来一一说明:

(1) 命名空间和类

这里要求同名的类和命名空间在定义的时候,类的定义必须在命名空间前面,最后合并之后的效果,一个包含一些以命名空间导出内容为静态属性的类,来看例子:

class Validation {
    checkType() { }
}
namespace Validation {
    export const numberReg = /^[0-9]+$/
    export const stringReg = /^[A-Za-z]+$/
    export const checkString = () => { }
}
namespace Validation {
    export const checkNumber = (value: any) => {
        return numberReg.test(value)
    }
}
console.log(Validation.prototype) // { checkType: fun () {} }
console.log(Validation.prototype.constructor) 
/*
{
    checkNumber: ...
    checkString: ...
    numberReg: ...
    stringReg: ...
}
/

(2) 命名空间和函数

在JavaScript中,函数也是对象,所以可以给一个函数设置属性,在TypeScript中,就可以通过声明合并实现。但同样要求,函数的定义要在同名命名空间前面,我们再拿之前讲过的计数器的实现来看下,如何利用声明合并实现计数器的定义:

function countUp () {
    countUp.count++
}
namespace countUp {
    export let count = 0
}
countUp()
countUp()
console.log(countUp.count) // 2

(3) 命名空间和枚举

可以通过命名空间和枚举的合并,为枚举拓展内容,枚举和同名命名空间的先后顺序是没有要求的,来看例子:

enum Colors {
    red,
    green,
    blue
}
namespace Colors {
    export const yellow = 3
}
console.log(Colors)
/
{
    0: "red",
    1: "green",
    2: "blue",
    red: 0,
    green: 1,
    blue: 2,
    yellow: 3 
}
/

通过打印结果你可以发现,虽然我们使用命名空间增加了枚举的成员,但是最后输出的值只有key到index的映射,没有index到key的映射。

小结

本小节我们学习了编译器对于相同命名的声明的合并策略,这个策略能够帮我们实现一些类型定义的复用,比如多个函数定义可合并为一个函数的重载,还可以利用声明合并实现一些复杂的类型定义。但是有时我们会无意地定义了一个之前定义过的名字,造成声明合并了,再使用这个新定义的时候,发现应用了一些这里未定义的类型校验,所以我们在定义名字的时候要注意这一点。本小节我们讲了接口、命名空间、不同类型是如何合并的,也学习了如何利用声明合并来定义复杂的数据结构。

下个小节我们来学习混入,在使用一些框架或者插件的时候,你可能听过这个概念,我们可以将公共逻辑抽取出来,然后通过混入实现复用。在TypeScript中,混入还需要考虑类型,所以我们下个小节来看下如何在TypeScript中实现混入。

 

24 条件类型,它不是三元操作符的写法吗?

24 条件类型,它不是三元操作符的写法吗?

更新时间:2019-07-18 14:21:16

 

成功=艰苦的劳动+正确的方法+少谈空话。

——爱因斯坦

 

3.11.1 基础使用

条件类型是 TS2.8 引入的,从语法上看它像是三元操作符。它会以一个条件表达式进行类型关系检测,然后在后面两种类型中选择一个,先来看它怎么写:

T extends U ? X : Y

这个表达式的意思是,如果 T 可以赋值给 U 类型,则是 X 类型,否则是 Y 类型。来看个实际例子:

type Type<T> = T extends string ? string : number
let index: Type<'a'> // index的类型为string
let index2: Type<false> // index2的类型为number

3.11.2 分布式条件类型

当待检测的类型是联合类型,则该条件类型被称为“分布式条件类型”,在实例化时会自动分发成联合类型,来看例子:

type TypeName<T> = T extends any ? T : never;
type Type1 = TypeName<string | number>; // Type1的类型是string|number

你可能会说,既然想指定 Type1 的类型为 string|number,为什么不直接指定,而要使用条件类型?其实这只是简单的示范,条件类型可以增加灵活性,再来看个复杂点的例子,这是官方文档的例子:

type TypeName<T> = T extends string
  ? string
  : T extends number
  ? number
  : T extends boolean
  ? boolean
  : T extends undefined
  ? undefined
  : T extends Function
  ? Function
  : object;
type Type1 = TypeName<() => void>; // Type1的类型是Function
type Type2 = TypeName<string[]>; // Type2的类型是object
type Type3 = TypeName<(() => void) | string[]>; // Type3的类型是object | Function

我们来看一个分布式条件类型的实际应用:

type Diff<T, U> = T extends U ? never : T;
type Test = Diff<string | number | boolean, undefined | number>;
// Test的类型为string | boolean

这个例子定义的条件类型的作用就是,找出从 T 中出去 U 中存在的类型,得到剩下的类型。不过这个条件类型已经内置在 TS 中了,只不过它不叫 Diff,叫 Exclude,我们待会儿会讲到。

来看一个条件类型和映射类型结合的例子:

type Type<T> = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T];
interface Part {
  id: number;
  name: string;
  subparts: Part[];
  updatePart(newName: string): void;
}
type Test = Type<Part>; // Test的类型为"updatePart"

来看一下,这个例子中,接口 Part 有四个字段,其中 updatePart 的值是函数,也就是 Function 类型。Type的定义中,涉及到映射类型、条件类型、索引访问类型和索引类型。首先[K in keyof T]用于遍历 T 的所有属性名,值使用了条件类型,T[K]是当前属性名的属性值,T[K] extends Function ? K : never表示如果属性值为 Function 类型,则值为属性名字面量类型,否则为 never 类型。接下来使用keyof T获取 T 的属性名,最后通过索引访问类型[keyof T]获取不为 never 的类型。

3.11.3 条件类型的类型推断-infer

条件类型提供一个infer关键字用来推断类型,我们先来看个例子。我们想定义一个条件类型,如果传入的类型是一个数组,则返回它元素的类型;如果是一个普通类型,则直接返回这个类型。来看下不使用 infer 的话,怎么写:

type Type<T> = T extends any[] ? T[number] : T;
type test = Type<string[]>; // test的类型为string
type test2 = Type<string>; // test2的类型为string

这个例子中,如果传入 Type 的是一个数组类型,那么返回的类型为T[number],也就是该数组的元素类型,如果不是数组,则直接返回这个类型。这里我们是自己通过索引访问类型T[number]来获取类型的,如果使用 infer 关键字则无需自己手动获取,我们来看下怎么使用 infer:

type Type<T> = T extends Array<infer U> ? U : T;
type test = Type<string[]>; // test的类型为string
type test2 = Type<string>; // test2的类型为string

这里 infer 能够推断出 U 的类型,并且供后面使用,你可以理解为这里定义了一个变量 U 来接收数组元素的类型。

3.11.4 TS 预定义条件类型

TS 在 2.8 版本增加了一些预定义的有条件类型,来看一下:

  • Exclude<T, U>,从 T 中去掉可以赋值给 U 的类型:

type Type = Exclude<"a" | "b" | "c", "a" | "b">;
// Type => 'c'
type Type2 = Exclude<string | number | boolean, string | number>;
// Type2 => boolean

  • Extract<T, U>,选取 T 中可以赋值给 U 的类型:

type Type = Extract<"a" | "b" | "c", "a" | "c" | "f">;
// Type => 'a' | 'c'
type Type2 = Extract<number | string | boolean, string | boolean>;
// Type2 => string | boolean

  • NonNullable,从 T 中去掉 null 和 undefined:

type Type = Extract<string | number | undefined | null>;
// Type => string | number

  • ReturnType,获取函数类型返回值类型:

type Type = ReturnType<() => string)>
// Type => string
type Type2 = ReturnType<(arg: number) => void)>
// Type2 => void

  • InstanceType,获取构造函数类型的实例类型:

InstanceType直接看例子可能不好理解,所以我们先来看下它的实现:

type InstanceType<T extends new (...args: any[]) => any> = T extends new (
  ...args: any[]
) => infer R
  ? R
  : any;

InstanceType 条件类型要求泛型变量 T 类型是创建实例为 any 类型的构造函数,而它本身则通过判断 T 是否是构造函数类型来确定返回的类型。如果是构造函数,使用 infer 可以自动推断出 R 的类型,即实例类型;否则返回的是 any 类型。

看过 InstanceType 的实现后,我们来看怎么使用:

class A {
  constructor() {}
}
type T1 = InstanceType<typeof A>; // T1的类型为A
type T2 = InstanceType<any>; // T2的类型为any
type T3 = InstanceType<never>; // T3的类型为never
type T4 = InstanceType<string>; // error

上面例子中,T1 的定义中,typeof A返回的的是类 A 的类型,也就是 A,这里不能使用 A 因为它是值不是类型,类型 A 是构造函数,所以 T1 是 A 构造函数的实例类型,也就是 A;T2 传入的类型为 any,因为 any 是任何类型的子类型,所以它满足T extends new (…args: any[]) => infer R,这里 infer 推断的 R 为 any;传入 never 和 any 同理。传入 string 时因为 string 不能不给构造函数类型,所以报错。

本节小结

本小节我们学习了条件类型的相关知识,它的语法是T extends U ? X : Y,我们可以形象地理解它是三元操作符的形式,T extends U是判断条件,如果T的类型符合U,则取类型X,否则为类型Y。我们还学习了分布式条件类型,它比较简单,是条件类型的一种特殊情况,即待检测的类型是联合类型。我们还学习了如何使用infer来更好地利用类型推断。最后我们学习了几个TypeScript中常用的内置条件类型,方便我们开发使用。

下个小节我们将学习装饰器的基础部分,装饰器是实验性功能,ECMAScript对于装饰器的提案也是一再修改,截止到本专栏撰写时还没有定论,但是TypeScript已经实验性支持,你可以先体验下它。

 

25 入手装饰器,给凡人添加超能力

25 入手装饰器,给凡人添加超能力

更新时间:2019-07-05 10:07:10

人的差异在于业余时间。

——爱因斯坦

ECMAScript 的装饰器提案到现在还没有定案,所以我们直接看 TS 中的装饰器。同样在 TS 中,装饰器仍然是一项实验性特性,未来可能有所改变,所以如果你要使用装饰器,需要在 tsconfig.json 的编译配置中开启experimentalDecorators,将它设为 true。

3.12.1. 基础

(1) 装饰器定义

装饰器是一种新的声明,它能够作用于类声明、方法、访问符、属性和参数上。使用@符号加一个名字来定义,如@decorat,这的 decorat 必须是一个函数或者求值后是一个函数,这个 decorat 命名不是写死的,是你自己定义的,这个函数在运行的时候被调用,被装饰的声明作为参数会自动传入。要注意装饰器要紧挨着要修饰的内容的前面,而且所有的装饰器不能用在声明文件(.d.ts)中,和任何外部上下文中(比如 declare,关于.d.ts 和 declare,我们都会在讲声明文件一课时学习)。比如下面的这个函数,就可以作为装饰器使用:

function setProp (target) {
    // ...
}
@setProp

先定义一个函数,然后这个函数有一个参数,就是要装饰的目标,装饰的作用不同,这个target代表的东西也不同,下面我们具体讲的时候会讲。定义了这个函数之后,它就可以作为装饰器,使用@函数名的形式,写在要装饰的内容前面。

(2) 装饰器工厂

装饰器工厂也是一个函数,它的返回值是一个函数,返回的函数作为装饰器的调用函数。如果使用装饰器工厂,那么在使用的时候,就要加上函数调用,如下:

function setProp () {
    return function (target) {
        // ...
    }
}

@setProp()

(3) 装饰器组合

装饰器可以组合,也就是对于同一个目标,引用多个装饰器:

// 可以写在一行
@setName @setAge target
// 可以换行
@setName
@setAge
target

但是这里要格外注意的是,多个装饰器的执行顺序:

  • 装饰器工厂从上到下依次执行,但是只是用于返回函数但不调用函数;

  • 装饰器函数从下到上依次执行,也就是执行工厂函数返回的函数。

我们以下面的两个装饰器工厂为例:

function setName () {
    console.log('get setName')
    return function (target) {
        console.log('setName')
    }
}
function setAge () {
    console.log('get setAge')
    return function (target) {
        console.log('setAge')
    }
}
@setName()
@setAge()
class Test {}
// 打印出来的内容如下:
/*
 'get setName'
 'get setAge'
 'setAge'
 'setName'
/

可以看到,多个装饰器,会先执行装饰器工厂函数获取所有装饰器,然后再从后往前执行装饰器的逻辑。

(4) 装饰器求值

类的定义中不同声明上的装饰器将按以下规定的顺序引用:

  1. 参数装饰器,方法装饰器,访问符装饰器或属性装饰器应用到每个实例成员;

  2. 参数装饰器,方法装饰器,访问符装饰器或属性装饰器应用到每个静态成员;

  3. 参数装饰器应用到构造函数;

  4. 类装饰器应用到类。

3.12.2. 类装饰器

类装饰器在类声明之前声明,要记着装饰器要紧挨着要修饰的内容,类装饰器应用于类的声明。

类装饰器表达式会在运行时当做函数被调用,它由唯一一个参数,就是装饰的这个类。

let sign = null;
function setName(name: string) {
  return function(target: Function) {
    sign = target;
    console.log(target.name);
  };
}
@setName("lison") // Info
class Info {
  constructor() {}
}
console.log(sign === Info); // true
console.log(sign === Info.prototype.constructor); // true

可以看到,我们在装饰器里打印出类的 name 属性值,也就是类的名字,我们没有使用 Info 创建实例,控制台也打印了"Info",因为装饰器作用与装饰的目标声明时。而且我们将装饰器里获取的参数 target 赋值给 sign,最后判断 sign 和定义的类 Info 是不是相等,如果相等说明它们是同一个对象,结果是 true。而且类 Info 的原型对象的 constructor 属性指向的其实就是 Info 本身。

通过装饰器,我们就可以修改类的原型对象和构造函数:

function addName(constructor: { new (): any }) {
  constructor.prototype.name = "lison";
}
@addName
class A {}
const a = new A();
console.log(a.name); // error 类型“A”上不存在属性“name”

上面例子中,我们通过 addName 修饰符可以在类 A 的原型对象上添加一个 name 属性,这样使用 A 创建的实例,应该可以继承这个 name 属性,访问实例对象的 name 属性应该返回"lison",但是这里报错,是因为我们定义的类 A 并没有定义属性 name,所以我们可以定义一个同名接口,通过声明合并解决这个问题:

function addName(constructor: { new (): any }) {
  constructor.prototype.name = "lison";
}
@addName
class A {}
interface A {
  name: string;
}
const a = new A();
console.log(a.name); // "lison"

如果类装饰器返回一个值,那么会使用这个返回的值替换被装饰的类的声明,所以我们可以使用此特性修改类的实现。但是要注意的是,我们需要自己处理原有的原型链。我们可以通过装饰器,来覆盖类里一些操作,来看官方的这个例子:

function classDecorator<T extends { new (...args: any[]): {} }>(target: T) {
  return class extends target {
    newProperty = "new property";
    hello = "override";
  };
}
@classDecorator
class Greeter {
  property = "property";
  hello: string;
  constructor(m: string) {
    this.hello = m;
  }
}
console.log(new Greeter("world"));
/
{
    hello: "override"
    newProperty: "new property"
    property: "property"
}
/

首先我们定义了一个装饰器,它返回一个类,这个类继承要修饰的类,所以最后创建的实例不仅包含原 Greeter 类中定义的实例属性,还包含装饰器中定义的实例属性。还有一个点,我们在装饰器里给实例添加的属性,设置的属性值会覆盖被修饰的类里定义的实例属性,所以我们创建实例的时候虽然传入了字符串,但是 hello 还是装饰器里设置的"override"。我们把这个例子改一下:

function classDecorator(target: any): any {
  return class {
    newProperty = "new property";
    hello = "override";
  };
}
@classDecorator
class Greeter {
  property = "property";
  hello: string;
  constructor(m: string) {
    this.hello = m;
  }
}
console.log(new Greeter("world"));
/
{
    hello: "override"
    newProperty: "new property"
}
/

在这个例子中,我们装饰器的返回值还是返回一个类,但是这个类不继承被修饰的类了,所以最后打印出来的实例,只包含装饰器中返回的类定义的实例属性,被装饰的类的定义被替换了。

如果我们的类装饰器有返回值,但返回的不是一个构造函数(类),那就会报错了。

3.12.3. 方法装饰器

方法装饰器用来处理类中方法,它可以处理方法的属性描述符,可以处理方法定义。方法装饰器在运行时也是被当做函数调用,含 3 个参数:

  • 装饰静态成员时是类的构造函数,装饰实例成员时是类的原型对象;

  • 成员的名字;

  • 成员的属性描述符。

讲到这里,我们先补充个 JS 的知识——属性描述符。对象可以设置属性,如果属性值是函数,那这个函数称为方法。每一个属性和方法在定义的时候,都伴随三个属性描述符configurablewritableenumerable,分别用来描述这个属性的可配置性、可写性和可枚举性。这三个描述符,需要使用 ES5 才有的 Object.defineProperty 方法来设置,我们来看下如何使用:

var obj = {};
Object.defineProperty(obj, "name", {
  value: "lison",
  writable: false,
  configurable: true,
  enumerable: true
});
console.log(obj);
// { name: 'lison' }
obj.name = "test";
console.log(obj);
// { name: 'lison' }
for (let key in obj) {
  console.log(key);
}
// 'name'
Object.defineProperty(obj, "name", {
  enumerable: false
});
for (let key in obj) {
  console.log(key);
}
// 什么都没打印
Object.defineProperty(obj, "name", {
  writable: true
});
obj.name = "test";
console.log(obj);
// { name: 'test' }
Object.defineProperty(obj, "name", {
  configurable: false
});
Object.defineProperty(obj, "name", {
  writable: false
});
// error Cannot redefine property: name

通过这个例子,我们分别体验了这三个属性修饰符,还要一个字段是 value,用来设置属性的值。首先当我们设置 writable 为 false 时,通过给 obj.name 赋值是没法修改它起初定义的属性值的;普通的属性在 for in 等迭代器中是可以遍历到的,但是如果设置了 enumerable 为 false,即为不可枚举的,就遍历不到了;最后如果设置 configurable 为 false,那么就再也无法通过 Object.defineProperty 修改该属性的三个描述符的值了,所以这是个不可逆的设置。正是因为设置属性的属性描述符需要用 Object.defineProperty 方法,而这个方法又没法通过 ES3 的语言模拟,所以不支持 ES5 的浏览器是没法使用属性描述符的。

讲完属性描述符,就要注意方法装饰器对于属性描述符相关的一些操作了。如果代码输出目标小于 ES5,属性描述符会是 undefined。

来看例子:

function enumerable(bool: boolean) {
  return function(
    target: any,
    propertyName: string,
    descriptor: PropertyDescriptor
  ) {
    console.log(target); // { getAge: f, constructor: f }
    descriptor.enumerable = bool;
  };
}
class Info {
  constructor(public age: number) {}
  @enumerable(false)
  getAge() {
    return this.age;
  }
}
const info = new Info(18);
console.log(info);
// { age: 18 }
for (let propertyName in info) {
  console.log(propertyName);
}
// "age"

这个例子中通过我们定义了一个方法装饰器工厂,装饰器工厂返回一个装饰器;因为这个装饰器修饰在下面使用的时候修饰的是实例(或者实例继承的)的方法,所以装饰器的第一个参数是类的原型对象;第二个参数是这个方法名;第三个参数是这个属性的属性描述符的对象,可以直接通过设置这个对象上包含的属性描述符的值,来控制这个属性的行为。我们这里定义的这个方法装饰器,通过传入装饰器工厂的一个布尔值,来设置这个装饰器修饰的方法的可枚举性。如果去掉@enumerable(false),那么最后 for in 循环打印的结果,会既有"age"又有"getAge"。

如果方法装饰器返回一个值,那么会用这个值作为方法的属性描述符对象:

function enumerable(bool: boolean): any {
  return function(
    target: any,
    propertyName: string,
    descriptor: PropertyDescriptor
  ) {
    return {
      value: function() {
        return "not age";
      },
      enumerable: bool
    };
  };
}
class Info {
  constructor(public age: number) {}
  @enumerable(false)
  getAge() {
    return this.age;
  }
}
const info = new Info();
console.log(info.getAge()); // "not age"

我们在这个例子中,在方法装饰器中返回一个对象,对象中包含 value 用来修改方法,enumerable 用来设置可枚举性。我们可以看到最后打印出的 info.getAge()的结果为"not age",说明我们成功使用function () { return “not age” }替换了被装饰的方法getAge () { return this.age }

注意,当构建目标小于 ES5 的时候,方法装饰器的返回值会被忽略。

3.12.4. 访问器装饰器

访问器也就是我们之前讲过的 set 和 get 方法,一个在设置属性值的时候触发,一个在获取属性值的时候触发。

首先要注意一点的是,TS 不允许同时装饰一个成员的 get 和 set 访问器,只需要这个成员 get/set 访问器中定义在前面的一个即可。

访问器装饰器也有三个参数,和方法装饰器是一模一样的,这里就不再重复列了。来看例子:

function enumerable(bool: boolean) {
  return function(
    target: any,
    propertyName: string,
    descriptor: PropertyDescriptor
  ) {
    descriptor.enumerable = bool;
  };
}
class Info {
  private name: string;
  constructor(name: string) {
    this.name = name;
  }
  @enumerable(false)
  get name() {
    return this.name;
  }
  @enumerable(false) // error 不能向多个同名的 get/set 访问器应用修饰器
  set name(name) {
    this.name = name;
  }
}

这里我们同时给 name 属性的 set 和 get 访问器使用了装饰器,所以在给定义在后面的 set 访问器使用装饰器时就会报错。经过 enumerable 访问器装饰器的处理后,name 属性变为了不可枚举属性。同样的,如果访问器装饰器有返回值,这个值会被作为属性的属性描述符。

3.12.5. 属性装饰器

属性装饰器声明在属性声明之前,它有 2 个参数,和方法装饰器的前两个参数是一模一样的。属性装饰器没法操作属性的属性描述符,它只能用来判断某各类中是否声明了某个名字的属性。

function printPropertyName(target: any, propertyName: string) {
  console.log(propertyName);
}
class Info {
  @printPropertyName
  name: string;
  @printPropertyName
  age: number;
}

3.12.6. 参数装饰器

参数装饰器有 3 个参数,前两个和方法装饰器的前两个参数一模一样:

  • 装饰静态成员时是类的构造函数,装饰实例成员时是类的原型对象;

  • 成员的名字;

  • 参数在函数参数列表中的索引。

参数装饰器的返回值会被忽略,来看下面的例子:

function required(target: any, propertName: string, index: number) {
  console.log(修饰的是${propertName}的第${index + 1}个参数);
}
class Info {
  name: string = "lison";
  age: number = 18;
  getInfo(prefix: string, @required infoType: string): any {
    return prefix + " " + this[infoType];
  }
}
interface Info {
  [key: string]: string | number | Function;
}
const info = new Info();
info.getInfo("hihi", "age"); // 修饰的是getInfo的第2个参数

这里我们在 getInfo 方法的第二个参数之前使用参数装饰器,从而可以在装饰器中获取到一些信息。

本节小结

本小节我们全面学习了装饰器的相关内容,虽然装饰器在ECAMAScript标准的议程中还没有最终确定,但是TypeScript的装饰器却已经被很多人接收,很多库和插件使用装饰器来处理一些值。本小节我们学习了装饰器的定义,以及使用装饰器工厂函数来实现可传参使用装饰器,这里我们着重强调了当一个地方添加了多个装饰器工厂函数和装饰器时的执行顺序,装饰器工厂函数是从上到下执行,装饰器是从下到上执行。我们还分别学习了:类装饰器、方法装饰器、访问器装饰器、属性装饰器和参数装饰器,它们分别处理对应的值。

本章的内容到这里就结束了,这一章的内容都是高阶语法,有一些难度,所以你需要多看多思考,自己动手把提到的例子都实践下,才能更好理解。下一章我们将对前面所学只是进行整合,将一些综合性知识。


 

 

 

更多推荐

TypeScript超详细入门教程(上)