說在前面
本文難度偏中下,涉及到的點大多為如何在專案中合理應用ts,小部分會涉及一些原理,受眾面較廣,有無TS基礎均可放心食用。 **>>>> 閱完本文,您可能會收穫到<<<<**
- 若您還不熟悉 TS,那本文可幫助您完成 TS 應用部分的學習,伴隨眾多 Demo 例來引導業務應用;
- 若您比較熟悉 TS,那本文可當作複習文,帶您回顧知識,希望能在某些點引發您新發現和思考;
- 針對於 class 元件的 IState 和 IProps,類比 Hook 元件的部分寫法和思考;
???TIPS:超好用的線上 TS 編輯器(諸多配置項可手動配置) 傳送門:TS 線上 ???
一、什麼是 TS
不扯晦澀的概念,通俗來說 TypeScript 就是 JavaScript 的超集,它具有可選的型別,並可以編譯為純 JavaScript 執行。(筆者一直就把 TypeScript 看作 JavaScript 的 Lint)那麼問題來了,為什麼 TS 一定要設計成靜態的? 或者換句話說,我們為什麼需要向 JavaScript 新增型別規範呢 ?
經典自問自答環節——因為它可以解決一些 JS 尚未解決的痛點:
- JS 是動態型別的語言,這也意味著在例項化之前我們都不知道變數的型別,但是使用 TS 可以在執行前就避免經典低階錯誤。 例: Uncaught TypeError:'xxx' is not a function
⚠️ 典中典級別的錯誤?:
JS 就是這樣,只有在執行時發生了錯誤才告訴我有錯,但是當 TS 介入後:
好傢伙!直接把問題在編輯器階段丟擲,nice!
- 懶人狂歡。 規範方便,又不容易出錯,對於 VS Code,它能做的最多隻是標示出有沒有這個屬性,但並不能精確的表明這個屬性是什麼型別,但 TS 可以通過型別推導/反推導(說白話:如果您未明確編寫型別,則將使用型別推斷來推斷您正在使用的型別),從而完美優化了程式碼補全這一項:
第一個 Q&A——思考 :
那麼我們還能想到在業務開發中 TS 解決了哪些 JS 的痛點呢?(提問)
回答,總結,補充:
-對函式引數的型別限制;
-對陣列和物件的型別限制,避免定義出錯 例如資料解構複雜或較多時,
可能會出現陣列定義錯誤 a = { }, if (a.length){ // xxxxx }
-let functionA = 'jiawen' // 實際上 let functionA: string = 'jiawen'
- 使我們的應用程式碼更易閱讀和維護,如果定義完善,可以通過型別大致明白引數的作用;
相信通過上述簡單的bug-demo,各位已對TS有了一個初步的重新認識
接下來的章節便正式介紹我們在業務開發過程中如何用好TS
二、怎麼用 TS
在業務中如何用TS/如何用好TS?這個問題其實和 " 在業務中怎麼用好一個API " 是一樣的。首先要知道這個東西在幹嘛,引數是什麼,規則是什麼,能夠接受有哪些擴充套件......等等。 簡而言之,擼它!
TS 常用型別歸納
通過對業務中常見的 TS 錯誤做出的一個綜合性總結歸納,希望 Demos 會對您有收穫
元語(primitives)之 string number boolean
筆者把基本型別拆開的原因是: 不管是中文還是英文文件,primitives/元語/元組 這幾個名詞都頻繁出鏡,筆者理解的白話:希望在型別約束定義時,使用的是字面量而不是內建物件型別,官方文件:
let a: string = 'jiawen';
let flag: boolean = false;
let num: number = 150
interface IState: {
flag: boolean;
name: string;
num: number;
}
元組
// 元組型別表示已知元素數量和型別的陣列,各元素的型別不必相同,但是對應位置的型別需要相同。
let x: [string, number];
x = ['jiawen', 18]; // ok
x = [18, 'jiawen']; // Erro
console.log(x[0]); // jiawen
undefined null
let special: string = undefined
// 值得一提的是 undefined/null 是所有基本型別的子類,
// 所以它們可以任意賦值給其他已定義的型別,這也是為什麼上述程式碼不報錯的原因
object 和 { }
// object 表示的是常規的 Javascript物件型別,非基礎資料型別
const offDuty = (value: object) => {
console.log("value is ", value);
}
offDuty({ prop: 0}) // ok
offDuty(null) offDuty(undefined) // Error
offDuty(18) offDuty('offDuty') offDuty(false) // Error
// {} 表示的是 非null / 非undefined 的任意型別
const offDuty = (value: {}) => {
console.log("value is ", value);
}
offDuty({ prop: 0}) // ok
offDuty(null) offDuty(undefined) // Error
offDuty(18) offDuty('offDuty') offDuty(false) // ok
offDuty({ toString(){ return 333 } }) // ok
// {} 和Object幾乎一致,區別是Object會對Object內建的 toString/hasOwnPreperty 進行校驗
const offDuty = (value: Object) => {
console.log("value is ", value);
}
offDuty({ prop: 0}) // ok
offDuty(null) offDuty(undefined) // Error
offDuty(18) offDuty('offDuty') offDuty(false) // ok
offDuty({ toString(){ return 333 } }) // Error
如果需要一個物件型別,但對屬性沒有要求,建議使用 object
{} 和 Object 表示的範圍太大,建議儘量不要使用
object of params
// 我們通常在業務中可多采用點狀物件函式(規定引數物件型別)
const offDuty = (value: { x: number; y: string }) => {
console.log("x is ", value.x);
console.log("y is ", value.y);
}
// 業務中一定會涉及到"可選屬性";先簡單介紹下方便快捷的“可選屬性”
const offDuty = (value: { x: number; y?: string }) => {
console.log("必選屬性x ", value.x);
console.log("可選屬性y ", value.y);
console.log("可選屬性y的方法 ", value.y.toLocaleLowerCase());
}
offDuty({ x: 123, y: 'jiawen' })
offDuty({ x: 123 })
// 提問: 上述程式碼有問題嗎?
答案:
// offDuty({ x: 123 }) 會導致結果報錯value.y.toLocaleLowerCase()
// Cannot read property 'toLocaleLowerCase' of undefined
方案1: 手動型別檢查
const offDuty = (value: { x: number; y?: string }) => {
if (value.y !== undefined) {
console.log("可能不存在的 ", value.y.toUpperCase());
}
}
方案2:使用可選屬性 (推薦)
const offDuty = (value: { x: number; y?: string }) => {
console.log("可能不存在的 ", value.y?.toLocaleLowerCase());
}
unknown 與 any
// unknown 可以表示任意型別,但它同時也告訴TS, 開發者對型別也是無法確定,做任何操作時需要慎重
let Jiaven: unknown
Jiaven.toFixed(1) // Error
if (typeof Jiaven=== 'number') {
Jiaven.toFixed(1) // OK
}
當我們使用any型別的時候,any會逃離型別檢查,並且any型別的變數可以執行任意操作,編譯時不會報錯
anyscript === javascript
注意:any 會增加了執行時出錯的風險,不到萬不得已不要使用;
如果遇到想要表示【不知道什麼型別】的場景,推薦優先考慮 unknown
union 聯合型別
union也叫聯合型別,由兩個或多個其他型別組成,表示可能為任何一個的值,型別之間用 ' | '隔開
type dayOff = string | number | boolean
聯合型別的隱式推導可能會導致錯誤,遇到相關問題請參考語雀 code and tips —— 《TS的隱式推導》
.值得注意的是,如果訪問不共有的屬性的時候,會報錯,訪問共有屬性時不會.上個最直觀的demo
function dayOff (value: string | number): number {
return value.length;
}
// number並不具備length,會報錯,解決方法:typeof value === 'string'
function dayOff (value: string | number): number {
return value.toString();
}
// number和string都具備toString(),不會報錯
never
// never是其它型別(包括 null 和 undefined)的子型別,代表從不會出現的值。
// 那never在實際開發中到底有什麼作用? 這裡筆者原汁原味照搬尤雨溪的經典解釋來做第一個例子
第一個例子,當你有一個 union type:
interface Foo {
type: 'foo'
}
interface Bar {
type: 'bar'
}
type All = Foo | Bar
在 switch 當中判斷 type,TS是可以收窄型別的 (discriminated union):
function handleValue(val: All) {
switch (val.type) {
case 'foo':
// 這裡 val 被收窄為 Foo
break
case 'bar':
// val 在這裡是 Bar
break
default:
// val 在這裡是 never
const exhaustiveCheck: never = val
break
}
}
注意在 default 裡面我們把被收窄為 never 的 val 賦值給一個顯式宣告為 never 的變數。
如果一切邏輯正確,那麼這裡應該能夠編譯通過。但是假如後來有一天你的同事改了 All 的型別:
type All = Foo | Bar | Baz
然而他忘記了在 handleValue 裡面加上針對 Baz 的處理邏輯,
這個時候在 default branch 裡面 val 會被收窄為 Baz,導致無法賦值給 never,產生一個編譯錯誤。
所以通過這個辦法,你可以確保 handleValue 總是窮盡 (exhaust) 了所有 All 的可能型別。
第二個用法 返回值為 never 的函式可以是丟擲異常的情況
function error(message: string): never {
throw new Error(message);
}
第三個用法 返回值為 never 的函式可以是無法被執行到的終止點的情況
function loop(): never {
while (true) {}
}
void
interface IProps {
onOK: () => void
}
void 和 undefined 功能高度類似,但void表示對函式的返回值並不在意或該方法並無返回值
enum
筆者認為ts中的enum是一個很有趣的列舉型別,它的底層就是number的實現
1.普通列舉
enum Color {
Red,
Green,
Blue
};
let c: Color = Color.Blue;
console.log(c); // 2
2.字串列舉
enum Color {
Red = 'red',
Green = 'not red',
};
3.異構列舉 / 有時也叫混合列舉
enum Color {
Red = 'red',
Num = 2,
};
<第一個坑>
enum Color {
A, // 0
B, // 1
C = 20, // 20
D, // 21
E = 100, // 100
F, // 101
}
若初始化有部分賦值,那麼後續成員的值為上一個成員的值加1
<第二個坑> 這個坑是第一個坑的延展,稍不仔細就會上當!
const getValue = () => {
return 23
}
enum List {
A = getValue(),
B = 24, // 此處必須要初始化值,不然編譯不通過
C
}
console.log(List.A) // 23
console.log(List.B) // 24
console.log(List.C) // 25
如果某個屬性的值是計算出來的,那麼它後面一位的成員必須要初始化值。
否則將會 Enum member must have initializer.
泛型
筆者理解的泛型很白話:先不指定具體型別,通過傳入的引數型別來得到具體型別
我們從下述的 filter-demo 入手,探索一下為什麼一定需要泛型
- 泛型的基礎樣式
function fun<T>(args: T): T {
return args
}
如果沒接觸過,是不是會覺得有點懵? 沒關係!我們直接從業務角度深入——
1.剛開始的需求:過濾數字型別的陣列
declare function filter(
array: number[],
fn: (item: unknown) => boolean
) : number[];
2.產品改了需求:還要過濾一些字串 string[]
彳亍,那就利用函式的過載, 加一個宣告, 雖然笨了點,但是很好理解
declare function filter(
array: string[],
fn: (item: unknown) => boolean
): string[];
declare function filter(
array: number[],
fn: (item: unknown) => boolean
): number[];
3.產品又來了! 這次還要過濾 boolean[]、object[] ..........
這個時候如果還是選擇過載,將會大大提升工作量,程式碼也會變得越來越累贅,這個時候泛型就出場了,
它從實現上來說更像是一種方法,通過你的傳參來定義型別,改造如下:
declare function filter<T>(
array: T[],
fn: (item: unknown) => boolean
): T[];
泛型中的
可以是任意,但是大部分偏好為 T、U、S 等,
當我們把泛型理解為一種方法實現後,那麼我們便很自然的聯想到:方法有多個引數、預設值,泛型也可以
type Foo<T, U = string> = { // 多引數、預設值
foo: Array<T> // 可以傳遞
bar: U
}
type A = Foo<number> // type A = { foo: number[]; bar: string; }
type B = Foo<number, number> // type B = { foo: number[]; bar: number; }
既然是“函式”,那也會有“限制”,下文列舉一些稍微常見的約束
1. extends: 限制 T 必須至少是一個 XXX 的型別
type dayOff<T extends HTMLElement = HTMLElement> = {
where: T,
name: string
}
2. Readonly<T>: 構造一個所有屬性為readonly,這意味著無法重新分配所構造型別的屬性。
interface Eat {
food: string;
}
const todo: Readonly<Eat> = {
food: "meat beef milk",
};
todo.food = "no food"; // Cannot assign to 'title' because it is a read-only property.
3. Pick<T,K>: 從T中挑選出一些K屬性
interface Todo {
name: string;
job: string;
work: boolean;
type TodoPreview = Pick<Todo, "name" | "work">;
const todo: TodoPreview = {
name: "jiawen",
work: true,
};
todo;
4. Omit<T, K>: 結合了 T 和 K 並忽略物件型別中 K 來構造型別。
interface Todo {
name: string;
job: string;
work: boolean;
}
type TodoPreview = Omit<Todo, "work">;
const todo: TodoPreview = {
name: "jiawen",
job: 'job',
};
5.Record: 約束 定義鍵型別為 Keys、值型別為 Values 的物件型別。
enum Num {
A = 10001,
B = 10002,
C = 10003
}
const NumMap: Record<Num, string> = {
[Num.A]: 'this is A',
[Num.B]: 'this is B'
}
// 型別 "{ 10001: string; 10002: string; }" 中缺少屬性 "10003",
// 但型別 "Record<ErrorCodes, string>" 中需要該屬性,所以我們還可以通過Record來做全面性檢查
keyof 關鍵字可以用來獲取一個物件型別的所有 key 型別
type User = {
id: string;
name: string;
};
type UserKeys = keyof User; // "id" | "name"
改造如下
type Record<K extends keyof any, T> = {
[P in K]: T;
};
此時的 T 為 any;
還有一些不常用,但是很易懂的:
6. Extract<T, U> 從T,U中提取相同的型別
7. Partial<T> 所有屬性可選
type User = {
id?: string,
gender: 'male' | 'female'
}
type PartialUser = Partial<User> // { id?: string, gender?: 'male' | 'female'}
type Partial<T> = { [U in keyof T]?: T[U] }
8. Required<T> 所有屬性必須 << === >> 與Partial相反
type User = {
id?: string,
sex: 'male' | 'female'
}
type RequiredUser = Required<User> // { readonly id: string, readonly gender: 'male' | 'female'}
function showUserProfile (user: RequiredUser) {
console.log(user.id) // 這時候就不需要再加?了
console.log(user.sex)
}
type Required<T> = { [U in keyof T]-?: T[U] }; -? : 代表去掉?
三、TS 的一些須知
TS 的 type 和 interface
- interface(介面) 只能宣告物件型別,支援宣告合併(可擴充套件)。
interface User {
id: string
}
interface User {
name: string
}
const user = {} as User
console.log(user.id);
console.log(user.name);
- type(型別別名)不支援宣告合併 -- l 型別
type User = {
id: string,
}
if (true) {
type User = {
name: string,
}
const user = {} as User;
console.log(user.name);
console.log(user.id) // 型別“User”上不存在屬性“id”。
}
??????
type 和 interface 異同點總結:
- 通常來講 type 更為通用,右側可以是任意型別,包括表示式運算,以及對映等;
- 凡是可用 interface 來定義的,type 也可;
- 擴充套件方式也不同,interface 可以用 extends 關鍵字進行擴充套件,或用來 implements 實現某個介面;
- 都可以用來描述一個物件或者函式;
- type 可以宣告基本型別別名、聯合型別、元組型別,interface 不行;
- ⚠️ 但如果你是在開發一個包,模組,允許別人進行擴充套件就用 interface,如果需要定義基礎資料型別或者需要型別運算,使用 type。
- interface 可以被多次定義,並會被視作合併宣告,而 type 不支援;
- 匯出方式不同,interface 支援同時宣告並預設匯出,而 typetype 必須先宣告後匯出;
TS 的指令碼模式和模組模式
Typescript 存在兩種模式,區分的邏輯是,檔案內容包不包含 import 或者 export 關鍵字
指令碼模式(Script) 一個檔案對應一個 html 的 script 標籤,
模組模式(Module)一個檔案對應一個 Typescript 的模組。
指令碼模式下,所有變數定義,型別宣告都是全域性的,多個檔案定義同一個變數會報錯,同名 interface 會進行合併;而模組模式下,所有變數定義,型別宣告都是模組內有效的。
兩種模式在編寫型別宣告時也有區別,例如指令碼模式下直接 declare var GlobalStore 即可為全域性物件編寫宣告。
例子:
- 指令碼模式下直接 declare var GlobalStore 即可為全域性物件編寫宣告。
GlobalStore.foo = "foo";
GlobalStore.bar = "bar"; // Error
declare var GlobalStore: {
foo: string;
};
- 模組模式下,要為全域性物件編寫宣告需要 declare global
GlobalStore.foo = "foo";
GlobalStore.bar = "bar";
declare global {
var GlobalStore: {
foo: string;
bar: string;
};
}
export {}; // export 關鍵字改變檔案的模式
TS 的索引簽名
- 索引簽名可以用來定義物件內的屬性、值的型別,例如定義一個 React 元件,允許 Props 可以傳任意 key 為 string,value 為 number 的 props
interface Props {
[key: string]: number
}
<Component count={1} /> // OK
<Component count={true} /> // Error
<Component count={'1'} /> // Error
TS 的型別鍵入
- Typescript 允許像物件取屬性值一樣使用型別
type User = {
userId: string
friendList: {
fristName: string
lastName: string
}[]
}
type UserIdType = User['userId'] // string
type FriendList = User['friendList'] // { fristName: string; lastName: string; }[]
type Friend = FriendList[number] // { fristName: string; lastName: string; }
- 在上面的例子中,我們利用型別鍵入的功能從 User 型別中計算出了其他的幾種型別。FriendList[number]這裡的 number 是關鍵字,用來取陣列子項的型別。在元組中也可以使用字面量數字得到陣列元素的型別。
type group = [number, string]
type First = group[0] // number
type Second = group[1] // string
TS 的斷言
- 型別斷言不是型別轉換,斷言成一個聯合型別中不存在的型別是不允許的
function getLength(value: string | number): number {
if (value.length) {
return value.length;
} else {
return value.toString().length;
}
// 這個問題在object of parmas已經提及,不再贅述
修改後:
if ((<string>value).length) {
return (<string>value).length;
} else {
return something.toString().length;
}
}
斷言的兩種寫法
1. <型別>值: <string>value
2. 或者 value as string
特別注意!!! 斷言成一個聯合型別中不存在的型別是不允許的
function toBoolean(something: string | number): boolean {
return <boolean>something;
}
- 非空斷言符 !
TypeScript 還具有一種特殊的語法,用於從型別中刪除 null 和 undefined 不進行任何顯式檢查。!在任何表示式之後寫入實際上是一個型別斷言,表明該值不是 null 或 undefined
function liveDangerously(x?: number | undefined | null) {
// 推薦寫法
console.log(x!.toFixed());
}
四、如何在 Hook 元件中使用 TS
usestate
- useState 如果初始值不是 null/undefined 的話,是具備型別推導能力的,根據傳入的初始值推斷出型別;初始值是 null/undefined 的話則需要傳遞型別定義才能進行約束。一般情況下,還是推薦傳入型別(通過 useState 的第一個泛型引數)。
// 這裡ts可以推斷 value的型別並且能對setValue函式呼叫進行約束
const [value, setValue] = useState(0);
interface MyObject {
name: string;
age?: number;
}
// 這裡需要傳遞MyObject才能約束 value, setValue
// 所以我們一般情況下推薦傳入型別
const [value, setValue] = useState<MyObject>(null);
-----as unkonwn as unkownun
useEffect useLayoutEffect
- 沒有返回值,無需型別傳遞和約束
useMemo useCallback
- useMemo 無需傳遞型別, 根據函式的返回值就能推斷出型別。
- useCallback 無需傳遞型別,根據函式的返回值就能推斷出型別。
但是注意函式的入參需要定義型別,不然將會推斷為 any!
const value = 10;
const result = useMemo(() => value * 2, [value]); // 推斷出result是number型別
const multiplier = 2;
// 推斷出 (value: number) => number
// 注意函式入參value需要定義型別
const multiply = useCallback((value: number) => value * multiplier, [multiplier]);
useRef
- useRef 傳非空初始值的時候可以推斷型別,同樣也可以通過傳入第一個泛型引數來定義型別,約束 ref.current 的型別。
1. 如果傳值為null
const MyInput = () => {
const inputRef = useRef<HTMLInputElement>(null); // 這裡約束inputRef是一個html元素
return <input ref={inputRef} />
}
2. 如果不為null
const myNumberRef = useRef(0); // 自動推斷出 myNumberRef.current 是number型別
myNumberRef.current += 1;
useContext
- useContext 一般根據傳入的 Context 的值就可以推斷出返回值。一般無需顯示傳遞型別
type Theme = 'light' | 'dark';
// 我們在createContext就傳了型別了
const ThemeContext = createContext<Theme>('dark');
const App = () => (
<ThemeContext.Provider value="dark">
<MyComponent />
</ThemeContext.Provider>
)
const MyComponent = () => {
// useContext根據ThemeContext推斷出型別,這裡不需要顯示傳
const theme = useContext(ThemeContext);
return <div>The theme is {theme}</div>;
五、關於 TS 的一些思考
1. 關於 TSC 如何把 TS 程式碼轉換為 JS 程式碼
這個部分比較冗長,後續可以單獨出一篇文章(2)來專門探索。
- 不過,tsconfig.json 的部分常用的配置屬性表還是值得一提的
{
"compilerOptions": {
"noEmit": true, // 不輸出檔案
"allowUnreachableCode": true, // 不報告執行不到的程式碼錯誤。
"allowUnusedLabels": false, // 不報告未使用的標籤錯誤
"alwaysStrict": false, // 以嚴格模式解析併為每個原始檔生成 "use strict"語句
"baseUrl": ".", // 工作根目錄
"lib": [ // 編譯過程中需要引入的庫檔案的列表
"es5",
"es2015",
"es2016",
"es2017",
"es2018",
"dom"
]
"experimentalDecorators": true, // 啟用實驗性的ES裝飾器
"jsx": "react", // 在 .tsx檔案裡支援JSX
"sourceMap": true, // 是否生成map檔案
"module": "commonjs", // 指定生成哪個模組系統程式碼
"noImplicitAny": false, // 是否預設禁用 any
"removeComments": true, // 是否移除註釋
"types": [ //指定引入的型別宣告檔案,預設是自動引入所有宣告檔案,一旦指定該選項,則會禁用自動引入,改為只引入指定的型別宣告檔案,如果指定空陣列[]則不引用任何檔案
"node", // 引入 node 的型別宣告
],
"paths": { // 指定模組的路徑,和baseUrl有關聯,和webpack中resolve.alias配置一樣
"src": [ //指定後可以在檔案之直接 import * from 'src';
"./src"
],
},
"target": "ESNext", // 編譯的目標是什麼版本的
"outDir": "./dist", // 輸出目錄
"declaration": true, // 是否自動建立型別宣告檔案
"declarationDir": "./lib", // 型別宣告檔案的輸出目錄
"allowJs": true, // 允許編譯javascript檔案。
},
// 指定一個匹配列表(屬於自動指定該路徑下的所有ts相關檔案)
"include": [
"src/**/*"
],
// 指定一個排除列表(include的反向操作)
"exclude": [
"demo.ts"
],
// 指定哪些檔案使用該配置(屬於手動一個個指定檔案)
"files": [
"demo.ts"
]
}
2. TS 泛型的底層實現
關於TS泛型進階篇 連結:[https://dtstack.yuque.com/rd-center/sm6war/wae3kg](https://dtstack.yuque.com/rd-center/sm6war/wae3kg)
這個部分比較複雜,筆者還需沉澱,歡迎各位直接留言或在文章中補充!!!