منصة ويب: الأسلوب المعتمد لإنشاء الواجهة وآلية تحديثها

يتناول هذا المقال الأسلوب المعتمد لكتابة برامج واجهات المستخدم باستخدام مكتبة مـنصة_ويب من ناحية طريقة كتابة البرنامج والتعامل مع عناصر الواجهة أثناء تفاعل المستخدم معها، كما يوضح آلية عمل مـنصة_ويب خلف الكواليس لتحديث الواجهة.

التفاعل مع عناصر واجهة المستخدم

المطلع على لغة الأسس ومكتبة مـنصة_ويب ومقالاتنا ومرئياتنا السابقة بهذا الخصوص يعلم الآن أن أسلوب إنشاء الواجهة يعتمد على المؤثر .{} لإنشاء شجرة العناصر التي تكون الواجهة. على سبيل المثال، إذا أردنا إنشاء صندوق وفي داخله نص ثم حقل إدخال ثم زر يكون الكود كالتالي:

نـافذة.النموذج.حدد_المشهد(
    صـندوق().{
        أضف_فروع({
            كـتابة(نـص("أدخل البيانات"))،
            مـدخل_نص()،
            الـزر(نـص("إرسال")).{
                عند_الضغط.اربط(مغلفة (عنصر_واجهة: سند[ودجـة]، حمولة: سند[صـحيح]) {
                })؛
            }
        })؛
    }
)؛

نفذ_حلقة_معالجة_الأحداث()؛
Window.instance.setView(
    Box().{
        addChildren({
            Text(String("Enter data")),
            TextInput(),
            Button(String("Send")).{
                onClick.connect(closure (widget: ref[Widget], payload: ref[Int]) {
                });
            }
        });
    }
);

runEventLoop();

لكن، كيف نتعامل بعد ذلك مع حقل الإدخال (مـدخل_نص) لجلب القيمة المدخلة؟ الأمر بسيط، وهو أن نحتفظ بمؤشر إليه لنتعامل معه لاحقًا، كما يلي:

عرف المدخل: سـندنا[مـدخل_نص]؛

نـافذة.النموذج.حدد_المشهد(
    صـندوق().{
        أضف_فروع({
            كـتابة(نـص("أدخل البيانات"))،
            مـدخل_نص().{
                المدخل = هذا؛
            }،
            الـزر(نـص("إرسال")).{
                عند_الضغط.اربط(مغلفة (عنصر_واجهة: سند[ودجـة]، حمولة: سند[صـحيح]) {
                    استخدم_النص_المدخل(المدخل.النص)؛
                })؛
            }
        })؛
    }
)؛

نفذ_حلقة_معالجة_الأحداث()؛
def input: SrdRef[TextInput];

Window.instance.setView(
    Box().{
        addChildren({
            Text(String("Enter data")),
            TextInput().{
                input = this;
            },
            Button(String("Send")).{
                onClick.connect(closure (widget: ref[Widget], payload: ref[Int]) {
                    useUserData(input.text);
                });
            }
        });
    }
);

runEventLoop();

في هذا المثال نحن نحتفظ بسند يشير إلى العنصر، وتحدد قيمة هذا السند عند إنشاء العنصر، ثم في دالة عند_الضغط الخاصة بالزر نتعامل مع هذا المتغير للوصول إلى القيمة التي أدخلها المستخدم.

هذه الطريقة تعمل، لكن فيها مشكلة، وهي أن المتغير المدخل صالح للاستعمال طالما أن التنفيذ لم يخرج من هذه الدالة. في حالة الدالة الرئيسية لا مشكلة لأنها دالة لا منتهية حيث يعلق التنفيذ فيها عند الأمر نفذ_حلقة_معالجة_الأحداث الغير منتهي، وبالتالي لا نخرج من الدالة أبدًا ويبقى المتغير المدخل صالحا طوال تنفيذ البرنامج. أما لو كانت هذه الدالة غير الدالة الرئيسية، كأن تكون دالة تنشئ الشجرة وترجعها لدالة أخرى تعرضها، فإننا في هذه الحالة لا نستطيع استخدام المتغيرات المحلية لأنها مؤقتة تُزال من الذاكرة عند الخروج من الدالة رغم أن الواجهة نفسها ما زالت صالحة ومعروضة على المتصفح. في مثل هذه الحالات لدينا حلان: إما استخدام متغيرات عمومية، وهو ما لا ننصح به إلا نادرا، أو استخدام الأسلوب الصحيح وهو الاعتماد على المركبات (components).

استخدام المُركّبات (components)

