Typescript详解

746 阅读12分钟

先去做几道题

首先,我写的这篇分享,是写给有一定ts基础,但关键时刻觉得对ts懵懵懂懂的朋友。其实我自己也是,共勉共勉,分享一些我的经验。先来几道题吧,感觉做题还是记东西最好的手段。点这里 -> typescript exercises

1111.png

如何编写声明文件

第三方的模块,声明文件分为两种,一种是写在@types/下的,一种是写在模块自身项目里的。我们在引用一个第三方包时,要查一下这个包是否有声明文件,没有的话,就要自己编写了。有如下几种情况:

  1. 全局类库编写
// globalLib.js
function globalLib(options) {
    console.log(options)
}
globalLib.version = '1.2.1'
globalLib.doSomthing = function() {}

然后在globalLib.js的相同的目录下,新增一个globalLib.d.ts的声明文件

// globalLib.d.ts
// 全局声明
declare function globalLib(options: globalLib.Options): vold;
// 命名空间(函数与命名空间声明合并)
declare namespace globalLib {
    const version: string;
    function doSomthing(): void;
    // 可索引类型的接口,可以接受任意的字符串属性,返回值是any
    interface Options {
        [key: string]: any
    }
}
  1. 模块类库编写
// moduleLib.js
function moduleLib(options) {
    console.log(options)
}
moduleLib.version = '1.2.1'
moduleLib.doSomthing = function() {}
module.exports = moduleLib
// moduleLib.d.ts
// 模块声明
declare function moduleLib(options: moduleLib.Options): vold;
// 声明文件本身是一个模块,所以该interface不会暴露
interface Options {
    [key: string]: any
}
// 命名空间(函数与命名空间声明合并)
declare namespace moduleLib {
    const version: string;
    function doSomthing(): void;
}

export = moduleLib
  1. umd库编写
// umdLib.js
(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        define(factory)
    } else if (typeof module === 'object' && module.exports) {
        module.exports = factory()
    } else {
        root.umdLib = factory()
    }
}(this, function() {
    return {
        version: '1.2.1',
        doSomthing: function() {}
    }
}))
// umdLib.d.ts

declare namespace umdLib {
    const version: string;
    function doSomthing(): void;
}
// 专门为umd库编写的语句,不可缺少!!!
export as namespace umdLib
export = umdLib

当以全局变量的方式引用umd库时,要把tsconfig.json中的allowUmdGlobalAccess配置打开,不然会报错

  1. 给模块类库添加自定义方法
import moment from 'moment'
declare module 'moment' {
    export function myFunction(name: string): string
} 
moment.myFunction = (name: string) => name + 's'
  1. 给全局变量添加方法
declare global {
    namespace globalLib {
        function doAnything: void
    }
}

globalLib.doAnything = () => {}

编译项详解

选项类型默认值描述
filesarray编译器需要编译的单个文件的列表
includearray编译器需要编译的文件或目录,支持通配符,比如:src/,表示只编译src目录下的一级目录,不包含二级目录。src//*表明只编译二级目录的ts文件。如果是src的话,就表示编译src目录下的所有ts文件
excludearraynode_modules下的文件,以及所有声明文件编译器需要编译的单个文件的列表
extendsstring配置可继承,如'./tsconfig.base.json'
incrementalboolean增量编译,可优化编译速度,第二次编译速度会有很大提升
diagnosticsboolean打印编译的诊断信息
tsBuildInfoFilestring增量编译文件的名称位置
tragetstringES3指定ECMAScirpt目标版本:ES3,ES5,ES6(ES2015),ES2016,ES2017 或 ESNext
modulestringtraget === 'ES6' ? 'ES6' : 'commonjs'指定生成代码的模块标准: None,CommonJS,AMD,System,UMD,ES6,ESNext
libstring[]默认注入的库为:(1) 针对于--target ES5:DOM,ES5,ScriptHost (2) 针对于--target ES6:DOM,ES6,DOM.Iterable,ScriptHost编译过程中需要引入的库文件列表:ES5,ES6(ES2015),ES7(ES2106),ES2017, ES2018,ESNext,DOM,DOM.Iterable,WebWorker,ScriptHost,ES2015.Core,ES2015.Collection等等,可详见typescript源码。
allowJsbooleanfalse允许编译js文件
jsxstringpreserve在 .tsx文件里支持JSX:'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'.
outDirstring重定向输出目录
rootDirstring仅用来控制输出的目录结构 --outDir
moduleResolutionstringmodule === "AMD" or "System" or "ES6" ? "Classic" : "Node"决定如何处理模块解析
baseUrlstring解析非相对模块名的基准目录
pathsObject模块名到基于 baseUrl 的路径映射的列表
typesstring[]要包含的类型声明文件名列表。如果指定了types,只有指定的内容回去@types/下查找,如果没指定types,则都会去@types下查找
compositeboolean支持工程引用
  • 下面来解析一下noImplicitThis这个配置,不允许this有隐式的any类型。
