async-preact-signals

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Async Preact Signals

异步Preact Signals

When working with signals in Javascript, it is very common to work with async data from Promises.
在Javascript中使用signals时,处理来自Promises的异步数据是非常常见的场景。

Async vs Sync

异步 vs 同步

But unlike other state management libraries, signals do not have an asynchronous state graph and all values must be computed synchronously.
When people first start using signals they want to simply add async to the function callback but this breaks how they work under the hood and leads to undefined behavior. ☹️
Async functions are a leaky abstraction and force you to handle them all the way up the graph. Async is also not always better and can have a performance impact. 😬
但与其他状态管理库不同,Signals没有异步状态图,所有值必须同步计算。
当人们刚开始使用Signals时,往往会想直接给函数回调添加async关键字,但这会破坏其底层工作机制,导致出现未定义的行为。☹️
异步函数是一种有漏洞的抽象,会迫使你在整个状态图中层层处理异步逻辑。而且异步并不总是更优选择,还可能带来性能影响。😬

Working with Promises

处理Promises

We can still do so much with sync operations, and make it eaiser to work with common async patterns.
For example when you make a http request using fetch, you want to return the data in the Promise and update some UI.
const el = document.querySelector('#output');
let postId = '123';
fetch(`/posts/${postId}`).then(res => res.json()).then(post => {
    el.innerText = post.title;
})
Now when we add signals we can rerun the fetch everytime the post id changes.
import { effect, signal } from "@preact/signals-core";

const el = document.querySelector('#output');
const postId =  signal( '123');

effect(() => {
    fetch(`/posts/${postId.value}`).then(res => res.json()).then(post => {
        el.innerText = post.title;
    });
});
This is better, but now we need to handle stopping the previous request if the post id changes before the previous fetch completes.
import { effect, signal } from "@preact/signals-core";

const el = document.querySelector('#output');
const postId =  signal( '123');
let controller;

effect(() => {
    if (controller) {
         controller.abort();
    }
    controller = new AbortController();
    const signal = controller.signal;
    try {
       fetch(`/posts/${postId.value}`, { signal }).then(res => res.json()).then(post => {
            el.innerText = post.title;
        }); 
    } catch (err) {
       // todo: show error message
    }
});
But this still skips a lot of things we normally want to show like loading states and error states.
import { effect, signal, batch } from "@preact/signals-core";

const el = document.querySelector('#output');
const postId =  signal( '123');
const postData = signal({});
const errorMessage = signal('');
const loading = signal(false);
let controller;

effect(() => {
    if (controller) {
         controller.abort();
    }
    controller = new AbortController();
    const signal = controller.signal;
    batch(() => {
       loading.value = true;
       errorMessage.value = '';
       postData.value = {};
    });
    try {
       fetch(`/posts/${postId.value}`, { signal }).then(res => res.json()).then(post => {
            batch(() => {
                 postData.value = post;
                 loading.value = false;
             });
        }); 
    } catch (err) {
        errorMessage.value = err.message;
    }
});
effect(() =>  {
    if (loading.value) {
        el.innerText = 'Loading...';
    } else if (errorMessage.value) {
        el.innerText = `Error: ${errorMessage.value}`;
    } else {
        el.innerText = postData.value.title;
    }
});
Now we can show the proper states, but this is only for one request...
We could wrap this up in a class to reuse or create a new type of signal that can work with asynchronous data.
我们仍然可以通过同步操作完成很多工作,同时简化常见异步模式的处理流程。
例如,当你使用fetch发起HTTP请求时,你希望从Promise中返回数据并更新UI。
const el = document.querySelector('#output');
let postId = '123';
fetch(`/posts/${postId}`).then(res => res.json()).then(post => {
    el.innerText = post.title;
})
现在我们引入Signals,这样每次post id变化时都会重新发起请求。
import { effect, signal } from "@preact/signals-core";

const el = document.querySelector('#output');
const postId =  signal( '123');

effect(() => {
    fetch(`/posts/${postId.value}`).then(res => res.json()).then(post => {
        el.innerText = post.title;
    });
});
这样有所改善,但现在我们需要处理一种情况:如果在之前的请求完成前post id发生变化,我们需要终止之前的请求。
import { effect, signal } from "@preact/signals-core";

const el = document.querySelector('#output');
const postId =  signal( '123');
let controller;

effect(() => {
    if (controller) {
         controller.abort();
    }
    controller = new AbortController();
    const signal = controller.signal;
    try {
       fetch(`/posts/${postId.value}`, { signal }).then(res => res.json()).then(post => {
            el.innerText = post.title;
        }); 
    } catch (err) {
       // todo: 显示错误信息
    }
});
但这仍然缺少我们通常需要展示的很多状态,比如加载状态和错误状态。
import { effect, signal, batch } from "@preact/signals-core";

const el = document.querySelector('#output');
const postId =  signal( '123');
const postData = signal({});
const errorMessage = signal('');
const loading = signal(false);
let controller;

