تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

التوثيق

التفاعلية

يحدّث NSH واجهة المستخدم عبر الإشارات (signals): قيم تعرف من يقرؤها. عندما تتغير إشارة، يعيد NSH تشغيل الحسابات التي قرأتها بالضبط (نص، أو نمط، أو تأثير) ولا شيء غير ذلك. لا تُعاد معالجة المكونات. النموذج مأخوذ من Solid، أما الرسم التفاعلي نفسه فهو أصلي (native): تعمل مكونات Rust وTSX على الرسم نفسه المكتوب بلغة Rust، وهكذا يجري الجدولة وتتبع الاعتماديات والتنظيف خارج المفسّر حتى في TSX.

guide-reactivity.tsx
guide-reactivity.tsxLive
import { batch, cleanup, computed, context, effect, mount, Scroll, signal, Text, untracked, View } from "nsh";
import { button, buttonText, focusRing, tint, ui, wide } from "./palette";

/** The accent's palette key: a key, not a colour, so it follows the theme. */
type Swatch = "accent" | "info" | "up" | "violet";
const Accent = context(signal<Swatch>("accent"));

/** A button filled with a palette colour instead of a surface. */
const filled = (color: () => string) => () => ({ backgroundImage: "none", backgroundColor: color(), borderColor: "rgba(0,0,0,0)", boxShadow: "none" });

/** A stopwatch: the effect starts a timer and its cleanup stops it. */
function Stopwatch() {
  const running = signal(false);
  const tenths = signal(0);
  effect(() => {
    if (!running()) return;
    const id = setInterval(() => tenths.update((t) => t + 1), 100);
    cleanup(() => clearInterval(id));
  });
  const accent = Accent();
  return (
    <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
      <Text style={{ flex: 1, fontSize: 28, fontWeight: "600", fontVariant: ["tabular-nums"], color: ui().text }}>
        {(tenths() / 10).toFixed(1)} s
      </Text>
      <View onPress={() => running.set(!running())} {...button("primary", { style: filled(() => ui()[accent()]) })}>
        <Text style={{ ...buttonText(), color: ui().page }}>{running() ? "Stop" : "Start"}</Text>
      </View>
      <View onPress={() => tenths.set(0)} {...button()}>
        <Text style={buttonText()}>Reset</Text>
      </View>
    </View>
  );
}

function Section(props: { title: string; children: any }) {
  return (
    <View style={{ ...ui().card, borderRadius: 16, padding: 14, gap: 10 }}>
      <Text style={{ fontSize: 13, fontWeight: "600", color: ui().muted }}>{props.title}</Text>
      {props.children}
    </View>
  );
}

