背景
ts 用了一年了,回顾起来,也没有那么顺利。趁这两天春节假期有时间,整理了几个自己觉得需要注意的情况,复盘一下。
我上学时学过 java 和 C# ,毕业后又做了两年 C# 全栈开发,对于静态类型语言是有一定经验的。ts 之所以能够慢慢取代 js ,也是因为它是静态类型语言。
但 ts 和 java 是不一样的,本质是因为它作为一个静态类型语言,要编译成弱类型语言 js 来执行。所以,ts 只管得了编译时,却管不了运行时。 下文的很多内容,都是这个特点的具体表现。
【个人提醒】我感觉 ts 为了能让自己更适应 js 的转型,做了很多非常繁琐(或者叫灵活)的设计,我没有详细总结,但这种感觉很强烈。所以,如果你觉得 ts 有些地方过于繁琐时,也不要担心,这可能不是你的问题,而是它的问题。
任何美好的东西,都是应该简单的、明确的。
易混乱的类型
如果问“ts 的变量有多少种类型”,你能否回答全面?ts 比 js 类型多一些。
never
vs void
只需要记住一个特点:返回 never 的函数,都必须存在无法到达的终点,如死循环、抛出异常。
function fn1(): never {
while(true) { /*...*/ }
}
function fn2(): never {
throw new Error( /*...*/ )
}
复制代码
点击并拖拽以移动
any vs unknown
const bar: any = 10;
any.substr(1); // OK - any 会忽略所有类型检查
const foo: unknown = 'string';
foo.substr(1); // Error: 语法检查不通过报错
// (foo as string).substr(1) // OK
// if (typeof foo === 'string') { foo.substr(1) } // OK
复制代码
一些“欺骗”编译器语法检查的行为
就如同你告诉编译器:“按我写的来,不要管太多,出了事儿我负责!”
编译器不给你添麻烦了,不进行语法检查了,但你一定要考虑好后果。所以,以下内容请慎用,不要无脑使用。
@ts-ignore
增加 @ts-ignore
的注释,会忽略下一行的语法检查。
const num1: number = 100
num1.substr() // Error 语法检查错误
const num2: number = 200
// @ts-ignore
num2.substr() // Ok 语法检查通过
复制代码
any
如果 ts 是西游记,any
就是孙悟空,自由、无约束。了解西游记大部分是从孙悟空开始,了解 ts 可能也是从 any
开始用。
但西游记最后,孙悟空变成了佛。你的 any
也应该变成 interface
或者 type
。
类型断言 as
文章一开始说过,ts 只管编译时,不管运行时。as
就是典型的例子,你用 as
告诉编译器类型,编译器就听你的。但运行时,后果自负。
function fn(a: string | null): void {
const length = (a as string).length
console.log(length)
}
fn('abc') // Ok
// fn(null) // Error js 运行报错
复制代码
非空断言操作符 !
!
用于排除 null undefined
,即告诉编译器:xx 变量肯定不是 null 或 undefined
,你放心吧~
同理,运行时有可能出错。
// 例子 1
function fn(a: string | null | undefined) {
let s: string = ''
s = a // Error 语法检查失败
s = a! // OK —— 【注意】如果 a 真的是 null 或者 undefined ,那么 s 也会是 null 或者 undefined ,可能会带来 bug !!!
}
// fn(null)
复制代码
// 例子 2
type NumGenerator = () => number;
function myFunc(numGenerator: NumGenerator | undefined) {
const num1 = numGenerator(); // Error 语法检查失败
const num2 = numGenerator!(); // OK
}
// myFunc(undefined) // 【注意】,如果真的传入 undefined ,也会去执行,当然会执行报错!!!
复制代码
// 例子 3
let a: number
console.log(a) // Error - Variable 'n' is used before being assigned.
let b!: number
console.log(b) // OK - `!` 表示,你会给 b 一个赋值,不用编译器关心
复制代码
可选链 ?.
?.
遇到 null
或 undefined
就可以立即停止某些表达式的运行,并返回 undefined
【注意】这里只针对 null
和 undefined
,对于 0 false ''
等 falsely 变量是不起作用的。这一点和 &&
不一样。
这个运算符,看似是获取一个属性,其实它是有条件判断的。即,它就是一个 ? :
三元表达式的语法糖。既然它有判断逻辑,那你考虑不到位,就有可能出错。
// 例子 1 - 获取对象属性
interface IFoo { a: number }
function fn(obj: IFoo | null | undefined): number | undefined {
const a = obj?.a // ?. 可选链运算符
// 第一,如果 a 是 IFoo 类型,则打印 100
// 第二,如果 a 是 null 或者 undefined ,则打印 undefined
console.log('a', a)
return a // 100 或者 undefined
}
fn({ a: 100 })
// fn(null)
// fn(undefined)
复制代码
// 例子 2 - 获取数组元素
function tryGetArrayElement<T>(arr?: T[], index: number = 0) {
return arr?.[index];
}
// 编译产出:
// "use strict";
// function tryGetArrayElement(arr, index = 0) {
// return arr === null || arr === void 0 ? void 0 : arr[index];
// }
复制代码
// 例子 3 - 用于函数调用
type NumGenerator = () => number;
function fn(numGenerator: NumGenerator | undefined | null) {
const num = numGenerator?.();
console.log('num', num) // 如果不是函数,则不调用,也不会报错,返回 undefined
}
// fn(null)
// fn(undefined)
复制代码
【吐槽】对于这种语法糖,我还是比较反感的,我觉得自己写几行逻辑判断会更好。它虽然简洁,但是它会带来阅读理解上的负担,代码简洁不一定就可读性好 —— 当然了,如果大家都这么用,用久了,大家都熟悉了,可能也就没有这个障碍了。
type 和 interface
关于两者的区别,大家可以看看这篇文章 ,本文主要说一下我的理解。
先说结论:我目前还是处于一种懵逼状态。我感觉 type
和 insterface
有太多的灰色地带,这就导致我们日常使用时,大部分情况下用谁都可以。我搞不懂 ts 为何要这样设计。
按照我前些年对 java 和 C# 的理解:(我不知道近几年 java C# 有没有相关的语法变化)
但是查到的资料,以及查阅 ts 的类库 lib.dom.d.ts 和 lib.es2015.d.ts 源码,也都是用 interface 。我曾经一度很困惑,见的多了,就慢慢习惯成自然了,但问题并没有解决。
问题没有解决,但事情还是要继续做的,代码也是要继续写的,所以我就一直跟随大众,尽量用 interface
。
private 和
两者都表示私有属性。背景不同:
如果仅对于 ts 来说,用哪个都一样。
但本文一开始提到过:ts 只关注编译时,不关注运行时。所以,还得看看两者的编译结果。
private
private
编译之后,就失去了私有的特点。即,如果你执行 (new Person()).name
,虽然语法检查不通过,但运行时是可以成功的。即,private
仅仅是 ts 的语法,编译成 js 之后,就失效了。
// ts 源码
class Person {
private name: string
constructor() {
this.name = 'zhangsan'
}
}
/* 编译结果如下
"use strict";
class Person {
constructor() {
this.name = 'zhangsan';
}
}
*/
复制代码
#
#
编译之后,依然具有私有特点,而且用 (new Person()).name
,在运行时也是无法实现的。即,#
是 ts 语法,但同时也是 ES 的提案语法,编译之后也不能失效。
但是,编译结果中,“私有”是通过 WeekMap 来实现的,所以要确保你的运行时环境支持 ES6 。WeekMap
没有完美的 Polyfill 方案,强行 Polyfill 可能会发生内存泄漏。
// ts 源码
class Person {
#name: string
constructor() {
this.#name = 'zhangsan'
}
}
/* 编译结果如下
"use strict";
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, privateMap, value) {
if (!privateMap.has(receiver)) {
throw new TypeError("attempted to set private field on non-instance");
}
privateMap.set(receiver, value);
return value;
};
var _name;
class Person {
constructor() {
_name.set(this, void 0);
__classPrivateFieldSet(this, _name, 'zhangsan');
}
}
_name = new WeakMap();
*/
复制代码
函数重载
java 中的函数重载
java 中的函数重载是非常好用,而且非常好理解的,傻瓜式的,一看就懂。如下代码,定义了四个名为 test
的函数,参数不同。那就直接写四个函数即可,调用时也直接调用,java 会自动匹配。
public class Overloading {
public int test(){
System.out.println("test1");
return 1;
}
public void test(int a){
System.out.println("test2");
}
public String test(int a,String s){
System.out.println("test3");
return "returntest3";
}
public String test(String s,int a){
System.out.println("test4");
return "returntest4";
}
public static void main(String[] args){
Overloading o = new Overloading();
System.out.println(o.test());
o.test(1);
System.out.println(o.test(1,"test3"));
System.out.println(o.test("test4",1));
}
}
复制代码
ts 中的函数重载
ts 的函数重载,先把各个情况的函数头写出来,然后再写一个统一的、兼容上述所有情况的函数头。最后,函数体自行处理参数。
class Person {
// 第一,各个情况的函数头写出来
test(): void
test(a: number, b: number): number
test(a: string, b: string): string
// 第二,统一的、兼容上述所有情况的函数头(有一个不兼容,就报错)
test(a?: string | number, b?: string | number): void | string | number {
// 第三,函数体自行处理参数
if (typeof a === 'string' && typeof b === 'string') {
return 'string params'
}
if (typeof a === 'number' && typeof b === 'number') {
return 'number params'
}
console.log('no params')
}
}
复制代码
这和 java 的语法比起来,简直就是复杂 + 丑陋,完全违背设计原则。但是,为何要这样呢?最终还是因为 ts 只关注编译时,管不了运行时 —— 这是原罪。试想,如果 ts 也设计像 java 一样的重载写法,那编译出来的 js 代码就会乱套的。因为 js 是弱类型的。
注意函数定义的顺序
参数越精准的,放在前面。
/* 错误:any 类型不精准,应该放在最后 */
declare function fn(x: any): any;
declare function fn(x: HTMLElement): number;
declare function fn(x: HTMLDivElement): string;
var myElem: HTMLDivElement;
var x = fn(myElem); // x: any, wat?
复制代码
不要为仅在末尾参数不同时写不同的重载,应该尽可能使用可选参数。
/* 错误 */
interface Example1 {
diff(one: string): number;
diff(one: string, two: string): number;
diff(one: string, two: string, three: boolean): number;
}
/* OK */
interface Example2 {
diff(one: string, two?: string, three?: boolean): number;
}
复制代码
DOM 相关的类型
Vue 和 React 框架的普及,让大部分业务开发者不用直接操作 DOM ,变成了框架工程师。但 Web 是基于 DOM 的,可以不用,但千万不要忘记。
js 写 DOM 操作非常简单,不用关心类型,直接访问属性和方法即可。但用 ts 之后,就得关心 DOM 操作的相关类型。
不光我们使用 ts ,微软在设计 ts 时,也需要定义 DOM 操作相关的类型,放在 ts 的类库中,这样 ts 才能被 web 场景所使用。这些都定义在 lib.dom.d.ts
中。补:还有 ES 语法的内置类库,也在同目录下。
PS:一门成熟可用的编程语言,最基本的要包括:语法 + 类库 + 编译器 + 运行时(或者编译器和运行时统一为解释器)。然后再说框架,工具,包管理器等这些外围配置。
Node Element 等类型
这些都是现成的,W3C 早就定义好了的,我们直接回顾一下就可以。我觉得一张图就可以很好的表达,详细的可以参考各自的 MDN 文档。
事件参数类型
在使用 ts 之前,我并没有特别关注事件参数类型(或者之前看过,后来不用,慢慢忘了),反正直接获取属性,拿来用就可以。
document.body.addEventListener('click', e1 => {
// e1 的构造函数是什么?
})
document.body.addEventListener('keyup', e2 => {
// e2 的构造函数是什么?
})
复制代码
于是我查了一下 MDN 的文档,其实也很好理解,就是不同的事件,参数类型是不一样的,当然属性、方法也就不一样。下面列出我们常见的,所有的类型参考 MDN 这个文档。
事件参数类型 click dbclick mouseup mousedown mousemove mouseenter mouseleaveMouseEvekeyup keyrpess keydownKeyboardEventcompositionstart compositionupdate compositionend(输入法)CompositionEventfocus blur focusin focusoutFocusEventdrag dropDragEventpaste cut copyClipboardEvent
他们的继承关系如下图。其中 UIEvent
表示的是用户在 UI 触发的一些事件。因为事件不仅仅是用户触发的,还有 API 脚本触发的,所以要单独拿出一个 UIEvent
,作为区分。
总结
我感觉重点的就是那句话:ts 是一门静态类型语言,但它要编译成为 js 这个弱类型语言来执行,所以它管得了编译时,却管不了运行时。这是很多问题的根本。
目前看来,前端社区会慢慢往 ts 转型,所以能熟练使用 ts 已经是一名前端人员必备的技能。希望本文能给大家带来一点点帮助。
评论