اشتراکها
مثالهای این سری به نگارش RC2 به روز رسانی شدند. تغییرات صورت گرفته را در اینجا میتوانید مشاهده کنید.
خلاصهی آن در مورد «فرمهای reactive جدید» به صورت زیر است:
1- به فایل systemjs.config.js، در قسمت ngPackageNames نام forms هم اضافه میشود. این forms از طریق وابستگی زیر
که به فایل package.json اضافه میشود، تامین خواهد شد.
2- به فایل main.ts این تغییرات اضافه میشوند:
3- در کدها هرجایی Control هست، به FormControl تبدیل میشود که از ماژول ذیل تامین خواهد شد:
4- ngFormModel به formGroup تغییرنام خواهد یافت. این دایرکتیو جدید به صورت زیر تامین میشود:
5- تمام inputها باید دارای name باشند. در غیراینصورت خطا خواهید گرفت.
6- Validators اینبار از ماژول ذیل تامین میشود:
بجای ماژول قدیمی angular/common. اگر این تغییر را اعمال نکنید، امکان استفادهی از آنها را در new FormControl نخواهید داشت.
7- FormBuilder سازگاری با reactive forms جدید ندارد و با FormGroup جایگزین شد.
خلاصهی آن در مورد «فرمهای reactive جدید» به صورت زیر است:
1- به فایل systemjs.config.js، در قسمت ngPackageNames نام forms هم اضافه میشود. این forms از طریق وابستگی زیر
"dependencies": { "@angular/forms": "^0.1.0", },
2- به فایل main.ts این تغییرات اضافه میشوند:
// ... import {disableDeprecatedForms, provideForms} from '@angular/forms'; // ... bootstrap(AppComponent, [ disableDeprecatedForms(), provideForms() ]);
import { FormControl } from '@angular/forms';
directives: [REACTIVE_FORM_DIRECTIVES]
6- Validators اینبار از ماژول ذیل تامین میشود:
import { Validators, REACTIVE_FORM_DIRECTIVES, FormControl, FormGroup } from '@angular/forms';
7- FormBuilder سازگاری با reactive forms جدید ندارد و با FormGroup جایگزین شد.
در مطلب «بررسی روش آپلود فایلها از طریق یک برنامهی Angular به یک برنامهی ASP.NET Core» روش عمومی آپلود فایلها را بررسی کردیم. آن مطلب وابستگی به کامپوننت خاصی ندارد و عمومی است. در مطلب جاری میخواهیم روش دیگری را مبتنی بر کامپوننت ng2-file-upload بررسی کنیم که به همراه نمایش درصد پیشرفت ارسال فایلها، امکان انتخاب بهتر نوع فایلهای آپلودی و همچنین امکان مشاهدهی لیست کامل فایلهای انتخاب شده و امکان حذف مواردی از آن، پیش از ارسال نهایی است.
پیشنیازهای کار با کامپوننت ng2-file-upload
برای شروع به کار با کامپوننت ng2-file-upload، ابتدا نیاز است بستهی npm آنرا نصب کرد:
همچنین یک کامپوننت آزمایشی را هم به برنامه (دقیقا همان مثال مطلب قبلی) جهت اعمال آن اضافه میکنیم:
پس از آن نیاز است به ماژولی که این کامپوننت جدید در آن قرار دارد، مدخل FileUploadModule کامپوننت ng2-file-upload را افزود:
در غیراینصورت خطای شناخته نشدن خاصیت uploader را در حین اعمال این کامپوننت مشاهده خواهید کرد.
تکمیل Ng2FileUploadTestComponent جهت اعمال ng2-file-upload
اکنون به کلاس کامپوننت جدیدی که ایجاد کردیم، مراجعه کرده و تغییرات ذیل را اعمال میکنیم:
در اینجا یک خاصیت عمومی از نوع FileUploader تعریف شدهاست که در اختیار قالب این کامپوننت قرار خواهد گرفت. همچنین شیء مدل فرم نیز همانند مطلب «بررسی روش آپلود فایلها از طریق یک برنامهی Angular به یک برنامهی ASP.NET Core» تهیه شدهاست. هدف این است که بررسی کنیم علاوه بر ارسال فایلها، چگونه میتوان اطلاعات یک فرم را نیز به سمت سرور ارسال کرد.
وهله سازی از کامپوننت ng2-file-upload و انجام تنظیمات اولیهی آن
پس از تعریف خاصیت عمومی fileUploader، اکنون نوبت به وهله سازی آن است:
- در اینجا url، مسیر اکشن متدی را در سمت سرور مشخص میکند که قرار است فایلهای ارسالی را دریافت و ذخیره کند.
- اگر برنامه از نکات anti-forgery token استفاده میکند، این کامپوننت برخلاف روش مطرح شدهی در مطلب مشابه قبلی، هیچ هدری را به سمت سرور ارسال نمیکند. بنابراین نیاز است کوکی مرتبط را خودمان یافته و سپس به لیست هدرها اضافه کنیم. در اینجا روش استخراج یک کوکی را توسط کدهای جاوا اسکریپتی مشاهده میکنید:
- برای محدود سازی فایلهای ارسالی توسط این کامپوننت، دو روش وجود دارد:
الف) مشخص سازی مقدار خاصیت allowedMimeType
همانطور که مشاهده میکنید، در اینجا باید mime type فایلهای مجاز را مشخص کرد.
ب) مشخص سازی مقدار خاصیت allowedFileType
برخلاف تصور، در اینجا از پسوند فایلها استفاده نمیکند و از یک لیست از پیش مشخص که نمونهای از آنرا در اینجا مشاهده میکنید، کمک گرفته میشود. بنابراین اگر برای مثال تنها نیاز به ارسال تصاویر بود، مقدار image را نگه داشته و مابقی را از لیست حذف کنید.
- removeAfterUpload به این معنا است که آیا لیست نهایی که نمایش داده میشود، پس از آپلود باقی بماند یا خیر؟
- توسط خاصیت maxFileSize میتوان حداکثر اندازهی قابل قبول فایلهای ارسالی را مشخص کرد.
مدیریت رخدادهای کامپوننت ng2-file-upload
اکنون که وهلهای از این کامپوننت ساخته شدهاست، میتوان رخدادهای آنرا نیز مدیریت کرد. برای مثال:
الف) نحوهی ارسال اطلاعات اضافی به همراه یک فایل به سمت سرور
در اینجا شبیه به مطلب مشابه قبلی، مقادیر خواص شیء مدل، به صورت خودکار استخراج شده و به خاصیت form این کامپوننت که درحقیقت همان FormData ارسالی به سمت سرور است، اضافه میشوند.
ب) اطلاع یافتن از رخداد خاتمهی کار
رخداد onCompleteAll پس از ارسال تمام فایلها به سمت سرور فراخوانی میشود:
ج) در حین وهله سازی fileUploader، تعدادی محدودیت نیز قابل اعمال هستند. این محدودیتها سبب نمایش هیچگونه پیام خطایی نمیشوند. فقط زمانیکه کاربر فایلی را انتخاب میکند، این فایل در لیست ظاهر نمیشود. اگر علاقمند به مدیریت این وضعیت باشید، میتوان از رخداد onWhenAddingFileFailed استفاده کرد:
د) اگر ارسال فایلی به سمت سرور با شکست مواجه شود، در رخدادگردان onErrorItem میتوان به نام این فایل و اطلاعات بیشتری که از سمت سرور دریافت شدهاست، دسترسی یافت:
ه) اگر از سمت سرور اطلاعات JSON مانندی یا هر اطلاعات دیگری به سمت کلاینت پس از آپلود ارسال میشود، این اطلاعات را میتوان در رخدادگردان onSuccessItem دریافت کرد:
ارسال نهایی فرم و فایلها به سمت سرور
در پایان، با فراخوانی متد uploadAll شیء fileUploader جاری، میتوان اطلاعات فرم و تمام فایلهای آنرا به سمت سرور ارسال کرد:
فقط باید دقت داشت که این کامپوننت هر فایل را جداگانه به سمت سرور ارسال میکند و برخلاف روش مطلب قبلی، همه را یکجا و در طی یک درخواست به سمت سرور ارسال نمیکند. اما کدهای سمت سرور آن با مطلب مشابه قبلی دقیقا یکی است و تفاوتی نمیکند (همان نکات قسمت «دریافت فرم درخواست پشتیبانی در سمت سرور و ذخیرهی فایلهای آن» مطلب قبلی نیز در اینجا صادق است).
کدهای کامل کامپوننت ng2-file-upload-test.component.ts را در اینجا میتوانید مشاهده کنید.
تکمیل قالب کامپوننت Ng2FileUploadTestComponent
اکنون که کار تکمیل کامپوننت آزمایشی ارسال فایلها به سمت سرور به پایان رسید، نوبت به تکمیل قالب آن است.
افزودن فیلد اضافی توضیحات به فرم
هدف از این فیلد این است که شیء Ticket را وهله سازی و مقدار دهی کند. از مقدار آن در رخدادگردان onBuildItemForm که پیشتر توضیح داده شد، استفاده میشود.
تعریف ویژهی فیلد ارسال فایلها به سمت سرور
در اینجا ابتدا دایرکتیو ng2FileSelect ذکر میشود تا کامپوننت مرتبط فعالسازی شود. سپس خاصیت uploader این دایرکتیو توسط خاصیت fileUploader که پیشتر در کامپوننت، وهله سازی و تنظیم شد، مقدار دهی میشود.
ذکر ویژگی استاندارد multiple را نیز در اینجا مشاهده میکنید. وجود آن سبب خواهد شد تا کاربر بتواند چندین فایل را با هم انتخاب کند. اگر نیازی به ارسال چندین فایل نیست، این ویژگی را حذف کنید.
نمایش لیست فایلها و نمایش درصد پیشرفت آپلود آنها
جدولی را که در تصویر ابتدای بحث مشاهده کردید، به صورت ذیل شکل میگیرد (کدهای آن در همان صفحهی توضیحات کامپوننت نیز موجود هستند):
شیء fileUploader وهله سازی شدهی در کامپوننت این قالب، دارای خاصیت queue است. در این خاصیت، لیست فایلهای انتخابی توسط کاربر درج میشوند. برای مثال مقدار fileUploader?.queue?.length مساوی تعداد فایلهای انتخابی توسط کاربر است. بنابراین میتوان حلقهای را بر روی آن تشکیل داد و مشخصات این فایلها را در صفحه نمایش داد. همچنین هر آیتم آن دارای متد remove نیز هست. کار این متد، حذف این آیتم از لیست queue است و یا اگر متد fileUploader.clearQueue فراخوانی شود، تمام آیتمهای این لیست را حذف میکند.
در اینجا از progress-bar بوت استرپ برای نمایش درصد آپلود فایلها استفاده شدهاست:
این کامپوننت ارسال فایل، خاصیت item.progress هر فایل موجود در queue را مدام به روز رسانی میکند. به همین جهت میتوان از آن جهت تغییر عرض پیشرفت progress-bar بوت استرپ استفاده کرد.
غیرفعال کردن دکمهی ارسال، در صورت عدم انتخاب یک فایل
اگر بخواهیم انتخاب حداقل یک فایل را توسط کاربر اجباری کنیم، میتوان خاصیت disabled دکمهی ارسال را به طول صف یا fileUploader.queue.length نیز متصل کرد.
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید.
پیشنیازهای کار با کامپوننت ng2-file-upload
برای شروع به کار با کامپوننت ng2-file-upload، ابتدا نیاز است بستهی npm آنرا نصب کرد:
>npm install ng2-file-upload --save
همچنین یک کامپوننت آزمایشی را هم به برنامه (دقیقا همان مثال مطلب قبلی) جهت اعمال آن اضافه میکنیم:
>ng g c UploadFile/ng2-file-upload-test
پس از آن نیاز است به ماژولی که این کامپوننت جدید در آن قرار دارد، مدخل FileUploadModule کامپوننت ng2-file-upload را افزود:
import { FileUploadModule } from "ng2-file-upload"; @NgModule({ imports: [ FileUploadModule ]
تکمیل Ng2FileUploadTestComponent جهت اعمال ng2-file-upload
اکنون به کلاس کامپوننت جدیدی که ایجاد کردیم، مراجعه کرده و تغییرات ذیل را اعمال میکنیم:
import { FileUploader, FileUploaderOptions } from "ng2-file-upload"; import { Ticket } from "./../ticket"; export class Ng2FileUploadTestComponent implements OnInit { fileUploader: FileUploader; model = new Ticket();
وهله سازی از کامپوننت ng2-file-upload و انجام تنظیمات اولیهی آن
پس از تعریف خاصیت عمومی fileUploader، اکنون نوبت به وهله سازی آن است:
this.fileUploader = new FileUploader( <FileUploaderOptions>{ url: "api/SimpleUpload/SaveTicket", headers: [ { name: "X-XSRF-TOKEN", value: this.getCookie("XSRF-TOKEN") }, { name: "Accept", value: "application/json" } ], isHTML5: true, // allowedMimeType: ["image/jpeg", "image/png", "application/pdf", "application/msword", "application/zip"] allowedFileType: [ "application", "image", "video", "audio", "pdf", "compress", "doc", "xls", "ppt" ], removeAfterUpload: true, autoUpload: false, maxFileSize: 10 * 1024 * 1024 } );
- اگر برنامه از نکات anti-forgery token استفاده میکند، این کامپوننت برخلاف روش مطرح شدهی در مطلب مشابه قبلی، هیچ هدری را به سمت سرور ارسال نمیکند. بنابراین نیاز است کوکی مرتبط را خودمان یافته و سپس به لیست هدرها اضافه کنیم. در اینجا روش استخراج یک کوکی را توسط کدهای جاوا اسکریپتی مشاهده میکنید:
getCookie(name: string): string { const value = "; " + document.cookie; const parts = value.split("; " + name + "="); if (parts.length === 2) { return decodeURIComponent(parts.pop().split(";").shift()); } }
- برای محدود سازی فایلهای ارسالی توسط این کامپوننت، دو روش وجود دارد:
الف) مشخص سازی مقدار خاصیت allowedMimeType
همانطور که مشاهده میکنید، در اینجا باید mime type فایلهای مجاز را مشخص کرد.
ب) مشخص سازی مقدار خاصیت allowedFileType
برخلاف تصور، در اینجا از پسوند فایلها استفاده نمیکند و از یک لیست از پیش مشخص که نمونهای از آنرا در اینجا مشاهده میکنید، کمک گرفته میشود. بنابراین اگر برای مثال تنها نیاز به ارسال تصاویر بود، مقدار image را نگه داشته و مابقی را از لیست حذف کنید.
- removeAfterUpload به این معنا است که آیا لیست نهایی که نمایش داده میشود، پس از آپلود باقی بماند یا خیر؟
- توسط خاصیت maxFileSize میتوان حداکثر اندازهی قابل قبول فایلهای ارسالی را مشخص کرد.
مدیریت رخدادهای کامپوننت ng2-file-upload
اکنون که وهلهای از این کامپوننت ساخته شدهاست، میتوان رخدادهای آنرا نیز مدیریت کرد. برای مثال:
الف) نحوهی ارسال اطلاعات اضافی به همراه یک فایل به سمت سرور
this.fileUploader.onBuildItemForm = (fileItem, form) => { for (const key in this.model) { if (this.model.hasOwnProperty(key)) { form.append(key, this.model[key]); } } };
ب) اطلاع یافتن از رخداد خاتمهی کار
رخداد onCompleteAll پس از ارسال تمام فایلها به سمت سرور فراخوانی میشود:
this.fileUploader.onCompleteAll = () => { // clear the form // this.model = new Ticket(); };
ج) در حین وهله سازی fileUploader، تعدادی محدودیت نیز قابل اعمال هستند. این محدودیتها سبب نمایش هیچگونه پیام خطایی نمیشوند. فقط زمانیکه کاربر فایلی را انتخاب میکند، این فایل در لیست ظاهر نمیشود. اگر علاقمند به مدیریت این وضعیت باشید، میتوان از رخداد onWhenAddingFileFailed استفاده کرد:
this.fileUploader.onWhenAddingFileFailed = (item, filter, options) => { // msg: `You can't select ${item.name} file because of the ${filter.name} filter.` };
د) اگر ارسال فایلی به سمت سرور با شکست مواجه شود، در رخدادگردان onErrorItem میتوان به نام این فایل و اطلاعات بیشتری که از سمت سرور دریافت شدهاست، دسترسی یافت:
this.fileUploader.onErrorItem = (fileItem, response, status, headers) => { // };
ه) اگر از سمت سرور اطلاعات JSON مانندی یا هر اطلاعات دیگری به سمت کلاینت پس از آپلود ارسال میشود، این اطلاعات را میتوان در رخدادگردان onSuccessItem دریافت کرد:
this.fileUploader.onSuccessItem = (item, response, status, headers) => { if (response) { const ticket = JSON.parse(response); console.log(`ticket:`, ticket); } };
ارسال نهایی فرم و فایلها به سمت سرور
در پایان، با فراخوانی متد uploadAll شیء fileUploader جاری، میتوان اطلاعات فرم و تمام فایلهای آنرا به سمت سرور ارسال کرد:
submitForm(form: NgForm) { this.fileUploader.uploadAll(); // NOTE: Upload multiple files in one request -> https://github.com/valor-software/ng2-file-upload/issues/671 }
کدهای کامل کامپوننت ng2-file-upload-test.component.ts را در اینجا میتوانید مشاهده کنید.
تکمیل قالب کامپوننت Ng2FileUploadTestComponent
اکنون که کار تکمیل کامپوننت آزمایشی ارسال فایلها به سمت سرور به پایان رسید، نوبت به تکمیل قالب آن است.
افزودن فیلد اضافی توضیحات به فرم
<div class="container"> <h3>Support Form(ng2-file-upload)</h3> <form #form="ngForm" (submit)="submitForm(form)" novalidate> <div class="form-group" [class.has-error]="description.invalid && description.touched"> <label class="control-label">Description</label> <input #description="ngModel" required type="text" class="form-control" name="description" [(ngModel)]="model.description"> <div *ngIf="description.invalid && description.touched"> <div class="alert alert-danger" *ngIf="description.errors.required"> description is required. </div> </div> </div>
تعریف ویژهی فیلد ارسال فایلها به سمت سرور
<div class="form-group"> <label class="control-label">Screenshot(s)</label> <input required type="file" multiple ng2FileSelect [uploader]="fileUploader" class="form-control" name="screenshot"> </div>
ذکر ویژگی استاندارد multiple را نیز در اینجا مشاهده میکنید. وجود آن سبب خواهد شد تا کاربر بتواند چندین فایل را با هم انتخاب کند. اگر نیازی به ارسال چندین فایل نیست، این ویژگی را حذف کنید.
نمایش لیست فایلها و نمایش درصد پیشرفت آپلود آنها
جدولی را که در تصویر ابتدای بحث مشاهده کردید، به صورت ذیل شکل میگیرد (کدهای آن در همان صفحهی توضیحات کامپوننت نیز موجود هستند):
<div style="margin-bottom: 10px" *ngIf="fileUploader.queue.length"> <h3>Upload queue</h3> <p>Queue length: {{ fileUploader?.queue?.length }}</p> <table class="table"> <thead> <tr> <th width="50%">Name</th> <th>Size</th> <th>Progress</th> <th>Status</th> <th>Actions</th> </tr> </thead> <tbody> <tr *ngFor="let item of fileUploader.queue"> <td><strong>{{ item?.file?.name }}</strong></td> <td nowrap>{{ item?.file?.size/1024/1024 | number:'.2' }} MB</td> <td> <div class="progress" style="margin-bottom: 0;"> <div class="progress-bar" role="progressbar" [ngStyle]="{ 'width': item.progress + '%' }"></div> </div> </td> <td class="text-center"> <span *ngIf="item.isError"><i class="glyphicon glyphicon-remove"></i></span> </td> <td nowrap> <button type="button" class="btn btn-danger btn-xs" (click)="item.remove()"> <span class="glyphicon glyphicon-trash"></span> Remove </button> </td> </tr> </tbody> </table> <div> <div> Queue progress: <div class="progress"> <div class="progress-bar" role="progressbar" [ngStyle]="{ 'width': fileUploader.progress + '%' }"></div> </div> </div> <button type="button" class="btn btn-danger btn-s" (click)="fileUploader.clearQueue()" [disabled]="!fileUploader.queue.length"> <span class="glyphicon glyphicon-trash"></span> Remove all </button> </div> </div>
در اینجا از progress-bar بوت استرپ برای نمایش درصد آپلود فایلها استفاده شدهاست:
<div class="progress" style="margin-bottom: 0;"> <div class="progress-bar" role="progressbar" [ngStyle]="{ 'width': item.progress + '%' }"></div> </div>
غیرفعال کردن دکمهی ارسال، در صورت عدم انتخاب یک فایل
<button class="btn btn-primary" [disabled]="form.invalid || !fileUploader.queue.length" type="submit">Submit</button> </form>
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید.
تا اینجا کامپوننت صفحه بندی را به همراه اعمال آن به لیست نمایش داده شده، پیاده سازی کردیم. در ادامه میخواهیم لیست ژانرهای سینمایی را که در فایل fakeGenreService.js تعریف شدهاند:
توسط list-groupهای بوت استرپی، در کنار صفحه نمایش داده و سپس به ازای هر گروه انتخابی توسط کاربر، فیلمهای مرتبط با آن گروه را فیلتر کرده و نمایش دهیم.
بررسی ساختار کامپوننت ListGroup
شبیه به کامپوننت صفحه بندی که در قسمت قبل ایجاد کردیم، میخواهیم کامپوننت ListGroup نیز به طور کامل از اشیاء movie مستقل باشد؛ تا در آینده بتوان از آن در جاهای دیگری نیز استفاده کرد. به همین جهت فایل جدید src\components\common\listGroup.jsx را ایجاد کرده و سپس با استفاده از میانبرهای imrc و cc در VSCode، ساختار ابتدایی این کامپوننت را ایجاد میکنیم. هرچند میتوان این کامپوننت را به صورت «Stateless Functional Component» نیز طراحی کرد؛ چون state و متد دیگری بجز render نخواهد داشت و تمام اطلاعات خودش را از والد خود دریافت میکند.
سپس به کامپوننت movies مراجعه کرده و این کامپوننت خالی را import میکنیم:
پس از آن به متد رندر کامپوننت movies مراجعه کرده و با اضافه کردن یک row بوت استرپی دو ستونی، قصد داریم کامپوننت لیست فیلمها را در ستون اول این ردیف نمایش دهیم. به همین جهت المان آنرا در این محل قرار میدهیم تا بتوانیم اینترفیس ابتدایی آنرا پیش از پیاده سازی آن، طراحی کنیم.
برای این منظور ابتدا React.Fragment موجود را با یک div با "className="row جایگزین میکنیم. سپس داخل این row، دو ستون را تعریف خواهیم کرد که در اولی، المان جدید ListGroup قرار میگیرد و در دومی، مابقی عناصری که تاکنون اضافه کردهایم؛ مانند جدول، صفحه بندی و نمایش تعداد آیتمها:
این listGroup، حداقل نیاز به لیست آیتمهایی را دارد که باید نمایش دهد. این لیست نیز از fakeGenreService و متد getGenres آن تامین میشود که به صورت یک خاصیت جدید در state به نحو زیر درج خواهد شد:
همانطور که در قسمت 9 این سری نیز بررسی کردیم، اگر getGenres قرار است از سمت سرور و توسط یک درخواست Ajax ای تامین شود، محل صحیح قرارگیری آن در متد lifecycle hook ویژهای به نام componentDidMount است. اما در اینجا چون genres یک لیست درون حافظهای است، مقدار دهی فوق، مشکلی را ایجاد نمیکند. هرچند میتوان هم اکنون نیز تعریف فوق را کمی اصولیتر نوشت. برای اینکار متد componentDidMount را اضافه کرده و به نحو زیر تنظیم میکنیم:
ابتدا آرایههای مورد نیاز movies و genres را در state تعریف کرده و آنها را با یک آرایهی خالی، مقدار دهی اولیه میکنیم. از این جهت که تا رسیدن به مرحلهی componentDidMount که اندکی طول میکشد، خطاهای زمان اجرای عدم دسترسی به این آرایهها در برنامه رخ ندهد. سپس زمانیکه وهلهای از این کامپوننت در DOM رندر شد، متد componentDidMount فراخوانی شده و دو خاصیت state را با مقادیر دریافتی، به روز رسانی میکند.
پس از آن میتوان ویژگی جدید items این کامپوننت را به آرایهی genres دریافتی از state، تنظیم کرد:
در این مرحله، ورودی دیگری به نظر نمیرسد که مورد نیاز باشد. اکنون این سؤال مطرح میشود که چه رخدادهایی را قرار است از این کامپوننت دریافت کنیم یا به عبارتی خروجی آن چیست؟
بهتر است هر زمانیکه کاربر، آیتمی را از این لیست انتخاب کرد، توسط بروز رخدادی مانند onItemSelect از وقوع آن مطلع شد و سپس نسبت به آن توسط متد handleGenreSelect، واکنش نشان داد؛ مانند فیلتر کردن لیست فیلمها بر اساس آیتم انتخابی و نمایش آن. به همین جهت ویژگی onItemSelect را به تعریف المان ListGroup اضافه میکنیم:
و سپس متد handleGenreSelect متصل به آنرا به نحو زیر تعریف خواهیم کرد:
تا اینجا اینترفیس کامپوننت ListGroup را پیش از پیاده سازی آن تعریف کردیم (تعیین ورودی و خروجی آن). در مرحلهی بعد، این کامپوننت را تکمیل میکنیم.
پیاده سازی نمایش آیتمها در کامپوننت ListGroup
پیاده سازی ابتدایی کامپوننت ListGroup را در اینجا مشاهده میکنید:
کار با درج یک ul که با کلاس list-group مزین شدهاست، شروع میشود. سپس باید liهای آنرا که نمایانگر آیتمهای این لیست است، به صورت پویا با کلاسهای list-group-item رندر کرد. برای اینکار از آرایهی دریافتی this.props.items و فراخوانی متد map بر روی آن کمک میگیریم. در اینجا key هر ردیف با استفاده از خاصیت id هر آیتم و برچسب هر کدام از طریق خاصیت name هر شیء دریافتی، تامین میشود.
تا اینجا اگر برنامه را ذخیره کرده و در مرورگر نمایش دهیم، به خروجی زیر میرسیم:
البته به نظر عرض ستون آن نامناسب است. به همین جهت به کامپوننت movies مراجعه کرده و col-2 ستون آنرا به col-3 تبدیل میکنیم.
پویا سازی انتخاب نام خواص شیء دریافتی، در کامپوننت ListGroup
در حال حاضر پیاده سازی کامپوننت ListGroup، به شیءای دقیقا با خواص id_ و name وابستهاست و اگر شیء دیگری را که دارای خواصی معادل این نامها نیست، به آن ارسال کنیم، دیگر کار نخواهد کرد. به همین جهت در محل تعریف المان این کامپوننت در کامپوننت movies، دو ویژگی دیگر نام خواص شیء مدنظر را تنظیم میکنیم تا بتوانیم با هر نوع شیءای در اینجا کار کنیم:
پس از این تغییر و افزودن textProperty و valueProperty، برای پویا سازی نامهای خواص دریافتی در کامپوننت ListGroup، از روش کار با []، جهت دسترسی پویای به خواص یک شیء، استفاده میکنیم تا دیگر این کامپوننت به شیء خاص genre، وابستگی نداشته باشد و قابلیت استفادهی مجدد از آن افزایش یابد:
تعیین مقادیر پیشفرضی برای خواص props
با زیاد شدن تعداد خواص props، اینترفیس کامپوننتها پیچیدهتر میشوند. در یک چنین حالتی میتوان در کامپوننتها defaultProps را تعریف کرد و توسط آن مقادیر پیشفرضی را برای خواص props درنظر گرفت. به این صورت در حین تعریف المان این کامپوننت، اگر مقادیر مدنظر با مقادیر پیشفرض تعیین شده یکی باشند، دیگر نیازی به ذکر این پارامترها نخواهد بود. برای مثال در انتهای کامپوننت ListGroup، خاصیت جدید defaultProps را تعریف میکنیم (املای آن باید دقیقا به همین شکل باشد؛ و گرنه شناخته نخواهد شد). سپس در اینجا خواصی را که میخواهیم مقادیر پیشفرضی را برای آنها تعیین کنیم، ذکر خواهیم کرد:
برای نمونه در اینجا دو خاصیت جدید textProperty و valueProperty را به همان مقادیر name و id_ مورد استفادهی در این مثال تنظیم کردهایم. پس از این تعریف، میتوان به کامپوننت movies که از این ویژگیها استفاده میکند مراجعه کرده و آنهایی را که با defaultProps تطابق دارند، از لیست ویژگیهای ذکر شده حذف کرد؛ یعنی تعریف المان ListGroup به صورت زیر ساده میشود:
بدیهی است اگر در آینده با اشیاء دیگری سر و کار داشتیم، میتوان مجددا این خواص پیشفرض را بر اساس ساختار این اشیاء، مقدار دهی و تعیین کرد.
مدیریت انتخاب گروههای فیلمها
در ادامه میخواهیم رخداد onClick بر روی هر li این لیست را مدیریت کنیم و سبب بروز رخدادی به نام onItemSelect شویم که در ابتدای بحث، آنرا به عنوان خروجی این کامپوننت تعریف کردیم. این رخداد نیز در کامپوننت movies به متد handleGenreSelect متصل است. به همین جهت تعریف ویژگی onClick را که سبب انتقال شیء جاری رندر شده، توسط رویداد onItemSelect به خارج از آن میشود، به المان li کامپوننت ListGroup اضافه میکنیم:
پس از این تغییرات و ذخیرهی برنامه، اگر به خروجی برنامه در مرورگر مراجعه کرده و بر روی هر کدام از آیتمهای لیست گروههای فیلمها کلیک کنیم، شیء مرتبط با آن آیتم در کنسول توسعه دهندههای مرورگر، لاگ میشود که نشان از برقراری صحیح ارتباطات این قسمت را دارد.
پس از فعالسازی امکان کلیک بر روی هر آیتم لیست رندر شده، اکنون میخواهیم با انتخاب هر گروه، این گروه در این لیست، به صورت انتخاب شده، همانند شماره صفحهی انتخاب شدهی در کامپوننت صفحه بندی، تغییر رنگ دهد و متمایز نمایش داده شود تا مشخص باشد که هم اکنون با کدام آیتم در حال کار هستیم. برای اینکار تنها کافی است کلاس active را به صورت پویا به className هر li، اضافه یا کم کنیم. البته برای این منظور این کامپوننت باید از آیتم انتخاب شده مطلع باشد؛ به همین جهت selectedItem را در لیست ویژگیهای اینترفیس تعریف این المان اضافه میکنیم. برای اینکار ابتدا selectedGenre را با هربار فراخوانی handleGenreSelect که به onItemSelect کامپوننت متصل است، با فراخوانی متد setState به روز رسانی میکنیم:
در یک چنین حالتی الزامی به تعریف selectedGenre در خاصیت state ابتدای کامپوننت نیست. چون با فراخوانی متد setState اگر یکی از خواص منتسب به شیء state به روز شده باشد، آن خاصیت نیز به روز میشود و یا اگر این خاصیت جدید باشد، با state موجود یکی خواهد شد؛ هرچند آنرا به صورت زیر نیز میتوان تعریف کرد که با یک شیء خالی مقدار دهی شدهاست:
سپس ویژگی selectedItem کامپوننت را به این مقدار تغییر یافتهی this.state.selectedGenre تنظیم میکنیم تا با هر بار فراخوانی setState که سبب رندر مجدد کامپوننت Movies در DOM مجازی React میشود، کامپوننت از selectedItem تغییر یافته مطلع شده و با افزودن کلاس active به آن آیتم، واکنش نشان دهد:
اکنون به کامپوننت ListGroup مراجعه کرده و بر اساس ویژگی جدید selectedItem، تغییرات زیر را به className اعمال میکنیم:
در اینجا اگر item در حال رندر با this.props.selectedItem دریافتی یکی باشد، کلاس active به کلاس list-group-item اضافه میشود و برعکس.
مدیریت فیلتر کردن اطلاعات گروه فیلم انتخابی
در قسمت قبل، در ابتدای متد رندر کامپوننت movies، از متد paginate برای صفحه بندی اطلاعات استفاده کردیم. فیلتر گروه جاری انتخاب شده را باید پیش از این متد قرار دارد؛ چون تعداد صفحات و اطلاعات نمایش داده شدهی در هر کدام باید بر اساس لیست فیلمهای فیلتر شده باشد.
برای انجام اینکار تغییرات زیر را اعمال خواهیم کرد:
الف) بجای متد paginate، از متد getPagedData زیر استفاده میکنیم:
- در اینجا بجای اینکه مدام this.statها را جهت دریافت خواص آن تکرار کنیم، با استفاده از ویژگی Object Destructuring، خواصی را که نیاز داریم یکبار انتخاب کرده و سپس به دفعات از آنها استفاده میکنیم. به همین جهت در این قطعه کد، فقط یکبار this.state را مشاهده میکنید که بسیار تمیزتر است و همچنین کارآیی آن نیز به علت عدم انتخاب مداوم مقدار خاصیتی از یک شیء، بالاتر از حالت قبل است.
- در حین Object Destructuring، نام خاصیت movies را نیز به allMovies تغییر دادهایم تا واضحتر باشد.
- در ادامه با استفاده از متد filter جاوااسکریپت، بر اساس id هر گروه انتخاب شده، اشیاء مرتبط با آن، از allMovies جدا شده و بازگشت داده میشود. البته اگر id هم انتخاب نشده باشد (اولین بار نمایش صفحه)، تمام رکوردها یعنی allMovies، مورد استفاده قرار میگیرد.
- پس از آن، همان کدهای صفحه بندی اطلاعات را که در قسمت قبل بررسی کردیم، مشاهده میکنید که اینبار بجای allMovies قسمت قبل، بر روی filteredMovies اعمال شدهاست.
- در آخر، این متد، یک شیء را با دو خاصیت که بیانگر تعداد کل رکوردهای انتخاب شده و دادههای فیلتر شدهی صفحه بندی شدهاست، بازگشت میدهد.
ب) تغییرات متد رندر کامپوننت movies به صورت زیر است:
- ابتدا متد getPagedData فوق، فراخوانی شده و شیء دریافتی از آن با استفاده از ویژگی Object Destructuring، به دو خاصیت totalCount و movies انتساب داده میشود:
- از آرایهی movies، در قسمت قبل برای رندر لیست فیلمها استفاده شد. به همین جهت در اینجا تغییر نام data به movies را مشاهده میکنید.
- همچنین کامپوننت صفحه بندی، اینبار باید totalCount آیتمهای فیلتر شده را نمایش دهد و نه totalCount تمام فیلمهای موجود را:
در اینجا برچسب نمایش تعداد آیتمهای موجود نیز باید تغییر کند:
ج) ممکن است در اولین بار مشاهدهی صفحه، کاربر صفحهی شمارهی 3 را انتخاب کند که سبب تغییر currentPage موجود در state، به عدد 3 میشود. اکنون اگر کاربر نمایش فیلتر شدهی فیلمهای یک گروه خاص را انتخاب کند، باید این شماره، به عدد 1 مجددا تنظیم شود:
افزودن گزینهی نمایش تمام اطلاعات به لیست گروههای فیلمها
در ادامه قصد داریم به بالای لیست گروههای موجود، گزینهی All Genres را نیز اضافه کنیم تا با کلیک بر روی آن، مجددا بتوان لیست تمام فیلمهای موجود را مشاهده کرد.
برای این منظور در جائیکه لیست getGenres را دریافت و نمایش میدهیم، یعنی متد componentDidMount، اندکی تغییر ایجاد کرده و یک آرایهی جدید را ایجاد میکنیم؛ بطوریکه اولین عنصر آن، گزینهی جدید All Genres باشد و سپس توسط spread operator، مابقی عناصر آرایهی گروهها را به این آرایهی جدید اضافه میکنیم:
همین اندازه تغییر برای فعالسازی این گزینه کفایت میکند؛ از این جهت که در متد getPagedData، ابتدا بررسی میشود که اگر آیتمی انتخاب شده بود و همچنین دارای id نیز بود، آنگاه کار فیلتر کردن صورت گیرد، درغیراینصورت، تمام رکوردها را بازگشت دهد:
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: sample-12.zip
export const genres = [ { _id: "5b21ca3eeb7f6fbccd471818", name: "Action" }, { _id: "5b21ca3eeb7f6fbccd471814", name: "Comedy" }, { _id: "5b21ca3eeb7f6fbccd471820", name: "Thriller" } ]; export function getGenres() { return genres.filter(g => g); }
بررسی ساختار کامپوننت ListGroup
شبیه به کامپوننت صفحه بندی که در قسمت قبل ایجاد کردیم، میخواهیم کامپوننت ListGroup نیز به طور کامل از اشیاء movie مستقل باشد؛ تا در آینده بتوان از آن در جاهای دیگری نیز استفاده کرد. به همین جهت فایل جدید src\components\common\listGroup.jsx را ایجاد کرده و سپس با استفاده از میانبرهای imrc و cc در VSCode، ساختار ابتدایی این کامپوننت را ایجاد میکنیم. هرچند میتوان این کامپوننت را به صورت «Stateless Functional Component» نیز طراحی کرد؛ چون state و متد دیگری بجز render نخواهد داشت و تمام اطلاعات خودش را از والد خود دریافت میکند.
سپس به کامپوننت movies مراجعه کرده و این کامپوننت خالی را import میکنیم:
import ListGroup from "./common/listGroup";
برای این منظور ابتدا React.Fragment موجود را با یک div با "className="row جایگزین میکنیم. سپس داخل این row، دو ستون را تعریف خواهیم کرد که در اولی، المان جدید ListGroup قرار میگیرد و در دومی، مابقی عناصری که تاکنون اضافه کردهایم؛ مانند جدول، صفحه بندی و نمایش تعداد آیتمها:
return ( <div className="row"> <div className="col-2"> <ListGroup /> </div> <div className="col"> ... </div> </div> );
import { getGenres } from "../services/fakeGenreService"; // ... class Movies extends Component { state = { // ... genres: getGenres() };
class Movies extends Component { state = { movies: [], pageSize: 4, currentPage: 1, genres: [] }; componentDidMount() { this.setState({ movies: getMovies(), genres: getGenres() }); }
پس از آن میتوان ویژگی جدید items این کامپوننت را به آرایهی genres دریافتی از state، تنظیم کرد:
<ListGroup items={this.state.genres} />
بهتر است هر زمانیکه کاربر، آیتمی را از این لیست انتخاب کرد، توسط بروز رخدادی مانند onItemSelect از وقوع آن مطلع شد و سپس نسبت به آن توسط متد handleGenreSelect، واکنش نشان داد؛ مانند فیلتر کردن لیست فیلمها بر اساس آیتم انتخابی و نمایش آن. به همین جهت ویژگی onItemSelect را به تعریف المان ListGroup اضافه میکنیم:
<ListGroup items={this.state.genres} onItemSelect={this.handleGenreSelect} />
handleGenreSelect = genre => { console.log("handleGenreSelect", genre); };
پیاده سازی نمایش آیتمها در کامپوننت ListGroup
پیاده سازی ابتدایی کامپوننت ListGroup را در اینجا مشاهده میکنید:
import React, { Component } from "react"; class ListGroup extends Component { render() { return ( <ul className="list-group"> {this.props.items.map(item => ( <li key={item._id} className="list-group-item"> {item.name} </li> ))} </ul> ); } } export default ListGroup;
تا اینجا اگر برنامه را ذخیره کرده و در مرورگر نمایش دهیم، به خروجی زیر میرسیم:
البته به نظر عرض ستون آن نامناسب است. به همین جهت به کامپوننت movies مراجعه کرده و col-2 ستون آنرا به col-3 تبدیل میکنیم.
پویا سازی انتخاب نام خواص شیء دریافتی، در کامپوننت ListGroup
در حال حاضر پیاده سازی کامپوننت ListGroup، به شیءای دقیقا با خواص id_ و name وابستهاست و اگر شیء دیگری را که دارای خواصی معادل این نامها نیست، به آن ارسال کنیم، دیگر کار نخواهد کرد. به همین جهت در محل تعریف المان این کامپوننت در کامپوننت movies، دو ویژگی دیگر نام خواص شیء مدنظر را تنظیم میکنیم تا بتوانیم با هر نوع شیءای در اینجا کار کنیم:
<ListGroup items={this.state.genres} textProperty="name" valueProperty="_id" onItemSelect={this.handleGenreSelect} />
import React, { Component } from "react"; class ListGroup extends Component { render() { return ( <ul className="list-group"> {this.props.items.map(item => ( <li key={item[this.props.valueProperty]} className="list-group-item"> {item[this.props.textProperty]} </li> ))} </ul> ); } } export default ListGroup;
تعیین مقادیر پیشفرضی برای خواص props
با زیاد شدن تعداد خواص props، اینترفیس کامپوننتها پیچیدهتر میشوند. در یک چنین حالتی میتوان در کامپوننتها defaultProps را تعریف کرد و توسط آن مقادیر پیشفرضی را برای خواص props درنظر گرفت. به این صورت در حین تعریف المان این کامپوننت، اگر مقادیر مدنظر با مقادیر پیشفرض تعیین شده یکی باشند، دیگر نیازی به ذکر این پارامترها نخواهد بود. برای مثال در انتهای کامپوننت ListGroup، خاصیت جدید defaultProps را تعریف میکنیم (املای آن باید دقیقا به همین شکل باشد؛ و گرنه شناخته نخواهد شد). سپس در اینجا خواصی را که میخواهیم مقادیر پیشفرضی را برای آنها تعیین کنیم، ذکر خواهیم کرد:
ListGroup.defaultProps = { textProperty: "name", valueProperty: "_id" }; export default ListGroup;
<ListGroup items={this.state.genres} onItemSelect={this.handleGenreSelect} />
مدیریت انتخاب گروههای فیلمها
در ادامه میخواهیم رخداد onClick بر روی هر li این لیست را مدیریت کنیم و سبب بروز رخدادی به نام onItemSelect شویم که در ابتدای بحث، آنرا به عنوان خروجی این کامپوننت تعریف کردیم. این رخداد نیز در کامپوننت movies به متد handleGenreSelect متصل است. به همین جهت تعریف ویژگی onClick را که سبب انتقال شیء جاری رندر شده، توسط رویداد onItemSelect به خارج از آن میشود، به المان li کامپوننت ListGroup اضافه میکنیم:
<li key={item[this.props.valueProperty]} className="list-group-item" onClick={() => this.props.onItemSelect(item)} style={{ cursor: "pointer" }} > {item[this.props.textProperty]} </li>
پس از فعالسازی امکان کلیک بر روی هر آیتم لیست رندر شده، اکنون میخواهیم با انتخاب هر گروه، این گروه در این لیست، به صورت انتخاب شده، همانند شماره صفحهی انتخاب شدهی در کامپوننت صفحه بندی، تغییر رنگ دهد و متمایز نمایش داده شود تا مشخص باشد که هم اکنون با کدام آیتم در حال کار هستیم. برای اینکار تنها کافی است کلاس active را به صورت پویا به className هر li، اضافه یا کم کنیم. البته برای این منظور این کامپوننت باید از آیتم انتخاب شده مطلع باشد؛ به همین جهت selectedItem را در لیست ویژگیهای اینترفیس تعریف این المان اضافه میکنیم. برای اینکار ابتدا selectedGenre را با هربار فراخوانی handleGenreSelect که به onItemSelect کامپوننت متصل است، با فراخوانی متد setState به روز رسانی میکنیم:
handleGenreSelect = genre => { console.log("handleGenreSelect", genre); this.setState({selectedGenre: genre}); };
class Movies extends Component { state = { // ... selectedGenre: {} };
<ListGroup items={this.state.genres} onItemSelect={this.handleGenreSelect} selectedItem={this.state.selectedGenre} />
<li key={item[this.props.valueProperty]} className={ item === this.props.selectedItem ? "list-group-item active" : "list-group-item" } style={{ cursor: "pointer" }} onClick={() => this.props.onItemSelect(item)} > {item[this.props.textProperty]} </li>
مدیریت فیلتر کردن اطلاعات گروه فیلم انتخابی
در قسمت قبل، در ابتدای متد رندر کامپوننت movies، از متد paginate برای صفحه بندی اطلاعات استفاده کردیم. فیلتر گروه جاری انتخاب شده را باید پیش از این متد قرار دارد؛ چون تعداد صفحات و اطلاعات نمایش داده شدهی در هر کدام باید بر اساس لیست فیلمهای فیلتر شده باشد.
برای انجام اینکار تغییرات زیر را اعمال خواهیم کرد:
الف) بجای متد paginate، از متد getPagedData زیر استفاده میکنیم:
getPagedData() { const { pageSize, currentPage, selectedGenre, movies: allMovies } = this.state; const filteredMovies = selectedGenre && selectedGenre._id ? allMovies.filter(m => m.genre._id === selectedGenre._id) : allMovies; const first = (currentPage - 1) * pageSize; const last = first + pageSize; const pagedMovies = filteredMovies.slice(first, last); return { totalCount: filteredMovies.length, data: pagedMovies }; }
- در حین Object Destructuring، نام خاصیت movies را نیز به allMovies تغییر دادهایم تا واضحتر باشد.
- در ادامه با استفاده از متد filter جاوااسکریپت، بر اساس id هر گروه انتخاب شده، اشیاء مرتبط با آن، از allMovies جدا شده و بازگشت داده میشود. البته اگر id هم انتخاب نشده باشد (اولین بار نمایش صفحه)، تمام رکوردها یعنی allMovies، مورد استفاده قرار میگیرد.
- پس از آن، همان کدهای صفحه بندی اطلاعات را که در قسمت قبل بررسی کردیم، مشاهده میکنید که اینبار بجای allMovies قسمت قبل، بر روی filteredMovies اعمال شدهاست.
- در آخر، این متد، یک شیء را با دو خاصیت که بیانگر تعداد کل رکوردهای انتخاب شده و دادههای فیلتر شدهی صفحه بندی شدهاست، بازگشت میدهد.
ب) تغییرات متد رندر کامپوننت movies به صورت زیر است:
- ابتدا متد getPagedData فوق، فراخوانی شده و شیء دریافتی از آن با استفاده از ویژگی Object Destructuring، به دو خاصیت totalCount و movies انتساب داده میشود:
render() { const { length: count } = this.state.movies; if (count === 0) return <p>There are no movies in the database.</p>; const { totalCount, data: movies } = this.getPagedData();
- همچنین کامپوننت صفحه بندی، اینبار باید totalCount آیتمهای فیلتر شده را نمایش دهد و نه totalCount تمام فیلمهای موجود را:
<Pagination itemsCount={totalCount}
<p>Showing {totalCount} movies in the database.</p>
handleGenreSelect = genre => { console.log("handleGenreSelect", genre); this.setState({ selectedGenre: genre, currentPage: 1 }); };
افزودن گزینهی نمایش تمام اطلاعات به لیست گروههای فیلمها
در ادامه قصد داریم به بالای لیست گروههای موجود، گزینهی All Genres را نیز اضافه کنیم تا با کلیک بر روی آن، مجددا بتوان لیست تمام فیلمهای موجود را مشاهده کرد.
برای این منظور در جائیکه لیست getGenres را دریافت و نمایش میدهیم، یعنی متد componentDidMount، اندکی تغییر ایجاد کرده و یک آرایهی جدید را ایجاد میکنیم؛ بطوریکه اولین عنصر آن، گزینهی جدید All Genres باشد و سپس توسط spread operator، مابقی عناصر آرایهی گروهها را به این آرایهی جدید اضافه میکنیم:
componentDidMount() { const genres = [{ _id: "", name: "All Genres" }, ...getGenres()]; this.setState({ movies: getMovies(), genres }); }
const filteredMovies = selectedGenre && selectedGenre._id ? allMovies.filter(m => m.genre._id === selectedGenre._id) : allMovies;
اشتراکها
پشتیبانی رسمی RethinkDB از Windows
پس از آشنایی با مقدمات کار با Axios، در این قسمت امکانات پیشرفتهتر آنرا مانند خطایابی سراسری، interceptors و ... بررسی میکنیم.
به روز رسانیهای خوشبینانهی UI
پیاده سازی اعمال CRUD توسط Axios در قسمت قبل، به همراه یک مشکل مهم است: اعمال کار با شبکه و سرور، زمانبر هستند و مدتی طول میکشد تا پاسخ عملیات از سمت سرور دریافت شود. در این بین اگر خطایی رخ دهد، مابقی کدهای نوشته شدهی در متدهایی مانند Update و Delete، اجرا نمیشوند. به این حالت «به روز رسانی بدبینانهی UI» گفته میشود. در حالت خوشبینانه، فرض بر این است که در اکثر موارد، فراخوانی سرور با موفقیت به پایان میرسد. در یک چنین حالتی، ابتدا UI به روز رسانی میشود و سپس فراخوانیهای سمت سرور صورت میگیرند. اگر این فراخوانی با شکست مواجه شد، مجددا UI را به حالت قبلی آن باز میگردانیم:
در کدهای فوق، ابتدا UI به روز رسانی میشود (که بسیار سریع است)، سپس حذف سمت سرور صورت میگیرد. یک چنین پیاده سازی، به کاربر حس کار با یک برنامهی بسیار سریع را القاء میکند؛ هرچند فراخوانی سمت سرور انجام شده، ممکن است مدتی طول بکشد.
اما اگر در این بین خطایی رخ داد، چه باید کرد؟ باید آخرین تغییر انجام شده را به حالت اول باز گرداند. انجام یک چنین کاری در React سادهاست. چون ما state را به صورت مستقیم ویرایش نمیکنیم، همیشه میتوان ارجاعی را به state قبلی، ذخیره و سپس در صورت نیاز آنرا بازیابی کرد:
در اینجا در ابتدا توسط متغیر originalPosts، ارجاعی را به وضعیت قبلی آرایهی posts موجود در state (وضعیت ابتدایی UI)، نگهداری میکنیم. سپس کار حذف بسیار سریع آیتم درخواستی را از UI انجام میدهیم. اکنون کار حذف اصلی رکورد را از سرور، درون یک try/catch انجام خواهیم داد. اگر خطایی رخ دهد، پیامی را به کاربر نمایش داده و سپس مجددا state را به همان originalPosts پیشین، باز خواهیم گرداند.
مدیریت خطاهای رخ دادهی در حین فراخوانی سرور
تا اینجا مشاهده کردیم که یک روش مدیریت خطاها در کدهای Axios، قرار دادن آنها در یک قطعه کد try/catch است. در اینجا نیز باید بتوان بین خطاهای پیش بینی شده و نشده، تفاوت قائل شد.
- خطاهای پیش بینی شده: برای مثال اگر درخواست حذف رکوردی را دادیم که در بانک اطلاعاتی موجود نیست، انتظار داریم سرور، خطای 404 یا return NotFound را بازگشت دهد و یا 400 که معادل bad request است و در حالت ارسال دادههایی غیرمعتبر، رخ میدهد. در این موارد بهتر است خطاهایی خاص را به کاربران نمایش داد؛ برای مثال رکورد درخواستی وجود ندارد یا پیشتر حذف شدهاست.
- خطاهای پیش بینی نشده: این نوع خطاها نباید و یا قرار نیست در شرایط عادی رخ دهند. برای مثال اگر شبکه در دسترس نیست، امکان ارتباط با سرور نیز میسر نخواهد بود و یا حتی ممکن است خطایی در کدهای سمت سرور، سبب بروز خطایی شده باشد. این نوع خطاها ابتدا باید لاگ شوند تا با بررسیهای آتی آنها، بتوان مشکلات پیش بینی نشده را بهتر برطرف کرد. همچنین در یک چنین مواردی، باید یک پیام خطای خیلی عمومی را به کاربر نمایش داد؛ برای مثال «یک خطای پیش بینی نشده رخ دادهاست.».
برای مدیریت این دو حالت باید به جزئیات شیء ex، در بدنهی catch، دقت کرد که دارای دو خاصیت request و response است. اگر ex.response تنظیم شده بود، یعنی دریافت خروجی از سرور موفقیت آمیز بودهاست. اگر سرور در دسترس نباشد و یا برنامهی سمت سرور کرش کرده باشد، ex.response نال خواهد بود. اگر ex.request نال نبود، یعنی ارسال درخواست به سمت سرور با موفقیت انجام شدهاست. برای مثال جهت بررسی خطای مورد انتظار 404، میتوان در قسمت catch(ex) به صورت زیر عمل کرد:
در اینجا ابتدا بررسی میشود که آیا شیء response نال است یا خیر؟ سپس خاصیت status آنرا برای بررسی خطاهای پیش بینی شده، بررسی میکنیم. خطایی که در اینجا نمایش داده میشود، اختصاصیتر است. در غیراینصورت، ابتدا باید این خطا لاگ شود و سپس یک اخطار عمومی نمایش داده میشود. پس از بررسی هر دو حالت، باید UI را مجددا به حالت اول آن بازگشت داد.
عموما خطاهای پیشبینی شده را لاگ نمیکنیم؛ چون ممکن است کاربر، یک صفحه را در چندین برگه باز کرده باشد و در یکی، رکوردی را حذف کند. در این حال، این رکورد هنوز در برگههای دیگر موجود است و اگر مجددا درخواست حذف آنرا صادر کند، مشکل خاصی از دیدگاه برنامه رخ ندادهاست و نیازی به پیگیریهای آتی را ندارد. یعنی صرفا یک client error است.
مدیریت سراسری خطاهای رخ دادهی در حین فراخوانی سرور
برای مدیریت خطاها، نیاز است یک چنین try/catchهایی را در تمام قسمتهای برنامه که با سرور کار میکنند، قرار دهیم. برای کاهش این کدهای تکراری، از interceptors کتابخانهی Axios استفاده میشود. در این کتابخانه میتوان در جاهائیکه درخواستی به سمت سرور ارسال میشود و یا پاسخی از سمت سرور دریافت میشود، قطعه کدهایی سراسری را قرار داد و بر روی درخواست و یا پاسخ، تغییراتی را اعمال کرد و یا حتی اطلاعات مربوطه را لاگ کرد؛ به این نوع قطعه کدها، interceptor گفته میشود و برای تعریف آنها میتوان از axios.interceptors.request و یا axios.interceptors.response، خارج از کلاس جاری استفاده کرد. برای مثال بر روی شیء axios.interceptors.response، میتوان متد use را فراخوانی کرد که دو پارامتر را که هر کدام یک callback function هستند، میپذیرد. اولی در صورت موفقیت آمیز بودن response فراخوانی میشود و دومی در صورت شکست آن. اگر نیازی به هر کدام نبود، میتوان آنرا به null مقدار دهی کرد. اگر مدیریت قسمت شکست علمیات مدنظر است، نیاز خواهد بود در پایان این callback function، یک Rejected Promise را بازگشت داد تا ادامهی برنامه، به درستی مدیریت شود. در این حالت اگر خطایی رخ دهد، ابتدا این interceptor فراخوانی میشود و سپس کنترل به بدنهی catch منتقل خواهد شد:
اکنون میخواهیم قطعه کد نمایش خطاهای عمومی پیش بینی نشده را از تمام بدنههای catch حذف کرده و به یک interceptor منتقل کنیم:
خطاهای پیش بینی شده عموما در بازهی 400 تا 500 قرار دارند. به همین جهت اگر یک چنین خطاهایی را دریافت کردیم، اخطاری را نمایش نداده و صرفا کنترل را به catch block منتقل میکنیم. اما اگر خطا، پیش بینی نشده بود، کار لاگ کردن خطا و همچنین نمایش اخطار را در اینجا انجام خواهیم داد.
یک نکته: استفاده از try/catchها فقط برای بازگشت UI به حالت قبلی و یا نمایش خطایی خاص به کاربر توصیه میشوند. اگر از روش «به روز رسانیهای خوشبینانهی UI» استفاده نمیکنید و همچنین خطاهای ویژهای بجز خطای عمومی لاگ شدهی در interceptor فوق مدنظر شما نیست، نیازی هم به try/catch نخواهد بود و پس از بروز خطا، قسمتهای بعدی کد اجرا نمیشوند؛ اما خطای عمومی فوق نمایش داده خواهد شد.
ایجاد یک HTTP Service با قابلیت استفادهی مجدد
تا اینجا تعریف interceptor را پیش از کلاس کامپوننت جاری قرار دادهایم که هم سبب شلوغی این ماژول شدهاست و هم در صورت نیاز به آن در سایر برنامهها، باید همین قطعه کد را مجددا در آنها کپی کرد. به همین جهت پوشهی جدید src\services را ایجاد کرده و سپس فایل src\services\httpService.js را در آن با محتوای زیر ایجاد میکنیم:
در اینجا علاوه بر انتقال interceptor تعریف شده، کار export متدهای axios نیز به صورت یک شیء جدید صورت گرفتهاست.
سپس به app.js مراجعه کرده و این ماژول را با یک نام دلخواه import میکنیم:
در ادامه هرجائیکه ارجاعی به axios وجود دارد، آنرا با http فوق جایگزین میکنیم. در این حالت میتوان "import axios from "axios را نیز از ابتدای app.js حذف کرد. مزیت اینکار، مخفی کردن Axios، در پشت صحنهی ماژول جدیدی است که ایجاد کردیم. به این ترتیب اگر در آینده خواستیم، Axios را با کتابخانهی دیگری جایگزین کنیم، در کل برنامه تنها نیاز است این httpService.js جدید را تغییر دهیم.
ایجاد یک ماژول Config
بهبود دیگری را که میتوانیم اعمال کنیم، انتقال const apiEndpoint تعریف شده، به یک ماژول مجزا است؛ تا اگر نیاز به استفادهی از آن در قسمتهای دیگری نیز وجود داشت، به سادگی بتوان آنرا مدیریت کرد. به همین جهت فایل جدید src\config.json را با محتوای زیر ایجاد میکنیم:
سپس به فایل app.js بازگشته و ابتدا const apiEndpoint را حذف و سپس import زیر را به ابتدای فایل، اضافه میکنیم:
اکنون هر جائی در کدهای خود که apiEndpoint را داریم، تبدیل به config.apiEndpoint میکنیم.
نمایش بهتر خطاها به کاربر توسط کتابخانهی react-toastify
بجای alert توکار مرورگرها، میتوان یک صفحهی دیالوگ زیباتر را برای نمایش خطاها درنظر گرفت. به همین جهت ابتدا کتابخانهی react-toastify را نصب میکنیم:
سپس به فایل app.js مراجعه کرده و importهای لازم آنرا اضافه میکنیم:
همچنین نیاز است ToastContainer را به ابتدای متد render نیز اضافه کرد:
اکنون به src\services\httpService.js مراجعه کرده و alert آنرا به صورت زیر تغییر میدهیم:
ابتدا، شیء toast آن import میشود و سپس توسط این شیء میتوان از متد error آن، جهت نمایش خطاهایی شکیلتر استفاده کرد؛ با این خروجی:
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: sample-22-backend-part-03.zip و sample-22-frontend-part-03.zip
به روز رسانیهای خوشبینانهی UI
پیاده سازی اعمال CRUD توسط Axios در قسمت قبل، به همراه یک مشکل مهم است: اعمال کار با شبکه و سرور، زمانبر هستند و مدتی طول میکشد تا پاسخ عملیات از سمت سرور دریافت شود. در این بین اگر خطایی رخ دهد، مابقی کدهای نوشته شدهی در متدهایی مانند Update و Delete، اجرا نمیشوند. به این حالت «به روز رسانی بدبینانهی UI» گفته میشود. در حالت خوشبینانه، فرض بر این است که در اکثر موارد، فراخوانی سرور با موفقیت به پایان میرسد. در یک چنین حالتی، ابتدا UI به روز رسانی میشود و سپس فراخوانیهای سمت سرور صورت میگیرند. اگر این فراخوانی با شکست مواجه شد، مجددا UI را به حالت قبلی آن باز میگردانیم:
handleDelete = async post => { const posts = this.state.posts.filter(item => item.id !== post.id); this.setState({ posts }); await axios.delete(`${apiEndpoint}/${post.id}`); };
اما اگر در این بین خطایی رخ داد، چه باید کرد؟ باید آخرین تغییر انجام شده را به حالت اول باز گرداند. انجام یک چنین کاری در React سادهاست. چون ما state را به صورت مستقیم ویرایش نمیکنیم، همیشه میتوان ارجاعی را به state قبلی، ذخیره و سپس در صورت نیاز آنرا بازیابی کرد:
handleDelete = async post => { const originalPosts = this.state.posts; const posts = this.state.posts.filter(item => item.id !== post.id); this.setState({ posts }); // Optimistic Update try { await axios.delete(`${apiEndpoint}/${post.id}`); } catch (ex) { alert("An error occurred when deleting a post!"); this.setState({ posts: originalPosts }); // Undo changes } };
مدیریت خطاهای رخ دادهی در حین فراخوانی سرور
تا اینجا مشاهده کردیم که یک روش مدیریت خطاها در کدهای Axios، قرار دادن آنها در یک قطعه کد try/catch است. در اینجا نیز باید بتوان بین خطاهای پیش بینی شده و نشده، تفاوت قائل شد.
- خطاهای پیش بینی شده: برای مثال اگر درخواست حذف رکوردی را دادیم که در بانک اطلاعاتی موجود نیست، انتظار داریم سرور، خطای 404 یا return NotFound را بازگشت دهد و یا 400 که معادل bad request است و در حالت ارسال دادههایی غیرمعتبر، رخ میدهد. در این موارد بهتر است خطاهایی خاص را به کاربران نمایش داد؛ برای مثال رکورد درخواستی وجود ندارد یا پیشتر حذف شدهاست.
- خطاهای پیش بینی نشده: این نوع خطاها نباید و یا قرار نیست در شرایط عادی رخ دهند. برای مثال اگر شبکه در دسترس نیست، امکان ارتباط با سرور نیز میسر نخواهد بود و یا حتی ممکن است خطایی در کدهای سمت سرور، سبب بروز خطایی شده باشد. این نوع خطاها ابتدا باید لاگ شوند تا با بررسیهای آتی آنها، بتوان مشکلات پیش بینی نشده را بهتر برطرف کرد. همچنین در یک چنین مواردی، باید یک پیام خطای خیلی عمومی را به کاربر نمایش داد؛ برای مثال «یک خطای پیش بینی نشده رخ دادهاست.».
برای مدیریت این دو حالت باید به جزئیات شیء ex، در بدنهی catch، دقت کرد که دارای دو خاصیت request و response است. اگر ex.response تنظیم شده بود، یعنی دریافت خروجی از سرور موفقیت آمیز بودهاست. اگر سرور در دسترس نباشد و یا برنامهی سمت سرور کرش کرده باشد، ex.response نال خواهد بود. اگر ex.request نال نبود، یعنی ارسال درخواست به سمت سرور با موفقیت انجام شدهاست. برای مثال جهت بررسی خطای مورد انتظار 404، میتوان در قسمت catch(ex) به صورت زیر عمل کرد:
try { await axios.delete(`${apiEndpoint}/${post.id}`); } catch (ex) { if (ex.response && ex.response.status === 404) { alert("This post has already been deleted!"); } else { console.log("Error", ex); alert("An unexpected error occurred when deleting a post!"); } this.setState({ posts: originalPosts }); // Undo changes }
عموما خطاهای پیشبینی شده را لاگ نمیکنیم؛ چون ممکن است کاربر، یک صفحه را در چندین برگه باز کرده باشد و در یکی، رکوردی را حذف کند. در این حال، این رکورد هنوز در برگههای دیگر موجود است و اگر مجددا درخواست حذف آنرا صادر کند، مشکل خاصی از دیدگاه برنامه رخ ندادهاست و نیازی به پیگیریهای آتی را ندارد. یعنی صرفا یک client error است.
مدیریت سراسری خطاهای رخ دادهی در حین فراخوانی سرور
برای مدیریت خطاها، نیاز است یک چنین try/catchهایی را در تمام قسمتهای برنامه که با سرور کار میکنند، قرار دهیم. برای کاهش این کدهای تکراری، از interceptors کتابخانهی Axios استفاده میشود. در این کتابخانه میتوان در جاهائیکه درخواستی به سمت سرور ارسال میشود و یا پاسخی از سمت سرور دریافت میشود، قطعه کدهایی سراسری را قرار داد و بر روی درخواست و یا پاسخ، تغییراتی را اعمال کرد و یا حتی اطلاعات مربوطه را لاگ کرد؛ به این نوع قطعه کدها، interceptor گفته میشود و برای تعریف آنها میتوان از axios.interceptors.request و یا axios.interceptors.response، خارج از کلاس جاری استفاده کرد. برای مثال بر روی شیء axios.interceptors.response، میتوان متد use را فراخوانی کرد که دو پارامتر را که هر کدام یک callback function هستند، میپذیرد. اولی در صورت موفقیت آمیز بودن response فراخوانی میشود و دومی در صورت شکست آن. اگر نیازی به هر کدام نبود، میتوان آنرا به null مقدار دهی کرد. اگر مدیریت قسمت شکست علمیات مدنظر است، نیاز خواهد بود در پایان این callback function، یک Rejected Promise را بازگشت داد تا ادامهی برنامه، به درستی مدیریت شود. در این حالت اگر خطایی رخ دهد، ابتدا این interceptor فراخوانی میشود و سپس کنترل به بدنهی catch منتقل خواهد شد:
import "./App.css"; import axios from "axios"; import React, { Component } from "react"; axios.interceptors.response.use(null, error => { console.log("interceptor called."); return Promise.reject(error); }); const apiEndpoint = "https://localhost:5001/api/posts"; class App extends Component {
axios.interceptors.response.use(null, error => { const expectedError = error.response && error.response.status >= 400 && error.response.status < 500; if (!expectedError) { console.log("Error", error); alert("An unexpected error occurred when deleting a post!"); } return Promise.reject(error); });
یک نکته: استفاده از try/catchها فقط برای بازگشت UI به حالت قبلی و یا نمایش خطایی خاص به کاربر توصیه میشوند. اگر از روش «به روز رسانیهای خوشبینانهی UI» استفاده نمیکنید و همچنین خطاهای ویژهای بجز خطای عمومی لاگ شدهی در interceptor فوق مدنظر شما نیست، نیازی هم به try/catch نخواهد بود و پس از بروز خطا، قسمتهای بعدی کد اجرا نمیشوند؛ اما خطای عمومی فوق نمایش داده خواهد شد.
ایجاد یک HTTP Service با قابلیت استفادهی مجدد
تا اینجا تعریف interceptor را پیش از کلاس کامپوننت جاری قرار دادهایم که هم سبب شلوغی این ماژول شدهاست و هم در صورت نیاز به آن در سایر برنامهها، باید همین قطعه کد را مجددا در آنها کپی کرد. به همین جهت پوشهی جدید src\services را ایجاد کرده و سپس فایل src\services\httpService.js را در آن با محتوای زیر ایجاد میکنیم:
import axios from "axios"; axios.interceptors.response.use(null, error => { const expectedError = error.response && error.response.status >= 400 && error.response.status < 500; if (!expectedError) { console.log("Error", error); alert("An unexpected error occurred when deleting a post!"); } return Promise.reject(error); }); export default { get: axios.get, post: axios.post, put: axios.put, delete: axios.delete };
سپس به app.js مراجعه کرده و این ماژول را با یک نام دلخواه import میکنیم:
import http from "./services/httpService";
ایجاد یک ماژول Config
بهبود دیگری را که میتوانیم اعمال کنیم، انتقال const apiEndpoint تعریف شده، به یک ماژول مجزا است؛ تا اگر نیاز به استفادهی از آن در قسمتهای دیگری نیز وجود داشت، به سادگی بتوان آنرا مدیریت کرد. به همین جهت فایل جدید src\config.json را با محتوای زیر ایجاد میکنیم:
{ "apiEndpoint" : "https://localhost:5001/api/posts" }
import config from "./config.json";
نمایش بهتر خطاها به کاربر توسط کتابخانهی react-toastify
بجای alert توکار مرورگرها، میتوان یک صفحهی دیالوگ زیباتر را برای نمایش خطاها درنظر گرفت. به همین جهت ابتدا کتابخانهی react-toastify را نصب میکنیم:
> npm i react-toastify --save
import { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css";
render() { return ( <React.Fragment> <ToastContainer />
import { toast } from "react-toastify"; // ... axios.interceptors.response.use(null, error => { // ... if (!expectedError) { // ... toast.error("An unexpected error occurrred."); }
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: sample-22-backend-part-03.zip و sample-22-frontend-part-03.zip
اشتراکها