class A {
    constructor() {
        this.a = 'Lizzy'
    }
    getA() {
        return function() {
            console.log(this.a)
        }
    }
}
const foo = new A().getA()

foo()

此时,在控制台执行这段代码会报错,因为this的指向变了。此时需要改成箭头函数,就不会有问题。配置noImplicitThistrue,在代码编写阶段,编译器就会报错,可以避免这类的错误发生。

class A {
    constructor() {
        this.a = 'Lizzy'
    }
    getA() {
        return () => {
            console.log(this.a)
        }
    }
}
const foo = new A().getA()

foo()
  • 再来看一个moduleResolution,typescript模块解析策略有classic(AMD、SystemJs,ES6)和node的解析策略,查找文件的查找路径会有所不同,如下图:

截屏2021-08-26 下午10.57.04.png

截屏2021-08-26 下午10.57.19.png

如下给出vue3中的ts配置,作为举例说明:

{
  "compilerOptions": {
    "baseUrl": ".", // 解析非相对模块的基地址,默认当前目录
    "outDir": "dist", // 指定输出文件的目录
    "sourceMap": false, // 生成目标文件的sourceMap
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node", // 模块的解析策略
    "allowJs": false,
    "strict": true, // 开启所有严格的类型检查 -> 在代码中注入use strict,不允许有隐式的any类型,不允许把null,undefined赋值给其他类型的变量,不允许this有隐式的any类型
    "noUnusedLocals": true,
    "experimentalDecorators": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "removeComments": false, // 删除注释
    "jsx": "preserve",
    "lib": ["esnext", "dom"],
    "types": ["jest", "puppeteer", "node"], // 控制声明文件目录,如果指定了,则只查找指定的声明文件
    "rootDir": ".", // 指定输入文件目录
    "paths": { // 路径映射,相对于baseUrl
      "@vue/*": ["packages/*/src"],
      "vue": ["packages/vue/src"]
    }
  },
  "include": [
    "packages/global.d.ts",
    "packages/*/src",
    "packages/runtime-dom/types/jsx.d.ts",
    "packages/*/__tests__",
    "test-dts"
  ]
}

Typescript核心库(lib)的类型详解

Typescript是把DOM、JavaScript内置对象等都定义好了类型,也就是上面配置项的--lib选项,可以配置编译过程中需要引入的库文件。

  1. DOM 例如,我们在用document.getElementById('app'),获取到app这个DOM元素时,它的类型在Typescript中就是:HTMLElement接口

如下,HTMLElement接口继承了Element, DocumentAndElementEventHandlers, ElementCSSInlineStyle, ElementCSSInlineStyle, ElementContentEditable, GlobalEventHandlers, HTMLOrSVGElement 这些接口,然后declare var HTMLElement: {}声明了全局变量。我们可以通过查看Typescript里面的这些接口,快速了解DOM对象的结构,也不失为一种有趣的学习方式。

源码如下:

/** Any HTML element. 
Some elements directly implement this interface, 
while others implement it via an interface that inherits it. */

interface HTMLElement extends Element, DocumentAndElementEventHandlers, ElementCSSInlineStyle, ElementCSSInlineStyle, ElementContentEditable, GlobalEventHandlers, HTMLOrSVGElement {
    accessKey: string;
    readonly accessKeyLabel: string;
    autocapitalize: string;
    dir: string;
    draggable: boolean;
    hidden: boolean;
    innerText: string;
    lang: string;
    readonly offsetHeight: number;
    readonly offsetLeft: number;
    readonly offsetParent: Element | null;
    readonly offsetTop: number;
    readonly offsetWidth: number;
    spellcheck: boolean;
    title: string;
    translate: boolean;
    click(): void;
    addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}

declare var HTMLElement: {
    prototype: HTMLElement;
    new(): HTMLElement;
};

针对以上源码,有几个知识点,我在此列出:

  1. declare var声明全局变量
  2. 有一些elements直接实现了HTMLElement这个接口,然而还有一些是实现了继承自HTMLElement的接口:

截屏2021-06-05 下午7.24.34.png

