引言
高质量的类型可以提高项目的可维护性并避免一些潜在的漏洞。 via: type-challenges
前端开发的潮流之一就是使用 Typescript 进行开发,可以说 Typescript 让大型项目的前端开发体验上了一个台阶,不知读者们有无开发过大型的基于 Javascript 的前端系统的经验,一开始的时候 JS 带来的流畅的体验和效率让人很兴奋,但是随着维护时间的增加,以及业务逻辑随着时间推移不可避免的锈化,很多时候在工作时会对正在操作的对象产生怀疑:这个属性存在吗,是什么意思的来着? 从我的角度来看,在使用 JS 进行开发的时候,需要开发者自己去维护一套上下文模型,以应对返回的数据,和需要保存的状态等等。当项目扩大,参与人员增多的时候,多人开发无法快速互相沟通的问题,也会导致开发进度的受阻。所以如果可以的话,使用 TypeScript 吧!这篇文章不是布道,也不是关于 TS 的教程,网上资料很多,本文主要是想分享一下我在开发大型长期维护项目的过程中,对于类型计算和 TypeScript 的一些理解,以及 TypeScript 是如何服务于领域驱动设计和开发者的心智模型的。
类型化的 JS 的前世今生
JavaScript 是一门使用广泛并且很容易入门的语言,但是这种易用性导致了很大的不稳定性,相比于 Java,Rust 等我用过的静态类型语言来说,JavaScript 的超高自由度可以让我在一天里撸出一个前后端简单点单系统,但是也可以让我在后续的重构中痛不欲生——如果你在里面不能很规范的书写你的代码的话。 易书写,难梳理是我对 JavaScript 的开发的一个总结,为了解决这个问题,除 TypeScript 之外也有许多解决方案:
- 首先是 JSDoc,使用注释来给 JavaScript 方法等添加类型,结合编辑器可以实现一定的类型推断功能,详细大家都不陌生。
- Flow 可以说是一个很重要的 JavaScript 静态类型检查工具了。如果你读过 vue.js 的源码,应该对 Flow 有印象,有点类似于 Eslint,Flow 会在文件开头添加特殊注释打开检查功能,添加的类型注解会计算获得的类型,这和 TypeScript 也很类似。
关于 Flow 为什么没能竞争过 TypeScript ,可以去看看这篇文章,但总的来说就是使用起来不够方便,所以下面我们就看看,如何可以在项目中使用 TypeScript ,并且最大限度的利用 TS 的类型计算功能,辅助开发的流程。
如何在开发中最大限度利用 TypeScript
TypeScript 是 JavaScript 的超集,也就是说:
所谓超集是集合论的术语,A ⊇ B,则 A 集是 B 的超集,也就是说 B 的所有元素 A 里都有,但 A 里的元素 B 就未必有。 int i = 0; 合乎 C 语言语法,在 C++ 里也是合法的。
可以通过直接修改文件后缀名的方式,把 .js
代码直接转换为 .ts
的代码。这种渐进式的引入我认为是可以快速传播的关键之一,配合 ts-node 可以很快的进行测试使用。
当然如果是新建的项目,主流的脚手架目前都支持拉取 ts 的模板,入门门槛已经很低了,网上的“第一天教程”也是满天飞。
那接下来应该怎么办呢,很多时候当我们开始真正写代码的时候,就会对 TS 有点手足无措了,也许我应该给我声明的变量设置一个类型,但是如果是接口返回的数据应该如何做?一些工具方法,例如 parse 结构的方法,应该如何最好的设置类型?对于使用的组件库/轮子,如何使用提供的d.ts
文件?可能需要后续的学习了。这里不是很想赘述很多“第二天教程”,还是结合一些我最近写 ng 的体验来说说我是如何在开发中最大限度利用 TypeScript 的。
例子
考虑一个对象数组{id:number,name:string}[]
,如果知道了 id 想去查询对应的 name,应该还是很容易的:
function findObjByKey(objArr, key, value) {
const res = {};
objArr.forEach(x => {
if (x[key] === value) {
Object.assign(res, x);
}
});
return res;
}
const source = [
{
id: 1,
name: 'zhangsan',
},
{
id: 2,
name: 'alfxjx',
},
];
const id = 1;
const targetName = findObjByKey(source, 'id', id)['name'];
// 'zhangsan'
但如果真的在编辑器里面手敲这段代码,就会发现并没有任何的推断,vscode 可以计算出一定的类型,但这对我们的意义不是很重要:毕竟业务中可能返回的不是基本类型而是一个领域模型的话,简单的推导就无能为力了。
考虑这么一种情况:
const source = [
{
id: 1,
userInfo: {
userId: 12345,
userName: 'zhangsan',
email: {
value: '[email protected]',
domain: 'abandon.work',
},
},
},
];
那么计算得出的结果,后续的属性获取就无法计算了,对于领域模型来说这是不可接受的。 如果我们使用了 TS,并且辅助以良好的类型计算:
type User = {
id: number;
userInfo: {
userId: number;
userName: string;
email: Email;
};
};
type Email = {
value: string;
domain: string;
};
const source: User[] = [
{
id: 1,
userInfo: {
userId: 12345,
userName: 'zhangsan',
email: {
value: '[email protected]',
domain: 'abandon.work',
},
},
},
];
function findObjByKey<T>(objArr: T[], key: keyof T, value: T[keyof T]): T {
const res = {} as T;
objArr.forEach(x => {
if (x[key] === value) {
Object.assign(res, x);
}
});
return res;
}
// 尝试输入这一行
const email = findObjByKey(source, 'id', 1)['userInfo'].email['value'];
使用的时候是这样的:
此时已经可以满足常规的业务开发的需求了,当然我们回头再看看上面的类型,可以发现对于此方法我们的主要目的就是
- 对于一个已知类型的数组(
T[]
) - 根据其中的一个键名(
keyof T
) - 传对应的键值(
T[keyofT]
) - 查出对象(
T
)
通过一些高级类型和泛型,我们可以做到在类型声明时的计算,从而获得更好的类型支持。
另一个例子
参考上面的例子中的 Email
类型,对于Email.value
来说,事实上不是一个简单的基本字符串类型,而是一个有一定的规则的字符串,那么如何能够判断呢?
我们知道在 JS 中有很多方式,一个比较常用的方法就是使用正则表达式来判断。考虑一个最简的情况,一个邮箱地址分成三部分:邮箱名称 + @ + 域名(组织名称+.后缀),或许用 TS 也可以很好的描述
const _isValidEmail: (email: string) => boolean = email => {
const isEmailRegex = new RegExp(/.+\@.+\..+/);
return isEmailRegex.test(email);
};
但是这样一来返回的是 Boolean,那如果我想知道在开发阶段就知道输入的邮箱是否正确的话(虽然这个没啥意义。)可以借助 TS 来实现:
type ParseEmail<T extends string> =
T extends `${infer P}@${infer A}.${infer Rest}` ? true : false;
const rightEmail = '[email protected]';
const valid = _isValidEmail(rightEmail); // const valid: true
const noValid = _isValidEmail(''); // const noValid: false
这里使用了一个 infer 关键字,和 extends 关键字配合可以发挥出类似于解构赋值的功能。简单来说就是我推断传入的类型 T 是一个基于 string 扩展的类型,对于这个类型,判断其字符串内部的格式是否为 [email protected]
的格式,返回一个 Boolean。
另外的,考虑上一个例子里面的场景,用户提交用户信息的时候,需要在提交邮箱地址之外,另外传一个域名提供商信息:
type ParseEmailString<T extends string> =
T extends `${infer P}@${infer A}.${infer Rest}` ? T : unknown;
type ParseEmailProvider<T extends string> =
T extends `${infer P}@${infer A}.${infer Rest}` ? `${A}.${Rest}` : unknown;
type EmailObj<Email extends string> = {
email: ParseEmailString<Email>;
provider: ParseEmailProvider<Email>;
};
function getEmailObj<T extends string>(email: T): EmailObj<T> {
return {
email: email,
provider: email.split('@')[1],
} as any;
}
const emailValidation = getEmailObj(rightEmail);
Vuex Actions 类型推断
通过这个例子可以做什么呢?似乎在日常的开发中也不会硬编码一个邮箱,然后去判断它是不是正确的格式。比如使用 vuex 的时候,我们会使用 dispatch 发布改动的时候,会大量的使用字符串模板,可以用这样的方式去给不同的 Dispatch Type 标记类型:
type VuexOptions<M, N> = {
namespace?: N;
mutations: M;
};
type Action<M, N> = N extends string ? `${N}/${keyof M & string}` : keyof M;
type Store<M, N> = {
dispatch(action: Action<M, N>): void;
};
declare function Vuex<M, N>(options: VuexOptions<M, N>): Store<M, N>;
const store = Vuex({
// 这里提示编译器,N泛型即为字符串'cart',无需对他进行推断了
namespace: 'cart' as const,
mutations: {
// 简写,实际是 add: function(){}
add() {},
remove() {},
},
});
store.dispatch('cart/add');
store.dispatch('cart/remove');
原文章的地址在这里:
解析 UrlParams
另外一个很有用的地方就是对于/purchase/[shopid]/[itemid]/args/[...args]
这样的 nodejs 请求地址,可以通过上面的类型计算去推到请求里面的shopid
,itemid
以及args
等:
这里预设了地址栏里面的 id 是 number 类型,而后续的 ...args 是一个 string[]
type IsParameter<Part> = Part extends `[${infer ParamName}]`
? ParamName
: never;
type FilteredParts<Path> = Path extends `${infer PartA}/${infer PartB}`
? IsParameter<PartA> | FilteredParts<PartB>
: IsParameter<Path>;
type ParamValue<Key> = Key extends `...${infer Anything}` ? string[] : number;
type RemovePrefixDots<Key> = Key extends `...${infer Name}` ? Name : Key;
type Params<Path> = {
[Key in FilteredParts<Path> as RemovePrefixDots<Key>]: ParamValue<Key>;
};
type CallbackFn<Path> = (req: { params: Params<Path> }) => void;
function get<Path extends string>(path: Path, callback: CallbackFn<Path>) {
// TODO: implement
}
app.get('/purchase/[shopid]/[itemid]/args/[...args]', () => {
const { params } = req;
// params: {
// shopid: number;
// itemid: number;
// args: string[];
// }
});
上面的这个例子来自最近的一期 JavaScript Weekly 中的一篇:
领域驱动设计
从上面的两个例子可以看出类型计算的魅力,即从基础的类型出发,通过计算和推导即可得到后续的类型。TS 将开发者开发时的上下文记录了下来,帮助我们能够更好的编写健壮的代码。 TS 另外一个优势是它很有利于 DDD (领域驱动设计),如果你喜欢,可以看看这个代码 ,这是一个基于 TS 的领域驱动设计实例,Readme 里面有很详细的说明,这方面我也需要继续学习,后续会出这个方面的文章。
写在最后
最后非常推荐大家去读 TypeScript 类型体操通关秘籍 这本小册,里面说是类型体操,读完之后你的 TS 水平一定会有所提高。另外有几个 github repo 也值得关注:
- type-challenges 类型体操题库 有时间可以上去做做练习,ts 的 leetcode
- ts-toolbelt,utility-types,SimplyTyped 等 TS 的工具类型库。