看完就会的 TypeScript 教程

1,930 阅读11分钟

描述 本文十分适合那些觉得难学、被官网文档劝退、初次接触typescript的老铁们。2018年的时候我开始用typescript,也算是有点使用经验吧,所以文中将从我以往学习的思维作为出发点来讲解。

下面所有代码中注释的提示均是jsDoc注释,vscodeatom微信开发者工具自带的,在写好的函数或者变量声明处输入/**然后摁回车键就出来了,注意看编辑器提示即可,其他使用细节方面这里不提及。

基础篇

先说一句:ts等于js,会js你就会ts,为什么?因为你可以在.ts文件不定义类型,就按写js的方式来写ts,编辑器哪一行代码报红了,你再给它加上类型,这就是ts基本呈现的样子,并不是为了写ts而写类型,类型的本质就是方便提示我们,过多的写类型不但增加阅读难度,而且也降低了编写效率。不用看文档,先写再说,因为编辑器会有智能提示,照着提示来写即可。

1. 什么时候需要定义类型

先来看一个代码片段

/**
 * 获取日期值(工作日和周末)
 */
function getValue() {
    const day = new Date().getDay();
    let value;
    if (day === 0 || day === 6) {
        value = "周末";
    } else {
        value = day;
    }
    return value;
}

可以看到,这就是普通的js代码,同时也是标准的ts代码。接着把鼠标放上去getValue函数名上,就会获得最终返回的类型提示,像这样:

ts1.png

该函数会返回string或者number类型,这个就是ts的类型检测带来的智能提示之一,能够在静态代码时推断出后面代码的类型;再来看一段代码

/**
 * 格式化价格
 * @param value 价格
 */
function formatPirce(value) {
    return value.toFixed(2);
}

这个时候把鼠标放到toFixed这个位置上,会提示any,同时编辑器就会报红,像这样

ts2.png

再把鼠标放至报红的value上,可以看到有个快速修复的提示操作,假设你第一次写ts可以试着点击看看,会得到以下代码

function formatPirce(value: number) {
    return value.toFixed(2);
}

编辑器帮你完成这些基本的操作了,不会ts没关系,会用编辑器就行。大概可以了理解为:你所做的操作都将是为了告诉编辑接下来为你提示什么类型,有类型提示就会有代码报错检测,这就是ts的核心,记住这点即可。

究竟什么时候需要声明类型?其实就两点:

编辑器报红时

需要确定类型推断时

继续来看代码片段

/**
 * 将秒数换成时分秒格式
 * @param value 秒数
 * @param withDay 是否带天数倒计
 */
function formatSecond(value: number, withDay = false) {
    let day = Math.floor(value / (24 * 3600));
    let hour = Math.floor(value / 3600) - day * 24;
    let minute = Math.floor(value / 60) - (day * 24 * 60) - (hour * 60);
    let second = Math.floor(value) - (day * 24 * 3600) - (hour * 3600) - (minute * 60);
    if (!withDay) {
        hour = hour + day * 24;
    }
    // 格式化
    day = day < 10 ? ("0" + day).slice(-2) : day.toString();
    hour = hour < 10 ? ("0" + hour).slice(-2) : hour.toString();
    minute = ("0" + minute).slice(-2);
    second = ("0" + second).slice(-2);
    return { day, hour, minute, second }
}

这里我的操作是把转换好的数字类型最终补全2位字符串然后返回,但是在编辑器上正常js的写法会报红,像这样

ts3.png

原因是一开始dayhourminutesecond默认没有声明类型时,ts推断这4个变量都是number类型,所以导致后面类型发生变动时类型检测不通过,这个时候就需要给初始化的变量声明类型,最终呈现这样

/**
 * 将秒数换成时分秒格式
 * @param value 秒数
 * @param withDay 是否带天数倒计
 */
