Template-driven Form 與 LocalStorage 整合

實現表單值的暫存功能,讓使用者重整頁面後仍能保留先前的輸入。

基本原理

  1. 監聽表單值變化,將值存入 LocalStorage

  2. 頁面載入時,從 LocalStorage 讀取值並設置回表單

  3. 考慮表單初始化的時機問題

實作步驟

1. 使用 viewChild 獲取表單參考

首先,使用 viewChild 獲取範本中的表單參考:

private form = viewChild.required<NgForm>('form');
private destroyRef = inject(DestroyRef);

這裡我們找到模板中 #form="ngForm" 這個範本變數,並標註它是 NgForm 類型。

2. 監聽表單值變化並存入 LocalStorage

在 constructor 中,我們使用 afterNextRender 鉤子確保表單已完全初始化:

constructor() {
  afterNextRender(() => {
    // 監聽表單值變化
    const subscription = this.form()
      .valueChanges?.pipe(debounceTime(500))
      .subscribe({
        next: (value) =>
          window.localStorage.setItem(
            'saved-login-form',
            JSON.stringify({ email: value.email })
          ),
      });
    
    // 記得取消訂閱
    this.destroyRef.onDestroy(() => {
      subscription?.unsubscribe();
    });
  });
}

使用 afterNextRender 是因為這個表單是用 Template-driven 方法,表單控制項是在範本渲染後才被建立,所以必須等到範本完全初始化後才能操作表單。

我們使用 valueChanges Observable 來監聽表單值的變化,並加入 debounceTime(500) 防抖機制,讓使用者停止輸入 500ms 後才將值存入 LocalStorage。

沒有用防抖直接印出來

3. 載入時從 LocalStorage 恢復表單值

同樣在 afterNextRender 鉤子中,我們從 LocalStorage 讀取之前儲存的值:

afterNextRender(() => {
  const savedForm = window.localStorage.getItem('saved-login-form');

  if (savedForm) {
    try {
      const loadedFormData = JSON.parse(savedForm);
      const savedEmail = loadedFormData.email;
      
      // 使用 setTimeout 確保表單控制項已完全初始化
      setTimeout(() => {
        this.form().controls['email'].setValue(savedEmail);
      }, 50);
    } catch (error) {
      console.error('Error parsing saved form data:', error);
    }
  }
  
  // 接著是監聽表單值變化的程式碼...
});

為什麼需要使用 setTimeout?這是因為即使在 afterNextRender 中,表單控制項可能仍未完全註冊到表單,直接使用 this.form().controls['email'] 可能會導致錯誤:

ERROR RuntimeError: NG01000: There are no form controls registered with this group yet.
If you're using ngModel, you may want to check next tick (e.g. use setTimeout).

透過 setTimeout 將操作推遲到下一個事件循環,我們可以確保表單控制項已完全註冊。

4. 設置表單值的不同方法

有兩種方式可以設置表單值:

  1. 設置整個表單的值

this.form().setValue({
  email: savedEmail,
  password: '', // 必須提供所有欄位
});

使用 setValue() 時必須提供表單中所有控制項的值,否則會出錯。

  1. 設置單個控制項的值

this.form().controls['email'].setValue(savedEmail);

只設置特定控制項的值,其他控制項不受影響。

  1. 部分更新表單值

this.form().form.patchValue({
  email: savedEmail
  // 不需要提供 password
});

使用 patchValue() 可以只更新指定的欄位,其他欄位保持不變。

完整範例

HTML 模板:

<form #form="ngForm" (ngSubmit)="onSubmit(form)">
  <h2>Login</h2>
  <div class="control-row">
    <div class="control no-margin">
      <label for="email">Email</label>
      <input
        id="email"
        type="email"
        name="email"
        ngModel
        required
        email
        #emailCtrl="ngModel"
      />
    </div>
    <div class="control no-margin">
      <label for="password">Password</label>
      <input
        id="password"
        type="password"
        name="password"
        ngModel
        required
        minlength="6"
        #passwordCtrl="ngModel"
      />
    </div>
    <button class="button">Login</button>
  </div>
  @if (emailCtrl.touched && emailCtrl.dirty && emailCtrl.invalid) {
    <p class="control-error">Invalid email address entered.</p>
  } 
  @if (passwordCtrl.touched && passwordCtrl.dirty && passwordCtrl.invalid) {
    <p class="control-error">
      Invalid password entered - must be at least 6 characters long.
    </p>
  }
</form>

元件程式碼:

import {
  afterNextRender,
  Component,
  DestroyRef,
  inject,
  viewChild,
} from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { debounceTime } from 'rxjs';

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './login.component.html',
  styleUrl: './login.component.css',
})
export class LoginComponent {
  private form = viewChild.required<NgForm>('form');
  private destroyRef = inject(DestroyRef);

  constructor() {
    afterNextRender(() => {
      // 從 LocalStorage 讀取先前儲存的表單值
      const savedForm = window.localStorage.getItem('saved-login-form');

      if (savedForm) {
        try {
          const loadedFormData = JSON.parse(savedForm);
          const savedEmail = loadedFormData.email;
          setTimeout(() => {
            // 設置表單控制項的值
            this.form().controls['email'].setValue(savedEmail);
          }, 50);
        } catch (error) {
          console.error('Error parsing saved form data:', error);
        }
      }

      // 監聽表單值變化並儲存到 LocalStorage
      const subscription = this.form()
        .valueChanges?.pipe(debounceTime(500))
        .subscribe({
          next: (value) =>
            window.localStorage.setItem(
              'saved-login-form',
              JSON.stringify({ email: value.email })
            ),
        });
        
      // 元件銷毀時取消訂閱
      this.destroyRef.onDestroy(() => {
        subscription?.unsubscribe();
      });
    });
  }

  onSubmit(formData: NgForm) {
    if (formData.form.invalid) {
      return;
    }

    const enteredEmail = formData.form.value.email;
    const enteredPassword = formData.form.value.password;
    console.log(enteredEmail, enteredPassword);

    formData.form.reset();
  }
}

注意事項

  1. 時機問題:在表單完全初始化前設置值會導致錯誤,所以需要使用 afterNextRendersetTimeout

  2. 錯誤處理:對 JSON 解析等操作應加入錯誤處理

  3. 防抖處理:使用 debounceTime 減少不必要的 LocalStorage 寫入操作

  4. 取消訂閱:使用 DestroyRef.onDestroy 確保訂閱被正確取消,避免記憶體洩漏

  5. 表單重置:在表單提交後使用 formData.form.reset() 重置表單,這也會觸發 valueChanges,可能會將空值寫入 LocalStorage

Last updated