Ccmmutty logo
Commutty IT
0 pv11 min read

Dioxusのuse_signalについて

https://cdn.magicode.io/media/notebox/e7c17bab-a662-4398-bec1-474dd0fbdc59.jpeg
最近趣味で使っている、Dioxusのhooksを紹介します。今回はuse_signalの実装部分をみていきます。
Dioxus v0.5.6packages/hooksにある使用可能なhooksが以下です。
  • use_signal
  • use_effect
  • use_resource
  • use_memo
  • use_coroutine

use_hook

まずは、hooksを作る際の中核部分について
#[track_caller]
pub fn use_hook<State: Clone + 'static>(initializer: impl FnOnce() -> State) -> State {
    Runtime::with_current_scope(|cx| cx.use_hook(initializer)).unwrap()
}
初期値の保持と更新Stateのクローンをやっています。ライフタイムの所有権('static)はStateに紐づいているのでアンマウント時に解放されます。use_hookを生成した時点ではStateを毎回クローンしてるだけで差分更新の仕組みは実装していないようです。
pub struct InnerCustomState(usize);

impl Drop for InnerCustomState {
    fn drop(&mut self) {
        println!("Component has been dropped.");
    }
}

#[derive(Clone, Copy)]
pub struct CustomState {
    inner: Signal<InnerCustomState>,
}

pub fn use_custom_state() -> CustomState {
    use_hook(|| CustomState {
        inner: Signal::new(InnerCustomState(0))
    })
}
これはサンプルの部分、こんな感じでhooksを自作できます。
Dioxus 0.5からuse_state, use_callbackuse_signalに統合されました。ドキュメントからもuse_callbackの存在が消されました。しかしながら、use_signalを完全に理解するにはuse_callbackの仕組みを知ってた方が良いのではないかと思います。
今回は、use_callbackについても触れます。先に、use_callbackについてみていきます。

use_callback

#[doc = include_str!("../docs/rules_of_hooks.md")]
pub fn use_callback<T: 'static, O: 'static>(f: impl FnMut(T) -> O + 'static) -> Callback<T, O> {
    let mut callback = Some(f);

    // Create a copyvalue with no contents
    // This copyvalue is generic over F so that it can be sized properly
    let mut inner = use_hook(|| Callback::new(callback.take().unwrap()));

    if let Some(callback) = callback.take() {
        // Every time this hook is called replace the inner callback with the new callback
        inner.replace(Box::new(callback));
    }

    inner
}
use_callbackは初回レンダリング時にポインタ・クロージャーを生成。2回目以降は中身だけ更新する仕組みになっています。 これにより、常に最新のコールバックを参照しつつも、ハンドルを再作成する必要がないので、効率が良さそうに見えます。
Callbackの中身を見ていきます。
pub struct Callback<Args = (), Ret = ()> {
    pub(crate) origin: ScopeId,
    /// During diffing components with EventHandler, we move the EventHandler over in place instead of rerunning the child component.
    ///
    /// ```rust
    /// # use dioxus::prelude::*;
    /// #[component]
    /// fn Child(onclick: EventHandler<MouseEvent>) -> Element {
    ///     rsx! {
    ///         button {
    ///             // Diffing Child will not rerun this component, it will just update the callback in place so that if this callback is called, it will run the latest version of the callback
    ///             onclick: move |evt| onclick(evt),
    ///         }
    ///     }
    /// }
    /// ```
    ///
    /// This is both more efficient and allows us to avoid out of date EventHandlers.
    ///
    /// We double box here because we want the data to be copy (GenerationalBox) and still update in place (ExternalListenerCallback)
    /// This isn't an ideal solution for performance, but it is non-breaking and fixes the issues described in <https://github.com/DioxusLabs/dioxus/pull/2298>
    pub(super) callback: GenerationalBox<Option<ExternalListenerCallback<Args, Ret>>>,
}

impl<Args, Ret> std::fmt::Debug for Callback<Args, Ret> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Callback")
            .field("origin", &self.origin)
            .field("callback", &self.callback)
            .finish()
    }
}

impl<T: 'static, Ret: Default + 'static> Default for Callback<T, Ret> {
    fn default() -> Self {
        Callback::new(|_| Ret::default())
    }
}
第1引数ScopeId: 一意キーを作成。Callbackがどこで発行されたか管理する。
第2引数のダブルボックス(GenerationalBox<Option<ExternalListenerCallback<Args, Ret>>>):
  • GenerationalBox: 世代管理されたポインタ
  • ExternalListenerCallback:差分レンダリング中の更新にも対応
値の更新手順
  1. ExternalListenerCallback<Args, Ret>で新しいコールバックを作成
pub(super) struct ExternalListenerCallback<Args, Ret> {
    callback: Box<dyn FnMut(Args) -> Ret>,
    runtime: std::rc::Weak<Runtime>,
}
FnMutになってます。
  1. callBackがSomeに格納
if let Some(callback) = callback.take() {
        // Every time this hook is called replace the inner callback with the new callback
        inner.replace(Box::new(callback));
    }
  1. replaceメソッドでGenerationalBox内部に保存 古いデータを解放し、新しいコールバックを現在の世代として設定。
