摘要:一份精心準備的《JS項目改造TS指南》文檔供大家參考,順便介紹TS 基礎知識和 TS 在 Vue 中的實踐。 本文分享自華為雲社區《歷史性的時刻!OpenTiny 跨端、跨框架組件庫正式升級 TypeScript,10 萬行代碼重獲新生!》,作者:Kagol。 根據 The Software ...
摘要:一份精心準備的《JS項目改造TS指南》文檔供大家參考,順便介紹TS 基礎知識和 TS 在 Vue 中的實踐。
本文分享自華為雲社區《歷史性的時刻!OpenTiny 跨端、跨框架組件庫正式升級 TypeScript,10 萬行代碼重獲新生!》,作者:Kagol。
根據 The Software House 發佈的《2022 前端開發市場狀態調查報告》數據顯示,使用 TypeScript 的人數已經達到 84%,和 2021 年相比增加了 7 個百分點。
3月16日發佈了 TypeScript 5.0 版本。TypeScript 可謂逐年火熱,使用者呈現逐年上升的趨勢,再不學起來就說不過去。
我們 OpenTiny 近期做了一次大的升級,將原來運行了 9年 的 JavaScript 代碼升級到了 TypeScript,並通過 Monorepo 進行子包的管理,還在用 JavaScript 的朋友抓緊升級哦,我特意準備了一份《JS項目改造TS指南》文檔供大家參考,順便介紹了一些 TS 基礎知識和 TS 在 Vue 中的一些實踐。
通過本文你將收穫:
- 通過瞭解 TS 的四大好處,說服自己下定決心學習 TS
- 5 分鐘學習 TS 最基礎和常用的知識點,快速入門,包教包會
- 瞭解如何在 Vue 中使用 TypeScript,給 Vue2 開發者切換到 Vue3 + TypeScript 提供最基本的參考
- 如何將現有的 JS 項目改造成 TS 項目
1 學習 TS 的好處
1.1 好處一:緊跟潮流:讓自己看起來很酷
如果你沒學過 TS
你的前端朋友:都 2023 年了,你還不會 TS?給你一個眼色你自己感悟吧
如果你學過 TS
你的前端朋友:哇,你們的項目已經用上 Vue3 + TS 啦,看起來真棒!教教我吧
如果說上面那個好處太虛了,那下麵的3條好處可都是實實在在能讓自己受益的。
1.2 好處二:智能提示:提升開發者體驗和效率
當迴圈一個對象數組時,對象的屬性列表可以直接顯示出來,不用到對象的定義中去查詢該對象有哪些屬性。
通過調用後臺介面獲取的非同步數據也可以通過TS類型進行智能提示,這樣相當於集成了介面文檔,後續後臺修改欄位,我們很容易就能發現。
Vue 組件的屬性和事件都可以智能提示。
下圖是我們OpenTiny跨端跨框架前端組件庫中的 Alert 組件,當在組件標簽中輸入 des 時,會自動提示 description 屬性;當輸入 @c 時,會自動提示 @close 事件。
1.3 好處三:錯誤標記:代碼哪裡有問題一眼就知道
在 JS 項目使用不存在的對象屬性,在編碼階段不容易看出來,到運行時才會報錯。
在 TS 項目使用不存在的對象屬性,在IDE中會有紅色波浪線標記,滑鼠移上去能看到具體的錯誤信息。
在 JS 項目,調用方法時拼錯單詞不容易被髮現,要在運行時才會將錯誤暴露出來。
在 TS 項目會有紅色波浪線提示,一眼就看出拼錯單詞。
1.4 好處四:類型約束:用我的代碼就得聽我的
你寫了一個工具函數 getType 給別人用,限定參數只能是指定的字元串,這時如果使用這個函數的人傳入其他字元串,就會有紅色波浪線提示。
Vue 組件也是一樣的,可以限定組件 props 的類型,組件的使用者如果傳入不正確的類型,將會有錯誤提示,比如:我們 OpenTiny 的 Alert 組件,closable 只能傳入 Boolean 值,如果傳入一個字元串就會有錯誤提示。
2 極簡 TS 基礎,5分鐘學會
以下內容雖然不多,但包含了實際項目開發中最實用的部分,對於 TS 入門者來說也是能很快學會的,學不會的找我,手把手教,包教包會,有手就會寫。
2.1 基本類型
用得較多的類型就下麵5個,更多類型請參考:TS官網文檔
- 布爾 boolean
- 數值 number
- 字元串 string
- 空值 void:表示沒有任何返回值的函數
- 任意 any:表示不被類型檢查
用法也很簡單:
let isDone: boolean = false; let myFavoriteNumber: number = 6; let myName: string = 'Kagol'; function alertName(name: string): void { console.log(`My name is ${name}`); }
預設情況下,name 會自動類型推導成 string 類型,此時如果給它賦值為一個 number 類型的值,會出現錯誤提示。
let name = 'Kagol' name = 6
如果給 name 設置 any 類型,表示不做類型檢查,這時錯誤提示消失。
let name: any = 'Kagol' name = 6
2.2 函數
主要定義函數參數和返回值類型。
看一下例子:
const sum = (x: number, y: number): number => { return x + y }
以上代碼包含以下 TS 校驗規則:
- 調用 sum 函數時,必須傳入兩個參數,多一個或者少一個都不行
- 並且這兩個參數的類型要為 number 類型
- 且函數的返回值為 number 類型
少參數:
多參數:
參數類型錯誤:
返回值:
用問號 ? 可以表示該參數是可選的。
const sum = (x: number, y?: number): number => { return x + (y || 0); } sum(1)
如果將 y 定義為可選參數,則調用 sum 函數時可以只傳入一個參數。
需要註意的是,可選參數必須接在必需參數後面。換句話說,可選參數後面不允許再出現必需參數了。
給 y 增加預設值 0 之後,y 會自動類型推導成 number 類型,不需要加 number 類型,並且由於有預設值,也不需要加可選參數。
const sum = (x: number, y = 0): number => { return x + y } sum(1) sum(1, 2)
2.3 數組
數組類型有兩種表示方式:
- 類型 + 方括弧 表示法
- 泛型 表示法
// `類型 + 方括弧` 表示法 let fibonacci: number[] = [1, 1, 2, 3, 5] // 泛型表示法 let fibonacci: Array<number> = [1, 1, 2, 3, 5]
這兩種都可以表示數組類型,看自己喜好進行選擇即可。
如果是類數組,則不可以用數組的方式定義類型,因為它不是真的數組,需要用 interface 進行定義
interface IArguments { [index: number]: any; length: number; callee: Function; } function sum() { let args: IArguments = arguments }
IArguments 類型已在 TypeScript 中內置,類似的還有很多:
let body: HTMLElement = document.body; let allDiv: NodeList = document.querySelectorAll('div'); document.addEventListener('click', function(e: MouseEvent) { // Do something });
如果數組裡的元素類型並不都是相同的怎麼辦呢?
這時 any 類型就發揮作用啦啦
let list: any[] = ['OpenTiny', 112, { website: 'https://opentiny.design/' }];
2.4 介面
介面簡單理解就是一個對象的“輪廓”
interface IResourceItem { name: string; value?: string | number; total?: number; checked?: boolean; }
介面是可以繼承介面的
interface IClosableResourceItem extends IResourceItem { closable?: boolean; }
這樣 IClosableResourceItem 就包含了 IResourceItem 屬性和自己的 closable 可選屬性。
介面也是可以被類實現的
interface Alarm { alert(): void; } class Door { } class SecurityDoor extends Door implements Alarm { alert() { console.log('SecurityDoor alert') } }
如果類實現了一個介面,卻不寫具體的實現代碼,則會有錯誤提示
2.5 聯合類型 & 類型別名
聯合類型是指取值可以為多種類型中的一種,而類型別名常用於聯合類型。
看以下例子:
// 聯合類型 let myFavoriteNumber: string | number myFavoriteNumber = 'six' myFavoriteNumber = 6 // 類型別名 type FavoriteNumber = string | number let myFavoriteNumber: FavoriteNumber
當 TypeScript 不確定一個聯合類型的變數到底是哪個類型的時候,我們只能訪問此聯合類型的所有類型里共有的屬性或方法:
function getLength(something: string | number): number { return something.length } // index.ts(2,22): error TS2339: Property 'length' does not exist on type 'string | number'. // Property 'length' does not exist on type 'number'. 上例中,length 不是 string 和 number 的共有屬性,所以會報錯。 訪問 string 和 number 的共有屬性是沒問題的: function getString(something: string | number): string { return something.toString() }
2.6 類型斷言
類型斷言(Type Assertion)可以用來手動指定一個值的類型。
語法:值 as 類型,比如:(animal as Fish).swim()
類型斷言主要有以下用途:
- 將一個聯合類型斷言為其中一個類型
- 將一個父類斷言為更加具體的子類
- 將任何一個類型斷言為 any
- 將 any 斷言為一個具體的類型
我們一個個來看。
用途1:將一個聯合類型斷言為其中一個類型
interface Cat { name: string; run(): void; } interface Fish { name: string; swim(): void; } const animal: Cat | Fish = new Animal() animal.swim()
animal 是一個聯合類型,可能是貓 Cat,也可能是魚 Fish,如果直接調用 swim 方法是要出現錯誤提示的,因為貓不會游泳。
這時類型斷言就派上用場啦啦,因為調用的是 swim 方法,那肯定是魚,所以直接斷言為 Fish 就不會出現錯誤提示。
const animal: Cat | Fish = new Animal() (animal as Fish).swim()
用途2:將一個父類斷言為更加具體的子類
class ApiError extends Error { code: number = 0; } class HttpError extends Error { statusCode: number = 200; } function isApiError(error: Error) { if (typeof (error as ApiError).code === 'number') { return true; } return false; }
ApiError 和 HttpError 都繼承自 Error 父類,error 變數的類型是 Error,去取 code 變數肯定是不行,因為取的是 code 變數,我們可以直接斷言為 ApiError 類型。
用途3:將任何一個類型斷言為 any
這個非常有用,看一下例子:
function getCacheData(key: string): any { return (window as any).cache[key]; } interface Cat { name: string; run(): void; } const tom = getCacheData('tom') as Cat;
getCacheData 是一個歷史遺留函數,不是你寫的,由於他返回 any 類型,就等於放棄了 TS 的類型檢驗,假如 tom 是一隻貓,裡面有 name 屬性和 run() 方法,但由於返回 any 類型,tom. 是沒有任何提示的。
如果將其斷言為 Cat 類型,就可以 點 出 name 屬性和 run() 方法。
用途4:將 any 斷言為一個具體的類型
這個比較常見的場景是給 window 掛在一個自己的變數和方法。
window.foo = 1; // index.ts:1:8 - error TS2339: Property 'foo' does not exist on type 'Window & typeof globalThis'. (window as any).foo = 1;
由於 window 下沒有 foo 變數,直接賦值會有錯誤提示,將 window 斷言為 any 就沒問題啦啦。
2.7 元組
數組合併了相同類型的對象,而元組(Tuple)合併了不同類型的對象。
let tom: [string, number] = ['Tom', 25];
給元組類型賦值時,數組每一項的類型需要和元組定義的類型對應上。
當賦值或訪問一個已知索引的元素時,會得到正確的類型:
let tom: [string, number]; tom[0] = 'Tom'; tom[1] = 25; tom[0].slice(1); tom[1].toFixed(2);
也可以只賦值其中一項:
let tom: [string, number]; tom[0] = 'Tom';
但是當直接對元組類型的變數進行初始化或者賦值的時候,需要提供所有元組類型中指定的項。
let tom: [string, number]; tom = ['Tom']; // Property '1' is missing in type '[string]' but required in type '[string, number]'.
當添加越界的元素時,它的類型會被限製為元組中每個類型的聯合類型:
let tom: [string, number]; tom = ['Tom', 25]; tom.push('male'); tom.push(true); // Argument of type 'true' is not assignable to parameter of type 'string | number'.
push 字元串和數字都可以,布爾就不行。
2.8 枚舉
枚舉(Enum)類型用於取值被限定在一定範圍內的場景,比如一周只能有七天,顏色限定為紅綠藍等。
enum Days {Sun, Mon, Tue, Wed, Thu, Fri, Sat}
枚舉成員會被賦值為從 0 開始遞增的數字,同時也會對枚舉值到枚舉名進行反向映射:
console.log(Days.Sun === 0) // true console.log(Days[0] === 'Sun') // true console.log('Days', Days)
手動賦值:未手動賦值的枚舉項會接著上一個枚舉項遞增。
enum Days {Sun = 7, Mon = 1, Tue, Wed, Thu, Fri, Sat}
2.9 類
給類加上 TypeScript 的類型很簡單,與介面類似:
class Animal { name: string constructor(name: string) { this.name = name } sayHi(welcome: string): string { return `${welcome} My name is ${this.name}` } }
類的語法涉及到較多概念,請參考:
2.10 泛型
泛型(Generics)是指在定義函數、介面或類的時候,不預先指定具體的類型,而在使用的時候再指定類型的一種特性。
可以簡單理解為定義函數時的形參。
設想以下場景,我們有一個 print 函數,輸入什麼,原樣列印,函數的入參和返回值類型是一致的。
一開始只需要列印字元串:
function print(arg: string): string { return arg }
後面需求變了,除了能列印字元串,還要能列印數字:
function print(arg: string | number): string | number { return arg }
假如需求又變了,要列印布爾值、對象、數組,甚至自定義的類型,怎麼辦,寫一串聯合類型?顯然是不可取的,用 any?那就失去了 TS 類型校驗能力,淪為 JS。
function print(arg: any): any { return arg }
解決這個問題的完美方法就是泛型!
print 後面加上一對尖括弧,裡面寫一個 T,這個 T 就類似是一個類型的形參。
這個類型形參可以在函數入參里用,也可以在函數返回值使用,甚至也可以在函數體裡面的變數、函數裡面用。
function print<T>(arg: T): T { return arg }
那麼實參哪裡來?用的時候傳進來!
const res = print<number>(123)
我們還可以使用泛型來約束後端介面參數類型。
import axios from 'axios' interface API { '/book/detail': { id: number, }, '/book/comment': { id: number comment: string } ... } function request<T extends keyof API>(url: T, obj: API[T]) { return axios.post(url, obj) } request('/book/comment', { id: 1, comment: '非常棒!' })
以上代碼對介面進行了約束:
- url 只能是 API 中定義過的,其他 url 都會提示錯誤
- 介面參數 obj 必須和 url 能對應上,不能少屬性,屬性類型也不能錯
而且調用 request 方法時,也會提示 url 可以選擇哪些
如果後臺改了介面參數名,我們一眼就看出來了,都不用去找介面文檔,是不是很厲害!
泛型的例子參考了前端阿林的文章:
- 輕鬆拿下 TS 泛型
3 TS 在 Vue 中的實踐
3.1 定義組件 props 的類型
不使用 setup 語法糖
export default defineComponent({ props: { items: { type: Object as PropType<IResourceItem[]>, default() { return [] } }, span: { type: Number, default: 4 }, gap: { type: [String, Number] as PropType<string | number>, default: '12px' }, block: { type: Object as PropType<Component>, default: TvpBlock }, beforeClose: Function as PropType<() => boolean> } })
使用 setup 語法糖 – runtime 聲明
import { PropType, Component } from 'vue' const props = defineProps({ items: { type: Object as PropType<IResourceItem[]>, default() { return [] } }, span: { type: Number, default: 4 }, gap: { type: [String, Number] as PropType<string | number>, default: '12px' }, block: { type: Object as PropType<Component>, default: TvpBlock }, beforeClose: Function as PropType<() => boolean> })
使用 setup 語法糖 – type-based 聲明
import { Component, withDefaults } from 'vue' interface Props { items: IResourceItem[] span: number gap: string | number block: Component beforeClose: () => void } const props = withDefaults(defineProps<Props>(), { items: () => [], span: 4, gap: '12px', block: TvpBlock }) IResourceItem: interface IResourceItem { name: string; value?: string | number; total?: number; checked?: boolean; closable?: boolean; }
3.2 定義 emits 類型
不使用 setup 語法糖
export default defineComponent({ emits: ['change', 'update'], setup(props, { emit }) { emit('change') } })
使用 setup 語法糖
<script setup lang="ts"> // runtime const emit = defineEmits(['change', 'update']) // type-based const emit = defineEmits<{ (e: 'change', id: number): void (e: 'update', value: string): void }>() </script>
3.3 定義 ref 類型
預設會自動進行類型推導
import { ref } from 'vue' // inferred type: Ref<number> const year = ref(2020) // => TS Error: Type 'string' is not assignable to type 'number'. year.value = '2020'
兩種聲明 ref 類型的方法
import { ref } from 'vue' import type { Ref } from 'vue' // 方式一 const year: Ref<string | number> = ref('2020') year.value = 2020 // ok! // 方式二 // resulting type: Ref<string | number> const year = ref<string | number>('2020') year.value = 2020 // ok!
3.4 定義 reactive 類型
預設會自動進行類型推導
import { reactive } from 'vue' // inferred type: { title: string } const book = reactive({ title: 'Vue 3 Guide' })
使用介面定義明確的類型
import { reactive } from 'vue' interface Book { title: string year?: number } const book: Book = reactive({ title: 'Vue 3 Guide' })
3.5 定義 computed 類型
預設會自動進行類型推導
import { ref, computed } from 'vue' const count = ref(0) // inferred type: ComputedRef<number> const double = computed(() => count.value * 2) // => TS Error: Property 'split' does not exist on type 'number' const result = double.value.split('')
兩種聲明 computed 類型的方法
import { ComputedRef, computed } from 'vue' const double: ComputedRef<number> = computed(() => { // type error if this doesn't return a number }) const double = computed<number>(() => { // type error if this doesn't return a number })
3.6 定義 provide/inject 類型
provide
import { provide, inject } from 'vue' import type { InjectionKey } from 'vue' // 聲明 provide 的值為 string 類型 const key = Symbol() as InjectionKey<string> provide(key, 'foo') // providing non-string value will result in error
inject
// 自動推導為 string 類型 const foo = inject(key) // type of foo: string | undefined // 明確指定為 string 類型 const foo = inject<string>('foo') // type: string | undefined // 增加預設值 const foo = inject<string>('foo', 'bar') // type: string // 類型斷言為 string const foo = inject('foo') as string
3.7 定義模板引用的類型
<script setup lang="ts"> import { ref, onMounted } from 'vue' const el = ref<HTMLInputElement | null>(null) onMounted(() => { el.value?.focus() }) </script> <template> <input ref="el" /> </template>
3.8 定義組件模板引用的類型
定義一個 MyModal 組件
<!-- MyModal.vue --> <script setup lang="ts"> import { ref } from 'vue' const isContentShown = ref(false) const open = () => (isContentShown.value = true) defineExpose({ open }) </script>
在 App.vue 中引用 MyModal 組件
<!-- App.vue --> <script setup lang="ts"> import MyModal from './MyModal.vue' const modal = ref<InstanceType<typeof MyModal> | null>(null) const openModal = () => { modal.value?.open() } </script>