function formatSecond(value: number, withDay = false) {
    let day: number | string = Math.floor(value / (24 * 3600));
    let hour: number | string = Math.floor(value / 3600) - day * 24;
    let minute: number | string = Math.floor(value / 60) - (day * 24 * 60) - (hour * 60);
    let second: number | string = Math.floor(value) - (day * 24 * 3600) - (hour * 3600) - (minute * 60);
    if (!withDay) {
        hour = hour + day * 24;
    }
    // 格式化
    day = day < 10 ? ("0" + day).slice(-2) : day.toString();
    hour = hour < 10 ? ("0" + hour).slice(-2) : hour.toString();
    minute = ("0" + minute).slice(-2);
    second = ("0" + second).slice(-2);
    return { day, hour, minute, second }
}

类型声明基本上就这些操作,官方文档的教程总是在每个变量声明或者函数声明时带上类型声明,容易导致初学者理解混乱,其实按照上面这几个操作来理解的话,或许会简单很多,ts哪里报红,我们就在哪个地方告诉它该做对应的处理即可。

2. 字符串枚举

举例:某个函数需要在传参时指定一些字段作为参数时,看代码片段

/**
 * 获取用户权限值
 * @param type 
 */
function getUserValue(type: "amdin" | "staff" | "developer") {
    let value = 0;
    switch (type) {
        case "amdin":
            value = 1;
            break;
        case "staff":
            value = 2;
            break;
        case "developer":
            value = 3;
            break;
    }
    return value;
}

ts4.png

调用getUserValue函数时,在第一个参数输入""时,就会提示出来上面枚举好的三个选项,并且第一个参数只能是这个三个参数,如果是其他,则报红。这个是我最常用且最依赖的功能,打个比方用在接口传参的时候,首次声明枚举传参字段,之后再也不用看文档或者问后台了,直接提示出来对应的参数是那些字段,别人用到该函数的时候也是,不需要去翻之旧代码就知道。

同样的,变量声明也可以用到字段枚举,像这样

let skill: "javascript" | "java" | "php";

// 在其他地方调用该变量时,可以获得枚举好的提示
if (skill === "java") {

}

ts5.png

这个枚举不仅有提示,还有代码执行逻辑提示,像这样

ts6.png

3. 接口

还是先看代码,之后再总结描述

/**
 * 设置用户信息
 * @param info 信息内容
 */
function setUserInfo(info: { token: string, id: number | string, phone: number | "" }) {
    // do some...
}

这里定义了一个函数,第一个传参是个对象,属性有tokenidphone,如果再多 N 个属性的话,该函数就会变得非常难看,这时候就需要用到类型接口这个东西:interface或者type;再来看下用接口抽离出来的样子

// 接口声明的对象属性是可以不需要写逗号的

/** 用户信息类型 */
interface UserInfo {
    /** 用户登陆凭据 */
    token: string
    /** 用户账户`id` */
    id: number | string
    /**
     * 绑定到客户端的手机号
     * @description 注意,手机号可能会为空,注意使用
    */
    phone: number | ""
}

/**
 * 设置用户信息
 * @param info 信息内容
 */
function setUserInfo(info: UserInfo) {
    // do some...
}

interfacetype这两个功能是一样的,只是写法稍有不同,把上面的代码换成type则这样

/** 用户信息类型 */
type UserInfo = {
    /** 用户登陆凭据 */
    token: string
    /** 用户账户`id` */
    id: number | string
    /**
     * 绑定到客户端的手机号
     * @description 注意,手机号可能会为空,注意使用
    */
    phone: number | ""
}

可以看到,使用接口抽离出来的类型定义更加直观,而且可以加上要说明的注释(jsDoc注释),之后鼠标放在对应的变量时候会获得对应定义的注释提示,像这样

ts7.png

注意,函数也是可以使用接口来声明类型的,同时interface也可以继承extends,功能和class中的一致

反过来,我们看看下以下代码如何取interface的键值,还是基于上面原有代码进行

const userInfo: UserInfo = {
    token: "",
    id: "",
    phone: ""
}

function getUserInfoValue(key: "token" | "id" | "phone") {
    return userInfo[key];
}

假设我要定义getUserInfoValue函数中key传参字段为userInfo的键值,一般操作会这样写。但是UserInfo这个类型如果有N个键值的话,总不能一个个枚举出来吧,这个时候就需要用到keyof这个关键字了,像这样

function getUserInfoValue(key: keyof UserInfo) {
    return userInfo[key];
}

