iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Vue.js

作為 Angular 專家探索 Vue 3 和 Svelte 5系列 第 27

第26天 - 從 AlertBar 中抽取邏輯和元件

  • 分享至 

  • xImage
  •  

在第26天,我檢視了 AlertBar 元件的程式碼,並發現兩項改進可以讓它更乾淨。

  • 該元件包含一個靜態標籤 (static label) 和一個雙向綁定 (Two-way binding) 到 AlertList 元件的選擇元素 (select element),這部分可以抽取成一個 AlertDropdown 元件。

  • AlertListAlertBar 元件都有管理closedNotifications參考狀態的邏輯,這些邏輯和參考可以封裝到一個狀態管理解決方案中。

Framework State Management
Vue Composable
Angular Service
Svelte $state in Store

建立一個AlertDropDown元件

Vue 3 application

<script setup lang="ts">

type Props = {
    label: string
    items: { value: string, text: string }[]
}

const { label, items } = defineProps<Props>()
const selectedValue = defineModel<string>('selectedValue')

</script>
<template>
    <span>{{ label }}&nbsp;&nbsp;</span>
    <select class="select select-info mr-[0.5rem]" v-model="selectedValue">
        <option v-for="{value, text} in items" :key="value" :value="value">
            {{ text }}
        </option>
    </select>
</template>

Props 類型包含一個 label 字串和一個 items 陣列。使用defineModel 來建立一個與父元件綁定的 selectedValue 參考 (ref)。


SvelteKit application

<script lang="ts">
    type Props = {
        label: string;
        items: { text: string, value: string }[];
        selectedValue: string;
    }

    let { label, items, selectedValue = $bindable() }: Props = $props();