function App() {
  const price = signal(12);
  const quantity = signal(1);
  const total = computed(() => price() * quantity());
  const writes = signal(0);
  const accent = Accent();

  // Runs again whenever `total` changes; reads of `writes` are not tracked.
  const log = signal<string[]>([]);
  effect(() => {
    const t = total();
    const n = untracked(writes);
    log.update((l) => [`total ${t} after ${n} writes`, ...l].slice(0, 3));
  });

  const step = (dp: number, dq: number) => {
    batch(() => {
      price.update((p) => Math.max(0, p + dp));
      quantity.update((q) => Math.max(0, q + dq));
      writes.update((w) => w + 2);
    });
  };

  const swatches: Swatch[] = ["accent", "info", "up", "violet"];

  return (
    <Scroll style={{ flex: 1, backgroundColor: ui().page }}>
      <View
        style={
          wide()
            ? { padding: 20, paddingTop: 24, gap: 16, display: "grid", gridTemplateColumns: "repeat(2, 1fr)" }
            : { padding: 20, paddingTop: 24, gap: 16 }
        }
      >
        <Section title="signal + computed">
          <Text style={{ fontSize: 17, color: ui().text }}>
            {price()} × {quantity()} = <Text style={{ fontWeight: "700", color: ui()[accent()] }}>{total()}</Text>
          </Text>
          <View style={{ flexDirection: "row", gap: 8 }}>
            <View onPress={() => step(1, 0)} {...button()}>
              <Text style={buttonText()}>price +1</Text>
            </View>
            <View onPress={() => step(0, 1)} {...button()}>
              <Text style={buttonText()}>qty +1</Text>
            </View>
            <View
              onPress={() => step(1, 1)}
              {...button("secondary", { style: () => ({ backgroundImage: "none", backgroundColor: tint(ui().warn, 0.14), borderColor: tint(ui().warn, 0.3), boxShadow: "none" }) })}
            >
              <Text style={{ ...buttonText(), color: ui().warn }}>both (batch)</Text>
            </View>
          </View>
        </Section>

        <Section title="effect + untracked">
          <Each of={log()}>{(line) => <Text style={{ fontSize: 13, color: ui().muted }}>{line()}</Text>}</Each>
        </Section>

        <Section title="effect + cleanup">
          <Stopwatch />
        </Section>

        <Section title="context (the value is a signal)">
          <View style={{ flexDirection: "row", gap: 10 }}>
            <Each of={swatches}>
              {(c) => (
                <View
                  onPress={() => accent.set(c())}
                  style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: ui()[c()], borderWidth: 3, borderColor: accent() === c() ? ui().text : "transparent" }}
                  hoverStyle={{ opacity: 0.88 }}
                  focusStyle={focusRing()}
                  activeStyle={{ opacity: 0.7 }}
                />
              )}
            </Each>
          </View>
        </Section>
      </View>
    </Scroll>
  );
}

mount(() => (
  <Accent.Provider value={signal<Swatch>("accent")}>
    <App />
  </Accent.Provider>
));

تأخذ مكونات Rust كل ما في هذه الصفحة من prelude، بينما يستورده TSX من nsh:

use nsh_core::prelude::*;   // signal, computed, effect, resource, batch, untracked, cleanup, provide_context, use_context

#الإشارات

signal(value) تنشئ إشارة. اقرأها باستخدام get() في Rust، أو باستدعائها في TSX؛ وset وupdate تكتبانها:

let count = signal(0);

count.get();                    // 0, and tracks the read
count.set(5);
count.update(|n| *n += 1);      // 6

القراءة داخل نطاق تتبّع (ربط، أو computed، أو effect) تشترك ذلك النطاق في الإشارة. والكتابة تُخطر المشتركين إذا تغيرت القيمة: تُقارَن بـ PartialEq في Rust، وبـ === في TSX.

fn counter() -> impl IntoElement {
    let count = signal(0);
    view()
        .on_press(move || count.update(|n| *n += 1))
        .child(text(move || format!("Tapped {} times", count.get())))
}

تؤدي النقرة إلى تحديث عقدة النص الوحيدة التي تقرأ count. أما counter() / Counter نفسها فتعمل مرة واحدة. الإشارة في Rust مقبض Copy، لذا تأخذها الإغلاقات (closures) باستخدام move وتستعملها بحرية.

يمكن للإشارة أن تعدّ كل كتابة تغييرًا، ويمكن لـ TSX مقارنة القيم بطريقته الخاصة:

let tick = signal_with(0, SignalEquals::Never);   // every write notifies

في TSX، تبدأ signal<T>() بلا قيمة بالقيمة undefined ويكون نوعها Signal<T | undefined>؛ وفي Rust، يقول Option الشيء نفسه (signal(None::<User>)).

#الكائنات والمصفوفات

في Rust، تغيّر update القيمة في مكانها وتُخطر القرّاء؛ بينما تستبدلها set. في TSX، تُقارَن الإشارات بـ ===، لذا فالقيمة التي تُعدَّل في مكانها لا تُعدّ تغييرًا: اكتب كائنًا أو مصفوفة جديدة.

let todos = signal(Vec::<Todo>::new());