effect(() => {
    if (controller) {
         controller.abort();
    }
    controller = new AbortController();
    const signal = controller.signal;
    batch(() => {
       loading.value = true;
       errorMessage.value = '';
       postData.value = {};
    });
    try {
       fetch(`/posts/${postId.value}`, { signal }).then(res => res.json()).then(post => {
            batch(() => {
                 postData.value = post;
                 loading.value = false;
             });
        }); 
    } catch (err) {
        errorMessage.value = err.message;
    }
});
effect(() =>  {
    if (loading.value) {
        el.innerText = '加载中...';
    } else if (errorMessage.value) {
        el.innerText = `错误: ${errorMessage.value}`;
    } else {
        el.innerText = postData.value.title;
    }
});
现在我们可以展示正确的状态了,但这只是针对单个请求的实现...
我们可以将这些逻辑封装到类中以便复用,或者创建一种新的Signal类型来处理异步数据。

AsyncState

AsyncState

We want to have a base class that we can make our loading states easily extend from:
export class AsyncState<T> {
  constructor() {}

  get value(): T | null {
    return null;
  }

  get requireValue(): T {
    throw new Error("Value not set");
  }

  get error(): any {
    return null;
  }

  get isLoading(): boolean {
    return false;
  }

  get hasValue(): boolean {
    return false;
  }

  get hasError(): boolean {
    return false;
  }

  map<R>(builders: {
    onLoading: () => R;
    onError: (error: any) => R;
    onData: (data: T) => R;
  }): R {
    if (this.hasError) {
      return builders.onError(this.error);
    }
    if (this.hasValue) {
      return builders.onData(this.requireValue);
    }
    return builders.onLoading();
  }
}
This class actually comes from a Dart port of preact signals I created.
This allows us to easily check if there is an actual value, error or if it is loading. It also provides an easy builder method to map the state to another value. 🤩
我们需要一个基类,让我们可以轻松扩展出各种加载状态:
export class AsyncState<T> {
  constructor() {}

  get value(): T | null {
    return null;
  }

  get requireValue(): T {
    throw new Error("Value not set");
  }

  get error(): any {
    return null;
  }

  get isLoading(): boolean {
    return false;
  }

  get hasValue(): boolean {
    return false;
  }

  get hasError(): boolean {
    return false;
  }

  map<R>(builders: {
    onLoading: () => R;
    onError: (error: any) => R;
    onData: (data: T) => R;
  }): R {
    if (this.hasError) {
      return builders.onError(this.error);
    }
    if (this.hasValue) {
      return builders.onData(this.requireValue);
    }
    return builders.onLoading();
  }
}
这个类实际上来自我创建的Preact Signals的Dart移植版本中的AsyncState
这个类让我们可以轻松检查当前是否有实际数据、错误或处于加载状态,还提供了一个便捷的构建器方法来将状态映射为其他值。🤩

AsyncData

AsyncData

The loading state extends AsyncState and passes the value in the constructor to the overriden methods.
export class AsyncData<T> extends AsyncState<T> {
  private _value: T;

  constructor(value: T) {
    super();
    this._value = value;
  }

  get requireValue(): T {
    return this._value;
  }

  get hasValue(): boolean {
    return true;
  }

  toString() {
    return `AsyncData{${this._value}}`;
  }
}
加载完成状态类继承自AsyncState,并在构造函数中传入值,重写对应的方法。
export class AsyncData<T> extends AsyncState<T> {
  private _value: T;

  constructor(value: T) {
    super();
    this._value = value;
  }

  get requireValue(): T {
    return this._value;
  }

  get hasValue(): boolean {
    return true;
  }

  toString() {
    return `AsyncData{${this._value}}`;
  }
}

AsyncLoading

AsyncLoading

For the loading state we override the methods like AsyncData.
export class AsyncLoading<T> extends AsyncState<T> {
  get value(): T | null {
    return null;
  }

  get isLoading(): boolean {
    return true;
  }

  toString() {
    return `AsyncLoading{}`;
  }
}
对于加载状态,我们像AsyncData一样重写对应的方法。
export class AsyncLoading<T> extends AsyncState<T> {
  get value(): T | null {
    return null;
  }

  get isLoading(): boolean {
    return true;
  }

  toString() {
    return `AsyncLoading{}`;
  }
}

AsyncError

AsyncError

For the error state we can pass an object of any type to return the error as value instead of throwing an exception (like Go).
export class AsyncError<T> extends AsyncState<T> {
  private _error: any;

  constructor(error: any) {
    super();
    this._error = error;
  }

  get error(): any {
    return this._error;
  }

  get hasError(): boolean {
    return true;
  }

  toString() {
    return `AsyncError{${this._error}}`;
  }
}
对于错误状态,我们可以传入任意类型的对象来返回错误值,而不是抛出异常(类似Go语言的处理方式)。
export class AsyncError<T> extends AsyncState<T> {
  private _error: any;