常见的操作还有这样

for (const key in userInfo) {
    userInfo[key as keyof UserInfo] = "";
}

为什么这里key要加 as keyof UserInfo ? 因为keyfor in循环中默认是string类型,正常写js时就是没问题的,但是在ts中除了any外,必须指定类型,所以这里就需要类型指定操作;如果直接userInfo[key] = "";会报红,提示:"元素隐式具有 "any" 类型,因为类型为 "string" 的表达式不能用于索引类型 "UserInfo"。在类型 "UserInfo" 上找不到具有类型为 "string" 的参数的索引签名"

4. 类型断言

一句话描述:有时候我们知道某个值就是指定的某个类型,但是ts就是会报红时,我们强行给它赋值对应的类型;像这样

const el = document.querySelector(".box");

el.textContent = "修改内容";

明明没问题啊,为什么el还是报红了

ts8.png

鼠标放到el上发现它的类型可能为null,但是我们明确.box这个节点是一定存在的,不会是null,那么就使用断言,像这样

const el = document.querySelector(".box") as HTMLElement;

el.textContent = "修改内容";

现在就正常了,并且之后el也可以使用对应的类型提示;还有类似的情况也是

// 临时或者作为调试而定义的全局变量
window.version = "1.0.1";

这样window会报红,至于对应提示什么,可以自行操作便知晓;接着使用断言来使用它

// 临时或者作为调试而定义的全局变量
(window as any).version = "1.0.1";

任何类型:any;我在前面一直没说,因为使用到它的场景其实很少的,熟悉ts之后基本上不会用到它,同时我也不建议使用,除非你真的不需要类型检测。

还有一种情况也是比较常见的,像这样:

interface Goods {
    date: string
    price?: number,
    setPrice(): void
}

const info: Goods = {
    date: new Date().toLocaleString(),
    setPrice() {
        this.price = 123;
    }
}

info.setPrice();

info.price.toFixed();

这里会报错,出现以下提示:

微信截图_20220226114812.png

这个时候我们也是明确知道price是已经存在的,并且已经有值的情况下,可以这样:

info.price!.toFixed();

需要注意!这个只能用在undefined类型的确定时,null则需要用as来确定。

理解上面 4 点,基本上可以对付日常操作了

5. 如何获取想要的类型

看代码

const input = document.querySelector(".input") as HTMLElement;
input.value = "xxx"; 

会发现:input.value这里会报红,鼠标放上去提示:类型“HTMLElement”上不存在属性“value”。,没问题啊,.input这个节点<input class="input" type="text">就是有value属性的,为什么还会报错?是因为类型不够准确,那要怎么才能获取这个input的准确类型?可以这样

const el = document.createElement("input");

然后鼠标放上el上可以看到提示类型,像这样

ts13.png

冒号右边HTMLInputElement这个就是准确的类型了,再复制到上面报红的代码去

const input = document.querySelector(".input") as HTMLInputElement;
input.value = "xxx"; 

这时候不报错了,鼠标放到input.value也得到了正确的类型提示。

ts14.png

再来看一个也算是比较常见的的类型,假设我声明一个函数,要获取鼠标event或者对应触发的changeEvent时,如何知道对应类型:

/**
 * 鼠标按下事件
 * @param e 鼠标`event`
 */
function onDown(e) { // 这里e会报红:参数“e”隐式具有“any”类型
    console.log(e);
}

相对应的,我们写一个对应监听的代码,来用鼠标放上去e的位置来获得类型提示:

document.addEventListener("mousedown", function(e) {})

会得到这样:

微信截图_20220226120631.png

MouseEvent这个就是function onDown函数的传参类型了;细心的同学还可以发现在打印时,可以看到控制台也会有一个无法被选取并且置灰的MouseEvent,其实这个也是浏览器把对应的类型告诉你了,这种属于动态获取类型,上面的方法则是通过编辑器静态获取类型。

函数也一样,举个稍复杂点的例子。

function request() {
    return new Promise(function (resolve, reject) {
        // 这里是一个接口请求操作
        const res = {
            code: 1,
            data: {},
            msg: "success"
        }
        resolve(res)
    })
}