再比方,我们用document.createElement('div')生成的div,就是一个HTMLDivElement接口。它的继承关系如下图:

截屏2021-06-06 上午10.26.05.png

Typescript中的lib.dom.d.ts声明文件,已经定义好了这些关系的声明。

interface HTMLDivElement extends HTMLElement {
    /**
     * Sets or retrieves how the object is aligned with adjacent text.
     */
    /** @deprecated */
    align: string;
    addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLDivElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLDivElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}

declare var HTMLDivElement: {
    prototype: HTMLDivElement;
    new(): HTMLDivElement;
};

ESNext

Typescript 中的 ESNext 包含了es2020,esnext.intl等,如下三斜线指令可看出:

/// <reference lib="..." />,该指令允许文件显式包含现有的内置 lib 文件。
/// <reference no-default-lib="true"/>该指令将文件标记为默认库。您将在 lib.d.ts 及其不同变体的顶部看到此注释。

  • 模块依赖:/// <reference types="sizzle" />,查找方式是在node_modules/@types/目录下查找sizzle目录下的相应声明文件
  • 路径依赖: /// <reference path="legacy.d.ts" />会在该文件同级的目录下找相应的声明文件legacy.d.ts
/// <reference no-default-lib="true"/>

/// <reference lib="es2020" />
/// <reference lib="esnext.intl" />
/// <reference lib="esnext.string" />
/// <reference lib="esnext.promise" />
/// <reference lib="esnext.weakref" />

Typescript内置工具类型

下面我来介绍一些工具泛型使用及其实现, 这些泛型接口定义大多数是语法糖(简写), 你可以在 typescript 包中的 lib.es5.d.ts 中找到它的定义。

根据使用范围,我们可以将工具类型划分为操作接口类型、联合类型、函数类型、字符串类型这四个方向。如下我只给出了实现该工具类型的源码,其实大家可以尝试着自己实现一次,对ts的提升会有很大的帮助。

操作接口类型

也就是说,这些工具类型泛型变量是接口类型

Partial

可以将一个类型的所有属性变为可选的

/**
 * Make all properties in T optional
 */
type Partial<T> = {
    [P in keyof T]?: T[P];
};

Required

将给定类型的所有属性变为必填,如下,映射类型在键值的后面使用了一个 - 符号,- 与 ? 结合起来表示去除类型的可选属性,因此给定类型的所有属性都变为必填了。

/**
 * Make all properties in T required
 */
type Required<T> = {
    [P in keyof T]-?: T[P];
};

Readonly

将给定类型的所有属性设置为只读

/**
 * Make all properties in T readonly
 */
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

Pick

从给定类型中选取出指定的键值,然后组成一个新的类型。如下,Pick工具类型接收了两个泛型参数,第一个T为给定的参数类型,第二个参数为需要提取的键值key。

/**
 * From T, pick a set of properties whose keys are in the union K
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};
type Exclude<T, U> = T extends U ? never : T;

Omit

与Pick类型相反,Omit工具类型的功能是返回去除指定的键值之后返回的新类型。有趣的是,看如下Omit的实现,是借助Pick类型加Exclude类型组合而成的。

/**
 * Construct a type with the properties of T except for those in type K.
 */
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

操作联合类型

Exclude

Exclude的作用就是从联合类型中去除指定的类型。Exclude的实现使用了条件类型。如果类型T可被分配给类型U,则不返回类型T,否则返回类型T。仔细体会它与Omit的区别,Omit操作的是接口类型,而Exclude操作的是联合类型。

/**
 * Exclude from T those types that are assignable to U
 */
type Exclude<T, U> = T extends U ? never : T;

// 示例:
type T = Exclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'

type NewPerson = Omit<Person, 'weight'>
// 相当于
type NewPerson = Pick<Person, Exclude<keyof Person, 'weight'>>

Extract

Extract类型的作用与Exclude正好相反,Extract主要用来从联合类型中提取指定的类型,换句话说是取出两个联合类型的交集,类似于接口操作类型中的Pick类型。

/**
 * Extract from T those types that are assignable to U
 */
type Extract<T, U> = T extends U ? T : never;
// 示例
type T = Extract<'a' | 'b' | 'c', 'b' | 'c'> // 'b' | 'c'

发散思维:基础Extract实现一个获取接口类型交集的工具类型,你会怎么写?

type Intersect<T, U> = {
    [K in Extract<keyof T, keyof U>]: T[K]
}

NonNullable

Nonnullable 的作用是从联合类型中去除 null 或者 undefined 的类型。如果你对条件类型已经很熟悉了,那么应该知道如何实现 NonNullable类型了。

