كان من المفترض أن تكون الشاشة بسيطة. حقل نموذج لأصل رحلة. combobox تبحث فيه بالاسم أو برمز IATA، مع مجموعات مدن، وصفوف معطّلة للمداخل غير النشطة، وكل ARIA المعتاد الذي تتولاه آلة Zag. القائمة، في بداية المشروع، كانت «كل مطار في العالم».
هذه القائمة حوالي تسعة آلاف صف في يوم جيد. بعض الخرائط تحسب المدارج العسكرية. بعضها تحسب قواعد الطائرات المائية. الرقم لا يهم بمجرد أن يتجاوز بضعة آلاف: المتصفح سيشعر به.
جربت الطريقة الكسولة أولاً. أرسلت القائمة كاملة كـ items عند mount وتركت combobox يصفّي على العميل، وهو افتراض Corex. أول رسم كان جيداً. الـ hook اتصل جيداً. ثم كتبت حرفاً واحداً وراقبت القائمة تحاول تخطيط سبعة آلاف خيار في إطار واحد. التمرير تقطع. حلقات التركيز تأخرت نصف نبضة. على الهاتف، التبويب كله فقد إطارات لنصف ثانية.
الإصلاح لم يكن مكوّناً مختلفاً. كان تغذية مختلفة لنفس المكوّن.
الافتراض صحيح لنوع واحد من القوائم
التصفية على العميل هي الافتراض الصحيح لمنتقي دول. لـ enum طرق الدفع. لقائمة وسوم تتسع في الرأس. المتصفح يحتفظ بالقائمة كاملة في الذاكرة، يضيّقها عند كل ضغطة، لا يسأل الخادم شيئاً. زمن الاستجابة غير مرئي لأنه لا شبكة في الحلقة.
هذا النموذج ينكسر عندما يتوقف الكتالوج عن أن يتسع براحة في DOM واحد. تسعة آلاف صف تجاوزت ذلك النقطة، وكذلك أي شيء يلمس جدولاً حقيقياً حيث تعمل مطابقات جزئية، طيّ الحركات، ترتيب، أكثر من فحص substring حرفي.
المثير أن الجزء الذي يؤلم في تلك الحالات ليس الجزء الذي تفترضه. تصفية تسعة آلاف صف في JavaScript سريعة. رسم تسعة آلاف صف DOM هو ما يقتلك. لوحة المفاتيح، التمييز، typeahead، ARIA كلها على بضعة صفوف مرئية. لا تهتم كم صف غير مرئي خلفها. إن أبقيت الشريحة المرئية صغيرة، الآلة لا تلاحظ أن الكتالوج كبر.
علم واحد، معالج حدث واحد
التبديل كله سمة واحدة على المكوّن ومعالج حدث واحد في LiveView.
<.combobox
id="airport-combobox"
class="combobox combobox--accent combobox--lg"
placeholder="Search airports…"
items={@items}
filter={false}
on_input_value_change="search_airports"
>
<:empty>No results</:empty>
<:trigger>
<.heroicon name="hero-chevron-down" />
</:trigger>
</.combobox>
filter={false} يقول: لا تضيّق القائمة بنفسك. خذ ما أعطاك الخادم في items واعرضه. الآلة ما زالت تملك التمييز، حالة الفتح، لوحة المفاتيح، التركيز، ARIA. لا شيء من ذلك يتحرك. الشيء الوحيد الذي يتحرك هو من يقرر أي صفوف موجودة في items الآن.
اسم الحدث على on_input_value_change هو ما يطلقه الـ hook عندما تتغيّر القيمة المكتوبة. تتعامل معه على الخادم كأي حدث LiveView آخر. الحمولة تحمل value (ما كتبه المستخدم) وreason (input-change، clear-trigger، item-select، بهذا الترتيب تقريباً للفائدة).
LiveView يتنفس مع المستخدم
المعالج قصير. ابنِ الشريحة الأولية في mount. عند كل ضغطة، ابحث في قاعدة البيانات بحد أعلى، لف النتيجة في Corex.List.new/1، assign. عند المسح، أعد الشريحة الأولية. عند الاختيار، لا شيء (الآلة حدّثت قيمتها؛ تحتاج التفاعل فقط إن كنت controlled).
defmodule MyAppWeb.AirportComboboxLive do
use MyAppWeb, :live_view
@page_size 120
def mount(_params, _session, socket) do
rows = Airports.list_first(@page_size)
{:ok, assign(socket, :items, Corex.List.new(format_rows(rows)))}
end
def handle_event("search_airports", %{"reason" => "clear-trigger"}, socket) do
rows = Airports.list_first(@page_size)
{:noreply, assign(socket, :items, Corex.List.new(format_rows(rows)))}
end
def handle_event("search_airports", %{"value" => value}, socket) when is_binary(value) do
rows =
if String.trim(value) == "" do
Airports.list_first(@page_size)
else
Airports.search(value, limit: @page_size)
end
{:noreply, assign(socket, :items, Corex.List.new(format_rows(rows)))}
end
def handle_event("search_airports", _params, socket), do: {:noreply, socket}
defp format_rows(rows) do
Enum.map(rows, fn row ->
%{value: row.iata_code, label: "#{row.name} (#{row.iata_code})"}
end)
end
end
الرقم الذي تختاره لـ @page_size هو أهم جزء في هذا الكود، وهو أيضاً الأكثر مللاً. 120 عمل لي في هذا المشروع. كبير بما يكفي ليشعر «أفضل مئة تطابق» بالسخاء لمستخدم يكتب بسرعة. صغير بما يكفي أن المتصفح لا يتقطع. وصغير بما يكفي أن SQL رخيص على جدول مفهرس جيداً.
إن كان مخزن البيانات بطيئاً، debounce داخل handle_event وألغِ العمل القديم حتى لا تسبق الضغطة الثالثة الأولى في العودة للمستخدم. الاستجابات خارج الترتيب تجعل combobox يبدو مسكوناً.
لماذا ما زال يشعر كمكوّن واحد، لا اثنين
هناك شيء هادئ يحدث في كل مرة يكتب المستخدم حرفاً. الـ hook يدفع القيمة للخادم. الخادم يشغّل الاستعلام ويستبدل items. LiveView يفرق DOM. الآلة ترى items الجديدة عبر updateProps وتوفّق داخلياً. التمييز، التركيز، حالة الفتح، لا شيء يُعاد ضبطه. المستخدم كتب حرفاً، مئة صف تبدّلت بهدوء تحت المؤشر، وكل شيء آخر وقف.
ذلك يعمل لأن العقد بين الآلة والـ patch صلب. لا شيء من هذا سيبقى إن كان كل patch يرمي الآلة ويعيد mount. السبب الذي جعل محول vanilla يقبل updateProps هو الإبقاء على ما يفعله المستخدم في منتصفه بينما تتغيّر props تحته.
هذا ما يجعل المغذّى من الخادم يشعر كالمغذّى من العميل. نفس آلة Zag. نفس ARIA. نفس لوحة المفاتيح. مصدر مختلف لأي صفوف موجودة في هذا الإطار.
حفنة أشياء لا تفعلها
أنماط لتجنبها، تعلّمتها بالطريقة البطيئة.
إرسال آلاف items عند mount وترك filter مفعّلاً هو الأشيع. combobox سيعمل تقنياً. الهاتف لن يعجب.
إعادة بناء items داخل render/1 هو الثاني. العناصر في assigns. القالب يقرأ. لا يحسب قوائم كبيرة لكل إطار.
تجاهل reason هو الثالث. تعامل مع clear-trigger صراحة، وإلا زر المسح يترك المستخدم ينظر لشريحة من استعلامين مضى، يبدو معطّلاً حتى لو كان صحيحاً تقنياً.
وCorex.List.new/1 ليس زينة. يوسم القائمة كشيء يعرف combobox كيف يستهلكه. maps خام في assigns لن تنجو من patch بنظافة.
عندما تصبح الشريحة نفسها ميزة
بمجرد أن يكون النمط لديك، يمكنك أشياء كانت تبدو مبالغة قبل. الشريحة الأولية يمكن أن تكون مطارات المستخدم الأكثر استخداماً من تاريخ الحجز. استجابة الاستعلام الفارغ يمكن أن تكون مجموعة «بالقرب منك» منسّقة. البحث يمكن أن يُرتّب بشيء أثير من substring حرفي. لا شيء من ذلك يغيّر العقد على العميل. الآلة ترى قائمة items محدودة وترسم comboboxاً قابلاً للاستخدام. الخادم يفعل ما يريد تحته بهدوء.
هذا الجزء الذي أعود إليه. combobox المغذّى من الخادم ليس فقط مخرج أداء. طريقة لوضع حكم خلفي حقيقي خلف مكوّن يعرف المستخدم كيف يشغّله أصلاً.
الفكرة
الكتالوجات الكبيرة لا تحتاج مكوّناً مختلفاً. تحتاج تغذية مختلفة. أوقف التصفية على العميل. أرسل شريحة محدودة عند كل ضغطة. لفها في Corex.List.new/1. دع الآلة تحتفظ بالتمييز ولوحة المفاتيح وARIA. دع الخادم يحتفظ بالكتالوج.
يمكنك رؤية هذا يعمل على جدول مطارات حقيقي في /ar/combobox/patterns. نفس آلة Zag التي تحصل عليها في منتقي دول، مُقدَّمة من جدول لن تريده في DOM.
تنسيق تلك القائمة هو تصميم Corex. توصيل الـ hook هو آلة Vanilla JS. اختيار الشكل الصحيح للقالب هو التشريح. امتلاك القيمة على الخادم هو عقلان.