最近趣味で使っている、Dioxusのhooksを紹介します。今回はuse_signal
の実装部分をみていきます。
- 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_callback
はuse_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
:差分レンダリング中の更新にも対応
値の更新手順
ExternalListenerCallback<Args, Ret>
で新しいコールバックを作成
pub(super) struct ExternalListenerCallback<Args, Ret> {
callback: Box<dyn FnMut(Args) -> Ret>,
runtime: std::rc::Weak<Runtime>,
}
FnMut
になってます。
- 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));
}
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`からどう見えているのか疑問に思いました。
- コールバック中に別のコールバックが呼ばれた場合
特に制限がないためパニックになる?
-> 実際に無限ループさせることはできそう
- 複数スレッドで同時にコールバックが呼ばれた場合
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_signal
はuse_state
とuse_callback
が統合したものになります。
use_callback
から改善された点は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←こちらのリポジトリで簡単な実装をしてみたので興味がある方は覗いてみてください。