Skip to content

راهنمای کدنویسی

چگونه با ranui بسازیم: قرارداد کامپوننت چیست، مرز Shadow DOM کجا قواعد آشنای شما را عوض می‌کند، و کدام خطاها ارزش دارند پیش از مرتکب‌شدنشان بشناسیدشان.

نیمه دیداریِ این ماجرا راهنمای طراحی است و توکن‌ها سیستم طراحی.

کجا به کار می‌آید: وقتی کامپوننت‌های ranui را به یک برنامه وصل می‌کنید: انتخاب import، بستن رویدادها، استایل‌دادن به چیزی که یک گزینشگر به آن نمی‌رسد، رندر روی سرور، یا نوشتن آزمون.

اصول

  1. خودِ عنصر همان API است. اتریبیوت‌ها، ویژگی‌ها، رویدادها، اسلات‌ها و ::part() تمام قرارداد را می‌سازند. هر چیز دیگری که از بیرون می‌بینید جزئیات پیاده‌سازی است و جابه‌جا خواهد شد.
  2. وضعیت را دقیقاً در یک جا نگه دارید. یا برنامه شما مالک مقدار است و آن را به درون می‌راند، یا کامپوننت مالک است و تغییر را به شما خبر می‌دهد. آینه‌کردن در هر دو سو همان راهی است که مقدارها از هم دور می‌افتند.
  3. از آن‌سوی مرز Shadow DOM با ویژگی‌های سفارشی، ::part()، sheet و اسلات‌ها استایل بدهید. گزینشگرهای معمول از آن نمی‌گذرند و هیچ اندازه‌ای از ویژه‌بودن این را عوض نمی‌کند.
  4. آنچه را به کار می‌برید وارد کنید. هر کامپوننت نقطه ورودی خودش را دارد؛ بسته یکجا آسودگی است نه الزام.
  5. پلتفرم را ترجیح دهید. این‌ها عناصر سفارشی‌اند: addEventListener، setAttribute و hidden طبق مشخصات کار می‌کنند و انتزاع‌های فریم‌ورکی روی آن‌ها اختیاری‌اند.

نقاط ورود

هر نقطه ورود دقیقاً همان چیزی را ثبت می‌کند که نامش می‌گوید، نه بیشتر؛ پس صفحه‌ای که فقط پوسته می‌خواهد هرگز بهای کتابخانه کامپوننت را نمی‌پردازد.

importچه دارد
ranuiهمه کامپوننت‌ها (به‌عنوان اثر جانبی همه عناصر <r-*> را ثبت می‌کند)
ranui/<component>یک کامپوننت: ranui/button، ranui/select، ranui/modal
ranui/themeinitTheme / setTheme / getTheme و بازنویسی توکن؛ بدون عنصر
ranui/i18nموتور ترجمه؛ بدون عنصر
ranui/fontsGeist Sans و Geist Mono میزبانی‌شده توسط خودتان (فقط CSS مربوط به @font-face)
ranui/styleاستایل‌شیت، اگر پیکربندی شما خودش برش ندارد
ranui/builderسازنده روان DOM که کامپوننت‌ها با آن نوشته شده‌اند
ranui/ssr، ranui/ssr-streamرندر سمت سرور
ranui/testingکمک‌کننده‌هایی برای رسیدن به shadow rootِ بسته از درون آزمون
ranui/typingsتایپ‌های محیطی (تعریف عناصر برای JSX / TS)
js
import 'ranui/button'; // یک عنصر
import 'ranui'; // همه‌شان

برای اثر جانبی وارد کنید. import 'ranui/button' عنصر <r-button> را ثبت می‌کند؛ به‌ندرت به کلاسِ صادرشده نیاز دارید. استثنا رندر سمت سرور است که در آن خودتان نمونه می‌سازید.

قرارداد کامپوننت

اتریبیوت‌ها، ویژگی‌ها، رویدادها (به‌همراه شکل detail)، اسلات‌ها و partهای دقیق هر عنصر از روی سورس به COMPONENTS.md تولید می‌شوند. قواعد زیر همان چیزهایی‌اند که آن جدول نمی‌گوید.

اتریبیوت‌ها رشته‌اند؛ ویژگی‌ها تایپ دارند

اتریبیوت‌های HTML کوچک‌نویس و از جنس رشته‌اند؛ ویژگی متناظر کمل‌کیس است و مقدار واقعی می‌گیرد. یک وضعیت‌اند از دو راه:

