必不可少的UI元件一——元件的基礎知識

發表於2023-09-18

本文由郭凱南同學分享,主要是基於元件庫開發的場景,介紹了 Vue 元件開發的基礎知識與優秀實踐。

前言

很多同學隨著自己前端技能的增長,不滿足於常規的業務開發,開始想著做一些自己的技術積累。例如學會了 Vue 框架的使用以及如何封裝元件之後,想要自己試著開發一套元件庫引入到專案中,甚至共享給更多的人使用。但是在這個過程中,往往會遇到許多的問題:

  • 元件庫工程需要的基礎設施該如何搭建,如何實現元件庫的構建、提交門禁、測試、文件、釋出?
  • 對於複雜一些的元件,我在實現的過程中感覺邏輯越來越混亂,程式碼越來越難以維護,最終難以持續迭代下去。
  • 有些元件互動複雜,甚至由多個子元件構成(例如 Form 和 FormItem),它們之間的通訊和狀態共享如何處理?感覺缺少思路,無從下手。

磨刀不誤砍柴工,對於正處於經驗積累階段的前端同學,或許需要先重溫一些基礎知識,夯實內功,才能更好地實踐。

實踐 Vue 元件庫的搭建,我們是需要掌握一些前置知識的:

  • 一方面是前端工程化相關內容,它們是元件庫的地基、腳手架般重要的存在,是整個元件庫工程的基礎;
  • 另一方面是 Vue 元件開發的技巧與優秀實踐,它們在實現元件庫主體部分時發揮作用,決定了我們的能否實現、能否做好每一個元件。

本章分享的內容側重於後者,我們將基於元件庫開發的場景,介紹一些高頻使用的 Vue 框架基礎知識與實戰技巧,主要內容如下:

  • 元件的基本概念;
  • 官方主推的元件開發正規化:單檔案元件與組合式 API;
  • 深入組合式 API:響應式 API;
  • 深入組合式 API:元件的生命週期;
  • 深入組合式 API:元件之間的通訊方式;
  • 元件開發的優秀實踐介紹。

我們在舉例時,會盡量貼近當下環境中較新的實踐——使用 Vue 的最新版本與 TypeScript。如果讀者在閱讀過程中對程式碼示例中的內容感到困惑,可以前往以下文件補充學習:

元件的基本概念

對於元件庫而言,元件的概念是使用者介面 UI 中獨立的、可重用的部分,使用者傾向於多次複用元件,像搭積木一樣,將多個元件組合為完整的使用者介面。

9.14圖1.png

不過,站在 Vue 框架層面來看,我們先前提到的“元件”的概念其實是 Vue 框架中“元件”概念的子集。對於 Vue 框架而言,萬物都是元件——無論是大的使用者介面,還是小的功能模組。

任何一個 Vue 應用都可以看做是以 App.vue(入口元件可以叫其他名稱) 為根節點的元件樹。

既然我們的目標是編寫元件庫,那麼下文將要講解的基礎知識將圍繞著以下三個問題展開:

  • 應該採用什麼樣的正規化編寫 Vue 元件?
  • 如何編寫元件的內部執行邏輯?
  • 如何定義元件的外部互動介面?即處理元件之間的通訊問題。

單檔案元件與組合式 api

目前,Vue 官方主推的元件實現正規化是 單檔案元件 與 組合式 API 的結合。下面給出一個典型案例:

<script lang="ts" setup>
import { ref, onMounted } from 'vue'

// 響應式狀態
const count = ref(0)

// 更改狀態、觸發更新的函式
function increment() {
  count.value++
}

// 生命週期鉤子
onMounted(() => {
  console.log(`計數器初始值為 ${count.value}。`)
})
</script>

<template>
  <button class="btn" @click="increment">點選了:{{ count }} 次</button>
</template>

<style>
.btn {
  background-color: #c7000b;
}
</style>
如你所見,Vue 的單檔案元件是網頁開發中 HTML、CSS 和 JavaScript 三種語言經典組合的自然延伸。<template><script> 和 <style> 三個塊在同一個檔案中封裝、組合了元件的檢視、邏輯和樣式。

關於單檔案元件的優勢與選型理由,Vue 官網給出了非常充分清晰的理由:為什麼要使用 SFC

而 組合式 API 則體現在單檔案元件的邏輯部分(<script></script>),它要求我們使用函式語句而不是宣告選項的方式書寫 Vue 元件的邏輯部分。在 Vue 3 中,組合式 API 經常配合 <script setup> 語法出現在單檔案元件中。

Vue 官網對於組合式 API 的優勢也有著充分的說明:為什麼要有組合式 API?

組合式 API 的相比選項式 API 的一大優勢,在於可以將相同邏輯關注點的程式碼聚合為一組,而不用為了同一個邏輯關注點在不同的選項之間來回切換。

CompositionsAPI.gif

