TypeScript 进阶手册

本文旨在帮助更高效使用 TypeScript、帮助我们成为 TypeScript 高手,无论在面试、指导新人、交流中都可以一展所长,限于内容较多、会分2篇发布。

我对 TypeScript 的态度

关于是否推荐使用 TypeScript、一直各有声音,很多开发者也各有所好,有人热爱 TypeScript 的严谨和专业,有人更偏爱 JavaScript 本身的灵活与简洁。打开网络、你可能在某一刻接收到《使用 TypeScript 的 10 个 理由 》信息,正当你想在项目中大显身手的时候、搜索引擎给你推荐《不推荐使用 TypeScript 的 7 个 理由 》。这些不是本文要讨论的话题,我的建议是在实践中形成自己的经验和想法 :"If you can't figure out it,just try and do it. Hesitating or waiting for too long is a waste of time. "

建议学习TypeScript、用好TypeScript,但谨记它不是万能的、也并不是始终适用,可以把它当做一种开发 JavaScript 应用的语法、风格,代码最重要的还是产生价值,应用开发效率、代码可读性和高拓展性至关重要,切勿舍本求末。

TypeScript 的特点

强类型

TypeScript是一门强类型语言、类型规则是严格执行的,它的优势是:

  1. 有助于更早发现不稳定性问题:如对某变量操作使用不当、在 string 上使用number 相关操作、在未来的某一刻很可能暴雷;
  2. 更有利于多人或跨组件协作:通过智能提示、代码检测工具能更有效地进行代码推断、提高代码质量和协作效率,如我们可以更快捷的使用第三方库、清晰的知道 API 需要的数据格式、类型,也无需手工 check 是否遗漏关键信息。

编程范式

支持函数式和面向对象的编程方式写代码,在JavaScript 的基础上更强调面向对象特性,如继承、类、可见性范围,命名空间等,有更完整的代码组织能力。

  1. 面向对象编程(Object Oriented Programming) :TypeScript 提供了丰富的面向对象编程特性,包括类、接口、泛型等。可以极大的提升代码组织和复用性、在常见的代码迁移/重构场景优势明显;
  2. 函数式编程(Functional Programming) :函数式编程将函数作为一等公民,可以实现高度的抽象和复用;

语法

TypeScript 是JavaScript 的超集,继承了JavaScript的全部语法,所有JavaScript 代码都可以作为TypeScript 代码运行;除此之外,TypeScript 也为JavaScript 增加了对ES6的支持。自然而然、在享受其益处的同时、我们会需要一定的学习和上手成本,但是这一定是值得的。

类型世界

合理使用type 与 interface

type(类型别名) 和 interface 都可以用来定义类型、且多数情况可以互换,但他们有没有区别、以及如何更好的使用他们呢?推荐的方式是:使用interface 描述对象、类的结构 ,用type描述类型别名、联合类型、交叉类型等复杂类型

An interface can be named in an extends or implements clause, but a type alias for an object type literal cannot.

An interface can have multiple merged declarations, but a type alias for an object type literal cannot.

相同点

  • 两者都可以描述一个对象或者函数;
  • 都支持拓展(extends),语法不一样;
typescript 复制代码
type TBasicInfo = {
	name: string;
}
type TUser = TBasicInfo & { religion: string  };

interface IUser extends TBasicInfo { 
  religion: string; 
}

差异点

  • type 可以声明基本类型别名,联合类型,元组等类型;
  • interface 能够声明合并,支持实现(implements);
typescript 复制代码
type StringOrNumber = string | number;
typescript 复制代码
interface User {
  name: string;
}

interface User {
  age: string;
}

// Last output =
interface User {
  name: string;
  age: number;
}

善用枚举

枚举(Enum)类型用于取值被限定在一定范围内的场景,比如一周只能有七天,颜色限定为红绿蓝等。

枚举可以让代码拥有更好的类型提示,同时可以把常量真正地约束在一个命名空间下,最常见的场景就是项目中常量声明。

javascript 复制代码
const RouteMap = {
  HomePage: "xx",
  SettingPage: "xxxx",
}
// =>
enum RouteMap {
  HomePage: "xx";
  SettingPage: "xxxx";
}

// Usage
RouteMap.HomePage;