تتيح لك المركبات تحزيم واجهة مستخدم مع البيانات والعمليات المصاحبة لها في عنصر واحد يمكن إعادة استخدامه في أماكن متعددة، كما هو الحال مع الcomponents في معظم واجهات المستخدم مثل React و Angular وغيرها. لإنشاء مركب تحتاج لتعريف صنف جديد مشتق من الصنف مـركب (Component) ثم تنشئ واجهة المستخدم في مهيئ (constructor) ذلك الصنف. في هذه الحالة يمكنك تعريف المتغيرات التي تشير إلى عناصر من واجهة المستخدم كأعضاء في الصنف بدل أن تكون متغيرات مؤقتة، وهذا يضمن بقاءها في الذاكرة ما دام المركب في الذاكرة. هذا المثال يوضح كيف يمكن تحويل الكود أعلاه لصيغة مركب وكيفية استخدامه لاحقا في الدالة الرئيسية:

صنف مـركبي {
    @حقنة عرف مركب: مـركب؛
    عرف المدخل: سـندنا[مـدخل_نص]؛

    عملية هذا~هيئ() {
        عرف الكائن: سند[هذا_الصنف](هذا)؛
        هذا.المشهد = صـندوق().{
            أضف_فروع({
                كـتابة(نـص("أدخل البيانات"))،
                مـدخل_نص().{
                    الكائن.المدخل = هذا؛
                }،
                الـزر(نـص("إرسال")).{
                    عند_الضغط.اربط(مغلفة (عنصر_واجهة: سند[ودجـة]، حمولة: سند[صـحيح]) {
                        الكائن.استخدم_النص_المدخل(الكائن.المدخل.النص)؛
                    })؛
                }
            })؛
        }؛
    }

    عملية هذا_الصنف(): سـندنا[مـركبي] {
        أرجع سـندنا[مـركبي].أنشئ()؛
    }
}

@منفذ_مرئي["/"]
@عنوان["مثال"]
دالة رئيسي {
    نـافذة.النموذج.حدد_المشهد(مـركبي())؛
    نفذ_حلقة_معالجة_الأحداث()؛
}
class MyComponent {
    @injection def component: Component;
    def input: SrdRef[TextInput];

    handler this~init() {
        def self: ref[this_type](this);
        this.view = Box().{
            addChildren({
                Text(String("Enter data")),
                TextInput().{
                    self.input = this;
                },
                Button(String("Send")).{
                    onClick.connect(closure (widget: ref[Widget], payload: ref[Int]) {
                        self.useUserData(self.input.text);
                    });
                }
            });
        };
    }
    
    handler this_type(): SrdRef[MyComponent] {
        return SrdRef[MyComponent].construct();
    }
}

@uiEndpoint["/"]
@title["Example"]
func main {
    Window.instance.setView(MyComponent());
    runEventLoop();
}

نلاحظ في البرنامج أعلاه أن الصنف يبدأ بتعريف متغير من صنف مـركب وتعليمه بالمبدل @حقنة. هذه طريقة اشتقال الأصناف من أصناف أخرى في لغة الأسس، فهي ببساطة تعرف متغيرا من صنف ما نرغب الاشتقاق منه، وتخبر المترجم عبر المبدل @حقنة أن يحقن متغيرات هذا الصنف مـركب ضمن مجال صنفنا، وهذه ببساطة هي عملية اشتقاق الأصناف في أصلها، والتي في كثير من اللغات يبسطونها باستخدام كلمة مفتاحية مثل extends أو ما شابه، لكنها في أصلها ليست سوى عملية احتواء مع جعل العناصر الداخلية متوفرة في الصنف الخارجي.

نلاحظ أيضًا أن دالة التهيئة تعرف متغيرا باسم كائن بصنف مطابق تماما للمتغير هذا (this). نحن ببساطة ننسخ قيمة هذا هنا لنستخدمها لاحقا في الكود المضمن في شجرة الواجهة. السبب في هذا النسخ هو الحاجة للتمييز بين هذا التي تشير إلى المركب عن هذا التي تشير إلى عنصر واجهة المستخدم. منشأ هذه الحاجة أن المؤثر .{} يعرف تلقائيا متغيرا باسم هذا يشير إلى العنصر الذي يسبق هذا المؤثر. بمعنى آخر، فإن الكلمة المفتاحية هذا داخل القوسين الحاصرين في مـدخل_نص().{ هذا } تشير إلى مـدخل_نص الذي أنشئ هنا، مهيمنة على قيمة هذا التي تشير إلى المركب. أي أن داخل هذين القوسين الحاصرين فإن قيمة كائن تشير إلى المركب بينما قيمة هذا تشير إلى مـدخل_نص.