我們分享中的演示案例都將採用“單檔案模板和組合式 API 結合”的形式,同樣推薦大家編寫自己的元件庫時採納這種實踐。這主要基於以下理由:

  • 單檔案模板和組合式 API 各自的優勢。(參考官方文件中的描述)
  • Vue 官方已經針對這樣的正規化此做了足夠的最佳化,目前足以滿足絕大多數應用場景。
  • 作為官方主推的一種實踐方案,未來也將得到社群最大力度的支援。

組合式 API 和單檔案元件並不能天然被瀏覽器所支援,需要提供額外的編譯支援,因此必須搭配構建工具使用。  我們可以參考 Vite 搭建第一個 Vite 專案,基於 Vite,透過簡單的命令快速生成這樣的模板。

npm create vite@latest

其中的 src/components/HelloWorld.vue 就是符合“單檔案元件和組合式 API”實踐的典型元件,我們可以參考它並嘗試編寫我們自己的元件。

響應式 API

明確了編寫元件的正規化之後,下一步我們需要掌握如何編寫元件的內部執行邏輯。這就需要我們對組合式 API 涵蓋的內容——響應式 API生命週期鉤子依賴注入進行了解,這裡我們先來看響應式 API。

我建議大家仔細閱讀官方文件中的 深入響應式系統,它有助於我們更好地理解和運用響應式 API。

本文由於篇幅限制,不傾向於花篇幅分析響應式 API 的原理,這裡給出一個簡單的說明:響應式 API 用於建立響應式變數,響應式變數的改變可以觸發 <template> 模板渲染內容的改變,或者觸發一些關聯的事件。下面的例子對剛才的說明進行了解釋:

<script setup lang="ts">
import { ref, watch } from 'vue'

const a = ref('Hello');

// 響應式變數 a 發生修改,關聯事件(alert) 會被觸發
watch(a, () => {
  alert(a.value)
})


// 5 秒後,修改響應式變數 a
setTimeout(() => {
  a.value = 'Hello World!'
}, 5000)
</script>

<template>
  <div>
    <!-- 響應式變數 a 發生修改,模板渲染內容也會及時跟進 -->
    <p>{{ a }}</p>
  </div>
</template>

我們來簡單回顧開發過程中最常用的響應式 API:

ref 和 reactive

ref 和 reactive 是響應式 API 的基礎,它們能夠將普通 JavaScript 變數變成響應式變數:

  • reactive 方法接收一個物件,將其變成響應式。
  • ref 可以讓基本型別(字串、數字、布林)變數也能夠變成響應式。
  • ref 建立的響應式資料需要透過 .value 屬性進行訪問和修改;而 reactive 建立的響應式物件可以直接訪問和修改其屬性。
  • 從表面上看 ref 更適合於處理基本型別,而 reactive 更適合於處理物件。(不過這不代表 ref 不可以處理物件,許多實踐甚至推薦儘可能使用 ref 代替 reactive,參考:VueUse Guidelines)。
<script setup lang="ts">
import { ref, reactive } from 'vue'

const refState = ref(0);
console.log(refState) // Ref 物件
console.log(refState.value) // 0

const reactiveState = reactive({ state: 0 })
console.log(reactiveState.state) // 0

function clickHandler() {
  // ref 物件的設定也需要 .value
  refState.value++;
  reactiveState.state++;
}
</script>

<template>
  <div>
    <!-- 注意在模板中訪問 ref 變數不需要 value  -->
    <p>{{ refState }}</p>
    <p>{{ reactiveState.state }}</p>
    <button @click="clickHandler">+1</button>
  </div>
</template>

computed

computed 用於建立一個響應式的計算屬性。

<script setup lang="ts">
import { ref, reactive, computed } from 'vue'

const a = ref(1);
const b = reactive({ count: 2 })

// 函式內部無論是 a 還是 b 發生變化,都會自動觸發響應式變數 sum 的重新計算,永遠保持 sum = a + b.count
const sum = computed(() => a.value + b.count)

setTimeout(() => {
  a.value = 2;
  b.count = 3;
  // 注意訪問 computed 建立的響應式變數時也要加上 .value
  console.log(sum.value) // 5
}, 5000)
</script>

<template>
  <div>
    <!-- 注意在模板中訪問 ref 變數不需要 value  -->
    <p>a = {{ a }}</p>
    <p>b.count = {{ b.count }}</p>
    <p>sum = a + b.count = {{ sum }}</p>
  </div>
</template>

watch

watch 用於觀察一個或多個響應式物件,並在觀察物件發生變化時,執行與其相關聯的方法。

import { ref, reactive, watch } from 'vue'

const count = ref(0)
const data = reactive({
  count: 0
})

watch(count, (newVal, oldVal) => {
  // count changed from 0 to 1
  console.log(`count changed from ${oldVal} to ${newVal}`)
})

