成為優(yōu)秀的TS體操高手 之 TS 類型體操前置知識儲備
TS 類型體操前置知識儲備
如果你正在學(xué)習(xí) TS,可是像我一樣僅僅停留在定義類型,定義 interface/type 的層面的話, 這份體操類型練習(xí)題一定不要錯過 !type-challenges[1]
寫這篇文章的時候我只做完了體操類型的中等級別的題目(還有 2-3 道其實沒完全解出來)
簡單搭建一下做題環(huán)境
我喜歡做一題拷貝一題,所以我 clone 了一份 type-challenges[2]。然后在 type-challenges 新建了一個文件夾(my-type-challenges),專門用來做 TS 體操
每次想做題的時候都只需要記住編號,比如第一題 13-helloword 。只需要輸入就可以了
npm run copy 13
復(fù)制代碼
詳細(xì)的腳本在 Jioho/my-type-challenges[3]。寫了腳本后,就可以愉快的寫代碼了
體操入門基本語法
以下的內(nèi)容純粹是個人的見解,如有說錯或理解不到位的地方請指出
雖然說 TS 圖靈完備(如果一個計算系統(tǒng)可以計算每一個圖靈可計算函數(shù),那么這個系統(tǒng)就是圖靈完備的)
不過 TS 并沒有那么多語法,比如 if,switch,return 之類的。
TS 用的最多的都是三目運算符,判斷相等,數(shù)組,遞歸等一些技巧后面都會一一介紹到
TS 內(nèi)置的高級類型(內(nèi)置的體操)
入門第一步一定要看文檔(雖然我也不愛看,看不懂),不過還是需要有基礎(chǔ)的了解 utility-types[4]
目前的內(nèi)置方法就如下:
| --- | --- | --- | --- |
|---|---|---|---|
Partial<Type> | Required<Type> | Readonly<Type> | Record<Keys, Type> |
Pick<Type, Keys> | Omit<Type, Keys> | Exclude<UnionType, ExcludedMembers> | Extract<Type, Union> |
NonNullable<Type> | Parameters<Type> | ConstructorParameters<Type> | ReturnType<Type> |
InstanceType<Type> | ThisParameterType<Type> | OmitThisParameter<Type> | ThisType<Type> |
Uppercase<StringType> | Lowercase<StringType> | Capitalize<StringType> | Uncapitalize<StringType> |
比如拿一個后續(xù)可能用的比較多的來說一下:
Pick[5] 方法
官網(wǎng)的 demo:
interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = Pick<Todo, "title" | "completed">;
const todo: TodoPreview = {
title: "Clean room",
completed: false,
};
復(fù)制代碼
Pick 的作用就是從一個對象中,挑選需要的字段出來,比如從 TODO 里面只取出 title 和 completed
如果沒有類型體操的話,TodoPreview 還得額外定義一個類型
interface TodoPreview {
title:string;
completed: boolean;
}
復(fù)制代碼
之前有一個練手項目我就是遇到了這樣的情況,明明都是同一個對象上的字段,為了適應(yīng)不同場景,硬是定義了一大堆的附屬字段,關(guān)鍵是如果想改一個類型,還得全部跟著改。。如果有 Pick 就一了百了,想要啥就 pick 啥
想看 Pick 實現(xiàn)也很簡單,隨便起一個 type,按住 ctrl+點擊 Pick 就可以看到 Pick 實現(xiàn)
代碼實現(xiàn)如下:
/**
* From T, pick a set of properties whose keys are in the union K
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
復(fù)制代碼
不過第一次看到這代碼, extends , keyof , in 這都是什么東西?這就是這篇文章存在的意義。往下看
判斷常用 extends 關(guān)鍵字
extends 翻譯是延伸的意思
在 TS 充當(dāng)了 if 和 在 XXX 范圍內(nèi) 的一個作用。extends 后面通常就要接三目運算符了(也有例外)
舉個 ?? 子
type mytype1 = 1 extends string ? true : false // false
type mytype2 = '1' extends string ? true : false // true
type mytype2_1 = string extends '1' ? true : false // false
type mytype3 = mytype1 extends any ? 1 : 2 // 1
type mytype4 = [90] extends unknown[] ? true : false // true
type mytype5 = [90] extends string[] ? true : false // false
復(fù)制代碼
上面簡單的舉了幾個例子,簡單解釋下:
1 是否屬于 string 類型 得到 false 因為 1 是數(shù)字類型 '1' 屬于是 string 類型的 三目運算符判斷為 true string 類型 屬于 '1' 肯定是 false 的,string 類型范圍比'1'更大,不在屬于的范疇了 mytype1 屬于 any 類型是對的,因為 any 包含一切~ [90] 是一個數(shù)值型的數(shù)組,屬于一個 unknown未知類型的數(shù)組中,這個也是對的,因為未知類型也會包含數(shù)字類型而 [90] 就不屬于 string[]的范疇了
extends 也有不接三目運算符的時候
比如寫一個限制 string 類型的 push 方法
type StrPush<T extends string[], U extends string> = [...T, U]
type myarr = StrPush<[1, 2, 3], 4>
復(fù)制代碼
比如在這個例子中,直接會報錯。因為還沒進(jìn)到 StrPush 的判斷中,T 泛型就已經(jīng)被約束為 strinig 類型了,U 也被約束為 string 類型

extends 總結(jié)
extends 在 TS 的 函數(shù)體中的時候起到的是判斷范疇的一個作用在一些特殊位置 (比如接收泛型的時候,在函數(shù)運算過程中斷言變量類型的時候)起到的是一個 約束類型 的作用
循環(huán)對象的鍵 keyof 和 in
只要了解 keyof 和 in 之后,Pick 的所有關(guān)鍵字也就講解完了,體操練習(xí)中簡單的第一題 MyPick 也就完成了
還是上面的 demo 代碼
/**
* From T, pick a set of properties whose keys are in the union K
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = Pick<Todo, "title" | "completed">;
const todo: TodoPreview = {
title: "Clean room",
completed: false,
};
復(fù)制代碼
extends 上面講過,屬于范疇判斷/約束類型,在泛型定義<>里面明顯就是為了約束類型
keyof 的作用可以理解為 把一個對象中的所有 鍵 提取出來。
直接用 keyof 提取一個對象看看效果:
type TodoKeys = keyof Todo
//type TodoKeys = 'title' | 'description' | 'completed'
type testkeys = keyof {[k:number]: any; name:string}
// type testkeys = number | "name"
// 特殊的例子
type Mapish = { [k: string]: boolean; name:string };
type M = keyof Mapish;
// type M = string | number
復(fù)制代碼
上面的幾個例子中,第一個是最好理解的,提取所有的 鍵
第二個案例中,k 作為未知的內(nèi)容,提取 number 作為 key 的范圍,加上 "name" 所以就得出 number | "name"
特殊的例子也是官網(wǎng)的例子,為啥會有個 number 類型?
原話:Note that in this example, M is string | number — this is because JavaScript object keys are always coerced to a string, so obj[0] is always the same as obj["0"].
翻譯:請注意,在此示例中,M string | number — 這是因為 JavaScript 對象鍵總是被強(qiáng)制轉(zhuǎn)換為字符串,所以 obj[0] 總是與 obj["0"] 相同
結(jié)合 demo ,K extends keyof T 意思也就是說,K 參數(shù)的取值范圍只能在 Todo 的鍵中取('title' | 'description' | 'completed'),限制為字符串,并且是這 3 個鍵中的其中一個/多個,只能少,不能多
如果是下面這種
type Mapish = { [k: string]: boolean; name:string };
// K extends keyof Mapish;
復(fù)制代碼
K 的范圍則是 string | number 類型(因為沒有具體的鍵名,所以只能不限制具體的鍵名,只限制類型)
像這種用 | 拼起來的(或類型)規(guī)范點叫做 unio 聯(lián)合數(shù)據(jù)類型,想要循環(huán)這些數(shù)據(jù),可以用到 in 關(guān)鍵字
回到 demo 的講解
Pick 中 泛型 T 傳入的是 Todo 類型 K extends keyof T:K 限制為 Todo 的鍵值(傳入的是 "title" | "completed" 符合要求,因為只能少不能多嘛)P in k 可以理解為 循環(huán) P 會依次被賦值為 title 然后賦值 completed T[P] 的意思和 JS 的[]取值一樣,獲取 T['title'] => string 和 T['completed'] => completed {} 會包裹循環(huán)出來的結(jié)果
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
type TodoPreview = Pick<Todo, "title" | "completed">;
// TodoPreview = {title:string,completed:boolean}
復(fù)制代碼
Pick 的實現(xiàn)就完成了。[P in K] 這個循環(huán)和我們 JS 常規(guī)的寫法很不一樣,需要消化一下,其他應(yīng)該都好理解
以此內(nèi)推,完成 Readonly 眾所周知 readonly 只需要在鍵值前面加上 readonly 參數(shù)即可
實現(xiàn)如下:keyof 寫到了中括號里面,這個是允許的,這些關(guān)鍵字無論寫在哪里只要符合規(guī)范都 OK,比如 MyReadonly2
type MyReadonly<T> = {
readonly [P in keyof T]: T[P]
}
// 這個是畫蛇添足的,因為P肯定是屬于keyof T的,就是從keyof T中循環(huán)出來的
// 不過證明keyof不僅僅在 <> 中能用
type MyReadonly2<T> = {
readonly [P in keyof T]: P extends keyof T ? T[P] : never
}
復(fù)制代碼
小拓展:
'name' extends keyof T ? true : false也能判斷 T 這個泛型對象中有沒有 name 這個屬性
keyof 和 in 小結(jié)
keyof是為了拿到一個對象中的所有的鍵名,當(dāng)鍵名是一個類型的時候,則會全部被升級為對應(yīng)的 類型in則是為了循環(huán) unio 聯(lián)合類型數(shù)據(jù)的,多數(shù)都用于循環(huán)重新生成一個對象
JS 轉(zhuǎn) TS -- typeof 關(guān)鍵字
typeof 作為從 js 世界轉(zhuǎn)換為 ts 世界的內(nèi)容。
假設(shè)一個場景,我們使用一個 obj 對象作為數(shù)據(jù)映射的存儲,比如使用一個 map,存儲狀態(tài)碼返回對應(yīng)的 msg:
const statusMap = {
200: '操作成功',
404: '內(nèi)容找不到',
500: '操作失敗',
10001: '登錄失效'
}
var status1 = 200
console.log(statusMap[status1]) // 操作成功
var status2 = 10001
console.log(statusMap[status2]) // 登錄失效
復(fù)制代碼
類似上面的場景,那這時候statusMap中[] 中間的值肯定只有 200,404,500,10001 才符合要求,在 TS 層面自然我們就要約定 status 在這個范圍中
如果不用 typeof ,我們可以會寫出這樣的 TS:
type Status = 200 | 404 | 500 | 10001
var status1: Status = 200
console.log(statusMap[status1]) // 操作成功
var status2: Status = 10001
console.log(statusMap[status2]) // 登錄失效
var status3: Status = 301 // 報紅 (Type '301' is not assignable to type 'Status')
復(fù)制代碼
這時候假如我們在新增了幾個鍵值,那 TS 還得在同步跟著改一次(太麻煩了),用上 typeof
const statusMap = {
200: '操作成功',
404: '內(nèi)容找不到',
500: '操作失敗',
10001: '登錄失效'
}
type MyStatusMap = typeof statusMap
// 這時候 MyStatus 的值會變成
// type MyStatus = {
// 200: string;
// 404: string;
// 500: string;
// 10001: string;
// }
// 然后接上keyof關(guān)鍵字,提取對象的鍵名
type MyStatus = keyof MyStatusMap
// type MyStatus = 200 | 404 | 500 | 10001
// 上面的2步可以簡寫一步到位
type MyStatus2 = keyof typeof statusMap
// type MyStatus2 = 200 | 404 | 500 | 10001
復(fù)制代碼
這樣只需要我們改動 JS 的內(nèi)容,TS 將會自動獲取對新的對象。返回對象后還不夠,因為我們上面想約束的是傳入的鍵值(想獲取鍵值,剛好上面學(xué)習(xí)了 keyof 關(guān)鍵字),就能動態(tài)獲取所有符合規(guī)范的鍵值了!
typeof 小結(jié)
typeof 是一個可以動態(tài)把 JS 的對象轉(zhuǎn)換為 TS 的關(guān)鍵字。
不過也有限制場景,那就是轉(zhuǎn)換的前提是這部分 JS 是已固定的內(nèi)容。就好比例子中的一個對象映射,那是固定的內(nèi)容,然后讓 TS 去推導(dǎo)
而且打包后的代碼是不可能存在 TS 的,如果想實現(xiàn)后端接口動態(tài)返回內(nèi)容在用 typeof ,這是實現(xiàn)不了的
數(shù)組和字符串的循環(huán) 推斷類型 infer
infer 應(yīng)用場景非常多
簡單一句話概括 infer 只是一個 占位 的工具,我就站在這個位置,至于這個位置是什么內(nèi)容 infer 并不關(guān)心,可以留給后面的程序去判斷
用簡單題的 00014-easy-first[6] 來講解一下。實現(xiàn)一個 First 工具類型
通常獲取第一個元素,我們想到的就是 T[0] 當(dāng)然在 TS 這個語法是可以行得通的
可以在測試用例中有一項
Equal<First<[]>, never> // 使用 T[0] 的話這個會報錯,因為 First<[]> 返回的是 undefined
復(fù)制代碼
也就是說當(dāng)這個元素非指定聲明為 undefined 時,在 TS 多數(shù)都是要用 never 代替
正確答案如下:
type First<T extends any[]> = T extends [infer F,...infer Rest] ? F : never
復(fù)制代碼
簡單的說一下
infer必須在 TS 函數(shù)運算過程中使用(在定義泛型的<>中不能使用,而 extends 就可以)infer 可以配合 ... 進(jìn)行運算 T extends [infer F,...infer Rest]表達(dá)的意思就是 T extends [F,...Rest 剩余的值]。T 肯定是存在一個屬性 F的數(shù)組,...Rest是剩下的內(nèi)容,可有可無...infer Rest就是把除了 F 之外的元素在歸集為一個數(shù)組(這個是 ES6 的知識了)infer F的意思就是,我拿 F 在這個數(shù)組里面 占位 ,數(shù)組的第一項的內(nèi)容,就是被 F 占了回歸到題目,我們要拿的也正是第一項,所以直接 return F 類型 如果 T 是一個空數(shù)組,那么 extends 那一步就都判斷不通過,自然返回的就是 never,符合測試用例的要求。
infer 的其他妙用
infer 還能遍歷字符串
比如起一個 字符串切割為數(shù)組的需求:
type Split<T extends string, U extends unknown[] = []> =
T extends `${infer F}${infer Rest}` ? Split<Rest, [...U, F]> : U
type testSplit = Split<'123456'>
// type testSplit = ["1", "2", "3", "4", "5", "6"]
復(fù)制代碼
其中 ${infer F}${infer Rest} 的意思就是,F(xiàn) 占第一個字符,Rest 占剩下的字符,因為在字符串中不存在...的概念,所以 Rest 基本上就是占據(jù)了剩下的字符了
像這樣的一個測試?yán)樱还灿?3 個占位符,
type testInfer<T extends string> = T extends `${infer F}${infer S}${infer R}` ? [F, S, R] : T
type testInfer1 = testInfer<'123456'>
// 按照占位符的特性,前面F和S分別占據(jù)2個字符,剩余的都給R占去了
// type testInfer1 = ["1", "2", "3456"]
// 稍作改動,在S占位符后面添加一個5
type testInfer2<T extends string> = T extends `${infer F}${infer S}5${infer R}` ? [F, S, R] : T
type testInfer3 = testInfer<'123456'>
// F 占第一個字符 = 1
// S 占據(jù)2-4,因為在R之前有一個5,所以S代表了第二個字符開始到5的所有字符
// 那么R就是從5開始,到末尾,所以得出的結(jié)果如下:
// type testInfer1 = ["1", "234", "6"]
復(fù)制代碼
后面的習(xí)題還有很多會用到 infer ,所以先了解好 infer 占位 的特性就好
infer 小結(jié)
infer 相當(dāng)于一個占位置的關(guān)鍵字,把占下來的位置復(fù)制給對應(yīng)的運算變量。
其中對于數(shù)組或者其他的類型來說,還能用 ... 把所有的位置歸結(jié)起來形成一個數(shù)組
對于字符串這種不存在 ... 拓展運算符的來說,只要前面占了一個位置,剩下的字符就會被第二個占位符全部代替
數(shù)組的用法
數(shù)組也是 TS 體操的一個很重要的特性,因為在 TS 體操中并沒有加減法的概念,實際運算中少不了加減法的操作,包括獲取長度之類的。
所以數(shù)組還充當(dāng)了 計數(shù)器 的作用。關(guān)于數(shù)組的計數(shù)器還有一個非常有用的技巧,有用到我覺得可以單獨再起一個文章細(xì)細(xì)分析,下面就先簡單的介紹一下數(shù)組的功能
先來一道簡單題,學(xué)會數(shù)組的基本屬性和用法 00018-easy-tuple-length[7]
題目給出 2 個數(shù)組,用了 as const。需要求出這 2 個數(shù)組的長度
const tesla = ['tesla', 'model 3', 'model X', 'model Y'] as const
const spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT'] as const
type teslaLength = Length<typeof tesla>
type spaceXLength = Length<typeof spaceX>
// 答案:
type Length<T extends readonly any[]> = T['length']
復(fù)制代碼
是不是很簡單?!T['length'] 完事~
所以數(shù)組一個很重要的特性就是,他有 length 屬性。
想用
length屬性的前提:T extends any[]T 類型的范圍,肯定是一個數(shù)組,any[]或者unknown[]。都行,反正是數(shù)組,就有length屬性。
基于這個求長度的問題,延伸一下,求 2 個數(shù)組合并后的長度
type MergeArray<T extends readonly any[],U extends readonly any[]> = [...T,...U]['length']
type arrLength = MergeArray<typeof tesla, typeof spaceX> // 9
復(fù)制代碼
稍微來點有意思的題目在感受一下數(shù)組的作用 難度為中等的題 05153-medium-indexof[8]
實現(xiàn)一個 indexOf(原題還有另外一個需要注意的點就是判斷類型的時候需要注意的地方,我后面還會起文章來講解,現(xiàn)在先看最簡單的實現(xiàn)):
既然要算索引位置,自然就涉及到了一個 計數(shù)器 的問題,看下面的答案
type numberIndex = IndexOf<[1, 2, 3], 1> // 需要得到的答案是 0
type numberIndex2 = IndexOf<[1, 2, 3], 99> // 需要得到的答案是 -1
type numberIndex3 = IndexOf<[1, 2, 3], 1> // 需要得到的答案是 0
// 題目給出的初始模版(缺少計數(shù)器)
// type IndexOf<T extends unknown[],U> = any
// 對于這些缺參數(shù)的,我們完全可以自己補(bǔ)一個參數(shù),而且補(bǔ)充默認(rèn)值
type IndexOf2<T extends unknown[], U, C extends 1[] = []> =
T extends [infer F, ...infer Rest] ?
(F extends U ? C['length'] : IndexOf<Rest, U, [...C, 1]>) : -1
復(fù)制代碼
講解部分:
因為題目給出的模版缺少了一個計數(shù)器,我們可以補(bǔ)充一個 C 變量,并且默認(rèn)賦值為 []。只要有默認(rèn)值,就非必填,非必填的話測試用例就不會報錯了 T extends 和 infer 部分上面有講解過,如果 T 已經(jīng)不滿足至少有一個 F的時候,說明 T 數(shù)組已經(jīng)空了,空了之后就說明可以得出結(jié)果了T 為空了,還沒匹配到數(shù)據(jù),按 indexOf 的方法應(yīng)該是返回 -1 T 如果符合了至少有一個 F變量的要求的話,判斷F與傳入的U數(shù)據(jù)相比,相同的話返回當(dāng)前計數(shù)器的長度(也就是要計算的索引了)C['length']不符合的話,拿 Rest數(shù)組繼續(xù)循環(huán)(遞歸調(diào)用 IndexOf),與此同時 遞歸調(diào)用的時候 C 的入?yún)⒆兂闪?[...C,1]數(shù)組長度遞增1[...C,1]的作用,就是保留原數(shù)組的內(nèi)容,在添加一個 1,使得原數(shù)組的 length + 1 計數(shù)器 效果達(dá)成
通過一個簡單的拓展運算符,加上一個 1 使得數(shù)組的長度不斷的變化。達(dá)到計數(shù)器/加法運算的效果
比如在第 4182 的題目中,需要實現(xiàn)一個 斐波那契數(shù)列。斐波那契數(shù)列的特性就是 n = (n-1)+(n-2)。如何實現(xiàn)這個加法?用代碼來說就是 [...(N-1),...(N-2)]。(不理解沒關(guān)系,后面還有會詳細(xì)的文章來講)
數(shù)組小結(jié)
只要對應(yīng)的類型 extends [] 的話,就可以使用 ['length'] 屬性獲取長度 數(shù)組在體操中不僅僅充當(dāng)了數(shù)組的作用,還充當(dāng)了 計數(shù)器 和 加法實現(xiàn) 的作用,用法千變?nèi)f化 數(shù)組的累加依賴于 遞歸方法的實現(xiàn),在每一次遞歸的過程中往新的方法里面?zhèn)魅胄碌拈L度(不過遞歸容易造成內(nèi)存溢出,比如02257-medium-minusone[9]這一題)。需要一個更加高級的技巧處理
as 關(guān)鍵字
在上面講解數(shù)組的時候看到有 as const 的出現(xiàn),那就順便講講 as
在 TS 使用中,as 就是一個 斷言
假設(shè)這樣的一個場景,有一個變量 todo ,設(shè)置為了 Todo 類型,有對應(yīng)的屬性
然后某個函數(shù)的副作用,導(dǎo)致了我的 todo 變成了一個 string 類型(實際代碼應(yīng)該規(guī)避這種副作用函數(shù))
but 事情就這么發(fā)生了,而且不能改,這時候的 todo 應(yīng)該是 string 類型。todo 還需要調(diào)用一個方法,需要傳入 stirng 類型的 function todoFn(str:string){} 。這時候直接傳入 todo 肯定會報錯,類型不符合
解決辦法就是 todoFn(todo as string)。看下方的代碼截圖會好理解一點

在我斷定了這個類型就是 xxx 類型的時候就能用
as關(guān)鍵字(當(dāng)然不推薦使用),盡可能還是用 TS 的類型推導(dǎo)
在 TS 的類型定義的時候,as 又有別的含義
比如說這個
const teslaConst = ['tesla', 'model 3', 'model X', 'model Y'] as const
// const teslaConst: readonly ["tesla", "model 3", "model X", "model Y"]
// 用var變量和const推導(dǎo)是一樣的,不過會留下代碼隱患,不推薦
var teslaConst2 = ['tesla', 'model 3', 'model X', 'model Y'] as const
// var teslaConst: readonly ["tesla", "model 3", "model X", "model Y"]
const tesla = ['tesla', 'model 3', 'model X', 'model Y']
// const tesla: string[]
復(fù)制代碼
區(qū)別很明顯,as const 的會把所有的值拿出來,而且變成 readonly。因為 const 確實是只讀的標(biāo)記。
不過 TS 不吃 js 變量類型那一套,所以還得通過
as const來告訴 TS,我斷言這個就是一個 const 數(shù)組了,里面的元素都不會改了,你可以遍歷這里面的值
沒用 as const 的只會認(rèn)為是個 string[]的數(shù)組。這是一個很大的區(qū)別
最后
TS 類型體操前置知識儲備大概就介紹了extends,infer,typeof,keyof和in,數(shù)組的使用,as關(guān)鍵字
了解了這部分關(guān)鍵字作用之后,完成 TS 體操練習(xí)的中等難度的題目不在話下!(起碼完成 80%的題目沒得問題),剩下的 20% 還需要學(xué)習(xí)更多的 TS 體操技巧
這種感覺就好像如果你要解開一道一元二次方程之前,你得學(xué)習(xí)加減乘除的用法和規(guī)則。上面介紹的就是加減乘除的入門規(guī)則,后面還要學(xué)習(xí)更加巧妙地技能完成更復(fù)雜的 TS 體操
感興趣的可以到主頁看看關(guān)于 TS 體操的其他文章
以上的知識點也是我作為一個 TS 小白在摸索完中等題目后總結(jié)的一些筆記
如有說錯或理解不到位的地方請指出
關(guān)于本文
作者:Jioho
https://juejin.cn/post/7115789691810480135