html
<r-select showsearch dropdownclass="wide"></r-select>
js
select.showSearch = true; // ویژگی — کمل‌کیس
select.setAttribute('showsearch', ''); // اتریبیوت — کوچک‌نویس
  • اتریبیوت‌های بولی با حضورشان معنا می‌دهند، مثل disabled روی <button> بومی: هم disabled="" و هم disabled="false" یعنی غیرفعال. برای خاموش کردن، اتریبیوت را بردارید (یا ویژگی را false کنید).
  • مقدارهای پرمایه از راه ویژگی می‌روند. آرایه، شیء و File در اتریبیوت جان به در نمی‌برند: مثلاً attachments در r-attachments یک ویژگی است.
  • نام اتریبیوت در مارک‌آپ به بزرگی و کوچکی حرف حساس نیست، و به همین دلیل HTML بالا showsearch است اما ویژگی showSearch. در JSX شکل اتریبیوتی را بنویسید.

روی خودِ عنصر گوش کنید

کامپوننت‌های ranui رویدادهای CustomEvent می‌فرستند و بار آن‌ها همیشه در detail است:

js
select.addEventListener('change', (event) => {
  const { value, label } = event.detail;
});

اینکه رویدادی حباب می‌کند یا نه تصمیمی است در سطح هر کامپوننت، پس به عنصر ببندید نه به یک ظرف. هسته فرم‌ها و لایه‌ها (r-input، r-checkbox، r-select، r-modal) عمداً رویدادهای بی‌حباب را روی خودش می‌فرستد: changeی که از یک select درون فرم شما می‌آید نباید مثل changeی از خودِ فرم به نظر برسد. برخی دیگر حباب می‌کنند (و composedاند، پس از مرز shadow هم می‌گذرند): r-theme-switch، r-voice-button، r-attachments، r-conversation، r-tool-card، r-markdown، r-math، r-mermaid، r-router، r-route، r-link، r-colorpicker.

شنونده‌ای که روی خود عنصر باشد در هر دو حالت کار می‌کند؛ واگذاری روی یک نیا فقط برای گروه دوم کار می‌کند و برای گروه اول بی‌صدا شکست می‌خورد. پیش از تکیه بر واگذاری، سورس یا COMPONENTS.md را ببینید.

رویدادهای before* لغوپذیرند. r-modal پیش از عمل beforeopen / beforeclose می‌فرستد و event.preventDefault() بر آن گذار وتو می‌گذارد. جفت‌های open / close / afteropen / afterclose گزارش چیزی‌اند که پیشاپیش رخ داده و لغو نمی‌شوند.

js
modal.addEventListener('beforeclose', (event) => {
  if (hasUnsavedChanges) event.preventDefault();
});

اسلات‌ها و partها

محتوا از راه اسلات‌ها (پیش‌فرض و نام‌دار) وارد می‌شود و در سند شما می‌ماند، پس CSSِ خودِ صفحه شما به‌طور معمول استایلش می‌دهد. تنها چیزی که از دسترس بیرون است آن است که کامپوننت در درون خود می‌سازد، و ::part() برای همان است.

استایل‌دهی از آن‌سوی مرز shadow

هر کامپوننت ranui درون یک shadow rootِ بسته رسم می‌شود. CSSِ صفحه به درون نشت نمی‌کند و گزینشگرها از آن رد نمی‌شوند. دقیقاً چهار راه ورود هست، به ترتیب اولویت:

سازوکاربرای چهنمونه
ویژگی‌های سفارشیهرچه کامپوننت به‌شکل توکن در اختیار گذاشتهr-button { --ran-btn-background: #7c3aed; }
::part()دستکاری ساختاری‌ای که توکن‌ها پوشش نمی‌دهندr-card::part(footer) { justify-content: flex-end; }
اتریبیوت sheetCSS برنامه‌ای یا پویا که به درون تزریق می‌شودel.sheet = '.ran-btn { letter-spacing: .02em }'
محتوای اسلاتمارک‌آپی که در هر حال از آنِ شماست<span slot="extra">…</span>

ویژگی‌های سفارشی از همه بهترند چون از مرز به ارث می‌روند: گذاشتن یک توکن روی :root، روی یک دربرگیرنده یا روی خود عنصر، همه کار می‌کنند و اینها همان توکن‌هایی هستند که پوسته به کار می‌برد. partها و sheet شما را به ساختار درونی گره می‌زنند، پس آن‌ها را برای شکاف‌های واقعی نگه دارید و انتظار داشته باشید در هر ارتقا دوباره سراغشان بروید.

آنچه با هیچ درجه‌ای از ویژه‌بودن کار نمی‌کند: r-select .some-inner-class { … }، !important، یا querySelector به درون کامپوننت. بسته بودنِ root یعنی element.shadowRoot برای CSS شما، اسکریپت‌های شما و لوکیتورهای اجراکننده آزمون شما، همه، null است.

مالکیت وضعیت

برای هر مقدار تصمیم بگیرید مالکش کیست:

  • کامپوننت مالک است (کنترل‌نشده): مقدار آغازین را بگذارید و بعد هر بار که تغییر کرد مقدار را از detail رویداد بخوانید. ساده‌ترین راه، و پیش‌فرض فرم‌ها.
  • برنامه شما مالک است (کنترل‌شده): در هر رندر ویژگی را تعیین کنید و رویداد را درخواستی برای تغییر وضعیتتان بدانید، نه تغییری که پیشاپیش بر مدل شما رفته است.

چیزی که می‌شکند انجام هر دو است: نگه‌داشتن رونوشتی از مقدار کامپوننت در وضعیت خودتان، بازنوشتن آن با هر رویداد، و تعیین دوباره ویژگی از همان وضعیت. زیر تایپ سریع این دو از هم دور می‌افتند و نوشتن حین رویداد می‌تواند حلقه بزند. یک جهت را انتخاب کنید.

js
// کنترل‌شده: وضعیت سرچشمه حقیقت است، رویداد یک درخواست است
input.value = state.query;
input.addEventListener('input', (event) => {
  state.query = event.detail.value;
  render(); // که دوباره input.value را تعیین می‌کند — اما از یک مالک
});

هم‌نشینی با فریم‌ورک‌ها

این‌ها عناصر سفارشی استانداردند، پس چیز فریم‌ورک‌ویژه‌ای لازم نیست، اما سه نکته گاز می‌گیرند:

  • React (پیش از ۱۹) هر propی از JSX را به‌عنوان اتریبیوت می‌گذارد، پس مقدارهای پرمایه نمی‌رسند و propهایی از جنس onChange به رویدادهای سفارشی بسته نمی‌شوند. از یک ref استفاده کنید و در یک effect ویژگی‌ها را تعیین یا addEventListener را صدا بزنید. React ۱۹ وقتی ویژگی وجود داشته باشد آن را می‌گذارد اما همچنان رویداد سفارشی را با نام نمی‌بندد، پس ref را برای شنونده‌ها نگه دارید.
  • Vue تگ‌های ناشناخته را کامپوننت می‌پندارد مگر خلافش را بگویید؛ در پیکربندی بیلد، r- را به compilerOptions.isCustomElement بیفزایید. پس از آن :prop یک ویژگی و @change یک شنونده رویداد واقعی را، هر دو درست، می‌بندند.
  • Angular به CUSTOM_ELEMENTS_SCHEMA نیاز دارد؛ Svelte و Solid اتریبیوت‌ها و شنونده‌های on: / on را مستقیم رد می‌کنند و به چیزی نیاز ندارند.

کاربران TypeScript می‌توانند برای تعریف عناصر ذاتی JSX از import 'ranui/typings' استفاده کنند.

رندر سمت سرور

کامپوننت‌های ranui به Shadow DOM اعلانی سریالایز می‌شوند، پس سرور می‌تواند مارک‌آپ واقعی را بیرون بدهد و نخستین رسم پیش از اجرای هر جاوااسکریپتی درست باشد:

js
import 'ranui'; // رجیستری SSR را پر می‌کند
import { renderHTMLToString } from 'ranui/ssr-stream';

const html = await renderHTMLToString(`
  <r-button type="primary">Submit</r-button>
  <r-progress percent="65"></r-progress>
`);

renderToStream(html) همان است در قالب یک ژنراتور ناهمگام، برای پاسخ‌های استریمی؛ و renderToString(instance) در ranui/ssr یک نمونه کامپوننت را که خودتان ساخته‌اید سریالایز می‌کند. تگ‌های ناشناخته دست‌نخورده رد می‌شوند، پس اجرای آن روی یک صفحه کامل امن است.

دو چیز را بدانید:

  • کلاینت از نو می‌سازد، بازاستفاده نمی‌کند. چون rootها بسته‌اند، مرورگر نمی‌تواند درختی را که سرور رسم کرده برای کامپوننت بازاستفاده کند؛ پس هنگام ارتقا هر عنصر نسخه‌ای یکسان را از صفر می‌سازد. نخستین رسم را از سرور می‌گیرید؛ بازاستفاده در هیدریشن را نمی‌گیرید، و نباید به امید خوانده‌شدن توسط کلاینت، وضعیت را در مارک‌آپ shadowِ سمت سرور بگذارید.
  • هیچ چیز اندازه‌گیری‌شده‌ای روی سرور در دسترس نیست. هرچه به getBoundingClientRect یا offsetWidth وابسته باشد، پس از سوارشدن و در مرورگر حل می‌شود.

کارایی

  • در صفحه‌هایی که تنها چند تا را به کار می‌برند به تفکیک کامپوننت وارد کنید؛ بسته یکجا برای برنامه‌هایی است که بیشتر کتابخانه را استفاده می‌کنند.
  • گونه‌ها با تأخیر بار می‌شوند. r-icon و r-loading گونه را در زمان اجرا با نام می‌گیرند، پس هزینه پایه با شمار آیکن‌هایی که به کار نمی‌برید بالا نمی‌رود.
  • ویژگی را تعیین کنید، عنصر را از نو نسازید. جایگزین‌کردن یک عنصر سفارشی سازنده‌اش را دوباره اجرا می‌کند؛ تعیین یک ویژگی در جا به‌روزرسانی می‌کند.
  • نوشتن اتریبیوت‌ها را دسته کنید. هر نوشتن می‌تواند attributeChangedCallback را برانگیزد؛ هرجا شد وضعیت را پیش از درج سرهم کنید.

آزمون

shadow rootهای بسته جلوی لوکیتورهای آزمون را هم می‌گیرند. getByRole، getByText و querySelector در Playwright همگی سر مرز می‌ایستند و هیچ نمی‌یابند، پس مشخصه‌ای که با آن‌ها نوشته شده باشد سبز می‌شود در حالی که درباره عناصری ادعا می‌کند که هرگز ندیده است. دو مجموعه آزمون در همین مخزن پیش از آنکه کسی متوجه شود همین‌طور نوشته شده بودند. ranui/testing همان درزِ نام‌دار و مستندشده است:

js
import { insideShadow, settlePainted } from 'ranui/testing';

const label = await insideShadow(page, 'r-button', (root) => root.querySelector('[part=content]')?.textContent);

جز آن، قرارداد را بیازمایید نه درونیات را: یک اتریبیوت یا ویژگی بگذارید و درباره رویداد و آنچه کاربر می‌تواند درک کند ادعا کنید. ادعا بر پایه نام کلاس‌های درونی با هر بازآرایی می‌شکند و درباره درست کار کردن کامپوننت هیچ نمی‌گوید.

ضدالگوها

ضدالگوچرا شکست می‌خورد
واگذاری change روی یک ظرف برای r-input / r-selectآن رویدادها حباب نمی‌کنند؛ شنونده هرگز شلیک نمی‌شود. به خود عنصر ببندید.
document.querySelector('r-select').shadowRootrootِ بسته: همیشه null. از API عمومی، partها یا ranui/testing استفاده کنید.
استایل‌دادن به درونیات با r-card .inner { … }گزینشگرها با هیچ درجه ویژه‌بودنی از مرز نمی‌گذرند. توکن یا ::part() را به کار ببرید.
!important برای بردن در برابر یک کامپوننتتعارض آبشاری‌ای در کار نیست که ببرید: قاعده اصلاً اعمال نمی‌شود. همان راه‌حل بالا.
آینه‌کردن مقدار کامپوننت در وضعیت خودتان و برگرداندنشدو مالک برای یک مقدار؛ از هم دور می‌افتند و می‌توانند حلقه بزنند.
بازساختن عنصرها برای به‌روزرسانیسازنده دوباره اجرا می‌شود، فوکوس و وضعیت درونی از دست می‌رود. ویژگی را تعیین کنید.
نوشتن رنگ به‌صورت ثابت کنار یک کامپوننت پوسته‌آگاههمان لحظه که پوسته می‌چرخد می‌شکند. از توکن‌های معنایی استفاده کنید.
z-index فراگیر روی یک پوشش «مبادا» لایه‌ای باز شودمحتوای ایستا را برای همیشه بالای قاب خودتان می‌برد. با :has() محدودش کنید.
انتظار برای shadowRoot در آزمونمثل بالا: از راه ranui/testing یا بر پایه رفتار قابل مشاهده ادعا کنید.

مشارکت در ranui

مخزن برای کدِ کتابخانه استانداردهای سخت‌گیرانه‌تر خودش را دارد:

  • docs/DESIGN.md: استاندارد طراحیِ اجراشدنی؛ نه قاعده را pnpm -F ranui verify:design اجبار می‌کند.
  • docs/CODING.md: معماری کامپوننت، مالکیت وضعیت و قواعد آزمون برای کدِ کتابخانه.
  • docs/BUILDER.md: سازنده روان DOM و ابزارهای واکنشی آن.
  • CLAUDE.md در ریشه بسته: فایل راهنمای آغازین که در تاربال npm هم هست و هم آدم‌ها و هم عامل‌های کدنویس نخست آن را می‌خوانند.

پیش از باز کردن pull request: pnpm -F ranui test:all، pnpm -F ranui verify:design و pnpm verify:docs (جدول‌های API و توکن تولیدی‌اند؛ اگر کهنه بمانند CI شکست می‌خورد).

منتشرشده تحت مجوز MIT.