因为接口请求都基于一个方法,所以我要把每次接口响应的数据都约束成统一格式然后返回;来看看调用时的样子

// 其他地方调用时
request().then(res => {
    console.log(res);
})

可以发现,在写res这个变量的时候,并不能智能提示上面代码定义的类型格式,鼠标放上res上可以看到

ts15.png

按照上面获取HTMLInputElement的操作,我们把鼠标放到request这个方法上看看提示什么

ts16.png

然后复制粘贴,再定义一个接口类型,最终代码就呈现这样

/** 接口响应类型 */
interface ApiResult {
    /**
     * 请求状态码,`code === 1`为成功
     */
    code: number
    /** 接口响应数据 */
    data: any
    /** 接口响应描述 */
    msg: string
}

function request(): Promise<ApiResult> {
    return new Promise(function (resolve, reject) {
        // 这里是一个接口请求操作
        const res = {
            code: 1,
            data: {},
            msg: "success"
        }
        resolve(res)
    })
}

这时候鼠标再放到调用位置的res变量上,就看到类型提示了,细心的同学可以发现,是可以直接把<ApiResult>写在Promise后面的,也就是泛型,像这样

function request() {
    return new Promise<ApiResult>(function (resolve, reject) {
        // 这里是一个接口请求操作
        const res = {
            code: 1,
            data: {},
            msg: "success"
        }
        resolve(res)
    })
}

两种写法都是一样的,具体项目实践操作可以参考 vue-admin;不会写类型没关系,会细心找规律看编辑器代码提示就行;也可以多参考一些第三方库,看下别人怎么写类型的,多试下Ctrl+鼠标点击方法或者属性,是可以跳到对应的代码位置的。tips:javascript中也有类型提示,配合jsDoc注释即可。

进阶篇

1. 泛型

官网上对泛型的描述就很简单的直接把它当做java中的定义来说明,这里我们学的是javascript,所以就应该以javascript的思维来理解和描述;

也是一句话:泛型等于动态类型;泛型的定义一般用T来定义,看代码片段

假设我们要写一个forEach遍历数组的方法,js中是这样的

/**
 * `forEach`遍历数组
 * @param {Array} list 数组
 * @param {Function} fn 迭代函数
 */
function forEach(list, fn) {
    for (let i = 0; i < list.length; i++) {
        const item = list[i];
        typeof fn === "function" && fn(item);
    }
}

改成ts+泛型就长这样

/**
 * `forEach`遍历数组
 * @param list 数组
 * @param fn 迭代函数
 */
function forEach<T>(list: Array<T>, fn: (item: T) => void) {
    for (let i = 0; i < list.length; i++) {
        const item = list[i];
        fn(item);
    }
}

接着我们看看泛型有什么用

const options = [
    { id: 1, label: "标签一", on: true }, 
    { id: 2, label: "标签二" }, 
    { id: 3, label: "标签三" } 
]

forEach(options, function(item) {
    item.
})

在调用item时,或者鼠标放到item上时,就会知道当前item是什么类型,并且有哪些属性,分别是什么类型

ts9.png

再细说就是你传什么类型进去,代码就会根据对应类型做出对应的类型提示和检测;泛型可以是任何类型,上面我只举例了数组;泛型还可以进行一些复杂的递归处理,这里自己摸索实践下就清楚了。

2. 基础工具类型

只读类型

有时候我们定义接口或者类型时,不希望他人直接更改,也即是常量const,但是对象中的属性没有const,那么就需要借助只读属性

/** 用户信息类型 */
interface UserInfo {
    /** 用户登陆凭据 */
    readonly token: string
    /** 用户账户`id` */
    readonly id: number | string
    /**
     * 绑定到客户端的手机号
     * @description 注意,手机号可能会为空,注意使用
    */
    readonly phone: number | ""
}

const userInfo: UserInfo = {
    id: "",
    token: "",
    phone: ""
}

上面我把所有属性都设置只读,那么如果修改其中的属性就会报错

userInfo.id = "xxx"; // 无法分配到 "id" ,因为它是只读属性。ts(2540)

可选类型

