iT邦幫忙

2026 iThome 鐵人賽

DAY 29
1
Modern Web

Angular 22 Signal 進化論系列 第 29 篇

Day 29:Angular 也有 VueUse?使用 Signality 整合常見 Browser API

  • 分享至 

  • xImage
  •  

在 Vue 生態系中,VueUse 提供許多 Composables,讓我們可以快速使用瀏覽器功能,例如監聽網路狀態、保存使用者設定,或偵測元素是否進入可視範圍。

其實 Angular 也有類似的工具,就是 Signality。

Signality 是一套以 Angular Signal 為核心的工具函式庫,將常見的 Browser API 與 DOM Observer 封裝起來,減少自行處理事件監聽、狀態更新與生命週期清理的程式碼。

今天挑選四個比較實用的功能:

  • Network:監聽網路連線狀態。
  • Storage:將 Signal 資料保存到瀏覽器。
  • IntersectionObserver:偵測元素是否進入可視範圍。
  • BroadcastChannel:讓同源分頁之間可以互相傳遞訊息。

Network 網路連線狀態

使用者操作系統時,可能會遇到 Wi-Fi 中斷或網路不穩的情況,導致資料無法正常送出。

一般可以透過 navigator.onLine 取得連線狀態,再監聽 online、offline 事件。如果要搭配 Angular Signal,還需要自行更新狀態,並在 Component 銷毀時移除事件監聽。

network.component.ts

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

@Component({
  selector: 'app-network',
  template: `
    @if (!isOnline()) {
      <p>目前沒有網路連線,請檢查網路狀態</p>
    }
  `
})
export class NetworkComponent implements OnDestroy {
  readonly isOnline = signal(navigator.onLine);

  private readonly updateStatus = () => {
    this.isOnline.set(navigator.onLine);
  };

  constructor() {
    window.addEventListener('online', this.updateStatus);
    window.addEventListener('offline', this.updateStatus);
  }

  ngOnDestroy() {
    window.removeEventListener('online', this.updateStatus);
    window.removeEventListener('offline', this.updateStatus);
  }
}

改用 Signality 的 network() 後,就不需要自行處理事件監聽與清理:

network.component.ts

import { Component } from '@angular/core';
import { network } from '@signality/core';

@Component({
  selector: 'app-network',
  template: `
    @if (!net.isOnline()) {
      <p>目前沒有網路連線,請檢查網路狀態</p>
    }
  `
})
export class NetworkComponent {
  readonly net = network();
}

network() 會回傳多個 Signal,其中 isOnline() 代表瀏覽器目前的連線狀態。當網路中斷或恢復時,畫面就會跟著更新。

除了判斷是否連線,還可以取得其他網路資訊:

net.effectiveType(); // 連線類型,例如 3g、4g
net.downlink();      // 預估下載速度 Mbps
net.rtt();           // 預估往返延遲 ms
net.saveData();      // 是否啟用節省流量

不過,isOnline() 不代表後端 API 一定能正常連線,只能反映瀏覽器回報的連線狀態。

另外,effectiveType()、downlink() 等資訊依賴 Network Information API,目前主要由 Chromium 系列瀏覽器支援。可以透過 net.isSupported() 判斷是否支援,再決定是否使用這些資訊。

Storage 瀏覽器資料保存

開發網站時,經常需要保存一些資料,例如深淺色主題、購物車內容,或使用者還沒送出的表單。

通常會使用 localStorage 或 sessionStorage:

localStorage.setItem('name', 'Antonio');

const name = localStorage.getItem('name');

不過,Storage API 只能儲存字串。如果要保存物件,還得自行處理 JSON.stringify() 與 JSON.parse(),而且資料更新後也不會自動通知 Angular Signal。

Signality 提供的 storage() 會回傳 WritableSignal,可以直接使用 set()、update() 更新資料,並同步保存到瀏覽器 Storage。

例如,使用者正在填寫表單,希望重新整理頁面後,還能保留尚未送出的內容。

draft.component.ts

import { Component } from '@angular/core';
import { storage } from '@signality/core';

@Component({
  selector: 'app-draft',
  template: `
    <textarea
      [value]="draft()"
      (input)="draft.set($any($event.target).value)"
      placeholder="請輸入內容..."
    ></textarea>

    <button (click)="draft.set('')">
      清空草稿
    </button>
  `
})
export class DraftComponent {
  readonly draft = storage('form-draft', '', {
    type: 'session'
  });
}