نلاحظ في نهاية الصنف أننها نعرف عملية هذا_الصنف(). هذه العملية تعيد تعريف ما يفعله المترجم عند استخدام الأقواس مع اسم الصنف لاحقا، كما هو الحال في نـافذة.النموذج.حدد_المشهد(مـركبي()) داخل الدالة الرئيسية. مبدئيا، استخدام الأقواس مع اسم أي صنف يؤدي إلى إنشاء متغير مؤقت من ذلك الصنف. مثلا العملية صـحيح() تنشئ متغيرا مؤقتا من صنف صـحيح، لكن ما نريده نحن في واجهات المستخدم أن يكون إنشاء المركب في ذاكرة الheap باستخدام سند مشترك من صنف سـندنا يضمن بقاء الكائن في الذاكرة حتى بعد الخروج من الدالة، طالما أن هناك من يحتفظ بنسخة من هذا السند.

في حال كان المركب يستقبل معطيات في دالة التهيئة فإن عملية هذا_الصنف() ستكون بهذا الشكل:

    عملية هذا_الصنف(معطى: نـص): سـندنا[مـركبي] {
        أرجع سـندنا[مـركبي]().{ احجز()~هيئ(معطى) }؛
    }
    handler this_type(arg: String): SrdRef[MyComponent] {
        return SrdRef[MyComponent]().{ alloc()~init(arg) };
    }

وعندها يمكننا إنشاء كائن من هذا المركب بهذا الشكل: مـركبي(نـص("...")). في هذا التعريف عرفنا متغيرا من صنف سـندنا[مـركبي] دون حجز الكائن نفسه في الذاكرة، ثم طبقنا عليه العملية احجز لحجز الذاكرة، ثم العملية ~هيئ لتهيئته مع إعطائه المعطيات المطلوبة. عندما لا نحتاج لتمرير أي معطيات فيمكننا الاكتفاء باستدعاء دالة هيئ التابعة للصنف سـندنا والتي هي اختصار لاستدعاء احجز ثم ~هيئ.

الخصال والوظائف والاستدعاءات العكسية

يمكنك تمكين الوصول إلى عناصر الواجهة داخل مركباتك من الخارج باستخدام خصال الأصناف العادية. على سبيل المثال، لجعل نص حقل الإدخال في مركبنا متاحًا من الخارج يمكنك إضافة خصلة نص إلى المركب، كما يلي:

    عملية هذا.النص: نـص {
        أرجع هذا.المدخل.النص؛
    }

    عملية هذا.النص = نـص {
        هذا.المدخل.النص = قيمة؛
        أرجع قيمة؛
    }
    handler this.text: String {
        return this.input.text;
    }

    handler this.text = String {
        this.input.text = value;
        return value;
    }

وبالمثل يمكنك تعريف الوظائف والاستدعاءات العكسية كأي صنف عادي. على سبيل المثال:

صنف مـركبي {
    @حقنة عرف مركب: مـركب؛
    عرف المدخل: سـندنا[مـدخل_نص]؛
    عرف عند_الضغط_على_الزر: مغلفة()؛

    عملية هذا.النص: نـص {
        أرجع هذا.المدخل.النص؛
    }

    عملية هذا.النص = نـص {
        هذا.المدخل.النص = قيمة؛
        أرجع قيمة؛
    }

    عملية هذا~هيئ() {
        عرف الكائن: سند[هذا_الصنف](هذا)؛
        هذا.المشهد = صـندوق().{
            أضف_فروع({
                كـتابة(نـص("أدخل البيانات"))،
                مـدخل_نص().{
                    الكائن.المدخل = هذا؛
                }،
                الـزر(نـص("إرسال")).{
                    عند_الضغط.اربط(مغلفة (عنصر_واجهة: سند[ودجـة]، حمولة: سند[صـحيح]) {
                        الكائن.استخدم_النص_المدخل(الكائن.المدخل.النص)؛
                        إذا ليس الكائن.عند_الضغط_على_الزر.أهو_عدم() {
                            الكائن.عند_الضغط_على_الزر()؛
                        }
                    })؛
                }
            })؛
        }؛
    }

    عملية هذا_الصنف(): سـندنا[مـركبي] {
        أرجع سـندنا[مـركبي].أنشئ()؛
    }
}
class MyComponent {
    @injection def component: Component;
    def input: SrdRef[TextInput];
    def onButtonClicked: closure();

    handler this.text: String {
        return this.input.text;
    }

    handler this.text = String {
        this.input.text = value;
        return value;
    }

    handler this~init() {
        def self: ref[this_type](this);
        this.view = Box().{
            addChildren({
                Text(String("Enter data")),
                TextInput().{
                    self.input = this;
                },
                Button(String("Send")).{
                    onClick.connect(closure (widget: ref[Widget], payload: ref[Int]) {
                        self.useUserData(self.input.text);
                        if not self.onButtonClicked.isNull() {
                            self.onButtonClicked();
                        }
                    });
                }
            });
        };
    }

    handler this_type(): SrdRef[MyComponent] {
        return SrdRef[MyComponent].construct();
    }
}