watch(data, (newVal, oldVal) => {
  // { count: 2 }
  console.log(oldVal)

  // { count: 2 }
  console.log(newVal)
})

// 檢測 reactive 物件內部屬性時,需要寫成函式返回的形式
watch(() => data.count, (newVal, oldVal) => {
  // data.count changed from 0 to 2
  console.log(`data.count changed from ${oldVal} to ${newVal}`)
})

// 觀測多個 響應式物件/屬性 的變化
watch([
  count,
  () => data.count
], ([newCount, newDataCount], [oldCount, oldDataCount]) => {
  // count changed from 0 to 1
  // data.count changed from 0 to 2
  console.log(`count changed from ${oldCount} to ${newCount}`)
  console.log(`data.count changed from ${oldDataCount} to ${newDataCount}`)
})

setTimeout(() => {
  count.value = 1
  data.count = 2
}, 5000)

上述提到的響應式 API 具有最強的泛用性,涵蓋了 90% 甚至更多的應用場景。需要更加深入的瞭解響應式 API,可以進一步參考官方文件:

元件的生命週期

每個 Vue 元件例項在建立時都需要經歷一系列的初始化步驟,比如設定好資料偵聽,編譯模板,掛載例項到 DOM,以及在資料改變時更新 DOM。在此過程中,它也會執行被稱為生命週期鉤子的函式,讓開發者有機會在特定階段執行自己的程式碼。

9.14圖3.png

  • onBeforeMount(): 在元件被掛載之前呼叫,此時元件已經完成了響應式狀態的設定,但還沒有建立 DOM 節點。
  • onMounted(): 在元件被掛載之後呼叫,此時元件已經建立了 DOM 節點,並插入了父容器中。可以在這個鉤子中訪問或操作 DOM 元素
  • onBeforeUpdate(): 在元件即將因為響應式狀態變更而更新其 DOM 樹之前呼叫,可以在這個鉤子中訪問更新前的 DOM 狀態。
  • onUpdated(): 在元件因為響應式狀態變更而更新其 DOM 樹之後呼叫,可以在這個鉤子中訪問更新後的 DOM 狀態。
  • onBeforeUnmount(): 在元件例項被解除安裝之前呼叫,此時元件例項依然還保有全部的功能。
  • onUnmounted(): 在元件例項被解除安裝之後呼叫,此時元件例項已經失去了全部的功能。可以在這個鉤子中清理一些副作用,如計時器、事件監聽器等
  • onErrorCaptured(): 在捕獲了後代元件傳遞的錯誤時呼叫,可以在這個鉤子中處理錯誤或阻止錯誤繼續向上傳遞。
  • onRenderTracked(): 在元件渲染過程中追蹤到響應式依賴時呼叫,僅在開發模式下可用,用於除錯響應式系統。

在實際的開發過程中,我們最常用到的宣告週期鉤子是 onMountedonBeforeUnmountonUnmounted——它們具有最強的泛用性,在實際開發過程中佔據了 90% 的出場率。

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

const el = ref<HTMLDivElement>()
console.log(el.value) // undefined
onMounted(() => {
  // 通常在 onMounted 中獲取 DOM
  console.log(el.value) // HTMLDivElement
})

const timer = setTimeout(function() {
  // 定時器任務
}, 5000)
onBeforeUnmount(() => {
  // 在 onBeforeUnmount 中登出定時器、繫結事件等
  clearTimeout(timer)
})

</script>

<template>
  <div ref="el"></div>
</template>

曾經的選項式 API 中,全域性只有一個 mounted 鉤子,所有的 DOM 初始化相關邏輯都要寫進去。不同的是,組合式 API 中的生命週期鉤子是可以多次呼叫的,這一特點使得組合式 API 更加擅於“按邏輯關係組織程式碼"。

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

const a = ref<HTMLDivElement>()
onMounted(() => {
  console.log(a.value) // HTMLDivElement
  console.log(a.value.innerText) // aaa
})

const b = ref<HTMLDivElement>()
onMounted(() => {
  console.log(b.value) // HTMLDivElement
  console.log(b.value.innerText) // bbb
})

const c = ref<HTMLDivElement>()
onMounted(() => {
  console.log(c.value) // HTMLDivElement
  console.log(c.value.innerText) // ccc
})

</script>

<template>
  <div>
    <div ref="a">aaa</div>
    <div ref="b">bbb</div>
    <div ref="c">ccc</div>
  </div>
</template>

元件之間的通訊方式

接下來還有一個問題,就是元件如何與外部進行互動,即如何與其他元件通訊?對於元件庫的開發而言,我們推薦使用以下通訊機制,對於每種通訊機制都給出了示例程式碼,大家可以在自己建立的示例工程中,或者在 Vue SFC Playground 嘗試執行示例,檢視效果。

props / v-bind