storage() 的第一個參數是 Storage Key,第二個參數是預設值。這裡使用 type: 'session',將資料保存在 sessionStorage,所以即使重新整理頁面,草稿也不會消失。

如果希望關閉分頁後仍然保留資料,可以改用預設的 localStorage:

readonly draft = storage('form-draft', '');

兩者的差異如下:

localStorage sessionStorage
保存時間 持續保存,直到被清除 分頁工作階段結束時清除
重新整理 保留 保留
不同分頁 同源分頁可共用 通常各分頁獨立

storage() 也能保存物件或陣列,不需要自行處理 JSON 轉換:

readonly settings = storage('settings', {
  theme: 'light',
  fontSize: 16
});

changeTheme() {
  this.settings.update(value => ({
    ...value,
    theme: 'dark'
  }));
}

這裡透過 update() 取得目前的設定,再使用展開運算子建立新物件,只修改 theme,其他屬性則保留原本的值。

要注意,Storage 裡的資料可以透過 DevTools 查看或修改,因此不適合保存密碼或其他敏感資訊。

IntersectionObserver 圖片延遲載入

製作圖片延遲載入、無限捲動或進場動畫時,我們經常需要知道某個元素是否進入畫面。

瀏覽器原生提供 IntersectionObserver,可以觀察元素與可視範圍的交集狀態。Signality 則提供 intersectionObserver(),讓我們可以搭配 Angular 的 viewChild() 使用。

以圖片延遲載入為例,HTML 本身就提供了 loading="lazy":

<img
  src="/images/photo.jpg"
  loading="lazy"
  alt="風景照片"
/>

設定後,瀏覽器會自行決定什麼時候下載圖片,不一定要等到圖片真正出現在畫面上。

如果希望更精確地控制載入時機,例如當圖片距離可視範圍 200px 時才開始載入,就可以使用 intersectionObserver()。另外,也可以先顯示 Placeholder,避免圖片載入前出現空白區塊。

lazy-image.component.ts

import {
  Component,
  ElementRef,
  signal,
  viewChild
} from '@angular/core';

import { intersectionObserver } from '@signality/core';

@Component({
  selector: 'app-lazy-image',
  template: `
    <div class="spacer">往下捲動</div>

    <div #container class="image-container">
      @if (shouldLoad()) {
        <img
          src="/images/photo.jpg"
          width="800"
          height="500"
          alt="風景照片"
          [class.loaded]="isLoaded()"
          (load)="isLoaded.set(true)"
        />
      }
    </div>
  `,
  styles: [`
    .spacer {
      height: 100vh;
    }

    .image-container {
      width: 100%;
      aspect-ratio: 8 / 5;
      background: #e5e7eb;
      overflow: hidden;
    }

    img {
      display: block;
      width: 100%;
      height: 100%;
      object-fit: cover;
      opacity: 0;
      transition: opacity 0.3s ease;
    }

    img.loaded {
      opacity: 1;
    }
  `]
})
export class LazyImageComponent {
  readonly container = viewChild<ElementRef>('container');

  readonly shouldLoad = signal(false);
  readonly isLoaded = signal(false);

  readonly observer = intersectionObserver(
    this.container,
    ([entry]) => {
      if (entry?.isIntersecting) {
        this.shouldLoad.set(true);
      }
    },
    {
      rootMargin: '200px 0px',
      threshold: 0
    }
  );
}

這裡先透過 viewChild() 取得 #container,再交給 intersectionObserver() 觀察。

第二個參數是 Callback,當元素與觀察範圍的交集狀態符合觸發條件時,就會收到 entries 陣列,每筆資料都是一個 IntersectionObserverEntry。因為目前只觀察一個元素,所以使用 [entry] 直接取得第一筆結果。

另外,這次設定了兩個參數:

  • rootMargin: '200px 0px':將上下觀察範圍向外延伸 200px,讓圖片在進入畫面前就能觸發載入。
  • threshold: 0:以元素是否進入或離開觀察範圍作為觸發條件,再透過 isIntersecting 判斷目前是否相交。