مع هذا الإصدار المحدث من المركب، يمكنك الآن التفاعل مع هذه العناصر من خارج المركب بهذا الشكل:

مـركبي().{
    النص = نـص("قيمة افتراضية")؛
    عند_الضغط_على_الزر = مغلفة() {
        // نفذ شيئا عند الضغط على الزر
        افعل_شيئا(هذا.النص)؛
    }؛
}
MyComponent().{
    text = String("some default value");
    onButtonClick = closure() {
        // Do something when the button is clicked
        doSomething(this.text);
    };
}

التنظيف وتحرير الموارد

على عكس اللغات التي تعتمد على مبدأ الgarbage collection كجافاسكريبت وتايبسكريبت وغيرها، لغة الأسس تعتمد على عداد السندات (reference counting) لإدارة الذاكرة، وما يميز هذا الأسلوب أن وقت تهيئة وإتلاف الكائنات معرف ومباشر وخاضع للسيطرة، وبالتالي يمكن الاعتماد عليه في تحرير الموارد التي ننشئها ضمن المركب. كل ما نحتاج لفعله هو تعريف عملية ~أتلف في صنف مـركبي ثم نجري أي عمليات تحرير مطلوبة داخل هذه الدالة.

    عملية هذا~أتلف() {
        // تحرير أي موارد تم حجزها من قبل هذا المركب
    }
    handler this~terminate() {
        // Free resources allocated by this component
    }

في حالة الأسس نضمن أن دالة ~أتلف ستستدعى مباشرة عند انتهاء الحاجة لهذا المركب ولن تبقى في الذاكرة منتظرة الgarbage collector لتحريرها وبالتالي يمكن الاعتماد عليها مباشرة في تحرير الموارد. على سبيل المثال، لو أن الدالة استخدم_النص_المدخل الافتراضية في مركبنا الذي أنشأناه أعلاه ترسل طلبا إلى الخادم، فبإمكاننا إلغاء ذلك الطللب في دالة الإتلاف، وبالتالي نضمن إلغاء الطلب مباشرة بعد إزالة المركب من الواجهة (على فرض عدم الاحتفاظ بأي سند آخر لهذا المركب في أي مكان).

خلف الكواليس: آلية تحديث الواجهة

السؤال الذي قد يتبادر لأذهان الكثيرين الآن هو: متى تُنشأ عناصر الواجهة فعلا على المتصفح ومتى تُزال وكيف تحدّث؟ على عكس مكتبة React وكثير غيرها، آلية إنشاء وتحديث الواجهة في الأسس ومـنصة_ويب بسيطة جدا ومباشرة. لا يوجد في مـنصة_ويب دورة حياة (lifecycle) كتلك التي في React و Angular وغيرهما، وإنما ببساطة، تُنشأ عناصر الواجهة مباشرة عند إضافتها إلى النافذة أو ربطها بشجرة مضافة حاليا إلى النافذة، وضمن نفس الاستدعاء الذي يجري ذلك الربط. مثلا، عند استدعاء الأمر:

نـافذة.النموذج.حدد_المشهد(مـركبي())؛

فإن عناصر الواجهة (الكتابة وحقل الإدخال والزر) ستُنشأ في المتصفح مباشرة وقبل الرجوع من دالة حدد_المشهد. وإذا ما استدعينا حدد_المشهد ثانية بقيمة مختلفة فإن هذه العناصر ستُزال وتستبدل بالعناصر الجديدة مباشرة وقبل الرجوع من دالة حدد_المشهد. الأمر ذاته ينطبق على استدعاء دالة أضف_فروعا (addChildren) ضمن الودجة صـندوق (Box) أو ما يشبهها في الودجات الأخرى.

وعند استدعاء دالة حدد_المشهد بقيمة جديدة فإن المشهد الحالي سيُزال وستستدعى أي دوال إتلاف ضمن هذا المشهد مباشرة وقبل الرجوع من دالة حدد_المشهد. كذلك الأمر مع دالة صـندوق.أزل_فروعا (Box.removeChildren) والتي ستتلف هذا الفرع وتحرر كل موارده قبل العودة من هذه الدالة.

الأمر نفسه ينطبق على تحديث الواجهة، فعند تنفيذ عملية مثل الكتابة.النص = نـص("...") فإن تحديث الواجهة يتم مباشرة وقبل الانتهاء من تنفيذ هذه العملية.

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

هذه البساطة في التصميم تجعل التطبيقات المكتوبة بلغة الأسس ومـنصة_ويب أكثر استقرارا وأحسن أداءًا وخالية من المفاجآت التي تصعّب استكشاف الأخطاء وإصلاحها، فلا يوجد عملية معقدة تجري خلف الكواليس كتلك التي تجري في مكتبات أخرى وتسبب الحيرة والصداع للمبرمج.