可选类型关键字比较简单是个问号?,对应的必选类型更简单,默认声明只要不加?就是必选的了

/** 用户信息类型 */
interface UserInfo {
    /** 用户登陆凭据 */
    token: string
    /** 用户账户`id` */
    id: number | string
    /**
     * 绑定到客户端的手机号
     * @description 注意,手机号可能会为空,注意使用
    */
    phone?: number | ""
}

const userInfo: UserInfo = {
    id: "", // 必选
    token: "", // 必选
    // phone: "" // phone 可以不写,因为它是可选的
}

用在函数上

function getUsrInfo(info?: UserInfo, type?: string) {
    // do some ...
}

深层工具类

现在有个功能:假设用上面UserInfo接口,我要它每个属性都设为只读的,那么可能会这样写

interface UserInfo {
    readonly token: string
    readonly id: number | string
    readonly phone: number | ""
}

但是有个问题,如果这个接口很多属性,而属性的子属性也是有很多层,那这种手动加关键字的操作就显得很low了,回到上面我说到的,泛型可以动态操作类型,那么我们可以写一些递归的操作,像这样

/** 深层递归所有属性为只读 */
export type DeepReadonly<T> = {
    readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
}

使用时

interface UserInfo {
    /** 用户登陆凭据 */
    token: string
    /** 用户账户`id` */
    id: number | ""
    /**
     * 绑定到客户端的手机号
     * @description 注意,手机号可能会为空,注意使用
    */
    phone: number | ""
    /** 详细信息 */
    info: {
        /** 用户年龄 */
        age: number | ""
        /** 用户备注 */
        desc: string
    }
}

const userInfo: DeepReadonly<UserInfo> = {
    token: "",
    id: "",
    phone: "",
    info: {
        age: "",
        desc: ""
    }
}

// 报红
userInfo.info.age = 20; // 无法分配到 "age" ,因为它是只读属性。

这样就达到我们想要的效果了,不能修改这个对象的所有属性,同理可得,我们可以再写多一个递归泛型:可选;