參考:Vue 官方文件:Props

  • 這是 Vue 中父子元件最基礎的通訊方式。子元件宣告自身的屬性 props,父元件呼叫子元件時透過 v-bind 繫結屬性。
  • 結合使用 withDefaults 和 defineProps,可以完整地設定元件屬性的型別與預設值。
  • 元件的屬性可以是任何型別,包括複雜物件、函式等。
  • 元件原則上不允許修改 props,因此 props 是一種從父到子的單向通訊機制。但是子元件可以利用函式型別的 props,將內部的狀態透過函式引數告知父元件實現反向通訊。
<!-- 子元件 child.vue -->
<script setup lang="ts">
import { reactive } from 'vue'

const props = withDefaults(defineProps<{
  // props 的型別
  text?: string;
  data?: Record<string, any>;
  clickCallback?: (data: Record<string, any>) => void
}>(), {
  // props 的預設值
  text: 'Button',
  data: () => ({}),
  clickCallback: () => {}
})

const childData = reactive({
  ...props.data,
  count: 0,
})

function clickHandler() {
  childData.count++
  props.clickCallback(childData)
}
</script>

<template>
  <button @click="clickHandler">
    {{ text }}
  </button>
</template>

<!-- 父元件中使用子元件 child.vue -->
<script setup lang="ts">
import Child from './child.vue'

function clickHandler(data: Record<string, any>) {
  console.log('子元件的資料物件:', data); // 子元件的資料物件:{ message: 'parent', count: 1 }
}
</script>

<template>
  <Child 
    text="Hello World" 
    :data="{ message: 'parent' }" 
    :clickCallback="clickHandler" />
</template>

本案例的程式碼演示:props / v-bind

emit / v-on

參考:Vue 官方文件:事件

  • 元件之間從子到父的單向通訊機制。元件透過 defineEmits 宣告事件。
  • 父元件透過 v-on 監聽子元件事件,當子元件內部呼叫 emit() 觸發事件時,會執行 v-on 繫結的方法。
  • 因為 emit() 可以攜帶引數,因此子元件可以向父元件傳遞自身的狀態。
<!-- 子元件 child.vue -->
<script setup lang="ts">
import { reactive } from 'vue'

const emit = defineEmits<{
  (event: 'add', val: string, list: string[]): void;
}>();

const list: string[] = reactive([])

function clickHandler() {
  const value = `第${String(list.length + 1)}項`
  list.push(value)
  emit('add', value, list);
}
</script>

<template>
  <div>
    <ul>
      <li v-for="(item, index) in list" :key="index">{{ item }}</li>
    </ul>
    <button @click="clickHandler">Add</button>
  </div>
</template>

<!-- 父元件中使用子元件 child.vue -->
<script setup lang="ts">
import Child from './child.vue'

function addHandler(value: string, list: string[]) {
  console.log('向子元件列表新增項:', value)
  console.log('子元件當前列表:', list)
}
</script>

<template>
  <Child @add="addHandler" />
</template>

本案例的程式碼演示:emit / v-on

v-model

參考:Vue 官方文件:元件 v-model

  • v-model 機制是 vue 提供的一個語法糖,它能夠使一個響應式變數在父子元件之間始終保持同步,實現雙向繫結。
  • 實現元件的 v-model 機制需要綜合使用上述的 props 和 emit。子元件透過 emit() 方法觸發一個攜帶了新值的 update:xxx 自定義事件,就能使父元件繫結到子元件 props 上的 xxx 屬性同步為對應的新值。
  • 下面的例子以一個 input 輸入框元件為例子,透過 watch 方法實現 v-model 機制。無論父元件從外部修改,還是子元件在內部修改,v-model 繫結的 value 屬性始終雙向同步。
<!-- 子元件 child.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'

const props = withDefaults(defineProps<{
  value?: string;
}>(), {
  value: ''
})

const emit = defineEmits<{
  (event: 'update:value', val: string): void;
}>();

const valueModel = ref(props.value);

watch(() => props.value, (val) => {
  valueModel.value = val
})

watch(valueModel, (val) => {
  emit('update:value', val)
})

function inputHandler(event: Event) {
  const { value } = event.target as HTMLInputElement
  valueModel.value = value
}

function clickHandler() {
  valueModel.value += 'Hello World!'
}
</script>

<template>
  <div>
    <input :value="valueModel" @input="inputHandler" />
    <button @click="clickHandler">子元件內部修改 value</button>
  </div>
</template>

<!-- 父元件中使用子元件 child.vue -->
<script setup lang="ts">
import Child from './child.vue'
import { ref, watch } from 'vue'

const msg = ref('')
</script>

<template>
  <div>
    <Child v-model:value="msg" />
    <p>{{ msg }}</p>
  </div>
</template>

本案例的程式碼演示:v-model

defineExpose / ref

參考:Vue 官方文件:模板引用

  • 子元件使用 defineExpose 向外暴露自身的屬性與方法。
  • 父元件透過 ref 獲取子元件的例項物件,訪問與呼叫子元件暴露的屬性與方法。