</script>
<span>{ label }&nbsp;&nbsp;</span>
<select class="select select-info mr-[0.5rem]" bind:value={selectedValue}>
    {#each items as item (item.value) }
        <option value={item.value}>
            { item.text }
        </option>
    {/each}
</select>

Props 類型包含一個 label 字串、一個 selectedValue 字串以及一個 items 陣列。使用 $bindable 巨集將 selectedValue 綁定到父元件。


Angular 20 application

import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-alert-dropdown',
  imports: [FormsModule],
  template: `
      <span>{{ label() }}&nbsp;&nbsp;</span>
      <select class="select select-info mr-[0.5rem]" [(ngModel)]="selectedValue">
        @for (style of items(); track style.value) {
          <option [ngValue]="style.value">
            {{ style.text }}
          </option>
        }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlertDropdownComponent {
  label = input.required<string>();
  items = input.required<{ text: string, value: string }[]>();
  selectedValue = model.required<string>();
}

labelitems 是必填的訊號。selectedValue 是一個 model 訊號,用於將值與父元件綁定。


Refactor the AlertBar Component

重構 AlertBar 元件以使用 AlertDropdown,以減少8至10行程式碼。

Vue 3 application

使用 AlertDropdown 元件取代HTML的選擇元素和靜態標籤。

<template>
    <p class="mb-[0.75rem]">
        <span>Has close button? </span>
        <input type="checkbox" class="mr-[0.5rem]" v-model="hasCloseButton" />
        <AlertDropdown :label="config.styleLabel" :items="config.styles" v-model:selectedValue="style" />
        <AlertDropdown :label="config.directionLabel" :items="config.directions" v-model:selectedValue="direction" />
    </p>
</template>

對於 style 下拉選單,label 屬性接收config.styleLabelitems 屬性接收config.styles陣列。此外,v-model更新為v-model:selectedValue以綁定到 style ref。

對於方 direction 下拉選單,label 屬性接收config.directionLabelitems 屬性接收config.directions陣列。此外,v-model更新為v-model:selectedValue以綁定到 direction ref。


SvelteKit application

<p class="mb-[0.75rem]">
    <span>Has close button?</span>
    <input type="checkbox" class="mr-[0.5rem]" bind:checked={hasCloseButton} />
    <AlertDropdown label={configs.styleLabel} items={configs.styles} bind:selectedValue={style} />
    <AlertDropdown label={configs.directionLabel} items={configs.directions} bind:selectedValue={direction} />
</p>

對於 style 下拉選單,label 屬性接收 config.styleLabelitems 屬性接收 config.styles 陣列。此外,bind:value 更新為 bind:selectedValue 以綁定到style rune。

對於 direction 下拉選單,label 屬性接收config.directionLabelitems 屬性接收 config.directions陣列。此外,bind:value 更新為 bind:selectedValue 以綁定到direction rune。


Angular 20 application

<p class="mb-[0.75rem]">
    <span>Has close button? </span>
    <input type="checkbox" class="mr-[0.5rem]" [(ngModel)]="hasCloseButton" />
    <app-alert-dropdown [label]="c.styleLabel" [items]="c.styles"  [(selectedValue)]="style" />
    <app-alert-dropdown [label]="c.directionLabel" [items]="c.directions" [(selectedValue)]="direction" />
</p>

對於 style 下拉選單,label 輸入接收 c.styleLabelitems 輸入接收 c.styles 陣列。此外,[(ngModel)] 更新為[(selectedValue)] 以綁定到 style 模型。

對於 direction 下拉選單,label 輸入接收 c.directionLabelitems 輸入接收 c.directions 陣列。此外,[(ngModel)] 更新為 [(selectedValue)] 以綁定到 direction 模型。


抽取 Closed Notifications 的邏輯

AlertListAlertBar 中有一些小函式用於新增、移除、清除與取得已關閉的通知。因此,我想將這些函式抽取到一個新檔案中,並在元件中調用這些函式。

closedNotification 被轉換為可共享的資料,供 AlertListAlertBar 共同存取。

Vue 3 application

composables目錄下建立一個useNotifications可組合函式。

import { readonly, ref } from "vue"

const closedNotifications = ref<string[]>([])

export function useNotifications() {

    function remove(type: string) {
        closedNotifications.value = closedNotifications.value.filter((t) => t !== type)
    }
      
    function removeAll() {
        closedNotifications.value = []
    }
      
    function isNonEmpty() {
        return closedNotifications.value.length > 0
    }

    function add(type: string) {
        closedNotifications.value.push(type)
    }

    return {
        closedNotifications: readonly(closedNotifications),
        remove,
        clearAll,
        isNonEmpty,
        add
    }
}

closedNotifications 是元件之間共享的 ref。

useNotifications composable 包含用於 CRUD 的函式。

Name Purpose
remove remove a notification's type
removeAll remove all notification types
isNonEmpty check the array has a closed notification
add append the type of the closed notification

closedNotification是唯讀的,因此可以防止程式碼中的意外變異。


SvelteKit 應用程式

stores目錄下建立一個新檔案。Svelte 4使用store來維護狀態,但在Svelte 5中runes能夠執行簡單的狀態管理。

const state = $state({
    closedNotifications: [] as string[]
});

const closedNotifications = $derived(() => state.closedNotifications);

export function getClosedNotification() {
    return closedNotifications;
}

export function removeNotification(type: string) {
    state.closedNotifications = state.closedNotifications.filter((t) => t !== type);
}

export function removeAllNotifications() {
    state.closedNotifications = [];
}

export function isNotEmpty() {
    return state.closedNotifications.length > 0
}

export function addNotification(type: string) {
    state.closedNotifications.push(type);
}

Svelte 編譯器在終端機中記錄 "Cannot export state from a module if it is reassigned" 錯誤,因此 state 是一個包含closedNotifications 陣列的物件 rune。否則,我無法在removeNotification 函式中修改 closedNotifications

Name Purpose
removeNotification remove a notification's type
removeAllNotifications remove all notification types
isNonEmpty check the array has a closed notification
addNotification append the type of the closed notification
getClosedNotification return the readonly closedNotification rune

closedNotifications 是一個唯讀的 rune,儲存一個回傳closedNotifications 陣列的函式。

const a = getClosedNotification() 將該 rune 指派給 a。呼叫 a() 會回傳響應 (reactive) 的closedNotifications,用於模板渲染。


Angular 20 應用程式

建立一個 NotificationsService,並稍後將該服務注入到元件中。

import { Injectable, signal } from '@angular/core';

@Injectable({
    providedIn: 'root'
})
export class NotificationsService {
    #closedNotifications = signal<string[]>([]);
    closedNotifications = this.#closedNotifications.asReadonly();

    remove(type: string) {
        this.#closedNotifications.update((prev) => prev.filter((t) => t !== type));
    }

    removeAll() {
        this.#closedNotifications.set([])
    }

    isNonEmpty() {
        return this.#closedNotifications().length > 0;
    }

    add(type: string) {
        this.#closedNotifications.update((prev) => ([...prev, type ]));
    }
}

#closedNotifications 是一個儲存字串陣列的訊號 (signal)。Signal類別的 asReadonly 方法會建立一個唯讀訊號 (readonly signal)。因此,closedNotifications 是一個用於內嵌模板渲染的唯讀訊號。

Name Purpose
remove remove a notification's type
removeAll remove all notification types
isNonEmpty check the array has a closed notification
add append the type of the closed notification

將 Notification 邏輯應用到 AlertBar 元件

Vue 3 application

新的邏輯已應用於 AlertBarAlertList 元件。

AlertBar 元件中,

import { useNotifications } from '@/composables/useNotification'

const { closedNotifications, removeAll, isNonEmpty, remove } = useNotifications()

匯入 useNotifications 並解構函式。接著,刪除先前宣告的closedNotification ref。然後,更新按鈕以呼叫相應函式。

<button v-for="type in closedNotifications"
    :key="type"
    @click="remove(type)"
>
    <OpenIcon />{{ capitalize(type) }}
</button>    

removeNotification替換為remove,且v-for指令不變。

<button
    v-if="isNonEmpty()"
    class="btn btn-primary" 
    @click="removeAll">
    Open all alerts
</button>

clearAllNotifications 替換為 removeAll,並且 v-if 指令會檢查 isNonEmpty 條件。


SvelteKit application

AlertBar 元件中,

import { 
    getClosedNotification, 
    removeNotification, 
    removeAllNotifications, 
    isNotEmpty 
} from './stores/notification.svelte';


 type Props = {
    ... omitted for brevity ...
    style: string;
    direction: string;
}

let { 
    ... omitted for brevity ...
    style = $bindable(), 
    direction = $bindable(), 
}: Props = $props();

const closedNotifications = getClosedNotification();

./stores/notification.svelte 匯入函式。然後,刪除先前宣告的closedNotification參考。更新按鈕以呼叫相應函式。

Props 類型和 $props() 中刪除 closedNotifications

呼叫 getClosedNotification() 並將 rune 指派給 closedNotifications

接著,更新按鈕的函式呼叫。

{#each closedNotifications() as type (type)}
    <button
        class={getBtnClass(type) + ' mr-[0.5rem] btn'}
        onclick={() => removeNotification(type)}
    >
        <OpenIcon />{ capitalize(type) }
    </button>    
{/each}

#each 迴圈迭代 closedNotifications(),因為 closedNotifications rune的結果是一個響應的陣列。

{#if isNotEmpty()}
    <button
        class="btn btn-primary" 
        onclick={removeAllNotifications}>
        Open all alerts
    </button>
{/if}

clearAllNotifications 替換為 removeAllNotifications,並且 if control flow 判斷 isNonEmpty 條件。


Angular 20 application

AlertBarComponent 注入 NotificationService 並存取其方法。

@Component({
  selector: 'app-alert-bar',
  imports: [FormsModule, OpenIconComponent, AlertDropdownComponent],
  template: `...inline template...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlertBarComponent {
  notificationService = inject(NotificationsService);

  ... omit other models ...

  closedNotifications = this.notificationService.closedNotifications;

  capitalize = capitalize;

  remove(type: string) {
    this.notificationService.remove(type);
  }

  removeAll() {
    this.notificationService.removeAll();
  }

  isNonEmpty() {
    return this.notificationService.isNonEmpty();
  }
}

重新命名元件內的方法,並在方法內呼叫服務的方法。 closedNotification 為服務中的唯讀訊號 (readonly signal)。

@for (type of closedNotifications(); track type) {
    <button (click)="remove(type)">
         <app-open-icon />{{ capitalize(type) }}
    </button>
}

點擊事件在重新命名後呼叫remove

@if (isNonEmpty()) { 
    <button (click)="removeAll()">
      Open all alerts
    </button>
}

@if control flow 判斷 isNonEmpty 條件。點擊事件在重新命名後呼叫removeAll


將 Notification 邏輯應用到 AlertList 元件

Vue 3 application

AlertList 元件中,

import { useNotifications } from '@/composables/useNotification'

const { closedNotifications, add } = useNotifications()

const alerts = computed(() => props.alerts.filter((alert) => 
  !closedNotifications.value.includes(alert.type))
)

匯入 userNotifications composable 並解構其函式。
closedNotification ref 替換為 composable 的 ref。

alerts 計算型參考 (computed ref) 使用新的 closedNotification ref,衍生當前開啟的通知。

<Alert v-for="{ type, message } in alerts"
    :key="type"
    :type="type"
    :alertConfig="alertConfig"
    @closed="add">
    {{  message }}
</Alert>

Alert 元件發出自訂 closed 事件時,add 函式會將 type 新增到響應式陣列中。模板不會渲染已關閉的通知。


SvelteKit application

import { addNotification, getClosedNotification } from './stores/notification.svelte';

const closedNotifications  = getClosedNotification();
let filteredNotifications = $derived.by(() => 
    alerts.filter(alert => !closedNotifications().includes(alert.type))
);

getClosedNotification 的結果指派給 closedNotifications

filteredNotification rune 呼叫 closedNotification() 取得可響應陣列,並在回呼函式 (callback function) 中執行過濾,最終結果為目前開啟的通知。

{#each filteredNotifications as alert (alert.type) } 
    <Alert {alert} {alertMessage} notifyClosed={() => addNotification(alert.type)} {alertConfig} />
{/each}

notifyClosed 屬性呼叫 addNotification 函式,將 type 附加到可反應陣列中。模板不會渲染已關閉的通知。

Angula 20 application

AlertListComponent 中注入 NotificationService

@Component({
  selector: 'app-alert-list',
  imports: [AlertComponent, AlertBarComponent],
  template: `...inline template...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlertListComponent {
  notificationService = inject(NotificationsService);
  
  ... omitted for brevity ...
    
  filteredAlerts = computed(() => 
    this.alerts().filter(alert => 
      !this.notificationService.closedNotifications().includes(alert.type))
  ); 

  add(type: string) {
    this.notificationService.add(type);
  }
}

filteredAlerts 計算型訊號 (computed signal) 使用服務的closedNotifications 來衍生目前開啟的通知。

add 方法會將 type 加入服務的響應式陣列中。

@for (alert of filteredAlerts(); track alert.type) {
  <app-alert [type]="alert.type" 
    [alertConfig]="alertConfig()"
    (closeNotification)="add($event)">
    {{ alert.message }}
  </app-alert>
}

當自訂的closeNotification 事件發出 type 時,add 方法會將其加入響應式陣列。模板不會渲染已關閉的通知。


Github Repositories

Github Pages

資源


上一篇
第 25 天 - Alert 元件第 4 部分 - 更新 Alert Bar 以重新開啟已關閉的 Alert
下一篇
第27天 - 建立一個簡單的部落格頁面
系列文
作為 Angular 專家探索 Vue 3 和 Svelte 530
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言