راهنمای کدنویسی
چگونه با ranui بسازیم: قرارداد کامپوننت چیست، مرز Shadow DOM کجا قواعد آشنای شما را عوض میکند، و کدام خطاها ارزش دارند پیش از مرتکبشدنشان بشناسیدشان.
نیمه دیداریِ این ماجرا راهنمای طراحی است و توکنها سیستم طراحی.
کجا به کار میآید: وقتی کامپوننتهای ranui را به یک برنامه وصل میکنید: انتخاب import، بستن رویدادها، استایلدادن به چیزی که یک گزینشگر به آن نمیرسد، رندر روی سرور، یا نوشتن آزمون.
اصول
- خودِ عنصر همان API است. اتریبیوتها، ویژگیها، رویدادها، اسلاتها و
::part()تمام قرارداد را میسازند. هر چیز دیگری که از بیرون میبینید جزئیات پیادهسازی است و جابهجا خواهد شد. - وضعیت را دقیقاً در یک جا نگه دارید. یا برنامه شما مالک مقدار است و آن را به درون میراند، یا کامپوننت مالک است و تغییر را به شما خبر میدهد. آینهکردن در هر دو سو همان راهی است که مقدارها از هم دور میافتند.
- از آنسوی مرز Shadow DOM با ویژگیهای سفارشی،
::part()،sheetو اسلاتها استایل بدهید. گزینشگرهای معمول از آن نمیگذرند و هیچ اندازهای از ویژهبودن این را عوض نمیکند. - آنچه را به کار میبرید وارد کنید. هر کامپوننت نقطه ورودی خودش را دارد؛ بسته یکجا آسودگی است نه الزام.
- پلتفرم را ترجیح دهید. اینها عناصر سفارشیاند:
addEventListener،setAttributeوhiddenطبق مشخصات کار میکنند و انتزاعهای فریمورکی روی آنها اختیاریاند.
نقاط ورود
هر نقطه ورود دقیقاً همان چیزی را ثبت میکند که نامش میگوید، نه بیشتر؛ پس صفحهای که فقط پوسته میخواهد هرگز بهای کتابخانه کامپوننت را نمیپردازد.
| import | چه دارد |
|---|---|
ranui | همه کامپوننتها (بهعنوان اثر جانبی همه عناصر <r-*> را ثبت میکند) |
ranui/<component> | یک کامپوننت: ranui/button، ranui/select، ranui/modal … |
ranui/theme | initTheme / setTheme / getTheme و بازنویسی توکن؛ بدون عنصر |
ranui/i18n | موتور ترجمه؛ بدون عنصر |
ranui/fonts | Geist Sans و Geist Mono میزبانیشده توسط خودتان (فقط CSS مربوط به @font-face) |
ranui/style | استایلشیت، اگر پیکربندی شما خودش برش ندارد |
ranui/builder | سازنده روان DOM که کامپوننتها با آن نوشته شدهاند |
ranui/ssr، ranui/ssr-stream | رندر سمت سرور |
ranui/testing | کمککنندههایی برای رسیدن به shadow rootِ بسته از درون آزمون |
ranui/typings | تایپهای محیطی (تعریف عناصر برای JSX / TS) |
import 'ranui/button'; // یک عنصر
import 'ranui'; // همهشانبرای اثر جانبی وارد کنید. import 'ranui/button' عنصر <r-button> را ثبت میکند؛ بهندرت به کلاسِ صادرشده نیاز دارید. استثنا رندر سمت سرور است که در آن خودتان نمونه میسازید.
قرارداد کامپوننت
اتریبیوتها، ویژگیها، رویدادها (بههمراه شکل detail)، اسلاتها و partهای دقیق هر عنصر از روی سورس به COMPONENTS.md تولید میشوند. قواعد زیر همان چیزهاییاند که آن جدول نمیگوید.
اتریبیوتها رشتهاند؛ ویژگیها تایپ دارند
اتریبیوتهای HTML کوچکنویس و از جنس رشتهاند؛ ویژگی متناظر کملکیس است و مقدار واقعی میگیرد. یک وضعیتاند از دو راه:
<r-select showsearch dropdownclass="wide"></r-select>select.showSearch = true; // ویژگی — کملکیس
select.setAttribute('showsearch', ''); // اتریبیوت — کوچکنویس- اتریبیوتهای بولی با حضورشان معنا میدهند، مثل
disabledروی<button>بومی: همdisabled=""و همdisabled="false"یعنی غیرفعال. برای خاموش کردن، اتریبیوت را بردارید (یا ویژگی راfalseکنید). - مقدارهای پرمایه از راه ویژگی میروند. آرایه، شیء و
Fileدر اتریبیوت جان به در نمیبرند: مثلاًattachmentsدرr-attachmentsیک ویژگی است. - نام اتریبیوت در مارکآپ به بزرگی و کوچکی حرف حساس نیست، و به همین دلیل HTML بالا
showsearchاست اما ویژگیshowSearch. در JSX شکل اتریبیوتی را بنویسید.
روی خودِ عنصر گوش کنید
کامپوننتهای ranui رویدادهای CustomEvent میفرستند و بار آنها همیشه در detail است:
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 گزارش چیزیاند که پیشاپیش رخ داده و لغو نمیشوند.
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; } |
اتریبیوت sheet | CSS برنامهای یا پویا که به درون تزریق میشود | 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رویداد بخوانید. سادهترین راه، و پیشفرض فرمها. - برنامه شما مالک است (کنترلشده): در هر رندر ویژگی را تعیین کنید و رویداد را درخواستی برای تغییر وضعیتتان بدانید، نه تغییری که پیشاپیش بر مدل شما رفته است.
چیزی که میشکند انجام هر دو است: نگهداشتن رونوشتی از مقدار کامپوننت در وضعیت خودتان، بازنوشتن آن با هر رویداد، و تعیین دوباره ویژگی از همان وضعیت. زیر تایپ سریع این دو از هم دور میافتند و نوشتن حین رویداد میتواند حلقه بزند. یک جهت را انتخاب کنید.
// کنترلشده: وضعیت سرچشمه حقیقت است، رویداد یک درخواست است
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 اعلانی سریالایز میشوند، پس سرور میتواند مارکآپ واقعی را بیرون بدهد و نخستین رسم پیش از اجرای هر جاوااسکریپتی درست باشد:
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 همان درزِ نامدار و مستندشده است:
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').shadowRoot | rootِ بسته: همیشه 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 شکست میخورد).