<!-- 子元件 child.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const isVisible = ref(false);

function open() {
  isVisible.value = true;
}

function close() {
  isVisible.value = false;
}

defineExpose({
  isVisible,
  open,
  close
})
</script>

<template>
  <div v-if="isVisible">Child</div>
</template>

<!-- 父元件中使用子元件 child.vue -->
<script setup lang="ts">
import Child from './child.vue'
import { ref, computed } from 'vue'

const childInstance = ref<InstanceType<typeof Child>>()

const showState = computed(() => `${childInstance.value?.isVisible ? '顯示' : '隱藏'}`)

function showHandler() {
  childInstance.value?.open()
  console.log('當前元件的狀態:', showState.value)
}

function hideHandler() {
  childInstance.value?.close();
  console.log('當前元件的狀態:', showState.value)
}

</script>

<template>
  <div>
    <button @click="showHandler">顯示 Child</button>
    <button @click="hideHandler">隱藏 Child</button>
    <p>當前元件的狀態:{{ showState }}</p>
    <Child ref="childInstance" />
  </div>
</template>

本案例的程式碼演示:defineExpose / ref

provide / inject

參考:Vue 官方文件:依賴注入

provide / inject 是 vue 中的依賴注入 API,可用於在元件樹中傳值。凡是在上層元件中透過 provide 註冊的值,都可以在下層元件中使用 inject 獲取。

9.14圖provide.png

我們透過 單選框組 RadioGroup 的場景來演示 provide / inject 的典型使用,radio-group 元件可以將包括選中值在內的自身狀態包裝為上下文物件,透過 provide 向下傳遞,內部的 radio 元件中透過 inject 方法獲取上下文物件,從而可以根據自身屬性更新 select 元件的狀態。

這樣的傳值方式,使得子元件之間只要處在同一個父元件之下,也得以共享狀態,實現同級元件之間的通訊。

<!-- radio-group.vue -->
<script setup lang="ts">
import { ref, watch, provide, Ref } from 'vue'

const props = withDefaults(defineProps<{
  modelValue?: any;
}>(), {
  modelValue: ''
})

const emit = defineEmits<{
  (event: 'update:modelValue', val: any): void;
}>();

// 實現選中項 v-model 雙向繫結
const model = ref(props.modelValue)
watch(() => props.modelValue, (val) => { model.value = val })
watch(model, (val) => { emit('update:modelValue', val) })

// 將元件的上下文物件向下傳遞
const context = {
  radioGroupSelected: model,
  selections: <Ref<boolean>[]>[]
};

export type RadioGroupContext = typeof context

provide('radio-group', context)
</script>

<template>
  <ul class="radio-group">
    <slot />
  </ul>
</template>

<!-- radio.vue -->
<script setup lang="ts">
import { ref, watch, inject, Ref } from 'vue'
import type { RadioGroupContext } from './radio-group.vue'

const props = withDefaults(defineProps<{
  /** 單個 radio 的選中狀態 */
  modelValue?: boolean;

  /** radio 的繫結值 */
  value?: any;
}>(), {
  modelValue: false,
  value: ''
})

const emit = defineEmits<{
  (event: 'update:modelValue', val: boolean): void;
}>();

// 獲取 radio-group 元件的上下文物件
const radioGroupContext = inject<RadioGroupContext>('radio-group')

// 實現選中狀態 v-model 雙向繫結
const model = ref(props.modelValue)
watch(() => props.modelValue, (val) => { model.value = val })
watch(model, (val) => { emit('update:modelValue', val) })

if (radioGroupContext) {
  // 若檢測到父級 radio-group 元件,將自身狀態推入上下文物件
  radioGroupContext.selections.push(model);
}

function changeHandler(event: Event) {
  const { checked } = event.target as HTMLInputElement
  model.value = checked

  if (checked && radioGroupContext) {
    // 子元件被選中時,根據子元件繫結的 value,控制父元件的 v-model 繫結值
    radioGroupContext.radioGroupSelected.value = props.value

    // 取消其他同級 radio 的選中狀態
    radioGroupContext.selections.forEach((selection) => {
      if (selection !== model) {
        selection.value = false
      }
    })
  }
}
</script>

<template>
  <li class="radio">
    <input type="radio" :checked="model" @change="changeHandler" />
    <slot />
  </li>
</template>

<!-- 父元件中使用子元件 radio-group.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import RadioGroup from './radio-group.vue'
import Radio  from './radio.vue'

const value = ref('')
</script>

<template>
  <div>
    <RadioGroup v-model="value">
      <Radio value="11111">選項 1</Radio>
      <Radio value="22222">選項 2</Radio>
      <Radio value="33333">選項 3</Radio>
    </RadioGroup>
    <p>當前選中的值:{{ value }}</p>
  </div>
</template>

本案例的程式碼演示:provide / inject

