大家好,我是 Kagol,OpenTiny 開源社群運營,TinyVue 跨端、跨框架元件庫核心貢獻者,專注於前端元件庫建設和開源社群運營。
微軟於3月16日釋出了 TypeScript 5.0 版本。微軟表示新版本體積更小、開發者更容易上手且執行速度更快。
根據 The Software House 釋出的《2022 前端開發市場狀態調查報告》資料顯示,使用 TypeScript 的人數已經達到 84%,和 2021 年相比增加了 7 個百分點。
TypeScript 可謂逐年火熱,使用者呈現逐年上升的趨勢,再不學起來就說不過去。
通過本文你將收穫:
如果你沒學過 TS
你的前端朋友:都 2023 年了,你還不會 TS?給你一個眼色你自己感悟吧
如果你學過 TS
你的前端朋友:哇,你們的專案已經用上 Vue3 + TS 啦,看起來真棒!教教我吧
如果說上面那個好處太虛了,那下面的3條好處可都是實實在在能讓自己受益的。
當迴圈一個物件陣列時,物件的屬性列表可以直接顯示出來,不用到物件的定義中去查詢該物件有哪些屬性。
通過呼叫後臺介面獲取的非同步資料也可以通過TS型別進行智慧提示,這樣相當於整合了介面檔案,後續後臺修改欄位,我們很容易就能發現。
Vue 元件的屬性和事件都可以智慧提示。
下圖是我們 OpenTiny 跨端跨框架前端元件庫中的 Alert 元件,當在元件標籤中輸入 des
時,會自動提示 description
屬性;當輸入 @c
時,會自動提示 @close
事件。
在 JS 專案使用不存在的物件屬性,在編碼階段不容易看出來,到執行時才會報錯。
在 TS 專案使用不存在的物件屬性,在IDE中會有紅色波浪線標記,滑鼠移上去能看到具體的錯誤資訊。
在 JS 專案,呼叫方法時拼錯單詞不容易被發現,要在執行時才會將錯誤暴露出來。
在 TS 專案會有紅色波浪線提示,一眼就看出拼錯單詞。
你寫了一個工具函數 getType 給別人用,限定引數只能是指定的字串,這時如果使用這個函數的人傳入其他字串,就會有紅色波浪線提示。
Vue 元件也是一樣的,可以限定元件 props 的型別,元件的使用者如果傳入不正確的型別,將會有錯誤提示,比如:我們 OpenTiny 的 Alert 元件,closable 只能傳入 Boolean 值,如果傳入一個字串就會有錯誤提示。
以下內容雖然不多,但包含了實際專案開發中最實用的部分,對於 TS 入門者來說也是能很快學會的,學不會的找我,手把手教,包教包會,有手就會寫。
用得較多的型別就下面5個,更多型別請參考:TS官網檔案
用法也很簡單:
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
主要定義函數引數和返回值型別。
看一下例子:
const sum = (x: number, y: number): number => {
return x + y
}
以上程式碼包含以下 TS 校驗規則:
少引數:
多引數:
引數型別錯誤:
返回值:
用問號 ?
可以表示該引數是可選的。
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)
陣列型別有兩種表示方式:
型別 + 方括號
表示法泛型
表示法// `型別 + 方括號` 表示法
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/' }];
介面簡單理解就是一個物件的「輪廓」
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')
}
}
如果類實現了一個介面,卻不寫具體的實現程式碼,則會有錯誤提示
聯合型別是指取值可以為多種型別中的一種,而型別別名常用於聯合型別。
看以下例子:
// 聯合型別
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()
}
型別斷言(Type Assertion)可以用來手動指定一個值的型別。
語法:值 as 型別,比如:(animal as Fish).swim()
型別斷言主要有以下用途:
我們一個個來看。
用途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 就沒問題啦啦。
陣列合併了相同型別的物件,而元組(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 字串和數位都可以,布林就不行。
列舉(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}
給類加上 TypeScript 的型別很簡單,與介面類似:
class Animal {
name: string
constructor(name: string) {
this.name = name
}
sayHi(welcome: string): string {
return `${welcome} My name is ${this.name}`
}
}
類的語法涉及到較多概念,請參考:
泛型(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: '非常棒!'
})
以上程式碼對介面進行了約束:
而且呼叫 request 方法時,也會提示 url 可以選擇哪些
如果後臺改了介面引數名,我們一眼就看出來了,都不用去找介面檔案,是不是很厲害!
泛型的例子參考了前端阿林的文章:
不使用 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;
}
不使用 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>
預設會自動進行型別推導
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!
預設會自動進行型別推導
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' })
預設會自動進行型別推導
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
})
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
<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>
定義一個 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>
參考 Vue 官網檔案:
還是使用 JS 的同學有福啦!為了讓大家快速用上 TS,享受 TS 的絲滑體驗,我整理了一份《JS 專案改造成 TS 專案指南》
。有了這份步驟指南,JS 專案轉 TS 不再是難事!
我們新開源的 TinyVue 元件庫,就使用這份《JS 專案改造成 TS 專案指南》
,成功地由 JS 專案改造成了 TS 專案,悄悄地告訴大家:
10萬行
。這麼龐大的程式碼量都能從 JS 轉 TS,其他小規模的專案更是不在話下。
為了驗證自己的猜想,我又在 GitHub 找到了一個6年前的 Vue2 + JS 專案,目前早已不再維護,打算嘗試將其改造成 TS 專案,結果按照這份指南,1個小時不用就搞定啦啦
https://github.com/liangxiaojuan/vue-todos
這個專案的效果圖長這樣:
我已經提了 issue,看下作者是否同意改造成 TS,同意的話,我立馬就是一個 PR 過去!
話不多說,大家有需要的,可直接拿走!
JS 專案改造成 TS 步驟:
npm i typescript ts-loader -D
tsconfig.json
x.js -> x.ts
x.vue
檔案增加 lang:<script lang="ts">
vite.config.js
設定字尾名loader
/ plugin
等tsconfig.ts
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true
},
"include": [
"src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"
]
}
組態檔字尾名,增加 .ts
和 .tsx
extensions: ['.js', '.vue', '.json', '.ts', 'tsx'],
入口檔案要由 main.js
改成 main.ts
entry: {
app: './src/main.ts'
},
需要設定下 loader
{
test: /\.tsx?$/,
loader: 'ts-loader',
exclude: /node_modules/,
options: {
appendTsSuffixTo: [/\.vue$/]
},
include: [resolve('src')]
}
以及 plugin
const { VueLoaderPlugin } = require('vue-loader')
plugins: [
new VueLoaderPlugin()
],
完成之後,先測試下專案是否能正常啟動和構建:npm run dev
/ npm run build
都沒問題之後,本次 TS 專案改造就完成大部分啦啦!
後續就是逐步補充程式碼涉及到的變數和函數的型別宣告即可。
改造過程中遇到問題歡迎留言討論,希望你也能儘快享受 TS 的絲滑開發者體驗!
如果你對我們的跨端跨框架元件庫 TinyVue 感興趣,歡迎參與到我們的開源社群中來,一起將它建設得更好!
參與 TinyVue 元件庫建設,你將收穫:
直接的價值:
Monorepo
+ Vite
+ Vue3
+ TypeScript
技術長遠的價值:
往期活動禮品及貢獻者的反饋:
如果你對我們 OpenTiny 的開源專案感興趣,歡迎新增小助手微信:opentiny-official,拉你進群,一起交流前端技術,一起玩開源。
GitHub倉庫:https://github.com/opentiny/
TinyVue:https://github.com/opentiny/tiny-vue(歡迎 Star