发布于 2026-01-06 7 阅读
0

由 Mux 主办的 Signals DEV 全球展示挑战赛:展示你的项目!

使用信号同步 Web 存储

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

在 JavaScript 中使用本地存储既简单又有时令人沮丧。虽然Web StorageAPI非常直观,但它缺少一个在现代 Angular 应用中比以往任何时候都更加重要的关键特性:响应式。

幸运的是,最新版本提供了许多工具,让我们能够创建一个方便的实用函数,根据本地存储的值创建一个响应式信号!

在本文中,我们将了解如何在专用的 Angular 服务中抽象出 Web 存储,以及如何利用该服务通过信号同步其值,以实现以下结果:

最终结果 GIF

本文所有代码都托管在 GitHub 上,欢迎查看!

抽象化 Web 存储

在实际构建信号逻辑之前,我们首先需要抽象出原生 Web Storage API。

这将使我们能够选择要操作的内容、操作方式,以及在使用何种存储方式方面拥有更大的灵活性(更不用说更容易模拟以进行测试)。

动态存储类型

我们首先要做的是为要使用的存储类型定义一个注入令牌:

// 📂 storage.service.ts
export const STORAGE = new InjectionToken<Storage>(
  'Web Storage Injection Token'
);
Enter fullscreen mode Exit fullscreen mode

从现在起,我们可以为我们的 Angular 应用程序提供所需的StoragelocalStorage,,sessionStorage等等):

// 📂 main.ts
bootstrapApplication(AppComponent, {
  providers: [{ provide: STORAGE, useValue: localStorage }],
}).catch((err) => console.error(err));
Enter fullscreen mode Exit fullscreen mode

创建StorageService

由于Storage注入容器中现在已知该类型,我们可以在 Angular 服务中使用它来管理读写操作,同时还能强制执行类型安全(在一定限制内):

// 📂 storage.service.ts
@Injectable({ providedIn: 'root' })
export class StorageService {
  readonly #storage = inject(STORAGE);

  getItem<T>(key: string): T | null {
    const raw = this.#storage.getItem(key);
    return raw === null
      ? null
      : JSON.parse(raw) as T;
  }

  setItem<T>(key: string, value: T | null): void {
    const stringified = JSON.stringify(value);
    this.#storage.setItem(key, stringified);
  }
}
Enter fullscreen mode Exit fullscreen mode

现在基本组成部分已经准备就绪,我们可以开始处理实际信号了!

利用信号

信号使用起来非常简单,而且也很容易进行封装和扩展

在深入探讨读写同步之前,我们先来创建实用函数:

// 📂 from-storage.function.ts
export const fromStorage = <TValue>(storageKey: string): WritableSignal<TValue | null> => {
  const storage = inject(StorageService);

  const initialValue = storage.getItem<TValue>(storageKey);
  return signal<TValue | null>(initialValue);
}
Enter fullscreen mode Exit fullscreen mode

目前,这只会signal根据注入的值(或其缺失)创建一个Storage

调用后fromStorage,我们现在可以跟踪特定的键及其(键入的)值:

// 📂 app.component.ts
type ColorScheme = 'light' | 'dark';

@Component({ /*...*/ })
export class AppComponent {
  readonly preferredTheme = fromStorage<ColorScheme>('preferred-theme');
}
Enter fullscreen mode Exit fullscreen mode

这看起来很有希望,但在两个主要方面,我们仍然缺乏所需的反应能力:

  • 更新值时,我们也应该更新存储的值。
  • 当此键的存储发生任何更新时,我们也应该更新其值。

让我们来解决这些问题!

同步写入

更新值时,Storage同时更新signal值本身是最简单的部分。

这样,effect我们只需调用StorageService.setItem该方法即可写入更新后的值,因为它会在值实际更改时(或与定义的相等性匹配时)被调用:

// 📂 from-storage.function.ts
export const fromStorage = <TValue>(storageKey: string): WritableSignal<TValue | null> => {
  // ...
  const fromStorageSignal = signal<TValue | null>(initialValue);

  const writeToStorageOnUpdateEffect = effect(() => {
    const updated = fromStorageSignal();
    untracked(() => storage.setItem(storageKey, updated));
  });

  return fromStorageSignal;
}
Enter fullscreen mode Exit fullscreen mode

一个问题解决了!

Syncinc 读取:充分利用 Web 存储 API

主要问题在于,更新可能发生在两种我们无法始终控制的情况下:

  • 另一段代码更新了存储的值。
  • 另一个标签页更新了相同的值

我们需要一种方法来检测某些变化,这些变化可能发生在我们的应用程序之外。

使用setTimeout

我们首先想到的解决方案是使用setTimeout

虽然可行,但如果选择较小的轮询机制,要么会在更新过程中引入较长的延迟(更不用说监视多个值会引入大量的轮询系统了)。