插槽 slot

參考:Vue 官方文件:插槽

  • 插槽功能允許我們將自定義模板內容渲染到元件的特定位置,也算作一種父元件向子元件通訊的方式。
  • 透過 作用域插槽 功能,元件可以向一個插槽的出口上傳遞屬性,而父元件使用插槽時透過 v-slot 指令就能接收到子元件所傳遞的內容。
<!-- 子元件 child.vue -->
<script setup lang="ts">
import { reactive } from 'vue'

const data = reactive({
  default: 0,
  special: 0
})
</script>

<template>
  <div>
    <p>defaultCount:{{ data.default }}</p>
    <slot :data="data" />
    <p>specialCount:{{ data.special }}</p>
    <slot name="special" :data="data" />
  </div>
</template>

<!-- 父元件中使用子元件 child.vue -->
<script setup lang="ts">
import Child from './child.vue'
</script>

<template>
  <Child>
    <template #default="{ data }">
      <button @click="data.default++">Click</button>
    </template>
      <template #special="{ data }">
      <button @click="data.special++">Click</button>
    </template>
  </Child>
</template>

本案例的程式碼演示:插槽 slot

封裝元件的優秀實踐

瞭解了 Vue 框架的基礎知識和元件開發技巧後,我們給大家分享一些優秀的實踐,可以改善編碼體驗,更好地組織元件的邏輯模組,促進程式碼質量的提升。

安裝並設定配套的 IDE 外掛

許多小夥伴還在使用 Vetur 作為 Vue 開發的輔助外掛,雖然 Vetur 的下載量壓倒性得高,但它代表的是 Vue2 時代的歷史,目前已經不再得到持續維護。

9.14圖5.png

我們應該解除安裝 Vetur,改為安裝 Volar 和 TypeScript Vue Plugin。前者支援 Vue3 的語法特性,後者提供了對 .vue 單檔案模板的 TypeScript 支援。

如果想要更進一步加強 TypeScript 支援,我們應當參照 Vue 官方文件:Volar Takeover 模式 對編輯器進行配置,使得 TypeScript Vue Plugin 也能接管普通的 .ts 檔案,進而支援對 Vue 元件例項型別的推斷。

9.14圖6.png

單元件的檔案結構

我們推薦大家在開發單個元件時,嘗試用以下檔案結構來組織程式碼:

?comp
 ┣ ?comp.vue
 ┣ ?composables.ts
 ┣ ?index.ts
 ┗ ?props.ts

概述和介紹

  • props.ts - 集中定義元件的屬性 props、事件 emits 相關的介面。
  • composables.ts - 使用組合式 API,按照邏輯關注點的不同,將元件邏輯封裝為多個組合式函式。
  • comp.vue - 元件的單檔案模板。
  • index.ts - 元件的出口,匯出其他檔案中的內容,參考內容如下:
// index.ts
import Comp from './comp.vue';

export { Comp }
export * from './composables';
export * from './props';

規範元件的定義

我們推薦在 props.ts 中集中定義元件的屬性 props、事件 emits 相關的介面,供元件的邏輯實現 composables.ts 以及單檔案模板 .vue 檔案使用。這裡以 input 輸入框元件的屬性定義為例子,在 props.ts 中應當定義以下內容:

  • 元件的屬性 props 介面以及預設值。
  • 元件的事件 emits 介面。
  • 元件的例項型別。
// props.ts
import { InferVueDefaults } from '@/utils';
import type Input from './Input.vue';

export interface InputProps {
  /** 原生 input 型別 */
  type?: string;

  /** 繫結值 */
  modelValue?: string;

  /** 輸入框佔位文字 */
  placeholder?: string;

  /** 是否顯示清楚按鈕 */
  clearable?: boolean;
}

export type RequiredInputProps = Required<InputProps>

export function defaultInputProps(): Required<InferVueDefaults<InputProps>> {
  return {
    type: 'text',
    modelValue: '',
    placeholder: '',
    clearable: false
  };
}

export interface InputEmits {
  (event: 'update:modelValue', val: string): void;
  (event: 'input', val: string): void;
  (event: 'clear'): void;
  (event: 'focus'): void;
  (event: 'blur'): void;
}

export type InputInstance = InstanceType<typeof Input>

關於 InferVueDefaults,這個是 Vue 中推斷預設 props 型別的型別工具,我們可以自己實現它:

type NativeType = null | number | string | boolean | symbol | Function;
type InferDefault<P, T> = ((props: P) => T & {}) | (T extends NativeType ? T : never);

/** 推斷出 props 預設值的型別 */
export type InferVueDefaults<T> = {
  [K in keyof T]?: InferDefault<T, T[K]>;
};

在元件的單檔案模板實現 input.vue 中,我們可以引入 props.ts 中的介面與型別,使用 Vue 官方文件:編譯器宏 規範清晰地定義元件。