拒绝AnyScript - 理解any 、unknown 与 never

any 、unknown 与 never 都是 TypeScript 的内置类型,有不同的含义和使用场景。

any:表示任意类型 ,能兼容所有类型,也能够被所有类型兼容,使用 any 时类型推导与检查不会生效;

typescript 复制代码
let obj: any = null;

// 以下代码均无异常提示
obj.getCoordinate();
obj.x.y;

unknown:表示类型未知、但未来会确定类型。

typescript 复制代码
let obj: unknown = null;

obj.getCoordinate(); // 报错:obj类型为 unknown

(obj as { getCoordinate: () => string  }).foo(); // ok

never:代表无类型,不带类型信息,常见的是 throwerror 函数。

typescript 复制代码
function throwError(): never {
  throw new Error()
}

有时候也可以用它来做流程分支运行时安全/错误检查、兜底:

typescript 复制代码
function  handle(key: string | number): boolean {
  if (typeof key === 'string') {
    return true;
  } else if (typeof key === 'number') {
    return false;
  }

  // 如果不是never 类型会报错:因为检查到无法访问的代码 / 无预期返回类型
  // throwError 函数返回为never 类型、则可以被调用
  return throwError('Unexpected error');
}

function throwError(message: string): never {
  throw new Error(message);
}

Class 的隐秘用法

修饰符

Class 的基本结构主要有构造函数、属性、方法和访问符,我们主要介绍给 类成员添加的访问性修饰符:public / private / protected,不显式使用访问性修饰符,默认会被标记为 public:

  • public:此类成员在类、类实例、子类中均能被访问;
  • private:此类成员仅支持在类内部访问;
  • protected:此类成员仅支持在类与子类中被访问。
typescript 复制代码
// 在构造函数中对参数设置访问性修饰符,等价于 单独声明类属性然后在构造函数中进行一一赋值
class User {
  constructor(public name: string, private age: number) { }
}

new User('Didi', 22);

static 私有构造函数

当类的构造函数标记为private 后,就只允许在类内部访问了,主要作用场景是定义工具库、工具库内都是静态方法,并不期望被实例化:

typescript 复制代码
class Utils {
  public static tactics = ['money', 'number', 'ratio'];
  
  private constructor(){}

  public static transferNum() {
  }
}

Utils.transferNum();

泛型

泛型(Generics)是指在定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。

泛型的本质是为了参数化类型、即不创建新类型,通过泛型就可以控制 函数/类/接口 接收形参时的具体类型。所以总结就是支持动态类型、以及可以实现类型约束

参数化类型

可以考虑以下2 个需求如何实现:

  1. 我们需要打印或获取某变量参数类型;
  2. 我们已有一个接口定义,现在只是期望把所有属性变成可选;

需要怎么实现呢?需求 1 暴力做法是把能想到的参数类型都枚举列出、显然麻烦一些、且不够灵活,因为我们并不期望预置固定类型、反而希望它能灵活处理、在调用时做限制:

typescript 复制代码
// 暴力写法
function print(arg: string | number | boolean): string | number | boolean {
    console.log(typeof arg);
    return arg;
}

// 泛型解法
function print<T>(arg: T): T {
    console.log(typeof arg);
    return arg;
}

需求 2 暴力写法就是重新声明一份接口、加上可选修饰符?

typescript 复制代码
// 暴力写法
interface IUser {
  name: string;
  age: number;
  sex: string;
}
interface IUserPartial {
  name?: string;
  age?: number;
  sex?: string;
}
const user: IUser = { name: 'cat' }; // Error: Type '{ name: string; }' is missing the following properties from type 'IUser': age, sexts(2739)
const user: IUserPartial = { name: 'cat' }; // ok

// 泛型
type Partial<T> = {
    [P in keyof T]?: T[P];
};
const user: Partial<IUser> = { name: 'cat' }; // ok

再比如实现数组/元组项交换例子:

typescript 复制代码
function swap<T, U>(tuple: [T, U]): [U, T] {
    return [tuple[1], tuple[0]];
}

swap(['cat', 'dog']); // ['dog', 'cat']
swap([3, 'dog']); // ['dog', 3]

类型约束

另外、可以利用泛型约束更精准控制函数调用参数,更可预期、有助于提高代码质量和可维护性,比如我们实现对象merge 功能:

typescript 复制代码
function merge<T extends U, U>(target: T, source: U): T {
    for (let id in source) {
        target[id] = (<T>source)[id];
    }
    return target;
}

const obj = { a: 1, b: 2, c: 3, d: 4 };

merge(obj, { b: 10, d: 20 }); // { a: 1, b: 10, c: 3, d: 20 }
merge(obj, { b: 10, e: 20 }); // Error: Property 'e' is missing in type '{ a: number; b: number; c: number; d: number; }' but required in type '{ b: number; e: number; }'.ts(2345)

另外关于类型约束常见的场景就是对接 API 实现数据请求功能,我们通常期望对 API 格式、字段值、请求库返回结构等做好约定、提高程序稳定性、以及开发效率:

typescript 复制代码
// src/api/type.ts
// API 定义接口,可以是具体的 url,可选、可用于做请求 url拦截
interface IApi {
  '/app/list': any;
	'/app/detail': any;
	[key: string]: Record<string, unknown>;
}

// API 返回格式 接口
interface IResponseData<T> {
  code: 0 | 1 | 2 | 3;
  data: T;
  message: string;
}

// 具体数据格式接口
interface IUserData {
  name: string;
  age: number;
}

// 通用请求 类型定义
type TCommonRequest<T, U> = (url: Extract<keyof T, string>, params: Record<string, unknown>) => Promise<U | undefined>;
// 业务请求 类型定义
type TRequest<T> = TCommonRequest<IApi, IResponseData<T>>;
typescript 复制代码
// src/api/index.ts
// 定义 API 常量
const Api = {
  app: {
    list: '/app/list',
    detail: '/app/detail',
  },
};
typescript 复制代码
// src/components/User.tsx

// 业务组件实现 数据请求功能代码
const request: TRequest<IUserData> = async (url, params) => {
  const res = await fetch(url, params);
  return res.json();
};

async function fetchData() {
  const user = await request(Api.app.list, {});
	// outcome log
  // {
  //  code: 0,
  //  result: { name: 'cat', age: 3 },
  //  message: '请求成功'
  // }
  console.log(user);
}

fetchData();

联合类型

联合类型的常用场景之一是通过多个对象类型的联合,实现手动的互斥属性,即这一属性如果有字段1,那就没有字段2:

typescript 复制代码
interface IUser {
  info:
    | {
        vip: true;
        expires: string;
      }
    | {
        vip: false;
        promotion: string;
      };
}

declare let user:IUser;

if (user.info.vip) {
  console.log(user.info.expires); // ok
	console.log(user.info.promotion); // Error: Property 'promotion' does not exist on type '{ vip: true; expires: string; }'.ts(2339)
}

TypeScript 工程问题

项目配置

eslint 配置

推荐统一通过 eslint 进行项目配置(tslint 已废弃):

bash 复制代码
# 使用npm 安装
npm install --save-dev eslint
npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin

# 使用yarn 安装
yarn add --dev eslint
yarn add  --dev @typescript-eslint/parser @typescript-eslint/eslint-plugin
  • @typescript-eslint/parser:ESLint 解析器,用于解析Typescript,从而检查和规范Typescript代码;
  • @typescript-eslint/eslint-plugin:ESLint 插件,包含了各类定义好的Typescript 代码检测规范。
json 复制代码
// .eslintrc.json 文件
{
  "parser": "@typescript-eslint/parser", // 定义ESLint的解析器
  "plugins": ["@typescript-eslint"], // 定义了该eslint文件所依赖的插件
  "extends": [ // 定义文件继承的子规范
    "plugin:@typescript-eslint/recommended"
  ],
	"env":{    //指定代码的运行环境
    "browser": true,
    "node": true,
	},
	"parserOptions": {        //指定ESLint可以解析JSX语法
    "ecmaVersion": 2019,
    "sourceType": "module",
    "ecmaFeatures": {
      "jsx": true,
		},
  },
	"rules": {
		"quotes": "off",
    "@typescript-eslint/quotes": ["error", "single"],
  },
}

// package.json 文件添加脚本
"scripts": {
  "eslint": "eslint src/**"
}

// 执行脚本
npm run eslint

prettier 配置