/** 深层递归所有属性为可选 */
export type DeepPartial<T> = {
    [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
}

利用上面这几个工具类型,我们来做些实际性的事情,比如userInfo在调用时是不能够直接修改的,只能约定一个方法来做更改,那么就可以这样

先定义一个工具函数,修改对象用的

/**
 * 修改属性值-只修改之前存在的值
 * @param target 修改的目标
 * @param value 修改的内容
 */
function modifyData<T>(target: T, value: T) {
    for (const key in value) {
        if (Object.prototype.hasOwnProperty.call(target, key)) {
            // target[key] = value[key];
            // 需要的话,深层逐个赋值
            if (typeof target[key] === "object") {
                modifyData(target[key], value[key]);
            } else {
                target[key] = value[key];
            }
        }
    }
}

然后再定义一个能够修改userInfo的函数

/**
 * 更新用户信息字段
 * @param value 
 */
function updateUserInfo(value: DeepPartial<UserInfo>) {
    modifyData(userInfo, value);
}

最后来试一下修改深层只读的userInfo.info.age

updateUserInfo({
    info: {
        age: 20
    }
})

这里单独修改了age同时也获得了泛型带来的类型提示,是想要的结果;

这里扯一下题外话:用过Vuex的都知道,在store状态中,是可以直接修改store的某个属性的,只是官方说明是约定store.commit来修改属性,并不能真正意义上做到限制直接修改store.xxx;现在,这个就是最佳解决方案,参考我的另外一篇文章你不需要Vuex

更多高阶工具类型可以参考TypeScript 高级用法,本文只做基础讲解。

3. class相关类型

classts的加持下变得更像java,还是那句话,我们就会javascript,所以不要谈java

public

class ModuleA {
    // 这里 public 写不写都可以,因为默认就是 public
    // 类型定义也是跟普通变量一样,可以声明可不声明,报红了就一定要声明
    public age = 12;
}
const a = new ModuleA();

// 这里实例化可以访问
a.age // 12

private

私有属性,其实很好理解,就是当前ModuleA{}里面可以访问,实例化或者继承的子类都无法访问

class ModuleA {
    
    private age = 12;
}

const a = new ModuleA();

a.age // 编辑器报红提示 属性“age”为私有属性,只能在类“ModuleA”中访问。


class ModuleB extends ModuleA {
    constructor() {
        super();
    }
    getAge() {
        console.log(this.age); // 编辑器报红提示 属性“age”为私有属性,只能在类“ModuleA”中访问。
    }
}

const b = new ModuleB();

b.age // 编辑器报红提示 属性“age”为私有属性,只能在类“ModuleA”中访问。

protected

受保护的:实例化后不能访问,其余环境都能访问

class ModuleA {
    protected age = 12;
}

const a = new ModuleA();

a.age // 属性“age”受保护,只能在类“ModuleA”及其子类中访问。

class ModuleB extends ModuleA {
    constructor() {
        super();
    }

    getAge() {
        return this.age; // 可以访问
    }
}

const b = new ModuleB();

b.age // 属性“age”受保护,只能在类“ModuleA”及其子类中访问。

readonly

和接口interface一样,calss中也有readonly,不同的是:readonly声明的属性可以在constructor中修改,其他时候则不能,像这样:

class ModuleA {
    constructor() {
        this.age = 456;
    }
    
    readonly age: number | "" = "";

    onLoad() {
        this.age = 123; // 报红,提示:无法分配到 "age" ,因为它是只读属性。
    }
}

const a = new ModuleA();

a.age = 20; // 报红,提示:无法分配到 "age" ,因为它是只读属性。

定义动态属性或方法

属性用!,方法用?,类型还是用:,比较简单

class ModuleA {
    
    /** 用户信息 */
    userInfo!: UserInfo;

    /** 获取用户信息 */
    getUserInfo?(): UserInfo;


    onLoad() {
        // 动态设置方法和属性
        this.getUserInfo = function() {
            return {
                token: "",
                id: "",
                phone: "",
                info: {
                    age: 18,
                    desc: ""
                }
            }
        }
        this.userInfo = this.getUserInfo();
    }

}

实践定义动态属性或方法 | 设置动态方法

4. 全局类型声明

通常用来做一些功能模块定义用的,指定文件后缀为d.ts;例如上面有个window.version的断言操作,如果不想用any,那么就新建一个文件名.d.ts,然后这样

/**
 * [教程](https://blog.csdn.net/weixin_34289454/article/details/92072706)
 */
// declare global {
//     interface Window {
//         /** 当前版本,方便在控制台查看调试用 */
//         version: string
//     }
// }
interface Window {
    /** 当前版本,方便在控制台查看调试用 */
    version: string
}

也可以定义一些自定义全局变量或者函数

/** 接口请求返回字段 */
interface ApiResult {
    /** 图标 */
    icon: string
    /** 金额 */
    money: number,
    /**
     * 广告配置数组
     * - `0` 群 
     * - `1` 全
     * - `2` 群广告
     * - `3` 圈广告
     * @example
     * [0,0,0,0,0,0,0]
    */
    adConfig: Array<number>
    /** 分享图片 */
    shareImg: string
    /** 弹窗提示`html` */
    popupTip: string
    /** 对应提示的字段 */
    popupText: string
}

declare const api: ApiResult;

使用时就会获得类型提示了

ts10.png

现在基本上90%npm上的第三方库都支持typescript,所以在引入或者使用这些库的时候,都会有类型提示,哪怕你用的是javascript,因为他们都定义了自己代码库的类型标准;举个例子,我在使用element-ui

ts11.png

这些传参提示都是依靠对应第三方库的d.ts来实现的,Crtl+鼠标点击即可跳转到对应的位置,这时候就不需要看文档来写代码了,一是有传参类型提示,二是可以看到对应方法详细的使用方法;打个比方我要看Message这个方法要怎么传参,传些什么使,像这样

ts12.png

有比文档更加详细的使用方法,不用再像以往使用javascript一样,要查某个方法变量就全局搜,百度找,搞半天没能准确定位,效率十分低下。那些说typescript不好用的人,一定是不知道有这个功能的存在。typescript写得好,过一年半载回来改某个代码片段要比javascript代码重新看一遍节省时间,更别说bug。