todos.update(|list| list.push(Todo { id: 3, title: "New".into(), done: false }));   // notifies
todos.update(|list| {
    if let Some(todo) = list.iter_mut().find(|t| t.id == id) {
        todo.done = true;
    }
});

each / <Each> تفهرس الصفوف بالعنصر، فتغيير عنصر واحد يعيد بناء صفه فقط (JSX والمكونات).

#computed

computed(fn) تشتق قيمة من إشارات أخرى. تشغّل fn وتحتفظ بالنتيجة وتعيد تشغيلها عند تغير إشارة قرأتها. قراءتها كقراءة إشارة:

let price = signal(12);
let quantity = signal(2);
let total = computed(move || price.get() * quantity.get());

text(move || format!("Total: {}", total.get()))

استخدم computed عندما تُقرأ القيمة المشتقة في عدة أماكن أو تكون مكلفة: فهي تعمل مرة واحدة لكل تغيير مهما كان عدد قرّائها، ولا يُخطَر القرّاء إلا عندما تتغير نتيجتها. أما للقيمة الرخيصة التي تُقرأ مرة واحدة فيكفي إغلاق عادي:

let label = move || format!("{} items", quantity.get());
text(label)

في TSX، تستقبل fn القيمة السابقة (undefined في المرة الأولى)، للقيم التي تتراكم:

const max = computed((prev: number | undefined) => Math.max(prev ?? 0, speed()));

#effect

effect(fn) تشغّل fn، وتعيد تشغيلها كلما تغيرت إشارة قرأتها. التأثيرات مخصصة للآثار الجانبية: التسجيل (logging)، والمؤقتات، وطلبات الشبكة، والاستدعاءات الأمرية على المراجع.

#[derive(Debug, Clone, PartialEq, Props)]
pub struct SearchProps {
    /// What to search for.
    pub query: Prop<SharedString>,
}

#[component]
pub fn Search(props: SearchProps) -> impl IntoElement {
    let results = signal(Vec::<SharedString>::new());
    effect(move || {
        let q = props.query.get();          // tracked: runs again when the prop changes
        if q.is_empty() {
            return;
        }
        spawn(async move {
            if let Ok(response) = fetch(format!("https://api.example.com/search?q={q}")).await {
                results.set(response.text().lines().map(|line| SharedString::from(line.to_owned())).collect());
            }
        });
    });
    view().child(each(results, |item| item.clone(), |item, _| text(item)))
}

يتتبع التأثير القراءات التي تحدث أثناء تشغيله المتزامن. أما القراءات داخل مهمة، أو دالة .then أو بعد await فلا تُتتبَّع.

التأثير الذي يستدعي مرجعًا يعمل على العنصر الذي عليه المرجع؛ وفي TSX تعمل التأثيرات بعد وجود العناصر المنشأة في الدورة نفسها، فتكون المراجع قد ضُبطت:

let input = element_ref();
effect(move || {
    if editing.get() {
        input.focus();
    }
});

#resource

resource(source, fetcher) تحمّل قيمة بشكل غير متزامن وتحتفظ بحالة التحميل، فيعرض المكوّن البيانات أو مؤشر تحميل أو خطأ دون أن يديرها بنفسه:

let url = signal(SharedString::from("https://api.example.com/quote"));
let quote = resource(move || Some(url.get()), |url, _info| async move {
    let response = fetch(url.to_string()).await?;
    if !response.ok() {
        return Err(format!("HTTP {}", response.status()));
    }
    Ok(response.text())
});

view()
    .child(text(move || match (quote.get(), quote.error()) {
        (_, Some(error)) => format!("Failed: {error}"),
        (Some(quote), None) => quote,
        (None, None) => "Loading…".to_owned(),
    }))
    .child(text("Reload").on_press(move || quote.refetch()))

