غيَّر التحسُّن الذي أُجري على مكتبات جافا سكريبت الحديثة مثل مكتبة ريآكت React.js ومكتبة فيو جي إس Vue.js من تطوير واجهات الويب الأمامية نحو الأفضل، إذ وفًّرت لنا هذه المكتبات بعض الميزات، مثل دعم تطبيقات الصفحة الواحدة Single-Page Application -أو اختصارًا SPA-؛ وهي تقنيةٌ تحمّل محتوى صفحات الويب ديناميكيًّا دون اضطرار إلى جلبها مرة أخرى من موقع الخادم إلى المتصفح على جهاز العميل.
والمفهوم الذي تُبنى عليه معظم تطبيقات الصفحة الواحدة هو مفهوم التصيير rendering في طرف العميل، إذ يُصيّر أغلب المحتوى في شاشة المتصفح بجافا سكريبت، ولكن لن يُحمّل هذا المحتوى في البداية حتى ينتهي تنزيل download شيفرة جافا سكريبت كاملةً، ثم تتكفل هي بتصيير باقي الموقع.
يُعد مفهوم التكوين في طرف العميل حديثًا نسبيًا، ورغم تميزُّه ففيه قصور، إذ يؤدي عدم تصيير محتوى الصفحة حتى تحديث الصفحة باستخدام جافا سكريبت إلى عرقلة عملية تحسين محرك البحث Search Engine Optimization -أو اختصارًا SEO- على الموقع، إذ يحتاج SEO إلى بيانات لتتبع الارتباطات، ولن يكون هناك أي بيانات في هذه التقنية لتتبع الارتباطات فيها.
وعلى الطرف الآخر هناك مفهوم التصيير في طرف الخادم server-side، الذي يُعد الطريقة التقليدية لتصيير صفحات HTML على المتصفح؛ إذ يُبنى تطبيق الويب في هذا النوع من التطبيقات بلغة تعمل في طرف الخادم مثل PHP، وعندما يطلب المتصفح صفحة ويب، يضيف الخادمُ البعيدُ كاملَ المحتوى الديناميكي، ثم يرسل صفحة HTML مملوءة بالمحتوى إلى طرف العميل.
يعاني أيضًا التصيير في طرف الخادم من بعض أوجه القصور، مثل إرسال طلبات كثيرةٍ إلى الخادم، وتكرار تحميل الصفحات كاملةً المرة تلو المرة رغم احتوائها نفس البيانات.
وهناك نوعٌ ثالث من أطر العمل، مبنيٌّ بجافا سكريبت يعتمد على مبدأ تحميل صفحة الويب ابتداءً بطريقة التصيير في طرف الخادم، ثم يستخدم إطار عمل آخر لمعالجة التوجيه الديناميكي الإضافي والاقتصار على إحضار البيانات المهمة، ويطلق على التطبيقات الناتجة اسم التطبيقات العمومية Universal Applications كونها تشمل مزيجًا من الحلّين: التصيير عند الخادم وعند العميل. يُستخدمُ مصطلح "التطبيق العمومي" لوصف شيفرة جافا سكريبت التي يمكن أن تنفّذَ عند كلا الطرفين: العميل والخادم.
سنبني في هذه المقالة تطبيقًا عموميًّا موضوعه "وصفات الطبخ"، مستعينين بإطار عمل نُكست Nuxt.js؛ وهو إطار عمل من مستوى أعلى لتطوير تطبيقات فيو Vue.js عمومية، واستُلهمت فكرته من نِكست Next.js الذي تقدمه ريآكت وهو يساعد على تجريد التعقيدات التي تظهر لدى إعداد تطبيقات فيو جي إس التي تُصيَّر في طرف الخادم، مثل إعدادات الخادم، وتوزيع شيفرة العميل، ويأتي مع إطار العمل نُكست عدة مزايا تسهل عمليات التطوير بين جهتي العميل والخادم، مثل مزامنة البيانات async data والبرمجيات الوسيطة middleware والأنساق layouts ونحو ذلك.
ملاحظة: رغم إمكانية إدراج التطبيق الذي نبنيه تحت تصنيف التطبيقات التي تُصيَّر في طرف الخادم Server-Side Rendering - أو اختصارًا SSR -،لأن فيو Vue يتولى بطبيعة الحال مهمة التصيير عند العميل عندما ننشئ تطبيق صفحة واحدة، ومع ذلك فالتطبيق الذي نعمل عليه هو في حقيقته يندرج تحت التطبيقات العمومية.
سننشئ في هذه المقالة تطبيقًا عموميًّا مستخدمين لتحقيق ذلك جانغو ونُكست، إذ سيتولى جانغو مسؤولية معالجة العمليات في طرف الخادم ويوفر واجهات برمجة التطبيقات التي تستخدِم إطار عمل جانغو ريست Django REST، بينما ينشئ نُكست الواجهة الأمامية.
تبين الصورة التالية لقطةً توضيحيةً للتطبيق النهائي:
والتطبيق النهائي هو كما نرى تطبيق دليل لوصفات تحضير أطباق أو دليل طبخ ويمكّن مستخدميه من إجراء عمليات الإنشاء Create والقراءة Read والتحديث Update والحذف Delete- أو اختصارًا CRUD-.
المتطلبات الأساسية
ستحتاج للمضيّ في هذه المقالة إلى ما يلي:
- تثبيت نود جي إس Node.js محلي على جهازك، وإذا لم تكن تعرف ذلك، يمكنك مراجعة مقالة مقدمة إلى Node.js.
- تثبيت بايثون على جهازك أيضًا والطريقة هنا.
- سيستخدم هذا المشروع Pipenv. وهي أداة معدة للاستخدام المهني توفر لنا أفضل طرق التحزيم Packaging في عالم بايثون. فهي تحزم Pipfile و pip و virtualenv في أمرٍ واحد فقط.
وسنفترض أيضًا معرفة القارئ بما يلي:
- العملية الأساسية بكل من جانغو وإطار عمل جانغو ريست
- Django REST Framework.العملية الأساسية بمكتبة ـفيو جي إس Vue.js.
وقد تحققنا من عمل التطبيق في هذه المقالة باستخدام الاعتماديات التالية:
- Python v3.7.7
- Django v3.0.7
- Node v14.4.0
- npm v6.14.5
- nuxt v2.13.0
الخطوة الأولى - إعداد الواجهة الخلفية
سنعدّ الآن الواجهة الخلفية وننشئ كافة المجلدات التي نحتاجها لتجهيز التطبيق وتشغيله، لذا شغّل نسخة من الطرفية وأنشئ مجلد المشروع بتنفيذ الأمر التالي:
$ mkdir recipes_app
ثم انتقل إلى داخل المجلد:
$ cd recipes_app
الآن سنثبت أداة Pipenv باستخدام الأمر pip
:
$ pip install pipenv
ونفعّل بيئة افتراضية جديدة:
$ pipenv shell
ملاحظة: تجاوز الأمر الأول إذا كانت Pipenv مثبتةً على حاسوبك.
الآن، نثبت جانغو وبقية الاعتمادات باستخدام الأداة Pipenv:
(recipes_app)$ pipenv install django django-rest-framework django-cors-headers
ملاحظة: بعد تفعيل بيئة افتراضية جديدة باستخدام Pipenv، سيُسبق كل سطر أوامر في الطرفية باسم مجلد العمل الحالي، وهو في حالتنا هذه (recipes_app).
سننشئ الآن مشروع جانغو جديدًا ونسميه api:
(recipes_app)$ django-admin startproject api
انتقل إلى مجلد المشروع:
(recipes_app)$ cd api
أنشئ تطبيق جانغو وسمِّه core:
(recipes_app)$ python manage.py startapp core
دعنا الآن نسجّل التطبيق "core" مع "rest_framework" و "cors-headers" ليتمكن مشروع جانغو من التعرُّف عليه. افتح الملف "api/settings.py" وعدّله كما يلي:
# ... # Application definition INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', # add this 'corsheaders', # add this 'core' # add this ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', # add this 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] # add this block below MIDDLEWARE CORS_ORIGIN_WHITELIST = ( 'http://localhost:3000', ) # ... # add the following just below STATIC_URL MEDIA_URL = '/media/' # add this MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # add this
أضفنا هنا العنوان http://localhost:3000
إلى القائمة البيضاء لأن تطبيق العميل سيُخدَّم من هذا المنفذ ونريد منع حدوث أي خطأ من أخطاء سياسة تعدد الموارد Cross-Origin Resource Sharing - أو اختصارًا CORS-؛ كما أضفنا أيضًا MEDIA_URL
و MEDIA_ROOT
لأننا سنحتاج إليهما من أجل خدمة الصور في التطبيق.
تعريف نموذج وصفات تحضير الأطباق
لننشئ الآن النموذج Recipe الذي سيعرّف طريقة تخزين عناصر وصفات التحضير في قاعدة البيانات. افتح الملف "core/models.py" وبدّل محتواه بالشيفرة التالية:
from django.db import models # Create your models here. class Recipe(models.Model): DIFFICULTY_LEVELS = ( ('Easy', 'Easy'), ('Medium', 'Medium'), ('Hard', 'Hard'), ) name = models.CharField(max_length=120) ingredients = models.CharField(max_length=400) picture = models.FileField() difficulty = models.CharField(choices=DIFFICULTY_LEVELS, max_length=10) prep_time = models.PositiveIntegerField() prep_guide = models.TextField() def __str_(self): return "Recipe for {}".format(self.name)
تصف الشيفرة السابقة ست خاصّيات في نموذج Recipe، هي:
-
name
-
ingredients
-
picture
-
difficulty
-
prep_time
-
prep_guide
إنشاء المسلسلات لنموذج وصفات تحضير الأطباق
نحتاج إلى المسلسِلات لتحويل نسخ النموذج إلى نوع المحتوى JSON لتمكين الواجهة الأمامية من العمل مع البيانات التي ستستقبلها. سننشئ الملف "core/serializers.py" ونضيف إليه السطور التالية:
from rest_framework import serializers from .models import Recipe class RecipeSerializer(serializers.ModelSerializer): class Meta: model = Recipe fields = ("id", "name", "ingredients", "picture", "difficulty", "prep_time", "prep_guide")
حدّدنا في الشيفرة السابقة النموذج الذي سنعمل معه والحقول التي نريد تحويلها إلى محتوًى من النوع JSON.
تجهيز لوحة الإدارة
يوفر لنا جانغو واجهة إدارة جاهزة للعمل فورًا وتسهل اختبار عمليات CRUD على نموذج Recipe الذي أنشأناه للتو، لكن قبل ذلك سنجري بعض الضبط.
افتح الملف "core/admin.py" وبدّل محتواه كليًّا بالأسطر التالية:
from django.contrib import admin from .models import Recipe # add this # Register your models here. admin.site.register(Recipe) # add this
إنشاء العروض
لننشئ الصنف RecipeViewSet
في الملف core/views.py. بدّل محتواه كليًا بالشيفرة التالية:
from rest_framework import viewsets from .serializers import RecipeSerializer from .models import Recipe class RecipeViewSet(viewsets.ModelViewSet): serializer_class = RecipeSerializer queryset = Recipe.objects.all()
يوفّر الصنف viewsets.ModelViewSet
توابع لمعالجة عمليات CRUD افتراضيًا، ولا نحتاج سوى تحديد صنف المسلسل ومجموعة الاستعلام queryset
.
تجهيز عناوين محدد الموارد الموحد URL
اذهب إلى الملف "api/urls.py"، وبدّل محتواه كليًا بالسطور التالية التي تحدد مسار عنوان واجهة برمجة التطبيقات:
from django.contrib import admin from django.urls import path, include # add this from django.conf import settings # add this from django.conf.urls.static import static # add this urlpatterns = [ path('admin/', admin.site.urls), path("api/", include('core.urls')) # add this ] # أضف ما يلي if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
والآن أنشئ الملف "urls.py" في المجلد "core" وانسخ إليه السطور التالية:
from django.urls import path, include from rest_framework.routers import DefaultRouter from .views import RecipeViewSet router = DefaultRouter() router.register(r'recipes', RecipeViewSet) urlpatterns = [ path("", include(router.urls)) ]
يولد الصنف router
في الشيفرة السابقة أنماط عناوين URL التالية:
- "/recipes/": يمكن أن تُنفذ عمليتي الإنشاء CREATE والقراءة READ على هذا الاتجاه route.
- "{recipes/{id/": يمكن أن تُنفذ عمليات القراءة READ والتحديث UPDATE والحذف DELETE على هذا الاتجاه.
تنفيذ عمليات التهجير
بما أننا أنشأنا النموذج Recipe مؤخرًا وعرّفنا بنيته، سنحتاج لإنشاء ملف تهجير وتطبيق التغييرات المُجراة على النموذج على قاعدة البيانات، لذا سننفِّذ الأمرين التاليين:
(recipes_app)$ python manage.py makemigrations (recipes_app)$ python manage.py migrate
ننشئ الآن حساب مستخدم مميّز superuser للوصول إلى واجهة الإدارة:
(recipes_app)$ python manage.py createsuperuser
سيطلب منك إدخال اسم المستخدم وكلمة المرور والبريد الإلكتروني للمستخدم المميز، لذا احرص على اختيار تفاصيل سهلة التذكر لأنك ستحتاجها مرارًا للدخول إلى واجهة الإدارة.
وبهذا نكون انتهينا من التهيئة اللازمة للواجهة الخلفية. نستطيع الآن اختبار واجهات برمجة التطبيقات APIs التي أنشاناها، لذلك نبدأ بتشغيل خادم جانغو:
(recipes_app)$ python manage.py runserver
بعد أن يعمل الخادم، تستطيع التحقق من عمله بالذهاب إلى العنوان "/localhost:8000/api/recipes".
نستطيع إنشاء عنصر وصفة تحضير جديدة بهذه الواجهة:
نستطيع أيضًا إجراء عمليات DELETE و PUT و PATCH على بضعة عناصر من عناصر وصفات التحضير وذلك من خلال استخدام مفاتيحها الأساسية id؛ ولفعل ذلك نكتب في المتصفح عناوينًا تأخذ الشكل العام التالي: "{api/recipe/{id/".
لنجرب الآن العنوان "localhost:8000/api/recipes/1":
وبهذا يكتمل إعداد الواجهة الخلفية للتطبيق وحان الآن وقت الواجهة الأمامية.
الخطوة الثانية- تحضير الواجهة الأمامية
سنبني الآن الواجهة الأمامية للتطبيق. نريد وضع مجلد شيفرة الواجهة الأمامية في جذر المجلد "recipes_app"، لذا اخرج أولًا من المجلد "api"، أو شغّل نافذة طرفية جديدة تعمل جنبًا إلى جنب مع نافذة الطرفية الأولى قبل تنفيذ الأوامر التي ستأتي في هذا الجزء من المقالة.
لننشئ تطبيق nuxt ونطلق عليه اسم "client" مستخدمين الأمر التالي:
$ npx create-nuxt-app client
ملاحظة: سيؤدي وضع npx
قبل create-nuxt-app
في التعليمة إلى تثبيت الحزمة إذا لم تكن مثبّتة للعموم من قبل على جهازك.
بعد اكتمال التثبيت سيسألك "create-nuxt-app" بضعة أسئلة حول بعض الأدوات التي ستضاف، وقد اخترنا منها ما يلي:
? Project name: client ? Programming language: JavaScript ? Package manager: Npm ? UI framework: Bootstrap Vue ? Nuxt.js modules: Axios ? Linting tools: ? Testing framework: None ? Rendering mode: Universal (SSR / SSG) ? Deployment target: Server (Node.js hosting) ? Development tools:
سيعطي هذا إشارةً لمدير الحزم لكي يبدأ في تثبيت الاعتمادات.
انتقل الآن إلى المجلد client:
$ cd client
لننفذ الآن الأمر التالي لتشغيل التطبيق في وضع التطوير:
npm run dev
بعد أن يقلع خادم التطوير، توجّه إلى العنوان "localhost:3000" لرؤية التطبيق، إذ يفترض أن يكون كما في الصورة التالية:
الآن، لنلقِ نظرةً على بنية المجلد client:
├── client ├── assets/ ├── components/ ├── layouts/ ├── middleware/ ├── node_modules/ ├── pages/ ├── plugins/ ├── static/ └── store/
نلاحظ احتواءه على بضعة مجلدات نشرحها فيما يلي:
- Assets: يحتوي على ملفات غير مصرّفة uncompiled، مثل ملفات الصور و CSS و Sass وجافاسكريبت Javascript.
- Components: يحتوي على مكونات فيو جي إس Vue.js.
- Layouts: يحتوي على أنساق التطبيق، المُستخدمة لتغيير مظهر الصفحة وقد تُستخدم لعدة صفحات معًا.
- Middleware: يحتوي على البرمجيات الوسيطة للتطبيق، وهي دوال مخصصة تُشغّل قبل تصيير الصفحة على المتصفح.
- Pages: تحتوي على عروض ومسارات التطبيق. يقرأ نُكست كل الملفات ذات الامتداد "vue." في هذا المجلد ويستخدم المعلومات التي يحصل عليها لإنشاء موجّه التطبيق.
- Plugins: يحتوي على إضافات جافا سكريبت البرمجية التي ستُشغّل قبل استنساخ تطبيق فيو جي إس الجذر.
- Static: يحتوي على الملفات الساكنة (التي غالبًا لن تتغير) وكل الملفات المرتبطة بجذر التطبيق "/".
- Store: يحتوي على ملفات المتجر إذا كنا سنستخدم فيو إكس Vuex مع نُكست.
وهناك أيضًا الملف nuxt.config.js ضمن المجلد client، إذ يحتوي هذا الملف على تهيئة مخصصة لتطبيق نُكست.
وقبل المتابعة نزّل الملف، وفك ضغطه وضع مجلد images داخل المجلد static.
بنية الصفحات
سنضيف الآن بعض ملفات ذات الامتداد "vue." إلى المجلد pages لكي يحتوي تطبيقنا على خمس صفحات:
- Homepage
- All Recipes list page
- Single Recipe view page
- Single Recipe edit page
- Add Recipe page
لنضف الآن ملفات ومجلدات "vue." التالية إلى المجلد pages ليكون لدينا نفس البنية التالية:
├── pages/ ├── recipes/ ├── _id/ └── edit.vue └── index.vue └── add.vue └── index.vue └── index.vue
ستولد بنية الملفات السابقة الوجهات routes التالية:
- "/" الذي يُعالج بواسطة "pages/index.vue".
- "recipes/add/" الذي يُعالج بواسطة "pages/recipes/add.vue".
- "/recipes/" الذي يُعالج بواسطة "pages/recipes/index.vue".
- "/{recipes/{id/" الذي يُعالج بواسطة pages/recipes/_id/index.vue.
- "recipes/{id}/edit/" الذي يُعالج بواسطة "pages/recipes/_id/edit.vue".
ينشئ أي ملف أو مجلد "vue." يبدأ اسمه بشرطة سفلية underscore مسارًا ديناميكيًا، وهذا مفيدٌ في تطبيقنا لأنه سيسهل عرض وصفات الطبخ المختلفة وذلك بناءً على معرّفاتها IDs. على سبيل المثال: "/recipes/1" و "/recipes/2" وهكذا.
إنشاء الصفحة الرئيسية Homepage
تساعدك الأنساق layout في نُكست كثيرًا عندما تريد تغيير مظهر تطبيقك والشعور الذي يبثّه لمستخدميه. تأتي كل نسخة من تطبيق نُكست مع تنسيق افتراضي، ونريد إزالة جميع التنسيقات styles لكي لا تتداخل وتتعارض مع تطبيقنا.
افتح الملف layouts/default.vue وبدّل محتواه بالشيفرة التالية:
<template> <div> <nuxt/> </div> </template> <style> </style>
دعنا نحدّث الملف pages/index.vue بالشيفرة التالية:
<template> <header> <div class="text-box"> <h1>La Recipes ?</h1> <p class="mt-3">Recipes for the meals we love ❤️</p> <nuxt-link class="btn btn-outline btn-large btn-info" to="/recipes"> View Recipes <span class="ml-2">→</span> </nuxt-link> </div> </header> </template> <script> export default { head() { return { title: "Home page" }; }, }; </script> <style> header { min-height: 100vh; background-image: linear-gradient( to right, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0.4) ), url("/images/banner.jpg"); background-position: center; background-size: cover; position: relative; } .text-box { position: absolute; top: 50%; left: 10%; transform: translateY(-50%); color: #fff; } .text-box h1 { font-family: cursive; font-size: 5rem; } .text-box p { font-size: 2rem; font-weight: lighter; } </style>
لدينا في الشيفرة السابقة <nuxt-link>
، وهو مكون نُكست يُستخدم للتنقل بين الصفحات، وهو مشابهٌ كثيرًا للمكون <router-link>
من Vue Router
لنشغّل خادم التطوير للواجهة الأمامية، إذا لم يكن في وضع التشغيل فعليًا:
$ npm run dev
اذهب إلى العنوان localhost:3000 وتأمل الصفحة الرئيسية:
تأكد دومًا من عمل خادم الواجهة الخلفية جانغو في نسخة أخرى من الطرفية، لأنه لن يلبث طويلًا حتى تبدأ الواجهة الأمامية في التواصل معه طلبًا للبيانات.
ستكون كل صفحة في هذا التطبيق مكونًا من مكونات فيو، ويزود نُكست بسمات ودوال خاصة لتسهيل تطوير التطبيقات. يمكنك العثور على كل تلك السمات الخاصة في التوثيق الرسمي.
سنستعمل لأغراض هذه المقالة دالتين من هذه الدوال، هما:
-
()head
: يُستخدم هذا التابع لوضع وسوم وصفية محددة<meta>
للصفحة الحالية. -
()asyncData
: يُستخدم هذا التابع لجلب البيانات قبل أن يُحمّل مكون الصفحة. بعد ذلك، يُدمج الكائن المعاد مع بيانات مكون الصفحة، وسنستفيد من هذا لاحقًا في هذه المقالة.
إنشاء صفحة قائمة وصفات التحضير
دعنا ننشئ مكون فيو اسمه "RecipeCard.vue" داخل المجلد components ونحدّثه بالشيفرة التالية:
<template> <div class="card recipe-card"> <img :src="recipe.picture" class="card-img-top" > <div class="card-body"> <h5 class="card-title">{{ recipe.name }}</h5> <p class="card-text"> <strong>Ingredients:</strong> {{ recipe.ingredients }} </p> <div class="action-buttons"> <nuxt-link :to="`/recipes/${recipe.id}/`" class="btn btn-sm btn-success">View</nuxt-link> <nuxt-link :to="`/recipes/${recipe.id}/edit/`" class="btn btn-sm btn-primary">Edit</nuxt-link> <button @click="onDelete(recipe.id)" class="btn btn-sm btn-danger">Delete</button> </div> </div> </div> </template> <script> export default { props: ["recipe", "onDelete"] }; </script> <style> .recipe-card { box-shadow: 0 1rem 1.5rem rgba(0,0,0,.6); } </style>
يقبل المكون الظاهر أعلاه خاصّيتين، هما:
-
كائن
recipe
يحتوي على معلومات عن وصفة تحضير معينة. -
التابع
onDelete
الذي سيعمل عندما يضغط مستخدم التطبيق على زر حذف وصفة تحضير.
افتح الآن الوجهة "pages/recipes/index.vue" وحدثها بأسطر الشيفرة التالية:
<template> <main class="container mt-5"> <div class="row"> <div class="col-12 text-right mb-4"> <div class="d-flex justify-content-between"> <h3>La Recipes</h3> <nuxt-link to="/recipes/add" class="btn btn-info">Add Recipe</nuxt-link> </div> </div> <template v-for="recipe in recipes"> <div :key="recipe.id" class="col-lg-3 col-md-4 col-sm-6 mb-4"> <recipe-card :onDelete="deleteRecipe" :recipe="recipe"></recipe-card> </div> </template> </div> </main> </template> <script> import RecipeCard from "~/components/RecipeCard.vue"; const sampleData = [ { id: 1, name: "Jollof Rice", picture: "/images/food-1.jpeg", ingredients: "Beef, Tomato, Spinach", difficulty: "easy", prep_time: 15, prep_guide: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis, porro. Dignissimos ducimus ratione totam fugit officiis blanditiis exercitationem, nisi vero architecto quibusdam impedit, earum " }, { id: 2, name: "Macaroni", picture: "/images/food-2.jpeg", ingredients: "Beef, Tomato, Spinach", difficulty: "easy", prep_time: 15, prep_guide: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis, porro. Dignissimos ducimus ratione totam fugit officiis blanditiis exercitationem, nisi vero architecto quibusdam impedit, earum " }, { id: 3, name: "Fried Rice", picture: "/images/banner.jpg", ingredients: "Beef, Tomato, Spinach", difficulty: "easy", prep_time: 15, prep_guide: "Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis, porro. Dignissimos ducimus ratione totam fugit officiis blanditiis exercitationem, nisi vero architecto quibusdam impedit, earum " } ]; export default { head() { return { title: "Recipes list" }; }, components: { RecipeCard }, asyncData(context) { let data = sampleData; return { recipes: data }; }, data() { return { recipes: [] }; }, methods: { deleteRecipe(recipe_id) { console.log(deleted `${recipe.id}`) } } }; </script> <style scoped> </style>
شغّل الآن خادم التطوير للواجهة الأمامية إذا لم يكن يعمل فعلًا:
$ npm run dev
ثم اذهب إلى العنوان localhost:3000/recipes وتأمل في صفحة قائمة وصفات الطبخ:
نلاحظ في الصورة السابقة ظهور ثلاث بطاقات وصفات تحضير رغم أننا حددنا قيمة recipes بمصفوفة فارغة في قسم البيانات من المكون، وتفسير هذا هو أن التابع asyncData
يُنفّذ قبل أن تُحمّل الصفحة ويعيد كائنًا يحدّث بيانات المكون.
كل ما نحتاجه الآن هو أن نعدل التابع asyncData
لينشئ طلب api إلى الواجهة الخلفية لجانغو ويحدّث بيانات المكون بالنتيجة.
ولكن يجب علينا تجهيز آكسيوس Axios قبل تفعيل ذلك، لذا افتح الملف nuxt.config.js وحدثه كما يلي:
// أضف كائن أكسيوس التالي axios: { baseURL: "http://localhost:8000/api" },
ملاحظة: يفترِض هذا أنك اخترت "Axios" عند استخدام "create-nuxt-app"، فإذا لم تكن قد اخترته فعلًا، فستحتاج إلى تثبيت وتهيئة المصفوفة modules
يدويًا.
افتح الآن الملف "pages/recipes/index.vue" وبدّل القسم <script>
بالشيفرة التالية:
[...] <script> import RecipeCard from "~/components/RecipeCard.vue"; export default { head() { return { title: "Recipes list" }; }, components: { RecipeCard }, async asyncData({ $axios, params }) { try { let recipes = await $axios.$get(`/recipes/`); return { recipes }; } catch (e) { return { recipes: [] }; } }, data() { return { recipes: [] }; }, methods: { async deleteRecipe(recipe_id) { try { await this.$axios.$delete(`/recipes/${recipe_id}/`); // delete recipe let newRecipes = await this.$axios.$get("/recipes/"); // get new list of recipes this.recipes = newRecipes; // update list of recipes } catch (e) { console.log(e); } } } }; </script> [...]
يستقبل التابع ()asyncData
في الشيفرة السابقة كائنًا اسمه context
، الذي نفكّكه للحصول على axios$
. ولمعرفة المزيد عن بقية سمات الكائن context
يرجى مراجعة توثيقه الرسمي.
أضفنا ()asyncData
داخل كتلة try...catch
لأننا نريد منع ظهور الخطأ الذي يظهر عندما لا يكون خادم الواجهة الخلفية في حالة عمل ويفشل آكسيوس في استعادة البيانات. وهكذا، مع هذه الإضافة فإنه في كل مرة يحدث هذا الخطأ، توضع قيمة recipes
بمصفوفة فارغة بدلًا من ذلك.
هذا السطر من الشيفرة:
let recipes = await $axios.$get("/recipes/")
هو نسخةٌ أقصر من السطر التالي:
let response = await $axios.get("/recipes") let recipes = response.data
يحذف التابع ()deleteRecipe
وصفة تحضير معينة ويحضر أحدث قائمة لوصفات التحضير من الواجهة الخلفية لجانغو، ثم يحدّث أخيرًا بيانات المكوّن.
يمكننا الآن بدء تشغيل خادم التطوير للواجهة الأمامية إذا لم يكن يعمل فعلًا وسنرى أن بطاقات وصفات التحضير يجري الآن ملؤها بالبيانات من الواجهة الخلفية لجانغو؛ ولكي يعمل هذا يجب أن يكون خادم الواجهة الخلفية لجانغو في وضع تشغيل، وأن تتوفر بعض البيانات لعناصر وصفات التحضير Recipe، إذ تكون هذه البيانات مُدخلةً من واجهة الإدارة.
$ npm run dev
لنذهب الآن إلى الصفحة localhost:3000/recipes:
جرب حذف عناصر الوصفات وانظر ما سيحدث.
إضافة وصفات طبخ جديدة
نريد أن نتمكن كما ذكرنا من قبل من إضافة وصفات جديدة من الواجهة الأمامية للتطبيق، لذا افتح الملف "/pages/recipes/add" وحدّثه بمقطع الشيفرة التالي:
<template> <main class="container my-5"> <div class="row"> <div class="col-12 text-center my-3"> <h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2> </div> <div class="col-md-6 mb-4"> <img v-if="preview" class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" :src="preview" alt > <img v-else class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" src="@/static/images/placeholder.png" > </div> <div class="col-md-4"> <form @submit.prevent="submitRecipe"> <div class="form-group"> <label for>Recipe Name</label> <input type="text" class="form-control" v-model="recipe.name"> </div> <div class="form-group"> <label for>Ingredients</label> <input v-model="recipe.ingredients" type="text" class="form-control"> </div> <div class="form-group"> <label for>Food picture</label> <input type="file" name="file" @change="onFileChange"> </div> <div class="row"> <div class="col-md-6"> <div class="form-group"> <label for>Difficulty</label> <select v-model="recipe.difficulty" class="form-control"> <option value="Easy">Easy</option> <option value="Medium">Medium</option> <option value="Hard">Hard</option> </select> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for> Prep time <small>(minutes)</small> </label> <input v-model="recipe.prep_time" type="number" class="form-control"> </div> </div> </div> <div class="form-group mb-3"> <label for>Preparation guide</label> <textarea v-model="recipe.prep_guide" class="form-control" rows="8"></textarea> </div> <button type="submit" class="btn btn-primary">Submit</button> </form> </div> </div> </main> </template> <script> export default { head() { return { title: "Add Recipe" }; }, data() { return { recipe: { name: "", picture: "", ingredients: "", difficulty: "", prep_time: null, prep_guide: "" }, preview: "" }; }, methods: { onFileChange(e) { let files = e.target.files || e.dataTransfer.files; if (!files.length) { return; } this.recipe.picture = files[0]; this.createImage(files[0]); }, createImage(file) { // let image = new Image(); let reader = new FileReader(); let vm = this; reader.onload = e => { vm.preview = e.target.result; }; reader.readAsDataURL(file); }, async submitRecipe() { const config = { headers: { "content-type": "multipart/form-data" } }; let formData = new FormData(); for (let data in this.recipe) { formData.append(data, this.recipe[data]); } try { let response = await this.$axios.$post("/recipes/", formData, config); this.$router.push("/recipes/"); } catch (e) { console.log(e); } } } }; </script> <style scoped> </style>
وفقًا للتابع ()submitRecipe
: حالما تُنشر بيانات نموذج التعبئة وتنشأ وصفة التحضير بنجاح، يُعاد توجيه التطبيق إلى المجلد "/recipes/" باستخدام this.$router
.
إنشاء صفحة استعراض وصفة تحضير واحدة
لننشئ الآن العرض الذي يسمح للمستخدم باستعراض عنصر وصفة طبخ واحدة. افتح الملف "pages/recipes/_id/index.vue/" وضع فيه مقطع الشيفرة التالية:
<template> <main class="container my-5"> <div class="row"> <div class="col-12 text-center my-3"> <h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2> </div> <div class="col-md-6 mb-4"> <img class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" :src="recipe.picture" alt > </div> <div class="col-md-6"> <div class="recipe-details"> <h4>Ingredients</h4> <p>{{ recipe.ingredients }}</p> <h4>Preparation time ⏱</h4> <p>{{ recipe.prep_time }} mins</p> <h4>Difficulty</h4> <p>{{ recipe.difficulty }}</p> <h4>Preparation guide</h4> <textarea class="form-control" rows="10" v-html="recipe.prep_guide" disabled /> </div> </div> </div> </main> </template> <script> export default { head() { return { title: "View Recipe" }; }, async asyncData({ $axios, params }) { try { let recipe = await $axios.$get(`/recipes/${params.id}`); return { recipe }; } catch (e) { return { recipe: [] }; } }, data() { return { recipe: { name: "", picture: "", ingredients: "", difficulty: "", prep_time: null, prep_guide: "" } }; } }; </script> <style scoped> </style>
قدمنا هنا المفتاح params
الذي رأيناه في التابع ()asyncData
، ونستخدمه في حالتنا هذه للحصول على المعرف ID لوصفة الطبخ التي نريد مشاهدتها. نستخرج المفتاح params
من الرابط URL ونحضر بياناته قبل عرضها على الصفحة.
نلاحظ ظهور عنصر وصفة طبخ واحدة على شاشة المتصفح.
إنشاء صفحة تعديل وصفة تحضير واحدة
نحتاج لإنشاء العرض الذي يسمح للمستخدم بتحرير وتعديل عنصر وصفة طبخ واحدة. افتح الملف "pages/recipes/_id/edit.vue/" وضع فيه مقطع الشيفرة التالية:
<template> <main class="container my-5"> <div class="row"> <div class="col-12 text-center my-3"> <h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2> </div> <div class="col-md-6 mb-4"> <img v-if="!preview" class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" :src="recipe.picture"> <img v-else class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" :src="preview"> </div> <div class="col-md-4"> <form @submit.prevent="submitRecipe"> <div class="form-group"> <label for>Recipe Name</label> <input type="text" class="form-control" v-model="recipe.name" > </div> <div class="form-group"> <label for>Ingredients</label> <input type="text" v-model="recipe.ingredients" class="form-control" name="Ingredients" > </div> <div class="form-group"> <label for>Food picture</label> <input type="file" @change="onFileChange"> </div> <div class="row"> <div class="col-md-6"> <div class="form-group"> <label for>Difficulty</label> <select v-model="recipe.difficulty" class="form-control" > <option value="Easy">Easy</option> <option value="Medium">Medium</option> <option value="Hard">Hard</option> </select> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for> Prep time <small>(minutes)</small> </label> <input type="text" v-model="recipe.prep_time" class="form-control" name="Ingredients" > </div> </div> </div> <div class="form-group mb-3"> <label for>Preparation guide</label> <textarea v-model="recipe.prep_guide" class="form-control" rows="8"></textarea> </div> <button type="submit" class="btn btn-success">Save</button> </form> </div> </div> </main> </template> <script> export default { head(){ return { title: "Edit Recipe" } }, async asyncData({ $axios, params }) { try { let recipe = await $axios.$get(`/recipes/${params.id}`); return { recipe }; } catch (e) { return { recipe: [] }; } }, data() { return { recipe: { name: "", picture: "", ingredients: "", difficulty: "", prep_time: null, prep_guide: "" }, preview: "" }; }, methods: { onFileChange(e) { let files = e.target.files || e.dataTransfer.files; if (!files.length) { return; } this.recipe.picture = files[0] this.createImage(files[0]); }, createImage(file) { let reader = new FileReader(); let vm = this; reader.onload = e => { vm.preview = e.target.result; }; reader.readAsDataURL(file); }, async submitRecipe() { let editedRecipe = this.recipe if (editedRecipe.picture.name.indexOf("http://") != -1){ delete editedRecipe["picture"] } const config = { headers: { "content-type": "multipart/form-data" } }; let formData = new FormData(); for (let data in editedRecipe) { formData.append(data, editedRecipe[data]); } try { let response = await this.$axios.$patch(`/recipes/${editedRecipe.id}/`, formData, config); this.$router.push("/recipes/"); } catch (e) { console.log(e); } } } }; </script> <style scoped> </style>
يحتوي التابع ()submitRecipe
في الشيفرة السابقة على تعليمة شرطية لحذف صورة عنصر وصفة طبخ مُحررة من البيانات التي ستُرسل، إذا لم تكن الصورة قد تغيرت، ويُعاد توجيه التطبيق إلى صفحة قائمة وصفات الطبخ "/recipes/"عقب تحديث عنصر وصفة طبخ.
إعداد الانتقالات
مع أن التطبيق يعمل جيدًا، لكن يمكننا أيضًا إعطاءه مظهرًا أسلس إذا أضفنا الانتقالات التي تسمح لنا بتغيير قيم خصائص CSS من قيمة إلى أخرى بسلاسة ضمن فترة زمنية محددة.
وسنجهز الانتقالات في الملف nuxt.config.js. ويأخذ اسم الانتقال افتراضيًّا الاسم page الذي يعني أن الانتقالات التي نعرّفها ستكون ساريةً على جميع الصفحات.
لنضمّن نمط الانتقال، لذا أنشئ مجلدًا وسمِّه "css" في المجلد "assets" وأضف الملف "transitions.css" داخله. افتح الآن الملف "transitions.css" وضع فيه مقطع الشيفرة التالية:
.page-enter-active, .page-leave-active { transition: opacity .3s ease; } .page-enter, .page-leave-to { opacity: 0; }
افتح الملف nuxt.config.js وحدِّثه كما يلي لتحميل ملف CSS الذي أنشأناه للتو:
/* ** Global CSS */ css: [ '~/assets/css/transitions.css', // update this ],
احفظ التغييرات وافتح التطبيق في متصفحك:
وبهذا سيغير التطبيقُ الإطاراتِ عند كل انتقال بطريقة أنيقة وجميلة.
خاتمة
بدأنا هذه المقالة بالتعرُّف على الفرق بين التطبيقات التي يجري تصييرها من طرف الخادم وكذلك من طرف العميل، ثم تعرفنا على مفهوم التطبيق العمومي وأخيرًا رأينا كيف نبني تطبيقًا عموميًّا مستخدمين في سبيل ذلك كلًّا من نُكست وجانغو.
تتوفر الشيفرة المصدرية لهذه المقالة على موقع Github.
ترجمة -وبتصرف- للمقالة How To Build a Universal Application with Nuxt.js and Django لصاحبها Jordan Irabor.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.