Vue.js - المنافذ الديناميكية والعامة
مقدمة
نتناول في هذه المقالة المنافذ الديناميكية والعامة في Vue.js، بصيغة <script setup> وماكرو defineSlots الذي جاء به الإصدار 3.3. باجتماعهما نبني مكوّنًا يخصّصه المطوّرون كما يشاؤون، ومحرّرهم يخبرهم عند كل منفذ بما بين أيديهم. وهذا يجعل المكوّن أسهل في إعادة الاستعمال أيضًا.
سنمرّ بثلاثة مكوّنات صغيرة: جدول يصيّر منفذًا لكل عمود، ثم قائمة عامة يعرف منفذها نوع عناصرها، ثم جدول يجمع الأمرين.
المنافذ الديناميكية
المنافذ الديناميكية منافذ ذات نطاق بأسماء ديناميكية: يُحسب اسم المنفذ أثناء تصيير المكوّن، ولا يُكتب في القالب سلفًا.
وللأمر وجهان: مكوّن يصيّر منافذ بأسماء محسوبة، ومكوّن أب يملأ منفذًا يحسب هو اسمه.
نبدأ بالوجه الأول. يستقبل BasicTable صفوفًا وقائمة أعمدة، ويحيط كل خلية بمنفذ يحمل اسم عمودها:
<script setup lang="ts">
defineProps<{
rows: Record<string, unknown>[]
columns: string[]
}>()
</script>
<template>
<table>
<thead>
<tr>
<th v-for="column in columns" :key="column" scope="col">
<slot :name="`header-${column}`" :column="column">{{ column }}</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in rows" :key="index">
<td v-for="column in columns" :key="column">
<slot :name="`cell-${column}`" :value="row[column]" :row="row">{{ row[column] }}</slot>
</td>
</tr>
</tbody>
</table>
</template>
إذا كانت الأعمدة number و customer و amount و status، وفّر الجدول ثمانية منافذ: header-number و cell-number و header-customer وهكذا. ولكل منفذ محتوى افتراضي، فلا يملأ المكوّن الأب إلا المنافذ التي يريد تغييرها.
أما الوجه الثاني فعند المكوّن الأب. يُكتب اسم المنفذ وسيطًا ديناميكيًا بين قوسين مربّعين بعد v-slot: أو اختصاره #:
<script setup lang="ts">
import { ref } from 'vue'
import BasicTable from './BasicTable.vue'
const columns = ['number', 'customer', 'amount', 'status']
const invoices = [
{ number: 'INV-1042', customer: 'Northwind Traders', amount: 1240, status: 'paid' },
{ number: 'INV-1043', customer: 'Blue Harbor', amount: 860.5, status: 'issued' },
{ number: 'INV-1044', customer: 'Atlas Studio', amount: 2310, status: 'draft' },
]
const highlighted = ref('customer')
</script>
<template>
<select v-model="highlighted">
<option v-for="column in columns" :key="column">{{ column }}</option>
</select>
<BasicTable :rows="invoices" :columns="columns">
<template #[`cell-${highlighted}`]="{ value }">
<mark>{{ value }}</mark>
</template>
</BasicTable>
</template>
النتيجة
| number | customer | amount | status |
|---|---|---|---|
| INV-1042 | Northwind Traders | 1240 | paid |
| INV-1043 | Blue Harbor | 860.5 | issued |
| INV-1044 | Atlas Studio | 2310 | draft |
اختر عمودًا آخر فينتقل التمييز إليه: صار المكوّن الأب يملأ منفذًا مختلفًا.
كل هذا يعمل، لكن وقت التشغيل فقط. أما TypeScript فيرى value من النوع unknown، فلا نستطيع تنسيق مبلغ قبل تحويله. ولا شيء يتحقق من الأسماء: منفذ اسمه cell-amout يُقبل دون اعتراض، ثم لا يُصيَّر أبدًا.
المنافذ العامة
أضاف Vue 3.3 أمرين يحلّان المشكلة. الأول خاصية generic على <script setup>، وبها يأخذ المكوّن معاملات نوع كما تأخذها الدوال العامة. والثاني ماكرو defineSlots، وبه نصرّح بالمنافذ التي يصيّرها المكوّن وبالخاصيات التي يستقبلها كل منفذ.
يستعمل DataList الاثنين. فهو لا يعرف نوع العناصر التي ستُعطى له، فيسمّيه T:
<script setup lang="ts" generic="T">
defineProps<{
items: T[]
}>()
defineSlots<{
item(props: { item: T, index: number }): any
empty?(): any
}>()
</script>
<template>
<ul v-if="items.length">
<li v-for="(item, index) in items" :key="index">
<slot name="item" :item="item" :index="index" />
</li>
</ul>
<slot v-else name="empty" />
</template>
لا يأخذ defineSlots إلا نوعًا. كل مفتاح فيه اسم منفذ، وكل قيمة دالة يصف معاملها الأول خاصيات المنفذ. أما نوع القيمة المُرجَعة فمهمل حاليًا، ويكفي فيه any.
وعند استعمال المكوّن يُستنتج T من الخاصية items:
<script setup lang="ts">
import { computed, ref } from 'vue'
import DataList from './DataList.vue'
interface Invoice {
number: string
customer: string
amount: number
status: 'draft' | 'issued' | 'paid'
}
const invoices: Invoice[] = [/* the same three invoices */]
const query = ref('')
const matches = computed(() =>
invoices.filter(invoice => invoice.customer.toLowerCase().includes(query.value.toLowerCase())),
)
</script>
<template>
<input v-model="query" type="search" placeholder="Filter by customer">
<DataList :items="matches">
<template #item="{ item, index }">
{{ index + 1 }}. {{ item.number }} · {{ item.customer }}
</template>
<template #empty>
<p>No invoice matches.</p>
</template>
</DataList>
</template>
النتيجة
- 1. INV-1042 · Northwind Traders
- 2. INV-1043 · Blue Harbor
- 3. INV-1044 · Atlas Studio
داخل المنفذ #item يكون item من النوع Invoice. يكمل المحرّر حقوله تلقائيًا، وخطأ إملائي مثل item.custmer يصير خطأ في فحص الأنواع، لا سطرًا فارغًا على الشاشة.
وتفصيل يحسن أن نعرفه: في منفذ بسيط كهذا، تستنتج أدوات Vue الأنواع نفسها من القالب ولو لم نكتب defineSlots. لكن التصريح بها يبقى مفيدًا: يُكتب العقد في موضع واحد، وتُعلَّم المنافذ الاختيارية مثل empty بعلامة ?. أما مع الأسماء الديناميكية فلا يكفي الاستنتاج، كما سنرى.
الديناميكية والعامة معًا
نعود إلى الجدول لنعامله بالمثل. وجعله عامًّا هو الجزء السهل:
<script setup lang="ts" generic="T extends object">
defineProps<{
rows: T[]
columns: (keyof T & string)[]
}>()
</script>
لا تقبل columns الآن إلا مفاتيح نوع الصف، ويبقى القالب كما هو. بل إن أدوات Vue تستنتج أسماء المنافذ: فتعرف cell-amount وترفض cell-total. غير أن value يأخذ النوع نفسه في كل المنافذ، وهو اتحاد أنواع الأعمدة كلها (string | number في فواتيرنا)، لأن في القالب وسم <slot> واحدًا يخدم كل الأعمدة.
ولنعطي كل عمود نوعه، نصف المنافذ بنوع يُبنى من مفاتيح T، وهو ما يسمّى mapped type:
<script setup lang="ts" generic="T extends object">
type CellSlots = {
[K in keyof T & string as `cell-${K}`]?: (props: { value: T[K], row: T }) => any
}
type HeaderSlots = {
[K in keyof T & string as `header-${K}`]?: (props: { column: K }) => any
}
defineProps<{
rows: T[]
columns: (keyof T & string)[]
}>()
defineSlots<CellSlots & HeaderSlots>()
const cellSlot = (column: keyof T & string) => `cell-${column}` as keyof CellSlots
const headerSlot = (column: keyof T & string) => `header-${column}` as keyof HeaderSlots
</script>
<template>
<table>
<thead>
<tr>
<th v-for="column in columns" :key="column" scope="col">
<slot :name="headerSlot(column)" :column="column">{{ column }}</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in rows" :key="index">
<td v-for="column in columns" :key="column">
<slot :name="cellSlot(column)" :value="row[column]" :row="row">{{ row[column] }}</slot>
</td>
</tr>
</tbody>
</table>
</template>
يُقرأ النوع الأول هكذا: لكل مفتاح K من مفاتيح T منفذ اختياري اسمه cell- متبوعًا بالمفتاح، ونوع value فيه هو نوع ذلك الحقل، أي T[K]. وعبارة as هي التي تعيد تسمية المفاتيح.
وللدالتين المساعدتين سبب. ما دام T معامل نوع لم يتحدّد بعد، يعجز TypeScript عن إثبات أن `cell-${column}` واحد من مفاتيح CellSlots، فيعترض بأن هذه السلسلة لا تصلح فهرسًا لنوع المنافذ. لذلك نؤكّد الاسم مرة واحدة في السكربت، ويبقى القالب نظيفًا. وهذا شأن داخلي في المكوّن، أما من يستعمله فيحصل على منافذ مفحوصة كاملة:
<script setup lang="ts">
import DataTable from './DataTable.vue'
interface Invoice {
number: string
customer: string
amount: number
status: 'draft' | 'issued' | 'paid'
}
const invoices: Invoice[] = [/* the same three invoices */]
const money = new Intl.NumberFormat('en', { style: 'currency', currency: 'EUR' })
</script>
<template>
<DataTable :rows="invoices" :columns="['number', 'customer', 'amount', 'status']">
<template #cell-amount="{ value }">
{{ money.format(value) }}
</template>
<template #cell-status="{ value }">
<span class="pill" :data-status="value">{{ value }}</span>
</template>
</DataTable>
</template>
النتيجة
| number | customer | amount | status |
|---|---|---|---|
| INV-1042 | Northwind Traders | €1,240.00 | paid |
| INV-1043 | Blue Harbor | €860.50 | issued |
| INV-1044 | Atlas Studio | €2,310.00 | draft |
في #cell-amount يكون value من النوع number، فيُمرَّر مباشرة إلى Intl.NumberFormat. وفي #cell-status يكون 'draft' | 'issued' | 'paid'. ومنفذ لعمود غير موجود خطأ في فحص الأنواع، وكذلك اسم عمود ليس من مفاتيح Invoice.
وتبقى الأسماء الديناميكية متاحة للمكوّن الأب. فإن حُسب الاسم من اتحاد، مثل #[`cell-${key}`] حيث نوع key هو 'number' | 'customer'، أخذ value اتحاد نوعَي العمودين.
وملاحظة عن القيد: كتبنا T extends object لا T extends Record<string, unknown>. فالواجهة (interface) لا تحمل توقيع فهرسة، ومع الصيغة الثانية تُرفض مصفوفة من النوع Invoice[].
خاتمة
بالأسماء الديناميكية يوفّر جدولنا منفذًا لكل عمود. في BasicTable تُقبل الأسماء كسلاسل نصية بلا قيد، وتكون قيم الخلايا من النوع unknown بسبب نوع الصف المصرَّح به. وعندما نجعل الجدول عامًّا، تستنتج أدوات Vue أسماء المنافذ واتحاد أنواع الأعمدة. أما الأنواع المبنية من المفاتيح التي نمرّرها إلى defineSlots فتعطي منفذ كل عمود نوع القيمة الخاص به.
فهرس المصطلحات
| إنجليزية | عربية |
|---|---|
| slot | منفذ |
| scoped slot | منفذ ذو نطاق |
| dynamic slot | منفذ ديناميكي |
| slot props | خاصيات المنفذ |
| generic component | مكوّن عام |
| type parameter | معامل نوع |
| type inference | استنتاج الأنواع |
| union type | نوع اتحادي |
| mapped type | نوع يُبنى من المفاتيح |
| index signature | توقيع فهرسة |
| fallback content | محتوى افتراضي |
| macro | ماكرو |