/**
 * Exclude null and undefined from T
 */
type NonNullable<T> = T extends null | undefined ? never : T;
// 等同于使用 Exclude
type NonNullable<T> = Exclude<T, null | undefined>

Record

Record的作用是生成接口类型,然后我们使用传入的泛型参数分别作为接口类型的key和value。

划重点:这里的实现限定了第一个泛型参数继承自keyof any。在ts中,keyof any 指代可以作为对象键的属性,目前js只支持string、number、symbol作为对象的键值。

/**
 * Construct a type with a set of properties K of type T
 */
type Record<K extends keyof any, T> = {
    [P in K]: T;
};

函数类型

ConstructorParameters

ConstructorParameters 可以用来获取构造函数的参数,它的实现需要用到infer关键字推断构造参数的类型。infer大概实现是:如果真实的参数类型和infer匹配的一致,那么就返回匹配到的这个类型。

如下类型实现,ConstructorParameters 泛型接收了一个参数,并且限制了这个参数需要实现构造函数。再通过infer字段匹配了构造函数内的参数,并返回了这些参数。

/**
 * Obtain the parameters of a constructor function type in a tuple(元组类型)
 */
type ConstructorParameters<T extends new (...args: any) => any> 
= T extends new (...args: infer P) => any ? P : never;

// 举例说明
class Person {
    constructor(name: string, age?: number) {}
}

type T = ConstructorParameters<typeof Person> // [name: string, age?: number]

Parameters

用来获取函数的参数并返回

/**
 * Obtain the parameters of a function type in a tuple
 */
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

ReturnType

用来获取函数的返回类型,如下限制了入参类型需要满足函数类型

/**
 * Obtain the return type of a function type
 */
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

InstanceType

/**
 * Obtain the return type of a constructor function type
 */
type InstanceType<T extends new (...args: any) => any> = T extends new (...args: any) => infer R ? R : any;

字符串类型

模版字符串

Ts自4.1版本开始支持模版字符串字面量类型。为此,Ts也提供了Uppercase、Lowercase、Capitalize、Uncapitalize这4种内置的操作字符串的类型,如下。这4种操作字符串字面量工具类型的实现都是使用JavaScript运行时的字符串操作函数计算出来的。

type T = Uppercase<'Hello'>;  // 'HELLO'
type T1 = Lowercase<T>; // 'hello'
type T2 = Capitalize<T1>; // 'Hello'
type T3 = Uncapitalize<T2> // 'hello'

在Vue3中使用Typescript

初始化vue+ts项目

  • 第一种情况是,直接用@vue/cli去新建一个含有ts的项目

  • 第二种情况是在已有项目中添加ts 新建成功后,我们可以看到,

  1. vue的script是lang="ts"
  2. defineComponent是vue的一个帮助函数,提供更好的ts支持
<template>
  <div class="about">
    <h1>This is an about page</h1>
    <div @click="a++">{{a}}</div>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'

export default defineComponent({
  setup () {
    let a = ref(0)
    return {
      a
    }
  }
})
</script>

options API的这些属性如何使用ts

  • data:需要用as关键字表明Object的详细结构
  • props:需要引入PropType声明范型
  • computed:需要给出返回的计算属性的类型
  • methods:需要给出参数和返回值的类型
import { defineComponent, PropType, h } from 'vue'
interface NameListItem {
  name: string,
  age: number,
}
export default defineComponent({
  data () {
    return {
      list: [{
        name: 'lizzy',
        age: 28
      }, {
        name: 'rock',
        age: 32
      }] as NameListItem[]
    }
  },
  props: {
    defaultNameItem: {
      type: Object as PropType<NameListItem>
    },
    size: {
      type: Number,
      default: 4
    }
  },
  computed: {
    lizzy (): NameListItem {
      return this.list[0]
    }
  },
  methods: {
    addPerson (newPerson: NameListItem) {
      this.list.push(newPerson)
    }
  },
  render () {
    return this.list.map((item: NameListItem) => {
      const slot = this.$slots.default
        ? this.$slots.default()
        : []
      return h('ul', {
        key: item.name
      }, [
        h('li', item.name),
        h('li', item.age),
        slot.map((child, index) => {
          return h('div', { class: `mt-${this.$props.size} color-${index}` }, child)
        })
      ])
    })
  }
})

扩展阅读

  1. Typescript入门教程
  2. 在泛型中使用类类型
  3. 这些好用的TypeScript内置泛型帮助类型你用过几个
  4. Typescript handbook
  5. Typescript handbook 中文版