source متتبَّع: كلما أعطى قيمة جديدة، يحمّل fetcher(value, info) من جديد. في Rust هو إغلاق يعيد Option، وNone لا تحمّل شيئًا؛ وفي TSX هو دالة وصول (إشارة، دالة) أو قيمة، وundefined أو null أو false لا تحمّل شيئًا: لمستخدم لم يسجّل الدخول بعد أو لم يُختَر شيء. وعند غياب المصدر، يمرر Rust القيمة || Some(())، ويحمّل resource(fetcher) في TSX مرة واحدة عبر استدعاء fetcher(info). تعيد دالة الجلب في Rust Result؛ أما في TSX فتعيد قيمة أو وعدًا (promise)، ويُعدّ الرمي (throw) فشلًا.

RustTSXما هو
quote.get()posts()أحدث قيمة: لا شيء قبل انتهاء أول تحميل، وتبقى أثناء إعادة التحميل وبعد الفشل.
quote.loading()posts.loading()هل يجري تحميل.
quote.error()posts.error()خطأ آخر تحميل، ولا شيء بعد أن يعطي تحميل قيمة.
quote.state()posts.state()Unresolved / "unresolved" (لا شيء للتحميل)، Pending (التحميل الأول)، Ready، Refreshing (إعادة التحميل مع بقاء القيمة)، Errored.
quote.refetch()posts.refetch()يحمّل من جديد من المصدر نفسه: إعادة محاولة، أو السحب للتحديث.
quote.mutate(value)posts.mutate(value)يضبط القيمة هنا دون تحميل، وهو تحديث متفائل (optimistic)، ويمسح الخطأ.

المورد (resource) في Rust مقبض Copy كالإشارات؛ وأعضاء TSX مربوطة، لذا تعمل onRefresh={posts.refetch} وconst loading = posts.loading كما هي.

يُعتدّ بآخر تحميل فقط. التحميل الجديد يُسقط الجاري، وكذلك زوال المكوّن؛ وفي TSX، تُلغي info.signal الـ fetch الممرَّر إليها. التحميل الذي ينتهي بعد أن حلّ محله غيره لا يغيّر شيئًا. info.refetching تدل على أن refetch() بدأ التحميل. resource_with_value(value, source, fetcher) في Rust و{ initialValue } في TSX تمنحان المورد قيمة منذ البداية.

#cleanup

cleanup(fn) تسجّل fn لتعمل قبل إعادة تشغيل التأثير الحالي، أو عند زوال المكوّن أو التأثير الذي تنتمي إليه:

effect(move || {
    if !running.get() {
        return;
    }
    let ticker = spawn(async move {
        loop {
            timer(Duration::from_millis(100)).await;
            tick();
        }
    });
    cleanup(move || ticker.cancel());   // before the next run, or on removal
});

في Rust، تنتهي المهام والمؤقتات والفواصل الزمنية (spawn، set_timeout، set_interval) مع المالك الذي أُنشئت تحته، فلا تحتاج ساعة العرض الخاصة إلى تنظيف:

use std::time::Duration;

use nsh_core::time::unix_ms;

fn clock() -> impl IntoElement {
    let now = signal(unix_ms());
    set_interval(Duration::from_secs(1), move || now.set(unix_ms()));   // ends with the view clock() made
    text(move || format!("{:.0}", now.get() / 1000.0))
}

يمكن لتأثير TSX أيضًا أن يعيد دالة التنظيف الخاصة به، كما في React: الدالة التي تعيدها fn تعمل في اللحظات نفسها التي تعمل فيها الدالة التي تمررها إلى cleanup.

effect(() => {
  const id = setInterval(tick, 100);
  return () => clearInterval(id);
});

يزول المكوّن عندما لا يعود معروضًا: يُخفى فرع when / <If> الخاص به، أو يُزال صف each / <Each> الخاص به، أو تُزال صفحته من المكدس (popped). وكل ما أنشأه (التأثيرات، والقيم المحسوبة، والمهام، وعمليات التنظيف، والمكونات المتداخلة) يُتخلَّص منه معه.