这样的系统可以这样实现:

// 📂 from-storage.function.ts
export const fromStorage = <TValue>(storageKey: string): WritableSignal<TValue | null> => {
  // ...

  const updateSignalOnSignalWriteEffect = effect((onCleanup) => {
    const intervalId = setInterval(() => {
      const newValue = storage.getItem<TValue>(key);
      const currentValue = fromStorageSignal();

      const hasValueChanged = newValue !== currentValue;
      if (hasValueChanged) fromStorageSignal.set(newValue);
    }, 150)

    onCleanup(() => clearInterval(intervalId));
  });

  return fromStorageSignal;
}
Enter fullscreen mode Exit fullscreen mode

🚨 在仍然使用 zonejs 进行变更检测的应用程序中,您应该在 zone 之外运行此代码,以避免触发不必要的变更检测:

inject(NgZone).runOutsideAngular(() =>  /*...*/ );

使用存储事件

如果我们不用轮询机制,而是对变更做出反应呢?幸运的是,Web Storage API 定义了一个存储事件storage.onstorage,我们可以使用 `getStorageEvent`或 ` getStorageEvent`来监听该storage事件,它会告诉我们存储中的某些内容Storage已被修改,修改的是哪个键,以及其他一些信息。

听起来不错,我们就用这个吧:

// 📂 from-storage.function.ts
export const fromStorage = <TValue>(storageKey: string): WritableSignal<TValue | null> => {
  // ...

  const storageEventListener = (event: StorageEvent) => {
    const isWatchedValueTargeted = event.key === storageKey;
    if (!isWatchedValueTargeted) {
      return;
    }

    const currentValue = fromStorageSignal();
    const newValue = storage.getItem<TValue>(storageKey);

    const hasValueChanged = newValue !== currentValue;
    if (hasValueChanged) {
      fromStorageSignal.set(newValue);
    };
  }

  window.addEventListener('storage', storageEventListener);

  // 👇 Don't forget to clean up after yourself
  inject(DestroyRef).onDestroy(() => {
    window.removeEventListener('storage', storageEventListener);
  });

  return fromStorageSignal;
}
Enter fullscreen mode Exit fullscreen mode

太好了!我们来试试:

// 📂 app.component.ts
@Component({ /*...*/ })
export class AppComponent {
  readonly preferredTheme1 = fromStorage<ColorScheme>('preferred-theme');
  togglePreferredTheme(): void {
    this.preferredTheme1.update(current => current === 'light' ? 'dark' : 'light');
  }

  readonly preferredTheme2 = fromStorage<ColorScheme>('preferred-theme');
  setLightTheme(): void {
    this.preferredTheme2.set('light');
  }
}
Enter fullscreen mode Exit fullscreen mode

调用后setLightTheme没有更新preferredTheme1,也togglePreferredTheme没有任何效果preferredTheme2!我以为我们刚刚解决了这个问题?

解释其实在MDN 文档页面上:

注意:这在进行更改的同一浏览环境中无效(……)

这意味着我们自己更新值不会触发我们自己标签页中的事件。我们离实现响应式值就差一步之遥了!

幸运的是,我们StorageEvent可以自行创建该事件,并且我们有自己的服务来与之交互storage,这意味着我们可以在编写代码时自行触发该事件:

// 📂 storage.service.ts
@Injectable({ providedIn: 'root' })
export class StorageService {
  readonly #storage = inject(STORAGE);

  getItem<T>(key: string): T | null { /*...*/ }

  setItem<T>(key: string, value: T | null): void {
    const stringified = JSON.stringify(value);
    this.#storage.setItem(key, stringified);

    // 👇 Notify of the update
    const storageEvent = new StorageEvent('storage', {
      key: key,
      newValue: stringified,
      storageArea: this.#storage,
    });

    window.dispatchEvent(storageEvent);
  }
}
Enter fullscreen mode Exit fullscreen mode

🚨 请注意,这可能会为其他选项卡重复事件,因此需要在事件处理程序中检查值是否已更改,以避免任何问题。

如果我们再次尝试调用togglePreferredTheme`or` setLightTheme,我们可以看到这两个信号现在确实都更新了,并且 `中的值也更新了Storage。我们成功了!

总结

本文Storage对 Web Storage API 及其交互进行了抽象,以便更好地控制其使用。然后,我们引入了一种方法,可以根据键创建信号来监视 Web Storage 中的值Storage,从而实现信号与 Web Storage 之间的同步:

最终结果 GIF

如果您想亲自尝试这段代码,请查看GitHub 上的代码


希望你学到了有用的东西!

图片来自Unsplash用户CHUTTERSNAP

文章来源:https://dev.to/this-is-angular/synchronized-web-storage-with-signals-5b05