pub struct GenerationalBoxId {
    data_ptr: *const (),
    generation: NonZeroU64,
}
これはGenerationalBoxが持っている生ポインタ(*const ())と世代カウンター(NonZeroU64)です。
// Safety: GenerationalBoxId is Send and Sync because there is no way to access the pointer.
unsafe impl Send for GenerationalBoxId {}
unsafe impl Sync for GenerationalBoxId {}
ここでの世代管理の仕組みは、複数スレッドでコールバックした場合には生ポインタ(*const ())は同じ場所を指しますが、世代カウンター(NonZeroU64)が一致しなくなるため、そのポインタは無効だと判断できるというロジックになっているようです。
ここで、以下2点でSend,Syncが``GeneratonalBox`からどう見えているのか疑問に思いました。
  1. コールバック中に別のコールバックが呼ばれた場合
特に制限がないためパニックになる? -> 実際に無限ループさせることはできそう
  1. 複数スレッドで同時にコールバックが呼ばれた場合
Send,Syncが明示的に呼ばれています。
GenerationalBoxId自体はデータのアドレスと世代番号を持つだけで、直接データにアクセスしないため、スレッド間でのデータ競合は発生しません。 また、parking_lot::Mutexを使って可変なデータに対してスレッドセーフなアクセスを提供しています。
複数スレッドでコールバックした場合には生ポインタ(*const ())は同じ場所を指しますが、世代カウンター(NonZeroU64)が一致しなくなるため、そのポインタは無効だと判断できます。
では実際にコールバックの中身を見てみます。(v0.5を使うのでuse_signal)
#[component]
fn ButtonComponent() -> Element {
    let mut count = use_signal(|| 0);

    let on_click = move |_| {
        println!("Button clicked!");
        count.set((*count)() + 1);
        
    };

    rsx!(
        button { onclick: on_click, "Click me! Count: {count}" }
    )
}
ボタンをクリックしたときのログが以下です。
# 1回目
Listeners: [Listener(EventHandler { origin: ScopeId(1, "ButtonComponent"), callback: 0x133dfc@0 })]
# 2回目
 Listeners: [Listener(EventHandler { origin: ScopeId(1, "ButtonComponent"), callback: 0x133d5c@0 })]
新しいコールバックが生成されていることが確認でいます。

use_signal

前述しましたが、use_signaluse_stateuse_callbackが統合したものになります。
use_callbackから改善された点は2点あります。
  1. 状態管理の簡素化
  2. 自動的な再レンダリング
状態管理の簡素化、自動的な再レンダリング
Signalの再レンダリングを管理している部分について触れていきます。 以下の部分では依存関係を自動で追跡する関数を作成しています。
impl<T, S: Storage<SignalData<T>>> Readable for Signal<T, S> {
    type Target = T;
    type Storage = S;

    #[track_caller]
    fn try_read_unchecked(&self) -> BorrowResult<ReadableRef<'static, Self>> {
        let inner = self.inner.try_read_unchecked()?;

        if let Some(reactive_context) = ReactiveContext::current() {
            tracing::trace!("Subscribing to the reactive context {}", reactive_context);
            reactive_context.subscribe(inner.subscribers.clone());
        }

        Ok(S::map(inner, |v| &v.value))
    }

    /// Get the current value of the signal. **Unlike read, this will not subscribe the current scope to the signal which can cause parts of your UI to not update.**
    ///
    /// If the signal has been dropped, this will panic.
    #[track_caller]
    fn try_peek_unchecked(&self) -> BorrowResult<ReadableRef<'static, Self>> {
        self.inner
            .try_read_unchecked()
            .map(|inner| S::map(inner, |v| &v.value))
    }
}
この部分ではReactiveContext::current()を使って更新データ(現在追跡中のコンテキスト)をを追跡しています。 そしてreactive_context.subscribe(inner.subscribers.clone())で必要な依存関係全てを追跡します。
最終的な更新ロジックがReactiveContext::current()に集約していることになります。それが以下になります。
pub struct ReactiveContext {
    scope: ScopeId,
    inner: GenerationalBox<Inner, SyncStorage>,
}
ここでようやくGenerationalBoxが出てきます。 use_callbackを思い出してみてください。大きな違いは以下の点です。
// `use_callback`
if let Some(callback) = callback.take() {
        // Every time this hook is called replace the inner callback with the new callback
        inner.replace(Box::new(callback));
    }

// use_signal
if let Some(reactive_context) = ReactiveContext::current() {
            tracing::trace!("Subscribing to the reactive context {}", reactive_context);
            reactive_context.subscribe(inner.subscribers.clone());
    }
use_callbackではcallbackに一時的に変換する処理を挟みます。これによりコンテキストでの状態管理が複雑になっていました。 use_signalではReactiveContextを活用し、依存関係の管理もうまく実装できている様です。

最後に

hemi←こちらのリポジトリで簡単な実装をしてみたので興味がある方は覗いてみてください。

Discussion

コメントにはログインが必要です。