背景
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
any
任何類型,會忽略語法檢查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# 有沒有相關的語法變化)
- 如果自定義一個靜態的類型,僅有一些屬性,沒有方法,就用
type
- 如果定義一種行為(行為肯定是需要方法的,僅屬性是不夠的),需要 class 實現,就用
interface
但是查到的資料,以及查閱 ts 的類庫 lib.dom.d.ts 和 lib.es2015.d.ts 源碼,也都是用 interface 。我曾經一度很困惑,見的多了,就慢慢習慣成自然了,但問題並沒有解決。
問題沒有解決,但事情還是要繼續做的,代碼也是要繼續寫的,所以我就一直跟隨大眾,盡量用 interface 。
private
和 #
兩者都表示私有屬性。背景不同:
private
是 ts 中一開始就有的語法,而且目前只有 ts 有,ES 規范沒有。#
是 ES 目前的提案語法,然后被 ts 3.8 支持了。即,ts 和 ES 都支持#
。
如果僅對於 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 mouseleave | MouseEvent |
keyup keyrpess keydown | KeyboardEvent |
compositionstart compositionupdate compositionend(輸入法) | CompositionEvent |
focus blur focusin focusout | FocusEvent |
drag drop | DragEvent |
paste cut copy | ClipboardEvent |
他們的繼承關系如下圖。其中 UIEvent
表示的是用戶在 UI 觸發的一些事件。因為事件不僅僅是用戶觸發的,還有 API 腳本觸發的,所以要單獨拿出一個 UIEvent
,作為區分。
總結
我感覺重點的就是那句話:ts 是一門靜態類型語言,但它要編譯成為 js 這個弱類型語言來執行,所以它管得了編譯時,卻管不了運行時。這是很多問題的根本。
目前看來,前端社區會慢慢往 ts 轉型,所以能熟練使用 ts 已經是一名前端人員必備的技能。希望本文能給大家帶來一點點幫助。