Leptos 0.8.22SSR على Axum، وعميل TypeScript، ومن غير WASM
المتجر ده متبني إزاي
Leptos بيرسم كل صفحة على السيرفر. وعميل TypeScript صغير بيغيّر الحالة في المتصفح ومابيطلبش من السيرفر غير البيانات اللي مش عنده. كل كارت تحت بيقول الجزء ده بيعمل إيه وليه، وبيشغّله قدامك، وبيوديك للمكان اللي المتجر بيستخدمه فيه، وبيعرض الكود.
- 16كارت، وفي كل واحد ديمو حي
- 0بايت WebAssembly
- 0.9 KBJavaScript من غير ضغط، مصغّر
- سوقالمحل، من الـ Host
- 1المتصفحبيطلب /showcase ومعاه الـ Host والكوكيز.
GET /showcase - 2Axumبيحدد المحل من الـ Host، وبيقدّم /js و /media وأدوات /__prefs و /__sim، وبيضغط.
tower-http · moka - 3Leptos SSRبيرسم الـ route بالترتيب في مستند واحد كامل؛ وكود الحالة والهيدرز بيخرجوا الأول.
SsrMode::InOrder - 4بيانات الصفحة#app-config و #app-strings: المحل واللغة والـ endpoints وكل نصوص الواجهة اللي العميل محتاجها.
application/json - 5TypeScriptملف واحد بيبدأ: يقرا الصفحة، ويظبط الـ stores، ويركّب الـ features، ويشغّل الراوتر.
/js/app-….js
Leptos على السيرفر
كل صفحة كومبوننت Leptos بيترسم على السيرفر في مستند HTML واحد كامل. مفيش Rust بيشتغل في المتصفح.
SSR بالترتيب في كل صفحة
Leptos على السيرفركل route بيترسم بـ SsrMode::InOrder. Leptos بيرسم الصفحة من فوق لتحت وبيبعتها بنفس الترتيب، وبيستنى عند كل <Suspense> لحد ما بياناته توصل، فكل بايت طالع من السيرفر HTML نهائي. مفيش سكريبت محتاج يحط حاجة في مكانها.
ليه ده مهم هناالصفحات لازم تشتغل من غير JavaScript، والراوتر بتاع TypeScript بيبدّل صفحات كاملة جاية من السيرفر: الاتنين محتاجين HTML كامل. وكود الحالة وهيدرز الكاش بيتحددوا قبل أول بايت، فالـ 404 بيبقى 404 بجد.
ديمو حي
/InOrder/c/:slugInOrder/searchInOrder/p/:slugInOrder/cartInOrder/cart/editInOrder/checkoutInOrder/ordersInOrder/orders/:idInOrder/showcaseInOrder/uiInOrder/core-scratchInOrder/showcase/streamOutOfOrderمتقري من crates/app/src/lib.rs وقت بناء السيرفر.
- أول بايت من الصفحة دي
- —
- آخر بايت
- —
- الـ HTML، مضغوط ومفكوك
- —
- السكريبت اللازم عشان تشوف الصفحة
- ولا حاجة
التوقيتات من المتصفح نفسه (Navigation Timing)، للمستند اللي التاب ده حمّله الأول.
البث بأي ترتيب، في route لوحده
Leptos على السيرفرLeptos يقدر كمان يبعت إطار الصفحة الأول، وبعدين كل <Suspense> أول ما بياناته تخلص. كل قسم بيوصل كـ <template> ومعاه سكريبت صغير بيحطه في مكانه. صفحة الديمو /showcase/stream هي الوحيدة هنا اللي بتتبعت بالطريقة دي.
ليه ده مهم هنابيدّي أسرع أول رسمة، بس الأقسام مابتتحطش في مكانها غير لو الـ JavaScript شغال، والراوتر اللي بيجيب الصفحة كـ HTML مش هياخدها غير لما أبطأ قسم يخلص. عشان كده لينكات الديمو عليها data-router="off": التحميل الكامل للصفحة هو الطريقة الوحيدة اللي تتفرج بيها على البث.
ديمو حي
الاتنين بيفتحوا تحميل كامل للصفحة (data-router="off"). راوتر العميل كان هيجيب الصفحة كـ HTML وياخدها بعد أبطأ بلوك، فمش هتشوف أي بث.
دوال السيرفر هي الـ API
Leptos على السيرفر#[server(endpoint = "cache/stats", input = GetUrl)] بيحوّل دالة Rust async لـ endpoint ليه URL ثابت. كومبوننتس السيرفر بتناديها زي أي دالة. وعميل TypeScript بيعمل fetch للـ URL ويرجعله JSON، والفلوس متنسقة جاهزة.
ليه ده مهم هنادالة Rust واحدة بتخدم قراءات السيرفر نفسه وطلبات العميل، فمفيش طبقة REST تانية لازم تفضل ماشية معاها. والأسماء الثابتة بتخلّي الـ URLs زي ما هي من build للتاني (الأسماء الافتراضية فيها hash)، فالعميل والـ CDN يقدروا يعتمدوا عليها.
ديمو حي
- الحالة
- —
- الوقت
- —
- Cache-Control
- —
{ … }الـ endpoints اللي عميل الصفحة دي يقدر يكلمها، من #app-config:
GET /api/cache/statsPOST /api/cache/bump
حدود للأخطاء، واحد لكل قسم
Leptos على السيرفركل قسم بيقرا بيانات ليه <ErrorBoundary> خاص بيه. لو القراءة فشلت، القسم ده بس بيقول إيه اللي فشل ويديك «إعادة المحاولة»، وباقي الصفحة بيترسم عادي. ولو الفشل معروف قبل أول بايت، بتطلع صفحة خطأ كاملة بـ 500 أو 503 حقيقي.
ليه ده مهم هنالو خدمة الترشيحات بطيئة أو واقعة، ماينفعش توقّع صفحة المنتج معاها. و ?fail=<widget> بيبوّظ قراءة واحدة بالقصد، عشان نقدر نجرّب كل حالة خطأ.
ديمو حي
home_rail(Deals)
طماطم شيري29.99 ج.م
فاصوليا حمرا بونديل59.99 ج.م
مانجو74.99 ج.م
home_rail(Bestsellers)تعذّر تحميل الأكثر مبيعًا. باقي الصفحة يعمل بشكل طبيعي.
كل بلوك بيقرا جوه <Suspense> و <ErrorBoundary> خاصين بيه، ومترسومين بالترتيب. لو واحد فشل، التاني بيظهر عادي، وباقي الصفحة كمان.
كاش القراءة (moka)
Leptos على السيرفرقراءات الكتالوج كلها بتعدّي على كاش moka واحد، مفتاحه tenant:generation:kind:key. العناصر بتنتهي بعد الـ TTL بتاعها، ولو زوّدنا الـ generation بتاع محل، كل عناصره بتتشال مرة واحدة من غير ما نلف عليها.
ليه ده مهم هناالليستات والأقسام ونتايج البحث بتتحسب مرة واحدة وتتقدّم من الميموري لكل زائر بيطلب نفس الحاجة. وتعديل الكتالوج مجرد زيادة عداد، ومعاها purge للـ CDN بالـ Cache-Tag في الإنتاج.
ديمو حي
- العناصر
- 10
- مرات لقاها
- 7,480
- مرات ملقاهاش
- 5,790
- نسبة اللقيان
- 56%
- الـ generation بتاع المحل ده
- 0
الزيادة بتشيل كل القرايات المتخزنة للمحل ده: أول صفحة تفتحها بعدها مش هتلاقي، وبعدين هتلاقي تاني.
المحل من الـ Host
Leptos على السيرفرسيرفر واحد بيخدم المحلين. المحل بيتحدد من أول جزء في الـ Host (nile.… و souq.…). وبيتحدد قبل الرسم، فاللوجو والاسم واللغة الافتراضية بيبقوا في أول بايت.
ليه ده مهم هناإضافة محل جديد بيانات، مش deployment. والـ hosts اللي من غير اسم محل، زي IP على الموبايل، بترجع لكوكي المحل.
ديمو حي
- Host
- souq.leptos-preview.duckdns.org
- اتحدد من
- أول جزء في الـ Host (طريقة الإنتاج)
- المحل
- souq · Souq / سوق
- اللغة الافتراضية
- العربية
- الكتالوج
- 13 قسم · 192 منتجًا
- التوصيل
- 25.00 ج.م · مجاني فوق 600.00 ج.م · سريع في 30 دقيقة
- أكواد الطلبات
- SOQ-48213
- بادئة الـ Cache-Tag
- tsouq
الصفحة دي في نايلsubdomain تاني يعني origin تاني: تحميل كامل للصفحة، وكوكيز خاصة بيه.
الـ head والـ meta
Leptos على السيرفر<Title> و <Meta> و <Link> من leptos_meta بيتكتبوا في ساعتها من بيانات في الميموري. العنوان والوصف والـ canonical والـ OpenGraph والـ JSON-LD كلهم بيبقوا في الـ <head> من أول chunk.
ليه ده مهم هنامحركات البحث ومعاينات اللينكات بتقرا أول رد ومابتشغّلش سكريبتات. ومع الـ SSR بالترتيب ومن غير head بيستنى بيانات، بيشوفوا بالظبط اللي الزبون بيشوفه.
ديمو حي
- <title>
- المتجر ده متبني إزاي
- robots
- noindex
متقري دلوقتي من الـ <head> بتاع المستند ده. وبعد التنقل جوه الموقع بيعرض الـ head بتاع الصفحة الجديدة، اللي الراوتر بيجيبه معاها.
تأخير وأعطال تجريبية
Leptos على السيرفر/__sim?ms=350 بيحط كوكي ليك انت بس. بعدها قراءة كل widget بتستنى مضاعف من الرقم ده: العروض 2.6×، والمنتجات المشابهة 1.8×، والبحث 0.25×. و ?fail=<widget> بيبوّظ قراءة واحدة في طلب واحد، و /__sim?fail=deals بيبوّظها طول الجلسة.
ليه ده مهم هناعلى localhost كل حاجة بترد في ساعتها، وده بيخبّي حالات التحميل والموبايلات البطيئة والأعطال الجزئية. هو مقفول افتراضيًا، وبيه اتجرّبت كل حالة تحميل وتأخير وخطأ هنا.
عميل TypeScript
ملف ES module واحد صغير، بيجمّعه esbuild. بيغيّر الحالة في ساعتها، ومابيكلمش السيرفر غير لما يحتاج بيانات مش عنده.
الراوتر وكاش الصفحات
عميل TypeScriptcore/router.ts بيمسك اللينكات والفورمز اللي على نفس الموقع. بيجيب HTML الصفحة الجاية، ويبدّل أجزاء الصفحة، ويركّب الـ features في الجزء اللي وصل. الصفحات بتتخزن بـ TTL و stale-while-revalidate، والوقوف بالماوس (65 مللي ثانية) أو لمسة الصباع بيعملوا prefetch، و refresh() بيتخطّى الكاش.
ليه ده مهم هناالتنقل بيبقى فوري من غير ما الصفحات تترسم في المتصفح. السيرفر لسه هو اللي بيرسم كل صفحة، والراوتر بس بينقل الـ HTML. ومن غير JavaScript نفس اللينكات بتبقى تحميل صفحة عادي.
ديمو حي
- routing.pending
- false
- routing.to
- null
- التنقلات في التاب ده
- 0
اعمل prefetch وبعدين افتح: الصفحة بتطلع من كاش الصفحات. والوقوف على لينك 65 مللي ثانية أو حط صباعك عليه بيعمل نفس الحاجة.
store واحد لكل حاجة
عميل TypeScriptcreateStore(initial) هو الأداة الوحيدة للحالة: قيمة ومعاها مشتركين. كل حاجة ليها store واحد بس، بيتعمل مرة واحدة في stores/*.ts: السلة، وطابور التنبيهات، وحالة الراوتر وهو بيحمّل. وأي feature محتاجة واحد منهم بتستورد نفس الـ store ده.
ليه ده مهم هناعلامة السلة في الهيدر والدرج وصفحة السلة مستحيل يختلفوا طالما بيقروا من نفس الـ store. وكمان فيه طريق واحد لكل عملية: السلة مابتتغيرش غير من stores/cart.ts.
ديمو حي
- cart
- —
- toasts
- —
- routing
- —
كل سطر اشتراك حي. اعمل كام تنبيه: الطابور بيحتفظ بتلاتة، والأحدث قدام، والـ toaster بيرسمهم من نفس الـ store.
السلة عايشة في كوكي
عميل TypeScriptالمتصفح هو اللي ماسك السلة: سطور زي 2307:1:2 في كوكي cart، ومحدش بيكتبها غير stores/cart.ts. الإضافة والشيل وتغيير الكمية بيحصلوا عندك في ساعتها، من غير أي طلب. والسيرفر بيقرا نفس الكوكي بس لما يحتاج يسعّرها: في صفحة السلة، ولحساب الدرج، وعند الدفع.
ليه ده مهم هنااللمسات عمرها ما بتستنى النت، والسلة بتفضل بعد الريلود وبتفضل متظبطة بين التابات، والفورمز اللي من غير JS بتغيّر نفس الكوكي بالظبط. والأسعار والمخزون والإجمالي لسه جايين من السيرفر ومتنسقين.
ديمو حي
- كوكي cart
- —
- عدد القطع في السلة
- —
- طلبات عشان التغيير ده
- —
- حساب السيرفر
- —
دي سلتك الحقيقية: علامة الهيدر والدرج ماشيين معاها. الإضافة والشيل مابيعملوش أي طلب؛ «اسأل السيرفر» بس هو اللي بيعمل، ومابيبعتش غير الكوكي.
قوالب بيملاها TypeScript
عميل TypeScriptلما العميل يحتاج يعرض بيانات، زي تنبيه أو سطر في السلة أو اقتراح بحث، بياخد نسخة من <template id="tpl-…"> جاي من السيرفر، و fill() بيحط كل [data-slot] كنص. الـ markup والكلام المترجم بتاعه متكتبين مرة واحدة، في Rust.
ليه ده مهم هنامفيش نسخة تانية من الواجهة في العميل، ولا HTML متركّب من نصوص، ولا حاجة بتتحط كـ HTML. وأي تعديل في شكل كومبوننت بيتعمل في مكان واحد، والعميل بياخده.
كاش البيانات في العميل
عميل TypeScriptcore/cache.ts بيحتفظ بقراءات الـ API والصفحات في الميموري: طازة لمدة الـ TTL، وبعدها stale-while-revalidate (بتظهر على طول وتتحدّث في الخلفية)، وبعدين تتشال. والقراءات لنفس المفتاح في نفس الوقت بتشترك في طلب واحد، وكل مفتاح فيه المحل واللغة ونوع الأرقام.
ليه ده مهم هنالما ترجع لصفحة أو تفتح الدرج تاني البيانات بتظهر على طول، وتغيير اللغة عمره ما يعرض صفحة متخزنة باللغة التانية. ولو السيرفر بعت generation جديد للكتالوج (x-catalog-gen)، كل حاجة بتتشال.
ديمو حي
- المفتاح
- —
- العمر
- —
- stats() في cache.ts
- —
القيمة هي أرقام كاش السيرفر، ومتخزنة هنا 5 ثواني طازة و10 ثواني كمان قديمة. دوس كذا مرة وشوف الحالات التلاتة.
حركة السبرينج من core/motion.ts
عميل TypeScriptSpringValue بيحرّك رقم بسبرينج متخمّد، بيتحسب بالظبط في كل فريم. وتقدر تقاطعه: أي هدف جديد بيبدأ من القيمة والسرعة اللي عليها دلوقتي. والزقّة بتسلّم سرعتها لحظة ما تسيب، و project() بيقول هتقف فين، و rubberband() بيقاوم بعد الطرف.
ليه ده مهم هناالأدراج والشيتات والجاليري بيمشوا مع صباعك وبيخرجوا من نفس السكة اللي دخلوا منها، زي iOS. وفيه تنفيذ واحد لكل الحركات، فمفيش حاجة بتتحرك بمنحنى مختلف.
ديمو حي
- القيمة
- 0
- السرعة
- 0
- هيقف عند
- —
اسحب النقطة وسيبها. لو سبتها براحة هتقف عند أقرب علامة؛ ولو زقيتها هتتحسب وهتقف مكان ما كانت هتهدى. وبعد أي طرف بتتمط زي الأستك.
حجم العميل
عميل TypeScriptكود المتصفح كله ES module واحد. بيتشيك عليه tsc، وبيجمّعه esbuild من crates/app/build.rs، وبيتحط جوه السيرفر نفسه، وبيتقدّم على /js/app-….js باسم فيه hash، فالمتصفح بيحتفظ بيه على طول.
ليه ده مهم هناالميزانية للعميل كله 30 كيلوبايت gzip. وجزر الـ WASM اللي جه مكانها كانت بتكلّف من 379 لـ 769 كيلوبايت brotli في كل صفحة، قبل أي جزء خاص بالصفحة.
ديمو حي
- الملف
- /js/app-4EHXELV3.js
- من غير ضغط، زي ما اتبنى
- 0.9 KB
- الكود بس (من غير source map)، gzip
- —
- زي ما المتصفح بتاعك استلمه
- —
نسخة release: مصغّرة ومن غير source map.
قرارات
عملنا إيه الأول، وكلّفنا كام، وليه غيّرنا الطريق.
ليه سبنا جزر الـ WASM
قراراتالنسخة الأولى كانت بجزر Leptos: كومبوننتس #[island] بتتحول WebAssembly وتصحى في المتصفح، والـ islands router هو اللي بيعمل التنقل. كانت شغالة، وقسناها في Chromium على سيرفر الـ release.
ليه ده مهم هناكل صفحة كانت بتدفع من 379 لـ 769 كيلوبايت WASM (brotli) في متجر أغلبه ثابت، والـ hydration كان فيه مشاكل توقيت بتبوّظ صفحات كاملة. ودي القياسات.
ديمو حي
- ملف واحد، brotli
- 769 KB
- نسخة مقسّمة، الملف الأساسي
- 379 KB
- إعادة البناء بعد تعديل سطر
- 36–48 ثانية (السيرفر لوحده: 15 ثانية)
- جزيرة لسه مستنية بيانات وقت الـ hydration كانت بتعمل panic بعد التنقل جوه الموقع، والـ panic كان بيوقف كل الجزر اللي بعدها، ومعاهم علامة السلة في الهيدر.
- جزيرة lazy اتبنت من غير --split خلّت كود الـ JS يستورد module وهمي، فمفيش ولا جزيرة في أي صفحة صحيت.
- قيم معمولة للمتصفح جوه جسم الكومبوننت كانت ممكن تتشال على thread تاني في السيرفر، فيحصل panic والـ HTML يتقطع، بشكل عشوائي.
- الـ islands router كان محتاج حوالي 400 سطر ترقيع: ظبط الـ head، والـ <noscript>، وكاش المتصفح، وزرار الإرسال اللي اتداس، والـ scroll والرجوع.
اللي جه مكانها متقاس دلوقتي في «حجم العميل». شوفه