<!-- Input.vue -->
<script setup lang="ts">
import { 
  defaultInputProps, 
  InputProps,
  InputEmits
} from './props';

// 宣告自定義選項,如元件名稱 name
defineOptions({
  // ...
})

// 定義屬性 props
const props = withDefaults(
  defineProps<InputProps>(),
  defaultInputProps(),
);

// 定義事件 emits
const emit = defineEmits<InputEmits>();

// 元件實現邏輯


// 向外暴露屬性與方法
defineExpose({
  // ...
})
</script>

<template>
  <!-- ... -->
</template>

除了使元件本身的實現程式碼更具條理性,在 props.ts 中規範宣告的型別與介面帶來的另一大好處是:當使用者希望對元件進行使用和擴充時,可以得到強大、完善、貼心的型別支援。

  • 透過 ref 獲取元件例項時,使用者可以直接使用 Instance 型別,無需自己實現型別推斷。
<script setup lang="ts">
import { InputInstance } from './input'
import { ref } from 'vue'

const input = ref<InputInstance>()

</script>

<template>
  <Input ref="input" />
</template>
  • 在對元件進行二次封裝時,使用者可以引入 PropsEmits 介面,透過繼承在原元件的基礎上繼續擴充屬性與事件的定義。
<!-- MyInput.vue -->
<script setup lang="ts">
import { 
  defaultInputProps, 
  InputProps,
  InputEmits
} from './input';

interface MyInputProps extends InputProps {
  // ...
}

const props = withDefaults(
  defineProps<MyInputProps>(),
  {
    ...defaultInputProps(),
    // 更多屬性的預設值
  },
);

interface MyInputEmits extends InputEmits {
  // ...
}

const emit = defineEmits<MyInputEmits>();
</script>

封裝組合式函式

參考:Vue 官方文件:組合式函式

Vue 官方推薦我們按照類似下面的實踐,透過抽取組合式函式改善程式碼結構。

<script setup>
import { useFeatureA } from './featureA.js'
import { useFeatureB } from './featureB.js'
import { useFeatureC } from './featureC.js'

const { foo, bar } = useFeatureA()
const { baz } = useFeatureB(foo)
const { qux } = useFeatureC(baz)
</script>

遵循官方的建議,我們建議大家在 composables.ts 中,將元件的功能拆分為多個邏輯關注點,將每一個邏輯關注點封裝為一個組合式函式。

繼續以之前的 input 輸入框為例子,我們可以簡單劃分出三個邏輯點:

  • 輸入框內容的雙向繫結,透過 useInputModelValue 實現。
  • 輸入框的聚焦、失焦等各種事件的處理,透過 useInputEvents 實現。
  • 輸入框的清空邏輯,透過 useInputClearable 實現。
// composables.ts
import { 
  ref, 
  watch, 
  onMounted, 
  onBeforeUnmount 
} from 'vue';
import {
  RequiredInputProps,
  InputEmits
} from './props'

export function useInputModelValue(
  props: RequiredInputProps,
  emit: InputEmits
) {
  const model = ref(props.modelValue);

  watch(() => props.modelValue, (val) => {
    model.value = val
  })

  watch(model, (val) => {
    emit('update:modelValue', val)
  })

  return { model }
}

export function useInputEvents(
  emit: InputEmits,
  modelValueContext: ReturnType<typeof useInputModelValue>
) {
  const inputEl = ref<HTMLInputElement>()
  const { model } = modelValueContext

  function focus() {
    inputEl.value?.focus()
  }

  function blur() {
    inputEl.value?.blur()
  }

  function focusHandler () {
    emit('focus')
  }

  function blurHandler () {
    emit('blur')
  }

  function inputHandler(e: Event) {
    const { value } = e.target as HTMLInputElement
    model.value = value
    emit('input', value)
  }

  // 元件掛載後獲取 dom
  onMounted(() => {
    inputEl.value?.addEventListener('focus', focusHandler)
    inputEl.value?.addEventListener('blur', blurHandler)
    inputEl.value?.addEventListener('input', inputHandler)
  })

  // 元件登出前及時解綁事件
  onBeforeUnmount(() => {
    inputEl.value?.removeEventListener('focus', focusHandler)
    inputEl.value?.removeEventListener('blur', blurHandler)
    inputEl.value?.removeEventListener('input', inputHandler)
  })

  return { 
    inputEl,
    focus,
    blur
  }
}

export function useInputClearable(
  emit: InputEmits,
  modelValueContext: ReturnType<typeof useInputModelValue>
) {
  const { model } = modelValueContext

  function clearHandler() {
    model.value = ''
    emit('clear')
  }

  return { clearHandler }
}

最後,在 input.vue 單檔案模板中,我們引入 composables.ts 中的函式進行組合。

<!-- input.vue -->
<script setup lang="ts">
import { 
  defaultInputProps, 
  InputProps,
  InputEmits
} from './props';
import {
  useInputModelValue,
  useInputClearable,
  useInputEvents
} from './composables'

