اذهب إلى المحتوى

بناء تطبيق ويب من طرف الخادم باستخدام Nuxt.js وجانغو Django


Mostafa Almahmoud

غيَّر التحسُّن الذي أُجري على مكتبات جافا سكريبت الحديثة مثل مكتبة ريآكت 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، بينما ينشئ نُكست الواجهة الأمامية.

تبين الصورة التالية لقطةً توضيحيةً للتطبيق النهائي:

002.gif

والتطبيق النهائي هو كما نرى تطبيق دليل لوصفات تحضير أطباق أو دليل طبخ ويمكّن مستخدميه من إجراء عمليات الإنشاء Create والقراءة Read والتحديث Update والحذف Delete- أو اختصارًا CRUD-.

المتطلبات الأساسية

ستحتاج للمضيّ في هذه المقالة إلى ما يلي:

  • تثبيت نود جي إس Node.js محلي على جهازك، وإذا لم تكن تعرف ذلك، يمكنك مراجعة مقالة مقدمة إلى Node.js.
  • تثبيت بايثون على جهازك أيضًا والطريقة هنا.
  • سيستخدم هذا المشروع Pipenv. وهي أداة معدة للاستخدام المهني توفر لنا أفضل طرق التحزيم Packaging في عالم بايثون. فهي تحزم Pipfile و pip و virtualenv في أمرٍ واحد فقط.

وسنفترض أيضًا معرفة القارئ بما يلي:

  1. العملية الأساسية بكل من جانغو وإطار عمل جانغو ريست
  2. 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".

التحقق من عمل الخادم

نستطيع إنشاء عنصر وصفة تحضير جديدة بهذه الواجهة:

004.png

نستطيع أيضًا إجراء عمليات DELETE و PUT و PATCH على بضعة عناصر من عناصر وصفات التحضير وذلك من خلال استخدام مفاتيحها الأساسية id؛ ولفعل ذلك نكتب في المتصفح عناوينًا تأخذ الشكل العام التالي: "{api/recipe/{id/".

لنجرب الآن العنوان "localhost:8000/api/recipes/1":

استخدام العنوان "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 وتأمل الصفحة الرئيسية:

الصفحة الرئيسية للعنوان 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 ونحضر بياناته قبل عرضها على الصفحة.

استخراج المفتاح 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
],

احفظ التغييرات وافتح التطبيق في متصفحك:

011.gif

وبهذا سيغير التطبيقُ الإطاراتِ عند كل انتقال بطريقة أنيقة وجميلة.

خاتمة

بدأنا هذه المقالة بالتعرُّف على الفرق بين التطبيقات التي يجري تصييرها من طرف الخادم وكذلك من طرف العميل، ثم تعرفنا على مفهوم التطبيق العمومي وأخيرًا رأينا كيف نبني تطبيقًا عموميًّا مستخدمين في سبيل ذلك كلًّا من نُكست وجانغو.

تتوفر الشيفرة المصدرية لهذه المقالة على موقع Github.

ترجمة -وبتصرف- للمقالة How To Build a Universal Application with Nuxt.js and Django لصاحبها Jordan Irabor.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...