#batch

كل كتابة تُخطر مشتركيها فورًا. ولجعل عدة كتابات تُحسب تغييرًا واحدًا، غلّفها بـ batch: تعمل الاعتماديات مرة واحدة بعد آخر كتابة.

let save = move || {
    batch(|| {
        first_name.set(form.first.clone());
        last_name.set(form.last.clone());
        dirty.set(false);
    })
};

معالجات الأحداث لا تُجمَّع تلقائيًا: كتابتان في معالج الضغط تشغّلان تأثيرًا يقرأ الإشارتين مرتين. تعيد batch ما تعيده دالتها.

#untracked

untracked(fn) تشغّل fn دون الاشتراك فيما تقرؤه. استخدمها داخل نطاق تتبّع لقراءة قيمة دون الاعتماد عليها:

effect(move || {
    let q = query.get();                          // runs again when query changes
    let filters = untracked(|| active_filters.get());   // read, but not a reason to run again
    search(q, filters);
});

تقرأ get_untracked() في Rust إشارة واحدة بالطريقة نفسها. جسم المكوّن غير متتبَّع أصلًا: قراءة إشارة هناك تقرؤها مرة واحدة.

#context

السياق (context) قيمة يوفّرها مكوّن لكل ما يعرضه، مهما بلغ عمقه. في Rust، توفّر provide_context(value) قيمة من نوعها، وتجد use_context::<T>() أقرب قيمة؛ وفي TSX، تنشئ context(defaultValue) سياقًا، واستدعاؤه يقرأ أقرب قيمة موفَّرة (أو القيمة الافتراضية)، وتوفّر <Ctx.Provider value> قيمة:

#[derive(Clone, Copy)]
struct Dark(bool);

fn label(content: &'static str) -> impl IntoElement {
    let dark = use_context::<Dark>().is_some_and(|d| d.0);
    text(content).text_color(if dark { rgb(0xffffff) } else { rgb(0x1f2328) })
}

fn screen() -> impl IntoElement {
    provide_context(Dark(true));
    view().child(label("Hello"))
}

القيمة الموفَّرة تُقرأ مرة واحدة، عند البحث عنها. ولتوفير شيء يتغير، وفّر إشارة (أو بنية struct من الإشارات) واقرأها في ربط:

#[derive(Clone, Copy)]
struct Accent(Signal<Hsla>);

#[component]
fn App() -> impl IntoElement {
    provide_context(Accent(signal(rgb(0x0969da).into())));
    view().child(swatch())
}

fn swatch() -> impl IntoElement {
    let Accent(accent) = use_context::<Accent>().expect("an accent");
    view()
        .w(px(40.0))
        .h(px(40.0))
        .bg(move || accent.get())
        .on_press(move || accent.set(rgb(0x8250df).into()))
}

اقرأ السياق في جسم المكوّن (أو في شيفرة تعمل أثناء إنشاء المكونات)، لا في معالج حدث أو مؤقت، حيث لا يوجد مكوّن للبحث منه. تأخذ مكونات Rust سمة (theme) التطبيق بهذه الطريقة أيضًا: theme() من nsh_base (السمة).

#Error boundaries

تحصر حدود الأخطاء (error boundary) الفشل في جزء واحد من العرض. عندما يرمي شيء تحتها (في Rust: panic) أثناء العرض أو التشغيل، مثل جسم مكوّن أو ربط أو تأثير أو معالج حدث، يُزال الجزء الفاشل وتعرض الحدود بديلها (fallback) مكانه؛ ويبقى بقية العرض كما هو. في Rust هي catch_error(view, fallback)؛ وفي TSX هي <ErrorBoundary fallback> من nsh. يتلقى البديل الخطأ (في TSX القيمة المرمية نفسها، وهي عادةً Error) ودالة إعادة ضبط (reset) تعرض العرض من جديد:

fn profile_card(user: Signal<Option<User>>) -> impl IntoElement {
    catch_error(
        move || profile(user),
        |error, reset| {
            view()
                .child(text(format!("Something went wrong: {error}")))
                .child(text("Try again").on_press(move || reset.reset()))
        },
    )
}

يذهب الفشل إلى أقرب حدود فوق موضع حدوثه، ويذهب الفشل داخل البديل إلى الحدود التي فوقها. وبدون حدود، يُبلَّغ عن الفشل كخطأ غير ملتقَط (طبقة الأخطاء أثناء التطوير، والسجل). في TSX، يُعرض عنصر fallback كما هو (fallback={<Text>Unavailable</Text>})، وتُستدعى الدالة مع الخطأ وreset، مهما كانت المعاملات التي تعلنها.

#ما الذي يعمل، ومتى

ما تكتبهيعمليعمل من جديد عندما
جسم المكوّنمرة واحدة، عند إنشائهأبدًا (باستثناء إعادة التحميل الساخن)
ربط: إغلاق يُعطى إلى text أو نمط أو خاصية (prop) في Rust؛ {expr} أو prop={expr} يستدعي أو يقرأ خاصية في TSXمرة واحدة، عند إنشاء العنصرتتغير إشارة قرأها؛ تُحدَّث تلك الخاصية أو ذلك الابن فقط
computed(fn)عند إنشائهتتغير إشارة قرأها
effect(fn)بعد وجود عناصر الدورة الحاليةتتغير إشارة قرأها
when(cond, ..) / <If when>عند إنشائهيتغير تحقق الشرط
صف each(..) / <Each of>عند ظهور مفتاحه أول مرةأبدًا؛ بل تتغير إشارتا عنصره وفهرسه

البيئة تفاعلية بالطريقة نفسها: window() وdark() وfont_scale() وsafe_area() وkeyboard_height() في Rust، وwindowSize() وcolorScheme() وfontScale() من nsh وsafeArea() وkeyboardFrame() من nsh في TSX، لذا فالربوط التي تقرؤها تتبع التدوير والوضع الداكن ولوحة المفاتيح.

#أخطاء شائعة

قراءة إشارة في جسم المكوّن وانتظار التحديثات.

fn bad() -> impl IntoElement {
    let n = count.get();                       // read once
    text(format!("{n}"))                       // never changes
}

fn good() -> impl IntoElement {
    text(move || format!("{}", count.get()))   // a binding: follows count
}

قراءة الخصائص مرة واحدة. خاصية Rust المقروءة بـ props.title.get() في الجسم تُقرأ مرة واحدة؛ اقرأها في ربط (text(props.title)، move || props.title.get()). وفي TSX، تقرأ const { title } = props قيمة title مرة واحدة: اقرأ props.title حيث تستخدمها، أو قسّم الخصائص باستخدام split.

التعديل بدل الضبط، في TSX. list().push(x) يغيّر المصفوفة لكنه لا يُخطر أحدًا. اكتب مصفوفة جديدة، كما تفعل update في Rust نيابةً عنك.

اشتقاق الحالة بالتأثيرات. effect(() => total.set(price() * qty())) تعمل، لكنها تترك total قديمة بين الكتابة والتأثير، وتكلّف دورة ثانية. استخدم computed.

إنشاء التأثيرات خارج المكونات. التأثيرات والقيم المحسوبة تنتمي إلى المكوّن الذي أنشأها ويُتخلَّص منها معه. وإذا أُنشئت في معالج حدث أو دالة مؤقت فلا مالك لها وتبقى إلى الأبد. أنشئها في جسم مكوّن ودعها تقرأ الإشارات التي تتغير.

توقع تتبّع الشيفرة غير المتزامنة. تُتتبَّع فقط القراءات التي تحدث بشكل متزامن أثناء تشغيل تأثير أو قيمة محسوبة. اقرأ الإشارات أولًا، ثم ابدأ العمل غير المتزامن.

#ذات صلة