Vue3都要上的TypeScript之工程实践
0. 前言
怎么上... 咳咳,大家别想歪,这是一篇纯技♂术文章。
0.1 Why TypeScript
什么?尤大要把Vue 3.0全部改成用Typescript来写?这不是逗我吗,那我是不是要用TypeScript来写Vue应用了?
好吧,Vue3.0可能最快也要19年年末才出来,Vue3.0是会对Ts使用者更友好,而不是只能用ts了,尤大使用ts的原因也是因为ts的静态类型检测以及ts的表现比flow越来越好了。自从巨硬大步迈向开源,前端圈子多了很多新工具比如VS Code、TypeScript。个人认为TypeScript真正火起来还是因为前端应用的复杂度不断飙升,这带来的问题就是维护性以及扩展性会变差。尤其在编写类库的时候,更是需要考虑各个类以及方法的复用性和扩展性,所以会使用到设计模式来优化代码。还有更重要的就是,编码效率的提高,静态系统无疑是降低了调试bug的时间。
0.2 Advantages & Disadvantages
优点
静态类型系统,可以借助编译器帮助在编译期间处理错误,提前避免在运行时可能发生的错误,无形中提高了代码的可靠性。
其次是如果程序中确定了数据类型,编译器可以针对这些信息对程序进行优化。(Typescript是编译为JavaScript,针对JS的基本数据类型进行优化)。
社区上的工具很多, VS code的支持非常给力, 类型提示以及Reference标记都很赞,开发者工具和体验可以说是JS世界中做得做好。
缺点
学习曲线,对于没有Java/C++等静态语言背景的程序员可能会需要有适应期。
Typescript作为静态类型语言需要程序员依照契约编写程序,为每个变量规定类型,除了Javascript本身的string、number等基本类型,还需要通过Interface关键字为复合结构声明类型。
类型的声明会增加更多代码,在程序编写过程中,这些细节会将程序员的精力从业务逻辑上分散开来。
let foo = 123; foo = ‘456‘; // Error: cannot assign `string` to `number 复制代码
- TypeScript支持ES2015+的新特性,随着标准的发展,新特性会被不断加入TypeScript中,使用TypeScript可以通过编译来规避在一些版本不高的浏览器中使用新特性的风险。
1. 工程实践
1.1 老生常谈webpack配置
Webpack已经发布到版本4.41了,相信很多小伙伴已经上了webpack4了,Webpack4对typescript的支持也是8错的,它最大的变化莫过于"零配置"以及将commonChunks plugin插件嵌入为webpack内置。最新版本:
- 首先是安装TypeScript,TypeScript是JavaScript的超集,拥有很多原生没有的特性或者说是语法糖,同时浏览器无法直接运行它,需要有一个编译的过程,即将TypeScript编译为JavaScript,所以需要先安装typescript
npm install -g typescript 复制代码
- 然后来试试编译,本地安装完之后,就可以对后缀为.ts的文件进行编译,输出为标准的JavaScript文件。 假设我们有一个用TypeScript编写的Student类。
class Student { private name: string; constructor(name: string) { this.name = name; } } 复制代码
使用typescript compiler来编译它
tsc student.ts 复制代码
编译后的结果是根据编译选项来生成的标准JavaScript文件。
var Student = /** @class */ (function () { function Student(name) { this.name = name; } return Student; }()); 复制代码
- 命令行进行编译适用于对单个或少量的typescript文件的情况,如果要使用typescript来编写大型应用或类库,就需要配置webpack在构建的时候自动编译整个项目。使用Webpack配置TypeScript项目,遵循的流程是:
TypeScript-->ES Next的Javascript版本-->兼容性较好的JavaScript。 复制代码
值得注意
之前已经安装了TypeScript compiler,通常会在compiler option中指定typescript是要编译到支持ES5/ES6/ES Next的JavaScript版本,但是在实践中我们还需要利用Babel这个结果再进行一次转译,这么做的原因有两个。
- TypeScript编译器编译的结果还不能直接用于生产环境,使用Babel可以通过browserlist来转译出兼容性适用于生产环境的js代码。
- Babel可以引入polyfill,通常会把TypeScript的编译目标设置为ES Next,然后Babel可以根据需要引入polyfill,使得最后生成的js代码体积是最少的。
const path = require(‘path‘) const webpack = require(‘webpack‘) const config = { entry: ‘./src/index.ts‘, module: { rules: [ { // ts-loader: convert typescript to javascript(esnext), // babel-loader: converts javascript(esnext) to javascript(backward compatibility) test: /\.(tsx|ts)?$/, use: [‘babel-loader‘, ‘ts-loader‘], exclude: /node_modules/ }, ] }, resolve: { extensions: [‘.tsx‘, ‘.ts‘, ‘.js‘], alias: { ‘@‘: path.resolve(__dirname, ‘./src‘), ‘mobx‘: path.resolve(__dirname, ‘./node_modules/mobx/lib/mobx.es6.js‘) } }, } 复制代码
1.2 Typescript 编译器配置
简单介绍一下typescript的编译选项,通常会在这里指定编译目标JS版本,代码的模块化方式以及代码的检查规则等。
allowJS
表示是否允许编译JavaScript文件。target
表示ECMAScript目标版本,比如‘ESNext’、‘ES2015‘。module
表示模块化的方式,比如‘commonjs‘、‘umd‘或‘es2105‘(es module)moduleResolution
表示的是模块解析的策略,即告诉编译器在哪里找到当前模块,指定为‘node‘时,就采用nodejs的模块解析策略,完整算法可以在Node.js module documentation找到;当它的值指定为‘classic‘时则采用TypeScript默认的解析策略,这种策略主要是为了兼容旧版本的typescript。strict
是否启动所有的严格类型检查选型,包括‘noImplicitAny‘,‘noImplicitThis‘等。lib
表示编译过程中需要引入的库文件的列表,根据实际应用场景来引入。experimentalDecorators
是为了支持装饰器语法的选项,因为在项目中使用了Mobx做状态管理,所以需要启用装饰器语法。include
选项表示编译的目录outDir
表示编译结果输出的目录。
{ "compileOnSave": true, "compilerOptions": { "target": "esnext", "module": "esnext", "moduleResolution": "node", "sourceMap": true, "strict": true, "allowJs": true, "experimentalDecorators": true, "outDir": "./dist/", "lib": [ "es2015", "dom", "es2016", "es2017", "dom.iterable", "scripthost", "webworker" ] }, "include": [ "src/**/*.ts" ] } 复制代码
1.3 tslint实践
tslint是针对typescript的lint工具,类似eslint遵循Airbnb Style或Standard Style,eslint也可以指定要遵循的typescript规范,目前在tslint官方,给出了三种内置预设,recommended
、latest
以及all
,省去了我们去对tslint每条规则进行配置的麻烦。
recommended
是稳定版的规则集,一般的typescript项目中使用它比较好,遵循SemVer。latest
会不断更新以包含每个TSLint版本中最新规则的配置,一旦TSLint发布了break change,这个配置也会跟随着一起更新。all
将所有规则配置为最为严格的配置。
tslint规则
tslint的规则是有严重性等级的划分,每条规则可以配置default
error
warning
或off
。tslint预设提供了很多在代码实践中提炼出来的规则,我认为有下面若干的规则,我们会经常遇到,或者需要关注一下。
only-arrow-functions
只允许使用箭头函数,不允许传统的函数表达式。promise-function-async
任何返回promise的函数或方法,都应该使用‘async‘标识出来;await-promise
在‘await‘关键字后面跟随的值不是promise时会警告,规范我们异步代码的编写。no-console
禁止在代码中使用‘console‘方法,便于去除无用的调试代码。no-debugger
禁止在代码中使用‘debugger‘方法,同上。no-shadowed-variable
当在局部作用域和外层作用域存在同名的变量时,称为shadowing,这会导致局部作用域会无法访问外层作用域中的同名变量。no-unused-variable
不允许存在,未使用的变量、import或函数等。这个规则的意义在于避免编译错误,同时因为声明了变量却不适用,也导致了读者混淆。max-line-length
要求每行的字数有限制;quotemark
指定对字符串常量,使用的符号,一般指定‘single‘;这个看团队风格了。prefer-const
尽可能用‘const‘声明变量,而不是‘let‘,不会被重复赋值的变量,默认使用‘const‘;
其他规则大家可以详细看tslint官方文档,使用lint可以更好地规范代码风格,保持团队代码风格的统一,避免容易导致编译错误的问题以及提高可读性和维护性。
tslint的特殊flags
我们用ts写代码的时候,经常会遇到一行代码的字数过长的情况,此时可以使用tslint提供的flag来使得该行不受规则的约束。
// tslint:disable-next-line:max-line-length private paintPopupWithFade<T extends THREE.Object3D>(paintObj: T, popupStyleoption: PopupStyleOption, userDataType: number) { //... } 复制代码
实际上,tslint提示是该行的字数违反了 max-line-length规则,此处可以通过增加注释 // tslint: disable-next-line: rulex
来禁用这个规则。
2. Typescript类型系统避坑tips
2.1 "鸭子"类型
"鸭子"类型??(黑人问号), 第一次看到这名词我也很懵逼, 其实它说的是结构型类型,而目前类型检测主要分为结构型(structural)类型以及名义型(nominal)类型。
interface Point2D { x: number; y: number; } interface Point3D { x: number; y: number; z: number; } var point2D: Point2D = { x:0, y: 10} var point3D: Point3D = { x: 0, y: 10, z: 20} function iTakePoint2D(point: Point2D) { /*do sth*/ } iTakePoint2D(point2D); // 类型匹配 iTakePoint2D(point3D); // 类型兼容,结构类型 iTakePoint2D({ x:0 }); // 错误: missing information `y` 复制代码
区别
结构型类型中的类型检测和判断的依据是类型的结构,会看它有哪些属性,分别是什么类型;而不是类型的名称或者类型的id。
名义类型是静态语言Java、C等语言所使用的,简单来说就是,如果两个类型的类型名不同,那么这两个类型就是不同的类型了,尽管两个类型是相同的结构。
Typescript中的类型是结构型类型,类型检查关注的是值的形状,即鸭子类型duck typing, 而且一般通过interface定义类型,其实就是定义形状与约束~ 所以定义interface其实是针对结构来定义新类型。对于Typescript来说,两个类型只要结构相同,那么它们就是同样的类型。
2.2 类型判断/区分类型
知道了typescript是个‘鸭子类型‘后,我们就会想到一个问题,ts这种鸭子类型
怎么判断类型啊,比如下面这个例子:
public convertString2Image(customizeData: UserDataType) { if (Helper.isUserData(customizeData)) { const errorIcon = searchImageByName(this.iconImage, statusIconKey); if (errorIcon) { (customizeData as UserData).title.icon = errorIcon; } } else if (Helper.isUserFloorData(customizeData)) { // do nothing } else { // UserAlertData let targetImg; const titleIcon = (customizeData as UserAlertData)!.title.icon; if (targetImg) { (customizeData as UserAlertData).title.icon = targetImg; } } return customizeData; } 复制代码
该方法是根据传入的用户数据来将传入的icon字段用实际对应的图片填充,customizeData
是用户数据,此时我们需要根据不同类型来调用searchImageByName
方法去加载对应的图片,所以我们此时需要通过一些类型判断的方法在运行时判断出该对象的类型。
基础的类型判断
基本的类型判断方法我们可能会想到typeof
和instanceof
,在ts中,其实也可以使用这两个操作符来判断类型,比如:
- 使用
typeof
判断类型
function doSomething(x: number | string) { if(typeof x === ‘string‘) { console.log(x.toFixed()); // Property ‘toFixed‘ does not exist on type ‘string‘ console.log(x.substr(1)); } else if (typeof x === ‘number‘) { console.log(x.toFixed()); console.log(x.substr(1)); // Property ‘substr‘ does not exist on type ‘number‘. } } 复制代码
可以看到使用typeof
在运行时判断基础数据类型是可行的,可以在不同的条件块中针对不同的类型执行不同的业务逻辑,但是对于Class
或者Interface
定义的非基础类型,就必须考虑其他方式了。
- 使用
instanceof
判断类型 下面这个例子根据传入的geo
对象的类型执行不同的处理逻辑:
public addTo(geo: IMap | IArea | Marker) { this.gisObj = geo; this.container = this.draw()!; if (!this.container) { return; } this.mapContainer.appendChild<HTMLDivElement>(this.container!); if (this.gisObj instanceof IMap) { this.handleDuration(); } else if(this.gisObj instanceof Marker) { // } } 复制代码
可以看到,使用instanceof
动态地判断类型是可行的,而且类型可以是Class
关键字声明的类型,这些类型都拥有复杂的结构,而且拥有构造函数。总地来说,使用instanceof
判断类型的两个条件是:
- 必须是拥有构造函数的类型,比如类类型。
- 构造函数
prototype
属性类型不能为any
。
利用类型谓词来判断类型 结合一开始的例子,我们要去判断一个鸭子类型,在ts中,我们有特殊的方式,就是类型谓词
(type predicate)的概念,这是typescript的类型保护机制,它会在运行时检查确保在特定作用域内的类型。针对那些Interface
定义的类型以及映射出来的类型,而且它并不具有构造函数,所以我们需要自己去定义该类型的检查方法,通常也被称为类型保护
。
例子中的调用的两个基于类型保护的方法的实现
public static isUserData(userData: UserDataType): userData is UserData { return ((userData as UserData).title !== undefined) && ((userData as UserData).subTitle !== undefined) && ((userData as UserData).body !== undefined) && ((userData as UserData).type === USER_DATA_TYPE.USER_DATA); } public static isUserFloorData(userFloorData: UserDataType): userFloorData is UserFloorData { return ((userFloorData as UserFloorData).deviceAllNum !== undefined) && ((userFloorData as UserFloorData).deviceNormalNum !== undefined) && ((userFloorData as UserFloorData).deviceFaultNum !== undefined) && ((userFloorData as UserFloorData).deviceOfflineNum !== undefined); } 复制代码
实际上,我们要去判断这个类型的结构,这也是为什么ts的类型系统被称为鸭子类型
,我们需要遍历对象的每一个属性来区分类型。换句话说,如果定义了两个结构完全相同的类型,即便类型名不同也会判断为相同的类型~
2.3 索引类型干嘛用?
索引类型(index types),使用索引类型,编译器就能够检查使用了动态属性名的代码。ts中通过索引访问操作符keyof
获取类型中的属性名,比如下面的例子:
function pluck<T, K extends keyof T>(o: T, names: K[]): T[K][] { return names.map(n => o[n]); } ? interface Person { name: string; age: number; } let person: Person { name: ‘Jarid‘, age: 35 } let strings: string[] = pluck(person, [‘name‘]); 复制代码
原理 编译器会检查name
是否真的为person
的一个属性,然后keyof T
,索引类型查询操作符,对于任何类型T, keyof T
的结果为T上已知的属性名的联合。
let personProps: keyof Person; // ‘name‘ | ‘age‘ 复制代码
也就是说,属性名也可以是任意的interface类型!
索引访问操作符T[K]
索引类型指的其实ts中的属性可以是动态类型,在运行时求值时才知道类型。你可以在普通的上下文中使用T[K]
类型,只需要确保K extends keyof T
即可,例如下面:
function getProperty<T, K extends keyof T>(o: T, name: K): T[K] { return o[name]; } 复制代码
原理:o:T
和 name:K
表示o[name]: T[K]
当你返回T[K]
的结果,编译器会实例化key的真实类型,因此getProperty的返回值的类型会随着你需要的属性改变而改变。
let name: string = getProperty(person, ‘name‘); let age: number = getProperty(person, ‘age‘); let unknown = getProperty(person, ‘unknown‘); // error, ‘unknown‘ is not in ‘name‘ | ‘age‘ 复制代码
索引类型和字符串索引签名 keyof
和 T[K]
与字符串索引签名进行交互。 比如:
interface Map<T> { [key: string]: T; // 这是一个带有字符串索引签名的类型, keyof T 是 string } let keys: keyof Map<number>; // string let value: Map<number>[‘foo‘]; // number 复制代码
Map<T>
是一个带有字符串索引签名的类型,那么keyof T 会是string。
2.4 映射类型
背景 在使用typescript时,会有一个问题我们是绕不开的 --> 如何从旧的类型中创建新类型即映射类型。
interface PersonPartial { name?: string; age?: number; } interface PersonReadonly { readonly name: string; readonly age: number; } 复制代码
可以看到PersonReadOnly
这个类型仅仅是对PersonParial
类型的字段只读化设置,想象一下 如果这个类型是10个字段那就需要重复写这10个字段。我们有没办法不去重复写这种样板代码,而是通过映射得到新类型? 答案就是映射类型,
映射类型的原理 新类型以相同的形式去转换旧类型里每个属性:
type Readonly<T> { readonly [P in keyof T]: T[P]; } 复制代码
它的语法类似于索引签名的语法,有三个步骤:
- 类型变量K, 依次绑定到每个属性。
- 字符串字面量联合的
Keys
,包含了要迭代的属性名的集合 - 属性的类型。
比如下面这个例子
type Keys = ‘option1‘ | ‘option2‘; type Flags = { [K in keys]: boolean }; 复制代码
Keys
,是硬编码的一串属性名,然后这个属性的类型是boolean,因此这个映射类型等同于:
type Flags = { option1: boolean; option2: boolean; } 复制代码
典型用法 我们经常会遇到的或者更通用的是(泛型的写法):
type Nullable<T> = { [P in keyof T]: T[P] | null } 复制代码
声明一个Person类型,一旦用Nullable类型转换后,得到的新类型的每一个属性就是允许为null的类型了。
// test interface Person { name: string; age: number; greatOrNot: boolean; } type NullPerson = Nullable<Person>; const nullPerson: NullPerson = { name: ‘123‘, age: null, greatOrNot: true, }; 复制代码
骚操作 利用类型映射,我们可以做到对类型的Pick
和Omit
,Pick
是ts自带的类型,比如下面的例子:
export interface Product { id: string; name: string; price: string; description: string; author: string; authorLink: string; } export type ProductPhotoProps = Pick<Product, ‘id‘ | ‘author‘| ‘authorlink‘ | ‘price‘>; // Omit的实现 export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; export type ProductPhotoOtherProps = Omit<Product, ‘name‘ | ‘description‘>; 复制代码
我们可以把已有的Product
类型中的若干类型Pick
出来组成一个新类型;也可以把若干的类型忽略掉,把剩余的属性组成新的类型。
好处
- keyof T返回的是T的属性列表,T[P]是结果类型,这种类型转换不会应用到原型链上的其他属性,意味着映射只会应用到T的属性上而不会在原型链的其他属性上。编译器会在添加新属性之前拷贝所有存在的属性修饰符。
- 不管是属性或者方法都可以被映射。
2.5 Never类型 vs Void类型
never 首先,never
类型有两种场景:
- 作为函数返回值时是表示永远不会有返回值的函数。
- 表示一个总是抛出错误的函数。
// 返回never的函数必须存在无法达到的终点 function error(message: string): never { throw new Error(message); } // 推断的返回值类型为never function fail() { return error("Something failed"); } 复制代码
void void
也有它的应用场景
- 表示的是没有任何类型,当一个函数没有返回值时,通常typescript会自动认为它的返回值时
void
。 - 在代码中声明
void
类型或者返回值标记为void
可以提高代码的可读性,让人明确该方法是不会有返回值,写测试时也可以避免去关注返回值。
public remove(): void { if (this.container) { this.mapContainer.removeChild(this.container); } this.container = null; } 复制代码
小结
never
实质表示的是那些永远不存在值的类型,也可以表示函数表达式或箭头函数表达式的返回值。- 我们可以定义函数或变量为
void
类型,变量仍然可以被赋值undefined
或null
,但是never
是只能被返回值为never
的函数赋值。
2.6 枚举类型
ts中用enum
关键字来定义枚举类型,似乎在很多强类型语言中都有枚举的存在,然而Javascrip没有,枚举可以帮助我们更好地用有意义的命名去取代那些代码中经常出现的magic number
或有特定意义的值。这里有个在我们的业务里用到的枚举类型:
export enum GEO_LEVEL { NATION = 1, PROVINCE = 2, CITY = 3, DISTRICT = 4, BUILDING = 6, FLOOR = 7, ROOM = 8, POINT = 9, } 复制代码
因为值都是number
,一般也被称为数值型枚举。
基于数值的枚举 ts的枚举都是基于数值类型的,数值可以被赋值到枚举比如:
enum Color { Red, Green, Blue } var col = Color.Red; col = 0; // 与Color.Red的效果一样 复制代码
ts内部实现 我们看看上面的枚举值为数值类型的枚举类型会怎样被转为JavaScript:
// 转译后的Javascript define(["require", "exports"], function (require, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var GEO_LEVEL; (function (GEO_LEVEL) { GEO_LEVEL[GEO_LEVEL["NATION"] = 1] = "NATION"; GEO_LEVEL[GEO_LEVEL["PROVINCE"] = 2] = "PROVINCE"; GEO_LEVEL[GEO_LEVEL["CITY"] = 3] = "CITY"; GEO_LEVEL[GEO_LEVEL["DISTRICT"] = 4] = "DISTRICT"; GEO_LEVEL[GEO_LEVEL["BUILDING"] = 6] = "BUILDING"; GEO_LEVEL[GEO_LEVEL["FLOOR"] = 7] = "FLOOR"; GEO_LEVEL[GEO_LEVEL["ROOM"] = 8] = "ROOM"; GEO_LEVEL[GEO_LEVEL["POINT"] = 9] = "POINT"; })(GEO_LEVEL = exports.GEO_LEVEL || (exports.GEO_LEVEL = {})); }); 复制代码
非常有趣,我们先不去想为什么要这么转译,换个角度思考,其实上面的代码说明了这样一个事情:
console.log(GEO_LEVEL[1]); // ‘NATION‘ console.log(GEO_LEVEL[‘NATION‘]) // 1 // GEO_LEVEL[GEO_LEVEL.NATION] === GEO_LEVEL[1] 复制代码
所以其实我们可以通过这个枚举变量GEO_LEVEL去将下标表示的枚举转为key
表示的枚举,key
表示的枚举也可以转为用下标表示。
3. Reference
advanced typescript classes and types