TS 基礎及在 Vue 中的實踐:TypeScript 都發布 5.0 版本啦,現在不學更待何時!

2023-03-30 09:00:26

大家好,我是 Kagol,OpenTiny 開源社群運營,TinyVue 跨端、跨框架元件庫核心貢獻者,專注於前端元件庫建設和開源社群運營。

微軟於3月16日釋出了 TypeScript 5.0 版本。微軟表示新版本體積更小、開發者更容易上手且執行速度更快。

根據 The Software House 釋出的《2022 前端開發市場狀態調查報告》資料顯示,使用 TypeScript 的人數已經達到 84%,和 2021 年相比增加了 7 個百分點。

TypeScript 可謂逐年火熱,使用者呈現逐年上升的趨勢,再不學起來就說不過去。

通過本文你將收穫:

  • 通過了解 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 可以選擇哪些

如果後臺改了介面引數名,我們一眼就看出來了,都不用去找介面檔案,是不是很厲害!

泛型的例子參考了前端阿林的文章:

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>

參考 Vue 官網檔案:

4 JS 專案轉 TS

還是使用 JS 的同學有福啦!為了讓大家快速用上 TS,享受 TS 的絲滑體驗,我整理了一份《JS 專案改造成 TS 專案指南》。有了這份步驟指南,JS 專案轉 TS 不再是難事!

我們新開源的 TinyVue 元件庫,就使用這份《JS 專案改造成 TS 專案指南》,成功地由 JS 專案改造成了 TS 專案,悄悄地告訴大家:

  • TinyVue 是一套跨端、跨框架的企業級 UI 元件庫,支援 Vue 2 和 Vue 3,支援 PC 端和行動端。
  • 在內部經過9年持續打磨,服務於華為內外部上千個專案。
  • 目前程式碼量超過10萬行

這麼龐大的程式碼量都能從 JS 轉 TS,其他小規模的專案更是不在話下。

為了驗證自己的猜想,我又在 GitHub 找到了一個6年前的 Vue2 + JS 專案,目前早已不再維護,打算嘗試將其改造成 TS 專案,結果按照這份指南,1個小時不用就搞定啦啦

https://github.com/liangxiaojuan/vue-todos

這個專案的效果圖長這樣:

我已經提了 issue,看下作者是否同意改造成 TS,同意的話,我立馬就是一個 PR 過去!

話不多說,大家有需要的,可直接拿走!

《JS 專案改造成 TS 專案指南》

JS 專案改造成 TS 步驟:

  1. 安裝 TS:npm i typescript ts-loader -D
  2. 增加 TS 組態檔:tsconfig.json
  3. 修改檔案字尾名:x.js -> x.ts
  4. x.vue 檔案增加 lang:<script lang="ts">
  5. vite.config.js 設定字尾名
  6. 升級依賴,修改本地啟動和構建指令碼
  7. 新增 loader / plugin
  8. 逐步補充型別宣告

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 感興趣,歡迎參與到我們的開源社群中來,一起將它建設得更好!

參與 TinyVue 元件庫建設,你將收穫:

直接的價值:

  • 通過打造一個跨端、跨框架的元件庫專案,學習最新的 Monorepo + Vite + Vue3 + TypeScript 技術
  • 學習從 0 到 1 搭建一個自己的元件庫的整套流程和方法論,包括元件庫工程化、元件的設計和開發等
  • 為自己的簡歷和職業生涯添彩,參與過優秀的開源專案,這本身就是受面試官青睞的亮點
  • 結識一群優秀的、熱愛學習、熱愛開源的小夥伴,大家一起打造一個偉大的產品

長遠的價值:

  • 打造個人品牌,提升個人影響力
  • 培養良好的編碼習慣
  • 獲得華為雲 OpenTiny 開源社群的榮譽&認可和客製化小禮物
  • 成為 PMC & Committer 之後還能參與 OpenTiny 整個開源生態的決策和長遠規劃,培養自己的管理和規劃能力
    未來有更多機會和可能

往期活動禮品及貢獻者的反饋:

聯絡我們

如果你對我們 OpenTiny 的開源專案感興趣,歡迎新增小助手微信:opentiny-official,拉你進群,一起交流前端技術,一起玩開源。

官網:https://opentiny.design/

GitHub倉庫:https://github.com/opentiny/

TinyVue:https://github.com/opentiny/tiny-vue(歡迎 Star