  constructor(error: any) {
    super();
    this._error = error;
  }

  get error(): any {
    return this._error;
  }

  get hasError(): boolean {
    return true;
  }

  toString() {
    return `AsyncError{${this._error}}`;
  }
}

asyncSignal

asyncSignal

Now we the state classes created, we can create a function to create an asynchronous signal with all the logic we talked about earlier.
We need to show the sync value at any time and have a way to abort previous requests.
export function asyncSignal<T>(
  cb: () => Promise<T>
): ReadonlySignal<AsyncState<T>> {
  const loading = new AsyncLoading<T>();
  const reset = Symbol("reset");
  const s = signal<AsyncState<T>>(loading);
  const c = computed<Promise<T>>(cb);
  let controller: AbortController | null;
  let abortSignal: AbortSignal | null;

  function execute(cb: Promise<T>, cancel: AbortSignal) {
    (async () => {
      s.value = loading;
      try {
        const result = await new Promise<T>(async (resolve, reject) => {
          if (cancel.aborted) {
            reject(cancel.reason);
          }
          cancel.addEventListener("abort", () => {
            reject(cancel.reason);
          });
          try {
            const result = await cb;
            if (cancel.aborted) {
              reject(cancel.reason);
              return;
            }
            resolve(result);
          } catch (error) {
            reject(error);
          }
        });
        s.value = new AsyncData<T>(result);
      } catch (error) {
        if (error === reset) {
          s.value = loading;
        } else {
          s.value = new AsyncError<T>(error);
        }
      }
    })();
  }

  effect(() => {
    if (controller != null) {
      controller.abort(reset);
    }
    controller = new AbortController();
    abortSignal = controller.signal;
    execute(c.value, abortSignal);
  });

  return s;
}
This makes it very easy to create multiple asynchronous signals and also use it anywhere else you have signals in the application like effects and computeds.
const el = document.querySelector('#output');
const postId =  signal('123');

const result = asyncSignal(() => fetch(`/posts/${postId.value}`).then(res => res.json()));

effect(() => {
   el.innerText = result.value.map({
      onLoading: () => 'Loading...',
      onError: (err) => `Error: ${err}`,
      onData: (post) => post.title, 
   });
});

postId.value = '456';
现在我们已经创建了状态类,可以编写一个函数来创建具备前面所有逻辑的异步Signal。
我们需要在任何时候都能展示同步值,并且有办法终止之前的请求。
export function asyncSignal<T>(
  cb: () => Promise<T>
): ReadonlySignal<AsyncState<T>> {
  const loading = new AsyncLoading<T>();
  const reset = Symbol("reset");
  const s = signal<AsyncState<T>>(loading);
  const c = computed<Promise<T>>(cb);
  let controller: AbortController | null;
  let abortSignal: AbortSignal | null;

  function execute(cb: Promise<T>, cancel: AbortSignal) {
    (async () => {
      s.value = loading;
      try {
        const result = await new Promise<T>(async (resolve, reject) => {
          if (cancel.aborted) {
            reject(cancel.reason);
          }
          cancel.addEventListener("abort", () => {
            reject(cancel.reason);
          });
          try {
            const result = await cb;
            if (cancel.aborted) {
              reject(cancel.reason);
              return;
            }
            resolve(result);
          } catch (error) {
            reject(error);
          }
        });
        s.value = new AsyncData<T>(result);
      } catch (error) {
        if (error === reset) {
          s.value = loading;
        } else {
          s.value = new AsyncError<T>(error);
        }
      }
    })();
  }

  effect(() => {
    if (controller != null) {
      controller.abort(reset);
    }
    controller = new AbortController();
    abortSignal = controller.signal;
    execute(c.value, abortSignal);
  });

  return s;
}
这让创建多个异步Signal变得非常容易,并且可以在应用中任何使用Signals的地方(比如effects和computeds)使用它。
const el = document.querySelector('#output');
const postId =  signal('123');

const result = asyncSignal(() => fetch(`/posts/${postId.value}`).then(res => res.json()));

effect(() => {
   el.innerText = result.value.map({
      onLoading: () => '加载中...',
      onError: (err) => `错误: ${err}`,
      onData: (post) => post.title, 
   });
});

postId.value = '456';

Conclusion

总结

I have started a Preact Signals GitHub discussion here and you can find a gist with the final source code here. 🎉
This has made working with asynchronous data a lot eaiser to work with and would love to hear your thoughts about ways to improve it 👀
Also if you are curious about how Angular does asynchronous signals you can check out the resource signal and the computedFrom/Async signal.
我在Preact Signals的GitHub讨论区发起了相关讨论,点击查看,最终的源代码可以在这个Gist中找到:链接。🎉
这个实现让异步数据的处理变得简单很多,我很想听听大家关于优化方向的想法👀
如果你好奇Angular是如何处理异步Signals的,可以查看Resource Signal以及computedFrom/Async Signal