在本文中,我们将深入探讨在每个Angular项目中以及在处理TypeScript时应使用的一系列提示和技巧。
近年来,JavaScript中对静态类型的需求迅速增长。 越来越多的前端项目,更复杂的服务以及精心设计的命令行实用程序,已经增加了JavaScript世界中对防御性编程的需求。 此外,在实际运行应用程序之前对其进行编译的负担并不是弱点,而是机遇。 尽管出现了两个强方(TypeScript和Flow),但许多趋势实际上表明只有一种可能会占优势-TypeScript。
除了市场营销要求和众所周知的属性,TypeScript拥有一个非常活跃的社区,其贡献者非常活跃。 就语言设计而言,它也拥有最好的团队之一。 在Anders Hejlsberg的带领下,该团队已成功地将大型JavaScript项目的格局完全转变为几乎由TypeScript驱动的业务。 凭借VSTS或Visual Studio Code等非常成功的项目,Microsoft本身就是该技术的坚定拥护者。
但是,不仅TypeScript的功能使语言具有吸引力,而且TypeScript支持的可能性和框架也是如此。 Google决定完全接受TypeScript作为Angular 2+的首选语言,这是双赢的。 TypeScript不仅获得了更多关注,Angular本身也得到了更多关注。 使用静态类型,编译器已经可以向我们提供信息性警告和有关为什么我们的代码无法正常工作的有用解释。
TypeScript技巧1:提供您自己的模块定义
TypeScript是JavaScript的超集。 这样,可以利用每个现有的npm软件包。 尽管TypeScript生态系统非常庞大,但并不是所有的图书馆都提供了适当的类型。 更糟糕的是,对于某些(较小的)包,甚至没有单独的声明(以@types/{package}
)。 在这一点上,我们有两个选择:
- 使用TypeScript技巧7引入旧代码
- 自己定义模块的API。
后者绝对是首选。 无论如何,我们不仅必须查看该模块的文档,而且将其键入将防止在开发过程中发生简单的错误。 此外,如果我们对刚刚创建的类型非常满意,我们可以始终将它们提交给@types
以便在npm上包含它们。 因此,这也使我们得到了社区的尊重和感谢。 真好!
提供我们自己的模块定义的最简单方法是什么? 只需在源目录中创建一个module.d.ts
(或者也可以像该包一样命名-例如,对于npm包unknown-module
,为unknown-module.d.ts
)。
让我们为此模块提供一个示例定义:
declare module 'unknown-module' {
const unknownModule: any;
export = unknownModule;
}
显然,这只是第一步,因为我们根本不应该使用any
步骤。 (这样做的原因很多。TypeScript技巧5展示了如何避免这种情况。)但是,向TypeScript讲解模块并防止诸如“未知模块'unknown-module'”之类的编译错误就足够了。 此处的export
符号用于经典的module.exports = ...
类软件包。
这是此类模块在TypeScript中的潜在消耗:
import * as unknownModule from 'unknown-module';
如前所述,整个模块定义现在放在导出常量的类型声明中。 如果导出的内容是一个函数,则声明可能如下所示:
declare module 'unknown-module' {
interface UnknownModuleFunction {
(): void;
}
const unknownModule: UnknownModuleFunction;
export = unknownModule;
}
当然,也可以使用使用ES6模块语法导出功能的包:
declare module 'unknown-module' {
interface UnknownModuleFunction {
(): void;
}
const unknownModule: UnknownModuleFunction;
export const constantA: number;
export const constantB: string;
export default unknownModule;
}
TypeScript技巧2:枚举与常量枚举
TypeScript将枚举的概念引入了JavaScript,JavaScript确实代表了常量的集合。 和...之间的不同
const Foo = {
A: 1,
B: 2,
};
和
enum Foo {
A = 1,
B = 2,
}
在TypeScript中不仅具有语法性质。 虽然两者都将被编译为一个对象(即,第一个将保持原样,而后者将通过TypeScript进行转换),但TypeScript enum
受到保护,并且仅包含常量成员。 因此,将无法在运行时定义其值。 另外,TypeScript编译器将不允许更改这些值。
这也反映在签名中。 后者具有恒定的签名,类似于
interface EnumFoo {
A: 1;
B: 2;
}
而对象是广义的:
interface ConstFoo {
A: number;
B: number;
}
因此,我们在IDE中看不到这些“常量”的值。 const enum
现在给我们什么? 首先,让我们看一下语法:
const enum Foo {
A = 1,
B = 2,
}
这实际上是相同的-但请注意,前面有一个const
。 这个小关键字有很大的不同。 为什么? 因为在这种情况下,TypeScript无法编译任何内容。 因此,我们具有以下级联:
- 对象保持不变,但生成一个隐式的广义形状声明(接口)
-
enum
将生成一些样板对象初始化程序以及专门的形状声明 -
const enum
除了专门的形状声明外不会生成任何东西。
现在如何在代码中使用后者? 通过简单的替换。 考虑以下代码:
enum Foo {
A = 1,
B = 2
}
const enum Bar {
A = 1,
B = 2
}
console.log(Bar.A, Foo.B);
在这里,我们最终得到了以下结果:
var Foo;
(function (Foo) {
Foo[Foo["A"] = 1] = "A";
Foo[Foo["B"] = 2] = "B";
})(Foo || (Foo = {}));
console.log(1 /* A */, Foo.B);
请注意, enum Foo
仅生成了5行,而enum Bar
仅产生了简单的替换(恒定注入)。 因此, const enum
是仅编译时功能,而原始enum
是运行时+编译时功能。 大多数项目都非常适合const enum
,但是在某些情况下,首选enum
。
TypeScript技巧3:类型表达式
大多数时候,我们对使用interface
定义对象的新形状感到满意。 但是,在某些情况下,简单的接口已不再足够。 考虑以下示例。 我们从一个简单的界面开始:
interface StatusResponse {
issues: Array<string>;
status: 'healthy' | 'unhealthy';
}
'healthy' | 'unhealthy'
的表示法'healthy' | 'unhealthy'
'healthy' | 'unhealthy'
是指一个常数字符串是healthy
或者是另一个常数字符串,表示unhealthy
。 好的,这是一个声音接口定义。 但是,现在我们的代码中还有一个方法,该方法想要使StatusResponse
类型的对象发生突变:
function setHealthStatus(state: 'healthy' | 'unhealthy') {
// ...
}
到目前为止,一切都很好,但是现在将其更改为'healthy' | 'unhealthy' | 'unknown'
'healthy' | 'unhealthy' | 'unknown'
'healthy' | 'unhealthy' | 'unknown'
已经导致两个更改(一个在接口定义中,一个在函数中参数类型的定义中)。 不酷 实际上,到目前为止,我们所看到的表达式已经是类型表达式,我们只是没有“存储”它们-即给它们命名(有时称为alias )。 让我们这样做:
type StatusResponseStatus = 'healthy' | 'unhealthy';
const
, var
和let
在运行时根据JS表达式创建对象,而type
在编译时根据TS表达式(所谓的类型表达式)创建类型声明。 然后可以使用以下类型声明:
interface StatusResponse {
issues: Array<string>;
status: StatusResponseStatus;
}
在工具带中使用这样的别名,我们可以轻松地重构类型系统。 使用TypeScript的出色类型推断只会相应地传播更改。
TypeScript技巧4:使用区分符
类型表达式的用途之一是以前引入的几个(简单)类型表达式的并集-即类型名称或常量。 当然,并集不限于简单的类型表达式,但是出于可读性考虑,我们不应该提出这样的结构:
type MyUnion = {
a: boolean,
b: number,
} | {
c: number,
d: {
sub: string,
}
} | {
(): void;
};
相反,我们需要一个简单直接的表达式,例如:
type MyUnion = TypeA | TypeB | TypeC;
如果所有类型都公开至少一个具有相同名称但值(恒定)不同的成员,则可以将这种联合用作所谓的有区别的联合。 假设我们有三种类型,例如:
interface Line {
points: 2;
// other members, e.g., from, to, ...
}
interface Triangle {
points: 3;
// other members, e.g., center, width, height
}
interface Rectangle {
points: 4;
// other members, e.g., top, right, bottom, left
}
这些类型之间的区别联合可能是这样的:
type Shape = Line | Triangle | Rectangle;
现在,可以在函数中使用此新类型,在函数中,我们可以使用鉴别器上的某些验证来访问特定成员,这将是points
属性。 例如:
function calcArea(shape: Shape) {
switch (shape.points) {
case 2:
// ... incl. return
case 3:
// ... incl. return
case 4:
// ... incl. return
default:
return Math.NaN;
}
}
当然, switch
语句对于此任务非常有用,但是也可以使用其他验证方法。
区分联合在各种情况下都派上用场-例如,遍历类似AST的结构或处理在其架构中具有类似分支机制的JSON文件时。
TypeScript技巧5:避免使用任何文字,除非它确实是任何文字
我们都去过那里:我们确切地知道要编写什么代码,但是我们无法满足TypeScript编译器接受代码的数据模型的需要。 好吧,对我们来说幸运的是,我们总是可以退缩到any
来节省一天的时间。 但是我们不应该。 any
只应用于实际上可以是any的类型。 (例如, JSON.parse
故意返回any
,因为根据我们要分析的字符串,结果可能是任何东西。)
例如,在我们的一个数据存储中,我们明确定义了某个字段custom
将保存any
类型的数据。 我们不知道将在此处设置什么,但是使用者可以自由选择数据(因此可以选择数据类型)。 我们既不希望也无法阻止这种情况的发生,因此any
的类型都是真实的。
但是,在大多数情况下(即,在我们的代码专门涵盖的所有情况下), any
类型通常都是一种或多种类型。 我们只需要找出我们确切期望的类型以及如何构造这样的类型就可以为TypeScript提供所有必要的信息。
使用前面的一些技巧(例如TypeScript技巧4和TypeScript技巧3),我们已经可以解决一些最大的问题:
function squareValue(x: any) {
return Math.pow(x * 1, 2);
}
我们宁愿尽可能地限制输入:
function squareValue(x: string | number) {
return Math.pow(+x, 2);
}
现在有趣的部分是any
允许使用以前的表达式x * 1
,但通常是不允许的。 但是, +x
使我们被强制转换为所需的number
。 要检查我们的演员表是否适用于给定的类型,我们需要具体说明。 问题“什么类型可以在这里输入?” 是TypeScript可以为我们提供有用信息之前我们需要回答的一个合法问题。
TypeScript技巧6:有效使用泛型
TypeScript表示静态类型,但静态类型并不意味着显式类型。 TypeScript具有强大的类型推断功能,必须先使用并充分理解该类型推断才能真正在TypeScript中产生成果。 就我个人而言,我认为我在TypeScript中的工作效率已经比普通JavaScript高得多,因为我没有花很多时间在打字上,但是一切似乎都已准备就绪,并且TypeScript已检测到几乎所有琐碎的错误。 非专利药是推动生产率提高的因素之一。 泛型使我们能够将类型作为变量。
让我们考虑以下经典JS helper函数的情况:
function getOrUpdateFromCache(key, cb) {
const value = getFromCache(key);
if (value === undefined) {
const newValue = cb();
setInCache(key, newValue);
return newValue;
}
return value;
}
将其直接转换为TypeScript会给我们留下两个any
s:一个是从回调中检索的数据,另一个是从函数本身获取的数据。 但是,这不需要看起来像那样,因为我们显然知道类型(我们在cb
传递):
function getOrUpdateFromCache<T>(key: string, cb: () => T) {
const value: T = getFromCache(key);
if (value === undefined) {
const newValue = cb();
setInCache(key, newValue);
return newValue;
}
return value;
}
上面代码中唯一麻烦的位置是对调用getFromCache
函数的结果的显式类型分配。 在这里,我们必须暂时信任我们的代码,以便始终仅对相同的键使用相同的类型。 在TypeScript技巧10中,我们学习了如何改善这种情况。
在大多数情况下,泛型的使用只是“传递”一种类型,即告诉TypeScript有关某些参数类型之间的关系(在前一种情况下,结果的类型与回调的返回类型相关联) )。 向TypeScript讲授这种关系也可能受到进一步限制的约束,然后由TypeScript加以约束。
虽然泛型易于与接口,类型,类和标准函数一起使用,但使用箭头函数似乎不太容易使用。 根据定义,这些函数是匿名的(需要将它们分配给通过名称访问的变量)。
根据经验,我们可以采用这种方法:只需要考虑一个普通的但匿名的函数声明。 这里只有名字不见了。 这样, <T>
自然就放在括号之前。 我们最终得到:
const getOrUpdateFromCache = <T>(key: string, cb: () => T) => /* ...*/;
但是,一旦出于某种原因(无论出于何种原因)将其引入TSX文件中,最终将出现错误ERROR:未封闭的T
标签 。 这与强制类型转换(使用as
运算符解决)相同。 现在,我们的解决方法是明确告诉TypeScript该语法是供泛型使用的:
const getOrUpdateFromCache = <T extends {}>(key: string, cb: () => T) => /* ...*/;
TypeScript技巧7:引入旧版代码
将现有代码迁移到TypeScript的关键是一组经过良好调整的TypeScript配置参数-例如,允许隐式any
和禁用严格模式。 这种方法的问题在于,转换后的代码将从传统状态转变为冻结状态,这也会影响正在编写的新代码(因为我们禁用了一些最有用的编译器选项)。
更好的选择是只使用allowJs
在tsconfig.json
文件,旁边通常的(比较强)参数:
{
"compilerOptions": {
"allowJs": true,
// ...
}
}
现在,我们不再将现有文件从.js
重命名为.ts
,而是尽可能保留现有文件。 仅当我们能够认真处理内容时,才将重命名,以使代码从JavaScript完全转换为满足我们设置的TypeScript变体。
TypeScript技巧8:使用属性创建函数
我们已经知道使用接口声明函数的形状是一种合理的方法。 此外,这种方法允许我们将某些属性附加到给定的函数类型。 首先让我们看一下实际情况:
interface PluginLoader {
(): void;
version: string;
}
定义它很简单,但不幸的是,定义它并非如此。 让我们尝试通过创建实现该接口的对象来按预期使用此接口:
const pl: PluginLoader = () => {};
pl.version = '1.0.0';
哎呀:我们不能超越声明。 TypeScript(正确)抱怨,缺少version
属性。 好的,以下解决方法如何:
interface PluginLoaderLight {
(): void;
version?: string;
}
const pl: PluginLoaderLight = () => {};
pl.version = '1.0.0';
完善。 这行得通,但是它有一个主要缺点:即使我们知道经过pl.version
赋值后, version
属性将始终存在于pl
,但TypeScript并不知道这一点。 因此,从它的角度来看,对version
任何访问都可能是错误的,因此需要首先对undefined
version
进行检查。 换句话说,在当前解决方案中,我们用于生成此类对象的接口必须与用于使用的接口不同。 这不理想。
幸运的是,有办法解决这个问题。 让我们回到原始的PluginLoader
界面。 让我们尝试使用一个声明为TypeScript的强制类型转换,“相信我,我知道我在做什么”。
const pl = <PluginLoader>(() => {});
pl.version = '1.0.0';
这样做的目的是告诉TypeScript:“看这个函数,我知道它将具有给定的形状( PluginLoader
)”。 TypeScript仍在检查是否仍然可以实现。 由于没有可用的冲突定义,它将接受此强制转换。 演员阵容应该是我们的最后一道防线。 我不认为any
防御的可能行:要么类型是any
真正的(总可以-我们只是接受任何东西,完全罚款),也不宜使用,并通过具体的东西来代替(见打字稿秘诀5)。
虽然铸造方法可以解决上述问题,但在某些非角度环境(例如React组件)中可能不可行。 在这里,我们需要选择强制转换的替代变体,即as
运算符:
const pl = (() => {}) as PluginLoader;
pl.version = '1.0.0';
就个人而言,我总是会去as
驱动的转换。 它们不仅可以一直工作,甚至对于没有TypeScript背景的人也很容易阅读。 对我来说,一致性和可读性是两个原则,应始终作为每个代码库的核心。 它们可以被破坏,但是这样做必须有充分的理由。
TypeScript技巧9:运算符的键
实际上,TypeScript非常擅长于处理类型。 这样,它为我们提供了一些武器,可用于重新编写一些代码以实际生成接口的内容。 同样,它也为我们提供了用于迭代界面内容的选项。
考虑以下接口:
interface AbstractControllerMap {
user: UserControllerBase;
data: DataControllerBase;
settings: SettingsControllerBase;
//...
}
潜在地,在我们的代码中,我们有一个结构相似的对象。 这个对象的键很神奇:它的字符串在很多迭代中使用,因此在很多场合都使用。 很可能我们将这些键用作某处的参数。
显然,我们可以声明一个函数看起来像这样:
function actOnAbstractController(controllerName: string) {
// ...
}
缺点是我们肯定拥有更多的知识,而我们没有与TypeScript分享。 因此,更好的版本是:
function actOnAbstractController(controllerName: 'user' | 'data' | 'settings') {
// ...
}
但是,正如TypeScript技巧3中已经指出的那样,我们希望对重构具有弹性。 这没有弹性。 如果添加另一个键(即,在上面的示例中映射另一个控制器),则需要在多个位置编辑代码。
keyof
运算符提供了一种不错的解决方法,该方法可用于任何类型。 例如,别名为上面的AbstractControllerMap
的键如下所示:
type ControllerNames = keyof AbstractControllerMap;
现在,我们可以更改功能,以真正具有抵抗原始地图重构的能力。
function actOnAbstractController(controllerName: ControllerNames) {
// ...
}
对此,最酷的事情是keyof
实际上会尊重接口合并。 无论我们将keyof
放在keyof
,它都将始终与所应用类型的“最终”版本相对应。 在考虑工厂方法和有效的接口设计时,这也非常有用。
TypeScript技巧10:高效的回调定义
事件处理程序的类型比预期的要多出现。 让我们再看一下下面的界面:
interface MyEventEmitter {
on(eventName: string, cb: (e: any) => void): void;
off(eventName: string, cb: (e: any) => void): void;
emit(eventName: string, event: any): void;
}
回顾以前的所有技巧,我们知道这种设计既不理想也不可接受。 那么我们能做些什么呢? 让我们从问题的简单近似开始。 第一步当然是定义所有可能的事件名称。 我们可以使用TypeScript技巧3中介绍的类型表达式,但更好的方法是像前面的技巧中那样映射到事件类型声明。
因此,我们从地图开始,应用TypeScript技巧9获得以下内容:
interface AllEvents {
click: any;
hover: any;
// ...
}
type AllEventNames = keyof AllEvents;
这已经产生了作用。 现在,先前的接口定义变为:
interface MyEventEmitter {
on(eventName: AllEventNames, cb: (e: any) => void): void;
off(eventName: AllEventNames, cb: (e: any) => void): void;
emit(eventName: AllEventNames, event: any): void;
}
好一点点,但我们仍然有any
在所有感兴趣的职位。 现在可以应用TypeScript技巧6,使TypeScript对输入的eventName
更多的了解:
interface MyEventEmitter {
on<T extends AllEventNames>(eventName: T, cb: (e: any) => void): void;
off<T extends AllEventNames>(eventName: T, cb: (e: any) => void): void;
emit<T extends AllEventNames>(eventName: T, event: any): void;
}
这很好,但还不够。 现在,TypeScript在我们输入eventName
时便知道其确切类型,但是我们无法将T
存储的信息用于任何事情。 除此之外,我们可以将其与另一个强大的类型表达式一起使用:应用于接口的索引运算符。
interface MyEventEmitter {
on<T extends AllEventNames>(eventName: T, cb: (e: AllEvents[T]) => void): void;
off<T extends AllEventNames>(eventName: T, cb: (e: AllEvents[T]) => void): void;
emit<T extends AllEventNames>(eventName: T, event: AllEvents[T]): void;
}
这似乎是功能强大的东西,除了我们现有的声明都设置为any
。 因此,让我们进行更改。
interface ClickEvent {
leftButton: boolean;
rightButton: boolean;
}
interface AllEvents {
click: ClickEvent;
// ...
}
现在真正强大的部分是接口合并仍然有效。 也就是说,我们可以通过再次使用相同的接口名称来扩展事件定义:
interface AllEvents {
custom: {
field: string;
};
}
由于以出色而优雅的方式集成了可扩展性,这使得类型表达式更加强大。
进一步阅读
- (原版,2012年)TypeScript简介—类固醇上的JavaScript
- TypeScript简介
- TypeScript GitBook区分工会
- 官方TypeScript博客
- 使用TypeScript Angular 2入门
结论
希望这些TypeScript技巧中的一项或多项对您来说是新的,或者至少是您希望在更深入的撰写中看到的一些技巧。 该列表远非完整,但应该为您提供一个良好的起点,以避免出现一些问题并提高生产率。
什么技巧使您的代码大放异彩? 您在哪里最舒适? 让我们在评论中知道!
From: https://www.sitepoint.com/10-essential-typescript-tips-tricks-angular/