本文由郭凱南同學分享,主要是基於元件庫開發的場景,介紹了 Vue
元件開發的基礎知識與優秀實踐。
前言
很多同學隨著自己前端技能的增長,不滿足於常規的業務開發,開始想著做一些自己的技術積累。例如學會了 Vue
框架的使用以及如何封裝元件之後,想要自己試著開發一套元件庫引入到專案中,甚至共享給更多的人使用。但是在這個過程中,往往會遇到許多的問題:
- 元件庫工程需要的基礎設施該如何搭建,如何實現元件庫的構建、提交門禁、測試、文件、釋出?
- 對於複雜一些的元件,我在實現的過程中感覺邏輯越來越混亂,程式碼越來越難以維護,最終難以持續迭代下去。
- 有些元件互動複雜,甚至由多個子元件構成(例如 Form 和 FormItem),它們之間的通訊和狀態共享如何處理?感覺缺少思路,無從下手。
磨刀不誤砍柴工,對於正處於經驗積累階段的前端同學,或許需要先重溫一些基礎知識,夯實內功,才能更好地實踐。
實踐 Vue
元件庫的搭建,我們是需要掌握一些前置知識的:
- 一方面是前端工程化相關內容,它們是元件庫的地基、腳手架般重要的存在,是整個元件庫工程的基礎;
- 另一方面是
Vue
元件開發的技巧與優秀實踐,它們在實現元件庫主體部分時發揮作用,決定了我們的能否實現、能否做好每一個元件。
本章分享的內容側重於後者,我們將基於元件庫開發的場景,介紹一些高頻使用的 Vue
框架基礎知識與實戰技巧,主要內容如下:
- 元件的基本概念;
- 官方主推的元件開發正規化:單檔案元件與組合式 API;
- 深入組合式 API:響應式 API;
- 深入組合式 API:元件的生命週期;
- 深入組合式 API:元件之間的通訊方式;
- 元件開發的優秀實踐介紹。
我們在舉例時,會盡量貼近當下環境中較新的實踐——使用 Vue
的最新版本與 TypeScript
。如果讀者在閱讀過程中對程式碼示例中的內容感到困惑,可以前往以下文件補充學習:
元件的基本概念
對於元件庫而言,元件的概念是使用者介面 UI 中獨立的、可重用的部分,使用者傾向於多次複用元件,像搭積木一樣,將多個元件組合為完整的使用者介面。
不過,站在 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 的一大優勢,在於可以將相同邏輯關注點的程式碼聚合為一組,而不用為了同一個邏輯關注點在不同的選項之間來回切換。
我們分享中的演示案例都將採用“單檔案模板和組合式 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。在此過程中,它也會執行被稱為生命週期鉤子的函式,讓開發者有機會在特定階段執行自己的程式碼。
onBeforeMount()
: 在元件被掛載之前呼叫,此時元件已經完成了響應式狀態的設定,但還沒有建立 DOM 節點。onMounted()
: 在元件被掛載之後呼叫,此時元件已經建立了 DOM 節點,並插入了父容器中。可以在這個鉤子中訪問或操作 DOM 元素。onBeforeUpdate()
: 在元件即將因為響應式狀態變更而更新其 DOM 樹之前呼叫,可以在這個鉤子中訪問更新前的 DOM 狀態。onUpdated()
: 在元件因為響應式狀態變更而更新其 DOM 樹之後呼叫,可以在這個鉤子中訪問更新後的 DOM 狀態。onBeforeUnmount()
: 在元件例項被解除安裝之前呼叫,此時元件例項依然還保有全部的功能。onUnmounted()
: 在元件例項被解除安裝之後呼叫,此時元件例項已經失去了全部的功能。可以在這個鉤子中清理一些副作用,如計時器、事件監聽器等。onErrorCaptured()
: 在捕獲了後代元件傳遞的錯誤時呼叫,可以在這個鉤子中處理錯誤或阻止錯誤繼續向上傳遞。onRenderTracked()
: 在元件渲染過程中追蹤到響應式依賴時呼叫,僅在開發模式下可用,用於除錯響應式系統。
在實際的開發過程中,我們最常用到的宣告週期鉤子是 onMounted
、onBeforeUnmount
、onUnmounted
——它們具有最強的泛用性,在實際開發過程中佔據了 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
,父元件呼叫子元件時透過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
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
- 子元件使用
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
provide / inject
是 vue
中的依賴注入 API,可用於在元件樹中傳值。凡是在上層元件中透過 provide
註冊的值,都可以在下層元件中使用 inject
獲取。
我們透過 單選框組 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
時代的歷史,目前已經不再得到持續維護。
我們應該解除安裝 Vetur
,改為安裝 Volar 和 TypeScript Vue Plugin。前者支援 Vue3
的語法特性,後者提供了對 .vue
單檔案模板的 TypeScript
支援。
如果想要更進一步加強 TypeScript
支援,我們應當參照 Vue 官方文件:Volar Takeover 模式 對編輯器進行配置,使得 TypeScript Vue Plugin
也能接管普通的 .ts
檔案,進而支援對 Vue
元件例項型別的推斷。
單元件的檔案結構
我們推薦大家在開發單個元件時,嘗試用以下檔案結構來組織程式碼:
?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>
- 在對元件進行二次封裝時,使用者可以引入
Props
、Emits
介面,透過繼承在原元件的基礎上繼續擴充屬性與事件的定義。
<!-- 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
官方推薦我們按照類似下面的實踐,透過抽取組合式函式改善程式碼結構。
<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 開源社群貢獻,你將收穫:
直接的價值:
- 透過參與一個實際的跨端、跨框架元件庫專案,學習最新的
Vite
+Vue3
+TypeScript
+Vitest
技術 - 學習從 0 到 1 搭建一個自己的元件庫的整套流程和方法論,包括元件庫工程化、元件的設計和開發等
- 為自己的簡歷和職業生涯添彩,參與過優秀的開源專案,這本身就是受面試官青睞的亮點
- 結識一群優秀的、熱愛學習、熱愛開源的小夥伴,大家一起打造一個偉大的產品
長遠的價值:
- 打造個人品牌,提升個人影響力
- 培養良好的編碼習慣
- 獲得華為雲 OpenTiny 團隊的榮譽和定製小禮物
- 受邀參加各類技術大會
- 成為 PMC 和 Committer 之後還能參與 OpenTiny 整個開源生態的決策和長遠規劃,培養自己的管理和規劃能力
- 未來有更多機會和可能
關於 OpenTiny
OpenTiny 是一套企業級元件庫解決方案,適配 PC 端 / 移動端等多端,涵蓋 Vue2 / Vue3 / Angular 多技術棧,擁有主題配置系統 / 中後臺模板 / CLI 命令列等效率提升工具,可幫助開發者高效開發 Web 應用。
核心亮點:
跨端跨框架
:使用 Renderless 無渲染元件設計架構,實現了一套程式碼同時支援 Vue2 / Vue3,PC / Mobile 端,並支援函式級別的邏輯定製和全模板替換,靈活性好、二次開發能力強。元件豐富
:PC 端有100+元件,移動端有30+元件,包含高頻元件 Table、Tree、Select 等,內建虛擬滾動,保證大資料場景下的流暢體驗,除了業界常見元件之外,我們還提供了一些獨有的特色元件,如:Split 皮膚分割器、IpAddress IP地址輸入框、Calendar 日曆、Crop 圖片裁切等配置式元件
:元件支援模板式和配置式兩種使用方式,適合低程式碼平臺,目前團隊已經將 OpenTiny 整合到內部的低程式碼平臺,針對低碼平臺做了大量最佳化周邊生態齊全
:提供了基於 Angular + TypeScript 的 TinyNG 元件庫,提供包含 10+ 實用功能、20+ 典型頁面的 TinyPro 中後臺模板,提供覆蓋前端開發全流程的 TinyCLI 工程化工具,提供強大的線上主題配置平臺 TinyTheme
聯絡我們:
- 官方公眾號:
OpenTiny
- OpenTiny 官網:https://opentiny.design/
- OpenTiny 程式碼倉庫:https://github.com/opentiny/
- Vue 元件庫:https://github.com/opentiny/tiny-vue (歡迎 Star)
- Angluar元件庫:https://github.com/opentiny/ng (歡迎 Star)
- CLI工具:https://github.com/opentiny/tiny-cli (歡迎 Star)
更多影片內容也可以關注OpenTiny社群,B站/抖音/小紅書/影片號。