當 entry.isIntersecting 為 true,就將 shouldLoad 設為 true,讓 Angular 透過 @if 建立 <img> 元素,瀏覽器便能開始下載圖片。

圖片還沒載入時,.image-container 會先顯示灰色背景作為 Placeholder。等圖片下載完成並觸發 (load) 事件後,isLoaded 就會變成 true,透過 CSS 的 opacity 讓圖片淡入。

這裡沒有再加上 loading="lazy",因為我們已經透過 IntersectionObserver 控制圖片建立時機。如果兩者一起使用,圖片建立後仍可能被瀏覽器延後下載,反而無法明確控制開始載入的時間。

另外,shouldLoad 一旦變成 true 就不會再恢復 false,避免使用者捲動離開後移除已載入的圖片。Signality 也會在 Component 銷毀時自動停止觀察,不需要手動呼叫 disconnect()。

BroadcastChannel 跨分頁通訊

Angular Service 或 Signal 可以在同一個 Application 中共用狀態,但如果使用者同時開啟多個分頁,每個分頁都會建立自己的 Angular Application,狀態不會自動同步。

例如,使用者在其中一個分頁修改了訂單資料,希望其他分頁也能收到更新通知,就可以使用瀏覽器提供的 BroadcastChannel API。

BroadcastChannel 可以讓同源分頁透過指定頻道互相傳遞訊息,不需要透過後端轉送。不過,實際能否通訊也會受到瀏覽器儲存分區的限制。

Signality 提供 broadcastChannel(),讓我們可以透過 Signal 取得其他分頁發送的訊息。

order-notification.component.ts

import { Component } from '@angular/core';
import { broadcastChannel } from '@signality/core';

type OrderEvent = {
  type: 'updated';
  orderId: string;
};

@Component({
  selector: 'app-order-notification',
  template: `
    <button (click)="simulateUpdate()">
      模擬更新訂單
    </button>

    @if (channel.data(); as event) {
      <p>
        訂單 {{ event.orderId }} 已在其他分頁更新
      </p>
    }
  `
})
export class OrderNotificationComponent {
  readonly channel =
    broadcastChannel<OrderEvent>('order-events');

  simulateUpdate() {
    this.channel.post({
      type: 'updated',
      orderId: 'ORD-001'
    });
  }
}

broadcastChannel<OrderEvent>('order-events') 建立一個名為 order-events 的通訊頻道,並指定傳遞的訊息型別。

範例使用兩個功能:

  • channel.post():向同一頻道的其他分頁發送訊息。
  • channel.data():透過 Signal 取得最後一次收到的訊息。

可以在瀏覽器開啟兩個相同網址的分頁,接著在其中一個分頁點擊「模擬更新訂單」,另一個分頁就會收到訊息,並顯示訂單更新提示。

這裡只是模擬訂單更新事件。實際系統通常會在資料成功更新後才呼叫 post(),其他分頁收到通知後,再決定是否重新取得最新資料。

需要注意,BroadcastChannel 不會將訊息傳回發送者自己的 Channel 物件,因此發送訊息的分頁不會收到自己的更新通知。

另外,BroadcastChannel 不會保存先前發送的訊息。如果分頁重新整理或稍後才開啟,就不會收到過去的通知。這類資料仍然需要從後端重新取得。

除了訂單更新,BroadcastChannel 也可以用於跨分頁登出、通知已讀狀態同步等情境,特別適合處理同源分頁之間的即時事件通知。

本日小結

今天介紹了 Signality 的四個功能,包含網路狀態、瀏覽器資料保存、元素觀察與跨分頁通訊,這些都是平常開發時可能遇到的需求。

Signality 將常見的 Browser API 與 Angular Signal 整合,讓我們能直接使用響應式資料,也減少自行管理事件監聽與生命週期的程式碼。不過,實際使用時仍要留意瀏覽器相容性,以及各個 API 本身的限制。

除了這四個功能,Signality 還提供 Clipboard、ResizeObserver、PageVisibility 等工具。有興趣可以到 Signality 官方文件 看看其他功能,再依照專案需求決定是否使用。

資料來源


上一篇
Day 28:Angular 動態建立 Component,NgComponentOutlet 與 ViewContainerRef
下一篇
Day 30:Angular Router Providers 與生命週期管理,路由離開後 Service 會銷毀嗎?
系列文
Angular 22 Signal 進化論 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言