defineOptions({
  // 自定義選項
})

const props = withDefaults(
  defineProps<InputProps>(),
  defaultInputProps(),
)

const emit = defineEmits<InputEmits>()

// 元件實現邏輯
const modelValueContext = useInputModelValue(props, emit)
const { model } = modelValueContext

const { inputEl, focus, blur } = useInputEvents(emit, modelValueContext)

const { clearHandler } = useInputClearable(emit, modelValueContext)

defineExpose({
  clear: clearHandler,
  focus,
  blur,
})
</script>

<template>
  <div>
    <input 
      ref="inputEl" 
      :type="type" 
      :value="model"
      :placeholder="placeholder" />
    <button v-if="clearable" @click="clearHandler">清除</button>
  </div>
</template>

完整的案例程式碼演示:單元件封裝實踐

雖然元件的程式碼經過分離邏輯關注點後變得更加清晰,但是我們例子中的組合函式還是有很大的提升空間——composables.ts 中函式需要的引數被限定為 input 元件的 props 和 emits,這就使得我們的組合函式只能用於特定的元件,而缺乏通用性,這些邏輯很難被其他的元件複用。

如果希望更進一步瞭解元件封裝的技巧,可以持續關注後續的內容,本文的分享就到這裡。

OpenTiny 社群招募貢獻者啦

OpenTiny Vue 正在招募社群貢獻者,歡迎加入我們?

你可以透過以下方式參與貢獻:

  • 在 issue 列表中選擇自己喜歡的任務
  • 閱讀貢獻者指南,開始參與貢獻

你可以根據自己的喜好認領以下型別的任務:

  • 編寫單元測試
  • 修復元件缺陷
  • 為元件新增新特性
  • 完善元件的文件

如何貢獻單元測試:

  • packages/vue目錄下搜尋it.todo關鍵字,找到待補充的單元測試
  • 按照以上指南編寫元件單元測試
  • 執行單個元件的單元測試:pnpm test:unit3 button

如果你是一位經驗豐富的開發者,想接受一些有挑戰的任務,可以考慮以下任務:

  • ✨ [Feature]: 希望提供 Skeleton 骨架屏元件
  • ✨ [Feature]: 希望提供 Divider 分割線元件
  • ✨ [Feature]: tree樹形控制元件能增加虛擬滾動功能
  • ✨ [Feature]: 增加影片播放元件
  • ✨ [Feature]: 增加思維導圖元件
  • ✨ [Feature]: 新增類似飛書的多維表格元件
  • ✨ [Feature]: 新增到 unplugin-vue-components
  • ✨ [Feature]: 相容formily

參與 OpenTiny 開源社群貢獻,你將收穫:

直接的價值:

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

長遠的價值:

  1. 打造個人品牌,提升個人影響力
  2. 培養良好的編碼習慣
  3. 獲得華為雲 OpenTiny 團隊的榮譽和定製小禮物
  4. 受邀參加各類技術大會
  5. 成為 PMC 和 Committer 之後還能參與 OpenTiny 整個開源生態的決策和長遠規劃,培養自己的管理和規劃能力
  6. 未來有更多機會和可能

關於 OpenTiny

OpenTiny 是一套企業級元件庫解決方案,適配 PC 端 / 移動端等多端,涵蓋 Vue2 / Vue3 / Angular 多技術棧,擁有主題配置系統 / 中後臺模板 / CLI 命令列等效率提升工具,可幫助開發者高效開發 Web 應用。

核心亮點:

  1. 跨端跨框架:使用 Renderless 無渲染元件設計架構,實現了一套程式碼同時支援 Vue2 / Vue3,PC / Mobile 端,並支援函式級別的邏輯定製和全模板替換,靈活性好、二次開發能力強。
  2. 元件豐富:PC 端有100+元件,移動端有30+元件,包含高頻元件 Table、Tree、Select 等,內建虛擬滾動,保證大資料場景下的流暢體驗,除了業界常見元件之外,我們還提供了一些獨有的特色元件,如:Split 皮膚分割器、IpAddress IP地址輸入框、Calendar 日曆、Crop 圖片裁切等
  3. 配置式元件:元件支援模板式和配置式兩種使用方式,適合低程式碼平臺,目前團隊已經將 OpenTiny 整合到內部的低程式碼平臺,針對低碼平臺做了大量最佳化
  4. 周邊生態齊全:提供了基於 Angular + TypeScript 的 TinyNG 元件庫,提供包含 10+ 實用功能、20+ 典型頁面的 TinyPro 中後臺模板,提供覆蓋前端開發全流程的 TinyCLI 工程化工具,提供強大的線上主題配置平臺 TinyTheme

聯絡我們:

更多影片內容也可以關注OpenTiny社群,B站/抖音/小紅書/影片號。

相關文章