bash 复制代码
// 安装
npm install --save-dev prettier eslint-config-prettier eslint-plugin-prettier

配置.prettierrc.js 文件:

js 复制代码
module.exports =  {
  "printWidth": 120,
  "semi": false,
  "singleQuote": true,
  "trailingComma": "all",
  "bracketSpacing": false,
  "jsxBracketSameLine": true,
  "arrowParens": "avoid",
  "insertPragma": true,
  "tabWidth": 4,
  "useTabs": false  
};

.eslintrc.json文件中extends 下添加配置:

json 复制代码
"prettier/@typescript-eslint",
"plugin:prettier/recommended",
  • prettier/@typescript-eslint:替代@typescript-eslint 中代码样式规范,改为遵循prettier中的样式规范;
  • plugin:prettier/recommended:给 ESlint 检测规则添加prettier 代码样式规范,代码格式问题会默认以error 的形式展示;

最后你还可以添加Huskylint-staged用来在代码提交前做好代码规范、风格检测。

类型导入

我们知道、TypeScript 类型只是在编译时添加类型检测,运行时并不存在、或者说构建JavaScript 代码时会擦除类型相关代码,以代码为例:

typescript 复制代码
type TBasicInfo = {
	name: string;
}
type TUser = TBasicInfo & { religion: string  };

const setUser = (user: TUser) => {
	const name = user?.name;
}

转换后的代码类似如下,类型代码被擦除、语法做了转换:

typescript 复制代码
const setUser = (user) => {
	var name;
	if (user && user.name) {
		name = user.name;
	}
}

再来看一个模块导入 case:

typescript 复制代码
// ./foo.ts
export function doThing(options: Options) {
  // ...
}

// ./bar.ts
import { doThing } from './foo.ts';

function doThingBetter(options: Options) {
  doThing(options);
}

上面代码转换后会去除 Options,因为可以明确它是一个类型;但当我们遇到如下模块导入场景、就会出现问题:无法判断Thing 是模块还是类型,如果 Thing 是类型、则编译出的代码无法正确运行、因为运行时并不支持类型代码

typescript 复制代码
import { Thing } from './module.ts';

export { Thing };

三方库兼容

declare module/namespace

在项目中我们很可能会使用大量第三方库、可能会遇到其中一些库不支持类型声明,或者团队历史工具库不支持 TypeScript,使用时 eslint 检测会有错误提示:

可以通过 declare modulenamespace的方式来提供其类型,declare module通常用于为没有提供类型定义的库进行类型的补全:

typescript 复制代码
// Nlib/index.d.ts
declare module 'Nlib' {
  export const getLength: () => number;
  export default getLength;
}

// or namespace
declare namespace Nlib {
  export function getLength: () => number;
}

模块和命名空间的作用基本一致、都可以包含代码和声明,主要的不同之处在于:

  1. 模块可以声明它的依赖、import 其他模块;
  2. 命名空间最明确的目的就是解决重名问题、本质是位于全局命名空间下的一个普通有名字的 JavaScript 对象;

除了为缺失类型的模块声明类型以外,我们还可以为非代码文件,如图片、CSS文件等声明类型,如 markdown 文件,其本质和 npm 导入规则一致,因此可以类似地使用 declare module 语法:

typescript 复制代码
// declare.d.ts
declare module '*.md' {
  const raw: string;
  export default raw;
}

// index.ts
import readme from './readme.md';

DefinitelyTyped

@types/ scope下的 npm 包类型均属于 DefinitelyTyped ,由TypeScript 维护,专用于为社区存在的无类型定义的JavaScript 库 添加类型支持,内部其实是数个 .d.ts 后缀的声明文件,常见的有 @types/react @types/lodash 等等。

相关推荐
咖啡の猫1 小时前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲4 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5814 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路4 小时前
GeoTools 读取影像元数据
前端
ssshooter5 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
Jerry5 小时前
Jetpack Compose 中的状态
前端
dae bal6 小时前
关于RSA和AES加密
前端·vue.js
柳杉6 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化
lynn8570_blog7 小时前
低端设备加载webp ANR
前端·算法
LKAI.7 小时前
传统方式部署(RuoYi-Cloud)微服务
java·linux·前端·后端·微服务·node.js·ruoyi