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

أسامة دمراني

الأعضاء
  • المساهمات

    244
  • تاريخ الانضمام

  • تاريخ آخر زيارة

  • عدد الأيام التي تصدر بها

    24

كل منشورات العضو أسامة دمراني

  1. تعرفنا في المقال السابق على إطار العمل Angular وإمكانياته المختلفة، وكذلك على قاعدة البيانات Firestore المقدمة من جوجل، وكذلك على منصة Firebase التي تدعمها جوجل أيضًا والتي سننشر عليها تطبيقنا وهيأنا مشروعنا وأنشأنا ما يلزمه لبدء العمل عليه، أما في هذا الفصل فسنكمل العمل وسنتعلم كيفية بناء مدونة كتطبيق على استخدام إطار عمل Angular في إنشاء تطبيقات وحيدة الصفحة وتخصيصها وإضافة الوظائف اللازمة لها، وكذلك الصلاحيات التي تكون للمستخدمين للتعامل مع تلك الوظائف من خلال أزرار أو غيرها، ثم نشر تطبيق المدونة على منصة Firebase. هذا المقال جزء من سلسلة عن بناء مدونة باستخدام إطار العمل Angular وقاعدة بيانات Firestore: مقدمة في بناء تطبيقات الويب باستخدام إطار العمل Angular وقاعدة بيانات Firestore بناء مدونة باستخدام إطار العمل Angular وقاعدة بيانات Firestore - إضافة التدوينات وعرضها بناء مدونة باستخدام إطار العمل Angular وقاعدة بيانات Firestore - تعديل التدوينات بناء مدونة باستخدام إطار العمل Angular وقاعدة بيانات Firestore - إضافة الاستثيثاق نشر مدونة مبنية عبر Angular على Firebase إنشاء الصفحة الرئيسية سنبني الهيكل الأساسي للصفحة الرئيسية والتي فيها شريط للتنقل يحوي الروابط الأساسية في التطبيق. نشغِّل الأمر التالي في الطرفية لتوليد مكوِّن شريط التنقل navigation bar: ng g c components/nav-bar ثم نفتح الملف nav-bar.component.html ونستبدل الشيفرة التالية بالموجودة هناك: <mat-toolbar class="nav-bar mat-elevation-z2"></mat-toolbar> كذلك، نضيف تنسيق شريط التنقل في الملف src/app/components/nav-bar/nav-bar.component.scss كما يلي: .nav-bar { background-color: #1565C0; color: #FFFFFF; position: fixed; top: 0; z-index: 99; } button:focus { outline: none; border: 0; } لإنشاء مكون HomeComponent ليمثل الصفحة الرئيسية، نشغِّل الأمر التالي: ng g c components/home لن نضيف أي شيفرة أخرى هنا إلى HomeComponent، لكن سنعود إليها في الجزء الثالث والأخير من السلسلة. إضافة وحدة التوجيه Router Module سنضيف وحدة التوجيه RouterModule في الملف app.module.ts كما يلي: import { RouterModule } from '@angular/router'; @NgModule({ ... imports: [ ... RouterModule.forRoot([ { path: '', component: HomeComponent, pathMatch: 'full' }, { path: '**', component: HomeComponent } ]), ], }) المسار الفارغ هنا يمثل المسار الافتراضي للتطبيق، فإذا كان المسار في الرابط فارغًا سيعرض التطبيق صفحة HomeComponent، أما المسار فهو محرف بدل wildcard، بمعنى أن الموجِّه سيختار هذا الاتجاه إذا كان الرابط المطلوب لا يطابق أي اتجاه route معرَّف في الإعدادات من قبل. ومن المهم الانتباه إلى الترتيب الذي نعرِّف به الاتجاهات للتطبيق، فالموجِّه يستخدم سياسة أول تطابق ناجح عند مطابقة الاتجاهات، ولهذا يجب أن توضع الاتجاهات الأكثر دقة وتخصصًا فوق العامة منها والأقل تخصصًا، أما الاتجاه البدل wildcade route فيجب وضعه فوق النوع الثاني، أي الاتجاهات العامة، لأنه يطابق كل رابط، ويجب ألا يتم اختياره إلا إذا لم تطابَق اتجاهات أخرى قبله. يجب الآن تحديث AppComponent لإضافة مكون الموجه، لذا افتح الملف app.component.html واستبدل بمحتوياته الشيفرة التالية: <app-nav-bar></app-nav-bar> <div class="container"> <router-outlet></router-outlet> </div> الوسم <router-outlet> في الشيفرة أعلاه هو موضع مؤقت placeholder يملؤه Angular ديناميكيًا بالمكون وفقًا للحالة الراهنة للموجِّه. أضف التنسيقات التالية إلى الملف src/styles.scss: body { background-color: #fafafa; } .container { padding-top: 60px; } إضافة وحدة الاستمارات سنضيف وحدة الاستمارات forms module في الملف app.module.ts كما هو موضح أدناه، كي نستطيع استخدام استمارات قالبية template-driven في تطبيقنا: import { FormsModule } from '@angular/forms'; @NgModule({ ... imports: [ ... FormsModule, ], }) إنشاء نموذج البيانات وخدمة المدونة لكل تدوينة هيكل أساسي فيه بيانات التدوينة، ويعتمد التطبيق على هذا الهيكل بما أنه التطبيق هو مدونة بالأصل، لذا سننشئ نموذج بيانات التدوينة ثم سننشئ خدمة المدونة لمعالجة العمليات المطبقة على التدوينة في قاعدة البيانات من إنشاء وتعديل وحذف. أنشئ مجلدًا جديدًا باسم models داخل المجلد src/app، ثم أنشئ ملفًا باسم post.ts داخل هذا المجلد الجديد، والصق فيه الشيفرة التالية: export class Post { postId: string; title: string; content: string; author: string; createdDate: any; constructor() { this.content = ''; } } لقد هيأنا بهذا خاصية المحتوى لتكون سلسلة نصية فارغة في منشئ الصنف، وذلك لضمان أن المحتوى لن يظهر في صورة غير معرّفة undefined عند ربطه بحقل من حقول الاستمارة. أنشئ خدمة جديدة لمعالجة عمليات قاعدة البيانات، باستخدام الأمر التالي: ng g s services/blog افتح الملف blog.service.ts وأضف تعريفات الاستيراد التالية في أعلاه: import { Post } from '../models/post'; import { AngularFirestore } from '@angular/fire/firestore'; import { map } from 'rxjs/operators'; import { Observable } from 'rxjs'; ثم احقن AngularFirestore في المنشئ كما يلي: constructor(private db: AngularFirestore) { } سنضيف الآن التابع من أجل إنشاء تدوينة جديدة، من خلال وضع تعريف التابع في ملف blog.service.ts: createPost(post: Post) { const postData = JSON.parse(JSON.stringify(post)); return this.db.collection('blogs').add(postData); } سيقبل هذا التابع معامِلًا من النوع Post، وسنحلل المعامِل إلى كائن JSON، ونضيفه إلى تجميعة blogs في قاعدة بياناتنا وفقًا لحالة التجميعة، فيضاف إليها إذا كانت التجميعة موجودة مسبقًا، أما إذا لم تكن موجودة فسينشئها تابع الإضافة ثم يضيف الكائن الجديد إليها. إضافة محرر المدونة سيعتمد محرر المدونة على حزمة CKEditor لإضافة التدوينة الجديدة وتعديلها، وهذه الحزمة عبارة عن محرر نصوص غنية rich text مكتوب بجافاسكربت وذي معمارية مبنية على الوحدات، وتوفر خصائصه وواجهته الواضحة تجربة استخدام ممتازة لإنشاء محتوى دلالي semantic بأسلوب "ما تراه هو ما تحصل عليه WYSIWYG"، وهو متوافق مع جميع متصفحات الويب الحديثة. نفذ الأمر التالي لتثبيت مكون محرر CKEditor لإطار Angular: npm install --save @ckeditor/ckeditor5-angular ثم شغِّل الأمر التالي لتثبيت إحدى البنيات الرسمية، وهي المحرر التقليدي هنا classic editor: npm install --save @ckeditor/ckeditor5-build-classic واستورد CKEditorModule إلى الملف app.module.ts كما يلي: import { CKEditorModule } from '@ckeditor/ckeditor5-angular'; @NgModule( { imports: [ ... CKEditorModule, ], }) سننشئ مكونًا جديدًا لإضافة التدوينة وتحريرها، نفِّذ الأمر التالي: ng g c components/blog-editor إضافة وجهة لصفحة إنشاء التدوينة أضف وجهة route للمكون BlogEditor في الملف app.module.ts كما يلي: RouterModule.forRoot([ ... { path: 'addpost', component: BlogEditorComponent }, ... ]) إضافة المحرر إلى التدوينة افتح الملف blog-editor.component.ts وأضف تعريفات الاستيراد التالية: import * as ClassicEditor from '@ckeditor/ckeditor5-build-classic'; import { Post } from 'src/app/models/post'; import { DatePipe } from '@angular/common'; import { BlogService } from 'src/app/services/blog.service'; import { Router, ActivatedRoute } from '@angular/router'; كذلك، سنهييء بعض الخصائص لهذا المكون، فأضف الشيفرة التالية إلى صنف BlogEditorComponent: public Editor = ClassicEditor; ckeConfig: any; postData = new Post(); formTitle = 'Add'; postId = ''; سننشئ تابعًا لتعريف الإعدادات الخاصة بمحرر التدوينات، فأضف تعريف التابع setEditorConfig في الصنف BlogEditorComponent كما يلي: setEditorConfig() { this.ckeConfig = { removePlugins: ['ImageUpload', 'MediaEmbed'], heading: { options: [ { model: 'paragraph', title: 'Paragraph', class: 'ckheading_paragraph' }, { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ckheading_heading1' }, { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ckheading_heading2' }, { model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ckheading_heading3' }, { model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ckheading_heading4' }, { model: 'heading5', view: 'h5', title: 'Heading 5', class: 'ckheading_heading5' }, { model: 'heading6', view: 'h6', title: 'Heading 6', class: 'ckheading_heading6' }, { model: 'Formatted', view: 'pre', title: 'Formatted' }, ] } }; } يًستخدم التابع لضبط إعدادات المحرر التقليدي، وسنزيل إضافتي ImageUpload و MediaEmbed من المحرر بما أننا لن نستخدم وظائفهما في التطبيق، وسنضبط خيارات التنسيق في المحرر لتشمل الترويسات والفقرات والنصوص المنسَّقة formatted text. نستدعي هذا التابع داخل تابع ngOnInit كما يلي: ngOnInit() { this.setEditorConfig(); } تحديث قالب التدوينة افتح الملف blog-editor.component.html وضع الشيفرة التالية فيه: <h1>{{formTitle}} Post</h1> <hr /> <form #myForm="ngForm" (ngSubmit)="myForm.form.valid && saveBlogPost()" accept-charset="UTF-8" novalidate> <input type="text" class="blogHeader" placeholder="Add title..." class="form-control" name="postTitle" [(ngModel)]="postData.title" #postTitle="ngModel" required /> <span class="text-danger" *ngIf="myForm.submitted && postTitle.errors?.required"> Title is required </span> <br /> <div class="form-group"> <ckeditor name="myckeditor" [config]="ckeConfig" [(ngModel)]="postData.content" #myckeditor="ngModel" debounce="300" [editor]="Editor"></ckeditor> </div> <div class="form-group"> <button type="submit" mat-raised-button color="primary">Save</button> <button type=" button" mat-raised-button color="warn" (click)="cancel()">CANCEL</button> </div> </form> لقد عرّفنا هنا استمارة قالبية نستدعي منها التابع saveBlogPost على عملية الإرسال الناجحة لبيانات الاستمارة، وتحتوي الاستمارة على حقلين، أحدهما يقبل عنوان التدوينة وهو حقل إجباري، وأما الآخر فيقبل محتواها، ويحتوي على مكون محرر CKEditor، حيث تُستخدم خاصية المحرر لتعيين نوعه. ونستخدم هنا النسخة التقليدية من محرر CKEditor، وسنربط خاصية الإعداد config لمكون ckeditor مع المتغير ckeConfig الذي أعددناه في القسم السابق. كذلك سنربط محتوى المكون ckeditor مع خاصية المحتوى للكائن postData الذي يحمل النوع Post. كذلك، سيعيد المكون ckeditor المحتوى بتنسيق HTML وليس بتنسيق نص عادي، وسنحفظ كل تدوينة في صورة HTML في قاعدة البيانات، لنضمن عدم فقد تنسيق المحتوى أثناء الحفظ، وكذلك أثناء جلبه من قاعدة البيانات. أخيرًا، أضف التنسيق لمحرر التدوينة في الملف styles.scss كما يلي: .ck-editor__editable { max-height: 350px; min-height: 350px; } pre { display: block; padding: 9.5px; margin: 0 0 10px; font-size: 13px; line-height: 1.42857143; color: #333; word-break: break-all; word-wrap: break-word; background-color: #f5f5f5; border: 1px solid #ccc; border-radius: 4px; } blockquote { display: block; padding: 10px 20px; margin: 0 0 20px; font-size: 17.5px; border-left: 5px solid #eee; } .spacer { flex: 1 1 auto; } img{ max-width: 100%; } إضافة تدوينة جديدة سننفذ الآن خاصية إضافة التدوينة الجديدة في تطبيقنا، فافتح الملف blog-editor.component.ts وأضف تعريفات الخدمات التالية إلى المنشئ: constructor(private route: ActivatedRoute, private datePipe: DatePipe, private blogService: BlogService, private router: Router) { } ثم أضف مزود DataPipe في قسم المزخرِف ‎@Component كما يلي: @Component({ ... providers: [DatePipe] } وكذلك تعريف التابع saveBlogPost كما يلي: saveBlogPost() { this.postData.createdDate = this.datePipe.transform(Date.now(), 'MMdd-yyyy HH:mm'); this.blogService.createPost(this.postData).then( () => { this.router.navigate(['/']); } ); } أضف الآن التاريخ الحالي في كائن postData على أنه تاريخ إنشاء التدوينة، ثم استدع التابع createPost من BlogService لإضافة تدوينة جديدة إلى قاعدة بياناتنا، وسيُستدعى هذا التابع عند النقر على زر Save. سنضيف أيضًا تعريف التابع التالي لتابع cancel الذي يُستدعى عند النقر على زر Cancel: cancel() { this.router.navigate(['/']); } إضافة أزرار إلى شريط التنقل سنضيف في شريط التنقل زرًا للانتقال إلى محرر التدوينة وكذلك إلى الصفحة الرئيسية، من خلال وضع الشيفرة التالية في العنصر <mat-toolbar> داخل الملف nav-bar.component.html: <button mat-button [routerLink]='[""]'> My blog </button> <button mat-button [routerLinkActive]='["link-active"]' [routerLink]='["/addpost"]'> Add Post </button> <span class="spacer"></span> بما أن الخادم يعمل فستحدِّث الصفحة نفسها كلما حدث تغيير جديد، فافتح المتصفح وانقر على زر AddPost في شريط التنقل لينتقل التطبيق إلى صفحة إضافة التدوينة، ثم أضف تدوينة جديدة وانقر زر الحفظ لحفظ التدوينة في قاعدة البيانات، انظر الصورة التالية: ستضاف التدوينة إلى قاعدة بيانات Firestore الخاصة بنا بمجرد النقر على زر Save الموضح في الصورة أعلاه، ثم تنتقل الصفحة إلى الصفحة الرئيسية للتطبيق. فإذا أردنا هنا أن نتحقق من إضافة البيانات بنجاح إلى قاعدة البيانات، فإننا نفتح طرفية Firebase وننتقل إلى صفحة اللوحة العامة للمشروع ثم اضغط على الرابط Database في القائمة اليسرى، لنرى حينئذ سجلًا يُظهر التدوينة التي نشرناها، وسيحتوي حقل content على محتوى التدوينة بتنسيق HTML. انظر الصورة التالية: إنشاء أنابيب مخصصة لمعالجة البيانات سنضيف نوعين من الأنابيب المخصصة custom pipes في تطبيقنا: Excerpt: يعرض هذا النوع ملخصًا للمقالة على بطاقة التدوينة. Slug: يعرض هذا النوع الاسم اللطيف slug الموجود في رابط التدوينة. شغِّل الأمر التالي لتوليد أنبوب الملخص excerpt pipe: ng g p customPipes/excerpt ثم افتح excerpt.pipe.ts واستبدل الشيفرة التالية بتابع التحويل transform الذي في الملف: transform(content: string) { const postSummary = content.replace(/(<([^>]+)>)/ig, ''); if (postSummary.length > 300) { return postSummary.substr(0, 300) + ' [...]'; } else { return postSummary; } } سيقبل هذا الأنبوب محتويات التدوينة كسلسلة نصية ويعيد أول 300 محرف لتشكل ملخص تلك التدوينة، وبما أن محتوى التدوينة يكون بتنسيق HTML، فسنزيل كل وسوم HTML قبل استخراج الملخص. شغٍّل الأمر التالي لتوليد أنبوب Slug: ng g p customPipes/slug ثم افتح ملف slug.pipe.ts واستبدل الشيفرة التالية بتابع التحويل الذي فيه: transform(title: string) { const urlSlug = title.trim().toLowerCase().replace(/ /g, '-'); return urlSlug; } سيقبل هذا الأنبوب عنوان التدوينة ويعيده ليكون هو الاسم اللطيف slug في الرابط، وسنستبدل محرف المسافة البيضاء التي بين الكلمات في العنوان بمحرف - لإنشاء الاسم اللطيف للرابط. عرض التدوينات سنحتاج بعد إضافة كل تدوينة على حدى إلى عرض هذه التدوينة في مكان ما، لذا سنعرض كل التدوينات في الصفحة الرئيسية على شكل بطاقات يمكن الضغط عليها لعرض التدوينة في صفحة منفصلة. سنجلب أولًا التدوينات من قاعدة الباينات ونعرضها في الصفحة الرئيسية في مكون خاص يدعى BlogCardComponent. جلب التدوينات من قاعدة البيانات يُستخدم التابع getAllPosts لجلب جميع التدوينات الخاصة بالمدونة من قاعدة البيانات، وسنضيف ذلك التابع في الملف blog.service.ts. فيما يلي التعريف الخاص بتابع getAllPosts: getAllPosts(): Observable<Post[]> { const blogs = this.db.collection<Post>('blogs', refgetAllPosts(): Observable<Post[]> { const blogs = this.db.collection<Post>('blogs', ref => ref.orderBy('createdDate', 'desc')) .snapshotChanges().pipe( map(actions => { return actions.map( c => ({ postId: c.payload.doc.id, ...c.payload.doc.data() })); })); return blogs; } سنجلب التدوينة وفق تاريخ إنشائها تنازليًا، لنضمن أن التدوينة الأخيرة تكون في الأعلى دومًا. إنشاء مكون قائمة التدوينات BlogCardComponent نفِّذ الأمر التالي لإنشاء مكون بطاقة التدوينة Blog Card Component: ng g c components/blog-card افتح الملف blog-card.component.ts وأضف تعريفات الاستيراد التالية: import { OnDestroy } from '@angular/core'; import { BlogService } from 'src/app/services/blog.service'; import { Post } from 'src/app/models/post'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; ثم احقن خدمة المدونة blog service في منشئ الصنف BlogCardComponent كما يلي: constructor(private blogService: BlogService) { } وأضف خاصية تحتفظ بالتدوينة الحالية: blogPost: Post[] = []; private unsubscribe$ = new Subject<void>(); سننشئ الآن تابعًا ليجلب التدوينة ويستدعيها داخل ngOnInit الملف blog-card.component.ts: ngOnInit() { this.getBlogPosts(); } getBlogPosts() { this.blogService.getAllPosts() .pipe(takeUntil(this.unsubscribe$)) .subscribe(result => { this.blogPost = result; }); } بعد ذلك نطبق الواجهة OnDestroy على الصنف BlogCardComponent، ثم نكمل تنفيذ الاشتراك unsubscribe$‎ داخل التابع ngOnDestroy، كما هو موضح في الشيفرة أدناه: export class BlogCardComponent implements OnInit, OnDestroy { ... ngOnDestroy() { this.unsubscribe$.next(); this.unsubscribe$.complete(); } } افتح الملف blog-card.component.html واستبدل الشيفرة التالية بالشيفرة الموجودة في الملف: <ng-template #emptyblog> <div class="spinner-container"> <mat-spinner></mat-spinner> </div> </ng-template> <ng-container *ngIf="blogPost && blogPost.length>0; else emptyblog"> <div *ngFor="let post of blogPost"> <mat-card class="blog-card mat-elevation-z2"> <mat-card-content> <a class="blog-title" [routerLink]="['/blog/', post.postId, post.title | slug]"> <h2>{{ post.title}} </h2> </a> </mat-card-content> <mat-card-content> <div [innerHTML]="post.content | excerpt"></div> </mat-card-content> <mat-divider></mat-divider> <mat-card-actions align="end"> <ng-container> <button mat-raised-button color="accent" [routerLink]="['/editpost',post.postId]">Edit</button> <button mat-raised-button color="warn" (click)="delete(post.postId)">Delete</button> </ng-container> <span class="spacer"></span> <button mat-raised-button color="primary" [routerLink]="['/blog/', post.postId, post.title | slug]">Read More</button> </mat-card-actions> </mat-card> </div> <mat-divider></mat-divider> </ng-container> سنعرض عنوان التدوينة والملخص في المكوِّن mat-card، وهو مكون في Angular Material يُستخدم لعرض المحتوى في هيئة بطاقات، ونذكِّر هنا أننا استخدمنا أنبوب الاسم اللطيف slug لإنشاء رابط التدوينة، وأنبوب الملخص excerpt للحصول على ملخص لكل تدوينة. وستحتوي بطاقة كل تدوينة على أزرار لتحريرها وحذفها، ويستطيع أي مستخدم عند هذه النقطة أن يصل إلى هذه الأزرار، بيد أن هذا سلوك خاطئ وسنغيره لاحقًا بإضافة أذونات authorizations إلى التطبيق كي لا يستطيع أحد أن يعدّل أو يحذف من التدوينات إلا من لديه صلاحيات المدير admin فقط. جدير بالذكر هنا أننا قد نحصل على خطأ في هذا الملف، ذلك أننا أضفنا زر الحذف Delete وعرَّفنا تابعًا للحذف عند وقوع حدث النقر على الزر، غير أننا لم نضف أي تابع حذف بعد، وعليه فسنحصل على خطأ وقت التصريف compile-time error. ولحل هذه المشكلة، نضيف الشيفرة التالية في الملف blog-card.component.ts: delete(postId: string) { // هنا يوضع تعريف التابع، وسنكتبه لاحقًا } عرَّفنا في هذه الشيفرة تابعًا فارغًا، وسنضيف منطق الحذف إليه في الجزء الأخير من الكتاب. افتح الملف blog-card.component.scss واستبدل الشيفرة التالية بالموجودة في الملف: .blog-card { margin-bottom: 15px; } .blog-title { text-decoration: none; } a:hover { color: indianred; } .spinner-container{ display: flex; align-items: center; justify-content: center; height: 100%; } إضافة قائمة التدوينات إلى الصفحة الرئيسية بما أننا سنعرض بطاقات التدوينات على الصفحة الرئيسية، فسنحتاج إلى إضافة مكون بطاقة التدوينة إلى مكون الصفحة الرئيسية home، فافتح الملف home.component.html واستبدل شيفرة HTML التالية بالموجودة في الملف: <div class="row left-panel"> <div class="col-md-9"> <app-blog-card></app-blog-card> </div> </div> ثم افتح الملف home.component.scss وأضف تعريف التنسيق التالي داخله: .left-panel { margin-top: 15px; } هنا نكون قد أضفنا بطاقة التدوينة إلى الصفحة الرئيسية، فافتح المتصفح لترى التدوينة معروضة على الصفحة الرئيسية، وهي نفس التدوينة التي أضفناها في نقطة التحقق الأولى من قبل، وستحتوي البطاقة أيضًا على أزرار التحرير Edit والحذف Delete وقراءة المزيد Read More، غير أنها جميعًا لا تعمل إلى الآن، وإنما سنضيف منطق هذه الأزرار في الجزء الأخير من الكتاب، انظر الصورة التالية: عرض تدوينة واحدة بعد أن عرضنا قائمة بكل التدوينات في الصفحة الرئيسية، نحتاج إلى عرض كل تدوينة على حدى في صفحة منفصلة وذلك لمن يريد قراءة التدوينة، وهنا سننشئ المكون BlogComponent لعرض كل تدوينة معتمدين على مكتبات خطوط وأيقونات لتنسيق المكون. إضافة مكتبة الخط Awesome سنضيف مكتبات الخط Awesome وأيقونات السمة Material إلى التطبيق، ونستخدم تجميعات الأيقونات التي توفرها تلك المكتبة في تطبيقنا، فأضف الأسطر التالية في الملف index.html: <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/fontawesome.min.css" /> قراءة التدوينة شغِّل الأمر التالي لإنشاء مكون تدوينة، من أجل إضافة خاصية قراءة التدوينات للتطبيق: ng g c components/blog أضف رابط الموجِّه router لهذا المكون في ملف app.module.ts كما يلي: { path: 'blog/:id/:slug', component: BlogComponent }, لقد عرَّفنا معامليْن في هذا الاتجاه، الأول هو id، وهو المعرِّف الفريد لكل تدوينة، أما الآخر فهو الاسم اللطيف slug الذي يُنشأ من عنوان التدوينة نفسها. أضف تعريف التابع التالي في ملف blog.services.ts، سيجلب هذا التابع تفاصيل التدوينة بناءً على المعرِّف الموجود في تجميعات blogs: getPostbyId(id: string): Observable<Post> { const blogDetails = this.db.doc<Post>('blogs/' + id).valueChanges(); return blogDetails; } افتح الملف blog.component.ts واستورد التعريفات كما هو موضح أدناه: import { OnDestroy } from '@angular/core'; import { Post } from 'src/app/models/post'; import { ActivatedRoute } from '@angular/router'; import { BlogService } from 'src/app/services/blog.service'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; سنحدِّث الصنف BlogComponent كما يلي: export class BlogComponent implements OnInit, OnDestroy { postData: Post = new Post(); postId; private unsubscribe$ = new Subject<void>(); constructor(private route: ActivatedRoute, private blogService: BlogService) { if (this.route.snapshot.params['id']) { this.postId = this.route.snapshot.paramMap.get('id'); } } ngOnInit() { this.blogService.getPostbyId(this.postId) .pipe(takeUntil(this.unsubscribe$)) .subscribe( (result: Post) => { this.postData = result; } ); } ngOnDestroy() { this.unsubscribe$.next(); this.unsubscribe$.complete(); } } داخل تابع الخطاف ngOnInit، سنستدعي التابع getPostbyID الخاص بـ BlogService، ونمرر postID كمعامِل لجلب تفاصيل تدوينة ما، وسنحصل على المعرِّف من الرابط في منشئ الصنف، أما داخل التابع ngOnDestroy فسنكمل اشتراك unsubscribe$‎. افتح الملف blog.component.html واستبدل الشيفرة التالية بالموجودة فيه: <div class="docs-example-viewer-wrapper"> <h1 class="entry-title">{{postData.title}}</h1> <mat-card-subtitle class="blog-info"> <i class="fa fa-calendar" aria-hidden="true"></i> {{postData.createdDate | date:'longDate'}} </mat-card-subtitle> <mat-divider></mat-divider> <div class="docs-example-viewer-body"> <div [innerHTML]="postData.content"> </div> </div> </div> يُستخدم هذا المكون لعرض التدوينة كاملة، وسنعرض تاريخ إنشاء التدوينة وأيقونة التقويم calendar باستخدام الخط awesome، وسنربط محتوى التدوينة بالخاصية innerHTML للعنصر <div> بما أن محتوى التدوينة يخزَّن ويُجلب بتنسيق HTML، وذلك لضمان أن التدوينة ستُعرض كنص عادي على الشاشة. أخيرًا، سنضيف التنسيق إلى BlogComponent، فافتح الملف blog.component.scss وضع تعريفات التنسيق التالية فيه: .docs-example-viewer-body { padding: 20px; align-content: center; align-items: center; font-size: 14px; } .docs-example-viewer-wrapper { border: 1px solid rgba(0, 0, 0, .03); box-shadow: 0 1px 1px rgba(0, 0, 0, .24), 0 0 2px rgba(0, 0, 0, .12); border-radius: 4px; margin: 1em auto; background-color: #FFFFFF; } .entry-title { margin: 20px; } .blog-info { margin: 15px 20px 10px; align-content: center; align-items: center; .fa-user { margin-left: 10px; } } تجربة عرض التدوينة افتح المتصفح واضغط على زر Read More في بطاقة التدوينة لتنتقل إلى صفحة جديدة تعرض التدوينة كاملة، ويكون تاريخ إنشاؤها معروضًا تحت عنوانها مباشرة. كذلك، إذا نظرنا إلى رابط الصفحة سنجد أنه يحتوي على معرِّف التدوينة وعنوانها في صورة اسم لطيف slug، انظر الصورة: خاتمة تعلمنا في هذا الجزء من مشروع إنشاء مدونة في Angular وقاعدة بيانات Firsstore كيفية إضافة محرر للمدونة ثم إضافة تدوينات جديدة وعرضها وعرض كل التدوينات في الواجهة الرئيسية ثم عرض كل تدوينة على حدة، أما في الجزء الثاني فسنتعلم كيفية التعديل على التدوينات وحذفها. ترجمة -وبتصرف- لفصول من كتاب Build a full stack web application using angular and firebase لصاحبه Ankit Sharma. اقرأ أيضًا المقال السابق: مقدمة في بناء تطبيقات الويب باستخدام إطار العمل Angular وقاعدة بيانات Firestore بناء تطبيق ويب كامل باستخدام Angular ومنصة Firebase تهيئة بيئة تطبيقات Angular ونشرها على الويب
  2. يوجد ما يسمى بفعاليات مشاركة المهارات، حيث يتحدث الناس في كلمات موجزة غير رسمية عما يفعلونه لينفعوا غيرهم به، فإذا كانت الفعالية حول مشاركة مهارات الزراعة مثلًا فربما يتحدث أحدهم عن زراعة الكرفس، أو إذا كنا في مجموعة برمجية فربما تخبر الناس عن Node.js، كما تسمى مثل تلك الاجتماعات بمجموعات المستخدِمين إذا كانت تتعلق بالحوسبة والتقنية، وهي طريقة فعالة لتوسيع الأفق ومعرفة جديد التطورات، أو التعرف على أشخاص جدد لهم الاهتمامات نفسها، وسيكون هدفنا في هذا المقال الأخير إعداد موقع لإدارة الكلمات المقدمة في اجتماع لمشاركة المهارات. لنتخيل مجموعةً صغيرةً من الناس تجتمع بانتظام في مكتب أحد أعضائها للحديث عن ركوب الدراجات ذات العجلة الواحدة مثلًا، وقد انتقل من كان ينظم تلك الاجتماعات إلى مدينة أخرى ولم يشغل أحد مكانه، لذا نريد هنا إنشاء نظام يسمح للمشاركين بطلب الحديث ومناقشة الكلمات بين بعضهم بعضًا دون منظِّم مركزي لهم، كما ستكون بعض الشيفرة التي سنكتبها في هذا المقال موجهةً لبيئة Node.js كما فعلنا في المقال السابق، فلن تعمل مباشرةً في صفحة HTML العادية، ويمكن تحميل الشيفرة الكاملة للمشروع من ملف zip. التصميم سيحتوي هذا المشروع على جزء يعمل على الخادم مكتوب لبيئة Node وجزء للعميل مكتوب من أجل المتصفح، ويخزن الخادم بيانات النظام ويعطيها إلى العميل، كما يخدِّم الملفات التي تستخدِم النظام الخاص بجانب العميل، حيث يحتفظ الخادم بقائمة من الكلمات المقترحة للاجتماع التالي ويعرض العميل تلك القائمة، ويكون لكل كلمة اسم مقدِّمها وعنوانها وملخصها ومصفوفة من التعليقات المرتبطة بها، كما يسمح العميل للمستخدِمين باقتراح كلمات جديدة -أي إضافتها إلى القائمة- وحذف الكلمات والتعليق أيضًا على الكلمات الموجودة، فكلما نفّذ المستخدِم شيئًا من هؤلاء فسينشئ العميل طلب HTTP ليخبر الخادم بذلك. يهيَّأ التطبيق ليعرض الكلمات المقترحة وتعليقاتها عرضًا حيًا، وكلما أرسل أحد كلمةً جديدةً في مكان ما أو أضاف تعليقًا فيجب على كل من تكون الصفحة مفتوحة عنده رؤية ذلك الحدث، وهنا محل التحدي إذ لا توجد طريقة يفتح بها الخادم اتصالًا مع عميل ولا توجد طريقة مناسبة لنعرف مَن من العملاء ينظرون الآن إلى الموقع، ويسمى حل تلك المشكلة بالاستطلاع المفتوح long polling وهو أحد بواعث تصميم بيئة Node من البداية. الاستطلاع المفتوح نحتاج إلى اتصال بين العميل والخادم كي يستطيع الخادم إخبار العميل مباشرةً بتغير شيء ما، لكن لا تقبل متصفحات الويب الاتصالات عادةً، كما أنّ موجِّهات الانترنت routers تحجب عادةً مثل تلك الاتصالات عن العملاء، لذا لن نستطيع جعل الخادم يبدأ ذلك الاتصال، لكن نستطيع تهيئة الأمر كي يفتح العميل الاتصال ويحتفظ به لفترة كي يستطيع الخادم استخدامه من أجل إرسال معلومات عند الحاجة، غير أنّ طلب HTTP يسمح بتدفق معلومات بسيطة مثل إرسال العميل لطلب ما ورد الخادم عليه باستجابة لذلك الطلب وحسب. أما إذا أردنا أكثر من ذلك فثَم تقنية اسمها WebSockets تدعمها أغلب المتصفحات الحديثة وتسهل فتح الاتصالات من أجل تبادل البيانات عشوائي، غير أنها صعبة قليلًا في الاستفادة منها لحالتنا، والبديل الذي سنستخدِمه في هذا المقال سيكون تقنيةً أبسط وهي الاستطلاع المفتوح، حيث يطلب العميل من الخادم معلومات جديدة باستمرار باستخدام طلبات HTTP العادية، ويماطل الخادم في الاستجابة لتلك الطلبات إذا لم يكن ثمة شيء جديد لإبلاغه، وطالما أنّ العميل يضمن وجود طلب استطلاع وجس مفتوح دائمًا، فسيستقبل معلومات من الخادم بسرعة بعد توفرها، فإذا كان تطبيق مشاركة المهارات مفتوحًا لدى فاطمة في متصفحها، فسيكون ذلك المتصفح قد أنشأ طلبًا من أجل التحديثات وسيكون منتظرًا استجابةً لذلك الطلب، فإذا أرسلت إيمان كلمةً عن قيادة الدراجة هبوطًا على تل شديد الانحدار، فسيلاحظ الخادم انتظار فاطمة تحديثات، ويرسل استجابةً تحتوي على الكلمة الجديدة إلى طلبها المنتظِر، وسيستلم متصفح فاطمة تلك البيانات ويحدِّث الشاشة ليعرض الكلمة. المعتاد لمثل تلك الطلبات والاتصالات أنها تنقطع بعد مهلة محددة تسمى timeout إذا لم يكن ثمة نشاط أو رد، ولكي نمنع حدوث ذلك هنا فإنّ تقنيات الاستطلاع المفتوح تعيّن وقتًا أقصى لكل طلب، حيث يستجيب الخادم بعده ولا بد حتى لو لم يكن ثمة شيء يبلِّغه، ثم ينشئ العميل بعد ذلك طلبًا جديدًا، وإعادة التشغيل الدورية تلك تجعل التقنية أكثر ثباتًا لتسمح للعملاء بالعودة للاتصال بعد فشل مؤقت في الشبكة أو مشاكل في الخادم، وإذا كان لدينا خادمًا يستخدِم الاستطلاع المفتوح فقد يكون لديه آلاف الطلبات التي تنتظره، مما يعني أنّ اتصالات TCP مفتوحة، وهنا تأتي ميزة Node، إذ تسهِّل إدارة عدة اتصالات دون إنشاء خيط تحكم منفصل لكل اتصال منها. واجهة HTTP ينبغي النظر أولًا قبل تصميم الخادم أو العميل إلى النقطة التي يتلاقى فيها كل منهما، وهي واجهة HTTP التي يتواصلان من خلالها، حيث سنستخدم JSON على أساس صيغة لطلبنا وعلى أساس متن للاستجابة أيضًا، كما سنستفيد من توابع HTTP وترويساته كما في خادم الملفات من المقال السابق المشار إليه سلفًا، وبما أنّ الواجهة تتمحور حول مسار ‎/talks، فستُستخدَم المسارات التي لا تبدأ بـ ‎/talks لخدمة الملفات الساكنة، وهي شيفرة HTML وجافاسكربت لنظام جانب العميل، فإذا أرسلنا طلب GET إلى /talks فسيعيد مستند JSON يشبه ما يلي: [{"title": "Unituning", "presenter": "Jamal", "summary": "Modifying your cycle for extra style", "comments": []}] تُنشأ الكلمة الجديدة بإنشاء طلب PUT إلى رابط مثل ‎/talks/Unituning، حيث يكون الجزء الذي بعد الشرطة الثانية هو عنوان الكلمة، ويجب احتواء متن طلب PUT على كائن JSON يستخدِم الخاصيتين presenter وsummary، وبما أنّ عناوين الكلمات تحتوي على مسافات ومحارف قد لا تظهر كما يجب لها في الرابط، فيجب ترميز سلاسل العناوين النصية بدالة encodeURIComponent عند بناء مثل تلك الروابط. console.log("/talks/" + encodeURIComponent("How to Idle")); // → /talks/How%20to%20Idle قد يبدو طلب إنشاء كلمة عن الوقوف بالدراجة كما يلي: PUT /talks/How%20to%20Idle HTTP/1.1 Content-Type: application/json Content-Length: 92 {"presenter": "Hasan", "summary": "Standing still on a unicycle"} تدعم مثل تلك الروابط طلبات GET لجلب تمثيل JSON لكلمة ما وطلبات DELETE لحذف الكلمة، كما تضاف التعليقات إلى الكلمة باستخدام طلب POST إلى رابط مثل ‎/talks/Unituning/comments مع متن JSON يحتوي على الخاصيتين author وmessage. POST /talks/Unituning/comments HTTP/1.1 Content-Type: application/json Content-Length: 72 {"author": "Iman", "message": "Will you talk about raising a cycle?"} قد تحتوي طلبات GET إلى ‎/talks على ترويسات إضافية تخبر الخادم بتأخير الإجابة إذا لم تتوفر معلومات جديدة، وذلك من أجل دعم الاستطلاع المفتوح، كما سنستخدِم زوجًا من الترويسات صُممتا أساسًا من أجل إدارة التخزين المؤقت وهما ETag وIf-None-Match، وقد تدرِج الخوادم ترويسة ETag -التي تشير إلى وسم الكتلة Entity Tag- في الاستجابة، بحيث تكون قيمتها سلسلةً نصيةً تعرِّف الإصدار الحالي للمورِد، وقد تنشئ العملاء طلبًا إضافيًا عندما تطلب هذا المورد مرةً ثانيةً من خلال إدراج ترويسة If-None-Match التي تحمل قيمتها السلسلة نفسها؛ أما إذا لم يتغير المورد، فسيستجيب الخادم برمز الحالة 304 والذي يعني "غير معدَّل not modified"، ليخبر العميل أنّ إصداره المخزَّن لا زال هو الإصدار الحالي؛ أما إذا لم يطابق الوسم، فسيستجيب الاستجابة العادية. نحتاج إلى مثل ذلك النظام لأننا نريد تمكين العميل من إخبار الخادم بإصدار قائمة الكلمات التي لديه، وألا يستجيب الخادم إلا عند تغير تلك القائمة، لكن ينبغي على الخادم تأخير الإجابة وعدم الإعادة نهائيًا إلا عند توفر شيء جديد أو مرور مهلة زمنية محددة بدلًا من إعادة 304 مباشرةً، وعليه فمن أجل تمييز طلبات الاستطلاع المفتوح عن الطلبات الشرطية العادية، فإننا نعطيها ترويسةً أخرى هي Prefer: wait=90 التي تخبر الخادم باستعداد العميل لانتظار الاستجابة مدةً قدرها 90 ثانية، كما سيحتفظ الخادم برقم إصدار version number يحدِّثه في كل مرة تتغير فيها كلمة ما، وسيستخدم ذلك على أساس قيمة لوسم ETag، ويمكن للعملاء إنشاء طلبات مثل هذا ليتم إشعارها عند حدوث تغيير في الكلمة: GET /talks HTTP/1.1 If-None-Match: "4" Prefer: wait=90 (time passes) HTTP/1.1 200 OK Content-Type: application/json ETag: "5" Content-Length: 295 [....] لا يقوم البروتوكول في حالتنا هذه بأيّ تحكم في الوصول، إذ يستطيع أيّ أحد تعليق أو تعديل الكلمات أو يحذفها، وليس من الحكمة وضع نظام مثل هذا على الويب دون حماية إضافية. الخادم لنبدأ ببناء جانب الخادم من البرنامج، حيث ستعمل الشيفرة في هذا القسم على Node.js. التوجيه Routing سيستخدم خادمنا createServer من أجل بدء خادم HTTP، ويجب علينا التفريق في الدالة التي تعالج طلبًا جديدًا بين أنواع الطلبات المختلفة التي ندعمها وفقًا للتابع والمسار، وصحيح أنه يمكن تنفيذ ذلك بسلسلة طويلة من تعليمات if، إلا أنّ طريقة التوجيه أفضل، فالموجّه هو مكون يساعد في إرسال طلب إلى الدالة التي تستطيع معالجته، فنستطيع إخباره أنّ طلبات PUT مثلًا التي يطابق مسارها التعبير النمطي ‎/^\/talks\/([^\/]+)$/‎ -يشير إلى ‎/talks/‎ متبوعًا بعنوان الكلمة-، يمكن معالجتها بدالة ما، كما يساعد على استخراج أجزاء مفيدة من المسار -عنوان الكلمة في حالتنا- مغلَّفًا بين أقواس في التعبير النمطي ثم يمررها إلى الدالة المعالجة. هناك عدة حزم موجهات جيدة على NPM، لكننا سنكتب واحدةً بأنفسنا لتوضيح الفكرة، وتوضِّح الشيفرة التالية router.js الذي سنطلبه من وحدة الخادم الخاص بنا عن طريق require لاحقًا: const {parse} = require("url"); module.exports = class Router { constructor() { this.routes = []; } add(method, url, handler) { this.routes.push({method, url, handler}); } resolve(context, request) { let path = parse(request.url).pathname; for (let {method, url, handler} of this.routes) { let match = url.exec(path); if (!match || request.method != method) continue; let urlParts = match.slice(1).map(decodeURIComponent); return handler(context, ...urlParts, request); } return null; } }; تصدِّر الوحدة صنف Router، كما يسمح كائن الموجّه بتسجيل معالِجات جديدة باستخدام التابع add، ويمكن حل الطلبات باستخدام التابع resolve الخاص به، حيث سيعيد هذا التابع استجابةً عند العثور على معالج، وإذا لم يعثر فسيعيد قيمةً غير معرَّفة null، ويجرب طريقًا واحدًا في كل مرة بالترتيب الذي عرِّفَت به تلك الطرق إلى أن يعثر على تطابق، كما تُستدعَى الدوال المعالجة بقيمة context التي ستكون نسخة الخادم في حالتنا وسلاسل المطابقة لأيّ مجموعة تعرّفها في تعبيرنا النمطي وكائن الطلب، كما يجب فك تشفير روابط السلاسل النصية بما أنّ الرابط الخام قد يحتوي على رموز من تنسيق ‎%20. تقديم الملفات إذا لم يطابق الطلب أي نوع معرّف في موجهنا فيجب على الخادم تفسير ذلك على أنه طلب لملف في مجلد public، ومن الممكن هنا استخدام خادم الملفات المعرَّف في المقال السابق لتقديم مثل تلك الملفات، لكننا لا نحتاج ولا نريد دعم طلبات PUT أو DELETE على الملفات، ونرغب أن يكون لدينا ميزات مثل دعم التخزين، وعليه فسنستخدم خادم ملفات ساكنة مجرَّبًا من NPM وليكن ecstatic مثلًا، رغم أنه ليس الوحيد على NPM ولكنه يعمل جيدًا ومناسب لأغراضنا. تصدِّر حزمة ecstatic دالةً يمكن استدعاؤها مع كائن تهيئة configuration object لإنتاج دالة معالجة طلبات، وسنستخدِم الخيار root لنخبر الخادم بالمكان الذي يجب أعليه البحث فيه عن الملفات، كما تقبل الدالة المعالِجة المعاملَين request وresponse ويمكن تمريرهما مباشرةً إلى createServer لإنشاء خادم لا يقدم لنا إلا الملفات فقط، كما نريد التحقق أولًا من الطلبات التي يجب معالجتها معالجةً خاصةً، لذا نغلفها في دالة أخرى. const {createServer} = require("http"); const Router = require("./router"); const ecstatic = require("ecstatic"); const router = new Router(); const defaultHeaders = {"Content-Type": "text/plain"}; class SkillShareServer { constructor(talks) { this.talks = talks; this.version = 0; this.waiting = []; let fileServer = ecstatic({root: "./public"}); this.server = createServer((request, response) => { let resolved = router.resolve(this, request); if (resolved) { resolved.catch(error => { if (error.status != null) return error; return {body: String(error), status: 500}; }).then(({body, status = 200, headers = defaultHeaders}) => { response.writeHead(status, headers); response.end(body); }); } else { fileServer(request, response); } }); } start(port) { this.server.listen(port); } stop() { this.server.close(); } } نستخدِم هنا طريقةً للاستجابات تشبه خادم الملفات الذي رأيناه في المقال السابق، إذ تعيد المعالِجات وعودًا تُحل إلى كائنات تصف الاستجابة، وتغلِّف الخادم في كائن يحمل حالته كذلك. الكلمات على أساس موارد تخزَّن الكلمات المقترحة في الخاصية talks للخادم، وهو كائن تكون أسماء خصائصه عناوين الكلمات، كما ستُكشف على أساس موارد HTTP تحت ‎/talks/[title]‎، لذا نحتاج إلى إضافة معالجات إلى الموجه الخاص بنا تستخدِم التوابع المختلفة التي تستطيع العملاء استخدامها كي تعمل معها، كما يجب على معالج طلبات GET التي تطلب كلمة بعينها البحث عن تلك الكلمة، ويستجيب ببيانات JSON لها أو باستجابة خطأ 404. const talkPath = /^\/talks\/([^\/]+)$/; router.add("GET", talkPath, async (server, title) => { if (title in server.talks) { return {body: JSON.stringify(server.talks[title]), headers: {"Content-Type": "application/json"}}; } else { return {status: 404, body: `No talk '${title}' found`}; } }); تُحذَف الكلمة بحذفها من الكائن talks. router.add("DELETE", talkPath, async (server, title) => { if (title in server.talks) { delete server.talks[title]; server.updated(); } return {status: 204}; }); يرسل التابع updated -الذي سنعرِّفه لاحقًا- إشعارات إلى طلبات الاستطلاع المفتوح المنتظرة بشأن التغيير، ولجلب محتوى متن الطلب فإننا نعرِّف دالةً تدعى readStream تقرأ كل المحتوى من بث قابل للقراءة وتعيد وعدًا يُحل إلى سلسلة نصية. function readStream(stream) { return new Promise((resolve, reject) => { let data = ""; stream.on("error", reject); stream.on("data", chunk => data += chunk.toString()); stream.on("end", () => resolve(data)); }); } أحد المعالِجات التي تحتاج إلى قراءة متون الطلبات هو PUT المستخدَم في إنشاء كلمات جديدة، ويجب عليه التحقق من إذا كانت البيانات المعطاة لها الخصائص presenter وsummary والتي تكون سلاسل نصية، فقد تكون أيّ بيانات قادمة من خارج النظام غير منطقية، ولا نريد إفساد نموذج بياناتنا الداخلية أو تعطيله إذا أتت طلبات سيئة bad requests، وإذا بدت البيانات صالحةً، فسيخزِّن المعالِج كائنًا يمثِّل الكلمة الجديدة في كائن talks، وهذا سيكتب فوق كلمة موجودة سلفًا في العنوان نفسه ويستدعي updated مرةً أخرى. router.add("PUT", talkPath, async (server, title, request) => { let requestBody = await readStream(request); let talk; try { talk = JSON.parse(requestBody); } catch (_) { return {status: 400, body: "Invalid JSON"}; } if (!talk || typeof talk.presenter != "string" || typeof talk.summary != "string") { return {status: 400, body: "Bad talk data"}; } server.talks[title] = {title, presenter: talk.presenter, summary: talk.summary, comments: []}; server.updated(); return {status: 204}; }); تعمل إضافة تعليق إلى كلمة ما بصورة مشابهة، إذ نستخدِم readStream لنحصل على محتوى الطلب ونتحقق من البيانات الناتجة ونخزِّنها على هيئة تعليق إذا كانت صالحةً. router.add("POST", /^\/talks\/([^\/]+)\/comments$/, async (server, title, request) => { let requestBody = await readStream(request); let comment; try { comment = JSON.parse(requestBody); } catch (_) { return {status: 400, body: "Invalid JSON"}; } if (!comment || typeof comment.author != "string" || typeof comment.message != "string") { return {status: 400, body: "Bad comment data"}; } else if (title in server.talks) { server.talks[title].comments.push(comment); server.updated(); return {status: 204}; } else { return {status: 404, body: `No talk '${title}' found`}; } }); إذا حاولنا إضافة تعليق إلى كلمة غير موجودة فسنحصل على الخطأ 404. دعم الاستطلاع المفتوح يُعَدّ الجزء المتعلق بمعالجة الاستطلاع المفتوح في هذا الخادم أمرًا مثيرًا، فقد يكون الطلب GET الآتي إلى ‎/talks طلبًا عاديًا أو طلب استطلاع مفتوح، وسيكون لدينا أماكن عدة يجب علينا فيها إرسال مصفوفة من الكلمات talks إلى العميل، لذا سنعرِّف تابعًا مساعدًا يبني مثل تلك المصفوفة ويدرِج ترويسة ETag في الاستجابة. SkillShareServer.prototype.talkResponse = function() { let talks = []; for (let title of Object.keys(this.talks)) { talks.push(this.talks[title]); } return { body: JSON.stringify(talks), headers: {"Content-Type": "application/json", "ETag": `"${this.version}"`, "Cache-Control": "no-store"} }; }; يجب على المعالج النظر في ترويسات الطلب ليرى إذا كانت الترويستان If-None-Match وPrefer موجودتين أم لا، كما تخزِّن Node الترويسات التي تكون أسماؤها حساسةً لحالة الأحرف بأسماء ذات أحرف صغيرة. router.add("GET", /^\/talks$/, async (server, request) => { let tag = /"(.*)"/.exec(request.headers["if-none-match"]); let wait = /\bwait=(\d+)/.exec(request.headers["prefer"]); if (!tag || tag[1] != server.version) { return server.talkResponse(); } else if (!wait) { return {status: 304}; } else { return server.waitForChanges(Number(wait[1])); } }); إذا لم يُعط أيّ وسم أو كان الوسم المعطى لا يطابق إصدار الخادم الحالي، فسيستجيب المعالج بقائمة من الكلمات، وإذا كان الطلب شرطيًا ولم تتغير الكلمات، فسننظر في الترويسة Prefer لنرى إذا كان يجب علينا تأخير الاستجابة أم نستجيب فورًا، كما تخزَّن دوال رد النداء للطلبات المؤجلة في مصفوفة waiting الخاصة بالخادم كي يستطيع إشعارها عند حدوث شيء ما، ويضبط التابع waitForChanges مؤقتًا على الفور للاستجابة برمز الحالة 304 إذا انتظر الطلب لفترة طويلة. SkillShareServer.prototype.waitForChanges = function(time) { return new Promise(resolve => { this.waiting.push(resolve); setTimeout(() => { if (!this.waiting.includes(resolve)) return; this.waiting = this.waiting.filter(r => r != resolve); resolve({status: 304}); }, time * 1000); }); }; يزيد تسجيل التغيير بالتابع updated قيمة الإصدار التي هي قيمة الخاصية version ويوقظ جميع الطلبات المنتظِرة. SkillShareServer.prototype.updated = function() { this.version++; let response = this.talkResponse(); this.waiting.forEach(resolve => resolve(response)); this.waiting = []; }; هكذا تكون شيفرة الخادم قد تمت، فإذا أنشأنا نسخةً من SkillShareServer وبدأناها عند المنفَذ 8000، فسيخدم خادم HTTP الناتج الملفات من المجلد الفرعي public مع واجهة لإدارة الكلمات تحت رابط ‎/talks. new SkillShareServer(Object.create(null)).start(8000); العميل يتكون جانب العميل من موقع لمشاركة المهارات من ثلاثة ملفات هي صفحة HTML صغيرة وورقة تنسيقات style sheet وملف جافاسكربت. HTML يُعَدّ تقديم ملف اسمه index.html إحدى الطرق المستخدَمة بكثرة في خوادم الويب عند إنشاء طلب مباشرة إلى مسار موافق لمجلد ما، وتدعم وحدة خادم الملفات التي نستخدمها exstatic تلك الطريقة، فإذا أنشئ طلب إلى المسار / فسيبحث الخادم عن الملف ‎./public/index.html، حيث يكون ‎./public الجذر الذي أعطيناه إليه، ثم يعيد ذلك الملف إذا وجده، وعلى ذلك فإذا أردنا لصفحة أن تظهر عندما يوجَّه متصفح ما إلى خادمنا، فيجب علينا وضعها في public/index.html، حيث يكون ملف index الخاص بنا كما يلي: <!doctype html> <meta charset="utf-8"> <title>Skill Sharing</title> <link rel="stylesheet" href="skillsharing.css"> <h1>Skill Sharing</h1> <script src="skillsharing_client.js"></script> يعرِّف هذا الملف عنوان المستند، ويتضمن ورقة تنسيقات تعرِّف بعض التنسيقات لضمان وجود مسافة بين الكلمات، إضافة إلى أمور أخرى، كما يضيف في النهاية عنوانًا في قمة الصفحة ويحمِّل السكربت التي تحتوي على تطبيق جانب العميل. الإجراءات تتكون حالة التطبيق من قائمة من الكلمات واسم المستخدم، كما سنخزِّن ذلك في الكائن {talks,user}، ولا نريد السماح لواجهة المستخدِم بتعديل الحالة أو إرسال طلبات HTTP، بل قد تطلق إجراءات تصف ما الذي يحاول المستخدِم فعله، في حين تأخذ دالة handleAction مثل هذا الإجراء وتجعله يحدُث، كما تعالَج تغيرات الحالة في الدالة نفسها بما أنّ تحديثات حالتنا بسيطة جدًا. function handleAction(state, action) { if (action.type == "setUser") { localStorage.setItem("userName", action.user); return Object.assign({}, state, {user: action.user}); } else if (action.type == "setTalks") { return Object.assign({}, state, {talks: action.talks}); } else if (action.type == "newTalk") { fetchOK(talkURL(action.title), { method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ presenter: state.user, summary: action.summary }) }).catch(reportError); } else if (action.type == "deleteTalk") { fetchOK(talkURL(action.talk), {method: "DELETE"}) .catch(reportError); } else if (action.type == "newComment") { fetchOK(talkURL(action.talk) + "/comments", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ author: state.user, message: action.message }) }).catch(reportError); } return state; } سنخزن اسم المستخدِم في localStorage كي يمكن استعادتها عند تحميل الصفحة؛ أما الإجراءات التي تحتاج إلى إنشاء الخادم طلبات شبكية باستخدام fetch إلى واجهة HTTP التي وصفناها من قبل فسنستخدِم دالةً مغلِّفةً هي fetchOk تتأكد من أنّ الوعد المعاد مرفوض إذا أعاد الخادم رمز خطأ. function fetchOK(url, options) { return fetch(url, options).then(response => { if (response.status < 400) return response; else throw new Error(response.statusText); }); } تُستخدَم الدالة المساعدة التالية لبناء رابط لكلمة لها عنوان محدَّد. function talkURL(title) { return "talks/" + encodeURIComponent(title); } إذا فشل الطلب فلا نريد أن تظل صفحتنا ساكنةً لا تفعل شيء دون تفسير، لذا نعرِّف دالةً تدعى reportError تعرض للمستخدِم صندوقًا حواريًا يخبره أنّ شيئًا خاطئًا قد حدث. function reportError(error) { alert(String(error)); } إخراج المكونات Rendering Components سنستخدِم منظورًا يشبه الذي رأيناه في مقال إنجاز مشروع محرر رسوم نقطية باستخدام جافاسكربت والذي يقسِّم التطبيق إلى مكونات، لكن بما أن بعض تلك المكونات قد لا تحتاج إلى تحديث أبدًا أو تُرسم من جديد في كل مرة تُحدَّث فيها، فسنعرِّف أولئك على أساس دوال تعيد عقدة DOM مباشرةً وليس على أساس أصناف، ويوضِّح المثال التالي مكونًا يعرض حقلًا يمكن للمستخدِم إدخال اسمه فيه. function renderUserField(name, dispatch) { return elt("label", {}, "Your name: ", elt("input", { type: "text", value: name, onchange(event) { dispatch({type: "setUser", user: event.target.value}); } })); } الدالة elt المستخدَمة لبناء عناصر DOM هي نفسها التي استخدمناها في مقال إنجاز مشروع محرر رسوم نقطية باستخدام جافاسكربت المشار إليه أعلاه، وتُستخدَم دالة شبيهة بها لإخراج الكلمات، حيث تتضمن قائمةً من التعليقات واستمارةً من أجل إضافة تعليق جديد. function renderTalk(talk, dispatch) { return elt( "section", {className: "talk"}, elt("h2", null, talk.title, " ", elt("button", { type: "button", onclick() { dispatch({type: "deleteTalk", talk: talk.title}); } }, "Delete")), elt("div", null, "by ", elt("strong", null, talk.presenter)), elt("p", null, talk.summary), ...talk.comments.map(renderComment), elt("form", { onsubmit(event) { event.preventDefault(); let form = event.target; dispatch({type: "newComment", talk: talk.title, message: form.elements.comment.value}); form.reset(); } }, elt("input", {type: "text", name: "comment"}), " ", elt("button", {type: "submit"}, "Add comment"))); } معالِج الحدث "submit" يستدعي form.reset لمسح محتوى الاستمارة بعد إنشاء الإجراء "newcomment"، وعند إنشاء أجزاء متوسطة التعقيد من DOM، فسيبدو هذا التنسيق من البرمجة فوضويًا، وهناك امتداد جافاسكربت واسع الاستخدام رغم أنه ليس قياسيًا ويسمى JSX، حيث يسمح لنا بكتابة HTML في السكربتات الخاصة بك مباشرةً مما يحسِّن من مظهر الشيفرة، لكن يجب علينا تشغيل برنامج ما قبل تشغيل الشيفرة نفسها ليحوّل شيفرة HTML الوهمية تلك إلى استدعاءات لدوال جافاسكربت مثل تلك التي نستخدمها ها هنا؛ أما التعليقات فستكون أبسط في الإخراج. function renderComment(comment) { return elt("p", {className: "comment"}, elt("strong", null, comment.author), ": ", comment.message); } أخيرًا، تُخرَج الاستمارة التي يستطيع المستخدِم استخدامها في إنشاء الكلمة كما يلي: function renderTalkForm(dispatch) { let title = elt("input", {type: "text"}); let summary = elt("input", {type: "text"}); return elt("form", { onsubmit(event) { event.preventDefault(); dispatch({type: "newTalk", title: title.value, summary: summary.value}); event.target.reset(); } }, elt("h3", null, "Submit a Talk"), elt("label", null, "Title: ", title), elt("label", null, "Summary: ", summary), elt("button", {type: "submit"}, "Submit")); } الاستطلاع نحتاج إلى قائمة الكلمات الحالية إذا أردنا بدء التطبيق، وبما أن التحميل الابتدائي متعلق للغاية بعملية الاستطلاع المفتوح إذ يجب استخدام ETag من الحمل عند الاستطلاع، فسنكتب دالةً تظل تستطلع الخادم لـ ‎/talks وتستدعي دالة رد نداء عند توفر مجموعة كلمات جديدة. async function pollTalks(update) { let tag = undefined; for (;;) { let response; try { response = await fetchOK("/talks", { headers: tag && {"If-None-Match": tag, "Prefer": "wait=90"} }); } catch (e) { console.log("Request failed: " + e); await new Promise(resolve => setTimeout(resolve, 500)); continue; } if (response.status == 304) continue; tag = response.headers.get("ETag"); update(await response.json()); } } بما أن هذه الدالة هي دالة async فمن السهل تنفيذ تكرار حلقي وانتظار الطلب، وهي تشغِّل حلقةً تكراريةً لا نهائيةً تجلب قائمةً من الكلمات في كل تكرار إما جلبًا عاديًا أو مع تضمين الترويسات التي تجعله طلب استطلاع مفتوح إذا لم يكن هذا هو الطلب الأول، حيث تنتظر الدالة عند فشل الطلب لحظةً ثم تحاول مرةً أخرى وهكذا، فإذا انقطع الاتصال لدينا لوهلة ثم عادة مرةً أخرى فسيستطيع البرنامج أن يتعافى ويتابع التحديث، ويكون الوعد المحلول بواسطة setTimeout طريقةً لإجبار دالة async على الانتظار. إذا أعاد الخادم استجابة 304 فهذا يعني انتهاء المهلة الزمنية المحددة لطلب استطلاع مفتوح، لذا يجب أن تبدأ الدالة الطلب التالي، فإذا كانت الاستجابة هي 200 العادية، فسيُقرأ متنها على أنه JSON ويمرَّر إلى رد النداء، كما تخزَّن قيمة الترويسة ETag من أجل التكرار التالي. التطبيق يربط المكون التالي واجهة المستخدِم كلها بعضها ببعض: class SkillShareApp { constructor(state, dispatch) { this.dispatch = dispatch; this.talkDOM = elt("div", {className: "talks"}); this.dom = elt("div", null, renderUserField(state.user, dispatch), this.talkDOM, renderTalkForm(dispatch)); this.syncState(state); } syncState(state) { if (state.talks != this.talks) { this.talkDOM.textContent = ""; for (let talk of state.talks) { this.talkDOM.appendChild( renderTalk(talk, this.dispatch)); } this.talks = state.talks; } } } إذا تغيرت الكلمات فسيُعيد هذا المكون رسمها جميعًا، وهذا أمر بسيط حقًا لكنه مضيعة للوقت وسنعود إليه في التدريبات، إذ نستطيع بدء التطبيق كما يلي: function runApp() { let user = localStorage.getItem("userName") || "Anon"; let state, app; function dispatch(action) { state = handleAction(state, action); app.syncState(state); } pollTalks(talks => { if (!app) { state = {user, talks}; app = new SkillShareApp(state, dispatch); document.body.appendChild(app.dom); } else { dispatch({type: "setTalks", talks}); } }).catch(reportError); } runApp(); إذا شغلنا الخادم وفتحنا نافذتَي متصفح لـ http://localhost:8000 جنبًا إلى جنب، فسيمكنك رؤية كيف أنّ الإجراءات الذي تحدِثه في إحدى النافذتين تظهر مباشرةً في الأخرى. تدريبات ستتضمن التدريبات التالية تعديل النظام المعرّف في هذا المقال، ولكي تعمل عليها تأكد من تحميل الشيفرة أولًا من هذا الرابط وتكون قد ثبّتَّ Node لديك من موقعها الرسمي، وكذلك اعتماديات المشروع باستخدام الأمر npm install. ثبات القرص يحتفظ خادم مشاركة المهارات ببياناته في الذاكرة، وهذا يعني أنه ستضيع كل الكلمات والتعليقات عند تعطله أو إعادة تشغيله لأيّ سبب كان، لذا وسِّع الخادم ليخزِّن بيانات الكلمات في القرص، ويعيد تحميل البيانات تلقائيًا عند إعادة تشغيله، ولا تقلق بشأن الكفاءة وإنما افعل أبسط شيء يؤدي الغرض. إرشادات الحل أبسط حل لهذا هو ترميز كائن talks كله على أنه JSON وإلقائه في ملف بواسطة writeFile، وهناك تابع update بالفعل يُستدعى في كل مرة تتغير فيها بيانات الخادم، حيث يمكن توسيعه لكتابة البيانات الجديدة على القرص. اختر اسم ملف وليكن ‎./talks,json، ويمكن للخادم أن يحاول في قراءة هذا الملف باستخدام readFile عند بدء عمله، وإذا نجح فيمكن للخادم أن يستخدِم محتويات الملف على أساس تاريخ بدء له؛ لكن احذر، فكائن talks بدأ على أساس كائن ليس له نموذج أولي كي يمكن استخدام العامل in بصورة موثوقة. ستعيد JSON.parse كائنات عادية يكون نموذجها الأولي هو Object.prototype، فإذا استخدمت JSON على أساس صيغة ملفات لك، فيجب عليك نسخ خصائص الكائن المعاد بواسطة JSON.parse في كائن جديد ليس له نموذج أولي. إعادة ضبط حقول التعليقات تعمل إعادة رسم الكلمات كلها لأنك لا تستطيع عادةً معرفة الفرق بين عقدة DOM وبديلها التوأم، لكن هناك استثناءات لهذا، فإذا بدأت كتابة شيء ما في حقل التعليق لكلمة ما في نافذة متصفح ثم أضفت تعليقًا إلى الكلمة نفسها من متصفح آخر، فسيعاد رسم الحقل في النافذة الأولى ليحذف محتواه وتركيزه focus معًا، وسيكون هذا مزعجًا للغاية إذا كان لدينا نقاشًا بين عدة مستخدِمِين من حواسيب مختلفة ومتصفحات عدة يضيفون تعليقات في الوقت نفسه، فهل تستطيع إيجاد طريقة لحل هذه المشكلة؟ إرشادات الحل إنّ أفضل حل لهذا هو جعل مكونات الكلمات كائنات لها التابع syncState كي يمكن تحديثها لتعرض نسخةً معدلةً من الكلمة، وتكون الطريقة الوحيدة التي يمكن بها تغير كلمة ما هي بإضافة تعليقات أكثر، وعليه يكون التابع syncState بسيطًا نسبيًا هنا؛ أما الجزء الصعب فهو عند تغير قائمة الكلمات، إذ يجب إصلاح قائمة مكونات DOM الموجودة بكلمات من القائمة الجديدة، مما يعني حذف المكونات التي حُذفت كلماتها وتحديث المكونات التي تغيرت كلماتها. من المفيد عند تنفيذ ذلك الاحتفاظ بهيكل بيانات يخزن مكونات الكلمات تحت عناوين الكلمات نفسها كي تستطيع معرفة إذا كان مكون ما موجودًا بالنسبة لكلمة معطاة أم لا، ثم تكرِّر حلقيًا على المصفوفة الجديدة للكلمات وتزامن المكون الموجود سلفًا لكل واحدة منها أو تنشئ واحدًا جديدًا، ولحذف المكونات بالنسبة للكلمات المحذوفة فيجب عليك التكرار حلقيًا أيضًا على المكونات وتنظر هل لا زالت الكلمات الموافقة لها موجودةً أم لا. ترجمة -بتصرف- للفصل الحادي والعشرين من كتاب Elequent Javascript لصاحبه Marijn Haverbeke. اقرأ أيضًا تطوير تطبيق عملي يزيد من معدل الاحتفاظ بالعملاء عبر واجهة زد البرمجية هيكل تطبيقات الواجهة الخلفية: مدخل إلى الاختبارات (unit tests) استخدام محرك بحث لبلب العربي كمحرك بحث داخلي لمحتويات موقعك
  3. استخدمنا لغة جافاسكربت طيلة المقالات الماضية من هذه السلسلة في بيئة واحدة هي بيئة المتصفح؛ أما في هذا المقال والذي يليه فسنلقي نظرةً سريعةً على Node.js البرنامج الذي يسمح لك بتطبيق مهاراتك في جافاسكربت خارج نطاق المتصفح، إذ تستطيع بناء أيّ شيء بها من أدوات أوامر الطرفية البسيطة إلى خوادم HTTP التي تشغِّل مواقعًا ديناميكيةً، كما يهدف هذان المقالان إلى شرح المفاهيم الأساسية التي تستخدمها Node.js وإعطاء معلومات تكفي لكتابة برامج لها، إلا أنهما لن يتعمقا في تفاصيل تلك المنصة. لن تعمل أمثلة الشيفرات التي ستكون في هذا المقال في المتصفح على عكس المقالات السابقة، فهي ليست جافاسكربت خام وليست مكتوبةً للمتصفح وإنما مكتوبة من أجل Node، فإذا أردت تشغيل هذه الشيفرات فستحتاج إلى تثبيت Node.js الإصدار 10.1 أو الأحدث بالذهاب إلى الموقع https://nodejs.org واتباع إرشادات التثبيت لنظام تشغيلك، كما ستجد هناك توثيقًا أكثر تفصيلًا عن Node.js. تقديم إلى Node تُعَدّ إدارة الدخل والخرج إحدى المشكلات الصعبة في كتابة أنظمة تتواصل عبر الشبكة، أي قراءة وكتابة البيانات من وإلى الشبكة والأقراص الصلبة، ذلك أنّ نقل البيانات من مكان لآخر يستغرق وقتًا؛ أما إذا جدولنا ذلك النقل فيمكن إحداث فرق كبير في سرعة استجابة النظام إلى طلبات المستخدِم أو الشبكة، كما تكون البرمجة غير المتزامنة asynchronous مفيدةً في مثل تلك الحالات، فهي تسمح للبرنامج بإرسال واستقبال بيانات من وإلى أجهزة متعددة في الوقت نفسه دون إدارة معقدة للخيوط والتزامن. وُضع تصور Node في البداية من أجل تسهيل البرمجة غير المتزامنة، كما تتكامل جافاسكربت جيدًا مع Node، فهي إحدى لغات البرمجة القليلة التي ليس لديها طريقة مضمَّنة لتنفيذ الدخل والخرج، وبالتالي يمكن استخدام جافاسكربت مع منظور Node غير المركزي للدخل والخرج دون أن يكون لدينا واجهتين غير متناسقتين، وقد نفَّذ الناس البرمجة المبنية على الاستدعاءات الخلفية في المتصفح في عام 2009 بالفعل حين صُمِّمت Node، وعليه فقد كان المجتمع الذي عاصر هذه اللغة معتادًا على تنسيق البرمجة غير المتزامن. أمر node توفِّر Node.js عند تثبيتها على النظام برنامجًا اسمه node يُستخدَم لتشغيل ملفات جافاسكربت، فلنقل مثلًا أنه لدينا ملفًا اسمه hello.js يحتوي الشيفرة التالية: let message = "Hello world"; console.log(message); نستطيع تشغيل node من سطر الأوامر كما يلي لتنفيذ البرنامج: $ node hello.js Hello world ينفِّذ التابع console.log شيئًا شبيهًا بما يفعله في المتصفح أي سيطبع جزءًا من النص، لكن سيذهب النص في Node إلى مجرى الخرج القياسي للعملية بدلًا من منصة جافاسكربت التي في المتصفح، وهذا يعني أننا سنرى القيم المسجَّلة في طرفيتنا؛ أما إذا شغّلت node دون إعطائها ملفًا، فستزودك بمحث prompt تستطيع كتابة شيفرة جافاسكربت فيه وترى نتيجتها مباشرةً. $ node > 1 + 1 2 > [-1, -2, -3].map(Math.abs) [1, 2, 3] > process.exit(0) $ تكون الرابطة process متاحةً عمومًا في Node شأنها في ذلك شأن الرابطة console، حيث توفِّر طرقًا مختلفةً لفحص وتعديل البرنامج الحالي؛ أما التابع exit فينهي العملية ويمكن إعطائه رمز حالة خروج تخبر البرنامج الذي بدأ node -وهي صدفية سطر الأوامر في هذه الحالة- هل اكتمل البرنامج بنجاح - أي الرمز صفر- أم قابل خطأ -أي رمز آخر-. تستطيع قراءة process.argv لإيجاد الوسائط التي أُعطيت للسكربت الخاصة بك والتي هي مصفوفة من سلاسل نصية، لاحظ أنها تتضمن أمر node نفسه واسم السكربت الخاص بك، وبالتالي تبدأ الوسائط الحقيقية عند الفهرس 2، فإذا احتوى showargv.js على التعليمة ‎console.log(process.argv)‎، فستستطيع تشغيلها كما يلي: $ node showargv.js one --and two ["node", "/tmp/showargv.js", "one", "--and", "two"] توجد جميع رابطات جافاسكربت العامة مثل Array وMath وJSON في بيئة Node على عكس الوظائف المتعلِّقة بالمتصفح مثل document وprompt. الوحدات Modules تضيف Node بعض الرابطات الإضافية في النطاق العام global scope على الرابطات التي ذكرناها قبل قليل، فإذا أردت الوصول إلى الوظائف المضمَّنة، فيمكنك طلب ذلك من نظام الوحدات module system، وقد ذكرنا نظام وحدات CommonJS المبني على دالة require في مقال الوحدات Modules في جافاسكريبت المشار إليه أعلاه؛ أما هذا النظام فقد دُمِج في Node ويُستخدَم لتحميل أيّ شيء بدءًا من الوحدات المضمَّنة إلى الحزم المحمَّلة إلى الملفات التي هي جزء من برنامجك. يجب أن تحل Node السلسلة النصية المعطاة إلى ملف حقيقي يمكن تحميله عند استدعاء require، كما تُحَل أسماء المسارات التي تبدأ بـ / و‎./‎ و‎../‎ نسبيًا إلى مسار الوحدة الحالية؛ أما . فتشير إلى المجلد الحالي وتشير ‎../‎ إلى مجلد واحد للأعلى وتشير / إلى جذر نظام الملفات، فإذا طلبنا ‎"./graph"‎ من الملف ‎/tmp/robot/robot.js‎، فستحاول Node تحميل الملف ‎/tmp/robot/graph.js. يمكن إهمال الامتداد ‎.js‎، كما ستضيفه Node تلقائيًا إذا وُجد ملف بذلك الاسم، فإذا أشار المسار المطلوب إلى مجلد، فستحاول Node تحميل الملف الذي يكون اسمه index.js في ذلك المجلد، وإذا أعطينا سلسلةً نصيةً لا تبدو مسارًا نسبيًا أو مطلقًا إلى الدالة require، فستفترض أنها تشير إما إلى وحدة مضمَّنة أو وحدة مثبَّتة في المجلد node_modules، كما تعطينا ‎require("fs")‎ مثلًا وحدة نظام الملفات المضمَّن في Node؛ أما require("robot")‎‎ فستحاول تحميل المكتبة الموجودة في ‎node_modules/robot/‎، ويمكن تثبيت مثل تلك المكتبات باستخدام NPM الذي سنعود إليه بعد قليل. لنعدّ الآن مشروعًا صغيرًا يتكون من ملفين، الأول اسمه main.js بحيث يعرّف سكربت يمكن استدعاؤها من سطر الأوامر لعكس سلسلة نصية. const {reverse} = require("./reverse"); // Index 2 holds the first actual command line argument let argument = process.argv[2]; console.log(reverse(argument)); يعرِّف الملف reverse.js مكتبةً لعكس السلاسل النصية التي يمكن استخدامها بواسطة أداة سطر الأوامر هذه وكذلك بواسطة السكربتات الأخرى التي تحتاج إلى وصول مباشر إلى دالة عكس سلاسل نصية. exports.reverse = function(string) { return Array.from(string).reverse().join(""); }; تذكَّر أنّ إضافة الخصائص إلى exports يضيفها إلى واجهة الوحدة، وبما أنّ Node.js تعامل الملفات على أساس وحدات CommonJS، فيمكن أن تأخذ main.js دالة reverse المصدَّرة من reverse.js، ونستطيع الآن استدعاء أداتنا كما يلي: $ node main.js JavaScript tpircSavaJ التثبيت باستخدام NPM تعرَّضنا إلى مستودع NPM في مقال الوحدات Modules في جافاسكريبت وهو مستودع لوحدات جافاسكربت، وقد كُتب الكثير منها من أجل Node، فإذا ثبَّت Node على حاسوبك، فستستطيع الحصول على أمر npm الذي يمكن استخدامه للتفاعل مع ذلك المستوع، والغرض الأساسي من NPM هو تحميل الحِزم، حيث نستطيع استخدامه لجلب وتثبيت حزمة ini التي رأيناها في من قبل على حاسوبنا: $ npm install ini npm WARN enoent ENOENT: no such file or directory, open '/tmp/package.json' + ini@1.3.5 added 1 package in 0.552s $ node > const {parse} = require("ini"); > parse("x = 1\ny = 2"); { x: '1', y: '2' } يجب أن ينشئ NPM مجلدًا اسمه node_modules بعد تشغيل npm install، حيث سيكون مجلد ini بداخل ذلك المجلد محتويًا على المكتبة التي يمكن فتحها والاطلاع على شيفرتها، وتُحمَّل تلك المكتبة عند استدعاء ‎require("ini")‎، كما نستطيع استدعاء الخاصية parse الخاصة بها لتحليل ملف الإعدادات. يثبِّت NPM الحِزم تحت المجلد الحالي افتراضيًا بدلًا من المكان المركزي، وقد يكون هذا غريبًا إذا كنا معتادين على مدير حِزم آخر، لكن هذا له مزاياه، فهو يجعل كل تطبيق متحكمًا بالكامل في الحِزم التي يثبِّتها ويسهِّل إدارة الإصدارات ومحو التطبيقات إذا أردنا حذفها. ملفات الحزم تستطيع رؤية تحذير في مثال npm install أنّ الملف package.json غير موجود، كما يُنصح بإنشاء مثل هذا الملف لكل مشروع إما يدويًا أو عبر تشغيل npm init، حيث يحتوي على بعض معلومات المشروع مثل اسمه وإصداره ويسرد اعتمادياته، ولنضرب مثلًا هنا بمحاكاة الروبوت من مقال مشروع تطبيقي لبناء رجل آلي (روبوت) عبر جافاسكريبت التي عدلنا عليها عند تعرضنا للوحدات في مقال الوحدات Modules في جافاسكريبت، إذ قد يبدو ملف package.json الخاص بها كما يلي: { "author": "Marijn Haverbeke", "name": "eloquent-javascript-robot", "description": "Simulation of a package-delivery robot", "version": "1.0.0", "main": "run.js", "dependencies": { "dijkstrajs": "^1.0.1", "random-item": "^1.0.0" }, "license": "ISC" } عند تشغيل npm install دون تسمية الحزمة المراد تثبيتها، فسيثبِّت NPM الاعتماديات التي يسردها package.json؛ أما إذا ثبَّتت حزمةً ما ليست موجودة على أساس اعتمادية، فسيضيفها NPM إلى package.json. الإصدارات يسرد ملف package.json كلًا من إصدار البرنامج وإصدارات اعتمادياته، والإصدارات هي أسلوب للتعامل مع التطور المنفصل للحِزم، فقد لا تعمل الشيفرة التي كُتبت لحزمة في وقت ما مع الإصدار الجديد والمعدل من تلك الحزمة، حيث يشترط NPM أن تتبع الحِزم الخاصة به نظامًا اسمه الإصدار الدلالي semantic versioning الذي يرمّز بعض المعلومات عن الإصدارات المتوافقة التي لا تعطل الواجهة القديمة في رقم الإصدار version number، حيث يتكون الإصدار الدلالي من ثلاثة أعداد مفصولة بنقاط مثل 2.3.0، وكل مرة تضاف فيها ميزة جديدة فإننا نزيد العدد الأوسط، وكل مرة تُعطل فيها التوافقية بحيث لا تعمل الشيفرة الحالية التي تستخدِم الحزمة مع إصدارها الجديد فإننا نغير العدد الأول من اليسار. يوضِّح محرف الإقحام ^ الذي يكون أمام رقم إصدار الاعتمادية في package.json أنّ أيّ نسخة متوافقة مع الرقم المعطى يمكن تثبيتها، وعلى ذلك تعني ‎"^2.3.0"‎ أنّ أيّ نسخة أكبر من أو يساوي 2.3.0 وأقل من 3.0.0 مسموح بها، كما يُستخدَم أمر npm أيضًا لنشر حزم جديدة أو إصدارات جديدة من الحزم، فإذا شغّلنا npm publish في مجلد فيه ملف package.json، فسينشر حزمةً بالاسم والإصدار الموجودَين في ملف JSON إلى السجل registry، ويستطيع أيّ أحد نشر حزم في NPM لكن شرط أن يكون اسم الحزمة غير مستخدَم من قبل. ليس ثمة شيء فريد في وظيفته بما أنّ برنامج npm جزء برمجي يتواصل مع نظام مفتوح هو سجل الحزم، ويمكن تثبيت برنامج آخر مثل yarn من سجل NPM ليؤدي وظيفة npm نفسها باستخدام واجهة مختلفة قليلًا وكذلك استراتيجية تثبيت مختلفة، لكن هذه السلسلة لا تتعمق في استخدام NPM، وإنما ننصحك بالرجوع إلى npmjs.org لمزيد من التوثيق والبحث عن الحزم. وحدة نظام الملفات إحدى الوحدات المضمَّنة والمستخدَمة بكثرة في Node هي وحدة fs التي تشير إلى نظام الملفات file system، إذ تصدِّر الدوال من أجل العمل مع الملفات والمجلدات، حيث تقرأ مثلًا الدالة readFile ملفًا وتستدعي رد نداء بمحتويات الملف كما يلي: let {readFile} = require("fs"); readFile("file.txt", "utf8", (error, text) => { if (error) throw error; console.log("The file contains:", text); }); يشير الوسيط الثاني لدالة readFile إلى ترميز المحارف المستخدَم لفك تشفير الملف إلى سلسلة نصية، ورغم وجود عدة طرق لتشفير النصوص إلى بيانات ثنائية إلا أنّ أغلب النظم الحديثة تستخدِم UTF-8، فإذا لم يكن لديك سبب يجعلك تفضل ترميزًا آخر غير هذا فإنك تستخدمه، وعليه تمرر "utf8" عند قراءة ملف نصي، فإذا لم تمرر ترميزًا، فستفترض Node أنك تريد البيانات الثنائية وستعطيك الكائن Buffer بدلًا من سلسلة نصية، وهو كائن شبيه بالمصفوفة يحتوي أعدادًا تمثل البايتات (قطع بيانات بحجم 8 بت) التي في الملف. const {readFile} = require("fs"); readFile("file.txt", (error, buffer) => { if (error) throw error; console.log("The file contained", buffer.length, "bytes.", "The first byte is:", buffer[0]); }); تُستخدَم الدالة writeFile لكتابة ملف إلى القرص الصلب كما يلي: const {writeFile} = require("fs"); writeFile("graffiti.txt", "Node was here", err => { if (err) console.log(`Failed to write file: ${err}`); else console.log("File written."); }); ليس من الضروري هنا تحديد الترميز، إذ ستفترض الدالة عند إعطائها سلسلة نصية أنّ عليها كتابتها على أساس نص باستخدام ترميزها الافتراضي للمحارف -أي UTF-8- ما لم يكن كائن Buffer. تحتوي الوحدة fs على عدة دوال أخرى مفيدة مثل readdir التي ستعيد الملفات الموجودة في مجلد على أساس مصفوفة من السلاسل النصية، وstat التي ستجلب معلومات عن ملف ما وrename التي ستعيد تسمية الملف وunlink التي ستحذِف الملفات وهكذا، ولمزيد من التفاصيل انظر توثيق Node، كما تأخذ أغلب الدوال السابقة دالة رد نداء على أساس آخر معامِل لها وتستدعيها إما مع خطأ -أي أول وسيط- أو مع نتيجة ناجحة -أي ثاني وسيط-، ورأينا في مقال البرمجة غير المتزامنة في جافاسكريبت وجود تبعات لمثل هذا التنسيق من البرمجة لعل أكبرها أن عملية معالجة الأخطاء نفسها تصبح طويلة وعرضة للخطأ. لا زال تكامل الوعود مع Node قيد التطوير وقت كتابة هذه الكلمات رغم أنها أُدخلت في جافاسكربت منذ مدة لا بأس بها، فلدينا الكائن promises المصدَّر من حزمة fs منذ الإصدار 10.1 والذي يحتوي على أغلب الدوال الموجودة في fs لكنه يستخدِم الوعود بدلًا من دوال رد النداء. const {readFile} = require("fs").promises; readFile("file.txt", "utf8") .then(text => console.log("The file contains:", text)); قد لا نحتاج أحيانًا إلى اللاتزامنية، بل قد تعيق عملنا، ولحسن حظنا أنّ أغلب الدوال التي في fs نسخة تزامنية لها الاسم نفسه مع لاحقة Sync مضافة إلى آخرها، فسيكون اسم النسخة التزامنية من دالة readFile مثلًا readFileSync. const {readFileSync} = require("fs"); console.log("The file contains:", readFileSync("file.txt", "utf8")); لاحظ أنّ البرنامج سيتوقف عن العمل تمامًا أنه ريثما تُنفذ العملية التزامنية، وسيكون ذلك تأخيرًا مزعجًا إذا كان يُفترض به الاستجابة إلى المستخدِم أو إلى آلات أخرى في الشبكة أثناء تلك العملية. وحدة HTTP لدينا وحدة مركزية أخرى توفِّر وظيفة تشغيل خوادم HTTP وإنشاء طلبات HTTP كذلك واسمها http، وإذا أردنا تشغيل خادم HTTP فسيكون ذلك عبر السكربت التالية: const {createServer} = require("http"); let server = createServer((request, response) => { response.writeHead(200, {"Content-Type": "text/html"}); response.write(` <h1>Hello!</h1> <p>You asked for <code>${request.url}</code></p>`); response.end(); }); server.listen(8000); console.log("Listening! (port 8000)"); فإذا شغّلت هذه السكربت على الحاسوب، فستوجِّه المتصفح إلى http://localhost:8000/hello لإنشاء طلب إلى خادمك، وسيستجيب بصفحة HTML صغيرة، كما تُستدعى الدالة الممررة على أساس وسيط إلى createServer في كل مرة يتصل عميل بالخادم، كذلك تُعَدّ الرابطتان request وresponse كائنين يمثلان البيانات الواردة والصادرة، حيث يحتوي الأول على معلومات عن الطلب مثل خاصية url الخاصة به والتي تخبرنا بالرابط URL الذي أُنشئ الطلب إليه، لذلك عندما نفتح تلك الصفحة في المتصفح فإنها ترسل طلبًا إلى حاسوبك، وهذا يشغّل دالة الخادم ويرجع استجابةً نراها في المتصفح. أما لإرجاع شيء من طرفنا فستستدعي عدة توابع على الكائن response، أولها هو التابع writeHead الذي يكتب ترويسات الاستجابة -انظر مقال HTTP والاستمارات في جافاسكربت-، وتعطيه شيفرة الحالة -أي 200 التي تعني OK في هذه الحالة-، وكائنًا يحتوي على قيم الترويسة، ثم يعيِّن المثال ترويسة Content-Type لتخبر العميل أننا نرسل مستند HTML إليه، ثم يُرسَل متن الاستجابة الفعلية -أي المستند نفسه- باستخدام response.write، ويُسمح لنا باستدعاء هذا التابع عدة مرات إذا أردنا إرسال الاستجابة جزءًا جزءًا، كما في حالة بث البيانات إلى العميل كلما صارت متاحةً على سبيل المثال، ثم تشير response.end إلى نهاية الاستجابة. يتسبب استدعاء server.listen في جعل الخادم ينتظر اتصالًا على المنفَذ 8000 وهذا هو السبب الذي يجبرنا على الاتصال بـ localhost:8000 من أجل التواصل مع هذا الخادم بدلًا من localhost فقط والتي ستستخدِم المنفَذ 80، وتظل العملية في وضع انتظار عند تشغيل هذه السكربت، ولن تخرج node تلقائيًا عندما تصل إلى نهاية السكربت بما أنها تظل في وضع استماع إلى الإحداث وانتظارها والتي هي اتصالات الشبكة في هذه الحالة، كما نضغط control+c من أجل إغلاقها، وكان هذا الخادم مثالًا فقط، وإلا فإنّ خادم الويب الحقيقي يفعل أكثر من ذلك، فهو ينظر في تابع الطلب -أي الخاصية method- ليرى الإجراء الذي يحاول العميل تنفيذه، وينظر في رابط الطلب كي يعرف المورد الذي ينفَّذ عليه ذلك الإجراء، وسنرى لاحقًا في هذا المقال خادمًا أكثر تقدمًا وتعقيدًا. يمكننا استخدام الدالة request في وحدة http من أجل التصرف على أساس عميل HTTP. const {request} = require("http"); let requestStream = request({ hostname: "eloquentjavascript.net", path: "/20_node.html", method: "GET", headers: {Accept: "text/html"} }, response => { console.log("Server responded with status code", response.statusCode); }); requestStream.end(); يهيئ الوسيط الأول للدالة request الطلب ليخبر Node بالخادم الذي يجب التواصل معه والمسار الذي تطلبه من ذلك الخادم وأيّ تابع يجب استخدامه وهكذا؛ أما الوسيط الثاني فيكون الدالة التي يجب أن تستدعَى عندما تأتي الاستجابة، وتعطى كائنًا يسمح لنا بفحص الاستجابة، لمعرفة رمز حالتها مثلًا، كما يسمح الكائن الذي تعيده request ببث البيانات في الطلب باستخدام التابع write كما في كائن response الذي رأيناه في الخادم وتنهي الطلب بالتابع end، ولا يستخدم المثال write لأن طلبات GET يجب ألا تحتوي على بيانات في متونها. لدينا دالة request مشابهةً في وحدة https، حيث يمكن استخدامها لإنشاء طلبات إلى روابط ‎https:‎، ولا شك أنّ إنشاء الطلبات باستخدام Node الخام أمر طويل مسهب، لهذا توجد حزم تغليف سهلة الاستخدام متاحة في NPM مثل node-fetch التي توفر واجهة fetch مبنيةً على الوعود التي عرفناها من المتصفح. البث Stream رأينا نسختين من البث القابل للكتابة writable stream في مثالَي HTTP، هما كائن الاستجابة الذي يستطيع الخادم كتابته، وكائن الطلب الذي أعادته request، حيث يُستخدَم البث القابل للكتابة كثيرًا في Node، فمثل تلك الكائنات لها تابع اسمه write يُمكن تمرير سلسلة نصية إليه، أو كائن Buffer لكتابة شيء في البث؛ أما التابع end فيغلق البث ويأخذ قيمةً -بصورة اختيارية- للكتابة في البث قبل الإغلاق، ويمكن إعطاء كلا التابعَين السابقًين رد نداء على أساس وسيط إضافي، حيث يستدعيانه عند انتهاء الكتابة أو الإغلاق. يمكن إنشاء بث قابل للكتابة يشير إلى ملف باستخدام دالة createWriteStream من وحدة fs، ثم يمكنك استخدام التابع write على الكائن الناتج من أجل كتابة الملف جزءًا واحدًا في كل مرة بدلًا من كتابته على مرة واحدة كما في writeFile؛ أما البث القابل للقراءة ففيه تفصيل أكثر، فرابطة request التي مُرِّرت إلى الاستدعاء الخلفي لخادم HTTP قابلة للقراءة، وكذلك رابطة response الممررة إلى رد نداء العميل HTTP. حيث يقرأ الخادم الطلبات ثم يكتب الاستجابات، بينما يكتب العميل الطلب أولًا ثم يقرأ الاستجابة، وتتم القراءة من البث باستخدام معالجات الأحداث بدلًا من التوابع. تملك الكائنات التي تطلق الأحداث في Node تابعًا اسمه on يشبه التابع addEventListener الموجود في المتصفح، كما يمكن إعطاؤه اسم حدث ثم دالة، وسيسجل تلك الدالة لتُستدعى كلما وقع ذلك الحدث، كذلك فإن البث القابل للقراءة له حدثان هما "data" و"end"، حيث يُطلَق الأول في كل مرة تأتي بيانات فيها؛ أما الثاني فيُستدعى كلما كان البث عند نهايته، وهذا النموذج مناسب لبث البيانات التي يمكن معالجتها فورًا حتى لو كان باقي المستند غير متاح بعد، كما يُقرأ الملف على أساس بث قابل للقراءة من خلال استخدام دالة createReadStream من وحدة fs، وتنشئ الشيفرة التالية خادمًا يقرأ متون الطلبات ويبثها مرةً أخرى إلى العميل على أساس نص حروفه من الحالة الكبيرة: const {createServer} = require("http"); createServer((request, response) => { response.writeHead(200, {"Content-Type": "text/plain"}); request.on("data", chunk => response.write(chunk.toString().toUpperCase())); request.on("end", () => response.end()); }).listen(8000); ستكون القيمة chunk الممررة إلى معالج البيانات على هيئة Buffer ثنائي، ويمكن تحويل ذلك إلى سلسلة نصية بفك تشفيرها على أساس محارف UTF-8 مرمزة باستخدام التابع toString، منا ترسب الشيفرة التالية عند تشغيلها أثناء نشاط خادم الحروف الكبيرة طلبًا إلى ذلك الخادم وتكتب الاستجابة التي تحصل عليها: const {request} = require("http"); request({ hostname: "localhost", port: 8000, method: "POST" }, response => { response.on("data", chunk => process.stdout.write(chunk.toString())); }).end("Hello server"); // → HELLO SERVER يكتب المثال إلى process.stout والذي يُعَدّ خرجًا قياسيًا للعملية وبثًا قابلًا للكتابة، بدلًا من استخدام console.log، إذ لا نستطيع استخدام console.log لأنها تضيف محرف سطر جديد إضافي بعد كل جزء تكتبه من النص، وهذا ليس مناسبًا هنا بما أنّ الاستجابة قد تأتي في هيئة عدة كتل نصية. خادم الملفات نريد الآن دمج المعلومات التي عرفناها عن خوادم HTTP والعمل مع نظام الملفات لإنشاء جسر بين خادم HTTP يسمح بالوصول البعيد إلى نظام ملفات، كما يكون في مثل تلك الخوادم جميع أنواع الاستخدامات الممكنة، فهي تسمح لتطبيقات الويب بتخزين البيانات ومشاركتها، أو تعطي مجموعةً من الناس وصولًا مشتركًا إلى مجموعة ملفات، ويمكن استخدام التوابع GET وPUT وDELETE لقراءة وكتابة وحذف الملفات على الترتيب، وذلك عندما نعامل الملفات على أساس موارد HTTP، كما سنفسر المسار الذي في الطلب على أنه مسار الملف الذي يشير إليه الطلب، ولعلنا لا نريد مشاركة كل نظام الملفات الخاص بنا، لذا سنفسر تلك المسارات على أنها تبدأ في المجلد العامل للخادم وهو المجلد الذي بدأ فيه. فإذا شغّلنا الخادم من ‎/tmp/public/‎ أو على ويندوز من ‎C:\tmp\public\‎، فسيشير طلب ‎/file.txt‎ إلى ‎‎/tmp/public/file.txt أو ‎C:\tmp\public\file.txt‎ على ويندوز، كما سنبني البرنامج جزءًا جزءًا مستخدِمين الكائن methods لتخزين الدوال التي تعالج توابع HTTP المختلفة، وتكون معالجات التوابع دوال async تحصل على كائن الطلب على أساس وسيط وتعيد وعدًا يُحل إلى كائن يصف الاستجابة. const {createServer} = require("http"); const methods = Object.create(null); createServer((request, response) => { let handler = methods[request.method] || notAllowed; handler(request) .catch(error => { if (error.status != null) return error; return {body: String(error), status: 500}; }) .then(({body, status = 200, type = "text/plain"}) => { response.writeHead(status, {"Content-Type": type}); if (body && body.pipe) body.pipe(response); else response.end(body); }); }).listen(8000); async function notAllowed(request) { return { status: 405, body: `Method ${request.method} not allowed.` }; } يبدأ هذا خادمًا لا يعيد إلا استجابات خطأ 405، وهو الرمز الذي يشير إلى رفض الخادم لمعالجة التابع المعطى. يحوِّل استدعاء catch عند رفض وعد معالِج الطلب الخطأ إلى كائن استجابة إذا لم يكن هو كائن استجابة بالفعل، وذلك كي يستطيع الخادم إرسال استجابة خطأ مرةً أخرى لإخبار العميل أنه فشل في معالجة الطلب، كما يمكن إهمال حقل status في وصف الاستجابة وتكون حينئذ 200 افتراضيًا -وهي التي تعني OK-؛ أما نوع المحتوى في الخاصية type فيمكن إهماله كذلك، ويفترض حينها أنّ الاستجابة نص مجرد. حين تكون قيمة body بثًا قابلًا للقراءة فسيحتوي على التابع pipe الذي يُستخدَم لإعادة توجيه كل المحتوى من بث قابل للقراءة إلى بث قابل للكتابة، وإلا فيُفترض أنه إما null -أي لا شيء- أو سلسلةً نصيةً أو مخزنًا مؤقتًا buffer، ويُمرَّر مباشرة إلى التابع end الخاص بالاستجابة، كما تستخدِم الدالة urlPath وحدة url المضمَّنة لتحليل الرابط من أجل معرفة مسار الملف المتوافق مع رابط الطلب، وهي تأخذ اسم المسار الذي يكون شيئًا مثل ‎"/file.txt"‎ وتفك تشفيره لتتخلص من رموز التهريب التي على شاكلة ‎%20‎ وتحله نسبة إلى المجلد العامل للبرنامج. const {parse} = require("url"); const {resolve, sep} = require("path"); const baseDirectory = process.cwd(); function urlPath(url) { let {pathname} = parse(url); let path = resolve(decodeURIComponent(pathname).slice(1)); if (path != baseDirectory && !path.startsWith(baseDirectory + sep)) { throw {status: 403, body: "Forbidden"}; } return path; } يجب عليك القلق بشأن الأمان عند تهيئة وضبط البرنامج ليقبل طلبات الشبكة، ففي حالتنا من الممكن كشف كل نظام الملفات إلى الشبكة إذا لم نتوخَّ الحذر. تُعَدّ مسارات الملفات سلاسل نصية في Node، حيث يلزمنا قدر لا بأس به من التفسير interpretation لربط مثل تلك السلسلة النصية بملف حقيقي، فقد تحتوي المسارات على ‎../‎ لتشير إلى المجلد الأب، وعليه يكون أحد المصادر البدهية للمشكلة هي طلبات إلى مسارات مثل ‎‎/../secret_file، ومن أجل تجنب مثل تلك المشاكل تستخدِم urlPath الدالة resolve من وحدة path التي تحل الروابط النسبية، ثم تتحقق من كون النتيجة تحت المجلد العامل دائمًا، كما يمكن استخدام الدالة process.cwd لإيجاد ذلك المجلد العامل، حيث تشير cwd إلى المجلد العامل الحالي أو current working directory. أما رابطة sep من حزمة path فهي فاصلة مسار النظام، وهي شرطة مائلة خلفية على ويندوز وأمامية على أغلب نظم التشغيل الأخرى، فإذا لم يبدأ المسار بالمجلد الرئيسي فسترفع الدالة كائن استجابة خطأ باستخدام رمز حالة HTTP يشير إلى استحالة الوصول إلى المورد، وسنضبط التابع GET كي يعيد قائمةً من الملفات عند قراءة مجلد ويعيد محتوى الملف عند قراءة ملف عادي؛ أما السؤال الذي يطرح نفسه هنا هو نوع ترويسة Content-Type التي يجب تعيينها عند إعادة محتوى الملف، فبما أن تلك الملفات قد تكون أيّ شيء فلا يستطيع الخادم إعادة نوع المحتوى نفسه في كل مرة، ونستخدِم هنا NPM مرةً أخرى، إذ تعرف حزمة mime النوع الصحيح لعدد كبير من امتدادات الملفات، كما تسمى موضحات أنواع المحتوى مثل text/plain باسم mime، يثبّت الأمر npm أدناه إصدارًا محددًا من mime في المجلد الذي فيه سكربت الخادم: $ npm install mime@2.2.0 يعاد رمز الحالة 404 إذا لم يكن الملف المطلوب موجودًا، وسنستخدم الدالة stat التي تبحث عن معلومات تتعلق بملف ما لتعرف هل الملف موجود أم لا وهل هو مجلد أم لا. const {createReadStream} = require("fs"); const {stat, readdir} = require("fs").promises; const mime = require("mime"); methods.GET = async function(request) { let path = urlPath(request.url); let stats; try { stats = await stat(path); } catch (error) { if (error.code != "ENOENT") throw error; else return {status: 404, body: "File not found"}; } if (stats.isDirectory()) { return {body: (await readdir(path)).join("\n")}; } else { return {body: createReadStream(path), type: mime.getType(path)}; } }; تكون stat لا تزامنيةً لأنها ستحتاج أن تتعامل مع القرص وستأخذ وقتًا لذلك، وبما أننا نستخدِم الوعود بدلًا من تنسيق رد النداء فيجب استيراده من promises بدلًا من fs مباشرةً، حيث ترفع stat كائن خطأ به الخاصية code لـ "ENOENT" إذا لم يكن الملف موجودًا، وإذا بدت هذه الرموز غريبةً عليك لأول وهلة فاعلم أنها متأثرة بأسلوب نظام يونكس، كما ستجد أنواع الخطأ في Node على مثل هذه الشاكلة. يخبرنا الكائن stats الذي تعيده stat بمعلومات عديدة عن الملف مثل حجمه -أي الخاصية size- وتاريخ التعديل عليه -أي الخاصية mtime- وهل هذا الملف مجلد أم ملف عادي من خلال التابع isDirectory، كما نستخدِم readdir لقراءة مصفوفة ملفات في المجلد ونعيدها إلى العميل؛ أما بالنسبة للملفات العادية فسننشئ بثًا قابلًا للقراءة باستخدام createReadStream ونعيده على أنه المتن مع نوع المحتوى الذي تعطينا إياه الحزمة mime لاسم الملف، كما تكون الشيفرة التي تعالج طلبات DELETE أبسط قليلًا. const {rmdir, unlink} = require("fs").promises; methods.DELETE = async function(request) { let path = urlPath(request.url); let stats; try { stats = await stat(path); } catch (error) { if (error.code != "ENOENT") throw error; else return {status: 204}; } if (stats.isDirectory()) await rmdir(path); else await unlink(path); return {status: 204}; }; إذا لم تحتوي استجابة HTTP على أيّ بيانات فيمكن استخدام رمز الحالة 204 ("لا محتوى") لتوضيح ذلك، وهو الخيار المنطقي هنا بما أنّ الاستجابة للحذف لا تحتاج أيّ معلومات أكثر من تأكيد نجاح العملية، لكن لماذا نحصل على رمز حالة يفيد النجاح عند محاولة حذف ملف غير موجود أصلًا؟ أليس من المنطقي أن نحصل على خطأ؟ يرجع ذلك إلى معيار HTTP الذي يشجعنا على جعل الطلبات راسخة idempotent، مما يعني سيعطينا تكرار الطلب نفسه النتيجة نفسها التي خرجت في أول مرة، فإذا حاولنا حذف شيء ليس موجودًا فيمكن القول أنّ التأثير الذي كنا نحاول إحداثه قد وقع -وهو فعل الحذف-، فلم يَعد العنصر الذي نريد حذفه موجودًا، وكأن هدف الطلب قد تحقق كما لو كان موجودًا ثم حذفناه بطلبنا، كما تمثِّل الشيفرة التالية معالِج طلبات PUT: const {createWriteStream} = require("fs"); function pipeStream(from, to) { return new Promise((resolve, reject) => { from.on("error", reject); to.on("error", reject); to.on("finish", resolve); from.pipe(to); }); } methods.PUT = async function(request) { let path = urlPath(request.url); await pipeStream(request, createWriteStream(path)); return {status: 204}; }; لسنا في حاجة إلى التحقق من وجود الملف هذه المرة، فإذا كان موجودًا فسنكتب فوقه، ونستخدِم pipe هنا مرةً أخرى لنقل البيانات من البث القابل للقراءة إلى بث قابل للكتابة، وفي حالتنا هذه من الطلب إلى الملف، لكن بما أنّ pipe ليست مكتوبةً لتعيد وعدًا، فعلينا كتابة مغلِّف هو pipeStream الذي ينشئ وعدًا حول ناتج استدعاء pipe، كما سيعيد createWriteStream بثًا إذا حدث خطأ أثناء فتح الملف لكن سيطلق ذلك البث حدث "error"، وقد يفشل البث من الطلب كما في حالة انقطاع الشبكة، لذا فإننا نوصل الحدثين "error" لكلا البثين كي يرفضا الوعد. سيغلق بث الخرج الذي يتسبب في إطلاق الحدث "finish" عند انتهاء pipe، وهي النقطة التي يمكننا حل الوعد فيها بنجاح -أي لا نعيد شيئًا-، كما يمكن العثور على السكربت الكاملة للخادم في https://eloquentjavascript.net/code/file_server.js وهي متاحة للتحميل، وتستطيع بدء خادم الملفات الخاص بك بتحميلها وتثبيت اعتمادياتها ثم تشغيلها مع Node، كما تستطيع تعديلها وتوسيعها لحل تدريبات هذا المقالأو للتجربة، وتُستخدم أداة سطر الأوامر curl لإنشاء طلبات HTTP، وهي أداة متاحة في الأنظمة الشبيهة بنظام يونكس UNIX مثل ماك ولينكس وما شابههما، كما تختبر الشيفرة التالية خادمنا، حيث تستخدِم الخيار ‎-X لتعيين تابع الطلب و‎-d لإدراج متن الطلب. $ curl http://localhost:8000/file.txt File not found $ curl -X PUT -d hello http://localhost:8000/file.txt $ curl http://localhost:8000/file.txt hello $ curl -X DELETE http://localhost:8000/file.txt $ curl http://localhost:8000/file.txt File not found يفشل الطلب الأول إلى file.txt لأنّ الملف غير موجود بعد، لكن الطلب الثاني ينجح في جلب الملف بعد إنشاء طلب PUT لذلك الملف، ثم بعد ذلك يُفقد الملف مرةً أخرى بسبب طلب DELETE الذي يحذفه. خاتمة تسمح منصة Node لنا بتشغيل جافاسكربت في سياق خارج المتصفح، وقد صُممت أساسًا من أجل مهام الشبكات لتلعب دور عقدة -كما يشير الاسم Node- داخل شبكة ما، لكنها تتكامل جيدًا مع مهام السكربتات باختلاف أنواعها، وستستمتع بأتمتة المهام بها إذا كنت تحب جافاسكربت، كما يوفِّر NPM حزمًا لكل شيء تقريبًا ويسمح لنا بجلب وتثبيت تلك الحزم باستخدام البرنامج npm، كما تأتي Node بعدد من الوحدات المضمَّنة مثل وحدة fs التي تعمل مع نظام الملفات ووحدة http التي تشغّل خوادم HTTP وتنشئ طلبات HTTP أيضًا. تُنفَّذ جميع عمليات الإدخال والإخراج في Node بأسلوب غير متزامن إلا إذا استخدَمت نسخةً متزامنةً من الدالة صراحةً مثل readFileSync، كما يجب توفر دوال رد نداء عند استدعاء مثل تلك الدوال غير المتزامنة، وستستدعيها Node بقيمة خاطئة ونتيجة إذا كانت جاهزةً ومتاحةً. تدريبات أداة بحث توجد أداة سطر أوامر في UNIX للبحث السريع في الملفات عن تعبير نمطي وهي أداة grep. اكتب سكربت Node يمكن تشغيلها من سطر الأوامر وتتصرف مثل grep، بحيث تعامل أول وسيط سطر أوامر على أساس تعبير نمطي، وتعامل بقية الوسائط على أساس ملفات يجب البحث فيها، كما يجب أن يكون الخرج اسم الملف الذي يطابق محتواه التعبير النمطي، وإذا نجحت في هذا فوسِّع الأداة بحيث إذا كان أحد الوسائط مجلدًا فستبحث في جميع الملفات في ذلك المجلد ومجلداته الفرعية أيضًا. استخدم دوال تزامنية أو لا تزامنية وفق ما تراه مناسبًا، فرغم أنّ إعداد السكربت بحيث يمكن طلب عدة إجراءات غير متزامنة في الوقت نفسه قد يسرع البحث قليلًا، لكن ليس بالقدر الذي يكون فارقًا عن النمط التزامني بما أنّ نظم الملفات لا تستطيع قراءة أكثر من شيء واحد في كل مرة. إرشادات الحل ستجد الوسيط الأول لك -وهو التعبير النمطي- في ‎process.argv[2]‎، ثم تأتي ملفات الدخل بعد ذلك، ويمكنك استخدام الباني RegExp للتحويل من سلسلة نصية إلى كائن تعبير نمطي، ولا شك أنّ تنفيذ هذه السكربت تزامنيًا باستخدام readFileSync سيكون أبسط وأسهل، لكن إذا استخدمت fs.promises من أجل الحصول على دوال تعيد وعودًا وكتبت دالة async، فلن تبدو الشيفرة غريبةً أو مختلفةً، كما يمكنك استخدام stat أو statSync والتابع isDirectory الخاص بكائن stat لمعرفة هل العنصر المبحوث عنه مجلد أم لا. تُعَدّ عملية تصفح مجلد عمليةً متفرعةً، حيث يمكنك تنفيذها باستخدام دالة تعاودية أو بالاحتفاظ بمصفوفة عمل -أي ملفات يجب تصفحها-.، كما تستطيع استدعاء readdir أو readdirSync للبحث عن ملفات في مجلد ما، وعليك ملاحظة أنّ أسلوب التسمية في دوال Node يختلف عن جافاسكربت وهو أقرب إلى أسلوب دوال يونكس القياسية، كما في readdir التي تكون كل الحروف فيها من الحالة الصغيرة، ثم نضيف Sync بحرف S كبير، وإذا أردت الذهاب من ملف قرأته readdir إلى الاسم الكامل للسمار، فيجب جمعه إلى اسم المجلد بوضع محرف شرطة مائلة / بينهما. إنشاء المجلد رغم استطاعة التابع DELETE الذي في خادم ملفاتنا حذف المجلدات باستخدام rmdir إلا أنّ الخادم لا يدعم حاليًا أي طريقة لإنشاء مجلد، لذا أضف دعمًا للتابع MKCOL -الذي يعني أنشئ تجميعةً Make Collection-، والذي سينشئ مجلدًا باستدعاء mkdir من وحدة fs. لا يُستخدم MKCOL -وهو تابع HTTP- كثيرًا لكنه موجود لمثل هذا الغرض تحديدًا في معيار WebDAV الذي يحدِّد مجموعةً من الأساليب فوق HTTP لتجعله مناسبًا لإنشاء المستندات. إرشادات الحل يمكنك استخدام الدالة التي تستخدِم التابع DELETE على أساس نموذج للتابع MKCOL، وحاول إنشاء مجلد باستخدام mkdir إذا لم يُعثر على ملف؛ أما إذا وجد مجلد في ذلك المسار فأعد الاستجابة 204 كي تكون طلبات إنشاء المجلدات راسخةً idempotent، فإذا وجد ملف لا يكون مجلدًا هنا فأعد رسالة خطأ، وسيكون رمز الخطأ 400 -أي "طلب سيء bad request"- هو المناسب. مساحة عامة على الويب بما أن خادم الملفات يتعامل مع أيّ نوع من أنواع الملفات، بل ويدرِج ترويسة Content-Type المناسبة، فيمكنك استخدامه لخدمة موقع ما، كما سيكون موقعًا فريدًا بما أنه يسمح لأيّ أحد بحذف الملفات واستبدالها، حيث سيكون موقعًا يمكن تعديله وتحسينه وتخريبه كذلك من قِبل أيّ أحد لديه وقت لإنشاء طلب HTTP مناسب. اكتب صفحة HTML تدرِج ملف جافاسكربت بسيط، وضَع الملفات في مجلد يستطيع خادم الملفات الوصول إليه ويخدمه وافتحها في المتصفح، ثم ابن واجهةً صديقةً للمستخدِم لتعديل الموقع من داخل الموقع نفسه مستفيدًا من المعلومات التي حصلتها في هذه السلسلة وعلى أساس تدريب متقدم قليلًا أو حتى على أساس مشروع لنهاية الأسبوع،. استخدم استمارة HTML لتعديل محتوى الملفات التي تكوّن الموقع بما يسمح للمستخدِم بتحديثها على الخادم من خلال استخدام طلبات HTTP كما ذكرنا في مقال HTTP والاستمارات في جافاسكربت، وابدء بجعل ملف واحد فقط قابلًا للتعديل ثم أكثر من ملف بحيث يستطيع المستخدِم اختيار أيّ ملف يمكن تعديله، واستفد من كون خادم الملفات يعيد قائمةً من الملفات عند قراءة مجلد ما، كما لا تعمل في الشيفرة المعروضة لخادم الملفات مباشرةً بما أنك قد تخطئ فتدمِّر الملفات التي هناك، بل اجعل عملك خارج المجلد العام وانسخه عند الاختبار. إرشادات الحل تستطيع إنشاء عنصر <textarea> لحفظ محتوى الملف الذي يُعدَّل، ويمكن جلب محتوى الملف الحالي باستخدام GET الذي يستخدِم fetch، كما تستطيع استخدام الروابط النسبية مثل index.html بدلًا من http://localhost:8000/index.html للإشارة إلى الملفات التي على الخادم نفسه الذي عليه السكربت العاملة، وإذا نقر المستخدِم على زر ما -حيث يمكنك استخدام العنصر <form> والحدث "submit" لذلك- فأنشئ طلب PUT إلى الرابط نفسه بمحتوى <textarea> على أساس متن للطلب من أجل حفظ الملف. يمكنك بعد ذلك إضافة العنصر <select> الذي يحتوي على جميع الملفات في المجلد الأعلى للخادم بإضافة عناصر <option> التي تحتوي الأسطر المعادة بواسطة طلب GET إلى الرابط /، وإذا اختار المستخدِم ملفًا آخرًا -أي الحدث "change" على ذلك الملف-، فيجب على السكربت جلب ذلك الملف وعرضه، ومن أجل حفظ ملف ما استخدِم اسم الملف المحدد حاليًا. ترجمة -بتصرف- للفصل العشرين من كتاب Elequent Javascript لصاحبه Marijn Haverbeke. اقرأ أيضًا المقال السابق: إنجاز مشروع محرر رسوم نقطية باستخدام جافاسكربت كيفية استكشاف وإصلاح رموز أخطاء HTTP الشائعة أفعال المتصفح الافتراضية للأحداث وضبطها عبر جافاسكربت التعامل مع نوافذ المتصفح والنوافذ المنبثقة Popups في جافاسكربت
  4. سنبني في هذا المقال تطبيق ويب بناءً على ما درسناه في المقالات السابقة وسيكون هذا التطبيق للرسم بأسلوب البكسلات، حيث يمكنك أخذ رؤية مكبرة من الصورة المنفردة وتغيير أو تعديل كل بكسل فيها، كما يمكنك فتح الصورة بالبرنامج وتعديلها بالفأرة أو أيّ أداة تأشير أخرى ثم حفظها، حيث سيبدو البرنامج هكذا: المكونات تُظهر واجهة البرنامج عنصر <canvas> كبيرًا في الأعلى مع عدد من حقول الاستمارات form fields أسفله، ويرسم المستخدِم على الصورة عبر اختيار أداة من حقل <select> ثم ينقر عليه أو يدهن أو يسحب المؤشر في لوحة الرسم، كما هناك أدوات لرسم بكسلات منفردة أو مستطيلات ولملء مساحة ما باللون ولالتقاط لون ما من الصورة. سنبني هيكل المحرِّر على أساس عدد من المكونات والكائنات التي تكون مسؤولة عن جزء من تمثيل كائن المستند DOM وقد تحتوي مكونات أخرى داخلها، كما تتكون حالة التطبيق من الصورة الحالية والأداة المختارة واللون المختار كذلك، حيث سنضبط هذه المتغيرات كي تكون الحالة داخل قيمة واحدة، كما تبني مكونات الواجهة مظهرها دائمًا على الحالة الراهنة. دعنا ننظر في بديل ذلك كي نرى أهميته من خلال توزيع أجزاء من الحالة على الواجهة، وهذا سهل على البرنامج حتى نقطة ما، حيث نستطيع وضع حقل اللون ونقرأ قيمته عندما نريد معرفة اللون الحالي، لكن نضيف هنا منتقي الألوان color picker الذي يُعَدّ الأداة التي تسمح لك بالنقر على الصورة لاختيار اللون من بكسل ما، ولكي يظل حقل اللون مُظهرًا اللون الصحيح يجب على هذه الأداة معرفة أنه موجود وتحدِّثه كلما اختارت لونًا جديدًا، فإذا أضفت مكانًا آخرًا يجعل اللون مرئيًا بحيث يستطيع مؤشر الفأرة إظهاره مثلًا، فعليك تحديث شيفرة تغيير اللون لديك كي تبقى متزامنة. لكن في الواقع يخلق هذا مشكلةً بحيث يحتاج كل جزء من الواجهة إلى معرفة جميع الأجزاء الأخرى، وهذا ليس عمليًا إذ سيجعل البرنامج أقل مرونة في التعديل عليه، لكن لا يمثِّل هذا مشكلةً بالنسبة لبرنامج صغير مثل برنامجنا؛ أما في المشاريع الكبيرة فسيكون هذا كارثة حقيقية، ولكي نتجنب هذا الكابوس وإن كان من حيث المبدأ في مثالنا فسنكون حازمين بشأن تدفق البيانات، فهناك حالة تُرسم الواجهة وفقًا لها، وقد يستجيب مكوِّن الواجهة لإجراءات المستخدِم عبر تحديث الحالة، بحيث تحصل المكونات عندئذ على فرصة لمزامنة أنفسها مع هذه الحالة الجديدة. ضُبِط كل مكون من الناحية العملية ليُخطر عناصره الفرعية بإشعار كلما أُعطي حالةً جديدةً بالقدر الذي تحتاج إليه، لكن يُعَدّ ضبط ذلك أمرًا متعبًا، كما تفضِّل المتصفحات كثيرًا المكتبات البرمجية التي تسهل ذلك، لكن نستطيع إنشاء برنامج صغير مثل هذا بدون هذه البنية التحتية، كما تمثَّل التحديثات على الحالة على أساس كائنات سنطلق عليها إجراءات، وقد تنشئ المكونات مثل هذه الإجراءات وترسلها بسرعة إلى دالة مركزية لإدارة الحالة، حيث تحسب هذه الدالة الحالة التالية ثم تحدِّث مكونات الواجهة أنفسها إليها. نأخذ بهذا مهمة تشغيل واجهة المستخدِم ونضيف إليها بعض الهيكلة، كما تحتفظ الأجزاء المتعلقة بـ DOM بما يشبه العمود الفقري رغم أنها ملأى بالآثار الجانبية، إذ يُعَدّ هذا العمود دورة تحديث الحالة، كما تحدِّد الحالة كيف سيبدو DOM، ولا توجد طريقة تستطيع أحداث DOM تغيير الحالة بها إلا عبر إرسال الإجراءات إلى الحالة، كما توجد هناك صور عدة لهذا المنظور ولكل منها منافعه ومساوئه، لكن الفكرة الرئيسية لها واحدة وهي أنّ تغيرات الحالة يجب عليها المرور على قناة واحدة معرَّفة جيدًا بدلًا من كونها في كل مكان. ستكون مكوناتنا أصنافًا مطابقةً للواجهة، ويُعطى بانيها حالةً قد تكون حالة البرنامج كله أو قيمةً أصغر إذا لم يحتج الوصول إلى كل شيء، حيث يستخدِم هذا في بناء خاصية dom، ويُعَدّ هذا عنصر DOM الذي سيمثِّل المكوِّن، كما ستأخذ أغلب المنشئات قيمًا أخرى أيضًا لا تتغير مع الوقت مثل الدالة التي تستطيع استخدامها لإرسال إجراء ما، ويملك كل مكوِّن تابعًا syncState يُستخدَم لمزامنته مع قيمة الحالة الجديدة، حيث يأخذ التابع وسيطًا واحدًا وهو الحالة التي تكون من نوع الوسيط الأول نفسه لبانيها. الحالة ستكون حالة التطبيق كائنًا له الخاصيات picture وtool وcolor، كما ستكون الصورة نفسها كائنًا يخزِّن العرض والارتفاع ومحتوى البكسل للصورة، في حين تُخزَّن البكسلات في مصفوفة ثنائية عبر طريقة صنف المصفوفة matrix نفسها من مقال الحياة السرية للكائنات في جافاسكريبت صفًا صفًا من الأعلى حتى الأسفل. class Picture { constructor(width, height, pixels) { this.width = width; this.height = height; this.pixels = pixels; } static empty(width, height, color) { let pixels = new Array(width * height).fill(color); return new Picture(width, height, pixels); } pixel(x, y) { return this.pixels[x + y * this.width]; } draw(pixels) { let copy = this.pixels.slice(); for (let {x, y, color} of pixels) { copy[x + y * this.width] = color; } return new Picture(this.width, this.height, copy); } } نريد التمكّن من معاملة الصورة على أنها قيمة غير قابلة للتغير immutable لأسباب سنعود إليها لاحقًا في هذا المقال، لكن قد نحتاج أحيانًا إلى تحديث مجموعة بكسلات في الوقت نفسه أيضًا، ولكي نفعل ذلك فإن الصنف له تابع draw يتوقع مصفوفةً من البكسلات المحدَّثة، إذ تكون كائنات لها خاصيات x وy وcolor، كما ينشئ صورةً جديدةً مغيّرًا بها هذه البكسلات، ويستخدِم ذلك التابع slice دون وسائط لنسخ مصفوفة البسكلات كلها، بحيث تكون البداية الافتراضية لـ slice هي 0 والنهاية الافتراضية هي طول المصفوفة. يستخدِم التابع empty جزأين من وظائف المصفوفة لم نرهما من قبل، فيمكن استدعاء باني Array بعدد لإنشاء مصفوفة فارغة بطول محدَّد، ثم يمكن استخدام التابع fill بعدها لملء هذه المصفوفة بقيمة معطاة، وتُستخدَم هذه الأجزاء لإنشاء مصفوفة تحمل كل البكسلات فيه اللون نفسه. تُخزن الألوان على أساس سلاسل نصية تحتوي على رموز ألوان CSS العادية، وهي التي تبدأ بعلامة الشباك # متبوعة بستة أرقام ست-عشرية، بحيث يكون اثنان فيها للمكون الأحمر واثنان للأخضر واثنان للأزرق، وقد يكون هذا مبهمًا نوعًا ما، لكنها الصيغة التي تستخدمِها HTML في حقول ألوانها، حيث يمكن استخدامها في خاصية fillStyle لسياق لوحة رسم، وهي كافية لنا في هذا البرنامج، كما يُكتب اللون الأسود أصفارًا على الصورة ‎#000000، ويبدو اللون الوردي الزاهي هكذا ‎#ff00ff بحيث تكون مكونات اللونين الأحمر والأزرق لها القيمة العظمى عند 255، فتُكتب ff بالنظام الست-عشري الذي يستخدِم a حتى f لتمثيل الأعداد من 10 حتى 15. سنسمح للواجهة بإرسال الإجراءات على أساس كائنات لها خاصيات تتجاوز خاصيات الحالة السابقة، ويرسل حقل اللون كائنًا حين يغيره المستخدِم مثل {color: field.value} تحسِب منه دالة التحديث حالةً جديدةً. function updateState(state, action) { return Object.assign({}, state, action); } يُعَدّ استخدام Object.assign لإضافة خصائص state إلى كائن فارغ أولًا ثم تجاوز بعضها بخصائص من action، استخدامًا شائعًا في شيفرات جافاسكربت التي تستخدِم كائنات غير قابلة للتغير على صعوبته في التعامل معه، والأسهل من ذلك استخدام عامِلًا ثلاثي النقاط لتضمين جميع الخصائص من كائن آخر في تعبير الكائن، وذلك لا زال بعد في مراحل اعتماده الأخيرة، وإذا تم فسوف تستطيع كتابة ‎{...state, ...action}‎ بدلًا من ذلك، لكن هذا لم بثبت عمله بعد في جميع المتصفحات. بناء DOM أحد الأمور التي تفعلها مكونات الواجهة هي إنشاء هيكل DOM، كما سنقدم نسخة موسعة قليلًا من دالة elt لأننا لا نريد استخدام توابع DOM في ذلك: function elt(type, props, ...children) { let dom = document.createElement(type); if (props) Object.assign(dom, props); for (let child of children) { if (typeof child != "string") dom.appendChild(child); else dom.appendChild(document.createTextNode(child)); } return dom; } الفرق الأساسي بين هذه النسخة والتي استخدمناها في مقال مشروع لعبة منصة باستخدام جافاسكربت أنها تسند خاصيات إلى عقد DOM وليس سمات، ويعني هذا أننا لا نستطيع استخدامها لضبط سمات عشوائية، لكن نستطيع استخدامها لضبط خاصيات قيمها ليست سلاسل نصية مثل onclick والتي يمكن تعيينها إلى دالة لتسجيل معالج حدث نقرة، وهذا يسمح بالنمط التالي من تسجيل معالِجات الأحداث: <body> <script> document.body.appendChild(elt("button", { onclick: () => console.log("click") }, "The button")); </script> </body> اللوحة Canvas يُعَدّ جزء الواجهة الذي يعرض الصورة على أساس شبكة من الصناديق المربعة المكوّن الأول الذي سنعرِّفه، وهذا الجزء مسؤول عن أمرين فقط هما عرض showing الصورة وتوصيل أحداث المؤشر التي على هذه الصورة إلى بقية التطبيق، وبالتالي يمكننا تعريفه على أساس مكوِّن لا يطلع إلا على الصورة الحالية وليس له شأن بحالة التطبيق كاملًا، كما لا يستطيع إرسال إجراءات مباشرةً لأنه لا يعرف كيف يعمل التطبيق، وأنما يستدعي دالة رد نداء callback function توفرها الشيفرة التي أنشأته حين يستجيب لأحداث المؤشر بحيث تعالِج أجزاء التطبيق على حدة. const scale = 10; class PictureCanvas { constructor(picture, pointerDown) { this.dom = elt("canvas", { onmousedown: event => this.mouse(event, pointerDown), ontouchstart: event => this.touch(event, pointerDown) }); this.syncState(picture); } syncState(picture) { if (this.picture == picture) return; this.picture = picture; drawPicture(this.picture, this.dom, scale); } } سنرسم كل بكسل على أساس مربع بعداه 10*10 كما هو مُحدَّد في ثابت scale، ثم يحتفظ المكوِّن بصورته الحالية ولا يعيد الرسم إلا حين تُعطى syncState صورةً جديدةً، كما تضبط دالة الرسم الفعلية حجم اللوحة وفقًا لمقياس الصورة وحجمها، ثم تملؤها بسلسلة من المربعات يمثِّل كل منها بكسلًا واحدًا. function drawPicture(picture, canvas, scale) { canvas.width = picture.width * scale; canvas.height = picture.height * scale; let cx = canvas.getContext("2d"); for (let y = 0; y < picture.height; y++) { for (let x = 0; x < picture.width; x++) { cx.fillStyle = picture.pixel(x, y); cx.fillRect(x * scale, y * scale, scale, scale); } } } إذا ضغطنا زر الفأرة الأيسر أثناء وجود الفأرة فوق لوحة الصورة فيستدعي المكون رد نداء pointerDown ليعطيه موضع البكسل الذي تم النقر عليه في إحداثيات الصورة، حيث سيُستخدم هذا لتنفيذ تفاعل الفأرة مع الصورة، وقد يعيد رد النداء دالة رد نداء أخرى لتُنبَّه بإشعار حين يتحرك المؤشر إلى بكسل آخر أثناء الضغط على الزر. PictureCanvas.prototype.mouse = function(downEvent, onDown) { if (downEvent.button != 0) return; let pos = pointerPosition(downEvent, this.dom); let onMove = onDown(pos); if (!onMove) return; let move = moveEvent => { if (moveEvent.buttons == 0) { this.dom.removeEventListener("mousemove", move); } else { let newPos = pointerPosition(moveEvent, this.dom); if (newPos.x == pos.x && newPos.y == pos.y) return; pos = newPos; onMove(newPos); } }; this.dom.addEventListener("mousemove", move); }; function pointerPosition(pos, domNode) { let rect = domNode.getBoundingClientRect(); return {x: Math.floor((pos.clientX - rect.left) / scale), y: Math.floor((pos.clientY - rect.top) / scale)}; } بما أننا نعرف حجم البكسلات ونستطيع استخدام getBoundingClientRect في إيجاد موضع اللوحة على الشاشة، فمن الممكن الذهاب من إحداثيات حدث الفأرة clientX وclientY إلى إحداثيات الصورة، وتُقرَّب هذه دومًا كي تشير إلى بكسل بعينه؛ أما بالنسبة لأحداث اللمس فيتوجب علينا فعل شيء قريب من ذلك لكن باستخدام أحداث مختلفة والتأكد أننا نستدعي preventDefault على حدث "touchstart" لمنع التمرير العمودي أو الأفقي panning. PictureCanvas.prototype.touch = function(startEvent, onDown) { let pos = pointerPosition(startEvent.touches[0], this.dom); let onMove = onDown(pos); startEvent.preventDefault(); if (!onMove) return; let move = moveEvent => { let newPos = pointerPosition(moveEvent.touches[0], this.dom); if (newPos.x == pos.x && newPos.y == pos.y) return; pos = newPos; onMove(newPos); }; let end = () => { this.dom.removeEventListener("touchmove", move); this.dom.removeEventListener("touchend", end); }; this.dom.addEventListener("touchmove", move); this.dom.addEventListener("touchend", end); }; لا تكون أحداث clientX وclientY متاحةً مباشرةً لأحداث اللمس على كائن الحدث، لكن نستطيع استخدام إحداثيات كائن اللمس الأول في خاصية touches. التطبيق سننفذ المكون الأساسي على أساس صدَفة حول لوحة الصورة كي نبني التطبيق جزءًا جزءًا مع مجموعة من الأدوات والمتحكمات التي نمررها لبانيه، كما ستكون المتحكمات عناصر الواجهة التي ستظهر تحت الصورة، وستكون متاحةً في صورة مصفوفة من بواني المكونات. تفعل الأدوات مهامًا مثل رسم البكسلات أو ملء مساحة ما، ويعرض التطبيق مجموعةً من الأدوات المتاحة مثل حقل <select>، كما تحدِّد الأداة المختارة حاليًا ما يحدث عندما يتفاعل المستخدِم مع الصورة بأداة تأشير مثل الفأرة، وتوفَّر مجموعة من الأدوات المتاحة على أساس كائن ينظِّم الأسماء التي تظهر في الحقل المنسدل للدوال التي تنفِّذ الأدوات، كما تحصل مثل هذه الدوال على موضع الصورة وحالة التطبيق الحالية ودالة dispatch في هيئة وسائط، وقد تعيد دالة معالِج حركة move handler تُستدعى مع موضع جديد وحالة حالية عندما يتحرك المؤشر إلى بكسل جديد. class PixelEditor { constructor(state, config) { let {tools, controls, dispatch} = config; this.state = state; this.canvas = new PictureCanvas(state.picture, pos => { let tool = tools[this.state.tool]; let onMove = tool(pos, this.state, dispatch); if (onMove) return pos => onMove(pos, this.state); }); this.controls = controls.map( Control => new Control(state, config)); this.dom = elt("div", {}, this.canvas.dom, elt("br"), ...this.controls.reduce( (a, c) => a.concat(" ", c.dom), [])); } syncState(state) { this.state = state; this.canvas.syncState(state.picture); for (let ctrl of this.controls) ctrl.syncState(state); } } يستدعي معالج المؤشر المعطى لـ PictureCanvas الأداة المختارة حاليًا باستخدام الوسائط المناسبة، وإذا أعاد معالج حركة فسيكيّفه ليستقبل الحالة، وتُنشأ جميع المتحكمات وتُخزَّن في this.controls كي يمكن تحديثها حين تتغير حالة التطبيق، ويدخل استدعاء reduce مسافات بين عناصر متحكمات DOM كي لا تبدو هذه العناصر مكثَّفة بجانب بعضها، كما تُعَدّ قائمة اختيار الأدوات أول متحكم، وتنشئ عنصر <select> مع خيار لكل أداة وتضبط معالِج حدث "change" الذي يحدِّث حالة التطبيق حين يختار المستخدِم أداةً مختلفةً. class ToolSelect { constructor(state, {tools, dispatch}) { this.select = elt("select", { onchange: () => dispatch({tool: this.select.value}) }, ...Object.keys(tools).map(name => elt("option", { selected: name == state.tool }, name))); this.dom = elt("label", null, "? Tool: ", this.select); } syncState(state) { this.select.value = state.tool; } } حين نغلِّف نص العنوان label text والحقل داخل عنصر <label> فإننا نخبر المتصفح أن العنوان ينتمي إلى هذا الحقل كي تستطيع النقر مثلًا على العنوان لتنشيط الحقل، كذلك نحتاج إلى إمكانية تغيير اللون، لذا سنضيف متحكمًا لهذا وهو عنصر <input> من HTML مع سمة type لـ color، بحيث تعطينا حقل استمارة مخصص لاختيار الألوان، وقيمةً مثل هذا الحقل تكون دائمًا رمز لون CSS بصيغة ‎"#RRGGBB"‎ أي الأحمر ثم الأخضر ثم الأزرق بمعنى رقمين لكل لون، وسيعرض المتصفح واجهة مختار الألوان color picker عندما يتفاعل المستخدِم معها، كما ينشئ هذا المتحكم مثل ذلك الحقل ويربطه ليكون متزامنًا مع خاصية color الخاصة بحالة التطبيق. class ColorSelect { constructor(state, {dispatch}) { this.input = elt("input", { type: "color", value: state.color, onchange: () => dispatch({color: this.input.value}) }); this.dom = elt("label", null, "? Color: ", this.input); } syncState(state) { this.input.value = state.color; } } أدوات الرسم نحتاج قبل رسم أي شيء إلى تنفيذ الأدوات التي ستتحكم في وظائف الفأرة وأحداث اللمس على اللوحة، وأبسط أداة هي أداة الرسم التي تغير أيّ بكسل تنقر عليه أو تلمسه بإصبعك إلى اللون الحالي، وترسل إجراءً يحدِّث الصورة إلى إصدار يُعطى فيه البكسل المشار إليه اللون المختار الحالي. function draw(pos, state, dispatch) { function drawPixel({x, y}, state) { let drawn = {x, y, color: state.color}; dispatch({picture: state.picture.draw([drawn])}); } drawPixel(pos, state); return drawPixel; } تستدعي الدالة فورًا دالة drawPixel ثم تعيدها كي تُستدعى مرةً أخرى من أجل البكسلات التي ستُلمَس لاحقًا حين يسحب المستخدِم إصبعه أو يمرره على الصورة، ولكي نرسم أشكالًا أكبر فمن المفيد إنشاء مستطيلات بسرعة، كما ترسم أداة rectangle مستطيلًا بين النقطة التي تبدأ السحب منها حتى النقطة التي تترك فيها المؤشر أو ترفع إصبعك. function rectangle(start, state, dispatch) { function drawRectangle(pos) { let xStart = Math.min(start.x, pos.x); let yStart = Math.min(start.y, pos.y); let xEnd = Math.max(start.x, pos.x); let yEnd = Math.max(start.y, pos.y); let drawn = []; for (let y = yStart; y <= yEnd; y++) { for (let x = xStart; x <= xEnd; x++) { drawn.push({x, y, color: state.color}); } } dispatch({picture: state.picture.draw(drawn)}); } drawRectangle(start); return drawRectangle; } هناك تفصيل مهم في هذا التنفيذ، وهو أنك حين تسحب المؤشر سيعاد رسم المستطيل على الصورة من الحالة الأصلية، وهكذا تستطيع جعل المستطيل أكبر أو أصغر مرةً أخرى أثناء إنشائه دون مستطيل وسيط يتبقى في الصورة النهائية، وهذا أحد الأسباب التي تجعل كائنات الصورة غير القابلة للتغيّر مفيدةً، كما سننظر في سبب آخر لاحقًا، وسيكون تنفيذ مهمة ملء اللون أكثر تفصيلًا، إذ هي أداة تملأ البكسل الذي تحت المؤشر والبكسلات المجاورة له التي لها اللون نفسه، وإنما نعني بالمجاورة له تلك البكسلات المجاورة رأسيًا أو عموديًا مباشرةً وليس البكسلات المجاورة قطريًا له، كما توضِّح الصورة التالية مجموعة بكسلات تُلوَّن باستخدام أداة الملء على البكسل الذي يحمل النقطة الحمراء. من المثير أن الطريقة التي نفعل بها ذلك تشبه شيفرة الاستكشاف التي تعرضنا لها في مقال مشروع تطبيقي لبناء رجل آلي (روبوت) عبر جافاسكريبت، حيث بحثت تلك الشيفرة في مخطط لإيجاد طريق ما للروبوت، وتبحث هذه الشيفرة في شبكة لإيجاد كل البكسلات المرتبطة ببعضها بعضًا، لكن مشكلة تتبع مجموعة الفروع الممكنة مشابهةً هنا. const around = [{dx: -1, dy: 0}, {dx: 1, dy: 0}, {dx: 0, dy: -1}, {dx: 0, dy: 1}]; function fill({x, y}, state, dispatch) { let targetColor = state.picture.pixel(x, y); let drawn = [{x, y, color: state.color}]; for (let done = 0; done < drawn.length; done++) { for (let {dx, dy} of around) { let x = drawn[done].x + dx, y = drawn[done].y + dy; if (x >= 0 && x < state.picture.width && y >= 0 && y < state.picture.height && state.picture.pixel(x, y) == targetColor && !drawn.some(p => p.x == x && p.y == y)) { drawn.push({x, y, color: state.color}); } } } dispatch({picture: state.picture.draw(drawn)}); } تتصرف مصفوفة البكسلات المرسومة على أساس قائمة العمل للدالة، فيجب علينا من أجل كل بكسل نصل إليه رؤية إذا كان أيّ بكسل مجاور له يحمل اللون نفسه ولم يُدهن مسبقًا، وتتأخر حلقة العد التكرارية خلف طول مصفوفة drawn بسبب إضافة البكسلات الجديدة، كما سيحتاج أيّ بكسل يسبقها إلى استكشافه، وحين تلحق بالطول فستكون كل البكسلات قد استُكشِفت وقد أتمت الدالة عملها؛ أما الأداة النهائية فهي مختار الألوان color picker الذي يسمح لك بالإشارة إلى لون في الصورة لاستخدامه على أساس لون الرسم الحالي. function pick(pos, state, dispatch) { dispatch({color: state.picture.pixel(pos.x, pos.y)}); } نستطيع الآن اختبار التطبيق. <div></div> <script> let state = { tool: "draw", color: "#000000", picture: Picture.empty(60, 30, "#f0f0f0") }; let app = new PixelEditor(state, { tools: {draw, fill, rectangle, pick}, controls: [ToolSelect, ColorSelect], dispatch(action) { state = updateState(state, action); app.syncState(state); } }); document.querySelector("div").appendChild(app.dom); </script> الحفظ والتحميل لا شك أننا حين نرسم الصورة الخاصة بنا سنود حفظها لاحقًا، كما يجب إضافة زر لتحميل الصورة الحالية على أساس ملف صورة، حيث يوفر المتحكم التالي هذا الزر: class SaveButton { constructor(state) { this.picture = state.picture; this.dom = elt("button", { onclick: () => this.save() }, "? Save"); } save() { let canvas = elt("canvas"); drawPicture(this.picture, canvas, 1); let link = elt("a", { href: canvas.toDataURL(), download: "pixelart.png" }); document.body.appendChild(link); link.click(); link.remove(); } syncState(state) { this.picture = state.picture; } } يتتبّع المكون الصورة الحالية ليستطيع الوصول إليها عند الحفظ، كما يستخدِم عنصر <canvas> لإنشاء ملف الصورة والذي يرسم الصورة على مقياس بكسل واحد لكل بكسل، في حين ينشئ التابع toDataURL الذي على عنصر اللوحة رابطًا يبدأ بـ ‎data:‎ على عكس الروابط التي تبدأ ببروتوكولات http:‎ وhttps:‎ العادية، تحتوي هذه الروابط على المصدر كاملًا في الرابط، كما تكون طويلةً جدًا لهذا، لكنه يسمح لنا بإنشاء روابط عاملة إلى صور عشوائية من داخل المتصفح. ننشئ عنصر رابط للوصول إلى المتصفح وتحميل الصورة يشير إلى هذا الرابط وله سمة download، وعندما يُنقَر على مثل هذه الروابط فستجعل المتصفح يعرض صندوقًا حواريًا لحفظ الملف، كما نضيف ذلك الرابط إلى المستند ونحاكي النقر عليه ثم نحذفه مرةً أخرى، وهكذا تستطيع فعل الكثير والكثير باستخدام التقنيات المتاحة في المتصفح لكن قد تبدو بعض هذه التقنيات غريبةً، بل إذا أردنا أن نكون قادرين على تحميل ملفات صورة موجودة إلى تطبيقنا، فسنحتاج إلى تعريف مكوِّن لزر، أي كما في المثال التالي: class LoadButton { constructor(_, {dispatch}) { this.dom = elt("button", { onclick: () => startLoad(dispatch) }, "? Load"); } syncState() {} } function startLoad(dispatch) { let input = elt("input", { type: "file", onchange: () => finishLoad(input.files[0], dispatch) }); document.body.appendChild(input); input.click(); input.remove(); } إذا أردنا الوصول إلى ملف في حاسوب المستخدِم، فسيكون على المستخدِم اختيار الملف من حقل إدخال الملف، لكنّا لا نريد أن يبدو زر التحميل مثل حقل إدخال ملف، لذا سننشئ إدخال الملف عندما يُنقر على الزر ونتظاهر حينها أنّ إدخال الملف ذاك قد نُقر عليه، فإذا اختار المستخدِم ملفًا، فسنستطيع استخدام FileReader للوصول إلى محتوياته في صورة رابط بيانات كما ذكرنا قبل قليل، ويمكن استخدام هذا الرابط لإنشاء عنصر <img>، لكن بسبب أننا لا نستطيع الوصول مباشرةً إلى البكسلات في مثل هذه الصورة فلا نستطيع إنشاء كائن Picture منها. function finishLoad(file, dispatch) { if (file == null) return; let reader = new FileReader(); reader.addEventListener("load", () => { let image = elt("img", { onload: () => dispatch({ picture: pictureFromImage(image) }), src: reader.result }); }); reader.readAsDataURL(file); } يجب علينا رسم الصورة أولًا في عنصر <canvas> كي نصل إلى البكسلات، كما يملك سياق اللوحة canvas التابع getImageData الذي يسمح للسكربت قراءة بكسلاتها، لذا بمجرد أن تكون الصورة على اللوحة يمكننا الوصول إليها وبناء كائن Picture. function pictureFromImage(image) { let width = Math.min(100, image.width); let height = Math.min(100, image.height); let canvas = elt("canvas", {width, height}); let cx = canvas.getContext("2d"); cx.drawImage(image, 0, 0); let pixels = []; let {data} = cx.getImageData(0, 0, width, height); function hex(n) { return n.toString(16).padStart(2, "0"); } for (let i = 0; i < data.length; i += 4) { let [r, g, b] = data.slice(i, i + 3); pixels.push("#" + hex(r) + hex(g) + hex(b)); } return new Picture(width, height, pixels); } سنحدّ من حجم الصور إلى أن تكون 100 * 100 بكسل، بما أن أي شيء أكبر من هذا سيكون أكبر من أن يُعرض على الشاشة وقد يبطئ الواجهة، كما تكون خاصية data الخاصة بالكائن الذي يعيده getImageData مصفوفةً من مكونات الألوان، إذ تحتوي على أربع قيم لكل بكسل في المستطيل الذي تحدده الوسائط، حيث تمثل مكونات البكسل اللونية من الأحمر والأخضر والأزرق والشفافية alpha، كما تكون هذه المكونات أرقامًا تتراوح بين الصفر و255، ويعني الصفر في خانة الألفا أنه شفاف تمامًا و255 أنه مصمت، لكن سنتجاهل هذا في مثالنا إذ لا يهمنا كثيرًا. يتوافق كل رقمين ست-عشريين لكل مكوِّن مستخدَم في ترميزنا للألوان توافقًا دقيقًا للمجال الذي يتراوح بين الصفر و255، حيث يستطيع هذان الرقمان التعبير عن ‎162‎ = 256 عددًا، كما يمكن إعطاء القاعدة إلى التابع toString الخاص بالأعداد على أساس وسيط كي ينتج n.toString(16)‎ تمثيلًا من سلسلة نصية في النظام الست عشري، ويجب التأكد من أنّ كل عدد يأخذ رقمين فقط، لذلك فإن الدالة المساعِدة hex تستدعي padStart لإضافة صفر بادئ عند الحاجة، ونستطيع الآن التحميل والحفظ ولم يبق إلا ميزة إضافية واحدة. سجل التغييرات Undo History ستكون نصف عملية التعديل على الصور بارتكاب أخطاء صغيرة بين الحين والآخر ثم تصحيحها، لذا من المهم لنا وجود سجل للخطوات التي ينفذها المستخدِم كي يستطيع العودة إليها وتصحيح ما يريده، حيث سنحتاج لهذا تخزين النسخ السابقة من الصورة، وهو أمر يسير بما أنها قيمة غير قابلة للتغيّر لكنها تحتاج حقلًا إضافيًا داخل حالة التطبيق. سنضيف مصفوفة done للحفاظ على النسخ السابقة من الصورة، كما سيتطلب الحفاظ على هذه الخاصية دالةً معقدةً لتحديث الحالة بحيث تضيف الصورة إلى المصفوفة، لكن لا نريد تخزين كل تغيير يحدث، وإنما التغييرات التي تحدث كل فترة زمنية محدَّدة، لذا سنحتاج إلى خاصية ثانية هي doneAt تتتبّع آخر وقت حفظنا فيه صورة في السجل. function historyUpdateState(state, action) { if (action.undo == true) { if (state.done.length == 0) return state; return Object.assign({}, state, { picture: state.done[0], done: state.done.slice(1), doneAt: 0 }); } else if (action.picture && state.doneAt < Date.now() - 1000) { return Object.assign({}, state, action, { done: [state.picture, ...state.done], doneAt: Date.now() }); } else { return Object.assign({}, state, action); } } إذا كان الإجراء هو إجراء تراجع undo، فستأخذ الدالة آخر صورة من السجل وتجعلها هي الصورة الحالية، حيث تضبط doneِAt على صفر كي نضمن أن التغيير التالي يخزِّن الصورة في السجل مما يسمح لك بالعودة إليها في وقت آخر إذا أردت؛ أما إن كان الإجراء غير ذلك ويحتوي على صورة جديدة وكان آخر وقت تخزين صورة أكثر من ثانية واحدة -أي أكثر من 1000 ميلي ثانية-، فستُحدَّث خصائص done وdoneAt لتخزين الصورة السابقة، كما لا يفعل مكوّن زر التراجع الكثير، إذ يرسِل إجراءات التراجع عند النقر عليه ويعطِّل نفسه إذا لم يكن ثمة شيء يُتراجع عنه. class UndoButton { constructor(state, {dispatch}) { this.dom = elt("button", { onclick: () => dispatch({undo: true}), disabled: state.done.length == 0 }, "⮪ Undo"); } syncState(state) { this.dom.disabled = state.done.length == 0; } } لنرسم نحتاج أولًا إلى إنشاء حالة كي نستطيع استخدام التطبيق مع مجموعة من الأدوات والمتحكمات ودالة إرسال، ثم نمرر إليها باني PixelEditor لإنشاء المكوِّن الأساسي، وبما أننا نحتاج إلى إنشاء عدة محررات في التدريبات، فسنعرِّف بعض الرابطات bindings أولًا. const startState = { tool: "draw", color: "#000000", picture: Picture.empty(60, 30, "#f0f0f0"), done: [], doneAt: 0 }; const baseTools = {draw, fill, rectangle, pick}; const baseControls = [ ToolSelect, ColorSelect, SaveButton, LoadButton, UndoButton ]; function startPixelEditor({state = startState, tools = baseTools, controls = baseControls}) { let app = new PixelEditor(state, { tools, controls, dispatch(action) { state = historyUpdateState(state, action); app.syncState(state); } }); return app.dom; } تستطيع استخدام = بعد اسم الرابطة حين نفكك كائنًا أو مصفوفةً كي تعطي الرابطة قيمةً افتراضيةً تُستخدَم عندما تكون الخاصية مفقودةً أو تحمل قيمة غير معرفة undefined، كما تستفيد دالة StartPixelEditor من هذا في قبول كائن له عدد من الخصائص الاختيارية على أساس وسيط، فإذا لم توفر خاصية tools، فستكون مقيدةً إلى baseTools، وتوضِّح الشيفرة التالية كيفية الحصول على محرر حقيقي على الشاشة: <div></div> <script> document.querySelector("div") .appendChild(startPixelEditor({})); </script> تستطيع الآن الرسم فيه إذا شئت. سبب صعوبة البرنامج لا شك أنّ التقنيات المتعلقة بالمتصفحات رائعة وتمكننا من فعل الكثير بالواجهات المرئية من بنائها وتعديلها بل وتنقيحها من الأخطاء البرمجية كذلك، كما ستضمن أنّ البرنامج الذي تكتبه من أجل المتصفح سيعمل على كل حاسوب وهاتف يتصل بالإنترنت، لكن تُعَدّ تقنيات المتصفحات هذه بحارًا واسعةً، إذ عليك تعلّم الكثير الكثير من الطرق والأدوات لتستطيع الاستفادة منها، كما أنّ النماذج البرمجية الافتراضية المتاحة لها كثيرة المشاكل إلى حد أن أغلب المبرمجين يفضلون التعامل مع طبقات مجرَّدة عليها عوضًا عن التعامل المباشر معها، وعلى الرغم من اتجاه الوضع نحو الأفضل، إلا أن هذا يكون في صورة إضافة مزيد من العناصر لحل المشاكل وأوجه القصور الموجودة مما يخلق المزيد من التعقيد. لا يمكن استبدال الميزة المستخدَمة من قِبل ملايين المواقع بقرار واحد بسهولة، بل حتى لو أمكن ذلك فمن الصعب الاتفاق على بديلها، ونحن مقيدون بأدواتنا والعوامل الاجتماعية والاقتصادية والتاريخية التي أثرت في إنشائها، والأمر الذي قد يفيدنا في هذا هو أننا قد نصبح أفضل في الإنتاجية إذا عرفنا كيف تعمل هذه التقنيات ولماذا هي على الوجه التي عليه بدلًا من الثورة عليها وتجنبها كليًا. قد تكون التجريدات الجديدة مفيدةً حقًا، فقد كان نموذج المكونات وأسلوب تدفق البيانات اللذان استخدمناهما في هذا المقال مثالًا على ذلك، وهناك الكثير من المكتبات التي تجعل برمجة واجهة المستخدِم أفضل وأسهل، سيما React و Angular الشائعتا الاستخدام وقت كتابة هذه السلسلة بنسختها الأصلية، لكن هذا مجال كبير بحد ذاته ننصحك بإنفاق بعض الوقت في تصفِّحه كي تعرف كيف تعمل هذه المكتبات والفوائد التي تجنيها منها. تدريبات لا زال هناك مساحة نطور فيها برنامجنا، فلمَ لا نضيف بعض المزايا الجديدة في صورة تدريبات؟ رابطات لوحة المفاتيح أضف اختصارات للوحة المفاتيح إلى التطبيق، بحيث إذا ضغطنا على الحرف الأول من اسم أداة فستُختار الأداة، وctrl+z يفعِّل إجراء التراجع. افعل ذلك عبر تعديل مكوِّن PixelEditor وأضف خاصية tabIndex التي تساوي 0 إلى العنصر المغلِّف <div> كي يستطيع استقبال التركيز من لوحة المفاتيح. لاحظ أن الخاصية الموافقة لسمة tabindex تُسمى tabIndex حيث يكون حرف I فيها على الصورة الكبيرة، كما تتوقع دالة elt أسماء خصائص، ثم سجِّل معالجات أحداث المفاتيح مباشرةً على ذلك العنصر، حيث سيعني هذا أنه عليك ضغط أو لمس أو نقر التطبيق قبل أن تستطيع التفاعل معه بلوحة المفاتيح. تذكَّر أنّ أحداث لوحة المفاتيح لها الخاصيتان ctrlKey وmetaKey -المخصص لزر command في ماك-، حيث تستطيع استخدامهما لتعرف هل هذان الزران مضغوط عليهما أم لا. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <div></div> <script> // الأصلي PixelEditor صنف. // وسع المنشئ. class PixelEditor { constructor(state, config) { let {tools, controls, dispatch} = config; this.state = state; this.canvas = new PictureCanvas(state.picture, pos => { let tool = tools[this.state.tool]; let onMove = tool(pos, this.state, dispatch); if (onMove) { return pos => onMove(pos, this.state, dispatch); } }); this.controls = controls.map( Control => new Control(state, config)); this.dom = elt("div", {}, this.canvas.dom, elt("br"), ...this.controls.reduce( (a, c) => a.concat(" ", c.dom), [])); } syncState(state) { this.state = state; this.canvas.syncState(state.picture); for (let ctrl of this.controls) ctrl.syncState(state); } } document.querySelector("div") .appendChild(startPixelEditor({})); </script> إرشادات للحل ستكون خاصية key لأحداث مفاتيح الأحرف هي الحرف نفسه في حالته الصغرى إذا لم يكن زر shift مضغوطًا، لكن لا تهمنا أحداث المفاتيح التي فيها زر shift. يستطيع معالج "keydown" فحص كائن الحدث الخاص به ليرى إذا كان يطابق اختصارًا من الاختصارات، كما تستطيع الحصول على قائمة من الأحرف الأولى من كائن tools كي لا تضطر إلى كتابتها. إذا طابق حدث مفتاح اختصارًا ما، استدع preventDefault عليه وأرسل الإجراء المناسب. الرسم بكفاءة سيكون أغلب العمل الذي يفعله التطبيق أثناء الرسم داخل drawPicture، ورغم أنّ إنشاء حالة جديدة وتحديث بقية DOM لن يكلفنا كثيرًا، إلا أنّ إعادة رسم جميع البكسلات في اللوحة يمثِّل مهمةً ثقيلةً، لذا جِد طريقةً لتسريع تابع syncState الخاص بـ PictureCanvas عبر إعادة الرسم في حالة تغير البكسلات فقط. تذكَّر أنّ drawPicture تُستخدَم أيضًا بواسطة زر الحفظ، فتأكد إذا غيرتها من أنك لا تخرِّب الوظيفة القديمة، أو أنشئ نسخةً جديدةً باسم مختلف، ولاحظ أنّ تغيير حجم عنصر <canvas> عبر ضبط خاصيتَي width وheight له يتسبب في مسحه ليصير شفافًا مرةً أخرى. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <div></div> <script> // غيّر هذا التابع PictureCanvas.prototype.syncState = function(picture) { if (this.picture == picture) return; this.picture = picture; drawPicture(this.picture, this.dom, scale); }; // ربما تود تغيير هذا أو استخدامه كذلك. function drawPicture(picture, canvas, scale) { canvas.width = picture.width * scale; canvas.height = picture.height * scale; let cx = canvas.getContext("2d"); for (let y = 0; y < picture.height; y++) { for (let x = 0; x < picture.width; x++) { cx.fillStyle = picture.pixel(x, y); cx.fillRect(x * scale, y * scale, scale, scale); } } } document.querySelector("div") .appendChild(startPixelEditor({})); </script> إرشادات للحل يمثِّل هذا التدريب مثالًا ممتازًا لرؤية كيف تسرِّع هياكل البيانات غير القابلة للتغير من الشيفرة، كما نستطيع الموازنة بين الصورة القديمة والجديدة بما أن لدينا كليهما وإعادة الرسم في حالة تغير لون البكسلات فقط، مما يوفر 99% من مهمة الرسم في الغالب. اكتب دالة updatePicture جديدةً أو اجعل دالة drawPicture تأخذ وسيطًا إضافيًا قد يكون غير معرَّف أو قد يكون الصورة السابقة، وتتحقق الدالة لكل بكسل مما إذا كانت الصورة السابقة قد مرت على هذا الموضع باللون نفسه أم لا، كما تتخطى البكسل الذي تكون تلك حالته. يجب عليك تجنب width وheight حين يكون للصورتين الجديدة والقديمة نفس الحجم لأن اللوحة تُمسح حين يتغير حجمها، فإذا اختلفتا -وتلك ستكون حالتنا إذا حُمِّلت صورة جديدة- فيمكنك ضبط الرابطة التي تحمل الصورة القديمة على null بعد تغيير حجم اللوحة، إذ يجب ألا تتخطى أيّ بكسل بعد تغيير حجم اللوحة. الدوائر عرِّف أداةً اسمها circle ترسم دائرةً مصمتةً حين تسحب بالمؤشر، حيث يكون مركز الدائرة عند نقطة بداية السحب، ويُحدَّد نصف قطره بالمسافة المسحوبة. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <div></div> <script> function circle(pos, state, dispatch) { // ضع شيفرتك هنا } let dom = startPixelEditor({ tools: Object.assign({}, baseTools, {circle}) }); document.querySelector("div").appendChild(dom); </script> إرشادات للحل تستطيع النظر في أداة rectangle لتستقي منها إرشادًا لهذا التدريب، حيث ستحتاج إلى إبقاء الرسم على صورة البدء بدلًا من الصورة الحالية عندما يتحرك المؤشر، ولكي تعرف أيّ البكسلات يجب تلوينها، استخدام نظرية فيثاغورس بحساب المسافة بين الموضع الحالي للمؤشر والموضع الابتدائي من خلال أخذ الجذر التربيعي Math.sqrt لمجموع تربيع Math.pow(x, 2)‎ لفرق في إحداثيات x وتربيع الفرق في إحداثيات y. كرر بعد ذلك على تربيع البكسلات حول نقطة البداية التي تكون جوانبها ضعف نصف القطر على الأقل، ولوّن تلك التي تكون داخل نصف قطر الدائرة باستخدام معادلة فيثاغورس مرةً أخرى لتعرف بُعدها عن المركز، وتأكد من أنك لا تلون البكسلات التي تكون خارج حدود الصورة. الخطوط المستقيمة يُعَدّ هذا التدريب متقدمًا أكثر مما قبله وسيحتاج إلى تصميم حل لمشكلة ليست بالهينة، لذا تأكد من امتلاكك وقت وصبر قبل أن تبدأ العمل عليه، ولا يمنعنك الفشل في المحاولات أن تعيد الكرة. عندما تختار أداة draw في أغلب المتصفحات وتسحب المؤشر بسرعة ستجد أن ما حصلت عليه خطًا من النقاط التي تفصل بينها مسافات فارغة، وذلك لأن حدثَي "mousemove" أو "touchmove" لا ينطلقان بسرعة تغطي كل بكسل تمر عليه، لذا نريد منك تطوير أداة draw لتجعلها ترسم خطًا كاملًا، وهذا يعني أنه عليك جعل دالة معالج الحركة تتذكر الموضع السابق وتصله بالموضع الحالي، ولكي تفعل ذلك عليك كتابة دالة رسم خط عامة بما أنّ البكسلات التي تمر عليها قد لا تكون متجاورةً بما يصلح لخط مستقيم. يُعَدّ الخط المستقيم بين بكسلين سلسلةً من البكسلات المتصلة في سلسلة واحدة بأقرب هيئة تمثل خطًا مستقيمًا من البداية إلى النهاية، ويُنظر إلى البكسلات المتجاورة قطريًا على أنها متصلة، لذا فإن الخط المائل يجب أن يبدو مثل الصورة التي على اليسار وليس الصورة اليمنى. أخيرًا، إذا كانت لدينا شيفرةً ترسم خطًا بين نقطتين عشوائيتين فربما تريد استخدامها كي تعرِّف أداة سطر line ترسم خطًا مستقيمًا بين بداية السحب ونهايته. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <div></div> <script> // هذه أداة الرسم القديمة، أعد كتابتها. function draw(pos, state, dispatch) { function drawPixel({x, y}, state) { let drawn = {x, y, color: state.color}; dispatch({picture: state.picture.draw([drawn])}); } drawPixel(pos, state); return drawPixel; } function line(pos, state, dispatch) { // ضع شيفرتك هنا. } let dom = startPixelEditor({ tools: {draw, line, fill, rectangle, pick} }); document.querySelector("div").appendChild(dom); </script> إرشادات للحل تتكون مشكلة رسم خط من البكسلات من أربع مشكلات تختلف اختلافًا طفيفًا فيما بينها، إذ يُعَدّ رسم خط أفقي من اليسار إلى اليمين سهلًا إذ تكرر على إحداثيات x وتلون البكسل في كل خطوة، فإذا كان الخط يميل قليلًا أقل من 45 درجة أو ‎¼π راديان، فتستطيع وضع إحداثيات y مع الميل، لكن لا زلت في حاجة إلى بكسل لكل موضع x، ويُحدِّد الميل موضع y لكل بكسل من هذه البكسلات. لكن ستحتاج إلى تغيير الطريقة التي تعامل بها الإحداثيات بمجرد تجاوز الميل درجة 45، حيث ستحتاج الآن إلى بكسل واحد لكل موضع y بما أن الخط يتقدم إلى الأعلى أكثر من سيره إلى اليسار، وعندما تتجاوز 135 درجة فعليك العودة إلى التكرار على إحداثيات x لكن من اليمين إلى اليسار. لست بحاجة إلى كتابة أربع حلقات تكرارية، وبما أنّ رسم خط من A إلى B هو نفسه رسم خط من B إلى A، فيمكنك نبديل مواضع البداية والنهاية للخطوط التي تبدأ من اليمين إلى اليسار وتعامِلها على أنها من اليسار إلى اليمين، لذا تحتاج إلى حلقتين تكراريتين مختلفتين، وأول شيء يجب أن تفعله دالة رسم الخطوط هو معرفة هل الفرق بين إحداثيات x أكبر من الفرق بين إحداثيات y أم لا، فإذا كان فإنّ هذا خط مائل للأفقية، وإلا فإنه يميل لأن يكون رأسيًا. تأكد من أن توازن بين القيم المطلقة لفروق x وy والتي تحصل عليها بواسطة Math.abs، وبمجرد معرفتك أيّ محور ستكرر عليه، تستطيع تفقد نقطة البدء لترى إذا كان لها إحداثي أعلى على هذا المحور من نقطة النهاية أم لا وتبدلهما إذا دعت الحاجة، وتكون الطريقة المختصرة هنا لتبديل قيم رابطتين في جافاسكربت تستخدِم مهمة تفكيك كما يلي: [start, end] = [end, start]; ثم تحسب ميل الخط الذي يُحدِّد المقدار الذي يتغير به الإحداثي على المحور الآخر لكل خطوة تأخذها على المحور الأساسي، وتستطيع تشغيل حلقة تكرارية هنا على المحور الأساسي أثناء تتبع الموضع الموافق على المحور الآخر، كما يمكنك رسم بكسلات على كل تكرار، لكن تأكد من أن تقرِّب إحداثيات المحور غير الأساسي بما أنها ستكون أعدادًا كسرية على الأرجح ولا يتجاوب معها تابع draw جيدًا. ترجمة -بتصرف- للفصل التاسع عشر من كتاب Elequent Javascript لصاحبه Marijn Haverbeke. اقرأ أيضًا المقال السابق: HTTP والاستمارات في جافاسكربت جافاسكربت في بيئة المتصفح والمواصفات المتعلقة بها التعامل مع عنصر Canvas باستخدام جافاسكربت (رسم الأشكال) منحنى بيزيه وأهميته في الرسوميات وصناعة الحركات في جافاسكربت إنشاء الحركات عبر جافاسكربت
  5. إطار Angular هو إطار عمل مفتوح المصدر يُستخدم في إنشاء تطبيقات لمنصات متعددة مثل الويب وويب الأجهزة المحمولة وكذلك تطبيقات سطح المكتب، وهو أحد أشهر أطر العمل في مجال تطبيقات وحيدة الصفحة. سننشئ في هذه السلسلة تطبيقًا للتدوين باستخدام إطار أنجولَر في الواجهة الأمامية، ونستخدم Google cloud Firestore كقاعدة بيانات، كما سنتعلم كيفية نقل التطبيق إلى منصة Firebase وتشغيله عليها. سيحتوي التطبيق على المزايا التالية: لغة التصميم Material Design. إضافة تدوينة جديدة. تعديل تدوينة حالية. حذف تدوينة حالية. المصادقة مع حساب جوجل. المصادقة المبنية على الدور role-based. ترقيم التدوينات. التعليقات على التدوينات. خيار مشاركة التدوينة على الشبكات الاجتماعية. وسنتعلم المفاهيم التالية حول أنجولَر: استخدام Cloud Firestore مع تطبيق Angular. مكتبة Angular Material وإطار عمل Bootstrap. الاستمارات المبنية على القوالب template-driven. التحقق من الاستمارات. الأنابيب المخصصة Custom pipes. الصنف Auth-guards. الاستيثاق Authentication والتصريح Authorization. تسجيل الدخول بجوجل باستخدام Firebase. ترقيم الصفحات من جانب العميل Client-Side pagination باستخدام ترقيم ngx. يمكن الاطلاع على نسخة عاملة من التطبيق في firebaseapp. ينبغي أن تكون في نهاية السلسلة قد أتقنت مفاهيم إطار عمل Angular المتقدمة، واستطعت إنشاء تطبيقات ويب تفاعلية باستخدام Angular وقاعدة بيانات Firebase. هذا المقال جزء من سلسلة عن بناء مدونة باستخدام إطار العمل Angular وقاعدة بيانات Firestore مقدمة في بناء تطبيقات الويب باستخدام إطار العمل Angular وقاعدة بيانات Firestore. بناء مدونة باستخدام إطار العمل Angular وقاعدة بيانات Firestore - إضافة التدوينات وعرضها. بناء مدونة باستخدام إطار العمل Angular وقاعدة بيانات Firestore - تعديل التدوينات. بناء مدونة باستخدام إطار العمل Angular وقاعدة بيانات Firestore - إضافة الاستثيثاق. نشر مدونة مبنية عبر Angular على Firebase. مصطلحات أساسية لابد التطرق إلى المصطلحات التي سنتعرض لها خلال هذه السلسلة والمرور عليها سريعًا، إذ لن نتعمق فيها ونشرحها أثناء العمل على بناء التطبيق لذلك ننصحك بالقراءة عنها ما أمكن ليسهل عليك فهم عملية بناء المدونة. التطبيقات وحيدة الصفحة التطبيقات وحيدة الصفحة Single Page Application هي تطبيقات ويب تحتوي على صفحة HTML واحدة، تُحمَّل من الخادم عند إطلاق التطبيق لأول مرة عند، ثم يتولى المتصفح كل شيء بعدها، ولا يرسل الخادم أي HTML بعد التحميل الأول للصفحة، بل يطلب المتصفح بيانات من الخادم ويرسلها الخادم إليها، فتُعاد كتابة البيانات دون إعادة تحميل الصفحة. ولا تُحدَّث الصفحة في التطبيقات وحيدة الصفحات إلا عند طلب بيانات جديدة من الخادم -انتبه من أن التحديث refresh يختلف عن إعادة التحميل reload- وذلك لنحصل على تجربة مستخدم أفضل. ما هي Typescript؟ وفقًا لتعريفها في موسوعة حسوب، فهي لغة برمجة مفتوحة المصدر من تطوير شركة Microsoft، تُعَد امتدادًا وتوسعةً للغة JavaScript، حيث أضافت العديد من المزايا إليها، خاصّةً دعم الأنواع types الذي يُساعد على تجنّب الأخطاء والعلل البرمجيّة وتوفير شيفرة برمجية نقية قابلة للقراءة أكثر من شيفرة JavaScript العادية. ما هو Angular؟ هو إطار عمل مفتوح المصدر للغة جافاسكربت تقوم جوجل على تطويره ومتابعته. يسمح لنا Angular بإنشاء تطبيقات ويب من جانب العميل client-side باستخدام لغة Typescript، ويُستخدم في إنشاء التطبيقات وحيدة الصفحة، كما يسمح ببناء تطبيقات لمنصات التشغيل المختلفة مثل الويب وويب الأجهزة المحمولة mobile web، وتطبيقات الويب الأصلية native web apps، وتطبيقات سطح المكتب الأصلية كذلك. يحتوي إطار عمل Angular على العديد من المزايا، من أهمها ما يلي: الكفاءة العالية في التنفيذ. قابلية التوسع. معماريته قائمة على المكونات components. فيه دعم مضمَّن للاستمارات forms وعمليات التحقق validation فيها. فيه دعم متعدد المنصات للتطوير. مفتوح المصدر ومدعوم من قِبل جوجل. تُبنى معمارية إطار Angular على المكونات، أي أنه component-based، وسيمر كل مكون خلال سلسلة من الأحداث من إنشائه حتى تدميره، وتلك الأحداث المحورية في حياة المكونات تعرِّفها خطافات دورة الحياة lifecycle hooks، وتوفر مكتبة Angular الأساسية مجموعةً من واجهات خطاطيف دورات الحياة التي تسمح لنا بالاستفادة من تلك اللحظات الرئيسية في دورة حياة كل مكون. تحتوي كل واجهة من تلك الواجهات على تابع خطاف hook method وحيد يكون اسمه هو اسم الواجهة مسبوقًا بـ ng، ويوفر Angular ثمانية توابع لخطاطيف دورات الحياة، كما هو موضح في الجدول أدناه، وهو جدول مستوحى من https://angular.io/guide/lifecycle-hooks. table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } الخطاف الغرض والتوقيت ngOnChanges()‎ الاستجابة عند ضبط Angular لخصائص دخل البيانات المربوطة data-bound أو إعادة ضبطها. يستقبل التابع كائن SimpleChanges من قيم الخاصية الحالية والسابقة، ويُستدعى قبل ngOnInit()‎ وعند تغير واحد أو أكثر من خصائص دخل data-bound. ngOnInit()‎ تهيئة الموجِّه directive أو المكون بعد عرض Angular لخصائص data-bound لأول مرة، وضبطها لخصائص دخل المكوِّن. يُستدعى مرة واحدة، بعد أول ngOnChanges()‎. ngDoCheck()‎ اكتشاف التغييرات التي لا يستطيع Angular أن يكتشفها بنفسه، واتخاذ إجراء بشأنها. يُستدعى في كل مرة يُلتقط فيها تغير ما، مباشرة بعد ngOnChanges()‎ و ngOnInit()‎. ngAfterContentInit()‎ الاستجابة بعد أن يرسل Angular المحتوى الخارجي ليُعرض في المكون أو العرض الذي يوجد فيه الموجِّه. يُستدعى مرة واحدة بعد أول ngDoCheck()‎. ngAfterContentChecked()‎ الاستجابة بعد تحقق Angular أن المحتوى تم عرضه في الموجِّه أو المكون. يُستدعى بعد ngAfterContentInit()‎ وفي كل استدعاء ngDoCheck()‎ يتبعه. ngAfterViewInit()‎ الرد بعد تهيئة Angular لعروض المكون والعروض الفرعية أو العروض التي فيها الموجِّه. يُستدعى مرة واحدة بعد أول ngAfterContentChecked()‎. ngAfterViewChecked()‎ الرد بعد تحقق Angular من عروض المكون والعروض الفرعية أو التي يوجد فيها الموجِّه. يُستدعى بعد ngAfterViewInit()‎ وفي كل استدعاء ngAfterViewChecked()‎ يتبعه. ngOnDestroy()‎ التنظيف قبل تدمير Angular للموجِّه/المكون. ألغ الاشتراك في العناصر المرئية observables وافصل معالجات الأحداث event handlers لتجنب حدوث تسريب في الذاكرة. يُستدعى قبل تدمير Angular للموجِّه/المكون. يحتوي الموجِّه على نفس مجموعة خطاطيف دورات الحياة التي للمكوِّن، ولا ينفِّذ أي منهما كل الخطاطيف التي لديه، إذ لا يُستدعى التابع الخطاف إلا عند تعريفه. ينبغي أن تكون لديك معرفة أساسية بإطار عمل Angular كما ذكرنا في مقدمة المقالة، وإلا سيستلزم الأمر أن تُراجع سلسلة أساسيات Angular.js في أكاديمية حسوب أولًا. ما هي Firebase؟ هي منصة تطوير لتطبيقات الويب التي تُستخدم على الحواسيب الشخصية والأجهزة المحمولة، وقد طورتها شركة Firebase في عام 2011، ثم استحوذت عليها شركة جوجل في 2014، وتُعَد هذه المنصة من فئة تطبيقات الواجهة الخلفية التي تُقدَّم كخدمة Backend as a Service، واختصارها Baas. توفر منصة Firebase عدة مزايا لتطوير التطبيقات، أهمها ما يلي: سرعة بناء التطبيقات دون الحاجة لإدارة البنية التحتية لها، إذ توفر مزايا مثل التحليلات وقواعد البيانات والرسائل وتقارير التعطل crash reports. مدعومة من قِبل جوجل: بما أن جوجل استحوذت على المنصة، فهي توفر لها بنيةً تحتيةً قويةً وقابلةً للزيادة حتى في حالة التطبيقات الكبيرة. منصة واحدة لعدة منتجات تعمل معًا بشكل أفضل: تعمل منتجات Firebase بكفاءة وهي مستقلة عن بعضها، لكن تستطيع مشاركة البيانات فيما بينها، وهذا يعطيها مزية عن غيرها من التطبيقات التي لا تجمعها منصة واحدة. تدعم Firebase عدة منصات تشغيل مثل iOS وأندرويد والويب ويونِيتي Unity وكذلك C++‎، وهي منصة شاملة تحتوي على ثمانية عشر منتجًا، مقسمة إلى ثلاثة تصانيف: بناء تطبيقات أفضل. تطوير جودة التطبيق. نمو الشركة القائمة على ذلك التطبيق. يوضح الجدول التالي بعض المنتجات من هذه التصانيف، ويُرجع إلى https://firebase.google.com/products-build للحصول على التفاصيل الكاملة لكل منتج منها: بناء تطبيقات أفضل تطوير جودة التطبيق نمو الشركة Cloud Firestore Crashlytics Google Analytics Cloud Storage Performance Monitoring Predictions Authentication Test Lab Cloud Messaging Hosting App Distribution Remote Config أما خطط الأسعار في منصة Firebase فهي كما يلي: خطة Spark: هذه الخطة مجانية ومناسبة للشركات الصغيرة والتطبيقات المخصصة للعرض فقط demo apps. خطة Blaze: هذه الخطة يزيد الاشتراك فيها مع زيادة الاستهلاك، وهي مناسبة للشركات الكبيرة. يُرجع إلى معلومات التسعير المفصلة في موقع firebase لمزيد من التوضيح. ما هي Angular Material؟ تُعَد Angular Material مكتبة مكونات لواجهة المستخدم UI Components لإطار عمل Angular، ومبنية على مواصفات لغة التصميم المادي material design الخاصة بجوجل، وتوفر لنا مكونات حديثة لواجهة المستخدم للعمل على عدة منصات، حيث تم تحسينها لأجل Angular، ويمكن إدراجها في تطبيقاته بسهولة. كذلك تدعم هذه المكتبة جميع المتصفحات الحديثة، وتوفر سمات themes مضمنة فيها لتحسين مظهر التطبيقات، مع توفيرها لسمات مخصصة أيضًا. إعداد بيئة تطوير Angular يجب تثبيت البرامج التالية لنستطيع التطوير باستخدام إطار Angular: Node.js. Angular CLI. Visual Studio Code. Node.js تتصرف Node مثل خادم للتطوير، حيث تسمح لتطبيق Angular بالعمل على الحاسوب المحلي. ثبِّت النسخة طويلة الدعم من Node -النسخة الحالية وقت كتابة هذه الكلمات هي 14.18.0- والتي تناسب نظام تشغيلك من موقعها، وذلك التثبيت سيثبّت بالتبعية مدير الحزم NPM. افتح الطرفية وشغِّل الأمر التالي لمعرفة إصدار node: node -v وهذا الأمر أيضًا لنعرف إصدار NPM: npm -v انظر لقطة الشاشة أدناه للتوضيح: نلاحظ أن إصدار NPM هنا هو 6.14.15. Angular CLI أداة Angular CLI هي واجهة غير رسومية -تعمل من سطر الأوامر- تسمح لنا بتطوير تطبيقات Angular ووضع هياكلها البنيوية وتهيئتها، فهي توفر الأدوات والأوامر التي نحتاج إليها من أجل تسهيل تطوير تطبيقات Angular. افتح نافذة الطرفية وشغّل الأمر التالي لتثبيت Angular CLI: npm install -g @angular/cli يُستخدم الأمر أدناه لمعرفة إصدار Angular CLI: ng version الإصدار الحالي لها وقت كتابة هذه الكلمات هو الإصدار 12.2.7، انظر إلى الصورة أدناه: Visual Studio Code بيئة التطوير Visual Studio Code هي بيئة تطوير مجانية ومفتوحة المصدر طورتها مايكروسوفت، ويمكن استخدامها كمحرر برمجي خفيف، وتدعم تطوير البرمجيات بلغات C++‎ و C#‎ وجافا و PHP وبايثون و Typescript وغيرها، وهي متاحة لأنظمة التشغيل الثلاثة المشهورة: ويندوز وماك ولينكس. سنستخدم هذه البيئة في تطوير تطبيقات Angular في هذه السلسلة، إذ أنها من أشهر بيئات التطوير المستخدمة في كتابة برامج Angular، رغم دعم بيئات تطوير أخرى له، مثل Sublime Text و Atom و Webstorm وغيرها. والآن، ثبِّت النسخة الأخيرة من بيئة Visual Studio Code التي تناسب نظام تشغيلك من موقعه. إنشاء تطبيق Angular جديد ستجد الشيفرة المصدرية للتطبيق التالي متاحة في هذا المستودع في github، فتستطيع نسخ المستودع والاسترشاد بالشيفرة أثناء إنشائنا للتطبيق. كذلك، ستحتاج إلى حساب Gmail لتستطيع الدخول إلى Firebase، وذلك إضافة إلى إعداد بيئة تطوير Angular. انتقل الآن إلى المجلد الذي تريد إنشاء المشروع الجديد فيه، وافتح نافذة الطرفية -أو command prompt على ويندوز- وشغّل الأمر التالي لإنشاء تطبيق Angular جديد باسم blogsite: ng new blogsite --routing=false --style=scss غيّر المجلد وانتقل منه إلى مجلد الجذر root للمشروع، وهو blog site في حالتنا، وافتح المشروع في VS code من خلال تشغيل مجموعة الأوامر التالية: cd blogsite code . إعداد Firebase سننشئ مشروعًا على firebase، ونعِدّ قاعدة بيانات Google cloud firebase لها، وسنستخدم قاعدة البيانات تلك لتطبيق Angular، كما هو مبين في الخطوات التالية: إنشاء مشروع على Firebase اتبع الخطوات التالية لإنشاء مشروع جديد على Firebase: اذهب إلى https://console.firebase.google.com/‎ وسجل الدخول باستخدام حساب gmail. اضغط على زر Create a Project. أدخل اسم المشروع، يمكنك إعطاؤه أي اسم تريد، وسنستخدم الاسم blogsite في حالتنا. اضغط بعدها على Continue. انظر الصورة أدناه: عطِّل زر Enable Google Analytics for this project الذي في الصفحة التالية، ثم اضغط على Create Project. انظر الصورة: إضافة إعدادات Firebase إلى التطبيق سننشئ تطبيق ويب لمشروع Firebase، وسنضيف بيانات الإعدادات لتطبيق Firebase إلى تطبيق Angular، هذا يسمح له بالاتصال بتطبيق Firebase. في صفحة overview للمشروع، اضغط على أيقونة الويب التي تحمل رمز وسم إغلاق في HTML، كما هو موضح في الصورة أدناه: ثم في الصفحة التالية، أدخل اسمًا مستعارًا nickname، وتستطيع هنا اختيار أي اسم تريد، وقد استخدمنا blogsite، وهو نفس اسم مشروعنا. اضغط الآن على زر Register app كما هو موضح في الصورة أدناه: انسخ الكائن firebaseConfig من وسم <script>، والصق الشيفرة المنسوخة إلى src/environments/environment.ts كما هو موضح في الشيفرة أدناه: firebaseConfig: { apiKey: "AIzaSyCxqWK4SVAAJkozEkURuteREIW9197z6-s", authDomain: "blogsite-b165e.firebaseapp.com", databaseURL: "https://blogsite-b165e.firebaseio.com", projectId: "blogsite-b165e", storageBucket: "blogsite-b165e.appspot.com", messagingSenderId: "1057108181105", appId: "1:1057108181105:web:ac5bbb18e5f34c7e575bd0" } بالمثل، الصق الشيفرة إلى src/environments/environment.prod.ts، واستورد ثابت البيئة environment constant إلى src/app/app.module.ts كما هو موضح في الشيفرة أدناه: ... import { AppComponent } from './app.component'; import { environment } from 'src/environments/environment'; ... والآن، اضغط على Continue to the console في صفحة Firebase. إنشاء قاعدة بيانات Firebase السحابية انتقل إلى صفحة Project Overview لمشروع Firebase الخاص بك، واختر Database من قسم Develop الموجود في القائمة على اليسار، ثم اضغط على زر Create database. في نافذة إنشاء قاعدة البيانات Create database المنبثقة، اختر "البدء في وضع الاختبار" Start in test mode واضغط Next، ثم اترك القيمة الافتراضية لموقع Cloud Firestore كما هي واضغط Done. هكذا نكون قد أعددنا قاعدة بيانات Cloud Firestore لمشروع Firebase، انظر الصورة أدناه: يُنصح بإعداد قاعدة البيانات في وضع الاختبار test mode لعينات التطبيقات رغم أن هذا سلوك غير صحيح من الناحية الأمنية، حيث نرى من الرسالة المعروضة أنه بإمكان أي أحد يملك مرجعًا إلى قاعدة البيانات أن يقرأها ويعدّل فيها لمدة ثلاثين يومًا. سنغير هذه القاعدة في الجزء الأخير من الكتاب لنسمح بتعديل قاعدة البيانات من قِبل المستخدمين المصرح لهم فقط. ربط Firebase مع تطبيق Angular افتح نافذةً طرفيةً جديدةً في المجلد الجذر للمشروع لنستخدمها في تنفيذ جميع أوامر Angular، ونفِّذ الأمر التالي لتثبيت حزم Firebase الخاصة به: npm install firebase @angular/fire --save استورد المكتبات الموجودة في AnkitSharma، كما هو موضح أدناه. import { AngularFireModule } from '@angular/fire'; import { AngularFirestoreModule } from '@angular/fire/firestore'; @NgModule({ ... imports: [ // other imports AngularFireModule.initializeApp(environment.firebaseConfig), AngularFirestoreModule, ], ... }) تهيئة مكتبة التنسيق لتطبيق Angular يمكن الاعتماد على مكتبة Material Design أو مكتبة Bootstrap وذلك لإضافة التنسيقات لتطبيقات جاهزة بسهولة للتطبيق وسنشرح كيفية إضافة هاتين المكتبتين إلى تطبيقنا. إضافة مكتبة Angular Material نفِّذ الأمر التالي في الطرفية لتثبيت حزم Angular Material وحزمة تطوير المكونات Component Dev Kit CDK، ومكتبات تحريك Angular -أو Angular animations libraries-. npm install --save @angular/material @angular/cdk @angular/animations بعد نجاح تثبيت هذه الحزم، استورد المكتبات إلى ملف src/app/app.module.ts كما يلي: import {BrowserAnimationsModule} from '@angular/platformbrowser/animations'; @NgModule({ ... imports: [ ... BrowserAnimationsModule, ], }) توفر حزمة Angular Material أربعة سمات themes افتراضيًا، هي ما يلي: deeppurple-amber.css. indigo-pink.css. pink-bluegrey.css. purple-green.css. لإدراج سمة في تطبيق Angular، نحتاج إلى إضافة مرجع في الملف style.scss، فإذا أردنا إضافة السمة الثانية من القائمة أعلاه -indigo-pink.css- على التطبيق كله، فإننا سنضيف السطر التالي في ملف styles.scss: @import "~@angular/material/prebuilt-themes/indigo-pink.css"; أما لتعلم المزيد حول سمات material فيُنظر في دليل موقع Angular. سننشئ بعد ذلك وحدةً جديدةً نضع فيها المكونات المتعلقة بحزمة Angular Material، وذلك بكتابة الأمر التالي في الطرفية: ng g m ng-material افتح ملف ng-material.module.ts، واستبدل بالشيفرة الموجودة فيه الشيفرة التالية: import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progressspinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; @NgModule({ declarations: [], imports: [ CommonModule, MatToolbarModule, MatButtonModule, MatCardModule, MatInputModule, MatIconModule, MatDividerModule, MatMenuModule, MatSelectModule, MatSnackBarModule, MatProgressSpinnerModule, MatTooltipModule, ], exports: [ CommonModule, MatToolbarModule, MatButtonModule, MatCardModule, MatInputModule, MatIconModule, MatDividerModule, MatMenuModule, MatSelectModule, MatSnackBarModule, MatProgressSpinnerModule, MatTooltipModule, ] }) export class NgMaterialModule { } استوردنا في الشيفرة السابقة جميع الوحدات الخاصة بمكونات Angular Material التي سنستخدمها في هذا التطبيق، وستعمل وحدة أخرى مستقلة بذاتها على جعل التطبيق سهل الصيانة. استورد الوحدة NgMaterialModule إلى الملف app.module.ts كما يلي: import { NgMaterialModule } from './ng-material/ng-material.module'; @NgModule({ ... imports: [ ... NgMaterialModule, ], }) إضافة إطار Bootstrap إطار العمل Bootstrap هو إطار CSS يُستخدم في بناء تطبيقات ويب متجاوبة responsive وموجهة للأجهزة المحمولة أولًا mobile-first، وهو إطار عمل مفتوح المصدر يستخدمه مطورو الويب بكثرة، ويُعد لهذا أشهر إطار CSS لتطوير الويب، وآخر إصدار منه وقت كتابة هذه الكلمات هو الإصدار 5.1، وهو يدعم آخر الإصدارات المستقرة من متصفحات الويب المشهورة، بما في ذلك إنترنت إكسبلورر 10-11، ومتصفح Edge. لتثبيت إطار bootstrap في تطبيقنا، نشغِّل الأمر التالي: npm install bootstrap --save كذلك، نضيف مرجعًا عامًا global إلى إطار bootstrap في ملف src/styles.scss كما يلي: @import "~bootstrap/dist/css/bootstrap.css"; تشغيل التطبيق من الخادم افتح نافذةً طرفيةً جديدةً وشغِّل الأمر التالي: ng serve -o ستوفر حزمة Angular CLI الآن التطبيق على العنوان localhost:4200، والذي سيُفتح بواسطة المتصفح الافتراضي لديك، كما يظهر في الصورة التالية: سيعيد التطبيق الآن عملية التصريف compiling وإعادة التحميل كلما تغير الملف، وسنترك الخادم يعمل ونعود لمتابعة إنشاء بقية المكونات. خاتمة تعرفنا في هذا الفصل إلى إطار العمل Angular وإمكانية استخدامه في إنشاء تطبيقات ويب وحيدة الصفحة، وكيفية إعداد بيئة العمل المناسبة اللازمة لإنشاء مثل تلك التطبيقات؛ أما في المقال التالي فسنستخدمه لبناء مدونة على أساس تطبيق عملي عليه، وكيفية تخصيصها وإضافة الوظائف اللازمة لها من إنشاء للتدوينات ونشرٍ لها، ثم تحريرها وحذفها، والصلاحيات اللازمة لذلك لأنواع المستخدمين. اقرأ أيضًا تهيئة بيئة تطبيقات Angular ونشرها على الويب إضافة التنقل وإدارة البيانات في تطبيق Angular كيفية استعمال Angular في بناء تطبيقات الويب
  6. سنتطرق في هذا المقال إلى برنامج عدّ الكلمات الذي كتبناه من قبل، وسننشئ برنامجًا يحاكي برنامج wc في يونكس، من حيث حساب عدد الأسطر والكلمات والمحارف التي في ملف ما، ثم نتعمق أكثر في هذا لنخرج عدد الجمل وأجزاء الجمل clauses والفقرات أيضًا، وسنتابع تطوير هذا البرنامج مرحلةً تلو الأخرى، وسنزيد من إمكانياته تدريجيًا، ثم ننقله إلى وحدة module ليكون قابلًا لإعادة الاستخدام بأن نجعله كائني التوجه object oriented لتحقيق أقصى حد ممكن من الإمكانيات، ثم نغلفه أخيرًا في واجهة رسومية لسهولة الاستخدام، ورغم أننا سنستخدم بايثون في هذا البرنامج إلا أنه يمكن كتابة نسخ منه باستخدام جافاسكربت أو VBScript بإجراء بعض التعديلات. ويمكن إضافة مزايا أخرى لهذا البرنامج، لكننا سنتركها للقارئ للتدريب، من تلك المزايا: حساب فهرس FOG للنصوص، والذي يوضح مدى تعقيد النص، ويُعرَّف بأنه: (متوسط عدد الكلمات للجملة الواحدة) + (نسبة الكلمات الأكثر من 5 أحرف) * 0.4 حساب عدد الكلمات الفريدة المستخدمة، ومرات تكرارها. إنشاء نسخة جديدة تحلل ملفات RTF. حساب عدد الأسطر والكلمات والحروف إذا نظرنا إلى عداد الكلمات السابق: import string def numwords(s): lst = string.split(s) return len(lst) with open("menu.txt","r") as inp: total = 0 # accumulate totals for each line for line in inp.readlines(): total += numwords(line) print( "File had %d words" % total ) فسنجد أننا بحاجة إلى إضافة عدد الأسطر والأحرف، وعدد الأسطر سهل لأننا نكرر على كل سطر، لذا سنحتاج إلى متغير يتزايد مع كل تكرار على الحلقة التكرارية، أما عدد الأحرف فسيكون أصعب قليلًا، لأننا نستطيع التكرار على قائمة الكلمات مضيفين أطوالها في متغير آخر. كما ينبغي أن نجعل البرنامج عام الأغراض بقراءة اسم الملف من سطر الأوامر، أو نطلب من المستخدم أن يزودنا بالاسم إذا لم يكن متاحًا، كما يمكن قراءته من مجرى الدخل القياسي standard input، وهو ما يفعله برنامج wc. سيبدو wc.py النهائي كما يلي: import sys, string # احصل على اسم الملف من سطر الأوامر أو المستخدم if len(sys.argv) != 2: name = input("Enter the file name: ") else: name = sys.argv[1] # اضبط العدادات على الصفر. هذا ينشئ المتغيرات # words - lines - chars = 0, 0, 0 with open(name,"r") as inp: for line in inp: lines += 1 # Break into a list of words and count them lst = line.split() words += len(lst) chars += len(line) # Use original line which includes spaces etc. print( "%s has %d lines, %d words and %d characters" % (name, lines, words, chars) ) يستطيع من يستخدم برنامج wc في يونكس تمرير اسم ملف بمحارف بديلة wildcards؛ للحصول على إحصائيات لجميع الملفات المطابقة إضافةً إلى المجموع الكلي، أما برنامجنا فيتعامل مع اسم ملف واحد فقط، فإذا أردت توسيع ذلك إلى محارف البدل فألق نظرةً على الوحدة glob، وابن قائمةً من الأسماء، ثم كرر على قائمة الملفات، وستحتاج إلى عدادات مؤقتة لكل ملف، ثم عدادات تراكمية للمجموع الكلي، أو استخدم قاموسًا. عد الجمل بدلًا من الأسطر إذا أردنا توسيع هذا البرنامج ليشمل عد الجمل والكلمات بدلًا من "مجموعات المحارف"، فنكرر أولًا على الملف لاستخراج الأسطر إلى قائمة، ثم نكرر على كل سطر لنستخرج الكلمات إلى قائمة أخرى، ثم نعالج بعد ذلك كل "كلمة" لحذف المحارف الدخيلة عليها. لكن توجد طريقة أبسط، بأن نجمع السطور ونحلل محارف الترقيم لعد الجمل وأجزاء الجمل وغيرها، من خلال تعريف الجملة أو جزء الجملة بحسب عناصر الترقيم، لنجرب ذلك في شيفرة وهمية: لكل سطر في الملف: زِد عدد الأسطر بمقدار واحد إذا كان السطر فارغًا: زِد عدد الفقرات عد نهايات أجزاء الفقرات عد نهايات الجمل. أنشئ تقريرًا بالفقرات واأسطر والجمل وأجزاء الجمل والمجموعات والكلمات. لنحول الآن الشيفرة الوهمية إلى شيفرة حقيقية، وسنستخدم التعابير النمطية في حلنا، لذا ينبغي مراجعة مقال التعابير النمطية في البرمجة من نفس السلسلة، للاطلاع عليها ودراستها: import re,sys # استخدم التعابير النمطية للعثور على الأجزاء sentenceStops = ".?!" clauseStops = sentenceStops + ",;:\-" # escape '-' to avoid range effect sentenceRE = re.compile("[%s]" % sentenceStops) clauseRE = re.compile("[%s]" % clauseStops) # احصل على اسم الملف من سطر الأوامر أو من المستخدم if len(sys.argv) != 2: name = input("Enter the file name: ") else: name = sys.argv[1] # اضبط العدادات الآن lines, words, chars = 0, 0, 0 sentences,clauses = 0, 0 paras = 1 # assume always at least 1 para # عالج الملف with open(name,"r") as inp: for line in inp: lines += 1 if line.strip() == "": # empty line paras += 1 words += len(line.split()) chars += len(line.strip()) sentences += len(sentenceRE.findall(line)) clauses += len(clauseRE.findall(line)) # اعرض النتائج output = ''' The file %s contains: %d\t characters %d\t words %d\t lines in %d\t paragraphs with %d\t sentences and %d\t clauses. ''' % (name, chars, words, lines, paras, sentences, clauses) print( output ) هناك عدة نقاط ينبغي الانتباه إليها في الشيفرة السابقة: نستخدم التعابير النمطية هنا لتحسين كفاءة عمليات البحث، رغم إمكانية استخدام عمليات بحث بسيطة عن السلاسل النصية، لكن كنا سنحتاج حينئذ إلى البحث عن كل محرف ترقيم على حدة، وتزيد التعابير النمطية هنا من كفاءة البرنامج، من خلال إيجاد جميع العناصر التي نريدها في بحث واحد، لكن من ناحية أخرى يسهل حدوث الأخطاء فيها، فقد نسينا أثناء محاولتنا الأولى لكتابة الشيفرة أن نهرب المحرف -، فرآه التعبير النمطي مجالًا range، لذا عومل أي عدد في الملف على أنه فاصل لجزء من جملة، واستغرق الأمر كثيرًا حتى عرضنا المشكلة على مجتمع بايثون للعثور على الخطأ، فلما أضفنا محرف " حٌلت المشكلة، كما صرَّفنا التعبيرات مسبقًا precompiled، مما حسّن من الأداء أكثر من استخدام نفس التعبير عدة مرات، كما نلاحظ استخدام التابع findall للحصول على جميع التطابقات في سطر باستدعاء واحد. تظهر كفاءة هذا البرنامج في أنه ينفذ ما نريده بالضبط، وإن كان أقل كفاءةً من منظور إمكانية إعادة الاستخدام، لعدم وجود دوال يمكن استدعاؤها من برامج أخرى. ليست اختبارات الجمل مثاليةً، لأن العناوين المختصرة -مثل "Mr.‎"- ستُحسب جملةً، لأنها تحتوي على محرف النقطة، ونستطيع تحسين التعبير النمطي ليبحث عن النقطة متبوعةً بمسافة واحدة أو أكثر، ثم متبوعةً بحرف إنجليزي كبير، لكن ذلك لن يحل مشكلة "Mr.‎" لأنها تُتبع عادةً بمسافة ثم كلمة بحرف كبير، وهذا يوضح مدى صعوبة معالجة اللغات الطبيعية بكفاءة، فإذا احتجنا حقًا إلى مثل هذا البحث في الممارسة العملية فهناك وحدات متاحة على الإنترنت مصممة خصيصًا لذلك. سنتناول النقطة الثانية المتعلقة بإمكانية إعادة الاستخدام أثناء دراسة الحالة التي بين أيدينا، وننظر في المشاكل المتعلقة بتحليل النصوص بتفصيل أكثر، رغم أننا لن ننتج محلل نصوص مثاليًا في النهاية، حيث تحتاج هذه المهمة مهارات أكبر من التي نتوقعها من مبرمج مبتدئ. تحويل البرنامج إلى وحدة إذا أردنا تحويل البرنامج الذي كتبناه إلى وحدة فيجب أن نتبع بعض مبادئ التصميم الأساسية، فنضع الجزء الأكبر من الشيفرة في دوال ليستطيع مستخدمو الوحدة الوصول إليها، ثم ننقل شيفرة البدء التي تحصل على اسم الملف إلى جزء منفصل من الشيفرة؛ لا يُنفَّذ عند استيراد الوحدة، وأخيرًا سنترك التعريفات العامة global definitions متغيرات على مستوى الوحدة ليستطيع المستخدمون تغيير قيمها إذا أرادوا. لننقل كتلة المعالجة الأساسية الآن إلى دالة نسميها analyze()‎، وسنمرر كائن ملف معامِلًا إليها، وستعيد الدالة قائمةً من قيم العد في صف tuple، وستبدو الشيفرة كما يلي: ############################# # Module: grammar # Created: A.J. Gauld, 2010/12/02 # Modified: A.J. Gauld, 2018/01/12 # # Function: ''' Provides facilities to count words, lines, characters, paragraphs, sentences and 'clauses' in text files. It assumes that sentences end with [.!?] and paragraphs have a blank line between them. A 'clause' is simply a segment of sentence separated by punctuation. The sentence and clause searches are regular expression based and the user can change the regex used. Can also be run as a program.''' ############################# import re, sys # اضبط المتغيرات العامة paras = 1 # We will assume at least 1 paragraph! lines, sentences, clauses, words, chars = 0,0,0,0,0 sentenceMarks = '.?!' clauseMarks = '&();:,/\-' + sentenceMarks sentenceRE = None # set via a function call clauseRE = None format = ''' The file %s contains: %d\t characters %d\t words %d\t lines in %d\t paragraphs with %d\t sentences and %d\t clauses. ''' ############################ # عرّف الدوال التي ستنفذ العمل # بإعادة تصريف التعبير النمطي إذا setCounterREs تسمح لنا # غيرنا قائمة الأجزاء def setCounterREs(): "compiles global regexs from global punctuation sets" global sentenceRE, clauseRE sentenceRE = re.compile('[%s]' % sentenceMarks) clauseRE = re.compile('[%s]' % clauseMarks) # تصفير العدادات analyze() تستدعي def resetCounters(): " reset global counter variables to initial values " chars, words, lines, sentences, clauses = 0,0,0,0,0 paras = 1 # لشيفرة التعريفات، إذ توفر تقريرًا نصيًا بسيطًا reportStats توجَّه def reportStats(theFile): " prints out results from global results " print( format % (theFile.name, chars, words, lines, paras, sentences, clauses) ) # هي الدالة الأساسية التي تعالج الملف analyze() def analyze(theFile): ''' analyze(aFile) -> None Analyzes the input file object putting results in global variables ''' global chars,words,lines,paras,sentences,clauses # مصرَّفًا بالفعل REs تحقق إن كان if not (sentenceRE and clauseRE): setCounterREs() resetCounters() for line in theFile: lines += 1 if line.strip() == "": # empty line paras += 1 words += len(line.split()) chars += len(line.strip()) sentences += len(sentenceRE.findall(line)) clauses += len(clauseRE.findall(line)) # اسمح بتشغيلها إذا استُدعيت من سطر الأوامر #'__main__' على '__name__' يُضبط متغير if __name__ == "__main__": if len(sys.argv) != 2: print( "Usage: python grammar.py <filename>" ) sys.exit() else: with open(sys.argv[1],"r") as aFile: analyze(aFile) reportStats(aFile) إن أول ما نلاحظه هنا هو التعليقات التي في الأعلى، وهذا سلوك شائع لإعطاء فكرة عامة لمن يقرأ الملف عن محتوياته وكيفية استخدامه، كما أن معلومات الإصدار التي تشمل المؤلف والتاريخ مهمة عند موازنة النتائج مع شخص آخر قد يستخدم إصدارًا أحدث، ونلاحظ أن وصف الوحدة هو سلسلة نصية غير مخصصة لمتغير ما، وتذكر أن هذا ينشئ سلسلة توثيق documentation string في بايثون، يمكن الاطلاع عليها باستخدام الدالة help، كما لدينا سلاسل توثيق على كل تعريف دالة مستقلة، ولن نضيف تعليقات كثيرةً في الأمثلة الأخرى، رغم أننا سنعرض بعض الأنماط الأخرى من التعليقات والتوثيقات، لأن النص الأساسي يصف ما يحدث، فهذا المثال يوضح ما يمكن أن نوفره. أما الجزء الأخير فهو خاصية في بايثون تستدعي أي وحدة محمَّلة في سطر الأوامر "__main__"، نستطيع تجريب المتغير الخاص والمضمَّن __name__، حيث نعرف أن الوحدة ستُستورد وتشغَّل، لذا ننفذ شيفرة التشغيل driver code داخل الكتلة if. تتضمن شيفرة التعريف تلك إرشادًا حول كيفية تشغيل الملف إذا لم يتوفر اسم ملف، أو إذا توفرت أسماء ملفات كثيرة، فتسأل المستخدم عن اسم الملف باستخدام input()‎، ونلاحظ أن الدالة analyze()‎ تستخدم دوال الضبط لضمان ضبط العدادات والتعابير العادية ضبطًا صحيحًا قبل أن تبدأ، مما يفيد المستخدم عند استدعاء analyze عدة مرات، خاصةً بعد تغيير التعابير النمطية المستخدمة في عد الجمل وأجزائها. كما نلاحظ استخدام global لضمان ضبط المتغيرات على مستوى الوحدة بواسطة الدوال، ومن دونها كنا سننشئ متغيرات محليةً ليس لها تأثير على متغيرات الوحدة. استخدام الوحدة grammar بعد أن أنشأنا وحدةً نستطيع استخدامها مثل برنامج في سطر أوامر النظام، كما فعلنا من قبل عن طريق ما يلي: C:\> python grammar.py spam.txt إلا أننا نستطيع استيراد الوحدة إلى برنامج آخر أو في محث بايثون، شرط أن نكون قد حفظنا الوحدة في موقع تستطيع بايثون أن تراه. لنجرِ الآن بعض الاختبارات على ملف اختبارات اسمه spam.txt، والذي يعطي الناتج التالي: This is a file called spam. It has 3 lines, 2 sentences and, hopefully, 5 clauses. أي هذا ملف اسمه spam، فيه 3 أسطر وجملتين وخمسة أجزاء جمل. لنشغل بايثون الآن ونجرب: >>> import grammar >>> grammar.setCounterREs() >>> txtFile = open("spam.txt") >>> grammar.analyze(txtFile) >>> grammar.reportStats(txtFile) The file spam.txt contains: 80 characters 16 words 3 lines in 1 paragraphs with 2 sentences and 5 clauses. >>> txtFile.close() >>> txtFile = open('spam.txt') >>> grammar.resetCounters() >>> # redefine sentences as ending in vowels! >>> grammar.sentenceMarks = 'aeiou' >>> grammar.setCounterREs() >>> grammar.analyze(txtFile) >>> print( grammar.sentences ) 21 >>> txtFile.close() نلاحظ أنه يفضل استخدام open/cloSe على الملف عند استخدام المحث التفاعلي بدلًا من استخدام with، لأن with ستؤخر التنفيذ حتى نكتب جميع عمليات الملف التي ستكون داخل الكتلة with، أما استخدام open صراحةً فسيمكننا من تنفيذ كل أمر تنفيذًا منفصلًا. نستطيع الآن أن نرى أن إعادة تعريف أجزاء الجمل قد غيرت عدد الجمل تغييرًا جذريًا، ولا شك أن التعريف الجديد غريب لكنه يظهر أن الوحدة قابلة للاستخدام والتخصيص، ونلاحظ أننا كنا نستطيع طباعة عدد الجمل مباشرةً، دون الحاجة إلى استخدام الدالة reportStats()‎، وهذا يظهر قيمة مبدأ مهم في التصميم، وهو فصل البيانات عن العرض display، فلما فصلنا عرض البيانات عن حسابها صارت وحدتنا أكثر مرونةً للمستخدمين. ولننهي مشروعنا نعيد صياغة وحدة القواعد grammar لتستخدم تقنيات كائنية التوجه، ثم نضيف واجهةً رسوميةً بسيطةً، وسنرى خلال ذلك كيف يعطينا المنظور الكائني وحدات أكثر مرونةً للمستخدم، وقابلةً للتوسيع أيضًا. الأصناف والكائنات من أكبر المشاكل التي يواجهها من يستخدم وحدتنا الاعتماد على المتغيرات العامة، مما يعني أنها تستطيع تحليل ملف واحد في كل مرة، وسيؤدي تحليل أكثر من ملف إلى تغيير القيم العامة، فإذا نقلنا هذه المتغيرات العامة إلى صنف class، فسنستطيع إنشاء عدة نسخ من الصنف -واحد لكل ملف-، وستحصل كل نسخة على مجموعتها الخاصة من المتغيرات، وإذا جعلنا التوابع دقيقةً بما يكفي فيمكن إنشاء بنية يسهل من خلالها -على منشئ نوع جديد من كائن المستند- تعديل معايير البحث ليوافق احتياجات النوع الجديد، فإذا رفضنا جميع وسوم HTML من قائمة الكلمات مثلًا فيمكن معالجة ملفات HTML إضافةً إلى ملفات آسكي ASCII الخالصة. أدت محاولتنا الأولى في هذا إلى إنشاء الصنف Document لتمثيل الملف الذي نعالجه: #! /usr/local/bin/python ################################ # Module: document.py # Author: A.J. Gauld # Date: 2010/12/10 # Version: 3.1 ################################ ''' Provides 2 classes for parsing text/files. A Generic Document class for plain ASCII text, and an HTMLDocument for HTML files. Primary services available include - analyze(), - reportStats(). ''' import sys,re class Document: sentenceMarks = '?!.' clauseMarks = '&()/\-;:,' + sentenceMarks def __init__(self, filename): self.filename = filename self.setREs() self.resetCounters() def resetCounters(self): self.paras = 1 self.lines = self.getLines() self.sentences, self.clauses, self.words, self.chars = 0,0,0,0 def setREs(self): self.sentenceRE = re.compile('[%s]' % Document.sentenceMarks) self.clauseRE = re.compile('[%s]' % Document.clauseMarks) def getLines(self): with open(self.filename)as infile: lines = infile.readlines() return lines def analyze(self): self.resetCounters() for line in self.lines: self.sentences += len(self.sentenceRE.findall(line)) self.clauses += len(self.clauseRE.findall(line)) self.words += len(line.split()) self.chars += len(line.strip()) if line.strip() == "": self.paras += 1 def formatResults(self): format = ''' The file %s contains: %d\t characters %d\t words %d\t lines in %d\t paragraphs with %d\t sentences and %d\t clauses. ''' return format % (self.filename, self.chars, self.words, len(self.lines), self.paras, self.sentences, self.clauses) class TextDocument(Document): pass class HTMLDocument(Document): pass if __name__ == "__main__": if len(sys.argv) == 2: doc = Document(sys.argv[1]) doc.analyze() print( doc.formatResults() ) else: print( "Usage: python document3.py <file>" ) print( "Failed to analyze file" ) نلاحظ استخدام متغيرات الصنف في بداية تعريفه، لتخزين محددات الجمل وأجزائها، حيث تُشارَك متغيرات الصنف بين جميع نسخه، لذا فهي مكان ممتاز لتخزين المعلومات المشتركة، ويمكن الوصول إليها باستخدام اسم الصنف كما فعلنا هنا، أو باستخدام self، ونفضل استخدام اسم الصنف لأنه يبرز حقيقة أن هذه المتغيرات لصنف. لا زلنا بحاجة إلى resetCounters()‎ للمرونة في التعامل مع أنواع المستندات الأخرى، رغم أننا نخزن المتغيرات الآن داخل الصنف، فعلى الأرجح أننا سنستخدم مجموعة عدادات أخرى عند تحليل ملفات HTML -مثل عدد الوسوم-، ونستطيع التعامل مع أي نوع من المستندات تقريبًا إذا جمعنا resetCounters()‎ مع formatResults()‎، ووفرنا تابع analyze()‎ جديد، أما التوابع الأخرى فهي أكثر استقرارًا، لأن قراءة أسطر الملف أمر قياسي بغض النظر عن نوعه، وضبط التعبيرين النمطيين فرصة جيدة للتدرب، فإذا لم نكن بحاجة إلى ذلك فلا نفعله. لدينا الآن وظائف مماثلة لنسخة وحدتنا الخاصة لكننا عبرنا عنها في صنف، ونريد أن نستغل الأسلوب الكائني من خلال فك أجزاء من صنفنا كي لا يحتوي المستوى الأساسي أو Document المجرد إلا على أجزاء عامة، وسننقل الأجزاء الخاصة بمعالجة النصوص إلى الصنف TextDocument أكثر تحديدًا، وتُعرف هذه العملية بإعادة التصميم (Refactoring) في أوساط البرمجة الاحترافية، وسنرى كيفية تنفيذ ذلك فيما يلي. المستند النصي المستندات النصية مألوفة، لكننا يجب أن نتريث لنوضح الغرض من موازنة المستند النصي بالمفهوم العام للمستندات، تتكون المستندات النصية من محارف مرتبة في سطور، تحتوي مجموعات من الأحرف مرتبةً في كلمات تفصل بينها مسافات وعلامات ترقيم أخرى، وإذا جمعنا تلك الأسطر في مجموعات فسيتكون لدينا فقرات يُفصل بينها بأسطر فارغة. والمستند الافتراضي -يُطلق عليه vanilla document أو مستند الفانيليا أحيانًا في إشارة إلى نكهة المثلجات الافتراضية- يتكون من أسطر من المحارف التي لا نعرف عن صياغتها إلا القليل، لذا يجب أن يكون صنفنا Document الأساسي قادرًا على فتح الملف وقراءة محتوياته إلى قائمة من الأسطر، وربما يعيد عدد المحارف والأسطر مثلًا، كما يوفر توابع خطافيةً hook methods فارغةً للأصناف الفرعية الخاصة بالمستند لاستخدامها، لاحظ أن نص آسكي هو أحد أقدم وأبسط الطرق للتعبير عن النصوص، إلا أن الأبجديات التي أضيفت إليه وإضافة اليونيكود قد جعلته معقدًا. وفقًا لما شرحناه في الفقرات السابقة يجب أن يبدو الصنف Document كما يلي: ############################# # Module: document # Created: A.J. Gauld, 2010/12/15 # Version 3 # Function: ''' Provides abstract Document class to count lines, characters and provide hook methods for subclasses to use to process more specific document types''' ############################# import sys,re class Document: def __init__(self,filename): self.filename = filename self.lines = self.getLines() self.chars = sum( [len(L) for L in self.lines] ) self._initSeparators() def getLines(self): f = open(self.filename,'r') lines = f.readlines() f.close() return lines # قائمة بالتوابع الخطافية التي يجب تغييرها def formatResults(self): return "%s contains $d lines and %d characters" % (len(self.lines), self.chars) def _initSeparators(self): pass def analyze(self): pass نلاحظ أن التابع ‎_initSeparators يحوي شرطةً سفليةً قبل اسمه، حيث يستخدم هذا الاصطلاح مبرمجو بايثون للإشارة أن هذا التابع لا يُستدعى إلا من داخل توابع الصنف، وليس مخصصًا ليصل إليه مستخدمو الكائن، ويسمى مثل هذا التابع أحيانًا في بعض اللغات الأخرى بالتابع المحمي protected أو الخاص private. كما نلاحظ أننا استخدمنا الدالة sum()‎ لحساب عدد المحارف، وهي تعيد مجموع قائمة من الأعداد، والقائمة في حالتنا هي قائمة أطوال الأسطر في الملف المنتج بواسطة list comprehension. لم نوفر خيارًا قابلًا للتشغيل باستخدام if __name__ == etc لأن هذا الصنف مجرد abstract. يجب أن يبدو المستند النصي الآن كما يلي: class TextDocument(Document): def __init__(self,filename): super().__init__(filename) self.paras = 1 self.words, self.sentences, self.clauses = 0,0,0 # غيّر الخطاطيف الآن def formatResults(self): format = ''' The file %s contains: %d\t characters %d\t words %d\t lines in %d\t paragraphs with %d\t sentences and %d\t clauses. ''' return format % (self.filename, self.chars, self.words, len(self.lines), self.paras, self.sentences, self.clauses) def _initSeparators(self): sentenceMarks = "[.!?]" clauseMarks = "[.!?,&:;-]" self.sentenceRE = re.compile(sentenceMarks) self.clauseRE = re.compile(clauseMarks) def analyze(self): for line in self.lines: self.sentences += len(self.sentenceRE.findall(line)) self.clauses += len(self.clauseRE.findall(line)) self.words += len(line.split()) self.chars += len(line.strip()) if line.strip() == "": self.paras += 1 if __name__ == "__main__": if len(sys.argv) == 2: doc = TextDocument(sys.argv[1]) doc.analyze() print( doc.formatResults() ) else: print( "Usage: python <document> " ) print( "Failed to analyze file" ) يحقق دمج الأصناف هذا نفس ما يحققه الإصدار غير الكائني الأول، وإذا وازنّا بين طول هذه النسخة وطول الملف الأصلي فسندرك أن بناء كائنات قابلة لإعادة الاستخدام ليس سهلًا، لذا ينبغي أن نكتب إصدارات غير كائنية دومًا؛ ما لم نكن بحاجة إلى إعادة استخدام الكائنات، كأن نخطط لتوسيع التصميم مستقبلًا، كما سنفعل بعد قليل. ومن المهم أن نراعي الموقع الفعلي للشيفرة، فقد كان بإمكاننا عرض إنشاء ملفين، واحد لكل صنف، وهو سلوك شائع في البرمجة الكائنية، ويحافظ على النظام العام، رغم أنه يكون على حساب كثير من الملفات الصغيرة، وكثير من تعليمات الاستيراد في الشيفرة عند استخدام تلك الملفات أو الأصناف. ونفضل أن نعامل الأصناف المرتبطة ببعضها بشدة مثل مجموعة، ونضعها جميعًا في ملف واحد، بما يكفي على الأقل لإنشاء برنامج صغير عامل، لذا جمعنا الصنفين Document وTextDocument في وحدة واحدة، وميزة ذلك أن الصنف العامل يوفر قالبًا للمستخدمين ليقرؤوه مثالًا على توسيع الصنف المجرد، لكن عيبه أن أي تعديل في TextDocument سيؤثر على صنف المستند، وبالتالي سيعطل أجزاءً من الشيفرة، فلا يوجد حل صالح هنا، وتوجد أمثلة على كلا النمطين في مكتبة بايثون، فاختر أحدهما والتزم به. من مصادر المعلومات المفيدة في مثل هذا النوع من التعديل على الملفات النصية كتاب "معالجة النصوص في بايثون" أو Text Processing in Python لـ David Mertz، لكن لاحظ أن هذا الكتاب متقدم وموجه إلى المبرمجين المحترفين، لذا قد تجد صعوبةً في استيعاب مادته إذا كنت مبتدئًا في البرمجة، لكنك ستجد فيه دروسًا مفيدةً للغاية بالمثابرة والتعلم. مستند HTML الخطوة التالية في تطوير تطبيقنا هي توسيع إمكانياته لنستطيع تحليل مستندات HTML، وسنفعل ذلك بإنشاء صنف جديد، وبما أن مستند HTML ما هو إلا مستند نصي يحتوي على الكثير من وسوم HTML وقسم للترويسة في الأعلى؛ فكل ما نحتاج إليه هو حذف هذه العناصر الإضافية ومعاملته بعدها على أنه نص عادي، لذا سننشئ صنفًا جديدًا نسميه HTMLDocument مشتقًا من TextDocument، وسنغير التابع getLines()‎ الذي ورثناه من Document بحيث يحذف الترويسة ووسوم HTML. سيبدو الصنف HTMLDocument الآن كما يلي: class HTMLDocument(TextDocument): def getLines(self): lines = super().getLines() lines = self._stripHeader(lines) lines = self._stripTags(lines) return lines def _stripHeader(self,lines): ''' remove all lines up until start of body ''' bodyMark = '<body>' bodyRE = re.compile(bodyMark,re.IGNORECASE) while bodyRE.findall(lines[0]) == []: del lines[0] return lines def _stripTags(self,lines): ''' remove anything between < and >, not perfect but ok for now''' tagMark = '<.+>' tagRE = re.compile(tagMark) lines2 = [] for line in lines: line = tagRE.sub('',line).strip() if line: lines2.append(line) return lines2 استخدمنا التابع الموروث داخل getLines، وهذا سلوك شائع عند توسيع تابع موروث،حيث ننفذ ذلك بمعالجة أولية، أو نستدعي الشيفرة الموروثة ثم نكمل باقي العمل في الصنف الجديد كما فعلنا هنا، وينفّضذ هذا في التابع __init__ الخاص بالصنف TextDocument أعلاه. وصلنا إلى التابع الموروث getLines من خلال super()‎، رغم أن التابع معرَّف فعليًا في الصنف Document المجرد في الأعلى، ويرث TextDocument جميع مزايا Document، وبالتالي يحتوي على getLines أيضًا، وهكذا نرى أن بايثون وsuper تجدان التابع المناسب دومًا. أما التابعان الآخران فهما محميان نظريًا -كما هو واضح من الشرطة السفلية قبل اسميهما-، وهما هنا لإبقاء المنطق مستقلًا، ولتسهيل توسيع هذا الصنف في المستقبل إلى مستند XHTML أو XML مثلًا، فتدرب على بناء أحدهما. من الصعب أن نحذف جميع وسوم HTML باستخدام التعابير النمطية بسبب إمكانية تشعب الوسوم، وبسبب احتمال حدوث خطأ في احتساب محرفي < و> غير المهرَّبيْن على أنهما وسوم في حين أنهما ليسا كذلك، كما أن الوسوم قد تأتي على أكثر من سطر، وغير ذلك من احتمالات الخطأ الواردة، فالأسلم هنا استخدام محلل HTML -مثل الموجود في وحدة html.parser القياسية- لتحويل ملفات HTML إلى نص عادي، لذا أعد كتابة الصنف HTMLDocument لتستخدم وحدة المحلل هنا لتوليد الأسطر النصية. نحتاج الآن إلى تعديل شيفرة التعريف في نهاية الملف لتكون على الصورة التالية من أجل اختبار HTMLDocument: if __name__ == "__main__": if len(sys.argv) == 2: doc = HTMLDocument(sys.argv[1]) doc.analyze() print( doc.formatResults() ) else: print( "Usage: python <document> " ) print( "Failed to analyze file" ) وإذا كنت على دراية ببعض أنواع الملفات الأخرى مثل ملفات PDF و LaTeX و RTF و Postscript وغيرها، فهل تستطيع إنشاء أصناف تحقق وفحص لها؟ إضافة واجهة رسومية سنستخدم Tkinter الذي شرحناه باختصار في مقال البرمجة الحدَثية، ثم توسعنا فيه بتفصيل أكثر في مقال برمجة الواجهات الرسومية المشار إليه بالأعلى، أما هنا فستكون الواجهة الرسومية أكثر تعقيدًا وتستخدم مزيدًا من الودجات widgets التي يوفرها Tk، ومن العوامل التي ستعيننا على إنشاء النسخة الرسومية تجنبنا لوضع أي تعليمات طباعة في أصنافنا، ووضعنا لطباعة الخرج في شيفرة التعريف بدلًا من ذلك، وهذا سيفيدنا حين نستخدم الواجهة الرسومية، حيث سنستطيع استخدام سلسلة الخرج نفسها وعرضها في ودجت، بدلًا من طباعتها على مجرى الخرج القياسي stdout، ويُعد تغليف التطبيق في واجهة رسومية بسهولة من أهم الأسباب التي نتجنب استخدام تعليمات الطباعة في الدوال أو التوابع الخاصة بمعالجة البيانات لأجلها. تصميم الواجهة الرسومية إن الخطوة الأولى في بناء أي برنامج رسومي هي محاولة تصور شكله النهائي، فمثلًا سنحتاج إلى تحديد اسم ملف، لذا سنضيف متحكمًا للتعديل أو الإدخال النصي، كما سنحدد إذا كنا نريد تحليل ملف نصي أم ملف HTML، فنمثل هذا الاختيار من متعدد بمجموعة من متحكمات أزرار الانتقاء radiobuttons، ويجب أن تُجمع هذه الأزرار معًا لإظهار ارتباطها ببعضها. أما الشرط الثاني فهو أسلوب عرض النتائج، ورغم أنه يمكن اعتماد عدة متحكمات للعناوين؛ بحيث يكون لدينا عنوان لكل عداد، إلا أننا سنستخدم متحكمًا نصيًا بسيطًا نستطيع إدخال سلاسل نصية فيه، وهو بهذا أقرب إلى خرج سطر الأوامر، مع أن هذا يرجع بالنهاية لمصمم البرنامج، لكن هذا التصميم الذي سنعتمده لن يكون الأجمل مظهرًا. وأخيرًا نحتاج إلى وسيلة لبدء التحليل والخروج من التطبيق، وبما أننا نستخدم متحكمًا نصيًا لعرض النتائج؛ فقد يكون من الأفضل أن يكون لدينا وسيلة لإعادة ضبط الشاشة، ويمكن تمثيل جميع خيارات الأوامر بمتحكمات أزرار. إذا رسمنا هذه الأفكار أعلاه فقد نحصل على شكل قريب مما يلي: +-------------------------+-----------+ | FILENAME | O TEXT | | | O HTML | +-------------------------+-----------+ | | | | | | | | | | +-------------------------------------+ | | | ANALYZE RESET QUIT | | | +-------------------------------------+ يشبه هذا التصميم ثلاثة إطارات بالعرض الكامل فوق بعضها، وفي الإطار العلوي إطاران جنبًا إلى جنب، ويحتوي الإطار الأيمن العلوي على زري انتقاء مرتبين رأسيًا، أما الإطار السفلي فيحتوي على ثلاثة أزرار مرتبة أفقيًا، ونستطيع استخدام تخطيط المحزِّم pack لكل ما سبق، وبهذا نعلم الودجات التي نحتاج إليها، ومدير التخطيط المطلوب لكل إطار، ويتبقى لدينا مزية واحدة جديدة، وهي أزرار الانتقاء، وسننظر فيها لاحقًا. لنحول ما سبق إلى شيفرة: import tkinter as tk import document ################### CLASS DEFINITIONS ###################### class GrammarApp(Frame): def __init__(self, parent=0): super().__init__(parent) self.type = 1 # create variable with default value self.master.title('Grammar counter') self.buildUI() استوردنا وحدتي tkinter وdocument، وجعلنا كل أسماء Tkinter مرئيةً داخل الوحدة الحالية، أما بالنسبة للوحدة الثانية فسنحتاج إلى سبق الأسماء بـ document.. كما عرَّفنا التطبيق على أنه صنف فرعي للصنف Frame، ويستدعي التابع __init__ تابع الصنف الرئيسي Frame.__init__‎ لضمان إعداد Tk بالطريقة الصحيحة داخليًا، ثم ننشئ سمةً تخزن قيمة نوع المستند، ونستدعي التابع buildUI الذي ينشئ جميع الودجات لنا، وسننظر الآن فيه: def buildUI(self): # إطار الخيارات أولًا # - اسم الملف ونوعه fOpts = tk.Frame(self) fFile = tk.Frame(fOpts) tk.Label(fFile, text="Filename: ").pack(side="left") self.eName = tk.Entry(fFile) self.eName.insert(tk.INSERT,"test.htm") self.eName.pack(side='left', padx=5) fFile.pack(side='left', padx=3) # والآن أزرار الانتقاء fType = Frame(fFile, borderwidth=1, relief=tk.SUNKEN) self.rText = Radiobutton(fType, text="TEXT", variable = self.type, value=1, command=self.doText) self.rText.pack(side=tk.TOP, anchor=tk.W) self.rHTML = Radiobutton(fType, text="HTML", variable=self.type, value=2, command=self.doHTML) self.rHTML.pack(side=tk.TOP, anchor=tk.W) # هو الخيار الافتراضي TEXT اجعل self.rText.select() fType.pack(side=tk.RIGHT, padx=3) fOpts.pack(side=tk.TOP, fill=tk.X) # يحتوي الصندوق النصي على الخرج، فأضف له حشوة # (self أي) لإضافة حد إليه، واجعل الأب هو إطار التطبيق self.txtBox = Text(self, width=60, height=10) self.txtBox.pack(side=tk.TOP, padx=3, pady=3) # ضع بعض أزرار التحكم fButts = Frame(self) self.bAnal = Button(fButts, text="Analyze", command=self.doAnalyze) self.bAnal.pack(side=tk.LEFT, anchor=tk.W, padx=50, pady=2) self.bReset = Button(fButts, text="Reset", command=self.doReset) self.bReset.pack(side=tk.LEFT, padx=10) self.bQuit = Button(fButts, text="Quit", command=self.quit) self.bQuit.pack(side=tk.RIGHT, anchor=tk.E, padx=50, pady=2) fButts.pack(side=tk.BOTTOM, fill=tk.X) self.pack() لن نشرح كل هذا إذ يجب أن يكون مفهومًا لمن قرأ الفصل التاسع عشر: برمجة الواجهات الرسومية، أما لمن أراد الاستزادة فعليه فيمكنه الرجوع إلى مراجع وتوثيقات أخرى خارجية، والقاعدة العامة هي أن ننشئ ودجات من أصنافها الموافقة لها، ونوفر الخيارات مثل معاملات ذوات أسماء، ثم نحزّم الودجت في إطارها الحاوي لها. كذلك قد ترغب في تجربة إضافة زر "browse" الذي يفتح صندوق FileOpen الحواري، ويمكن تنفيذ ذلك باستخدام صناديق Tk الافتراضية، انظر الدليل أعلاه لمزيد من الشرح. أما النقاط الأخرى التي يجب ملاحظتها فهي استخدام ودجات Frame الفرعية لتحوي أزرار الانتقاء Radiobuttons وأزرار الأوامر Button، كما تأخذ أزرار الانتقاء زوجًا من الخيارات هما variable وvalue، ويربط الأول أزرار الانتقاء معًا، من خلال تحديد نفس المتغير الخارجي self.type، ويعطي الخيار الثاني قيمةً فريدةً لكل زر منها. لاحظ أيضًا الخيارات command=xxx الممررة إلى المتحكمات، فهي توابع سيستدعيها Tk عند الضغط على الزر. الشيفرة الممثلة لما سبق هي كما يلي: ################# EVENT HANDLING METHODS #################### # استعد الإعدادات الافتراضية def doReset(self): self.txtBox.delete(1.0, tk.END) self.rText.select() # اضبط قيم الانتقاء def doText(self): self.type = 1 def doHTML(self): self.type = 2 يتبقى لدينا آخر معالج أحداث، وهو الذي ينفذ التحليل: # أنشئ نوع المستند المناسب وحلله. # ثم اعرض النتائج في الاستمارة def doAnalyze(self): filename = self.eName.get() if filename == "": self.txtBox.insert(tk.END,"\nNo filename provided!\n") return if self.type == 1: doc = document.TextDocument(filename) else: doc = document.HTMLDocument(filename) self.txtBox.insert(tk.END, "\nAnalyzing...\n") doc.analyze() resultStr = doc.formatResults() self.txtBox.insert(tk.END, resultStr) تتحقق هذه الشيفرة من وجود اسم ملف صالح قبل إنشاء كائن المستند، رغم أنها قد لا تتحقق من صلاحية الاسم. تستخدم قيمة self.type المحددة بواسطة أزرار الانتقاء لتحديد نوع المستند الذي يجب إنشاؤه. تلحَق النتائج بالحقل النصي -الوسيط tk.END في insert- مما يعني أننا نستطيع التحليل عدة مرات وموازنة النتائج، وهذه ميزة الصندوق النصي هنا عن أسلوب خرج العناوين المتعددة multiple label output. وكل ما نحتاج إليه الآن هو إنشاء نسخة لصنف التطبيق GrammarApp وتشغيل حلقة الحدث: if __name__ == "__main__": myApp = GrammarApp() myApp.mainloop() لننظر الآن إلى النتيجة النهائية في نظام ويندوز، والتي تعرض نتائج تحليل ملف HTML: من الممكن جعل معالجة ملف HTML أكثر تعقيدًا إذا أردنا، حيث نتحقق أكثر من الأخطاء -مثل التحقق من عدم وجود الملف-، وننشئ وحدات لأنواع المستندات الجديدة، ونستبدل عدة عناوين مجموعة في إطار واحد بالصندوق النصي، كما يمكن استخدام قائمة منسدلة لأنواع المستندات، خاصةً إذا أضفنا أنواعًا جديدة. خاتمة ينظر المقال التالي في الجانب العملي من بايثون في مشاريع حقيقية، وستكون الأمثلة أطول ولن نكثر من التفاصيل في الشرح، إذ يجب أن تكون الآن قادرًا على متابعة الأمثلة بسهولة. ترجمة -بتصرف- للفصل الثاني والعشرين: A Case Study من كتاب Learn To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: التعامل مع قواعد البيانات المقال السابق: مقدمة إلى البرمجة الوظيفية Functional Programming تعلم البرمجة بداية رحلة تعلم البرمجة
  7. سننظر في كيفية دعم بايثون لأسلوب آخر من أساليب البرمجة، ألا وهو البرمجية الوظيفية Functional Programming، واختصارًا FP، وهو موضوع متقدم بالنسبة للمبتدئين في البرمجة، كما ذكرنا في شأن التعاودية في المقال السابق، وربما تصرف نظرك عنه الآن إلى أن تقطع شوطًا في البرمجة بنفسك. يعتقد المؤيدون للبرمجة الوظيفية أنها الأسلوب الأمثل لتطوير البرمجيات. سنغطي في هذا المقال ما يلي: الفرق بين أسلوب البرمجة التقليدي والأسلوب الدالّي في البرمجة. الدول والتقنيات الخاصة بالبرمجية الوظيفية في بايثون. دوال لامدا Lambda Functions. تقييم الدارة المقصورة البولياني Short Circuit Boolean evaluation، والتعابير الشرطية. البرامج كتعابير. ما هي البرمجية الوظيفية؟ ينبغي ألا نخلط بين البرمجية الوظيفية والأسلوب الإلزامي أو الإجرائي في البرمجة imperative style، وهو الذي كنا نستخدمه في أغلب الفصول حتى الآن، كما أنها تختلف عن البرمجة كائنية التوجه قليلًا بما أن المفاهيم التي سنراها هنا هي مفاهيم برمجية مألوفة لكننا نعبّر عنها تعبيرًا مختلفًا نوعًا ما، كما أن الفلسفة التي تقوم عليها البرمجية الوظيفية في حل المشاكل مختلفة عن باقي الأساليب أيضًا. وتدور البرمجية الوظيفية حول التعابير، بل يمكن القول إن البرمجية الوظيفية هي البرمجة تعبيرية التوجه expression oriented programming، لأن كل شيء فيها يؤول إلى تعبير في النهاية، وقد ذكرنا أن التعبير هو تجميعة من العمليات والمتغيرات التي ينتج عنها قيمة واحدة، فيكون x == 5 تعبيرًا بوليانيًا boolean، و5 + (7-Y) تعبيرًا حسابيًا، و"Hello world".uppercase()‎ تعبيرًا نصيًا، وهذا التعبير الأخير هو استدعاء دالة function أيضًا، أو استدعاء تابع method بالأحرى، على كائن السلسلة النصية "Hello world"، وسنرى أهمية الدوال في البرمجية الوظيفية، كما هو واضح من الاسم. تُستخدم الدوال في البرمجية الوظيفية مثل كائنات، أي أنها تُمرَّر من مكان لآخر داخل البرنامج بنفس طريقة تمرير المتغيرات، وقد رأينا أمثلةً على ذلك في برامج الواجهة الرسومية التي أنشأناها من قبل، حيث أسندنا اسم الدالة إلى سمة command الخاصة بمتحكم الزر، وعاملنا دالة معالج الحدث على أنها كائن وأسندنا إلى الزر مرجعًا إلى الدالة، وهذا المفهوم الخاص بتمرير الدوال في البرامج أمر أساسي في البرمجية الوظيفية، كما تميل البرامج الوظيفية إلى أن تكون قائمية التوجه List Oriented. تحاول البرمجية الوظيفية التركيز على ماهية المشاكل وليس كيفية حلها، أي أنها تصف المشكلة التي نريد حلها، بدلًا من التركيز على آلية الحل نفسها، وتوجد عدة لغات برمجة تميل إلى التصرف بهذه الطريقة، لعل أوسعها انتشارًا هي Haskell، ويحتوي موقع Haskell على أوراق عديدة تصف فلسفة البرمجية الوظيفية، إضافةً إلى لغة Haskell نفسها، رغم أننا نرى أن مؤيدي هذا النمط من البرمجة يبالغون في ذلك الهدف. ويهَيكل البرنامج الوظيفي بتعريف تعبير يلتقط الهدف من البرنامج، وكل شرط term في التعبير هو تعليمة لخاصية من خصائص المشكلة -وربما يوضع الشرط نفسه في تعبير آخر-، ونحصل على الحل من خلال تقييم كل شرط من هذه الشروط. لكن هل هذا الأسلوب ناجح؟ الجواب: أحيانًا نعم وبكفاءة، لكننا للأسف نحتاج في كثير من المشاكل الأخرى إلى أسلوب تفكير أكثر تجريدًا abstract، ويتأثر كثيرًا بالمفاهيم الرياضية، وتصعب قراءة الشيفرة الناتجة من قبل المبرمج العادي، وتكون عادةً أقصر من الشيفرة الإلزامية المكافئة لها، وأكثر موثوقيةً منها، وقد دفعت هذه المزايا الأخيرة -من الاختصار والموثوقية- الكثير من المبرمجين الذين يستخدمون الأسلوب الكائني أو الإلزامي إلى النظر في البرمجية الوظيفية، إذ توجد العديد من الأدوات القوية التي يمكن استخدامها، حتى لو لم ينتقل المبرمج إلى اتباع هذا النمط كليًا. كيف تنفذ بايثون البرمجية الوظيفية توفر بايثون دوالًا عديدة تمكننا من استخدام منظور البرمجية الوظيفية، وتمتلئ الدوال بالمزايا السهلة، أي يمكن كتابتها في بايثون بسهولة، أما ما يجب النظر إليه فهو الغرض المضمَّن في توفير تلك الدوال، وهو السماح لمبرمج بايثون بالعمل بأسلوب البرمجية الوظيفية إذا شاء. سننظر الآن في بعض الدوال المتوفرة في بايثون ونرى كيف تعمل على بعض أمثلة هياكل البيانات التي نعرّفها على النحو التالي: choices = ['eggs','chips','spam'] numbers = [1,2,3,4,5] def spam(item): return "Spam & " + item الدالة map(aFunction, aSequence)‎ تطبق الدالة aFunction الخاصة ببايثون على كل عضو من aSequence، ويكون التعبير كما يلي: L = map(spam, choices) print( list(L) ) ينتج عن هذا إعادة قائمة جديدة في L، مع السابقة Spam &‎ في حالتنا قبل كل عنصر، ونلاحظ كيف مررنا الدالة spam()‎ إلى دالة map()‎ مثل قيمة، أي أننا لم نستخدم الأقواس لتنفيذ شيفرة الدالة، بل استخدمنا اسمها مرجعًا إلى الدالة، ولعلك تذكر أننا فعلنا هذا مع معالجات الأحداث في مقال برمجة الواجهات الرسومية، وهذه الخاصية في معاملة الدوال مثل قيم هي إحدى المزايا الرئيسية في البرمجية الوظيفية. يمكن تحقيق نفس النتيجة بكتابة ما يلي: L = [] for i in choices: L.append( spam(i) ) print( L ) لكن نلاحظ أن دالة map تسمح لنا بإلغاء الحاجة إلى كتلة شيفرة متشعبة، مما يقلل تعقيد البرنامج، وسنرى أن هذه سمة متتكررة في البرمجية الوظيفية، حيث يقلل استخدام الدوال التعقيد النسبي للشيفرة بالتخلص من الكتل البرمجية. الدالة (filter(aFunction, aSequence تستخلص filter كل عنصر في التسلسل aSequence تعيد له الدالة aFunction القيمة True، وإذا عدنا إلى قائمة الأعداد الخاصة بنا فيمكن أن ننشئ قائمةً جديدةً من الأعداد الفردية فقط: def isOdd(n): return (n%2 != 0) # mod استخدم العامل L = filter(isOdd, numbers) print( list(L) ) نلاحظ مرةً أخرى أننا نمرر اسم الدالة isodd إلى filter قيمة وسيط، بدلًا من استدعاء isodd()‎ مثل دالة، وعلى أي حال نستطيع كتابة الأسلوب البديل التالي: def isOdd(n): return (n%2 != 0) L = [] for i in numbers: if isOdd(i): L.append(i) print( L ) ونلاحظ هنا أيضًا أن الأسلوب التقليدي يحتاج إلى مستويين إضافيين من الإزاحات لتحقيق نفس النتيجة، وهذه الزيادة في مستويات الإزاحة دليل على زيادة التعقيد. توجد عدة أدوات أخرى للبرمجة الوظيفية في وحدة اسمها functools يمكن استيرادها وتصفحها في محث بايثون، ويُرجع إلى dir()‎ وhelp()‎ عند الحاجة. الدالة لامدا Lambda إحدى الخصائص الواضحة في الأمثلة السابقة هو أن الدوال التي مُرِّرت إلى دوال البرمجية الوظيفية كانت قصيرة جدًا، وغالبًا ما كانت سطرًا واحدًا فقط، وتوفر بايثون دعمًا جديدًا للبرمجة الوظيفية لتوفير الجهد المبذول لتعريف هذه الدوال الصغيرة، وهي دالة لامدا lambda، والتي يأتي اسمها من فرع في الرياضيات هو حسابات لامدا Lambda Calculus، الذي يستخدم حرف لامدا الإغريقي λ لتمثيل مفهوم قريب من هذا. ويُستخدم مصطلح لامدا في البرمجية الوظيفية للإشارة إلى دالة مجهولة تمثل كتلةً برمجيةً يمكن تنفيذها كما لو كانت دالةً لكن دون اسم، ويمكن تعريف دوال لامدا في أي مكان داخل البرنامج يمكن أن يحدث فيه تعبير بايثون، وهذا يعني أننا نستطيع استخدامها داخل دوال البرمجية الوظيفية الخاصة بنا. وتبدو الدالة لامدا بالشكل التالي: lambda <aParameterList> : <a Python expression using the parameters> وعلى ذلك يمكن كتابة دالة isodd سالفة الذكر كما يلي: isOdd = lambda j: j%2 != 0 ونتجنب هنا تعريف السطر بالكامل من خلال إنشاء الدالة لامدا داخل الاستدعاء على filter كما يلي: L = filter(lambda j: j%2 != 0, numbers) print( list(L) ) ويُنفَّذ الاستدعاء إلى map باستخدام ما يلي: L = map(lambda s: "Spam & " + s, choices) print( list(L) ) ونلاحظ هنا أننا كنا نحول نتائج map()‎ وfilter()‎ إلى قوائم، لأنهما صنفان يعيدان نسخًا من شيء يدعى المتكرر itreable، وهو يتصرف مثل التسلسل أو التجميعة إذا استُخدم على حلقة تكرارية، ويمكن تحويله إلى قائمة، لكن كفاءته تظهر في استخدام الذاكرة، وصديقتنا القديمة range()‎ قابلة للتكرار كذلك، وتسمح بايثون بإنشاء أنواع قابلة للتكرار خاصة بنا، لكننا لن نشرحها. استيعاب القوائم استيعاب القوائم list comprehension هي تقنية لبناء قوائم جديدة، مستوردة من لغة Haskell وأُدخلت إلى بايثون في الإصدار الثاني، وبنيتها غريبة قليلًا وتشبه الصياغة الرياضية، مثلًا: [<expression> for <value> in <collection> if <condition>] والتي تكافئ ما يلي: L = [] for value in collection: if condition: L.append(expression) مما يوفر علينا كتابة بعض الأسطر كما في بقية البنى في البرمجية الوظيفية، ومستويين من الإزاحة كذلك، لننظر في بعض الأمثلة العملية، حيث سننشئ أولًا قائمةً من جميع الأعداد الزوجية الأصغر من 10: >>> [n for n in range(10) if n % 2 == 0 ] [0, 2, 4, 6, 8] والذي يقول إننا نريد قائمةً من القيم n التي تُختار من المجال 0-9، وتكون زوجيةً (n % 2 == 0)، ويمكن استبدال دالةٍ بالشرط الأخير لا شك، شرط أن تعيد الدالة قيمةً تستطيع بايثون أن تفسرها مثل قيمة بوليانية، وعليه يمكن إعادة كتابة المثال السابق كما يلي: >>>def isEven(n): return ((n%2) == 0) >>> [ n for n in range(10) if isEven(n) ] [0, 2, 4, 6, 8] والآن لننشئ قائمةً من تربيعات أول 5 أعداد: >>> [n*n for n in range(5)] [0, 1, 4, 9, 16] نلاحظ أن تعليمة if الأخيرة لم تكن ضروريةً لكل حالة، فالتعبير الابتدائي هنا هو n*n، حيث نستخدم جميع قيم المجال، لنستخدم الآن تجميعةً موجودةً مسبقًا بدلًا من دالة المجال: >>> values = [1, 13, 25, 7] >>> [x for x in values if x < 10] [1, 7] يمكن استخدام ذلك لاستبدال دالة المرشح التالية: >>> print( list(filter(lambda x: x < 10, values)) ) [1, 7] لا يقتصر استيعاب القوائم على متغير واحد أو اختبار واحد، لكن سيزداد تعقيد الشيفرة كلما زادت المتغيرات والاختبارات، ويعود إليك الاختيار بين استيعاب القوائم أو الدوال التقليدية بحسب ما تراه أسهل، إذ تستطيع استخدام دوال البرمجية الوظيفية السابقة أو استيعابات القوائم الجديدة عند إنشاء تجميعة جديدة مبنية على واحدة موجودة مسبقًا، لكن الأسهل في إنشاء التجميعات الجديدة هو الاستيعاب. ورغم أن هذه البُنى تبدو مغريةً إلا أن التعابير اللازمة للحصول على النتيجة التي نريدها قد تصبح معقدةً للغاية بحيث يسهل توسيعها إلى مكافئاتها التقليدية في بايثون، ولا عيب في هذا إذ إن سهولة القراءة أفضل من غموض الشيفرة، خاصةً إذا كان ذلك الغموض لمجرد التذاكي. بنى أخرى رغم أن هذه الدوال مفيدة في ذاتها إلا أنها لا تكفي للسماح بنمط برمجة وظيفية كامل داخل بايثون، إذ يجب تغيير هياكل التحكم أو على الأقل استبدالها بمنظور وظيفية، ويمكن تنفيذ هذا بتطبيق أثر جانبي لكيفية تقييم بايثون للتعابير البوليانية. التقييم المقصور أو تقييم الدارة المقصورة لعلك تذكر من مقال مقدمة في البرمجة الشرطية أن بايثون تستخدم التقييم المقصور للتعابير البوليانية، ويمكن استغلال بعض خصائص هذه التعابير في توفير أسلوب وظيفية للتحكم في البرامج، والتقييم المقصور باختصار هو بدء تقييم التعبير البولياني من التعبير الأيسر إلى الأيمن، ويتوقف التقييم عند عدم الحاجة إلى تقييم أكثر لتحديد النتيجة النهائية، لننظر في بعض الأمثلة لنرى كيف يعمل هذا التقييم: >>> def TRUE(): ... print( 'TRUE' ) ... return True ... >>> def FALSE(): ... print( 'FALSE' ) ... return False ... نعرِّف أولًا دالتين تخبراننا متى تُنفَّذان وتعيدان قيمة أسمائهما، ونستخدم ذلك لنرى كيفية تقييم التعابير البوليانية، لاحظ أن الخرج بالأحرف الكبيرة ناتج عن الدوال، والخرج بالأحرف مختلطة الحالة ناتج عن التعبير: >>> print( TRUE() and FALSE() ) TRUE FALSE False >>> print( TRUE() and TRUE() ) TRUE TRUE True >>> print( FALSE() and TRUE() ) FALSE False >>> print( TRUE() or FALSE() ) TRUE True >>> print( FALSE() or TRUE() ) FALSE TRUE True >>> print( FALSE() or FALSE() ) FALSE FALSE False نلاحظ أنه إذا تحقق الجزء الأول من تعبير AND -أي كان True- وفقط إذا تحقق؛ فسيقيَّم الجزء الثاني، أما إذا لم يتحقق الجزء الأول -كان False- فلن يُقيَّم الجزء الثاني بما أن التعبير ككل لا يمكن أن يتحقق. وبالمثل ففي التعبير المبني على OR، إذا كان الجزء الأول True فلا توجد حاجة إلى تقييم الجزء الثاني، لأن التعبير الكلي يجب أن يكون True، وذاك يتحقق بأحد الجزئين فقط. هناك ميزة أخرى في تقييم بايثون للتعابير البوليانية يمكن استغلالها، وهي أنها لا تعيد -عند تقييم تعبير ما- True أو False فقط، بل تعيد القيمة الحقيقية للتعبير، لذا ستعيد بايثون السلسلة النصية نفسها إذا تحققنا من سلسلة فارغة -والتي يجب أن تُعد False- كما يلي: if "This string is not empty": print( "Not Empty" ) else: print( "No string there" ) يمكن استخدام هذه الخصائص لإعادة إنتاج سلوك شبيه بالتفريع branching، لنفترض مثلًا أن لدينا جزءًا من شيفرة كما يلي: if TRUE(): print( "It is True" ) else: print( "It is False" ) يمكن استبدال بنية وظيفية دالية بها: V = (TRUE() and "It is True") or ("It is False") print( V ) جرب العمل على هذا المثال واستبدل استدعاءً إلى FALSE()‎ بالاستدعاء إلى TRUE()‎. وهكذا نكون قد وجدنا طريقةً للتخلص من تعليمات if/else الشرطية في برامجنا باستخدام التقييم المقصور للتعابير البوليانية، وقد ترى هذه الأساليب في البرامج القديمة، لكنها قد تأتي بنتائج عكسية، لهذا توجد بنية أُدخلت حديثًا إلى بايثون تسمى بالتعبير الشرطي تسمح لنا بكتابة شرط if/else مثل تعبير، كما يلي: result = <True expression> if <test condition> else <False expression> وسيبدو مثال حقيقي بها كما يلي: >>> print( "This is True" if TRUE() else "This is not printed" ) TRUE This is True وإذا استخدمنا else: >>> print( "This is True" if FALSE() else "We see it this time" ) FALSE We see it this time وقد ذكرنا في المقال السابق حول مفهوم التعاودية في البرمجة، أنه يمكن استخدام التعاودية لاستبدال بنية الحلقة التكرارية، فإذا جمعنا التعاودية مع التعابير الشرطية فيمكننا التخلص من بنى التحكم القديمة كلها من برنامجنا، ونستبدل بها تعابير صرفةً، وهذه خطوة كبيرة نحو تفعيل حلول البرمجية الوظيفية. ولنضع هذا في تدريب عملي سنكتب برنامج المضروب factorial بأسلوب وظيفية كليًا، باستخدام lambda بدلًا من def، والتعاودية بدلًا من الحلقات التكرارية، والتعابير الشرطية بدلًا من if/else المعتادة: >>> factorial = lambda n: ( None if n < 0 else 1 if (n == 0) else factorial(n-1) * n ) >>> print( factorial(5) ) 120 وهذا كل ما في الأمر، وقد لا تكون هذه الشيفرة سهلة القراءة مثل شيفرة بايثون العادية لكنها تعمل، وهي دالة بنمط البرمجية الوظيفية كليًا لأنها تعبير خالص، ونلاحظ أننا استخدمنا مجموعةً من الأقواس حول التعبير كله، وهو ما يسمح لنا بتوزيعه على عدة أسطر لتحسين قراءته، ثم حاذينا قيم الإعادة المحتملة الثلاثة رأسيًا في أسطر منفصلة، وهذا اصطلاح شائع مستورد من لغات Lisp التي تميل إلى استخدام التعاودية بكثرة. استنتاجات لعل السؤال الذي يطرح نفسه الآن هو الجدوى من كل ذلك، فرغم أن البرمجية الوظيفية تروق لكثير من الأكاديميين في مجال علوم الحاسوب وعلماء الرياضيات كذلك؛ إلا أن أغلب المبرمجين العاملين يقتصدون في استخدام تقنيات البرمجية الوظيفية، ويدمجونها مع الأساليب الإلزامية التقليدية وفق ما يرونه مناسبًا. وعند الحاجة إلى تطبيق عمليات على عناصر في قائمة مثل map أو filter تكون البرمجية الوظيفية هي المثلى للتعبير عن الحل، وبالمثل قد نجد أحيانًا أن التعاودية أفضل من الحلقات التكرارية، كما قد نجد استخدامًا للتقييم المقصور أو التعبير الشرطي بدلًا من تعابير if/else الشرطية، خاصةً داخل تعبير ما، والمهم أنه يجب على المتعلم ألا يتحمس كثيرًا لتقنية أو فلسفة برمجية بعينها، وإنما يستخدم الأداة المناسبة للمهمة التي بين يديه، ويكفيه حينئذ أن يعلم بوجود حلول بديلة. لدينا أمر أخير حول الدالة lambda، إذ يمكن استخدامها خارج مجال البرمجية الوظيفية، وهي تعريف معالجات الأحداث في برمجة الواجهات الرسومية، حيث تكون معالجات الأحداث دوالًا قصيرةً في الغالب، أو تستدعي دوالًا أكبر منها بقيم وسائط مدمجة فيها، وفي كلا الحالتين يمكن استخدام دالة لامدا مثل معالج حدث يتجنب الحاجة إلى تعريف الكثير من الدوال المنفردة وشغل فضاء الاسم بأسماء لن تُستخدم إلا مرةً واحدةً فقط، ويجب تذكر أن تعليمة لامدا تعيد كائن دالة يُمرَّر إلى الودجِت widget ويُستدعى في وقت وقوع الحدث، وقد عرَّفنا ودجت الزر في Tkinter من قبل، لذا ستبدو لامدا كما يلي: b = Button(parent, text="Press Me", command = lambda : print("I got pressed!")) b.pack() نلاحظ أنه رغم عدم السماح لمعالج الحدث بامتلاك معامِل إلا أننا نسمح بتحديد سلسلة داخل متن لامدا، ونستطيع الآن إضافة زر ثانٍ بسلسلة مختلفة كليًا: b2 = Button(parent, text="Or Me", command = lambda : print("I got pressed too!")) b2.pack() كما نستطيع توظيف lambda عند استخدام تقنية الربط bind technique التي ترسل كائن حدث مثل وسيط: b3 = Button(parent, text="Press me as well") b3.bind(<Button-1>, lambda ev : write("Pressed")) البرمجية الوظيفية في جافاسكربت لا نريد الخوض في تفاصيل البرمجية الوظيفية هنا، لكن من المهم أن ندرك أن جافاسكربت متأثرة كثيرًا بمفاهيم البرمجية الوظيفية، بل إن الاستخدام الحديث لها يشجع استخدام البرمجية الوظيفية أكثر من البرمجة الكائنية، ومفتاح البرمجية الوظيفية فيها هو أن إمكانية كتابة تعريفات الدوال بأسلوب مشابه لتعابير لامدا، فلا تتطلب إلا تغييرًا بسيطًا في الأسلوب والبنية اللغوية لتعريف الدالة، انظر هذا المثال من مقال البرمجة باستخدام الوحدات: <script type="text/javascript"> var i, values; function times(m) { var results = new Array(); for (i = 1; i <= 12; i++) { results[i] = i * m; } return results; } // استخدم الدالة values = times(8); for (i=1;i<=12;i++){ document.write(values[i] + "<br />"); } </script> لاحظ أننا عرّفنا الدالة بوضع الاسم times بعد الكلمة المفتاحية function، لكن جافاسكربت تسمح لنا بتسمية الدالة من خلال الإسناد، كما في أسلوب لامدا الخاص ببايثون أعلاه: times = function(m) { var results = new Array(); for (i = 1; i <= 12; i++) { results[i] = i * m; } return results; } لذا يكون times الآن متغيرًا يحمل مرجعًا إلى كائن الدالة، ويمكن استدعاؤه كما فعلنا من قبل: values = times(8); for (i=1;i<=12;i++){ document.write(values[i] + "<br />"); } بل يمكن استخدام function()‎ لإنشاء دوال مجهولة كما في لامدا، باستثناء أنه ليس لجافاسكربت قيود على أنواع الدوال التي ننشئها، فيمكن أن تكون بأي طول وتعقيد نريده، مما يؤدي إلى نمط في برمجة جافاسكربت ستقابله غالبًا في صفحات الويب، ولذا سنسرد مثالًا مختصرًا له هنا، وهو يتضمن استخدام دوال جافاسكربت التي تأخذ دوالًا أخرى معامِلات لها، وقد رأينا هذا في برامج الواجهة الرسومية من قبل وسميناه رد النداء callback، لأن العديد من مكتبات جافاسكربت تستخدم رد النداء ذاك، ويكون عادةً من دالة رد نداء تُحدَّد على أنها المعامِل الأخير، وهنا مثال من رد نداء مُستخدَم في صفحة ويب بسيطة، وهي مثال كامل، لذا يمكن تحميله وتجربته في المتصفح: <html> <head> <title>Callback test</title> <script type="text/javascript"> window.setTimeout( function() { document.write("رأيتني الآن!"); }, 3000); </script> </head> <body> <p>انتظر نهاية الوقت....<p> </body> </html> نلاحظ أن الدالة التي نفذها الوقت المستقطع لي لها اسم، فقد أُنشئت وسيطًا أول في استدعاء setTimeout نفسه، ثم أضيف زمن الانتظار الذي هو 3000 مللي ثانية وسيطًا ثانيًا، فإذا حملته في المتصفح لديك فسترى وقت الانتظار يحل محل الرسالة الأولى بعد ثلاث ثوانٍ. صار هذا الأسلوب من تعريف الدوال المضمَّنة شائعًا للغاية في مجتمع جافاسكربت، وما هو إلا برمجة وظيفية خالصة، وتحتوي JQuery وهي إحدى أشهر مكتبات الويب لجافاسكربت، على دوال كثيرة تأخذ دوالًا أخرى مثل معامِلات، لنحصل على أسلوب برمجي وظيفي للغاية. أما VBScript فليس فيها دعم مباشر للبرمجة الوظيفية، لكن يمكن استخدامها بأسلوب وظيفي إذا كان لدى المبرمج صبر على ذلك، بهيكلة البرامج مثل تعابير وعدم السماح للآثار الجانبية بتعديل متغيرات البرنامج. خاتمة نرجو في نهاية هذا المقال أن تكون تعلمت ما يلي: البرامج الوظيفية ما هي إلا تعابير خالصة. توفر بايثون map و filter و reduce و list comprehensions لدعم أسلوب البرمجية الوظيفية. تعابير لامدا هي كتل من الشيفرات المجهولة التي لا تحمل اسمًا، ويمكن إسنادها إلى متغيرات أو استخدامها مثل دوال. تقيِّم التعابير البوليانية عند الحاجة فقط لضمان النتيجة التي تمكن تلك التعابير من استخدامها مثل هياكل تحكم. عند جمع مزايا البرمجية الوظيفية لبايثون مع التعاودية فمن الممكن كتابة أي دالة بأسلوب وظيفي في بايثون، رغم أننا لا ننصح بهذا. تدعم جافاسكربت البرمجية الوظيفية، وهو الموجود الآن في أغلب صفحات الويب الحديثة. ترجمة -بتصرف- للفصل الحادي والعشرين: Functional Programming من كتاب Learn To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: دراسة حالة برمجية المقال السابق: مفهوم التعاودية Recursion البرمجة الحدثية Event Driven Programming المساقة بالأحداث مقدمة في البرمجة الشرطية البرمجة كائنية التوجه
  8. قد لا نحتاج إلى هذا المقال في أغلب التطبيقات التي نكتبها، إذ إنه موضوع متقدم في البرمجة (خصوصًا في مراحل تعلم البرمجة الأولى)، وسنعرضه هنا لمجرد الدراسة واحتمال احتياجه في مشروع ما، ولا تقلق إذا وجدت أن عناصره صعبة الفهم. سنشرح في هذا المقال ما يلي: تعريف التعاودية، وكيفية عملها. كيف تساعد التعاودية في تبسيط بعض المشاكل الصعبة. تعريف التعاودية رغم قولنا سابقًا إن الحلقات التكرارية loops هي إحدى ركائز البرمجة، إلا أنه يمكننا كتابة برامج كاملة دون استخدام صريح لهذه البنية، بل إن بعض اللغات مثل Scheme لا تحوي بنية حلقة تكرارية صريحةً مثل For وWhile وغيرهما، وإنما تستخدم تقنيةً تسمى التعاودية، وقد تبين أن هذه التقنية قوية للغاية في حل بعض أنواع المشاكل، وهي تعني تطبيق دالة كجزء من تعريف نفس الدالة، ولننظر مثالًا على ذلك أحد الاختصارات التعاودية المشهورة في الكلمات، وهو أحد أساليب التلاعب بالاختصارات يحتوي الاختصار نفسه على كلمة تطابق حروف الاختصار، مثل مشروع GNU مثلًا -وهو أحد أهم المشاريع في البرمجيات مفتوحة المصدر- والذي تشير حروف كلمته إلى "نظام GNU ليس يونكس" أو GNU's Not UNIX، فتكون اختصارًا تعاوديًا لأن كلمة GNU التي في الاختصار هي نفسها كلمة GNU المختصرة كلها، أما معنى الكلمة الحرفي فهو حيوان الثور الإفريقي. ويجب أن يوجد في الدالة شرط إنهاء، بحيث تتفرع الدالة إلى حل غير تعاودي عند نقطة ما، على عكس مثال GNU الذي ليس فيه هذا الشرط ويظل يتعاود إلى ما لا نهاية، وهو ما نطلق عليه الحلقة اللانهائية infinite loop. لننظر هنا في مثال بسيط، تُعرَّف فيه دالة المضروب الرياضي factorial function المشار إليها بعلامة التعجب بعد العدد n!‎، على أنها ناتج ضرب جميع الأعداد من الواحد حتى العدد المطلوب -بما في ذلك العدد نفسه-، ومضروب الصفر هو الواحد، فإذا أردنا التعبير عن هذا المثال بطريقة أخرى فسنقول إن مضروب N يساوي (N(N-1، وعليه سيكون: 1! = 1 2! = 1 x 2 = 2 3! = 1 x 2 x 3 = 2! x 3 = 6 N! = 1 x 2 x 3 x .... (N-2) x (N-1) x N = (N-1)! x N ويمكن التعبير عن ذلك في بايثون كما يلي: def factorial(n): if n == 0: return 1 else: return n * factorial(n-1) يجب أن تنتهي الدالة بما أننا نقلل قيمة N في كل مرة ونتحقق هل تساوي 1 أم لا، لكن ثمة مشكلة بسيطة في هذا التعريف، إذ سيدخل في حلقة لا نهائية إذا استدعيناه برقم سالب، ولحل هذا نضيف اختبارًا للتحقق من أن n أقل من صفر، ويعيد None إذا كان كذلك لأن مضروب العدد السالب غير معرَّف undefined. يظهر هذا مدى سهولة ارتكاب أخطاء في شروط الإنهاء، وهي أشهر حالة للزلات البرمجية bugs في الدوال التعاودية، إذ يجب أن نتأكد من اختبار جميع القيم حول حالة الإنتهاء لضمان التنفيذ الصحيح. لنرى الآن كيف يكون هذا عند تنفيذه، لاحظ أن تعليمة الإعادة تعيد * n (نتيجة استدعاء المضروب التالي)، فنحصل على ما يلي: factorial(4) = 4 * factorial(3) factorial(3) = 3 * factorial(2) factorial(2) = 2 * factorial(1) factorial(1) = 1 وتعود بايثون أدراجها لتستبدل القيم كما يلي: factorial(2) = 2 * 1 = 2 factorial(3) = 3 * 2 = 6 factorial(4) = 4 * 6 = 24 ليس من الصعب كتابة دالة مضروب دون استخدام التعاودية، ويمكنك تجريب هذا، إذ يجب أن تمر على جميع الأعداد حتى N؛ وتنفذ عمليات ضرب أثناء ذلك المرور التكراري، لكن قد يصعب كتابة بعض الدوال دون التعاودية، كما سنرى أدناه. التعاودية على القوائم إحدى الحالات التي تكون التعاودية مفيدةً فيها هي معالجة القوائم Lists، بشرط أن نستطيع التحقق من فراغ القائمة، وتوليد قائمة دون عنصرها الأول، ونفعل هذا في بايثون باستخدام تقنية تسمى التشريح Slicing، لكن كل ما يجب معرفته في هذا الفصل هو أن استخدام فهرس [‎1:‎] يعيد جميع العناصر من العنصر ذي الفهرس 1 حتى نهاية القائمة، لذا نكتب ما يلي لنصل إلى العنصر الأول من قائمة اسمها L: first = L[0] # استخدم الفهرسة العادية وللوصول إلى بقية القائمة: # استخدم التشريح للوصول إلى العناصر 1،2،3 وما بعدها butfirst = L[1:] لنجرب ذلك في محث بايثون، لنتأكد أنه يعمل: >>> L =[1,2,3,4,5] >>> print( L[0] ) 1 >>> print( L[1:] ) [2,3,4,5] نعود الآن إلى استخدام التعاودية لطبع القوائم، ولنفرض حالةً نطبع فيها كل عنصر من قائمة سلاسل نصية باستخدام الدالة printList: def printList(L): if L: print( L[0] ) printList(L[1:]) إذا تحققت L -أي كانت true ولم تكن فارغةً- فسنطبع العنصر الأول، ثم نعالج بقية القائمة كما يلي: # شيفرة وهمية ليست بايثون PrintList([1,2,3]) prints [1,2,3][0] => 1 runs printList([1,2,3][1:]) => printList([2,3]) => we're now in printList([2,3]) prints [2,3][0] => 2 runs printList([2,3][1:]) => printList([3]) => we are now in printList([3]) prints [3][0] => 3 runs printList([3][1:]) => printList([]) => we are now in printList([]) "if L" is false for an empty list, so we return None => we are back in printList([3]) it reaches the end of the function and returns None => we are back in printList([2,3]) it reaches the end of the function and returns None => we are back in printList([1,2,3]) it reaches the end of the function and returns None لاحظ أن الشرح أعلاه مأخوذ من شرح في نشرة تعليم بايثون البريدية بواسطة Zak Arnston بتاريخ يوليو 2003. يسهل تنفيذ هذا الأمر لقائمة بسيطة باستخدام حلقة for، لكن ماذا لو كانت القائمة معقدةً وتحتوي قوائم أخرى فيها، فإذا استطعنا التحقق من كون عنصر ما قائمةً باستخدام دالة type()‎ المضمنة وكان قائمة حقًا؛ فسنستدعي printList()‎ تعاوديًا، أما إن لم يكن قائمةً فنطبعه: def printList(L): # لا تفعل شيًا إن كانت فارغة if not L: return # على العنصر الأول printList إذا كانت قائمة فاستدع if type(L[0]) == list: printList(L[0]) else: # لا توجد قوائم لذا نطبع هنا print( L[0] ) # L نعالج بقية عناصر printList( L[1:] ) سيصعب تنفيذ ذلك باستخدام الحلقة التكرارية العادية، ويظهر الفرق مع استخدام التعاودية في تسهيل ذلك التنفيذ، لكن ثمة مشكلة هنا، فالتعاودية على بنى البيانات الكبيرة يستهلك الذاكرة كثيرًا، لذا عند وجود ذاكرة صغيرة أو بنى بيانات كبيرة لمعالجتها، فيجب تفضيل الشيفرة المعتادة للأمان، وبسبب مشكلة الذاكرة تلك واحتمال أن يتعطل المفسر interpreter بسببها فقد وضعت بايثون حدًا لعدد مستويات التعاودية التي تسمح بها، فإذا تجاوزنا ذلك الحد فسيُنهى برنامجنا مع خطأ RecusrionError، والذي نلتقطه باستخدام try/except: >>> def f(n): return f(n+1) نلاحظ أن سبب هذه الحالة هو عدم وجود شرط إنهاء، لكن يجب أن تكون مجموعةً كبيرةً من بيانات الدخل كافيةً لإطلاقها حتى في الدوال المكتوبة بإتقان، وهنا يكون الحل الوحيد هو إعادة الكتابة مرةً أخرى باستخدام الحلقات التكرارية المعتادة، وهذا ممكن دومًا مهما بدا صعبًا. جافاسكربت ولغة VBScript تدعم كل من لغة جافاسكربت ولغة VBScript التعاودية، لكن بما أننا ذكرنا كل شيء تقريبًا فسنتركك مع نسخة تعاودية من دالة المضروب للغتين: <script type="text/vbscript"> Function factorial(N) if N < 0 Then Factorial = -1 'a negative result is "impossible" if N = 0 Then Factorial = 1 Else Factorial = N * Factorial(N-1) End If End Function Document.Write "7! = " & CStr(Factorial(7)) </script> <script type="text/javascript"> function factorial(n){ if (n < 0) return NaN // NaN - Not a Number - يعني أنه غير صالح if (n == 0) return 1; else return n * factorial(n-1); }; document.write("6! = " + factorial(6)); </script> لننظر الآن في البرمجة الدالية Functional Programming (أو البرمجة الوظيفية) في الفصل التالي. خاتمة نأمل بنهاية هذا الفصل أن تكون تعلمت ما يلي: تستدعي الدوال التعاودية نفسها من داخل تعريفها. يجب أن تحتوي الدوال التعاودية على شرط إنهاء غير تعاودي نصل إليه في النهاية، وإلا فسنقع في حلقة لا نهائية من التكرار. التعاودية مستهلكة للذاكرة عادةً، لكن ليس في كل الحالات. ترجمة -بتصرف- للفصل العشرين: Recursion, or doing it to yourself، من كتاب Learn To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: مقدمة إلى البرمجة الوظيفية Functional Programming المقال السابق: برمجة الواجهات الرسومية باستخدام Tkinter التعاود recursion والمكدس stack في جافاسكربت التعاود recursion في جافا مفهوم التعاود (Recursion) والكائنات القابلة للاستدعاء (Callable Objects) في Cpp
  9. سنلقي في هذا المقال نظرةً عامةً على كيفية تجميع برنامج رسومي بشرح المفاهيم الأساسية لبناء الواجهات الرسومية، ثم كيفية تنفيذه باستخدام صندوق أدوات Tkinter الرسومي الخاص ببايثون، لكن لن يكون هذا الشرح مرجعًا لصندوق Tkinter بحال من الأحوال، إذ يحتوي موقع بايثون على شرح مفصل له، مع ملاحظة أن ذلك الشرح يستخدم الإصدار الثاني من بايثون الذي يحتوي على أسماء مختلفة للوحدات، لذا يجب التحقق من قائمة وحدات بايثون للحصول على الأسماء الصحيحة للوحدات المستوردة، أما شرحنا فيستعرض أساسيات برمجة الواجهات الرسومية، ويشرح بعض المكونات الأساسية للواجهات وكيفية استخدامها، كما ينظر في كيفية استخدام البرمجة الكائنية التوجه في تنظيم تطبيق رسومي، وبهذا تكون العناصر الأساسية لهذا المقال هي: مفاهيم بناء الواجهات الرسومية البسيطة. الوِدجات البسيطة. هيكل برنامج Tkinter بسيط. الواجهات الرسومية والبرمجة الكائنية التوجه، تطابق مثالي. wxPython بديل Tkinter. مبادئ الواجهات الرسومية لن نتعرض لمفاهيم برمجية جديدة هنا، فبرمجة الواجهات الرسومية تشبه أي نوع آخر من البرمجة، إذ تحوي تسلسلات sequences، ووحدات modules، وفروعًا branches، وحلقات تكراريةً loops، أما الأمر الإضافي فيها فهو استخدام صندوق أدوات Toolkit، واتباعنا نمطًا معينًا في تصميم البرمجيات يحدده من كتب صندوق الأدوات ذاك، لذا يكون لكل صندوق أدوات مجموعته الخاصة من الوحدات والأصناف والدوال، والتي تعرف باسم واجهة برمجة التطبيقات Application Programming Interface، واختصارًا API، كما يحتوي صندوق الأدوات على مجموعة من قواعد التصميم، وعلينا -نحن المبرمجون- أن نتعلم واجهة برمجة التطبيقات وقواعد التصميم معًا، ولهذا يحاول أغلبنا اعتماد صناديق أدوات قليلةً تكون متاحةً في عدة لغات برمجة، لأن تعلم استخدام صندوق الأدوات أصعب من تعلم لغة البرمجة نفسها. صناديق أدوات الواجهات الرسومية تأتي أغلب لغات برمجة نظام التشغيل ويندوز مع صندوق أدوات مضمن فيها، ويكون طبقةً خفيفةً فوق صندوق الأدوات البدائي المضمن في نظام النوافذ نفسه، ويوجد في لغات مثل Visual Basic وDelphi وVisual C++/.NET، أما جافا فتختلف عن لغات ويندوز في أنها تحتوي على صندوق أدوات الرسوم الخاص بها، بل أكثر من صندوق واحد في الواقع، وتعمل هذه الصناديق على أي منصة تعمل عليها جافا، وهذا يعني جميع المنصات تقريبًا، وتوجد صناديق أدوات أخرى يمكن الحصول عليها بشكل مستقل وتُستخدم في أي نظام تشغيل سواء كان لينكس أو ويندوز أو ماك، وتحتوي محولات adapters لتسمح للغات المختلفة باستخدامها، وبعض تلك الصناديق تجاري مدفوع، وبعضها مجاني أو حر، والأمثلة على هذه الصناديق تشمل GTK وQt وTk وwxWidegets، ولها جميعًا مواقع وتدعم ويندوز وماك ولينكس: wxPython: النسخة الخاصة ببايثون من wxWidgets، وهو مكتوب بلغة C++‎، وتسمى رابطة بايثون إلى wxWidgets باسم WxPython. PyQt, the Qt toolkit: يحتوي على روابط لأغلب لغات البرمجة، وتُعرف روابط بايثون إلى Qt باسم PyQt. GTK+‎: مجموعة من العناصر والأدوات لإنشاء واجهات رسومية، ورابطة بايثون فيه اسمها PyGTk. تُكتب أغلب برامج لينكس باستخدام Qt وGTk، وكلاهما مجاني للاستخدامات غير التجارية، ويوفر Qt رخصةً تجاريةً لمن شاء، في حين أن رخصة GTk هي رخصة Gnu GPL التي لها شروطها الخاصة بها. صندوق الأدوات الرسومي الخاص ببايثون والذي يأتي افتراضيًا مع اللغة هو Tkinter، المبني على Tk، وهو صندوق أدوات قديم متعدد نظم التشغيل، وسندرسه هنا، حيث توجد منه إصدارات للغات Tcl وHaskell وRuby وPerl وبايثون، وتختلف المبادئ المستخدمة في Tk عن صناديق الأدوات الأخرى قليلًا، لذا سنذكر باختصار نظرةً على صندوق أدوات رسومي آخر لبايثون و C و C++‎، يكون تقليديًا في منظوره العام، لكن سنتعرف أولًا على بعض المفاهيم العامة. عناصر الواجهات الرسومية الأساسية ذكرنا سابقًا أن التطبيقات الرسومية ذات طبيعة حدَثية -مدفوعة بالأحداث event-driven- في الغالب، ويمكن الرجوع للمقال السابق، للاطلاع على هذا المفهوم، وسنفترض أنك مستخدم معتاد على التعامل مع الواجهات الرسومية، وسنركز على كيفية عمل البرامج الرسومية من منظور المبرمج، ولن ندخل في تفاصيل كيفية كتابة واجهات رسومية معقدة وكبيرة لها نوافذ متعددة أو واجهات MDI أو غيرها، بل سنكتفي بأساسيات إنشاء تطبيق وحيد النافذة فيه بعض العناوين والأزرار والحقول النصية وصناديق الرسائل. نحتاج أولًا إلى التحقق من المصطلحات التي سنستخدمها، فلبرمجة الواجهات مجموعتها الخاصة من المصطلحات البرمجية، وأكثر المصطلحات استخدامًا فيها هي: النافذة window جزء من الشاشة يتحكم فيه التطبيق، وتكون النوافذ مربعةً في العادة، لكن قد تسمح بعض البيئات الرسومية بأشكال أخرى، وقد تحتوي النوافذ على نوافذ أخرى داخلها، ويُعامل كل متحكم رسومي GUI control على أنه نافذة بذاته. المتحكم Control كائن رسومي يُستخدم للتحكم في التطبيق، وتحتوي المتحكمات على خصائص properties، وتولّد أحداثًا events، وتستجيب عادةً لكائنات على مستوى التطبيق، حيث تُرفَق الأحداث بتوابع الكائن الموافق corresponding object، فإذا وقع الحدث نفّذ الكائن أحد توابعه، وتوفر الواجهة الرسومية آليات لربط الأحداث بالتوابع. الودجِت Widget متحكم مقيَّد عادةً بالمتحكمات المرئية، إذ يمكن ربط بعض المتحكمات -مثل المؤقتات timers- بنافذة ما، لكن دون أن تكون مرئيةً، أما الودجات فهي فئة مرئية فرعية من المتحكمات، ويمكن للمستخدم أو المبرمج أن يعدل فيها. سنغطي في هذا المقال الودجات Frame وLabel وButton وText Entry وMessage box، كما سنتعرض لاحقًا للودجات Text box وRadio Button، أما الودجات التي لن نذكرها مطلقًا فهي Canvas الخاصة بالرسم، وCheck button الخاصة بالاختيار المتعدد، وImage الخاص بعرض صور BMP وGIF وJPEG وPNG، وListbox للقوائم، و Menu/MenuButton لبناء القوائم المنسدلة menus، وScale/Scrollbar التي توضح الموضع. الإطار Frame نوع من الودجات يُستخدم لدمج ودجات أخرى معًا، ويُستخدم عادةً لتمثيل النافذة ككل، ويمكن دمج إطارات أخرى فيه. التخطيط Layout توضع المتحكمات في إطار وفقًا لمجموعة محددة من القواعد أو الإرشادات، وتشكل تلك القواعد ما يعرف بالتخطيط، وقد يُحدَّد بعدة طرق، مثل استخدام إحداثيات محددة بالبكسل على الشاشة، أو باستخدام مواضع نسبية لمكونات أخرى (مثل اليسار، الأعلى)، أو باستخدام شبكة أو جدول. ومن السهل فهم نظام الإحداثيات، لكن تصعب إدارته عند تعديل حجم النوافذ مثلًا، ويُنصح المبتدئون باستخدام نوافذ لا يمكن تغيير حجمها إذا استخدموا تخطيطات مبنيةً على إحداثيات. الأب/الابن Parent/Child تميل التطبيقات الرسومية لأن تتكون من تسلسل هرمي من المتحكمات أو الودجات، حيث يحتوي إطار المستوى الأعلى الذي يشغل نافذة التطبيق على إطارات فرعية تحتوي على إطارات أو متحكمات أخرى، وينظر إلى تلك المتحكمات على أنها هيكل شجري يحتوي كل متحكم فيه على أب واحد وعدة أبناء، بل يُخزَّن هذا الهيكل عادةً بصراحة بواسطة ودجات، ليستطيع المبرمج -أو البيئة الرسومية نفسها غالبًا- تنفيذ بعض الإجراءات الشائعة لمتحكم ما وجميع أبنائه، فإغلاق الودجت العليا ينتج عنه إغلاق جميع الودجات الفرعية. شجرة الاحتواء Containment tree من المفاهيم التي يجب استيعابها في برمجة الواجهات الرسومية هرمية الاحتواء containment hierarchy، حيث تُحتوى الودجات داخل هيكل شجري تتحكم فيه ودجت المستوى الأعلى بالواجهة كلها، ويكون لذلك الهيكل ودجات فرعية يمكن أن تحتوي بدورها على ودجات أخرى فرعية خاصة بها، وتصل الأحداث إلى ودجت فرعية تمرر الحدث -إذا لم تستطع معالجته- إلى الودجت الرئيسية (الأب) لها، وهكذا إلى أن نصل إلى ودجت المستوى الأعلى، وإذا أُعطي أمر لرسم ودجت ما فسيرسَل الأمر إلى الودجات الفرعية، وعليه فإن أمر الرسم إلى ودجت المستوى الأعلى قد يعيد رسم التطبيق كله، في حين أن أمر الرسم المرسَل إلى زر ما لن يعيد إلا رسم الزر فقط. ومفهوم صعود الأحداث إلى أعلى الشجرة ودفع الأوامر إلى الأسفل ضروري لفهم كيفية عمل الواجهات الرسومية بالنسبة للمبرمج، وهو السبب في أننا نحتاج إلى تحديد أب للودجت عند إنشائها، لتعرف الودجت مكانها في شجرة الاحتواء. وتُرسم شجرة الاحتواء لتطبيق بسيط سننشئه لاحقًا في هذا الموضوع كما يلي: توضح الصورة أعلاه ودجت المستوى الأعلى التي تحتوي على إطار Frame واحد يمثل الحد border الخارجي للنافذة، والذي يحتوي بدوره على إطارين آخرين، في الأول منهما ودجت Text Entry، وفي الثاني زران Buttons يُستخدمان للتحكم في التطبيق، وسنشير إلى هذا المخطط لاحقًا حين نأتي لبناء الواجهة الرسومية. نظرة على بعض الودجات الشائعة سنستخدم محث بايثون التفاعلي في هذا القسم لإنشاء بعض النوافذ والودجات البسيطة، ورغم أن IDLE نفسه ما هو إلا تطبيق Tkinter لكن لا يُمكن الاعتماد عليه في تشغيل تطبيقات Tkinter داخله، وإنما نستطيع إنشاء الملفات باستخدامه مثل محرر، ثم نشغلها في بيئة التطوير IDE كالمعتاد، رغم احتمال حدوث أشياء غير متوقعة هنا، فإذا وقعت تصرفات غريبة فيجب أن نشغل التطبيق من سطر أوامر النظام قبل أن نجرب شيئًا آخر، فربما تكون المشكلة تعارضًا بين إطار Tkinter الداخلي لبيئة IDLE وبين نسخته لبرنامجنا، فإذا لم تُحل المشكلة فسنبحث عن الزلات البرمجية bugs في شيفرة البرنامج. أما مستخدمو Pythonwin فيستطيعون تشغيل تطبيقات Tkinter مباشرةً دون مشاكل، لأن Pythonwin لا تستخدم Tkinter داخليًا، لكن حتى هنا قد تحدث سلوكيات غير متوقعة في تطبيقات Tkinter، ولهذا نستخدم محث بايثون الأساسي من نافذة نظام التشغيل الطرفية دومًا: >>> from tkinter import * هذا أول متطلبات أي برنامج Tkinter، وهو استيراد أسماء الودجات، يجب أن نستورد الوحدة، لكننا لا نريد كتابة tkinter قبل كل اسم مكون، لذا نستخدم الاسم البديل tk: >>> import tkinter as tk وهذا يعني أننا نحتاج إلى سبق الأسماء بـ tk.‎ فقط، لكن بما أننا نجرب هنا فمن الأسهل استيراد كل شيء: >>> top = Tk() ينشئ هذا ودجت المستوى الأعلى في هرمية الودجات، ونلاحظ حالة الأحرف في Tk، حيث كان اسم الوحدة بالأحرف الصغيرة، لكن اسم الودجت بأحرف كبيرة، وتُنشأ جميع الودجات الأخرى فروعًا لودجت top. يعتمد ما يحدث هنا على المكان الذي نكتب البرنامج فيه، فإذا كنا نستخدم بايثون من محث النظام فيجب أن تظهر نافذة جديدة كاملة بشريط عنوان فارغ مع شعار Tk أيقونةً، وأزرار التحكم المعتادة من التكبير والتصغير وغيرها، أما إذا كنا نستخدم بيئة تطوير IDE فقد لا نرى شيئًا، وقد لا تظهر النافذة إلا عند إكمال واجهة المستخدم الرسومية والبدء بتشغيل حلقة الحدث الرئيسية. سنضيف المكونات إلى هذه النافذة أثناء بنائنا للتطبيق، ولننظر الآن إلى ما لدينا: >>> dir(top) [....lots of stuff!...] توضح دالة dir()‎ الأسماء المعروفة للوسيط، ونستطيع استخدامها على الوحدات لكننا ننظر هنا إلى المكونات الداخلية للكائن top، وهو نسخة من الصنف Tk، حيث نلاحظ وجود سمات كثيرة للكائن top، نخص بالذكر منها children وmaster اللتان تمثلان روابط إلى شجرة احتواء الودجت، كما نلاحظ سمة _tclCommands_، لأن Tkinter بُني على صندوق أدوات من Tcl اسمه Tk. >>> F = Frame(top) لننشئ ودجت إطار Frame هنا تحتوي المتحكمات/الودجات الفرعية التي نستخدمها، ويحدد Frame كائن top على أنه معامله الأول -والوحيد في هذه الحالة-، وعليه فإن F ستكون ودجت فرعيةً للكائن top، ,يمكن التحقق من هذا بسهولة كما يلي: >>> top.children {'!frame': <tkinter.Frame object .!frame>} >>> F.master <tkinter.Tk object .> نرى هنا أن top.children ما هو إلا قاموس يربط اسمًا غريبًا قليلًا هو ‎'!frame'‎ بمرجع كائن object reference، وهذه التسمية الغريبة خاصة بـ Tcl/Tk وانتقلت إلى Tkinter، كما أن F.master ما هو إلا مرجع إلى top، وننصحك هنا بالتدرب على السمتين master وchildren لودجاتنا، مما يسهل عليك فهم الصلة بين الودجات وشجرة الاحتواء التي ذكرناها من قبل. >>> F.pack() لاحظ الآن تقلص نافذة Tk -إذا كانت مرئيةً- إلى حجم ودجت الإطار المضاف، وهي صغيرة للغاية لأن الودجت فارغة، لكن ينبغي أن نكون قادرين على تغيير حجمها بالفأرة وأزرار التصغير والتكبير في شريط عنوانها. يستدعي التابع pack()‎ مدير تخطيط يُعرف باسم المحزِّم Packer، وهو سهل الاستخدام في التخطيطات البسيطة، لكنه يصبح صعبًا كلما زاد تعقيد التخطيط، وسنستخدمه الآن لسهولته، حيث يكدس هذا المحزِّم الودجات بعضها فوق بعض، ونلاحظ أن الودجات لن تكون مرئيةً في تطبيقنا حتى نحزمها أو نستخدم تابع مدير تخطيط آخر، وسنتحدث عن مدراء التخطيطات لاحقًا، بعد إنهاء هذا البرنامج. >>> lHello = Label(F, text="Hello world") ننشئ هنا كائنًا جديدًا هو lHello، وهو نسخة من الصنف Label مع ودجت أب هي F، وسمة text قيمتها "Hello World"، نلاحظ أنه من المعتاد استخدام تقنية المعامِل المسمى بتمرير الوسائط إلى كائنات Tkinter بسبب ميل منشئات كائنات Tkinter لامتلاك عدة معامِلات لكل منها قيمته الافتراضية، كما نلاحظ أن الكائن ليس مرئيًا بعد لأننا لم نحزّمه. كما نلاحظ استخدام اصطلاح تسمية هنا، وهو حرف l الذي يشير إلى كلمة Label قبل الاسم Hello الذي يذكرنا بالغرض منه، ومسألة اصطلاحات التسمية هذه هي في الحقيقة أمور شخصية، لكنها مفيدة لهذا الغرض الذي ذكرناه. >>> lHello.pack() نستطيع الآن أن نراها، وينبغي أن تكون النافذة التي أنشأتها لديك شبيهة بهذه: تُحدَّد خصائص Label -مثل الخط واللون- باستخدام معامِلات منشئ الكائن أيضًا، ونستطيع الوصول إلى الخصائص الموافقة corresponding باستخدام التابع configure الخاص بودجات Tkinter كما يلي: >>> lHello.configure(text="Goodbye") لقد تغيرت الرسالة هنا، لذا نرى أن تقنية configure ممتازة عند تغيير عدة خصائص مرةً واحدةً لتمريرها جميعًا على أنها وسطاء، لكن إذا أردنا تغيير خاصية واحدة فقط في كل مرة كما فعلنا أعلاه فيمكن معاملة الكائن على أنه قاموس، وهذا أقصر وأسهل في الفهم: >>> lHello['text'] = "Hello again" إن ودجات العناوين مملة، ولا تعرض إلا نصوصًا قابلة للقراءة فقط، وإن كانت بألوان وخطوط مختلفة، رغم إمكانية استخدامها لعرض رسوم بسيطة، لكننا لن نتطرق إلى هذا هنا، نقول هذا لننظر في نوع كائن آخر، لكن ثمة شيء نفعله وهو إعداد عنوان النافذة، وذلك باستخدام تابع من ودجت المستوى الأعلى top: >>> F.master.title("Hello") كان بإمكاننا استخدام top مباشرةً، لكن الوصول من خلال الخاصية الرئيسية للإطار مفيد، كما سنرى لاحقًا: >>> bQuit = Button(F, text="Quit", command=F.quit) ننشئ هنا ودجت جديدةً هي زر له عنوان Quit، ويرتبط بالأمر F.quit، لاحظ أننا نمرر اسم التابع ولا نستدعيه بإضافة أقواس بعده، وهذا يعني أننا يجب أن نمرر كائن دالة وفقًا لبايثون، سواء كان تابعًا مضمنًا في Tkinter -كما ف حالتنا- أو أي دالة أخرى نعرّفها. يجب ألا تأخذ الدالة أو التابع أي وسطاء، ويُعرَّف التابع quit في صنف أساسي -وكذلك التابع pack- وترثه جميع ودجات Tkinter، لكنه يُستعدى في الغالب في مستوى النافذة العليا للتطبيق. >>> bQuit.pack() يجعل التابع pack الزر مرئيًا مرةً أخرى هنا، رغم أنك قد تحتاج إلى تغيير حجم النافذة لتراه وفقًا لإعدادات نظام التشغيل لديك، فلن يحدث شيء إذا ضغطت عليه، لأننا لا نملك حلقة أحداث تعمل الآن لتلتقط حدث ضغط الزر وتعالجه. >>> top.mainloop() وأخيرًا نبدأ حلقة حدث Tkinter، لاحظ اختفاء محث بايثون ‎>>>‎، وهذا يخبرنا أن Tkinter هو المتحكم الآن، فإذا ضغطنا زر Quit فسيعود المحث ليثبت لنا أن خيار command يعمل، لا تتوقع أن تنغلق النافذة، فلا زال مفسر بايثون يعمل ولم نرد إلا الخروج من دالة mainloop، فإذا خرجنا من بايثون فستُدمَّر الودجات الموجودة، ويكون هذا -في البرامج الحقيقية- بعد انتهاء mainloop مباشرة. ونلاحظ أنه إذا شغلنا هذا من IDLE أو Pythonwin فلم نكن لنرى شيئًا إلى الآن، ولحصلنا على نتيجة مختلفة قليلًا، وإذا حدث هذا معك فاكتب الأوامر التي كتبناها إلى الآن في سكربت بايثون ثم شغلها من سطر أوامر النظام، ومن المناسب الآن أن تجرب ذلك على أي حال بما أنه الكيفية التي ستشغل بها برامج Tkinter في الممارسة العملية، واستخدم الأوامر الأساسية التي شرحناها حتى الآن وكما وضحنا، واستخدم نمط الاستيراد المفضل: import tkinter as tk # إعداد النافذة ذاتها top = tk.Tk() F = tk.Frame(top) F.pack() # إضافة الودجات lHello = tk.Label(F, text="Hello") lHello.pack() bQuit = tk.Button(F, text="Quit", command=F.quit) bQuit.pack() # تشغيل الحلقة التكرارية top.mainloop() يبدأ استدعاء التابع top.mainloop حلقة حدث Tkinter لتوليد الأحداث، والحدث الوحيد الذي نلتقطه في هذه الحالة سيكون حدث ضغط الزر المتصل بالتابع F.quit، وينهي الأخير التطبيق وستنغلق النافذة هذه المرة لأن بايثون قد خرجت هي الأخرى، جربها بنفسك الآن، ينبغي أن تبدو كما يلي: لاحظ أننا نسينا السطر الذي يغير عنوان النافذة، جرب إضافة ذلك السطر بنفسك وتحقق من نجاح ذلك. استكشاف التخطيط سنبدأ من الآن بإعطاء الأمثلة في ملفات سكربتات بايثون بدلًا من أوامر في محث ‎>>>‎، وسنوفر مقتطفات من الشيفرات في الغالب لتكتب أنت الاستدعاءات إلى Tk()‎ وmainloop()‎ بنفسك، فاستخدم البرنامج السابق قالبًا، وسننظر في هذا القسم في توضع الودجات في النافذة في Tkinter، وقد رأينا ودجات Frame وLabel وButton من قبل، وهي كل ما نحتاج إليه في هذا القسم، وقد استخدمنا التابع pack الخاص بالودجت في المثال السابق لتحديد موقعها في الودجت الأب لها، والواقع أن ما نفعله هو استدعاء مدير تخطيط المحزِّم الخاص بـ Tk، ويطلق عليه أحيانًا اسم المدير الهندسي Geometry Manager، ووظيفته تحديد أفضل تخطيط للودجات بناءً على الإرشادات التي يوفرها المبرمج، إضافةً إلى القيود مثل حجم النافذة التي يتحكم بها المستخدم، ويستخدم بعض مدراء التخطيطات نفس المواقع داخل النافذة محددةً بالبكسل، وهذا أمر شائع في بيئات ويندوز مثل Visual Basic. ويحتوي Tkinter على مدير تخطيط واضع placer layout manager يستطيع تنفيذ ذلك من خلال تابع place، ولن ننظر فيه لوجود خيارات أخرى أفضل وأكثر ذكاءً للمدراء، لأنها توفر علينا التفكير -نحن المبرمجين- في ما يحدث عند تغيير حجم نافذة ما، وأبسط مدير تخطيط في Tkinter هو برنامج التحزيم الذي كنا نستخدمه، وهو يكدس الودجات بعضها فوق بعض، ويمكن تغيير هذا السلوك لتكديس الودجات يسارًا ويمينًا لكن هذا محدود للغاية، ونادرًا ما نريد ذلك من الودجات العادية، لكن إذا أردنا بناء تطبيقاتنا من إطارات Frames فيجب أن نكدس الإطارات فوق بعضها، ثم نستطيع وضع الودجات الأخرى في الإطارات باستخدام المحزِّم أو مدير تخطيط آخر داخل كل إطار حسب الحاجة، ويمكن أن يكون لكل إطار مدير التخطيط الخاص به، لكننا لا نستطيع الجمع بين المدراء في إطار واحد. يوفر المحزِّم -وإن كان بسيطًا- عدة خيارات، إذ نستطيع ترتيب ودجاتنا رأسيًا أو أفقيًا، ونستطيع تعديل أحجامها وتعديل الفواصل بينها والإطار التي تحاذيه، لننظر في المثال التالي على التحزيم الأفقي: lHello = tk.Label(F, text="Hello") lHello.pack(side="left") bQuit = tk.Button(F, text="Quit", command=F.quit) bQuit.pack(side="left") سيؤدي هذا إلى إجبار الودجات على الانتقال إلى اليسار، لذا ستظهر الودجت الأولى -وهي العنوان- في أقصى اليسار، متبوعةً بالودجت التالية -الزر-، فإذا عدّلنا الأسطر في المثال أعلاه فسيبدو كما يلي: وإذا غيرنا "left" إلى "right" فسيظهر العنوان على أقصى اليمين، وسيظهر الزر على يساره، كما يلي: يجب أن نلاحظ أن الشكل غير لطيف، لأن الودجات مضغوطة إلى جانب بعضها البعض، ويزودنا المحزِّم ببعض المعامِلات للتعامل مع ذلك، لعل أسهلها الحشو padding الذي يُحدَّد بحشو أفقي padx ورأسي pady، وتُكتب تلك القيم بالبكسل، لنضف حشوًا أفقيًا إلى مثالنا: lHello.pack(side="left", padx=10) bQuit.pack(side='left', padx=10) يجب أن يبدو كما يلي: إذا حاولنا تغيير عرض النافذة فسنرى أن الودجات تحافظ على مواضعها النسبية، لكنها ستظل في مركز النافذة، لأننا -رغم إضافتنا الحشو إلى اليسار- حزمنا الودجات في إطار Frame؛ وحزّمنا الإطار نفسه دون جانب side، لذا فإن موضعه يكون إلى الأعلى والمنتصف، وهو الافتراضي للمحزِّمات، فإذا أردنا أن تبقى الودجات في الجانب الصحيح من النافذة فيجب أن نحزم الإطار إلى الجانب الصحيح كذلك: F.pack(side='left') نلاحظ هنا أن الودجات تظل في المنتصف إذا غيرنا حجم النافذة الرأسي، وهذا هو السلوك الافتراضي للمحزِّمات أيضًا، سنترك لك تجربة تغيير padx وpady لترى تأثير القيم المختلفة عليها. وتسمح side وpadx/pady بمرونة كبيرة في تحديد مواضع الودجات باستخدام المحزِّم، وتوجد خيارات أخرى يضيف كل منها شكلًا خفيفًا من أشكال التحكم، يُرجَع فيها إلى توثيق Tkinter. يوجد عدة مدراء تخطيطات آخرين في Tkinter مثل الشبكة grid والواضع placer، إضافةً إلى وحدة Tix التي تعزز Tkinter وتوفر مدير التخطيط Form، لكننا لن نشرح هذه الوحدة هنا لأنها أُهملت في المكتبة القياسية رسميًا. نستخدم grid()‎ إذا أردنا استخدام مدير الشبكة grid manager، بدلًا من pack()‎ التي استخدمناها أعلاه، أما في الواضع placer فنستدعي place()‎ بدلًا من pack()‎، ولكل منها مجموعة خيارات خاصة، وبما أننا سنشرح المحزِّم فقط هنا فيُرجَع إلى توثيق Tkinter لمزيد من التفاصيل عن هؤلاء المدراء، لكن النقاط الأساسية التي نريد الإشارة إليها هنا هي ما يلي: تنظم الشبكة المكونات في "شبكة" داخل النافذة، وهذا مفيد في الصناديق الحوارية التي تحوي صناديق إدخال نصيةً مرتبةً مثلًا، ويفضل العديد من مستخدمي Tkinter استخدام الشبكة على المحزِّم، لكن قد يحتاج المبتدئ وقتًا حتى يتعلمها، خاصةً عندما يريد أن يشغل مكون عدة خلايا من الشبكة. يستخدم الواضع إحداثيات ثابتةً بالبكسل أو إحداثيات نسبيةً داخل النافذة، وتسمح الأخيرة بتغيير حجم المكونات مع تغير حجم النافذة، كأن تظل المساحة التي تشغلها 75% من المساحة الرأسية للنافذة مثلًا، لكن قد يبدو مظهر الأزرار غريبًا إذا زاد عرضها كثيرًا، وتظهر فائدة هذا في التصاميم المعقدة للنوافذ، لكنها تحتاج إلى كثير من التخطيط المسبق، لذا يُنصح باستخدام الورق والقلم! التحكم في المظهر باستخدام الإطارات والمحزم تحتوي ودجت الإطار Frame على العديد من الخصائص المفيدة التي يمكن استخدامها، فمن الجميل أن يكون لدينا إطار منطقي غير مرئي حول المكونات، لكننا قد نرغب في رؤيته، خاصةً عند جمع عدة متحكمات مثل أزرار الانتقاء radio buttons أو صناديق الاختيار check boxes، ويحل الإطار هذه المشكلة بتوفير حد يُعرف بخاصية المساعدة relief property، على غرار العديد من ودجات Tkinter الأخرى، يمكن أن تأخذ Relief إحدى القيم التالية: sunken: غائر. raised: مرتفع. groove: محفور. ridge: مشطوف. flat: مسطح. لنستخدم القيمة sunken على صندوق حوارنا البسيط، بتغيير سطر إنشاء الإطار Frame إلى ما يلي: F = Frame(top, relief="sunken", border=1) لاحظ أن عليك وضع حد border أيضًا، فإن لم تفعل فسيكون الإطار غاطسًا لكنه سيكون مخفيًا لأن حدوده غير مرئية، فلن ترى فرقًا إذا لم تضف الحد، ولا تضع حجم الحد بين علامتي اقتباس، وهذا أحد الأمور المربكة في برمجة Tk، أي معرفة متى نستخدم علامات الاقتباس حول خيار ما ومتى لا نستخدمها، لكن القاعدة العامة هي أنه يمكن ترك علامات الاقتباس إذا كانت القيمة عدديةً، أما إذا كانت مزيجًا بين الأرقام والحروف أو سلسلةً نصيةً فيجب وضع علامات الاقتباس، يمكن قول ذلك على حالة الأحرف التي يجب استخدامها، لكن للأسف لا توجد طريقة سهلة لمعرفة ذلك هنا، بل يجب أن نتعلمها بالخبرة، وتعطيك بايثون عادةً قائمةً من الخيارات الصالحة في رسائل الخطأ الخاصة بها. ومما يجب ملاحظته أيضًا أن الإطار لا يملأ النافذة، ونستطيع إصلاح ذلك بخيار محزِّم آخر هو fill، فنفعل ما يلي عند تحزيم الإطار: F.pack(fill="x") ستُملأ النافذة عرضيًا كما هو واضح من الإحداثي x، فإذا أردنا ملأها كلها فنستخدم fill='y'‎ أيضًا، ويوجد خيار ملء خاص هو both بسبب شيوع هذه العملية: F.pack(fill="both") ينبغي أن تكون النتيجة النهائية بعد تشغيل السكربت كما يلي: إضافة ودجات أخرى لننظر الآن في الودجت Entry، وهو السطر الواحد ذو الشكل المألوف لصندوق الإدخال النصي، والذي يتشارك الكثيرَ من التوابع مع ودجت النص متعدد الأسطر الذي استخدمناه في المقال السابق، كما سنستخدمه في مقال لاحق، وسنستخدم Entry لالتقاط النص الذي يكتبه المستخدم، ولمحو ذلك النص عند الحاجة. بالعودة إلى مثال Hello World، سنضيف ودجت إدخال نصي داخل إطار خاص به، ثم نضع زرًا في إطار ثانٍ يستطيع مسح ذلك النص الذي نكتبه داخل خانة الكتابة، ثم نضيف زرًا آخر للخروج من التطبيق، وهذا يوضح كيفية إنشاء واستخدام ودجت الإدخال، وكيفية تعريف دوال معالجة الأحداث الخاصة بنا وتوصيلها بالودجات. import tkinter as tk # أنشئ معالج الحدث لمسح النص def evClear(): eHello.delete(0,tk.END) # أنشئ نافذة/إطار المستوى الأعلى top = tk.Tk() F = tk.Frame(top) F.pack(fill="both") # والآن الإطار الذي فيه الإدخال النصي fEntry = tk.Frame(F, border=1) eHello = tk.Entry(fEntry) fEntry.pack(side="top") eHello.pack(side="left") # وأخيرًا الإطار الذي فيه الأزرار # سنجعل هذا غاطسًا لنبرزه fButtons = tk.Frame(F, relief="sunken", border=1) bClear = tk.Button(fButtons, text="Clear Text", command=evClear) bClear.pack(side="left", padx=5, pady=2) bQuit = tk.Button(fButtons, text="Quit", command=F.quit) bQuit.pack(side="left", padx=5, pady=2) fButtons.pack(side="top", fill="x") # والآن شغل حلقة الحدث F.mainloop() نلاحظ هنا أننا عرّفنا معالج الحدث مثل أي دالة أخرى، وبما أننا نريد إسنادها إلى حدث الأمر command event لزر فنعرف أنها يجب ألا تحتوي على معامِلات، رغم أن بعض معالجات الأحداث -مثل أحداث الفأرة- قد تأخذ معامِلات، لكن يجب التحقق من التوثيق لمعرفة المطلوب للحدث. لاحظ أيضًا أننا نمرر أسماء معالجات الأحداث evClear وF.quit -دون أقواس- قيمًا للمعامل command للأزرار، ولاحظ استخدام اصطلاح التسمية evXXX لربط معالج الحدث بالودجت XXX الموافقة له، لذا سيكون evClear هو معالج الحدث لودجت bClear. يستدعي معالج الحدث التابع delete الخاص بالودجت Entry، ورغم أن نظام الفهرسة المستخدم للوسطاء معقد قليلًا إلا أننا نستطيع في هذا المستوى أن نقول بأنه يمسح النص من الموضع 0 -أي من البداية- إلى الموضع tk.END -آخر موضع-، ولاحظ أن tk.END ثابت معرَّف في tkinter، ويوجد غيره مما يمكن استخدامه بدلًا من السلاسل الاختيارية right وleft وtop وغيرها، وذلك راجع لما يفضله كل منا، وبتشغيل البرنامج نحصل على النتيجة التالية: فإذا كتبت شيئًا في صندوق الإدخال النصي فاضغط Clear Text لمسحه مرةً أخرى. لاحظ أننا بنينا الواجهة الرسومية التي رسمها مخطط الاحتواء في بداية هذا المقال، فللودجت العليا إطار Frame تحتها، وفيه إطاران تحته، واحد فيه ودجت إدخال والآخر فيه زران، وهذا ما نراه في المخطط بالضبط. ولا توجد فائدة تذكر من وجود ودجت إدخال إلا إذا كنا نستطيع الوصول إلى النص الذي فيها، ونفعل ذلك باستخدام التابع get الخاص بالودجت، وسنوضح هذا بنسخ النص من الودجت إلى عنوان label قبل مسحه، لنستطيع رؤية النص الأخير الذي كان في الودجت، ولفعل ذلك نحتاج إلى إضافة ودجت عنوان تحت ودجت الإدخال، وتوسيع معالج الحدث evClear لينسخ النص، وسنلون نص العنوان بلون أزرق فاتح لإبرازه. import tkinter as tk # أنشئ معالج الحدث لمسح النص def evClear(): lHistory['text'] = eHello.get() eHello.delete(0,tk.END) # أنشئ نافذة/إطار المستوى الأعلى top = tk.Tk() F = tk.Frame(top) F.pack(fill="both") # والآن الإطار الذي فيه الإدخال النصي fEntry = tk.Frame(F, border=1) eHello = tk.Entry(fEntry) eHello.pack(side="left") lHistory = tk.Label(fEntry, foreground="steelblue") lHistory.pack(side="bottom", fill="x") fEntry.pack(side="top") # وأخيرًا الإطار الذي فيه الأزرار # سنجعل هذا غائرًا لنبرزه fButtons = tk.Frame(F, relief="sunken", border=1) bClear = tk.Button(fButtons, text="Clear Text", command=evClear) bClear.pack(side="left", padx=5, pady=2) bQuit = tk.Button(fButtons, text="Quit", command=F.quit) bQuit.pack(side="left", padx=5, pady=2) fButtons.pack(side="top", fill="x") # والآن شغل حلقة الحدث F.mainloop() نرى هنا أننا أضفنا السطر التالي عند إنشاء معالج الحدث الذي يمسح النص: lHistory['text'] = eHello.get() كما أضفنا الأسطر التالية في الإطار الذي يحتوي على إدخال نصي: lHistory = tk.Label(fEntry, foreground="steelblue") lHistory.pack(side="bottom", fill="x") من الممكن إسناد النص إلى متغير بايثون عادي لنستخدمه لاحقًا في برنامجنا، رغم أننا أسندناه مباشرةً إلى خاصية Label هنا. أحداث الربط: من الودجات إلى الشيفرة لقد استخدمنا الخاصية command للأزرار إلى الآن لربط دوال بايثون مع أحداث الواجهة الرسومية، لكننا قد نريد تحكمًا أكثر من هذا أحيانًا، لالتقاط تجميعة مفاتيح معينة مثلًا، ونفعل ذلك باستخدام دالة bind لربط حدث ما مع دالة بايثون صراحةً. سنعرِّف الآن مفتاحًا ساخنًا مثل CTRL+C لحذف النص في المثال أعلاه، سنحتاج هنا أن نربط تجميعة المفاتيح CTRL+C بنفس معالج الحدث الخاص بزر Clear، لكننا سنواجه مشكلةً غير متوقعة هنا، إذ يجب ألا تأخذ الدالة المحددة أي وسطاء عند استخدامنا للخيار command، أما حين نستخدم دالة الربط bind لأداء نفس الوظيفة فيجب أن تأخذ الدالة المحددة وسيطًا واحدًا، لذا نحتاج إلى إنشاء دالة جديدة ذات معامِل وحيد تستدعي evClear، أضف الشيفرة التالية بعد تعريف evClear: def evHotKey(event): evClear() ثم أضف السطر التالي مباشرةً بعد تعريف ودجت الإدخال eHello: eHello.bind("<Control-c>",evHotKey) # تعريف المفتاح حساس لحالة الأحرف شغّل البرنامج الآن مرةً أخرى، ستستطيع مسح النص الآن بضغط الزر أو باستخدام تجميعة المفاتيح CTRL+C، ويمكن استخدام الربط لالتقاط نقرات الفأرة أو فقدان تركيز النافذة Focus -أي كونها نشطةً أو غير نشطة-، أو حتى كون النافذة مرئيةً أم لا، ويُرجَع في هذا إلى توثيق Tkinter لمزيد من المعلومات، وسيكون الجزء الأصعب هو معرفة صيغة وصف الحدث. الرسالة القصيرة من الممكن إبلاغ رسائل قصيرة للمستخدمين باستخدام MessageBox، وهذا سهل للغاية في Tk ويمكن تنفيذه باستخدام دوال وحدة messagebox كما يلي: from tkinter import messagebox messagebox.showinfo("Window Text", "A short message") هناك أيضًا صناديق الخطأ والتحذير وصناديق نعم ولا Yes/No وموافق وإلغاء Ok/Cancel التي يمكن استخدامها من خلال دوال showXXX المختلفة، ويمكن تمييزها بأيقوناتها وأزرارها المختلفة، ويستخدم الصندوقان الأخيران askxxx بدلًا من showxxx، ويعيد قيمةً لتوضيح على أي زر ضغط المستخدم، كما يلي: res = messagebox.askokcancel("Which?", "Ready to stop?") print res فيما يلي بعض الأمثلة لصناديق الرسائل في Tkinter: وهذا شبيه بصناديق alert وMsgBox التي استخدمناها في برامج الويب من جافاسكربت وVBScript في دروسنا الأولى. كما توجد صناديق حوارية قياسية يمكن استخدامها للحصول على أسماء الملفات أو المجلدات من المستخدم، تشبه صناديق "Open File" أو "Save File"، ولن نشرحها هنا لكن يمكن الاطلاع على أمثلة عنها في صفحات Tkinter المرجعية، تحت قسم Standard Dialogs. تغليف التطبيقات مثل الكائنات من الشائع في برمجة الواجهات الرسومية أن نغلف التطبيق كله مثل صنف واحد، وهذا يطرح سؤال كيف نلائم ودجات تطبيق Tkinter في هيكل هذا الصنف؟ لدينا خياران هنا، فإما أن نقرر جعل التطبيق نفسه صنفًا فرعيًا من إطار Tkinter، وإما أن نجعل أحد الحقول الأعضاء (الخاصيات) يخزن مرجعًا إلى نافذة المستوى الأعلى، ويُستخدم المنظور الثاني بكثرة في صناديق الأدوات الأخرى لذا سنتبعه، أما المنظور الأول فيمكن رؤيته في المقال السابق، الذي يحوي أيضًا توضيحًا لاستخدام بسيط لودجت النص الخاصة بـ Tkinter، إضافةً إلى مثال آخر لاستخدام bind. سنحول المثال أعلاه إلى هيكل كائني التوجه باستخدام حقل إدخال وزر Clear وزر Quit، لكننا سننشئ أولًا صنف تطبيق، ونجمّع الأجزاء المرئية للواجهة الرسومية داخل الباني Constructor، ثم نسند الإطار الناتج إلى self.mainWindow، سامحين بهذا للتوابع الأخرى للصنف بالوصول إلى إطار المستوى الأعلى، وتُسنَد الودجات الأخرى التي قد نحتاج إلى الوصول إليها -مثل حقل الإدخال- إلى متغيرات أعضاء member variables للتطبيق. تصبح معالجات الأحداث توابعًا لصنف التطبيق باستخدام هذه التقنية، ويكون لها وصول إلى أي أعضاء بيانات data members لتطبيق -رغم عدم وجود أي منها في حالتنا هنا- من خلال مرجع self، مما يوفر تكاملًا سلسًا للواجهة الرسومية مع كائنات التطبيق الأساسية: import tkinter as tk # أنشئ معالج الحدث لمسح النص class ClearApp: def __init__(self, parent): # create the top level window/frame self.mainWindow = tk.Frame(parent) self.eHello = tk.Entry(self.mainWindow) self.eHello.insert(0,"Hello world") self.eHello.pack(fill="x", padx=5, pady=5) self.eHello.bind("<Control-c>", self.evHotKey) # والآن أنشئ الإطار ذا الأزرار. fButtons = tk.Frame(self.mainWindow, height=2) self.bClear = tk.Button(fButtons, text="Clear", width=10, height=1,command=self.evClear) self.bQuit = tk.Button(fButtons, text="Quit", width=10, height=1, command=self.mainWindow.quit) self.bClear.pack(side="left", padx=15, pady=1) self.bQuit.pack(side="right", padx=15, pady=1) fButtons.pack(side="top", pady=2, fill="x") self.mainWindow.pack() self.mainWindow.master.title("Clear") def evClear(self): self.eHello.delete(0,tk.END) def evHotKey(self, event): self.evClear() # والآن أنشئ التطبيق وشغل حلقة الحدث top = tk.k() app = ClearApp(top) top.mainloop() ستكون النتيجة كما يلي: تبدو النتيجة مشابهةً للصورة السابقة، رغم أننا عدلنا بعض الإعدادات وخيارات التحزيم لتبدو أشبه بمثال wxPython أدناه. لا شك أننا نستطيع إنشاء صنف مبني على إطار يحتوي مجموعةً قياسيةً من الأزرار، ونعيد استخدامه في بناء نوافذ حوارية مثلًا، فليس التطبيق الرئيسي وحده هو الذي يمكن تغليفه مثل كائن، بل يمكن إنشاء صناديق كاملة واستخدامها في مشاريع مختلفة، أو توسيع إمكانيات الودجات القياسية بإنشاء أصناف فرعية لها، لإنشاء زر يتغير لونه وفقًا لحالته مثلًا، وهو ما فعلناه في وحدة Tix التي ذكرناها أعلاه، وما هي إلا توسيع للصنف Tkinter. يحتوي Tkinter بدءًا من الإصدار 3.1 على بعض المزايا الجديدة التي تُعرف باسم الودجات ذات السمات themed widgets، وتوجد في وحدة tkinter.ttk، وهي تحسن كثيرًا من مظهر Tkinter إلى حد يصعب التفريق بين نوافذه وبين ودجات النظام المضمّنة، لكننا لن نشرحها هنا، ويُرجَع فيها إلى موقع Tcl/Tk. صندوق الأدوات البديل wxPython توجد عدة صناديق أدوات أخرى للواجهات الرسومية، لعل أشهرها صندوق WxPython الذي يغلف ودجات صندوق أدوات C++‎، وهذا الصندوق -أي WxPython- أكثر شيوعًا من صندوق أدوات Tkinter عمومًا بين صناديق الواجهات المرئية، حيث يوفر وظائف قياسيةً افتراضيًا أكثر من Tk، مثل التلميحات tooltips، وشرائط الحالة status bars وغيرها، أما في Tk فيجب إنشاؤها يدويًا، وسنستخدم wxPython لإعادة إنشاء مثال Hello World أعلاه. لكن المشكلة هنا هي أن wxPython ليس متاحًا بعد للإصدار الثالث من بايثون حتى الوقت الذي كتبنا فيه هذه الكلمات، مما يعني أننا سنعامل شيفرة الإصدار الثاني أدناه على أنها مجرد تدريب للقراءة لا غير، أو يمكن تثبيت الإصدار 2.7 من بايثون إذا أردنا التطبيق العملي، وتنزيل الحزمة من موقع wxPython، إذ لن ندخل كثيرًا في التفاصيل هنا، وعمومًا يعرِّف صندوق الأدوات إطار عمل يسمح لنا بإنشاء نوافذ وملئها بعناصر التحكم، وربط توابع بهذه المتحكمات، وبما أن هذا كائني التوجه فيجب استخدام التوابع وليس الدوال، ويبدو المثال كما يلي: import wx # --- عرّف إطارًا مخصصًا. سيكون هو النافذة الأساسية --- class HelloFrame(wx.Frame): def __init__(self, parent, id, title, pos, size): super().__init__(parent, id, title, pos, size) # we need a panel to get the right background panel = wx.Panel(self) # أنشئ ودجتي النص والزر self.tHello = wx.TextCtrl(panel, -1, "Hello world", pos=(3,3), size=(185,22)) bClear = wx.Button(panel, -1, "Clear", pos=(15, 32)) self.Bind(wx.EVT_BUTTON, self.OnClear, bClear) bQuit = wx.Button(panel, -1, "Quit", pos=(100, 32)) self.Bind(wx.EVT_BUTTON, self.OnQuit, bQuit) # هذه معالجات الأحداث الخاصة بنا def OnClear(self, event): self.tHello.Clear() def OnQuit(self, event): self.Destroy() # --- عرّف كائن التطبيق --- # يجب أن تعرّف صنف تطبيق wxPython لاحظ أن كل برامج # wx.App مشتق من class HelloApp(wx.App): def OnInit(self): frame = HelloFrame(None, -1, "Hello", (200,50), (200,90) ) frame.Show(True) self.SetTopWindow(frame) return True # أنشئ نسخة وابدأ حلقة الحدث HelloApp().MainLoop() نلاحظ استخدام اصطلاح التسمية onXXXX للتوابع التي يستدعيها إطار العمل، واستخدام ثوابت EVT_XXX لربط الأحداث بالودجات، وتوجد مجموعة كبيرة منها. يحتوي wxPython على ودجات كثيرة، أكثر من Tk، ويمكن بناء واجهات رسومية معقدة بها، لكنها للأسف تميل لاستخدام نظام موضعة مبني على الإحداثيات، وهذا متعب في العمل، لكن يمكننا استخدام نظام شبيه بمحزِّم Tk، مع أنه غير موثَّق جيدًا. وقد يكون من المهم ملاحظة أن هذا المثال ومثال Tkinter الشبيه به أعلاه يحتويان على نفس عدد الأسطر البرمجية تقريبًا، إذ يحتوي مثال Tk على 23 سطرًا، ومثال wxPython على 21، فإذا رغبنا في واجهة رسومية سريعة لأداة نصية فسيكون Tk كافيًا، أما إذا أردنا بناء تطبيقات كاملة تعمل على عدة منصات تشغيل فينبغي استخدام wxPython. ومن صناديق الأدوات الأخرى MFC و‎.NET، ولا ننسى Curses التي هي واجهة رسومية مبنية على نصوص، ويمكن تطبيق كثير من الدروس التي تعلمناها مع Tkinter على كل صناديق الأدوات هذه، لكن لكل منها مزاياه وعيوبه، فاختر واحدًا وتعرف عليه واستمتع ببرمجة الواجهات الرسومية. أخيرًا نشير إلى أن العديد من صناديق الأدوات لها أدوات بناء رسومية، مثل Blackadder لـ Qt ، وGlade لــ GTK ، وكذلك يحتوي wxPython على بانٍ رسومي هو Boa Constructor رغم أنه لا زال في مرحلة الإصدار Alpha مما يعني أنه غير مستقر، كما يوجد بانٍ رسومي لصندوق Tk يسمى GUI Builder كان مخصصًا ابتداءً لبناء واجهات Tcl/Tk، لكنه يستطيع توليد شيفرات في عدة لغات بما فيها بايثون. توجد عدة كتب أخرى لاستخدام Tcl/Tk وعدة كتب أخرى من بايثون لديها فصول عن Tk، وسنعود إليه في مقال قادم، حين نشرح طريقة تغليف برنامج وضع باتش batch mode في واجهة رسومية لتحسين الاستخدام. خاتمة نرجو في نهاية هذا المقال أن تكون تعلمت ما يلي: تُعرف عناصر تحكم الواجهات الرسومية بالودجات. تُجمَّع الودجات في هرمية احتوائية. توفر صناديق أدوات الواجهات الرسومية مجموعات مختلفةً من الودجات، رغم وجود مجموعة أساسية افتراضية فيها جميعًا. تسمح الإطارات بجمع الودجات المتشابهة، وتشكيل أساس لمكونات واجهة رسومية قابلة للاستخدام. ترتبط دوال معالجة الأحداث أو التوابع بالودجات من خلال ربط أسمائها بخاصية command الخاصة بالودجات. تستطيع البرمجة كائنية التوجه تبسيط برمجة الواجهات الرسومية كثيرًا بإنشاء كائنات تتوافق مع مجموعات الودجات، وتوابع تتوافق مع الأحداث. ترجمة -بتصرف- للفصل التاسع عشر: GUI Programming with Tkinter من كتاب Learn To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: مفهوم التعاودية Recursion المقال السابق: البرمجة الحدثية Event Driven Programming المساقة بالأحداث البرمجة كائنية التوجه (Object Oriented Programming) في PHP البرمجة كائنية التوجه (Object Oriented Programming) في لغة سي شارب #C تطبيق البرمجة كائنية التوجه في لغة سي شارب #C - الجزء الثالث التعابير النمطية في البرمجة
  10. درسنا حتى الآن البرامج جزئية التوجه batch oriented programs، والتي تستدعى بإحدى طريقتين: جزئية التوجه batch oriented، حيث تبدأ البرامج بالعمل ثم تنفذ شيئًا ما ثم تتوقف، أو مدفوعة بالأحداث أو حدثية التوجه event driven، أي تبدأ البرامج وتنتظر وقوع أحداث بعينها؛ ولا تتوقف إلا حين يأمرها حدث آخر بالتوقف. كيف ننشئ برنامجًا مقودًا بالأحداث؟ سنفعل ذلك بطريقتين، حيث سنحاكي أولًا بيئةً حدثيةً، ثم سننشئ برنامجًا رسوميًا بسيطًا يستخدم نظام التشغيل والبيئة لتوليد أحداث. وسنغطي في هذا المقال النقاط التالية: أوجه اختلاف البرامج المدفوعة بالأحداث عن البرامج جزئية التوجه. كيفية كتابة حلقة حدث تكرارية. كيفية استخدام إطار عمل أحداث مثل Tkinter. محاكاة حلقة أحداث تكرارية يحتوي البرنامج المدفوع بالأحداث أو البرنامج الحدثي event driven program على حلقة تلتقط الأحداث المستَلمة وتعالجها، وقد تولد بيئة التشغيل الأحداث -كما يحدث في جميع البرامج الرسومية تقريبًا-، أو يبحث البرنامج عن الأحداث -كما يحدث في أنظمة التحكم المدمجة التي في الكاميرات وغيرها-، وسنكتب برنامجًا يبحث عن نوع واحد من الأحداث، وهو مدخلات لوحة المفاتيح، ويعالج النتائج إلى أن يستلم حدث خروج quit، والذي سيكون حالتنا مفتاح المسافة space على لوحة المفاتيح، وسنعالج الأحداث المدخلة بطريقة سهلة، إذ سنطبع ترميز آسكي ASCII الخاص بالمفتاح الذي ضغطه المستخدم، وسنستخدم بايثون لأنها تحوي دالة getch()‎ سهلة الاستخدام، وسنقرأ المفاتيح مفتاحًا تلو الآخر، وتأتي هذه الدالة في صورتين وفقًا لنظام التشغيل الذي نستخدمه، فنجدها في لينكس في وحدة curses، أما في ويندوز فستكون في وحدة msvcrt، وسنستخدم نسخة ويندوز أولًا ثم نناقش خيار لينكس بالتفصيل، ويجب أن نشغل هذه البرامج من سطر أوامر النظام، لأن بيئات التطوير -مثل IDLE- ستلتقط ضربات لوحة المفاتيح التقاطًا مختلفًا. وسنطبق دوال معالجة الأحداث أولًا، والتي تُستدعى عند التقاط ضغطة أحد المفاتيح، ثم متن البرنامج الرئيسي الذي يبدأ حلقة جمع الأحداث؛ ويستدعي دالة معالجة الحدث المناسبة عند التقاط حدث صالح. تطبيق المثال في ويندوز إليك التطبيق التالي: import msvcrt import sys # معالجات الأحداث أولًا def doKeyEvent(key): if key == '\x00' or key == '\xe0': key = msvcrt.getch() print ( ord(key), ' ', end='') sys.stdout.flush() # make sure it appears on screen def doQuit(key): print() # أدخل سطرًا جديدًا raise SystemExit # أخل مساحة على الشاشة أولًا lines = 25 for n in range(lines): print() # والآن حلقة الحدث الأساسية while True: ky = msvcrt.getch() if len(str(ky)) != 0: # we have a real event if " " in str(ky): doQuit(ky) else: doKeyEvent(ky) لاحظ أن ما نفعله مع الأحداث لا علاقة له بالمتن الرئيسي، الذي يجمع الأحداث ويمررها إلى معالجات الأحداث وحسب، وهذه الاستقلالية في التقاط الأحداث معالجتها هي الميزة الأساسية في البرمجة الحدثية. كما نلاحظ أن getch()‎ تعيد بايتات، لذا نحتاج إلى تحويلها إلى سلسلة نصية، وإلى استخدام اختبار in للتحقق متى يمكن الخروج بما أن السلسلة الناتجة لن تكون حرفًا. وفي الحالة التي لا يكون فيها المفتاح محرف آسكي، كأن يكون مفتاحًا وظيفيًا مثلًا، وهي المفاتيح التي تحمل حرف F في أولها أعلى لوحة المفاتيح، فسنحتاج إلى جلب محرف ثانٍ من لوحة المفاتيح، لأن هذه المفاتيح الخاصة تولد أزواجًا من البايتات، أما getch()‎ فلا تجلب إلى بايتًا واحدًا في كل مرة، وتكون القيمة المهمة التي نريدها هي البايت الثاني فعليًا. تطبيق المثال في لينكس وماك لا يستطيع المبرمجون الذين يستخدمون أنظمة تشغيل لينكس وماك استخدام مكتبة msvcrt، لذا يستخدمون وحدةً أخرى تسمى curses، وتكون الشيفرة الناتجة شبيهةً بشيفرة ويندوز، مع بعض التعديلات التي يجب إجراؤها، كما يلي: import curses as c def doKeyEvent(key): if key == '\x00' or key == '\xe0': # non ASCII key key = screen.getch() # fetch second character screen.addstr(str(key)+' ') # uses global screen variable def doQuitEvent(key): c.resetty() # set terminal settings c.endwin() # end curses session raise SystemExit # امسح الشاشة واحفظ الإعدادات الحالية # وأوقف الطباعة الآلية للمحارف على الشاشة # ثم أخبر المستخدم ما يفعله للخروج screen = c.initscr() c.savetty() c.noecho() screen.addstr("Hit space to end...\n") # والآن تعمل الحلقة الأساسية بلا نهاية while True: ky = screen.getch() if ky != -1: # send events to event handling functions if ky == ord(" "): # check for quit event doQuitEvent(ky) else: doKeyEvent(ky) c.endwin() لا تعمل أوامر الطباعة العادية في وحدة curses، بل يجب أن نستخدم دوال معالجة الشاشة الخاصة بوحدة curses، كما تعيد getch هنا ‎-1 إذا لم يُضغط على أي مفتاح -بدلًا من سلسلة فارغة-، ويطابق منطق البرنامج نسخة ويندوز السابقة فيما عدا ذلك. ويجب أن تستعيد curses.endwin()‎ شاشتنا إلى الحالة العادية، لكنها قد لا تعمل أحيانًا، فإذا اختفى المؤشر أو لم نحصل على محرف إرجاع لبداية السطر أو غير ذلك، فسنصلح المشكلة بالخروج من بايثون باستخدام Ctrl+D واستخدام الأمر التالي: $ stty echo -nl تشير nl إلى "سطر جديد"، وينبغي أن يصلح هذا السطر المشكلة أعلاه حال حدوثها. إذا كنا ننشئ هذا مثل إطار عمل لاستخدامه في مشاريع عدة، فسنضيف استدعاءً إلى دالة تهيئة initialization function في البداية ودالة تنظيف في النهاية، ثم يستطيع المبرمج عندئذ استخدام الجزء الخاص بالحلقة وتوفير دواله الملائمة للتهيئة والمعالجة والتنظيف، وهذا ما تفعله البيئات الرسومية تحديدًا، حيث يكون الجزء الخاص بالحلقة مضمَّنًا في بيئة التشغيل أو إطار العميل، ويكون على البرامج أن توفر دوال معالجة الأحداث الخاصة بها وتربطها بحلقة الأحداث بشكل ما، لنر ذلك عمليًا أثناء دراسة مكتبة Tkinter الرسومية. برنامج رسومي سنستخدم صندوق أدوات Tkinter من بايثون في هذه التدريب، وهو مغلف بايثون لصندوق أدوات Tk، والذي كُتب في البداية امتدادًا للغة Tcl، كما أنه متاح للغتي Perl وروبي أيضًا، ونسخة بايثون منه هي إطار عمل كائني التوجه، العمل فيه أسهل من نسخة Tk الأصلية، وسننظر بتفصيل أكثر في مبادئ برمجة الواجهات الرسومية فيما بعد في مقال قادم، لذا لن نسهب كثيرًا في الحديث عن مبادئ الواجهات الرسومية في هذا المقال، لأننا نريد التركيز على نمط البرمجة نفسه، وهو استخدام Tkinter لمعالجة حلقة الأحداث، ونترك المبرمج ينشئ الواجهة الرسومية الابتدائية، ثم يعالج الأحداث حال وصولها. وفي مثالنا هنا ننشئ صنف تطبيق application class اسمه KeysApp ينشئ الواجهة الرسومية في التابع __init__، ويربط مفتاح المسافة بالتابع doQuitEvent، كما يعرّف الصنف تابع doQuitEvent المطلوب، أما الواجهة الرسومية نفسها فتتكون ببساطة من ودجِت widget (تطبيق مُصغَّر) لإدخال النصوص؛ سلوكها الافتراضي هو طباعة المحارف المدخَلة على الشاشة. إن إنشاء صنف تطبيق في البيئات الحدثية كائنية التوجه أمر شائع، بسبب الارتباط بين مفاهيم الأحداث المرسَلة إلى برنامج ما والرسائل المرسَلة إلى كائن، إذ يرتبطان ببعضهما بسهولة كبيرة، وبذلك تصبح دالة معالجة الحدث تابعًا لصنف التطبيق، وبعد أن عرّفنا الصنف سننشئ نسخةً منه ونرسل إليها رسالة mainloop، وستبدو الشيفرة كما يلي: ً # للحفظ، بما أن علينا تقديم كل شيءfrom X import * استخدم # tkinter.xxx كـ from tkinter import * import sys # أنشئ صنف التطبيق الذي يعرف الواجهة الرسومية وتوابع # معالجة الأحداث class KeysApp(Frame): def __init__(self): # use constructor to build GUI super().__init__() self.txtBox = Text(self) self.txtBox.bind("<space>", self.doQuitEvent) self.txtBox.pack() self.pack() def doQuitEvent(self,event): sys.exit() # والآن أنشئ نسخة وابدأ تشغيل حلقة الحدث myApp = KeysApp() myApp.mainloop() نلاحظ أن البرنامج لن يغلق إغلاقًا صحيحًا عند تشغيل هذه الشيفرة من داخل IDLE، بل سيطبع رسالة إغلاق في نافذة الصدفة، وهو أسلوب IDLE حين يريد أن يساعدنا! أما إذا شغلناها في سطر الأوامر فسيعمل كل شيء بسلاسة. كما نلاحظ أننا لا نطبق معالج أحداث المفاتيح، لأن السلوك الافتراضي لودجِت أو تطبيق النص هو طباعة المفاتيح المضغوطة، لكن هذا يعني أن برامجنا ليست متكافئةً وظيفيًا، فقد طبعنا رموز آسكي في الطرفية بدلًا من طباعة النسخة الأبجدية من المفاتيح القابلة للطباعة كما فعلنا هنا، فليس ثمة شيء يمنعنا من التقاط جميع ضغطات المفاتيح وفعل نفس الشيء، فإذا أردنا تنفيذ هذا فسنضيف السطر التالي إلى تابع __init__: self.txtBox.bind("<Key>", self.doKeyEvent) كما سنضيف التابع التالي لمعالجة الحدث: def doKeyEvent(self,event): str = "%d\n" % event.keycode self.txtBox.insert(END, str) return "break" نلاحظ تخزين قيمة المفتاح في حقل keycode للحدث، وقد اضطررت إلى العودة إلى الشيفرة الأساسية للملف Tkinter.py لمعرفة ذلك، تذكر أن الفضول هو الصفة المميزة للمبرمج. كما نلاحظ أن return "break"‎ هي إشارة سحرية تخبر Tkinter بأن لا يستدعي معالجة الأحداث الافتراضية لهذه الودجِت، وبدون هذا السطر سيعرض الصندوق النصي رمز آسكي متبوعًا بالمحرف الحقيقي الذي ضُغط، وليس هذا ما نريده. إلى هنا يكفي الحديث عن Tkinter، فلم نكن ننو شرحه هنا وإنما في مقال تالٍ. البرمجة الحدثية في VBScript وجافاسكربت نستطيع تطبيق البرمجة الحدثية في كل من جافاسكربت وVBScript عند برمجة متصفح، فعادةً إذا حُمِّلَت صفحة ويب تحتوي على سكربت فإن السكربت ينفَّذ جزءًا جزءًا مع تحميل الصفحة، لكن إذا لم يحوِ السكربت إلا تعريفات الدوال فلن يفعل التنفيذ شيئًا إلا تعريف الدوال على أنها جاهزة للاستخدام، أما الدوال نفسها فلن تُستدعى هنا ابتداءً، بل ستكون مقيدةً إلى عناصر HTML في الجزء الخاص بشيفرة HTML في الصفحة -داخل عنصر Form غالبًا-، بحيث تُستدعى هذه الدوال عند وقوع الأحداث، وقد رأينا هذا في مثال جافاسكربت الخاص بالحصول على مدخلات المستخدم عندما نقرأ المدخلات من استمارة HTML، لننظر في هذا المثال مرةً أخرى؛ ونر كيف أنه تطبيق عملي على برمجة حدَثية في صفحة ويب: <form name='entry'> <p>Type value then click outside the field with your mouse</p> <input Type='text' Name='data' onChange='alert("We got a value of " + document.entry.data.value);'/> </form> نلاحظ عدم وجود تعريف لدالة جافاسكربت، بل مجرد استدعاء لـ alert المرتبطة بسمة onChange لعنصر input، وهذه السمة هي إحدى الأحداث التي تستطيع عناصر HTML توليدها، ونستطيع ربط أي شيفرة جافاسكربت عشوائية لتنفيذها في كل مرة تقع فيها هذه الأحداث، كما يمكن إنشاء دالة واستدعاؤها بدلًا من استدعاء alert كما يلي: <script type="text/javascript"> function echoValue(){ alert("We got a value of " + document.entry.data.value); } </script> <form name='entry'> <p>Type value then click outside the field with your mouse</p> <input Type='text' Name='data' onChange='echoValue()'/> </form> يعرّف الجزء الخاص بالسكربت دالة جافاسكربت هي echoValue تحاكي استدعاء alert الذي كان معنا من قبل، ويحتوي عنصر input الآن على دالة مسندة على أنها معالج الأحداث للسمة onChange، ثم تنفَّذ الدالة عند تغير قيمة الدخل، وتكون حلقة الحدث التي تلتقط الأحداث مضمنةً داخل المتصفح. ورغم أن دالتنا هذه استدعت alert إلا أنها تستطيع أكثر من ذلك، بل من الممكن جعلها برنامجًا معقدًا بذاتها، وذلك وفقًا لما نضعه في متنها. يمكن استخدام VBScript بنفس الطريقة، عدا أن تعريفات الدوال ستكون بـ VBscript بدلًا من جافاسكربت، كما يلي: <script type="text/vbscript"> Sub EchoInput() MsgBox "We got a value of " & Document.entry2.data.value End Sub </script> <form name='entry2'> <p>Type value then click outside the field with your mouse</p> <input Type='text' Name='data' onChange='EchoInput()'/> </form> وبهذا نرى أن الشيفرة الموجهة للمتصفحات يمكن كتابتها في صورة أجزاء batches أو في صورة حدَثية event driven، أو بهما معًا، وفق متطلبات كل حالة. خاتمة نأمل في هذا المقال أن تكون تعلمت ما يلي: لا تبالي حلقات الأحداث بالأحداث التي تلتقطها. تعالج معالجات الأحداث حدثًا واحدًا في كل مرة. توفر أطر العمل -مثل Tkinter- حلقة أحداث، وبعض معالجات الأحداث الافتراضية أحيانًا. يمكن كتابة الشيفرة لمتصفحات الويب بأسلوب مدفوع بالأحداث، أو بالأجزاء، أو بهما معًا. ترجمة -بتصرف- للفصل الثامن عشر: Event Driven Programming، من كتاب Learn To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: برمجة الواجهات الرسومية باستخدام Tkinter المقال السابق: البرمجة كائنية التوجه تعلم البرمجة ما هي البرمجة ومتطلبات تعلمها؟
  11. رغم أن الأفكار التي كانت وراء البرمجة الكائنية التوجه طُورت في ستينيات القرن الماضي إلا أنها لم تشتهر في الوسط البرمجي إلا بعد ذلك بعقدين، أي في الثمانينيات، بعد إطلاق Smalltalk-80 ومجموعة متنوعة من تطبيقات لغة Lisp، ولم تكن وقتها اتجاهًا سائدًا في البرمجة وإنما كانت تثير الفضول فقط، ثم تغير ذلك عندما انتشرت الواجهات الرسومية في الحواسيب الشخصية على حواسيب أبل أولًا، ثم على الحواسيب العاملة بنظام ويندوز ونظام نوافذ X في يونكس، إلى أن اقتربنا من نهاية الألفية السابقة، حيث صارت البرمجة الكائنية التوجه Object Oriented Programming -والتي تعرف اختصارًا OOP- التقنية الأبرز لتطوير البرمجيات. وتتجسد مفاهيم البرمجة الكائنية التوجه في لغات مثل جافا وC++‎ وبايثون بحيث لا تكاد تفعل شيئًا فيها إلا وتقابل كائنًا في مكان ما، ونريد في هذا المقال أن نتعرف على هذه التقنية وننظر في المفاهيم الأساسية لها، مثل تعريف الكائن والصنف وتعددية الأشكال polymorphism والوراثة inheritance، وكيفية إنشاء الكائنات وتخزينها واستخدامها، والبرمجة الكائنية التوجه موضوع كبير قد كُتبت فيه كتب، فإذا أردت التعمق فيه أكثر مما هو مذكور في هذا المقال فانظر هذه الكتب باللغة الإنجليزية: كتاب Object Oriented Analysis لبيتر كود Peter Coad وإد يوردون Ed Yourdon. كتاب Object Oriented Analysis and Design with Applications لجريدي بوش Grady Booch (الطبعة الأولى أو الثالثة). كتاب Object Oriented Software Construction لبرتراند ماير Berterand Meyer (الطبعة الثانية). تختلف هذه الكتب عن بعضها في العمق والحجم والدقة الأكاديمية بالترتيب، فالكتاب الأول مناسب للأغراض العامة التي هي خارج نطاق العمل البرمجي، والحق أنها كلها ليست كتبًا في البرمجة، بل كتب تحليل وتصميم برمجي، لأن أفضل تطبيق للبرمجة الكائنية التوجه يكون بتطبيق المبادئ خلال دورة حياة المشروع، وهذه طريقة مختلفة لحل المشاكل عن الطريقة العادية للبرمجة. أما في هذا المقال فسنتحدث باختصار عن مفاهيم البرمجة الكائنية التوجه التي ذكرناها، ولا مشكلة إذا لم تستوعبها في البداية، فلا تزال تستطيع استخدام الكائنات بدون أن تستوعب المفهوم الذي بُنيت عليه، ثم ستتضح الأمور بالتدريج مع التدرب والوقت،كما يمكن استخدام تصميم كائني التوجه في لغة غير كائنية من خلال الاصطلاحات البرمجية، لكن لا يُنصح بهذا الأسلوب، وإنما يستخدَم ملاذًا أخيرًا عندما لا نجد حلًا آخر، حيث تُستخدم التقنيات الكائنية التوجه إذا كانت المشكلة توافقها وتُحل بها، ويُفضل أن تُستخدم لغة كائنية التوجه أيضًا عندها، وتدعم أغلب اللغات الحديثة البرمجة الكائنية التوجه جيدًا، بما فيها اللغات الثلاثة التي نتدرب عليها، لكننا سنستخدم بايثون في الأمثلة الواردة هنا، ثم سنعرض المفاهيم الأساسية فقط في جافاسكربت وVBScript. جمع البيانات والدوال الكائنات هي تجميعات من البيانات والدوال التي تنفذ مهامًا على تلك البيانات، وتوضعان معًا بحيث يمكن تمرير كائن من جزء ما في البرنامج كي نحصل تلقائيًا على وصول إلى العمليات المتاحة وسمات البيانات، وهذا الجمع بين البيانات والدوال هو أصل البرمجة الكائنية التوجه، ويُعرف باسم التغليف encapsulation، لأن بعض لغات البرمجة تخفي البيانات عن مستخدمي الكائن، وبناءً عليه تتطلب توابع الكائن للوصول إليها، وتسمى هذه التقنية باسم إخفاء البيانات، ويطلق عليها التغليف أحيانًا، وكمثال على التغليف، قد يخزن كائن سلسلة نصية سلسلة محارف، لكنه يوفر كذلك توابع للعمل على هذه السلسلة؛ لتنفيذ أمور مثل البحث وتغيير حالة الأحرف وحساب الطول وغير ذلك، وتستخدم الكائنات مجازًا تمرير الرسالة message passing، حيث يمرر كائنٌ رسالةً إلى كائن آخر، ويرد الكائن المستقبل بتنفيذ تابع -وهو إحدى عملياته-، وهكذا يُستدعى التابع عند استقبال الرسالة الموافقة له بواسطة الكائن المالك، ويمكن تمثيل ذلك بصيغ مختلفة، وأكثرها شيوعًا الصيغة النقطية . التي تحاكي الوصول إلى العناصر التي في الوحدات، فبالنسبة إلى صنف widget وهمي: w = Widget() # widget جديدة من w أنشئ نسخة w.paint() # paint أرسل إليها الرسالة ستستدعي هذه التعليمات التابع paint الخاص بكائن widget. تعريف الأصناف يمكن للكائنات أن تحتوي على أنواع مختلفة، كما أن للبيانات أنواعًا مختلفةً، وتُعرَّف تجميعات الكائنات التي لها صفات متطابقة باسم الأصناف classes، ونستطيع تعريف هذه الأصناف وإنشاء نسخ منها، حيث تكون تلك النسخ هي الكائنات الفعلية، كما يمكن تخزين مراجع إلى تلك الكائنات في المتغيرات داخل برامجنا، لننظر الآن في مثال حقيقي لنرى إن كنا نستطيع تفسيره وشرحه، حيث سننشئ صنف رسالة يحتوي على سلسلة نصية -تمثل نص الرسالة- وتابع لطباعة الرسالة. class Message: def __init__(self, aString): self.text = aString def printIt(self): print( self.text ) الملاحظة الأولى: يسمى أحد توابع هذا الصنف باسم __init__، وهو تابع خاص يسمى الباني constructor، وسبب هذا الاسم أنه يُستدعى عند إنشاء أو بناء نسخة جديدة من كائن ما، وستكون المتغيرات المسندة داخل هذا التابع -والتي أنشئت داخل بايثون- متغيرات فريدة للنسخة الجديدة، وتوجد عدة توابع خاصة مثل هذا التابع في بايثون، وجميعها مميزة بصيغة التسمية التي فيها شرطتان سفليتان عن يمينها وشمالها __xxx__، ويطلق عليها مستخدمو بايثون أحيانًا اسم التوابع السحرية magic methods أو التوابع المحاطة بشرطين سفليتين dunder methods (إذ dunder اختصار إلى double under)؛ أما وقت استدعاء الباني الدقيق فيختلف بين اللغات، حيث يُستدعى التابع init في بايثون بعد إنشاء النسخة في الذاكرة، لكنه في لغات أخرى يعيد النسخة نفسها، والفرق في هذا بين اللغات طفيف ولا يستحق الانتباه له. الملاحظة الثانية: يحتوي كلا التابعين المعرفين على معامل أول هو self، والاسم مجرد اصطلاح يشير إلى نسخة الكائن، وسنرى قريبًا أن هذا المعامِل لا يملؤه المبرمج، بل يُملأ بواسطة المفسر في وقت التشغيل، وعلى هذا يُستدعى printIt على نسخة للصنف -انظر أدناه-، بدون وسطاء بالشكل m.printIt()‎. الملاحظة الثالثة: لقد استدعينا الصنف Message بحرف M كبير كما نرى، وهذا للسهولة فقط، لكن هذا الاصطلاح يُستخدم بكثرة في لغات البرمجة الكائنية التوجه، وليس في بايثون وحدها، ويوجد اصطلاح قريب من هذا يقتضي أن تبدأ أسماء التوابع بحرف صغير ثم تبدأ الكلمات التالية في الاسم بحرف كبير، فإذا كان لدينا تابع اسمه calculate current balance، فسيُكتب بالشكل calculateCurrentBalance. ننصحك عند هذه النقطة بالعودة إلى مقال البيانات وأنواعها، لقراءة قسم الأنواع المعرَّفة من قِبل المستخدم، حيث ستفهم مثال دليل جهات الاتصال في بايثون فهمًا أفضل بعد هذا الشرح هنا، فالنوع الوحيد الذي يعرّفه المستخدم في بايثون هو الصنف، والصنف الذي له سمات attributes وليس له توابع -ما عدا __init__- يكافئ الباني المسمى record أو struct في بعض لغات البرمجة. الصيغة الرسومية تبنّى مجتمع هندسة البرمجيات صيغةً مرئيةً لوصف الأصناف والكائنات وعلاقاتها بعضها ببعض، وتسمى هذه الصيغة باسم لغة النمذجة الموحدة Unified Modelling Language أو UML اختصارًا، وهي أداة تصميم قوية وتحتوي على العديد من المخططات والأيقونات، وسننظر في بعضها هنا بما يعيننا على فهم المبادئ التي نريد شرحها فقط، وأول أيقونة سنقابلها في UML هي وصف الصنف، وهي أهم الأيقونات، وتتكون من صندوق من ثلاثة أجزاء، يحتوي الجزء العلوي على اسم الصنف، والأوسط على سماته أو البيانات فيه، أما الجزء السفلي فيحتوي على توابع الصنف أو دواله، وبناءً عليه سيبدو صنف Message المعرَّف أعلاه كما يلي: سنرى في هذا المقال أيقونات UML أخرى، ونتعرض لمفاهيم جديدة تدعمها هذه الصيغة. استخدام الأصناف بما أننا عرّفنا الصنف Message فنستطيع إنشاء نسخ منه الآن والعمل عليها: m1 = Message("Hello world") m2 = Message("So long, it was short but sweet") notes = [m1, m2] # ضع الكائنات في قائمة for msg in notes: msg.printIt() # اطبع الرسائل متتابعة وبهذا نعامل الصنف كما لو كان نوع بيانات قياسيًا في بايثون، وهو الغرض من التدريب ابتداءً. كما توجد أيقونة للكائن أو النسخة في UML، وهي مثل أيقونة الصنف إلا أننا نترك الجزئين السفليين في الصندوق فارغين، ويتكون الاسم من اسم الكائن أو النسخة متبوعًا بنقطتين رأسيتين ثم اسم الصنف، وعليه فإن m1:Message تخبرنا أن m1 ما هي إلا نسخة من الصنف Message، ويمكن رسم مثال الرسالة الخاص بنا الآن كما يلي: نلاحظ أن الصنف List يمثل نوع القائمة القياسي في بايثون، كما هو موضح من وضع كلمة builtin بين أقواس حادة، وهي بنية معروفة في UML باسم القالب النمطي stereotype، وتشير الخطوط ذوات الرؤوس الماسية في الصورة إلى القائمة التي تحتوي على كائنات Message، وبالمثل فإن كائن MyProg يُنمَّط stereotyped على أنه صنف مساعد utility class، مما يعني في هذه الحالة أنه غير موجود مثل صنف داخل البرنامج، لكنه منتج من منتجات البيئة نفسها، وتُظهَر أدوات نظام التشغيل عادةً بهذه الطريقة، مثل مكتبات لدوال. أما الخطوط المستقيمة التي تخرج من myProg إلى Message فتوضح أن الكائن myProg يرتبط بـكائنات Message أو يشير إليها، وتشير الأسهم المرافقة لتلك الخطوط أن كائن myProg يرسل رسالة printIt إلى كل كائن من كائنات Message، وتُنقل رسائل الكائنات من خلال ارتباطات associations. المعامل self يطرح من يبدأ حديثًا في البرمجة الكائنية التوجه ببايثون سؤالًا هو: ما هو المعامل self؟ لأن تعريف أي تابع في صنف ما في بايثون يبدأ به، ويجب أن نبين أن الاسم نفسه مجرد اصطلاح، ولم يتغير إلى الآن لأن الثبات أمر محمود في الاصطلاحات البرمجية، فلغة جافاسكربت مثلًا لديها مفهوم مشابه لكنها تستخدم اسم this بدلًا من self. أما self نفسه فهو مرجع إلى النسخة الحالية، فحين ننشئ نسخةً من الصنف فإنها تحتوي على بياناتها الخاصة كما أنشأها الباني، لكنها لا تحوي التوابع، لذا عندما نرسل رسالةً إلى نسخة وتستدعي التابع الموافق؛ فإنها تستدعيه إلى الصنف من خلال مرجع داخلي، فتمرر مرجعًا إلى نفسها self إلى التابع لتعرف شيفرة الصنف أي نسخة يجب أن تستخدمها. لننظر الآن في مثال مألوف، فإذا كان لدينا تطبيق رسومي فيه كائنات أزرار كثيرة، فسيُفعَّل التابع المرتبط بكل زر عندما يضغطه المستخدم، ويعرف تابع الزر أي زر ضغِط بالإشارة إلى قيمة self التي ستكون مرجعًا إلى نسخة الزر الحقيقي الذي ضُغط، وسنرى ذلك عمليًا في مقال لاحق. وعند إرسال رسالة إلى كائن يحدث ما يلي: تستدعي شيفرة العميل النسخة، أي ترسل الرسالة إذا أردنا الحديث باصطلاح البرمجة الكائنية التوجه. تستدعي النسخة تابع الصنف، وتمرر مرجعًا إلى نفسها self. يستخدم تابع الصنف بعدها المرجع الممرَّر ليأخذ بيانات النسخة للكائن المستقبِل. يمكن رؤية تلك النقاط عمليًا في تسلسل الشيفرة التالي، ونلاحظ أننا نستطيع استدعاء تابع الصنف صراحةً كما فعلنا في السطر الأخير: >>> class C: ... def __init__(self, val): self.val = val ... def f(self): print ("hello, my value is:", self.val) ... >>> # create two instances >>> a = C(27) >>> b = C(42) >>> # first try sending messages to the instances >>> a.f() hello, my value is 27 >>> b.f() hello, my value is 42 >>> # now call the method explicitly via the class >>> C.f(a) hello, my value is 27 نستطيع استدعاء التوابع كما نرى في المثال أعلاه من خلال النسخة، وتملأ بايثون معامِل self في تلك الحالة لنا، أو من خلال الصنف صراحةً، وفي تلك الحالة نحتاج إلى تمرير قيمة self صراحةً. لدينا سؤال يطرح نفسه الآن، فإذا كانت بايثون تستطيع توفير مرجع بين النسخة وصنفها، ألا تستطيع أن تملًا self بنفسها أيضًا؟ ربما يكون هذا سؤالًا منطقيًا لكن الإجابة عليه هي أن Guido Van Rossum -منشئ اللغة- صممها هكذا. لكن مع هذا فإن العديد من لغات البرمجة الكائنية التوجه تخفي معامِل self، لكن بايثون تعتمد الصراحة explicity وتفضلها على الضمنية implicity، ويتعود المبرمج على هذا المبدأ مع كثرة العمل. تعددية الأشكال polymorphism لدينا الآن القدرة على تعريف أصنافنا الخاصة وإنشاء نسخ منها وإسنادها إلى متغيرات، ثم تمرير رسائل إلى تلك الكائنات تؤدي إلى تشغيل التوابع التي عرفناها، لكن هناك عنصر أخير يتعلق بالبرمجة الكائنية التوجه هنا، وهو الأهم فيها من نواحٍ عدة، فإذا كان لدينا كائنان من صنفين مختلفين لكنهما يدعمان نفس مجموعة الرسائل، لكن مع التوابع الموافقة لها، فنستطيع عندئذ أن نجمع تلك الكائنات معًا ونعاملها معاملةً واحدةً في برنامجنا، لكنها ستتصرف تصرفًا مختلفًا، وتُعرف تلك القدرة على التصرف المختلف لنفس رسائل الدخل باسم تعددية الأشكال، تُستخدم هذه الخاصية عادةً لجعل عدد من الكائنات الرسومية المختلفة ترسم نفسها عند استلام رسالة paint، فترسم الدائرة شكلًا مختلفًا تمامًا عن المثلث، لكن بما أن لهما نفس تابع paint فنستطيع -نحن المبرمجين- أن نتجاهل الاختلافات ونراهما على أنهما مجرد أشكال، لننظر الآن في مثال نحسب فيه مساحة الأشكال بدلًا من رسمها: ننشئ أولًا الصنفين Square وCircle: class Square: def __init__(self, side): self.side = side def calculateArea(self): return self.side**2 class Circle: def __init__(self, radius): self.radius = radius def calculateArea(self): import math return math.pi*(self.radius**2) نستطيع الآن أن ننشئ قائمةً من الأشكال -دوائر أو مربعات- ثم نطبع مساحاتها: shapes = [Circle(5),Circle(7),Square(9),Circle(3),Square(12)] for item in shapes: print "The area is: ", item.calculateArea() إذا جمعنا هذه الأفكار مع وحدات modules فسنحصل على آلية بالغة القوة لإعادة استخدام الشيفرة، حيث نضع تعريفات الصنف في وحدة ولتكن shapes.py مثلًا، ثم نستورد تلك الوحدة حين نرغب في التعديل على الأشكال، وهذا بالضبط ما حصل مع العديد من وحدات بايثون القياسية، وهو السبب الذي يجعل الوصول إلى توابع كائن ما يشبه استخدام الدوال في وحدة. نرى في الصورة أعلاه مخطط كائن أكثر تعقيدًا، ونلاحظ أن الكائنات التي داخل القائمة في هذه الحالة ليس لها أسماء لأننا لم ننشئ متغيرات لها صراحةً، ففي تلك الحالة نعرض مسافةً فارغةً قبل النقطين الرأسيتين واسم الصنف، لكن هذا يجعل المخطط مزدحمًا، لذا لا نرسم مخططات الكائنات إلا عند الضرورة لتوضيح بعض المزايا غير المألوفة للتصميم، أما في الأحوال العادية فنستخدم خصائص معقدةً أكثر من مخططات الأصناف لعرض العلاقات التي لدينا، كما سنرى في الأمثلة التالية. الوراثة inheritence تُستخدم الوراثة (أو الاكتساب) عادةً لتنفيذ تعددية الأشكال واستخدامها، وقد تكون هي الآلية الوحيدة لذلك في العديد من لغات البرمجة الكائنية، ويمكن للصنف أن يرث السمات والعمليات من صنف أب parent class أو صنف رئيسي super class، وهذا يعني أن الصنف الجديد المطابق لصنف آخر في أغلب جوانبه لا يجب أن يعيد تنفيذ جميع التوابع التي في الصنف الأول، بل يمكن أن يرث تلك الإمكانيات ثم يغيرها لتنفيذ أمور مختلفة، كما في تابع calculateArea أعلاه، وسنستخدم للتوضيح مثالًا فيه هرمية أصناف حسابات بنكية، حيث نستطيع إيداع المال والحصول على الرصيد والقيام بعمليات سحب، ولبعض الحسابات نسبة ربوية (فائدة) سنفترض أنها تُحسب عند كل إيداع، إضافةً إلى بعض الرسوم الأخرى لعمليات السحب. الصنف BankAccount لنرى الآن كيف سيبدو هذا المثال، سننظر في السمات والعمليات الخاصة بالحساب البنكي في أكثر مستوىً عام له، ومن الأفضل هنا أن ننظر في العمليات أولًا، ثم نوفر السمات حسب الحاجة لدعم تلك العمليات، فمع الحساب المصرفي نستطيع القيام بما يلي: إيداع المال. سحب المال. التحقق من الرصيد الحالي. تحويل الأموال إلى حساب آخر. وسنحتاج إلى معرِّف الحساب المصرفي ID للحساب الآخر والرصيد الحالي، بالنسبة للمعرِّف فسنستخدم المتغير الذي نسند الكائن إليه، لكن إذا كنا في مشروع حقيقي فيجب إنشاء سمة خاصة بالمعرِّف تخزن مرجعًا فريدًا، كما سنحتاج إلى تخزين الرصيد، وعند تمثيل ذلك بلغة النمذجة الموحدة UML فسيبدو كما يلي: نستطيع الآن أن ننشئ صنفًا يدعم ذلك: # ‫ننشئ صنف اعتراض Exception مخصص class BalanceError(Exception): value = "Sorry you only have $%6.2f in your account" class BankAccount: def __init__(self, initialAmount): self.balance = initialAmount print( "Account created with balance %5.2f" % self.balance ) def deposit(self, amount): self.balance = self.balance + amount def withdraw(self, amount): if self.balance >= amount: self.balance = self.balance - amount else: raise BalanceError() def checkBalance(self): return self.balance def transfer(self, amount, account): try: self.withdraw(amount) account.deposit(amount) except BalanceError: print( BalanceError.value % self.balance ) الملاحظة الأولى: نتحقق من الرصيد قبل السحب، ونستخدم اعتراضًا لمعالجة الأخطاء، وبما أنه لا يوجد خطأ من النوع BalanceError في بايثون فسنحتاج إلى إنشاء واحد، وهو صنف فرعي من الصنف Exception مع قيمة نصية، وتُعرَّف قيمة السلسلة value سمةً لصنف الاعتراض لمجرد الاصطلاح فقط، وهي تضمن أننا نولد رسائل خطأ في كل مرة نرفع فيها اعتراضًا، ونلاحظ هنا أننا لم نستخدم self عند تعريف القيمة في BalanceError لأن value سمة مشتركة بين كل النسخ، وهي معرَّفة على مستوى الصنف وتُعرف بمتغير الصنف، ونصل إليها باستخدام اسم الصنف متبوعًا بنقطة BalanceError.value كما رأينا أعلاه، فعندما يولّد خطأ التعقب العكسي traceback -أي مسار مكان وقوع الخطأ ورجوعًا ضمن سلسلة الاستدعاءات- فسينتهي بطباعة سلسلة الخطأ المصاغة مع عرض الرصيد الحالي. الملاحظة الثانية: يستخدم التابع transfer الدالة التابعة withdraw/deposit الخاصة بالصنف BankAccount أو توابعه لتنفيذ عملية التحويل، وهذا أمر شائع في البرمجة الكائنية التوجه ويُعرف بالمراسلة الذاتية self messaging، ويعني أن الأصناف المشتقة تستطيع تنفيذ نسخها الخاصة من deposit/withdraw لكن يظل التابع transfer كما هو لجميع أنواع الحسابات. بما أننا عرّفنا BankAccount صنفًا قاعديًا فنستطيع أن نعود إلى الوراثة التي كنا نشرحها، ولننظر في أول صنف فرعي لنا فيما يلي. الصنف InterestAccount نستخدم الوراثة الآن لتوفير حساب يضيف نسبة ربوية -سنفترض أنها ‎3%- عند كل عملية إيداع، وستكون مطابقةً لصنف BankAccount القياسي عدا تابع الإيداع وبدء معدل النسبة، لذا نعيد كتابة تنفيذ هذه التوابع كما يلي: class InterestAccount(BankAccount): def __init__(self, initialAmount, interest=0.03): super().__init__(initialAmount) self.interest = interest def deposit(self, amount): super().deposit(amount) self.balance = self.balance * (1 + self.interest) الملاحظة الأولى: نمرر الصنف الرئيسي (أو الأب) معامِلًا في تعريف الصنف، ويكون هذا الصنف الأب في حالتنا هو BankAccount. الملاحظة الثانية: نستدعي super().__init__()‎ في بداية التابع ‎__init__()‎، والدالة super()‎ هي دالة خاصة وظيفتها معرفة الصنف الرئيسي، ويفيدنا ذلك عند وراثة أكثر من صنف رئيسي واحد فيما يسمى بالوراثة المتعددة، حيث نتجنب بعض المشاكل الغريبة التي قد تظهر إذا حاولنا استدعاء الصنف الرئيسي باسمه، لذلك يُفضل استخدام super()‎. ونبدأ الصنف الموروث باستدعاء التابع __init__ الخاص بالصنف الرئيسي، ولا نحتاج هنا إلا إلى بدء السمة interest التي قدمناها هنا، وبالمثل في استخدام super()‎ في تابع الإيداع، إذ يستدعي التابع deposit الخاص بالصنف الأب فلا نحتاج إلا إلى إضافة المزايا الجديدة للصنف InterestAccount. وهكذا نرى قوة البرمجة الكائنية التوجه وإمكانياتها، فبما أننا وضعنا BankAccount داخل الأقواس بعد اسم الصنف فقد صارت جميع التوابع موروثةً من BankAccount، ونلاحظ أن deposit تستدعي التابع deposit الخاص بالصنف الرئيسي بدلًا من نسخ الشيفرة، وسيحصل الصنف الفرعي على تلك التعديلات تلقائيًا إذا عدّلنا deposit الخاص بالصنف BankAccount بحيث يحوي تحققًا من بعض الأخطاء. الصنف ChargingAccount هذا الصنف مطابق للصنف BankAccount عدا أنه يطلب رسومًا افتراضية مقدارها ‎3$ لكل عملية سحب، وبالنسبة لـ InterestAccount فيمكن إنشاء صنف يرث من BankAccount ويعدّل التابعين init و withdraw: class ChargingAccount(BankAccount): def __init__(self, initialAmount, fee=3): super().__init__(initialAmount) self.fee = fee def withdraw(self, amount): super().withdraw(amount+self.fee) الملاحظة الأولى: نخزن الرسوم مثل متغير نسخة intance variable لنستطيع تغييره لاحقًا عند الحاجة، ونلاحظ أننا نستدعي __init__ مرةً أخرى مثل أي تابع آخر. الملاحظة الثانية: نضيف الرسوم إلى عملية السحب المطلوبة في استدعاء التابع الموروث withdraw الذي ينجز العمل الفعلي. الملاحظة الثالثة: نضيف أثرًا جانبيًا هنا حيث تُفرض رسوم تلقائيًا على عمليات التحويل، لكن هذا مطلوب على الأرجح لذا لا بأس به، وتجدر الإشارة هنا إلى أن إعادة الاستخدام هذه تحمل في طياتها احتمالية الآثار الجانبية غير المتوقعة التي يجب الحذر منها. ونمثل هذه الوراثة في UML بسهم مصمت من الصنف الفرعي إلى الصنف الرئيسي، فتُمثَّل هرمية الحساب البنكي الآن كما يلي: نلاحظ أننا سردنا التوابع والسمات التي تغيرت فقط أو أضيفت إلى الأصناف الفرعية. اختبار النظام للتحقق من عمل الهرمية السابقة بكفاءة، جرب تنفيذ الشيفرة التالية في محث بايثون أو بإنشاء ملف اختبار منفصل: from bankaccount import * # الحساب البنكي العادي a = BankAccount(500) b = BankAccount(200) a.withdraw(100) # a.withdraw(1000) a.transfer(100,b) print( "A = ", a.checkBalance() ) print( "B = ", b.checkBalance() ) # حساب للنسبة الربوية c = InterestAccount(1000) c.deposit(100) print( "C = ", c.checkBalance() ) # حساب للرسوم المفروضة d = ChargingAccount(300) d.deposit(200) print( "D = ", d.checkBalance() ) d.withdraw(50) print( "D = ", d.checkBalance() ) d.transfer(100,a) print( "A = ", a.checkBalance() ) print( "D = ", d.checkBalance() ) # حوّل من حساب الرسوم إلى حساب النسبة الربوية # حساب الرسوم سيطلب رسومًا، وحساب النسبة الربوية # يضيف نسبة ربوية print( "C = ", c.checkBalance() ) print( "D = ", d.checkBalance() ) d.transfer(20,c) print( "C = ", c.checkBalance() ) print( "D = ", d.checkBalance() ) أزل علامة التعليق الآن من السطر (a.withdraw(1000 لترى الاعتراض عمليًا. وبهذا نكون أتممنا مثالًا بسيطًا يظهر كيفية استخدام الوراثة لتوسيع إطار بسيط وإضافة مزايا قوية إليه، وقد رأينا كيف يبنى المثال على مراحل ويوضع برنامج اختبار للتحقق من نجاحه، مع أن اختباراتنا لم تكن كاملةً لأننا لم نغطِّ كل الحالات الممكنة، وكان من الممكن إدراج المزيد من الاختبارات، كما في حالة إنشاء حساب برصيد سالب. تجميعات الكائنات إحدى المشاكل التي قد تواجهها هي كيفية التعامل مع كائنات كثيرة، أو كيفية التعامل مع كائنات تنشئها في وقت التشغيل، فمن السهل إنشاء حسابات بنكية ثابتة كما فعلنا أعلاه: acc1 = BankAccount(...) acc2 = BankAccount(...) acc3 = BankAccount(...) etc... لكن في العالم الحقيقي لا تكون لدينا بيانات عن عدد الحسابات التي يجب إنشاؤها، فكيف نحل هذه المشكلة؟ لننظر فيها بتفصيل أكبر: نريد شكلًا ما من قواعد البيانات التي تسمح لنا بإيجاد أي حساب بنكي باسم مالكه أو رقم حسابه -بما أنه قد يكون للشخص الواحد عدة حسابات-، والعكس صحيح، ولكن ألا يشبه البحث عن شيء له معرّف خاص به القاموس؟ لنجرب استخدام قاموس في بايثون للاحتفاظ بكائنات منشأة ديناميكيًا: from bankaccount import BankAccount import time # أنشئ دالة جديدة لتوليد أرقام معرّفات فريدة def getNextID(): ok = input("Create account[y/n]? ") if ok[0] in 'yY': # check valid input id = time.time() # use current time as basis of ID id = int(id) % 10000 # حول إلى عدد صحيح وقلله إلى 4 أرقام else: id = -1 # وذلك سيوقف الحلقة التكرارية return id # أنشئ بعض الحسابات وخزنها في القاموس accountData = {} # قاموس جديد while True: # كرر حلقيًا بلا نهاية id = getNextID() if id == -1: break # تخرج إجباريًا من الحلقة التكرارية bal = float(input("Opening Balance? ")) # حول السلسلة إلى عدد ذي فاصلة عائمة accountData[id] = BankAccount(bal) # استخدم المعرِّف لإنشاء إدخال جديد في القاموس print( "New account created, Number: %04d, Balance %0.2f" % (id, bal) ) # دعنا نصل الآن إلى بعض الحسابات for id in accountData.keys(): print( "%04d\t%0.2f" % (id, accountData[id].checkBalance()) ) # ونبحث عن واحد فيها # أدخل محرفًا غير رقمي لرفع اعتراض وإنهاء البرنامج while True: id = int(input("Which account number? ")) if id in accountData: print( "Balance = %0.2d" % accountData[id].checkBalance() ) else: print( "Invalid ID" ) لا شك أن المفتاح الذي نستخدمه للقاموس قد يكون أي شيء يعرّف الكائن تعريفًا فريدًا، فقد يكون أحد سماته -مثل الرصيد balance- لكن الرصيد قد يتشابه بين الحسابات، ويتغير بالزيادة والنقص، وهكذا نفكر في الخيارات المتاحة إلى أن نصل إلى معرّف لا يتكرر، وهنا يمكن الرجوع إلى مقال البيانات وأنواعها، لقراءة القسم الخاص بالقاموس مرةً أخرى. يمثَّل هذا مرئيًا في UML باستخدام مخطط الصنف، ويُعرض القاموس مثل صنف له علاقة مع العديد من الحسابات البنكية، ونرى ذلك موضحًا بمحرف النجمة على الخط الموصل بين الأصناف، ونستخدم محرف النجمة هنا لأنه الرمز المستخدم في التعابير النمطية للإشارة إلى عدد من العناصر مقداره صفر أو أكثر، وهذا يُعرف بعدد عناصر العلاقة cardinality of the relationship، ويمكن رؤيته بعد طرق، لكن المجالات العددية للتعابير النمطية هي المستخدمة بكثرة لثرائها ومرونتها. نلاحظ استخدام القالب النمطي stereotype على القاموس Dictionary لإظهار أنه صنف مضمَّن، كما نلاحظ وجود الصندوق الملحق بالارتباط، والذي يوضح أن المفتاح هو قيمة المعرّف ID، فإذا كنا نستخدم قائمةً بسيطةً فلن يكون لدينا الصندوق ولكان الخط وصل بين الصنفين مباشرةً، وبهذا يتضح أننا نتجنب الحاجة إلى مخططات الكائنات الكبيرة والمعقدة باستخدام علاقات الأصناف وعدد العناصر في المجموعة cardinality، حيث نركز على العلاقات المجردة بين الأصناف بدلًا من التعامل مع عدد كبير من العلاقات الحقيقية بين النسخ المفردة. حفظ الكائنات إن فقد البيانات عند انتهاء المخطط هو أحد مشاكل السلوك السابق، لذا نريد طريقةً لحفظ الكائنات، ويمكن فعل ذلك باستخدام قواعد البيانات لكنه أسلوب متقدم، أما الآن فسنستخدم ملفًا نصيًا بسيطًا لحفظ الكائنات واسترجاعها، ورغم أن بايثون تحتوي على وحدتين لتنفيذ ذلك بكفاءة، وهما pickle وshelve، إلا أننا سنشرح الطريقة العامة التي تصلح لأي لغة، والطريقة العامة التي نقصدها هي إنشاء التابعين save وrestore في الكائن ذي المستوى الأعلى، ثم إعادة كتابتهما في كل صنف ليستدعيا النسخة الموروثة ثم يضيفا السمات المعرفة محليًا، وبالمناسبة يُستخدم مصطلح الثبات Persistence للإشارة إلى القدرة على حفظ الأشياء واستعادتها. class A: def __init__(self,x,y): self.x = x self.y = y self.f = None def save(self,fn): f = open(fn,"w") f.write(str(self.x)+ '\n') # convert to a string and add newline f.write(str(self.y)+'\n') return f # for child objects to use def restore(self, fn): f = open(fn) self.x = int(f.readline()) # convert back to original type self.y = int(f.readline()) return f class B(A): def __init__(self,x,y,z): super().__init__(x,y) self.z = z def save(self,fn): f = super().save(fn) # call parent save f.write(str(self.z)+'\n') return f # in case further children exist def restore(self, fn): f = super().restore(fn) self.z = int(f.readline()) return f # أنشئ النُسخ a = A(1,2) b = B(3,4,5) # احفظ النُسخ a.save('a.txt').close() # تذكر أن تغلق الملف b.save('b.txt').close() # اجلب النسخ newA = A(5,6) newA.restore('a.txt').close() # تذكر أن تغلق الملف newB = B(7,8,9) newB.restore('b.txt').close() print( "A: ",newA.x,newA.y ) print( "B: ",newB.x,newB.y,newB.z ) نلاحظ أن القيم المطبوعة هي القيم المسترجعة، وليست القيم التي استخدمناها لإنشاء النسخ. والمهم هنا هو إعادة كتابة التابع save والتابع restore في كل صنف واستدعاء التابع الرئيسي في خطوة أولى، ثم لا نتعامل في الصنف الفرعي إلا مع سمات ذلك الفرعي فقط، ومن البديهي أن تحويل السمة إلى سلسلة نصية وحفظها أمر متروك لك كونك مبرمجًا، لكن يجب إخراجها على سطر واحد، أما عند الاستعادة فببساطة يمكن عكس عملية التخزين، لكن تظهر هنا عقبة كبيرة في هذا الأسلوب، وهي أننا نحتاج إلى إنشاء ملف منفصل لكل كائن، وهذا قد يعني آلاف الملفات الصغيرة إذا كنا في بيئة برمجية لسوق حقيقي، حيث سيتعقد العمل بوتيرة متسارعة، وسنحتاج إلى استخدام قاعدة بيانات لحفظ الكائنات، وسنبحث في ذلك في مقال لاحق، أما الآن فيكفي أن تعلم أن المفاهيم الأساسية ستظل كما هي. دمج الأصناف والوحدات توفر الأصناف والوحدات آليات للتحكم في تعقيد البرنامج، ومن المنطقي مع ازدياد حجم البرنامج ازدياد الحاجة إلى دمج المزايا بوضع أصناف داخل وحدات، وتنصح بعض الهيئات بوضع كل صنف في وحدة منفصلة، لكن هذا يؤدي إلى وحدات كثيرة جدًا ويزيد التعقيد بدلًا من تقليله، والبديل الذي لدينا هو تجميع الأصناف معًا، ووضع المجموعة في وحدة، وإذا عدنا إلى مثالنا أعلاه فسنضع كل تعريفات أصناف الحساب المصرفي في وحدة واحدة هي bankaccount مثلًا، ثم ننشئ وحدةً منفصلةً لشيفرة التطبيق التي تستخدم الوحدة. يمكن تمثيل ذلك مرئيًا بواسطة UML بطريقتين، حيث يمكن تمثيل الجمع المنطقي للأصناف باستخدام حزمة، أو نستطيع تمثيل الملف الحقيقي مثل مكوّن: والهدف هنا أن تبدو أيقونة الحزمة مثل مجلد في أي برنامج مدير ملفات، أما الأيقونة الصغيرة التي في أعلى اليمين في أيقونة المكون فهي رمز المكون القديم في UML، وبما أن رسمها صعب في المخططات عند رسم الخطوط التي تظهر العلاقات بين المكونات فقد صغِّر شكلها في UML 2.0. هذا ما سنغطيه حول UML في هذه السللسلة، ويمكن الرجوع لمحركات البحث والويب للاستزادة من المراجع والتدريبات وأدوات رسم UML، رغم أن الأشكال سهلة الرسم في أي برنامج رسم متجهي. إذا أردنا تمثيلًا بسيطًا لذلك التصميم فسيكون كما يلي: # File: bankaccount.py # # Implements a set of bank account classes ################### class BankAccount: .... class InterestAccount: ... class ChargingAccount: ... ثم إذا أردنا استخدامه: import bankaccount newAccount = bankaccount.BankAccount(50) newChrgAcct = bankaccount.ChargingAccount(200) # هنا يمكن تنفيذ المهام التي تريدها لكن ماذا لو أراد صنفان في وحدتين الوصول إلى بيانات بعضهما بعضًا؟ إن أبسط حل لهذا هو استيراد كلتا الوحدتين وإنشاء نُسخ للأصناف التي نريدها، وتمرير نُسخ أحد الصنفين إلى توابع النسخة الأخرى، وتمرير الكائنات كاملةً من مكان لآخر ما هو إلا برمجة كائنية التوجه، وهنا ندرك أحد أسباب التسمية لهذا النوع من البرمجة، فلا نحتاج إلى استخراج سمات كائن وتمريرها إلى كائن آخر، بل نمرر الكائن كله، وإذا كان الكائن يستخدم رسالةً متعددة الأشكال للوصول إلى المعلومات التي يحتاج إليها فسيصلح التابع مع أي نوع من الكائنات التي تدعم الرسالة. لننظر في مثال واقعي لتوضيح ذلك، حيث ننشئ وحدةً قصيرةً نسميها logger تحتوي صنفين، هما Logger الذي يسجل النشاط داخل ملف، ويحتوي هذا الصنف على تابع واحد هو log()‎ الذي يأخذ معاملًا كائنًا قابلًا للتسجيل، أما الصنف الآخر فهو Loggable الذي تستطيع الأصناف الأخرى أن ترثه لتعمل مع logger: # File: logger.py # # Create Loggable and Logger classes for logging activities # of objects ############ class Loggable: def activity(self): return "This needs to be overridden locally" class Logger: def __init__(self, logfilename = "logger.dat"): self._log = open(logfilename,"a") def log(self, loggedObj): self._log.write(loggedObj.activity() + '\n') def __del__(self): self._log.close() نلاحظ أننا وفرنا تابع تدمير destructor هو __del__ لإغلاق الملف عند حذف كائن التسجيل أو كنسه garbage collected، وهو أحد التوابع السحرية الموجودة في بايثون كما نرى من الشرطتين السفليتين حوله، واللتين تشبهان ‎__init__()‎، مع فرق أن init يُستدعى عند إنشاء نسخة ما، أما del فيستدعى عندما يحذف كانس المخلفات النسخة، وقد لا يُستدعى إذا خرجت بايثون خروجًا غير متوقع، حيث سيكون لدينا في هذه الحالة مشاكل أكبر من استدعاء del أو عدم استدعائه. كما استدعينا سمة السجل ‎_log مع شرطة سفلية قبلها، وهو اصطلاح للتسمية في بايثون، كما في استخدام الكلمات ذات الأحرف الكبيرة لأسماء الأصناف، فالشرطة السفلية المفردة تعني أن السمة ليست مصممةً لنصل إليها مباشرةً، بل من خلال توابع الصنف. سننشئ الآن وحدةً جديدةً تعرِّف النسخ القابلة للتسجيل لأصناف حساباتنا المصرفية السابقة، لنستطيع استخدام وحدتنا: # File: loggablebankaccount.py # # Extend Bank account classes to work with logger module. ############################### import bankaccount, logger class LoggableBankAccount(bankaccount.BankAccount, logger.Loggable): def activity(self): return "Account balance = %d" % self.checkBalance() class LoggableInterestAccount(bankaccount.InterestAccount, logger.Loggable): def activity(self): return "Account balance = %d" % self.checkBalance() class LoggableChargingAccount(bankaccount.ChargingAccount, logger.Loggable): def activity(self): return "Account balance = %d" % self.checkBalance() استخدمنا الوراثة المتعددة في المثال أعلاه، حيث ورثنا صنفين رئيسيين وليس صنفًا واحدًا، وهذا غير ضروري في بايثون بما أننا نستطيع إضافة التابع activity()‎ إلى أصنافنا الأصلية ونحقق نفس الأثر، أما في لغات برمجة كائنية التوجه ثابتة مثل جافا أو C++‎ فسيكون هذا ضروريًا، لذا سنشرح التقنية هنا، قد تلاحظ أن التابع activity()‎ متطابق في الأصناف الثلاثة، وهذا يعني أننا نستطيع توفير بعض الكتابة على أنفسنا بإنشاء نوع وسيط من صنف حساب قابل للتسجيل يرث Loggable وله تابع activity فقط، ثم ننشئ ثلاثة أنواع حسابات قابلة للتسجيل بوراثتها من ذلك الصنف الجديد ومن صنف Loggable، كما يلي: class LoggableAccount(logger.Loggable): def activity(self): return "Account balance = %d" % self.checkBalance() class LoggableBankAccount(bankaccount.BankAccount, LoggableAccount): pass class LoggableInterestAccount(bankaccount.InterestAccount, LoggableAccount): pass class LoggableChargingAccount(bankaccount.ChargingAccount, LoggableAccount): pass لا يوفر هذا علينا الكثير من الكتابة، لكنه يعني أن علينا اختبار تعريف تابع واحد فقط والاحتفاظ به بدلًا من ثلاثة توابع متطابقة، وهذا النوع من البرمجة الذي يكون فيه لصنف رئيسي وظيفةً مشتركةً يسمى بالبرمجة الخليطة mixin programming، ويسمى الصنف الأدنى بالصنف الخليط mixin class، والناتج المتوقع من ذلك الأسلوب أن يكون لتعريفات الصنف النهائي متن صغير أو ليس لها متن أصلًا، لكنها تحوي قائمةً طويلةً من الأصناف الموروثة كما رأينا هنا. ومن الشائع أيضًا أن الأصناف المختلطة لا ترث من أي شيء بنفسها، رغم أننا فعلنا هذا هنا، فما هي إلا طريقة لإضافة تابع مشترك أو مجموعة من التوابع إلى صنف أو مجموعة أصناف باستخدام الوراثة، ويأتي مصطلح المختلطة mixin، من مجال صناعة المثلجات، حيث تضاف نكهات مختلفة إلى الفانيليا لإنتاج نكهة جديدة، وكانت أول لغة تدعم هذا الأسلوب هي لغة Flavors، والتي كانت إحدى لغات Lisp المشهورة وقتها. نأتي الآن إلى النقطة التي نُظهر فيها شيفرة التطبيق الخاص بنا بإنشاء كائن مسجل logger وبعض الحسابات المصرفية، ثم تمرير الحسابات إلى المسجل، رغم أنها معرّفة في وحدات مختلفة: # Test logging and loggable bank accounts. ############# import logger import loggablebankaccount as lba log = logger.Logger() ba = lba.LoggableBankAccount(100) ba.deposit(700) log.log(ba) intacc = lba.LoggableInterestAccount(200) intacc.deposit(500) log.log(intacc) نلاحظ هنا كلمة as المفتاحية التي تُستخدم لإنشاء اسم مختصر عند استدعاء loggablebankaccount. لا نحتاج إلى استخدام سابقة الوحدة module prefix بعد إنشاء النسخ المحلية، وبما أنه لا يوجد وصول مباشر من كائن إلى آخر بل من خلال الرسائل، فلا حاجة أن تشير وحدتا تعريف الصنف إلى بعضهما البعض مباشرةً، كما يعمل المسجل مع نسخ كل من LoggableBankAccount وLoggableInterestAccount لأنهما يدعمان واجهة Loggable، فالتوافق بين واجهات الكائنات من خلال تعددية الأشكال هو الأساس الذي تبنى عليه جميع البرامج الكائنية التوجه. يوجد نظام تسجيل أكثر تعقيدًا في وحدة المكتبة القياسية logging، لإظهار بعض التقنيات فقط، ونذكره هنا لتكون هذه المكتبة أول خيار نبحث فيه عند الحاجة إلى أدوات تسجيل في البرامج التي نكتبها. نأمل أن يكون هذا الشرح كافيًا لفهم البرمجة كائنية التوجه، مع الاستزادة من المصادر الموجودة في الويب أو قراءة أحد الكتب المذكورة في بداية المقال، أما الآن فسننظر في كيفية تنفيذ البرمجة كائنية التوجه في جافاسكربت وVBScript. البرمجة الكائنية في VBScript تدعم VBScript مفهوم الكائنات وتسمح بتعريف الأصناف وإنشاء نسخ منها، لكنها لا تدعم مفهوم الوراثة ولا تعددية الأشكال، وعلى هذا تكون لغة VBScript لغةً كائنية الأساس object based وليست كائنية التوجه، لكن مفهوم دمج البيانات والدوال في كائن واحد لا يزال صالحًا هنا، ويمكن تنفيذ صورة محدودة من الوراثة باستخدام تقنية تسمى التفويض delegation. تعريف الأصناف يُعرَّف الصنف في VBScript باستخدام تعليمة Class كما يلي: <script type=text/VBScript> Class MyClass Private anAttribute Public Sub aMethodWithNoReturnValue() MsgBox "MyClass.aMethodWithNoReturnValue" End Sub Public Function aMethodWithReturnValue() MsgBox "MyClass.aMethodWithReturnValue" aMethodWithReturnValue = 42 End Function End Class </script> وهذا يعرِّف صنفًا جديدًا اسمه MyClass مع سمة تسمى anAttribute تكون مرئيةً فقط للتوابع التي في الصنف، كما نرى من كلمة Private المفتاحية، ومن المتعارف عليه أن نصرح عن سمات البيانات بأنها Private، وعن أغلب التوابع بأنها Public، ويُعرف هذا باسم إخفاء البيانات، وميزته أنه يسمح لنا بالتحكم في الوصول إلى البيانات بإجبار استخدام التوابع التي تتحقق من جودة البيانات على القيم التي تمرَّر إلى داخل وخارج الكائن، وتوفر بايثون آليةً خاصةً بها لتنفيذ هذا لكنها خارج نطاق شرحنا. إنشاء النسخ ننشئ النسخ في VBScript بدمج الكلمتين المفتاحيتين Set وNew، ويجب أن يكون المتغير الذي أُسندت إليه النسخة الجديدة قد صرِّح عنه باستخدام كلمة Dim كما هو متبع في VBScript: <script type=text/VBScript> Dim anInstance Set anInstance = New MyClass </script> يؤدي هذا إلى إنشاء نسخة من الصنف مصرح عنها في القسم السابق وإسنادها إلى متغير anInstance، لاحظ أننا يجب أن نسبق اسم المتغير بـ Set، وأننا نستخدم كلمة New لإنشاء الكائن. إرسال الرسائل تُرسَل الرسائل إلى النسخ باستخدام نفس الصيغة النقطية . التي تستخدمها بايثون: <script type=text/VBScript> Dim aValue anInstance.aMethodWithNoReturnValue() aValue = anInstance.aMethodWithReturnValue() MsgBox "aValue = " & aValue </script> يُستدعى التابعان المصرح عنهما في تعريف الصنف، ولا توجد في الحالة الأولى قيمة معادة، أما في الحالة الثانية فسنسند القيمة المعادة إلى المتغير aValue، ولا يوجد شيء غير اعتيادي هنا باستثناء أن البرنامج الفرعي subroutine والدالة مسبوقان باسم النسخة. الوراثة وتعددية الأشكال لا تدعم VBScript أي آلية للوراثة أو تعددية الأشكال، لكن نستطيع محاكاة ذلك إلى حد ما باستخدام تقنية تسمى التفويض، وهذا يعني أننا نعرف سمةً للصنف الفرعي ليكون نسخةً من الصنف الرئيسي المفترض، ثم نعرف تابعًا لجميع التوابع الموروثة التي تستدعي تابع النسخة الرئيسية أو تفوض إليه، لننشئ صنفًا فرعيًا من MyClass كما هو معرَّف أعلاه: <script type=text/VBScript> Class SubClass Private parent Private Sub Class_Initialize() Set parent = New MyClass End Sub Public Sub aMethodWithNoReturnValue() parent.aMethodWithNoREturnVAlue End Sub Public Function aMethodWithReturnValue() aMethodWithReturnValue = parent.aMethodWithReturnValue End Function Public Sub aNewMethod MsgBox "This is unique to the sub class" End Sub End Class Dim inst,aValue Set inst = New SubClass inst.aMethodWithNoReturnVAlue aValue = inst.aMethodWithReturnValue inst.aNewMethod MsgBox "aValue = " & CStr(aValue) </script> نلاحظ هنا استخدام السمة الخاصة parent والتابع الخاص المميز Class_Initialise، فالأولى هي سمة تفويض الصنف الأب، والثاني هو المكافئ للتابع __init__ في بايثون لبدء النسخ عند إنشائها، أي أنه الباني في لغة VBScript. البرمجة الكائنية في جافاسكربت تدعم جافاسكربت الكائنات باستخدام تقنية تسمى النمذجة الأولية prototyping، وهذا يعني عدم وجود بنية صنف صريحة في جافاسكربت، بل نستطيع تعريف الصنف بمجموعة من الدوال أو مثل مفهوم شبيه بالقاموس يُعرف بالمهيئ initializer. تعريف الأصناف من أكثر الطرق شيوعًا لتعريف الأصناف في جافاسكربت هي إنشاء دالة بنفس اسم الصنف، ويكون هو الباني، لكنه لا يُحتوى داخل أي بنية أخرى: <script type=text/JavaScript> function MyClass(theAttribute) { this.anAttribute = theAttribute; }; </script> ربما تلاحظ كلمة this التي تُستخدم بنفس طريقة استخدام self في بايثون مثل مرجع نائب placeholder reference إلى النسخة الحالية، رغم أننا لا نحتاج في الغالب إلى إدراج this صراحةً في قائمة المعامِلات لتوابع الصنف، ونستطيع إضافة سمات جديدة إلى الصنف لاحقًا باستخدام السمة prototype المضمَّنة كما يلي: <script type=text/JavaScript> MyClass.prototype.newAttribute = null; </script> ويعرِّف هذا سمةً جديدةً لـ MyClass اسمها newAttribute، وتضاف التوابع من خلال تعريف دالة عادية؛ ثم إسناد اسم الدالة إلى سمة جديدة مع اسم التابع، ويكون للتابع والدالة نفس الاسم عادةً، لكن لا مانع من تغيير اسم التابع إلى شيء مختلف، كما يلي: <script type=text/JavaScript> function oneMethod(){ return this.anAttribute; } MyClass.prototype.getAttribute = oneMethod; function printIt(){ document.write(this.anAttribute + "<BR>"); }; MyClass.prototype.printIt = printIt; </script> ولا شك أن الأسهل تعريف الدوال ثم الباني، ثم إسناد التوابع داخل الباني، وهذا هو الأسلوب الافتراضي، لذا سيبدو تعريف الصنف كاملًا كما يلي: <script type=text/JavaScript> function oneMethod(){ return this.anAttribute; }; function printIt(){ document.write(this.anAttribute + "<BR>"); }; function MyClass(theAttribute) { this.anAttribute = theAttribute; this.getAttribute = oneMethod; this.printIt = printIt; }; </script> لكن ثمة طريقة أخرى في جافاسكربت، باستخدام صيغة مختلفة قليلًا لإنشاء دوال التابع، حيث تسمح جافاسكربت بتعريف الدالة كما يلي: square = function(x){ return x*x;} ثم نستدعي ذلك كما يلي: document.write("The square of 5 is: " + square(5)) فإذا طبقناه على تعريف الصنف الخاص بنا نحصل على: <script type=text/JavaScript> function MyClass(theAttribute) { this.anAttribute = theAttribute; this.getAttribute = function(){ return this.anAttribute; }; this.printIt = function printIt(){ document.write(this.anAttribute + "<BR>"); }; }; </script> يفضل بعض المبرمجين هذا الأسلوب لأنه يبقي تعريفات التابع داخل الصنف دون تلويث فضاء الاسم الخارجي، بينما يرى آخرون أن هذا أسلوب فوضوي وأصعب في القراءة، وأنت حر في اختيار أي الأسلوبين تفضل. إنشاء النسخ تُنشأ نسخ الأصناف باستخدام كلمة new المفتاحية كما يلي: <script type=text/JavaScript> var anInstance = new MyClass(42); </script> مما ينشئ نسخةً جديدةً اسمها anInstance. إرسال الرسائل لا يختلف إرسال الرسائل في جافاسكربت عن اللغات الأخرى، إذ نستخدم الصيغة النقطية: <script type=text/JavaScript> document.write("The attribute of anInstance is: <BR>"); anInstance.printIt(); </script> الوراثة وتعددية الأشكال يمكن استخدام آلية النمذجة الأولية في جافاسكربت للوراثة من صنف آخر -على عكس VBScript-، مع أنها أعقد من تقنية بايثون لكن يمكن التعامل معها وضبطها، لكنها ليست منتشرةً بين مبرمجي جافاسكربت، إن أساس الوراثة في جافاسكربت هو الكلمة المفتاحية prototype التي استخدمناها في التمرير في الشيفرة أعلاه، حيث يمكن إضافة مزايا إلى كائن ما بعد تعريفه، كما يلي: <script type="text/javascript"> function Message(text){ this.text = text; this.say = function(){ document.write(this.text + '<br>'); }; }; msg1 = new Message('This is the first'); msg1.say(); Message.prototype.shout = function(){ alert(this.text); }; msg2 = new Message('This gets the new feature'); msg2.shout(); /* msg1 وبالمثل بالنسبة لـ ...*/ msg1.shout(); </script> الملاحظة الأولى: أضفنا تابع alert جديد باستخدام prototype بعد إنشاء نسخة msg1 للصنف، غير أن الخاصية كانت متاحةً للنسخة الحالية ونسخة msg2 التي أنشئت بعد الإضافة، أي أن الخاصية الجديدة تضاف إلى جميع نسخ Message الحالية والجديدة، وتؤدي خاصية النمذجة الأولية هذه إلى إمكانية تغيير سلوك كائنات جافاسكربت المضمَّنة، بإضافة مزايا جديدة أو تغيير الطريقة التي تتصرف بها المزايا الحالية، لذا استخدمها بحرص إذا لم ترد أن تضيع وقتك مع زلات برمجية يصعب تعقبها. ومع ذلك لاستخدام prototype آليةً لإضافة وظائف إلى الأصناف الحالية عيوبه، التي منها تغيير سلوك النسخة الحالية، وتغيير تعريف الصنف الأصلي. ويوجد أسلوب تقليدي أكثر من الوراثة يمكن استخدامه، كما يلي: <script type="text/javascript"> function Parent(){ this.name = 'Parent'; this.basemethod = function(){ alert('This is the parent'); }; }; function Child(){ this.parent = Parent; this.parent(); this.submethod = function(){ alert('This from the child'); }; }; var aParent = new Parent(); var aChild = new Child(); aParent.basemethod(); aChild.submethod(); aChild.basemethod(); </script> يجب أن نلاحظ أن كائن child هنا له وصول إلى basemethod، دون أن يُعطى ذلك الوصول صراحةً، وإنما يرثه من الصنف الرئيسي بحكم أسطر الإسناد/الاستدعاء داخل تعريف الصنف Child: this.parent = Parent; this.parent(); وبهذا نكون ورثنا basemethod من الصنف الرئيسي Parent. يمكن استخدام نفس حيلة التفويض التي استخدمناها في VBScript، كما يلي: <script type=text/JavaScript> function noReturn(){ this.parent.printIt(); }; function returnValue(){ return this.parent.getAttribute(); }; function newMethod(){ document.write("This is unique to the sub class<BR>"); }; function SubClass(){ this.parent = new MyClass(27); this.aMethodWithNoReturnValue = noReturn; this.aMethodWithReturnValue = returnValue; this.aNewMethod = newMethod; }; var inst, aValue; inst = new SubClass(); // عرِّف الصنف الرئيسي document.write("The sub class value is:<BR>"); inst.aMethodWithNoReturnValue(); aValue = inst.aMethodWithReturnValue(); inst.aNewMethod(); document.write("aValue = " + aValue); </script> سنرى استخدام الكائنات والأصناف في دراسات الحالات والفصول التالية، ورغم أنه من الصعب أن يرى المبرمج المبتدئ كيف أن هذه البنية المعقدة تسهل كتابة وفهم البرامج، إلا أننا نأمل أن يتضح هذا المفهوم إذا رأيت استخدام الأصناف في البرامج الحقيقية، على أن ذلك ليس له فائدة كبيرة في البرامج الصغيرة، وإنما سيجعلها أعقد وأطول، لكن كلما زاد حجم البرنامج -أكثر من 100 سطر مثلًا-، فستعين الأصناف والكائنات على تنظيمه وتقليل كمية الشيفرات المكتوبة، ومن المهم أن تعلم أنه من الممكن تعلم البرمجة وكتابة برامج دون الحاجة إلى مفهوم البرمجة الكائنية التوجه أصلًا، فكم من مبرمج كتب برامج دون إنشاء صنف واحد طيلة حياته، لكن إذا استطعت استيعابها فإن فيها مزايا وتقنيات قويةً ومفيدةً. خاتمة نأمل في نهاية هذا المقال أن تكون تعلمت ما يلي: تغلِّف الأصناف البيانات والدوال في كيان واحد. تشبه الأصناف قاطعات البسكويت، حيث تُستخدم لإنشاء النسخ أو الكائنات. تتواصل الكائنات من خلال إرسال رسائل إلى بعضها البعض. عندما يستقبل كائن ما رسالةً فإنه ينفذ تابعًا موافقًا لها. التوابع هي دوال تُخزَّن مثل سمات للصنف. تستطيع الأصناف أن ترث التوابع والبيانات من أصناف أخرى، مما يسهل توسيع إمكانيات الصنف دون تغيير الصنف الأصلي. تعددية الأشكال هي القدرة على إرسال نفس الرسالة إلى عدة أنواع مختلفة من الكائنات، وسيستجيب كل منها بطريقته الخاصة. التغليف وتعددية الأشكال والوراثة كلها خصائص للغات البرمجة كائنية التوجه. تسمى لغة VBScript لغةً كائنية الأساس، لأنها لا تدعم الوراثة وتعددية الأشكال دعمًا كاملًا، رغم دعمها للتغليف. ترجمة -بتصرف- للفصل السابع عشر: Object Oriented Programming من كتاب Learning To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: البرمجة الحدثية Event Driven Programming المساقة بالأحداث المقال السابق: التعابير النمطية في البرمجة التوابع السحرية (Magic Methods) في PHP البرمجة كائنية التوجه (Object Oriented Programming) في PHP البرمجة كائنية التوجه (Object Oriented Programming) في لغة سي شارب #C تطبيق البرمجة كائنية التوجه في لغة سي شارب #C - الجزء الثالث
  12. لقد أمضيت الأعوام العشرة الماضية -وربما قبلها أيضًا- متنقلًا بين مجالات متباينة في السوق، سواءً على الأرض أم عن بعد، ومن الصناعات الثقيلة إلى الإلكترونيات إلى أعمال التصميم والبرمجة وتجربة الاستخدام والترجمة والتأليف، وربما بعض المهن والحرف أيضًا، إما عاملًا فيها بنفسي أو مساهمًا في شركة تقوم على ذلك، وقد رأيت فيها جميعًا عاملًا مشتركًا يؤثر تأثيرًا مباشرًا في مدى نجاحها أو تطورها أو جودة منتجها وحجم أرباحها، ألا وهو التعهيد الخارجي Outsourcing لبعض المهام فيها، وفي هذه المقالة سأتحدث بتفصيل عن أغلب الحالات التي ينبغي فيها استخدام التعهيد الخارجي، والحالات التي يجب الابتعاد عنه فيها، وإسقاط فوائد ذلك على المستقلين العاملين عن بعد. مفهوم التعهيد الخارجي نحن نوجه الخطاب هنا بالدرجة الأولى للمستقلين العاملين عن بعد من ذوي الخبرة. أي الذين مرت على عملهم عدة أعوام واكتسبوا خبرةً في مجالهم، وزاد ضغط عملهم إما بكثرة العملاء، أو المشاريع. إذ أن توكيل المهام إلى الغير يُعَد مكلفًا، خاصةً لمن بدأ عمله للتو، أو ليس بحوزته كثافة جيدة في الدخل المادي لتبرر له مثل هذا القرار، فإن لم تكن هذه الشروط تنطبق عليك، فننصحك أن تراجع الفصل التاسع من كتاب دليل العامل المستقل عن بعد، الذي نذكر فيه مدخلًا إلى التعهيد الخارجي وحالاته باختصار دون الدخول في تعقيدات ولا تفاصيل تجعلك تشعر أن عليك التعهيد بمهامك اليوم إلى غيرك. أما الفئة التي نذكرها، فلعلهم يدركون أنهم أنفسهم جزء من عملية تعهيد خارجي لعملائهم، ومثل هذا المقال شاهد على ذلك، فبعض الشركات تتعاقد مع مستقلين للنشر في قنواتها المختلفة أو التصميم أو البرمجة أو غير ذلك من المهام الخارجة عن صلب تخصصها، من غير أن يكونوا جزءًا من كيانها القانوني الذي تنطبق عليه قوانينها الخاصة بالعاملين النظاميين فيها، ويكون ذلك لعدة أسباب سنفصلها فيما يلي. وعلى ذلك يمكن القول أن التعهيد الخارجي للأعمال باختصار، هو التعاقد مع فرد أو شركة لإنجاز مهام لا نريد إنجازها بأنفسنا، وذلك إما لخروجها عن صلب مجالنا، أو لتوفير الموارد المادية والبشرية، أو لقلة خبرتنا فيها، بحيث تكون تلك المهام مؤقتة -وإن طالت فترات تنفيذها-، فلا ندفع للقائم على تنفيذها رواتبًا شهريةً، كما لا يخضع للوائحنا التنظيمية. فوائد تعهيد الأعمال قد تتساءل الآن عن جدوى تعهيد بعض مهامك إلى غيرك إن كنت تعمل بشكل حر وتقدر على إنجاز أغلبها بنفسك، فأنت أولى بالمال الذي ستدفعه في تنفيذها، وهو سؤال منطقي للغاية، غير أني وجدت منافعًا عظيمةً في الحالات التي شهدتها تتخلى عن إنجاز بعض مهامها وإن كانت تقدر عليها، والنتائج التي ترتبت مباشرةً على ذلك، كانت تحسين الإنتاج وتطويره وزيادة الأرباح مرةً أخرى، وهو الأمر الذي قد يبدو منافيًا للمنطق لأول وهلة. إن الوقت الذي تكسبه إذا عهدت ببعض المهام إلى غيرك، تصرفه في صلب ما يعود عليك بالمال من تخصصك، وتزيح عن كاهلك كثيرًا من المهام التي تستغرق الوقت، وتأكل من قيمة كل ساعة عمل لديك، وهو تطبيق عملي على منافع التخصص والبعد عن الشمولية في التنفيذ، وإن كانت الثانية لها منافعها أيضًا في بعض الحالات. لنضرب أمثلةً قريبةً من سياق مجالات العمل الحر لتوضيح مثل هذه المفاهيم، ولنبدأ بالمثال الذي نسمعه في قصص النجاح التي تفترض الشمولية في التنفيذ والحرص على تنفيذ كل شيء داخليًا، مع عدم تعهيد أي جزء من العمل إلى طرف خارجي، ولتكن سلسلة مطاعم كبيرة تريد التحكم في كل جزء من عملية الإنتاج فيها، فتشتري مزارع ومراعي للمواشي، ومخابزًا من أجل التحكم في عملية التكامل الخلفي -وهي سلاسل توريد المواد الخام لها-، من أجل ضمان جودة الطعام المقدم في فروعها وتوحيد الجودة فيه وعدم التقيد بجودة المواد الخام المتوفرة محليًا لكل فرع. فهذا المثال يفكر فيه في العادة من يرغب في تنفيذ كل شيء بنفسه، غير أنه بالنظر مرة أخرى إلى المثال نجد أن ما تقوم به سلسلة المطاعم تلك لم يخرج عن صلب عملها، بل هذا من زيادة التركيز على تخصصها ومنتجها الذي يعود بالربح عليها، أما إذا أردنا إسقاط مبدأ الشمولية وتعهيد الأعمال هنا سنجد أنها تعهد بأمور المحاسبة مثلًا إلى محاسبين متخصصين -إذا افترضنا أن القائمين على تلك المطاعم من المتخصصين بالتغذية إما دراسة أو عملًا-، وكذلك في شؤون تجهيز ديكورات الفروع والدعاية المطبوعة والرقمية لها، ولعلها تدفع شهريًا لمؤسسة قانونية ترعى شؤونها، وهكذا. لنأخذ الآن مثلًا آخر أكبر قليلًا -تعمدت هذه الأمثلة الكبيرة لتوضيح الصورة بالحالات المتطرفة على كل مقياس، وإلا فإننا سنحاول قدر الإمكان ضرب أمثلة من مجالات العمل الحر المعتادة على الويب في بقية المقال-، وهو لشركة في مصر تعتمد مبدأ الشمولية في الإدارة وقلما توكل إحدى مهامها لغيرها، وهي شركة للألومنيوم. ذلك أن لها مخازن خاصة بها داخل أحد الموانئ القريبة منها، تخزن فيه المواد الخام المستوردة لشحنها إليها، وأسطولًا من الشاحنات ينقل تلك المواد إلى المدينة التي فيها الشركة، وهي توصل العاملين من وإلى الشركة في أسطول سيارات خاص بها، بل أنشأت خط قطار خاص بها له محطة داخل الشركة! يمر على القرى المحيطة بها ليأتي بالعاملين في كل وردية، وهي القائمة على إطعام أولئك العاملين ولا توكل هذه المهمة لشركة مستقلة بخدمات التغذية، وكذلك أنشأت مدينة كاملة للعاملين بمساجدها وأسواقها ومدارسها وغير ذلك، وما يتبعه من كهرباء ومياه وغير ذلك تقدمها لهم مجانًا، فصارت مساحة الشركة بالمصنع والمدينة وغير ذلك ما يقرب من 20 كم مربعًا، عند هذا الحد فإن المرء ينسى أن مهمة هذه الشركة التي أنشئت من أجلها هي صناعة الألومنيوم! وقد وازنت بين جودة الخدمات التي تقدمها تلك الشركة للعاملين وبين شركة بترولية عملت فيها بعدها بسنوات، وكيف أن الثانية ترى وظيفتها استخراج النفط فقط من باطن الأرض، وأي شيء سوى ذلك لا علاقة لها به، فتوظف شركة لخدمة العاملين، وإطعامهم، وشركة لنقلهم، وهكذا. فرأيت كيف أن شركة الألومنيوم التي أثقلت كاهلها بكل تلك المهام غير الضرورية بالكاد تنتج بضعة منتجات أولية، ومع هذا فهي تقدم خدمات متدنية الجودة للعاملين في ما يخص نقلهم مثلًا، إذ أن تكلفة تجديد أسطول السيارات العملاق ذاك ليست قليلة. وكانت تستطيع صب جهودها واستغلال إمكانياتها في إنشاء مصانع متخصصة داخل مساحتها العملاقة لتأخذ تلك السبائك الخام وتصنع منها منتجات أخرى بدلًا من بيعها لمصانع غيرها، وقد تنبهت لذلك مؤخرًا فأنشأت مصنعًا لمثل هذا. أما الشركة الثانية فتعهد بأي شيء غير استخراج النفط إلى شركات غيرها، وبما أن الشركة التي تقدم كل خدمة لا تقدم غيرها فإن جودة الخدمات المقدمة إلى العاملين فيها عالية جدًا، وكذلك فإن مستويات الأمان وسلامة العاملين عالية جدًا موازنة بالشركة الأولى. وهكذا تستطيع رؤية قصدي في كل مهمة من مهام الشركة، وفوق ذلك كله فقد صرفت طاقات العاملين فيها إلى صلب عملها، وهو استخراج النفط. وخلاصة ذلك أن وقت الشركة محدود، وطاقات العاملين فيها كذلك، وقل مثل هذا على موارد العامل المستقل، فالأولى أن تكون كل ساعة يصرفها من وقته تعود بأعلى عائد ممكن، وأي شيء يعيق هذا فيجب توكيله إلى من يقوم به على الوجه الذي يجب، ونكتفي بمتابعة أدائه. حالات التعهيد الخارجي قلنا أن قرار التعهيد الخارجي مكلف ماديًا إن لم يكن ثمة غطاء مادي من كثرة المشاريع أو قيمتها، أو زيادة الأرباح باستغلال الموارد المادية والبشرية في صلب التخصص، لهذا يجب التفكير مليًا قبل اتخاذ هذا القرار، ووضع نظام للحالات التي يجب التعهيد فيها، وإدارة التعهيد في تلك الحالات لضمان نجاحه، فقد نعهد بمهمة لغيرنا لتوفير الوقت والتركيز على صلب عملنا، ثم لا نلبث أن نجد الوقت يضيع في متابعة التنفيذ مع من عهدنا إليه بالعمل. تعهيد المهام الإدارية لنضرب مثلًا ببعض التخصصات التقنية المنتشرة في العمل الحر مثل الهندسة أو التصميم أو الترجمة مثلًا، ماذا تكون المهام الإدارية لمثل تلك التخصصات يا ترى؟ تشمل المهام الإدارية هنا جدولة الاجتماعات مع العملاء والمتابعة معهم، ومتابعة البريد، وغير ذلك من المهام التي قد تدخل في نطاق المساعدة الافتراضية أو السكرتارية التقليدية، والحسابات المالية، وضرائب الدخل -إن كان العمل الحر يخضع للضريبة في بلدك-، والشؤون القانونية. ونذكّر هنا أن هذه الحالات للمستقلين ذوي الخبرة والذين يعملون على مشاريع كثيرة، أو مشاريع كبيرة تتعدد تخصصاتها، غير أن أغلب المهام الإدارية يجدر بالمستقل توظيف متخصصين لها على أي حال، خاصة في الأمور الضريبية أو المحاسبية. وهكذا ترى أن تلك المهام هي ما يخرج عن النطاق الفني لعمل المستقل، فهي ليست مهامًا هندسية أو ترجمة أو تصميمات، ورغم أن إحساس المستقل أنه يستطيع إنجاز حساباته وجدولة اجتماعاته وغير ذلك، لأنه يفعل ذلك حقًا لو لم يفوّض هذه الشؤون إلى غيره، إلا أن هذه أوقات ضائعة في غير ما يعود عليه بالمال المباشر، وتطرح من قيمة كل ساعة عمل له. هذا غير أنك قد تكون جاهلًا ببعض التفاصيل التي قد تتسبب لك في غرامات لاحقًا، وقد وقعت في هذا بنفسي إذ اكتشفت العام الماضي أن موعد دفع بعض المستحقات السنوية قد تغير بسبب تغيير في سياسة الحكومة، ولم أكن أعرف هذا لانشغالي بعملي عن مثل هذه التفاصيل، وبالكاد استطعت دفع تلك المستحقات قبل نهاية موعدها، وكنت لأتجنب هذا لو أني تعاقدت مع محاسب قانوني على إنجاز هذه المهام لي في كل عام. أما إذا لم تشأ تعهيد هذه المهام الإدارية إلى غيرك فاجعل على الأقل وقتًا كل أسبوع لإنهاء هذه المهام وجدولتها مسبقًا، لئلا يضيع الوقت في تنفيذها أو التفكير فيها كل يوم. تعهيد مهام التسويق شؤون التسويق ومهامه هنا هي إدارة صفحاتك الاجتماعية الخاصة بعملك المستقل، وإنشاء حملاتك البريدية وإدارتها، وإنشاء المحتوى الترويجي على موقعك أو المواقع الأخرى. وهنا يُفضل التعاقد مع متخصص بالتسويق بالمحتوى، إذ قد يظن المستقل أن خبرته بالتسويق كافية، أو أن سمعته بين العملاء تكفي مما يراه من إحالات العملاء إليه واتصالهم به، أو قد يكون قرأ في خوارزميات تحسين المحتوى لمحركات البحث SEO أو كيفية التعامل مع العملاء أو درس مساقًا للتسويق بالمحتوى قبل عدة أعوام. غير أن هذه الأمور كلها في تغير مستمر ومتسارع، وإنها لتفاجئني أنا شخصيًا عندما أنغمس في عدة مشاريع متتالية على مدار عام مثلًا ثم أفيق على ملاحظة لعميل أن هذه التقنية أو تلك صارت قديمة لأن جوجل غيرت شيئًا مثلًا. والحق أني لا أملك الوقت لمراجعة مثل هذه التطورات كل حين، حتى ولو أشار علي عملائي بهذا، فما العمل؟ إن الحل هنا هو أن أشتري وقتي بالمال، وأوكل هذه المهمة إلى متخصص بالتسويق يعرف متغيراته لأنه صلب مجاله، وكن على يقين أن ما ستدفعه له سيعود عليك في يوم عمل على الأكثر، وسيكون هذا أفضل من إزاحة مشاريعك جانبًا ودراسة هذه التغيرات لتطبيقها في عملك، بل تضمن أنك ستطبق أفضل التقنيات في الغالب وفقًا لنصائح هذا المتخصص، ويُنظر في هذه المقالة التي تشرح الدروس المستفادة من توظيف متخصص تسويق بالمحتوى من منصة مستقل للاستزادة. تعهيد المهام المتخصصة نأتي هنا إلى النقطة التي كتبنا المقال لأجلها، وهي التعهيد بمهام من صلب عملك وتخصصك أو تدور حوله، فإذا كنت مطور مواقع مثلًا وطلب منك عميل بناء موقع لشركة كبيرة باللغتين العربية والإنجليزية، فستكون هذه المهام هي تصميم تجربة الاستخدام للموقع، وتصميم الواجهة المرئية إذا كنت مطور واجهات خلفية أو العكس بالعكس، وترجمة محتوى الموقع، وهكذا. وقد تقول أنك تفعل كل ذلك بنفسك، وهذا صحيح، لكن غرض هذا المقال هو فتح أفق جديد للربح من خلال تحسين جودة الخدمة التي تقدمها، وتقليل وقت التنفيذ، ومن ثم يعني رضا أكبر للعملاء وترشيحهم لعملاء جدد أو عودتهم لأعمال جديدة، وزيادة في الأرباح لكثرة تواتر المشاريع في نفس مدة التنفيذ القديمة أو قريب منها، وذلك كله عبر التعهيد الخارجي للمهام. فقد تكون مطور واجهات خلفية فقط، فتعتمد على ذوقك في اختيار الخطوط والألوان التي ستقدمها إلى العميل، وكذلك على المنطق العام في ترتيب القوائم والأزرار وأحجامها، وربما تستخدم خبرتك اللغوية في ترجمة محتوى الموقع، وستخرج بنتيجة تقدمها إلى العميل، وقد يرضى ويتم الصفقة، فتقول حينئذ ما الداعي إلى تعهيد هذه المهام إلى غيري ما دمت أنفذها بنفسي! فنقول أن جزء التطوير الخلفي للموقع قد يستغرق ربع مدة التنفيذ مثلًا أو نصفها، فإذا عهدنا بمهمة تصميم تجربة الاستخدام إلى مصمم متخصص، وكذلك الترجمة والواجهة الأمامية، وبدؤوا جميعًا في تنفيذ تلك المهام منذ بداية العمل على المشروع فقد يستغرقوا نصف مدة التنفيذ إلى تمام الصور النهائية لمهامهم، ثم يأتي دورك كمطور واجهة خلفية لتتم بقية الموقع، فتفوز بتوفير ربع مدة التنفيذ الأصلية، تعمل فيها على مشروع جديد! وهذه المكاسب الصغيرة تتراكم لتمثل مشاريع كاملة بنهاية كل عام كنت تضيعها بتنفيذ كل شيء وحدك، وعملاء جدد تعاملت معهم، وتقييمات وسمعة وترشيحات لعملاء ما كنت لتحصل عليها لو أنك عملت وحدك على بضعة مشاريع في كل عام. لنضرب مثلًا آخر تتضح به الفكرة، وليكن لمصممٍ معماري ينفذ تصميمات ثلاثية الأبعاد، ومعلوم أن التصميمات ثلاثية الأبعاد تحتاج إلى حواسيب قوية للغاية من أجل تنفيذ عملية الإخراج النهائية Rendering للتصاميم لتبدو محاكية للواقع ويقبلها العملاء. فهنا يضطر المصمم إلى شراء حاسوب بآلاف الدولارات من أجل تنفيذ عمليات الإخراج تلك لكل عميل، لأن العميل يريد تصميماته في أسرع وقت ممكن، ذلك أن الحواسيب العادية التي يكون ثمنها أقل من ألف دولار لا تملك العتاد الذي يستطيع التعامل مع هذه التصاميم، ولك أن تعلم أن إخراج صورة واحدة عالية الدقة لتصميم كثير التفاصيل على حاسوب محمول ذي معالج Intel Core i3-3227U مثلًا -وهذا يعني مواصفات متوسطة بالتبعية- قد يستغرق أربعة أيام! هذا بعد إنهاء عملية التصميم والنمذجة التي ينفذها المصمم، فتلك الأيام الأربعة تترك فيها الحاسوب يعمل بلا توقف من أجل إخراج الصورة النهائية فقط! أما الحواسيب المجهزة للتصاميم ثلاثية الأبعاد فلا يتجاوز زمن ذلك الإخراج بضع دقائق مثلًا! وبناء عليه يجد المصمم نفسه يقدم صورًا منخفضة الجودة للعملاء، أو يقضي وقتًا طويلًا لكل مشروع أو كل تعديل، هذا أو شراء حاسوب غالي الثمن! والحل هنا هو اللجوء لمن يتخصص في تقديم خدمة الإخراج تلك، إما مستقلون على منصة مستقل مثلًا أو شركات متخصصة لديها حواسيب بالغة القوة متصلة ببعضها لتشكل عناقيد clusters تعمل معًا كحاسوب واحد كبير، يصل إليها المصمم عن بعد ويضبط إعدادات الإخراج التي يريدها ويرفع الملفات إليها، ثم يستلمها في مدة قد لا تتجاوز الساعة! وتكون تكلفة مثل تلك الخدمات زهيدة موازنة بميزانيات مشاريع التصميم نفسها، وعليه يكون المصمم رابحًا قطعًا إذا لجأ إلى تعهيد مهمة مملة ومكلفة مثل الإخراج إلى غيره. وهكذا ينبغي أن يُجاب على عدة أسئلة قبل تنفيذ العمل داخليًا أو التعاقد مع طرف ثالث لتنفيذه، وتوضح تلك الأسئلة الفائدة العملية والمهنية التي تعود من تنفيذ العمل ذاتيًا، فمشاريع التدقيق اللغوي تزيد من سرعة الترجمة للمترجم في مشاريع الترجمة الأخرى، وعليه فستفيده في صلب مجاله، فلا يعهد بها إلا عند الانشغال مثلًا أو كثرة الأعمال، وكذلك يجب توضيح المهام التي ستتعطل إن اخترنا تنفيذ المهمة محل النقاش داخليًا ولم نعهد بها إلى طرف ثالث، ويُرجع في هذا إلى مقالة كرائد أعمال، لا تستطيع القيام بكل شيء لصاحبته رايتشل أندرو Rachel Andrew. بين الوساطة التعهيد الخارجي يجب التفريق الواضح بين أن تعهد ببعض المهام من المشروع إلى غيرك من المستقلين أو الشركات، وبين أن تكون وسيطًا بينهم وبين العملاء، وقد نبهنا على هذا اللبس في الفصل التاسع من كتاب دليل العامل المستقل عن بعد، لكن نعيد ذكره هنا للأهمية. فإذا كنت تقدم نفسك في حساباتك الاجتماعية الخاصة بالعمل وموقعك وحساباتك على منصات العمل الحر بطريقة توحي أنك من ينفذ الأعمال والمشاريع الموكلة إليك، فلا ينبغي أن تكون وسيطًا تتعاقد مع العميل على المشروع ثم تذهب به إلى غيرك لينفذه لك، ففي هذا خداع للعميل الذي يُغرر بما تقوله عن نفسك في النبذة الشخصية عنك وفي معرض أعمالك، وقد بينّا في ذلك الفصل أمثلة لتلك الحالات ونتائجها على كل من صاحب العمل والمستقل. أما ما نقصده هو أن تعهد ببعض مهامك أو أجزاء من مشاريعك إلى غيرك من المتخصصين فيها التي تقع خارج نطاق تخصصك، للأسباب التي فصلناها قبل قليل. إدارة التعهيد الخارجي: آليات تنفيذ المهام من الجوانب التي يجب وضعها في الحسبان عند التعهيد الخارجي هو أسلوب إدارة العمليات مع المتعهد لضمان استلام الأعمال في الوقت المناسب وبالجودة المناسبة، إذ قد تكون أجزاءً من مشاريع أكبر منها كما أوضحنا، فإذا احتوت على أخطاء أو مشاكل أو لم تكن بالجودة المناسبة فهذا يعني وقتًا أكبر يضيع في متابعة العمل مع المتعهد، ونكون قد هربنا من مشكلة فوقعنا في أخرى. ينبغي على المستقل الراغب في تعهيد مهام إلى جهة خارجية -سواء كانت شخصًا واحدًا أو شركة- أن يحدد قواعد يُبنى عليها العمل مع تلك الجهة، وإلا فستتحول عملية التعهيد إلى ثقب أسود تدخل فيه المهمة دون أن يعرف المستقل ما يحدث فيها ولا كيف يتم العمل عليها، ولا يستطيع أن يبني عليها بقية المشروع لأنه لا يعلم متى يتسلمها. حقوق الملكية والملفات المصدرية يذكر ييجور باجايونكا Yegor Bugayenko عدة قواعد تؤسس لهذه الآليات بين صاحب العمل الذي هو أنت هنا، وبين المتعهد بمهمة واحدة أو أكثر، في مقال دليلك لإنجاح عملية التعهيد الخارجي للبرمجيات، فمنها الاتفاق على حقوق ملكية العمل المنفذ ليكون ملكًا لك وليس للمتعهد، وكذلك وضع يدك على مصدر العمل سواء كان مستودعًا للشيفرة البرمجية أم تصميمًا ثلاثي الأبعاد أم غير ذلك. أدوات إدارة المشاريع ينبغي الاستعانة بأداة لإدارة المشروع بينك وبين المستقل الذي تعهد إليه بالعمل، فإذا كانت صفحة نقاش الصفقة في منصة مستقل تكفي فيها، وإلا فيمكن الاستعانة بمنصة أنا لإدارة المهام ومشاركة لوحة العمل مع الأطراف القائمة بالتنفيذ أو المتابعة، أو خدمة أخرى مثل Trello، أو بمستندات جوجل أو خدمة تخزين سحابية أخرى. ثم يأتي دور المتابعة لذلك التنفيذ، وهنا يُفضل الاستعانة بمتخصص إذا كانت المهمة ليست من تخصصك، خاصة في المشاريع الكبيرة مثل مشاريع الهندسة الإنشائية أو المعمارية، ومشاريع التصميم الكبيرة، لئلا تدفع ميزانية ضخمة ويأتيك العمل في الوقت المحدد ثم لا يكون لديك المحك العلمي والمعرفي للحكم على جودة العمل! وقد وقع لي مثل هذا من قبل إذ كنت أعمل في شركة برمجية في 2016 واستلمت الشركة هويتها الجديدة لمواقعها ومطبوعاتها من شركة دعاية وتصميم وكلفت الشركة ميزانية كبيرة، فلما اطلعتُ عليها وجدت أخطاء ما كان ينبغي أن تخرج من عمل بربع تلك الميزانية، فأوكل مدير الشركة إلي مهمة مراجعة تلك الأخطاء مع الشركة بسبب خبرتي في هذا المجال، واستغرق الأمر بضعة أشهر حتى نصل إلى نتيجة مرضية للطرفين. وهكذا لم يكن كافيًا أن يأتي العمل في الوقت المحدد ولا أن يكون التواصل بين الطرفين جيدًا، بل ينبغي ضمان تسليم العمل بالجودة المطلوبة، لأن هذه عملية تعهيد بالنهاية وذلك العمل سيعود ليدخل في مشروع آخر أكبر، وستكون كلا من سمعتك مع العميل النهائي ونتيجة هذا المشروع مرآة لجودة الأعمال والمهام التي تعهد بها إلى غيرك، وهي نصيحة أخرى من مقال ييجور باجايونكا سالف الذكر. الهيكل التنظيمي لإدارة عملية التعهيد إذا كنت تعهد بأعمال تحتاج إلى تدخل أكثر من شخص فيجب أن تضع بعض قواعد العمل التي تنظم سيره بين المتعاهدين وبعضهم إذا كانوا يعملون معًا، وبينك وبينهم من جهة أخرى، لئلا تضيع تقارير سير العمل بينكم وتبقى مهام معلقة إلى حين السؤال عنها رغم تمامها. وذلك بأن تحدد مواعيد تسليم المهام ومن يتسلمها، وماذا يفعل بها بعد أن يتسلمها، وهكذا إلى أن تصل إليك بالنهاية لتضعها في مشروعك، وتُستخدم أدوات إدارة المشاريع سالفة الذكر في مثل هذا، مثل أداة أنا من حسوب، أو مثل Basecamp التي يمكن تحديد الأفراد المعنيين بكل مهمة تنفيذًا وإبلاغًا. وسيئة ترك هذه الأعمال دون قواعد متابعة واضحة هي احتمال تنفيذ بعض المتعاهدين لمهام على غير النحو المطلوب، ويضيع الوقت والجهد فيها ثم لا تكون مناسبة للمشروع الخاص بك، وتضطر إلى البدء من الصفر معهم مرة أخرى أو مع غيرهم. خاتمة إن غرضنا من هذه المقالة هو فتح أفق جديد للمستقل ينتقل فيه من العمل وحده بالتوالي على المشاريع، إلى التعاون مع غيره من المستقلين أو الجهات الأخرى على تنفيذ مشاريعه ومهامها بالتوازي، فيقل الوقت اللازم للعمل عليها، وتزيد أنواع المشاريع التي يستطيع العمل عليها من ناحية أخرى، فقد يرفض المستقل بعض المشاريع التي يكون ضعيف الخبرة في بعض أجزائها أو لا يملك الوقت الكافي لها. اقرأ أيضًا ما يلزم العامل المستقل معرفته عنالتعهيد الخارجي التعهيد الخارجي: الخطأ المميت للشركات الناشئة دليلك لإنجاح عملية التعهيد الخارجي لتطوير البرمجيات
  13. تعرَّف التعابير النمطية بأنها مجموعات من المحارف التي تصف مجموعةً أخرى من المحارف أكبر منها، وهي تصف نمط المحارف الذي نستطيع البحث عنه في متن نص ما، وهي تشبه مفهوم المحارف البديلة wildcards المستخدمة في تسمية الملفات على أغلب نظم التشغيل، حيث يمكن استخدام محرف النجمة * لتمثيل أي تسلسل من المحارف في اسم ملف، ولهذا فإن ‎*.py تعني أي ملف ينتهي بالامتداد ‎.py، بل إن المحارف البديلة ما هي إلا مجموعة فرعية صغيرة من التعابير النمطية. وتدعم أغلب لغات البرمجة الحديثة التعابير النمطية ضمنيًا، أو لديها مكتبات أو وحدات متاحة للاستخدام في البحث عن النصوص واستبدالها وفقًا لتعابير نمطية، وذلك بسبب الإمكانيات الكبيرة لهذه التعابير، لكن شرحها المفصل هو خارج نطاق حديثنا، وستجد مصادر أخرى تتحدث عنها بتوسع شديد، وننصحك هنا بمراجعة كتب مثل كتاب أورايلي في التعابير النمطية وهو باللغة الإنجليزية، إضافةً إلى المقالات الموجودة في أكاديمية حسوب. ولعل إحدى الخصائص المميزة للتعابير النمطية هي أنها تُظهر أوجه التشابه مع البرامج في البنية، فهي أنماط مبنية من وحدات أصغر منها، وتلك الوحدات هي: محارف منفردة. محارف بديلة. نطاقات أو مجموعات من المحارف، أو مجموعات محاطة بأقواس. وبما أن المجموعة نفسها ما هي إلا وحدة، فيمكن أن تكون لدينا مجموعات من المجموعات إلى أن نصل إلى مستوى تعقيد كبير، ونستطيع جمع تلك الوحدات بطرق تشبه استخدام التسلسلات أو التكرارات أو العوامل الشرطية في لغات البرمجة، وسننظر في كل منها في حينه، فإضافةً إلى شرح مفهوم التعابير النمطية، سنعرف كيف نستخدمها في برامج بايثون، وننظر كيف تدعمها لغتا VBScript وجافاسكربت، ولنستطيع تجربة الأمثلة هنا يجب أن نستورد وحدة re ونستخدم التوابع الخاصة بها، وسنفترض أنك استوردتها تلقائيًا دون ذكر ذلك في كل مرة. التسلسلات تسلسلات المحارف لا شك أن أبسط بنية برمجية يمكن تصورها هي تسلسل من المحارف، كما أن أبسط تعبير نمطي ما هو إلا تسلسل من المحارف كذلك: red وهذا سيطابق أو يبحث في سلسلة نصية عن أي حدوث لهذه الأحرف الثلاثة التي تتكون منها كلمة red على الترتيب، وبناءً على ذلك سيجد كلمات مثل red وlettered وcredible، لأنها تحتوي على كلمة red ضمنها. ولنتحكم أكثر في خرج المطابقات، فإننا نوفر بعض المحارف الخاصة التي تُعرف باسم المحارف الوصفية metacharacters للحد من نطاق البحث: table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } التعبير المعنى مثال ‎^red في بداية السطر فقط red ribbons are good red$‎ في نهاية السطر فقط I love red ‎\Wred في بداية الكلمة فقط it's redirected by post ‎red\W‎ في نهاية الكلمة فقط you covered it already يُطلق على هذه المحارف اسم المرابط anchors لأنها تثبت موضع التعبير النمطي في جملة أو كلمة ما، وهناك عدة مرابط أخرى معرَّفة في توثيق وحدة re يمكنك الاطلاع عليها. المحارف البديلة قد تحتوي التسلسلات على محارف بديلة Wildcard Characters تحل محل أي محرف، والمحرف البديل هو نقطة . جرِّب الشيفرة التالية مثلًا: >>> import re >>> re.match('be.t', 'best') <_sre.SRE_Match object at 0x01365AA0> >>> re.match('be.t', 'bess') تخبرنا الرسالة التي في الأقواس السهمية أن التعبير النمطي 'be.t' -الممرَّر وسيطًا أول- يطابق السلسلة 'best' الممرَّرة وسيطًا ثانيًا، كما يطابق 'beat' و'bent' و'belt' وغيرها، لكن المثال الثاني لا يطابق لأن 'bess' لا تنتهي بحرف t، لذا لا يُنشئ MatchObject. يمكنك تجريب عدة مطابقات أخرى لتفهم كيفية عملها، ولاحظ أن match()‎ لا تطابق إلا في بداية السلسلة النصية، أما لمنتصفها فنستخدم search()‎ كما سنرى لاحقًا. المجالات أو الفئات يتكون المجال range (أو الفئة set) من تجميعة من المحارف المغلَّفة في أقواس مربعة، ويبحث التعبير النمطي عن أي محرف يكون داخل هذه الأقواس: >>> re.match('s[pwl]am', 'spam') <_sre.SRE_Match object at 0x01365AD8> فهذا سيطابق swam أو slam، لكن لن يطابق sham لأن h غير موجودة في فئة التعبير النمطي. أما إذا وضعنا محرف الإقحام ^ أول عنصر في المجموعة، فكأننا نقول أننا نريد البحث عن أي محرف عدا المحارف الموجودة في المجموعة: >>> re.match('[^f]ool', 'cool') <_sre.SRE_Match object at 0x01365AA0> >>> re.match('[^f]ool','fool') وبناءً على ذلك نستطيع مطابقة cool وpool، لكننا لن نطابق fool لأننا نبحث عن أي محرف عدا f في بداية النمط. المجموعات نستطيع جمع تسلسلات من المحارف أو الوحدات الأخرى معًا من خلال تغليفها بأقواس، وهي لا تقوم بدور العزل هنا، بل تفيدنا عند دمجها مع خصائص التكرار والشرطيات التي سنشرحها لاحقًا. التكرار يمكن إنشاء تعابير نمطية تطابق تسلسلات مكررةً من المحارف باستخدام بعض المحارف الوصفية الخاصة، ونستطيع البحث بها عن تكرار محرف واحد أو مجموعة محارف: التعبير المعنى مثال '?' محرف واحد على الأكثر من المحارف السابقة -أي عدد صفر أو واحد من المحارف-، انتبه إلى الجزء الصفري هنا لأنه قد يربكك. pythonl?y يطابق: pythony وpythonly '*' يبحث عن صفر محرف سابق أو أكثر. pythonl*y يطابق ما ذكر في السطر السابق بالإضافة إلى: pythonlly وpythonllly ... إلخ '+' يبحث عن محرف واحد أو أكثر من المحارف السابقة. pythonl+y يطابق: pythonly وpythonlly وpythonllly ...إلخ {n,m} يبحث عن نطاق من التكرارات من n إلى m من المحارف السابقة. { fo{1,2 يطابق : fo أو foo يمكن تطبيق جميع محارف التكرار هذه على مجموعات أخرى من المحارف، وبناءً عليه: >>> re.match('(.an){1,2}s', 'cans') <_sre.SRE_Match object at 0x013667E0> يكون النمط هنا ‎(.an){1,2}s الذي يقول إن لدينا مجموعةً تتكون من أي محرف متبوع بالحرفين an، ونريد أن نجد مجموعةً أو اثنتين متبوعتين بالحرف s، وسيطابق هذا النمط: cancans وpans وcanpans، لكن لن يطابق bananas لانعدام وجود حرف قبل مجموعة an الثانية فيها. جرب تعديل البحث ليطابق bananas. هناك مشكلة واحدة مع نمط التكرار {m,n}، وهو أنه لا يحد المطابقة لعدد n من الوحدات، وعلى ذلك سيطابق مثال {fo{1,2 الذي في الجدول السابق fooo لأنه يطابق foo في بداية fooo، وعليه فإذا أردنا الحد من عدد المحارف المطابَقة، فسنحتاج إلى أن نُتبع تعبير الضرب بمرساة أو نطاق نفي negated range. وفي حالتنا، فإن [fo{1,2}[^o سيمنع fooo من المطابقة بما أنه يقول طابق حرف o واحد أو حرفين متبوعين بأي شيء غير o، لكن يجب أن يكون متبوعًا بشيء ما، لذا فإن foo لم تَعُد مطابقةً الآن. يوضح هذا الطبيعة المتقلبة للتعابير النمطية، فقد يصعب ضبطها للحصول على الخرج الذي نريده، ويجب أن نضعها تحت اختبارات فاحصة لتجنب الأخطاء. أما النمط الفعلي المطلوب للسماح بمطابقة foo وfoobar مع استثناء fooo، فهو ‎'fo{1,2}[^o]*$'‎، وهذا يعني fo أو foo المتبوعين بعدد صفر أو أكثر من حروف o ونهاية السطر، وفي الواقع حتى هذا النمط ليس تامًا وخاليًا من الأخطاء -جرب fooboo مثلًا-، لكن نحتاج أن نشرح بعض العناصر الأخرى قبل أن نحسّنه ونعدل فيه. التعابير الجشعة يقال إن التعابير النمطية جشعة، أي أن دوال البحث والمطابقة ستطابق كل ما تستطيعه من السلسلة النصية، بدلًا من التوقف عند أول مطابقة تامة، وهذا لا يهم غالبًا، لكننا سنحصل على مطابقات أكثر من المطلوب عند جمع المحارف البديلة مع عوامل التكرار. لننظر في المثال التالي: إذا كان لدينا تعبير نمطي مثل a.*b الذي يقول إننا نريد إيجاد a متبوعًا بأي عدد من المحارف إلى أن نصل إلى حرف b، فستبحث دالة المطابقة من أول a إلى آخر b، مما يعني أنه إذا احتوت سلسلة البحث على أكثر من b فستُضمَّن جميعًا في الجزء‏ ‎*. من التعبير عدا آخر واحدة، وعليه: re.match('a.*b','abracadabra') فقد طابق MatchObject في هذا المثال كل abracadab وليس أول ab فقط، وسلوك المطابقة الجشع هذا هو أكثر الأخطاء التي يرتكبها المبرمج في بداية استخدامه للتعابير النمطية، ولمنع هذا السلوك الجشع نضيف '?' بعد محرف التكرار، كما يلي: re.match('a.*?b','abracadabra') مما سيطابق ab فقط. الشرطيات يتبقى لدينا الآن أن نجعل التعبير النمطي يبحث في عناصر اختيارية أو يختار نمطًا من بين عدة أنماط، وسننظر في كل منها على حدة. العناصر الاختيارية يمكن تحديد محرف ما ليكون اختياريًا باستخدام عدد صفر أو أكثر من محارف التكرار الوصفية: >>> re.match('computer?d?', 'computer') <re.MatchObject instance at 864890> هذا سيطابق compute وcomputer وcomputed، كما سيطابق computerd، لكننا لا نريد هذه الأخيرة، لذا سنضيق النطاق الذي نريده كما يلي: >>> re.match('compute[rd]$','computer') <re.MatchObject instance at 874390> وهذا سيختار computer وcomputed فقط، ويرفض computerd، وإذا أضفنا ? بعد هذا النطاق فسنسمح باختيار compute مع تجنب computerd أيضًا. التعابير الاختيارية بالإضافة إلى خيارات المطابقة من قائمة محارف السابقة الذكر، يمكن المطابقة بناءً على اختيار من تعابير فرعية، فقد ذكرنا سابقًا أننا نستطيع جمع تسلسلات من المحارف في أقواس، لكن الواقع أننا نستطيع جمع أي تعبير نمطي عشوائي بين أقواس ومعاملته مثل وحدة، وسنستخدم الصيغة (RE) أثناء شرح التركيب اللغوي هنا للإشارة إلى أي تجميع لتعابير نمطية، والحالة التي نريد دراستها هنا هي مطابقة تعبير نمطي يحتوي على ‎(RE)xxxx أو ‎(RE)yyyy حيث تكون xxxx وyyyy أنماطًا مختلفةً، وبناءً عليه فإذا أردنا مطابقة premature وpreventative فسنفعل هذا بواسطة محرف الاختيار الوصفي |: >>> regexp = 'pre(mature|ventative)' >>> re.match(regexp,'premature') <re.MatchObject instance at 864890> >>> re.match(regexp,'preventative') <re.MatchObject instance at 864890> >>> re.match(regexp,'prelude') نلاحظ أنه عند تعريف التعبير النمطي تعين علينا أن ندرج النص الكامل لكلا الخيارين داخل أقواس بدلًا من (e|v)، ولو لم نفعل لاقتصر الخيار على prematureentative وprematurventative فقط، أي كان الحرفان فقط هما اللذان سيمثلان الخيارات المتاحة، وليس المجموعات كلها. نستطيع الآن باستخدام هذه التقنية أن نعود إلى المثال أعلاه الذي أردنا فيه التقاط fo أو foo وتجنب التقاط fooo إضافةً إلى أي شيء يأتي بعدها، وقد تركناها مع تعبير نمطي يتكون من fo{1,2}[^o]*$‎، والمشكلة هنا أن التطابق سيفشل إذا احتوت السلسلة النصية التالية لـ fo أو foo على o، لكن يمكن الالتفاف على ذلك باستخدام عدة خيارات من التعبيرات، ونريد أن ينجح التطابق سواء كان النمط في نهاية السطر أو متبوعًا بأي حرف سوى o، وسيبدو ذلك كما يلي: fo{1,2}($|[^o]) والذي سيعطينا أخيرًا ما نريده، تجدر الإشارة إلى أنه يجب تنفيذ اختبارات كافية عند استخدام التعابير النمطية، لضمان عدم التقاط أي شيء غير مرغوب فيه، وأننا نلتقط كل ما نريد التقاطه. المزيد من الملاحظات حول التعابير النمطية تحتوي وحدة re على مزايا كثيرة لم نذكرها هنا، يجدر النظر فيها ودراستها من توثيق الوحدة نفسها، لكننا نريد تسليط الضوء على مجموعة من الرايات flags التي يمكن استخدامها عند تصريف التعبيرات مع دالة re.compile()‎، والتي تتحكم في أمور مثل مطابقة النمط في الأسطر المختلفة، أو تجاهله لحالة الأحرف، أو غير ذلك. ومن المهم استخدام أداة تختبر التعابير النمطية للتحقق من نتائجها، وتوجد أدوات عديدة منها على الويب، لكن نخص بالذكر منها أداة regex101، حيث نكتب فيها تعبيرًا نمطيًا وسلسلة اختبار، ثم نرى الأجزاء التي طابقها التعبير من السلسلة النصية، وهذه الأداة تحديدًا تعطينا وصفًا مفيدًا عما يفعله التعبير النمطي، وتسمح لنا باختيار أصناف فرعية للمطابَقات الناتجة، وغيرها من المزايا المفيدة. استخدام التعابير النمطية في بايثون رأينا في السطور السابقة شيئًا يسيرًا من التعابير النمطية، ونريد أن نطبق ذلك في بايثون، حيث نستطيع استخدامها أداة بحث قويةً جدًا في النصوص، إذ يمكن البحث عن صور كثيرة مختلفة لسلاسل نصية في عملية واحدة، بل يمكن البحث عن المحارف التي لا تُطبع مثل الأسطر الفارغة باستخدام بعض المحارف الوصفية المتاحة، كما يمكن استبدال هذه الأنماط باستخدام التوابع والدوال الخاصة بوحدة re، كما رأينا في دالة match()‎ أعلاه، وفيما يلي بعض الدوال الأخرى المتاحة: الدالة - التابع التأثير (match(RE,string يعيد كائن مطابقة إذا طابق التعبير النمطي بداية السلسلة. (search(RE,string يعيد كائن مطابقة إذا وُجد تعبير نمطي في أي مكان في السلسلة النصية. (split(RE, string مثل string.split()‎ لكن يستخدم تعبيرًا نمطيًا مثل فاصل. (sub(RE, replace, string تعيد سلسلةً نصيةً أُنتجت عن طريق استبدال لـ re في أول مطابقة للتعبير النمطي، وهذه الدالة لها مزايا أخرى إضافية، انظر توثيقها للمزيد. (findall(RE, string تبحث عن جميع مرات حدوث التعبير النمطي في سلسلة نصية، وتعيد سلسلةً من كائنات المطابقة. (compile(RE تنتج كائن تعبير نمطي يمكن إعادة استخدامه لعدة عمليات مع نفس التعبير النمطي، ويكون للكائن جميع التوابع أعلاه لكن مع re مضمَّنة، ويكون أكثر كفاءةً من النسخ الخاصة بالدالة. لا شك أن هذه القائمة لا تحتوي جميع توابع re ودوالها، كما أن التوابع التي ذكرناها في الجدول لها معامِلات اختيارية لتوسيع استخدامها، واخترناها لأنها أكثر العمليات استخدامًا، ومناسبةً لاحتياجاتنا. مثال عملي على التعابير النمطية لننشئ برنامجًا يبحث في ملف HTML عن وسم IMG ليس له قسم ALT، فإذا وجدنا واحدًا فسنضيف رسالةً إلى المالك ليكتب ملفات HTML أفضل في المستقبل. import re # لنسمح بصفر مسافة أو أكثر بين img أو IMG التقط # < و I img = '< *[iI][mM][gG] ' # alt أو ALT السماح بأي عدد من المحارف حتى before > alt = img + '.*[aA][lL][tT].*>' # افتح الملف واقرأه في قائمة filename = input('Enter a filename to search ') inf = open(filename,'r') lines = inf.readlines() # ALT بدون IMG إذا احتوى السطر على وسم # HTML فأضف رسالتنا كتعليق for index,line in enumerate(lines): if ( re.search(img,line) and not re.search(alt,line) ): lines[index] += '<!-- PROVIDE ALT TAGS ON IMAGES! -->\n' # والآن اكتب الملف المعدَّل. inf.close() outf = open(filename,'w') outf.writelines(lines) outf.close() لدينا ملاحظتان على الشيفرة أعلاه نريد الإشارة إليهما، الأولى أننا استخدمنا re.search بدلًا من re.match، لأن الأولى تبحث عن الأنماط في أي مكان داخل السلسلة النصية، بينما تبحث الثانية في بداية السلسلة فقط، أما الملاحظة الثانية فهي أننا وضعنا زوجًا خارجيًا من الأقواس حول الاختبارين، وهو أمر غير ضروري لكنه يسمح لنا بتقسيم الاختبار إلى سطرين، مما يجعله أسهل في القراءة خاصةً إذا كنا سندمج تعبيرات كثيرةً. وهذه الشيفرة ليست مثاليةً لأنها لا تأخذ في الحسبان الحالة التي يكون وسم img فيها مقسمًا على عدة أسطر، لكنها تكفي لشرح التقنية عمومًا، على أنه يُفضل تجنب هذا "التخريب" الذي قمنا به في ملف HTML، لكن الذي ينسى وسوم alt يستحق جزاءه. نأتي لأمر أخير، وهو حدود كفاءة التعابير النمطية، فلهياكل البيانات المعرَّفة بوضوح -مثل HTML- أدوات أخرى غير التعابير النمطية، تُعرف باسم المحلِّلات تكون أكثر كفاءةً وأسهل في الاستخدام دون أخطاء، وسنستخدم محلل HTML في جزئية لاحقة من هذه السلسلة، وتتجلى فائدة التعابير النمطية في عمليات البحث المعقدة في النصوص الحرة إذ تحل لنا مشاكل كثيرة، مع التأكيد مرةً أخرى على الاختبار المفصل لها، كما لا يجب استخدامها إلا عند الحاجة الضرورية إليها، أما إذا كنا نريد البحث عن سلسلة بسيطة فنستخدم التابع find، لتجنب مشاكل التعابير النمطية. وسنعود مرةً أخرى إلى التعابير النمطية في دراسة الحالة لعدّاد القواعد النحوية، لذا جرب استخدامها حتى ذلك الحين، وتفقد التوابع الأخرى الموجودة في وحدة re، فلم نشرح حتى الآن إلا قشور هذه الأدوات بالغة القوة في معالجة النصوص. التعابير النمطية في جافاسكربت تدعم جافاسكربت التعابير النمطية ضمنيًا وبقوة، بل إن عمليات البحث في السلاسل النصية التي استخدمناها من قبل ما هي إلا بحوث تعابير نمطية، فقد استخدمنا أبسط صورة لها "تسلسل بسيط من المحارف"، وتنطبق جميع القواعد التي ذكرناها في بايثون على جافاسكربت، عدا أن التعابير النمطية هنا تكون محاطةً بشرطة مائلة / بدلًا من علامات الاقتباس: <script type="text/javascript"> var str = "A lovely bunch of bananas"; document.write(str + "<BR>"); if (str.match(/^A/)) { document.write("Found string beginning with A<BR>"); } if (str.match(/b[au]/)) { document.write("Found substring with either ba or bu<BR>"); } if (!str.match(/zzz/)) { document.write("Didn't find substring zzz!<BR>"); } </script> ينجح التعبيران الأولان ويفشل الثالث، لذا حصلنا على الاختبار السلبي، لاحظ علامة التعجب في البداية. التعابير النمطية في VBScript لا تحتوي VBScript على دعم مضمّن للتعابير النمطية كما في جافاسكربت، لكن فيها كائن تعبير نمطي يمكن بدؤه واستخدامه للبحث وعمليات الاستبدال وغيرها، كما يمكن التحكم فيه لتجاهل حالة الأحرف وللبحث في جميع النسخ أو نسخة واحدة فقط: <script type="text/vbscript"> Dim regex, matches Set regex = New RegExp regex.Global = True regex.Pattern = "b[au]" Set matches = regex.Execute("A lovely bunch of bananas") If matches.Count > 0 Then MsgBox "Found " & matches.Count & " substrings" End If </script> نكون بهذا قد وصلنا إلى نهاية هذا المقال مكتفين بما ذكرناه فيه، لكن نعيد التأكيد على أن التعابير النمطية غنية بالتعقيدات الدقيقة التي لا يمكن تغطيتها في هذا المقال القصير، ويمكن الرجوع إلى المصادر الموجودة في الويب لمزيد من المعلومات عن استخدامها، إضافةً إلى كتاب أورايلي الذي أوردناه في بداية المقال. خاتمة بنهاية هذا المقال نود أن تكون قد تعلمت: التعابير النمطية هي أنماط نصية تستطيع تطوير قوة وكفاءة عمليات البحث النصية. يصعب التحكم بالتعابير النمطية، وقد تتسبب في زلات برمجية غريبة، لذا يجب التعامل معها بحرص. التعابير النمطية ليست الحل السهل لكل مشكلة، بل قد يكون الحل في منظور أكثر تعقيدًا، فإذا لم ينجح استخدام التعابير النمطية في حل مشكلة لثلاث محاولات متتالية، فيجب البحث عن حل آخر. ترجمة -بتصرف- للفصل السادس عشر: Regular Expressions من كتاب Learn To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: البرمجة كائنية التوجه المقال السابق: فضاءات الأسماء في البرمجة ما هي البرمجة ومتطلبات تعلمها؟ تعلم البرمجة التعابير النمطية (regexp/PCRE) في PHP
  14. فضاء الاسم Namespace هو المساحة أو المنطقة داخل البرنامج التي يكون الاسم صالحًا فيها، سواءً كان ذلك الاسم متغيرًا، أو دالةً، أو صنفًا، أو غير ذلك. يُستخدم هذا المبدأ في الحياة العملية كل يوم، فلو كنت تعمل في شركة كبيرة مثلًا ولك زميل اسمه عماد، ويوجد عماد آخر في قسم المحاسبة، فإنك تشير إلى عماد زميلك في القسم باسمه المجرد "عماد"؛ أما عماد الآخر فستقول "عماد الذي في قسم المحاسبة"، أي أنك تستخدم أسماء أقسام الشركة معرِّفات للعاملين فيها، وهذا هو مبدأ فضاء الاسم في البرمجة، فهو يخبر المبرمج والمترجم بالاسم المقصود فيما لو وجد أكثر من اسم. لقد ظهرت فضاءات الأسماء لأن لغات البرمجة القديمة -مثل BASIC- لم تكن فيها إلا متغيرات عامة Global Variables، أي متغيرات يمكن رؤيتها في البرنامج كله وداخل الدوال أيضًا، لكن هذا جعل متابعة أداء البرامج الكبيرة وصيانتها صعبًا، لأن التعديل على متغير في جزء ما من البرنامج سيتسبب في تغير وظيفة جزء آخر دون أن يدرك المبرمج ذلك، وهو ما يسمى بالآثار الجانبية، وقد قدمت اللغات التالية -بما فيها النسخ الأحدث من BASIC- مبدأ فضاء الأسماء إلى البرمجة، بل إن لغةً مثل C++‎ تسمح للمبرمج بإنشاء فضاء اسم خاص به في أي مكان داخل البرنامج، وهذا مفيد لمنشئي المكتبات الذين يرغبون في الاحتفاظ بأسماء دوالهم فريدةً عند اختلاطها مع مكتبات أخرى. يشار أحيانًا إلى فضاء الاسم بالنطاق Scope، ونطاق الاسم هو امتداد البرنامج الذي يمكن استخدام الاسم فيه، كأن يكون داخل دالة أو وحدة module، وفضاء الاسم والنطاق هما وجهان لعملة واحدة باستثناء فروق طفيفة بين المصطلحين، ولن يناقش فيها إلا عالم حاسوب يحب الجدل؛ أما بالنسبة لمستوى الشرح الذي نريده فهما متطابقان. سيشرح هذا المقال: مفهوم فضاء الاسم أو النطاق وأهميته. كيفية عمل فضاء الاسم في بايثون. مفهوم فضاء الاسم في لغتي جافاسكربت وVBScript. فضاء الاسم في بايثون تنشئ كل وحدة في بايثون فضاء الاسم الخاص بها، وللوصول إلى تلك الأسماء لا بد أن نسبقها باسم الوحدة، أو أن نستورد الأسماء التي نريد استخدامها إلى فضاء الاسم الخاص بالوحدة، وقد كنا نفعل هذا مع وحدتي sys وtime في المقالات السابقة، كما أن تعريف الصنف Class ينشئ فضاء اسم خاص به. وبناءً عليه، فإذا أردنا الوصول إلى تابع أو خاصية في صنف ما، فسنحتاج إلى استخدام اسم متغير النسخة أو اسم الصنف أولًا، وسنتحدث عن هذا بالتفصيل في مقال لاحق. تتيح بايثون خمسة فضاءات أسماء -أو نطاقات- هي: النطاق المضمَّن: الأسماء المعرفة داخل بايثون نفسها، وهي متاحة دائمًا من أي مكان في البرنامج. نطاق الوحدة: وهي أسماء معرَّفة ومرئية داخل ملف أو وحدة، لكن هذا النطاق يشار إليه في بايثون باسم النطاق العام global scope، في حين أن المعنى الذي يتبادر للذهن عند سماع الاسم هو أن النطاق العام يمكن رؤيته في أي جزء من البرنامج. النطاق المحلي: وهي الأسماء المعرَّفة داخل دالة أو تابع صنف، بما في ذلك المعامِلات. نطاق الصنف: الأسماء المعرفة داخل الأصناف، وسننظر فيها في مقال لاحق. النطاق المتشعب nested Scope: هذا موضوع معقد قليلًا تستطيع تجاهله حاليًا. لننظر الآن في الشيفرة التالية التي تحتوي على أمثلة لأول ثلاثة نطاقات: def square(x): return x*x data = int(input('Type a number to be squared: ')) print( data, 'squared is: ', square(data) ) يسرد الجدول التالي كلًا من الاسم والنطاق الذي ينتمي إليه: table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } الاسم فضاء الاسم square الوحدة-عام x محلي للنطاق square data الوحدة-عام int مضمَّن input مضمَّن print مضمَّن لاحظ أننا لا نَعُد def وreturn من الأسماء، وذلك لأنهما كلمتان مفتاحيتان أو جزء من تعريف اللغة نفسها، وسنحصل على خطأ إذا استخدمنا كلمةً مفتاحيةً اسمًا لمتغير. لنطرح سؤالًا جديدًا الآن، ماذا يحدث عندما يكون للمتغيرات التي في فضاءات أسماء مختلفة نفس الاسم؟ وماذا يحدث عند الإشارة إلى اسم ليس موجودًا في فضاء الاسم الحالي؟ الوصول إلى الأسماء التي خارج النطاق الحالي تحدد بايثون الأسماء حتى لو لم تكن في فضاء الاسم الحالي بالنظر إلى: فضاء الاسم المحلي -الدالة الحالية- أو الدالة المغلِّفة أو الصنف المغلِّف إذا كانت دالةً متشعبةً أو تابعًا متشعبًا. نطاق الوحدة، أي الملف الحالي. النطاق المضمَّن. فإذا كان الاسم في وحدة أخرى، فسنستورد الوحدة باستخدام import كما رأينا في المقالات السابقة، وعند استيرادها سيكون اسم الوحدة مرئيًا في نطاق تلك الوحدة، وسنستطيع حينئذ أن نستخدم اسم الوحدة للوصول إلى أسماء المتغيرات فيها باستخدام نمط module.name المعتاد، ويتبين من هذا أن استيراد جميع الأسماء من وحدة إلى الملف الحالي ليس صحيحًا، إذ قد يتطابق اسم وحدة ما مع اسم أحد متغيراتنا، مما سيتسبب في سلوك غريب للبرنامج لأن أحد الاسمين سيغطي على الآخر. لنعرِّف وحدتين تستورد الثانية فيهما الأولى على سبيل المثال: ##### module first.py ######### spam = 42 def print42(): print( spam ) ############################### ##### module second.py ######## from first import * # استورد جميع الأسماء من الأولى spam = 101 # لتخفي النسخة الأولى spam أنشئ متغير print42() # ما الذي سيُطبع، هل 42 أم 101 ################################ إذا ظننت أن هذا المثال سيطبع 101 فستكون مخطئًا، لأنه سيطبع 42 بسبب تعريف المتغير في بايثون، فكما شرحنا -في مقال: البيانات وأنواعها-؛ الاسم هو عنوان يُستخدم للإشارة إلى كائن، وقد كان الاسم print42 في الوحدة الأولى يشير إلى كائن الدالة المعرَّف في الوحدة، (وسننظر في ذلك بالتفصيل حين نشرح تعبير لامدا في مقال لاحق)، ومع أننا استوردنا الاسم إلى وحدتنا، إلا أننا لم نستورد الدالة التي لا تزال تشير إلى نسخة الوحدة الخاصة بها من spam، وعلى ذلك فقد أنشأنا متغير spam جديد ليس لديه تأثير على الدالة المشار إليها بالاسم print42. يشرح المخطط التالي هذا لكلا النوعين من الاستيراد، ويمكنك ملاحظة كيف يكرر الاستيراد الثاني الاسم print42 من first.py إلى second.py: ينبغي أن يكون هذا اللبس قد شرح سهولة الوصول إلى الأسماء في الوحدات المستوردة باستخدام ترميز النقطة dot notation. رغم أننا سنكتب شيفرةً أكثر، لكن توجد وحدات قليلة -مثل Tkinter الذي سنتعرض لها لاحقًا-، تُستخدم لاستيراد جميع الأسماء، لكنها تُكتب بطريقة تقلل خطر تعارض الأسماء رغم وجود الخطورة على أي حال، كما قد تنشأ عنها زلات برمجية bugs يصعب العثور عليها، وعلى أي حال توجد طريقة أكثر أمانًا لاستيراد اسم وحيد من وحدة ما، كما يلي: from sys import exit وهنا نأتي بدالة exit فقط إلى فضاء الاسم المحلي، لكن لا نستطيع استخدام أسماء أخرى من sys، ولا حتى sys نفسها. تجنب تعارض الأسماء إذا كانت الدالة تشير إلى متغير اسمه X، مع وجود X آخر في الدالة أي في النطاق المحلي، فسيكون هذا الأخير هو الذي ستراه بايثون وتستخدمه، وهنا يقع على عاتق المبرمج تجنب تعارض الأسماء، بحيث إذا كان لدينا متغيران أحدهما محلي والآخر متغير وحدة لهما الاسم نفسه، فلا نطلبهما في نفس الدالة، إذ سيغطي المتغير المحلى على اسم الوحدة. ولن نواجه مشكلةً إذا أردنا قراءة متغير عام داخل دالة، إذ ستبحث بايثون عن الاسم محليًا، فإذا لم تجده فستبحث في النطاق العام، بل وفي النطاق المضمَّن كذلك إذا دعت الحاجة، وإنما ستظهر المشكلة عندما نريد إسناد قيمة إلى متغير عام، إذ سينشئ هذا متغيرًا محليًا جديدًا داخل الدالة، فكيف نسند القيمة إلى متغير عام دون إنشاء متغير محلي بنفس الاسم إذًا؟ يمكن تنفيذ هذا باستخدام الكلمة المفتاحية global: var = 42 def modGlobal(): global var # محلي var تمنع إنشاء var = var - 21 def modLocal(): var = 101 print( var ) # تطبع 42 modGlobal() print( var ) # تطبع 21 modLocal() print( var ) # تطبع 21 نرى هنا كيف غيرت الدالة modGlobal من المتغير العام، على عكس الدالة modLocal التي لم تغيره، لأنها تنشئ متغيرها الداخلي الخاص بها وتسند قيمةً إليه، ثم يُجمع هذا المتغير في نهاية الدالة ليُحذف، ويكون غير مرئي في مستوى الوحدة، لكن عمومًا ينبغي أن نقلل من استخدام تعليمات global، فمن الأفضل أن نمرر المتغير معامِلًا للدالة، ثم نعيد المتغير المعدَّل. يعيد المثال التالي كتابة دالة modGlobal دون استخدام تعليمة global: var = 42 def modGlobal(aVariable): return aVariable - 21 print( var ) var = modGlobal(var) print( var ) في هذه الحالة نسند القيمة التي أعادتها الدالة إلى المتغير الأصلي في نفس الوقت الذي نمررها فيه وسيطًا، وتكون النتيجة نفسها، لكن لا تعتمد الدالة الآن على أي شيفرة خارجها، مما يسهل إعادة استخدامها في برامج أخرى، كما يسهل رؤية كيف تتغير القيمة العامة global value، حيث نستطيع أن نرى حدوث الإسناد الصريح هنا، ويطبق المثال التالي كل ذلك عمليًا، إذ يوضح النقاط التي شرحناها إلى الآن، لهذا ادرسه جيدًا حتى تعرف استخدام الأسماء والقيم في كل خطوة فيه: # متغيرات نطاقها الوحدة W = 5 Y = 3 # المعامِلات تشبه متغيرات الدوال # نطاق محلي X وعليه يكون لـ def spam(X): # أخبر الدالة أن تنظر في مستوى الوحدة # خاصة بها w وألا تنشئ وحدة global W Z = X*2 # الذي له نطاق محلي Z أنشأنا متغير W = X+5 # كما شرحنا أعلاه w استخدم الوحدة if Z > W: # هو اسم نطاق مضمَّن pow print( pow(Z,W) ) return Z else: return Y # محلي Y لا يوجد # لذا نستخدم نسخة الوحدة print("W,Y = ", W, Y ) for n in [2,4,6]: print( "Spam(%d) returned: " % n, spam(n) ) print( "W,Y = ", W, Y ) فضاء الاسم في VBScript إذا صرحنا عن متغير خارج الدالة أو البرنامج الفرعي في VBScript، فسيكون عامًا globally أما إذا صرحنا عنه داخل الدالة أو البرنامج الفرعي، فسيكون محليًا في الوحدة وسيخفي أي متغير عام له نفس الاسم، كما سيكون المبرمج هنا هو المسؤول عن إدارة التعارض بين هذه الأسماء، وبما أن متغيرات VBScript تُنشأ باستخدام تعليمة Dim فلن يحدث غموض أو لبس حول المتغير المقصود على عكس بايثون، وبهذا نرى أن منظور VBScript لقواعد النطاقات أبسط وأوضح من بايثون، لكن هناك بعض الأمور الخاصة بصفحات الويب، حيث ستكون المتغيرات العامة مرئيةً في كامل الملف، وليس داخل حدود الوسم script الذي عُرَّفت فيه فقط، وتوضح الشيفرة التالية ذلك: <script type="text/vbscript"> Dim aVariable Dim another aVariable = "This is global in scope" another = "A Global can be visible from a function" </script> <script type="text/vbscript"> Sub aSubroutine Dim aVariable aVariable = "Defined within a subroutine" MsgBox aVariable MsgBox another ' uses global name End Sub </script> <script type="text/vbscript"> MsgBox aVariable aSubroutine MsgBox aVariable </script> توجد بعض مزايا النطاقات في VBScript، والتي نتيح بها إمكانية الوصول إلى المتغيرات بين الملفات المختلفة في صفحة ويب -من الفهرس مثلًا إلى المحتوى والعكس-، لكن لن نتحدث عن هذا المستوى من برمجة صفحات الويب هنا، لذا سنكتفي بالإشارة إلى وجود كلمات مفتاحية مثل Public وPrivate. فضاء الاسم في جافاسكربت تتبع جافاسكربت نفس القواعد تقريبًا، إذ تكون المتغيرات المصرح عنها داخل الدوال مرئيةً داخل تلك الدوال فقط؛ أما المتغيرات التي خارج الدوال فيمكن أن تراها الشيفرة التي خارج الدوال، إضافةً إلى إمكانية رؤيتها داخل الدوال أيضًا. وكما هو الحال في VBScript؛ ليس هناك تعارض أو غموض بشأن المتغير المقصود، لأن المتغيرات تُنشأ صراحةً باستخدام تعليمة var، والمثال التالي شبيه بمثال VBScript السابق لكنه مكتوب بلغة جافاسكربت: <script type="text/javascript"> var aVariable, another; // متغيرات عامة aVariable = "This is Global in scope<BR>"; another = "A global variable can be seen inside a function<BR>"; function aSubroutine(){ var aVariable; // متغير محلي aVariable = "Defined within a function<BR>"; document.write(aVariable); document.write(another); // يستخدم متغيرًا عامًا } document.write(aVariable); aSubroutine(); document.write(aVariable); </script> ولا أظننا بحاجة إلى تكرار شرح المثال مرةً أخرى هنا. خاتمة نرجو في نهاية هذا المثال أن تكون تعلمت ما يلي: النطاقات وفضاءات الأسماء وجهان لعملة واحدة، ويشيران إلى نفس الشيء. المفاهيم واحدة بين اللغات المختلفة، لكن الذي يختلف هو التطبيق الدقيق لها وفق قواعد كل لغة. تحتوي بايثون على خمسة نطاقات هي: النطاق المضمَّن built in، ونطاق الصنف class، والنطاق المتشعب nested، ونطاق الملف أو النطاق العام global، ونطاق الدالة أو النطاق المحلي function، وهذه النطاقات الثلاثة الأخيرة هي أهم نطاقات فيها من حيث كثرة الاستخدام في البرمجة. تحتوي كل من جافاسكربت وVBScript على نطاقين لكل واحدة منهما، هما نطاق الملف أو النطاق العام file، ونطاق الدالة أو النطاق المحلي function. ترجمة -بتصرف- للفصل الخامس عشر: Namespaces من كتاب Learn To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: التعابير النمطية في البرمجة المقال السابق: كيفية التعامل مع الأخطاء البرمجية فضاء الأسماء (namespaces) في PHP
  15. يُعَدّ بروتوكول نقل النصوص الفائقة Hypertext Transfer Protocol الذي ذكرناه في مقال علاقة جافاسكريبت بتطور الإنترنت والمتصفحات آليةً تُطلب البيانات وتوفَّر من خلالها على الشبكة العالمية، كما سننظر فيه بالتفصيل ونشرح الطريقة التي تستخدِمه بها جافاسكربت المتصفحات. البروتوكول إذا كتبت eloquentjavascript.net/18_http.html في شريط العنوان لمتصفحك، فسيبحث المتصفح أولًا عن عنوان الخادم المرتبط بـ eloquentjavascript.net ويحاول فتح اتصال TCP معه على المنفَذ 80 الذي هو المنفَذ الافتراضي لحركة مرور HTTP، فإذا كان الخادم موجودًا ويقبل الاتصال فقد يرسل المتصفح شيئًا مثل هذا: GET /18_http.html HTTP/1.1 Host: eloquentjavascript.net User-Agent: Your browser's name ثم يستجيب الخادم من خلال نفس قناة الاتصال: HTTP/1.1 200 OK Content-Length: 65585 Content-Type: text/html Last-Modified: Mon, 08 Jan 2018 10:29:45 GMT <!doctype html> ... the rest of the document تكون أول كلمة هي التابع الخاص بالطلب، إذ تعني GET أننا نريد الحصول على مصدر بعينه، كما هناك توابع أخرى مثل DELETE لحذف المصدر وPUT لإنشائه أو استبداله وPOST لإرسال معلومات إليه. لاحظ أنّ الخادم ليس عليه تنفيذ جميع الطلبات التي تأتيه، فإذا ذهبتَ إلى موقع ما وطلبت حذف صفحته الرئيسية فسيرفض الخادم؛ أما الجزء الذي يلي اسم التابع، فيكون مسار المورد الذي يُطبق الطلب عليه، حيث يكون ملفًا على الخادم في أبسط حالاته، لكن البروتوكول لا يشترط كونه ملفًا فقط، بل قد يكون أي شيء يمكن نقله كما لو كان ملفًا، كما تولِّد العديد من الخوادم الاستجابات التي تنتجها لحظيًا، فإذا فتحت https://github.com/marijnh مثلًا، فسيبحث الخادم في قاعدة بياناته عن مستخدِم باسم marijnh، فإذا وجده فسيولِّد صفحة مستخدِم له. يذكر أول سطر في الطلب بعد مسار المورد الـ HTTP/1.1 للإشارة إلى نسخة بروتوكول HTTP الذي يستخدِمه، كما تستخدِم مواقع كثيرة النسخة الثانية من HTTP عمليًا، إذ تدعم المفاهيم نفسها التي تدعمها النسخة الأولى 1.1، لكنها أعقد منها لتكون أسرع، كما ستبدِّل المتصفحات إلى البروتوكول المناسب تلقائيًا أثناء التحدث مع الخادم المعطى، وسيكون خرج الطلب هو نفسه بغض النظر عن النسخة المستخدَمة، لكننا سنركز على النسخة 1.1 بما أنها أبسط وأسهل في التعديل عليها. ستبدأ استجابة الخادم بالنسخة أيضًا تليها بحالة الاستجابة مثل شيفرة حالة من ثلاثة أرقام أولًا، ثم مثل سلسلة نصية مقروءة من قِبَل المستخدِم. HTTP/1.1 200 OK تبدأ رموز الحالة بـ 2 لتوضح نجاح الطلب؛ أما الطلبات التي تبدأ بـ 4 فتعني أنّ ثمة شيء خطأ في الطلب، ولعل أشهر رمز حالة HTTP هنا هي 404، والتي تعني أن المصدر غير موجود أو لا يمكن العثور عليه؛ أما الرموز التي تبدأ بالرقم 5، فتعني حدوث خطأ على الخادم ولا تتعلق المشكلة بالطلب نفسه، وقد يُتبع أول سطر من الطلب أو الاستجابة بعدد من الترويسات، وهي أسطر في صورة name: value توضِّح معلومات إضافية عن الطلب أو الاستجابة، وهي جزء من المثال الذي يوضح الاستجابة: Content-Length: 65585 Content-Type: text/html Last-Modified: Thu, 04 Jan 2018 14:05:30 GMT يخبرنا هذا بحجم مستند الاستجابة ونوعه، وهو مستند HTML في هذه الحالة حجمه 65585 بايت، كما يخبرنا متى كانت آخر مرة عُدِّل فيها. يملك كل من العميل والخادم في أغلب الترويسات حرية إدراجها في الطلب أو الاستجابة، لكن بعض الترويسات يكون إدراجها إلزاميًا مثل ترويسة HOST التي تحدد اسم المضيف hostname، إذ يجب إدراجها في الطلب لأن الخادم قد يخدِّم عدة أسماء مضيفين على عنوان IP واحد، فبدون الترويسة لن يعرف أيّ واحد فيها يقصده العميل الذي يحاول التواصل معه، وقد تدرِج الطلبات أو الاستجابات سطرًا فارغًا بعد اسم المضيف متبوعًا بمتن body يحتوي على البيانات المرسلة، كما لا ترسل طلبات GET وDELETE أيّ بيانات، على عكس طلبات PUT وPOST، وبالمثل فقد لا تحتاج بعض أنواع الاستجابات إلى متن مثل استجابات الخطأ error responses. المتصفحات وHTTP رأينا في المثال السابق أنّ المتصفح سينشئ الطلب حين نكتب الرابط URL في شريط عنوانه، فإذا أشارت صفحة HTML الناتجة إلى ملفات أخرى مثل صور أو ملفات جافاسكربت، فسيجلب المتصفح هذه الملفات أيضًا، ومن المعتاد للمواقع متوسطة التعقيد إدراج من 10 إلى 200 مصدر مع الصفحة، كما سترسل المتصفحات عدة طلبات GET في الوقت نفسه بدلًا من انتظار الاستجابة الواحدة، ثم إرسال طلب آخر من أجل تحميل الصفحة بسرعة، وقد تحتوي صفحات HTML على استمارات forms تسمح للمستخدِم بملء بيانات وإرسالها إلى الخادم، وفيما يلي مثال عن استمارة: <form method="GET" action="example/message.html"> <p>Name: <input type="text" name="name"></p> <p>Message:<br><textarea name="message"></textarea></p> <p><button type="submit">Send</button></p> </form> تصف الشيفرة السابقة استمارةً لها حقلين أحدهما صغير يطلب الاسم والآخر أكبر لكتابة رسالة فيه، وتُرسَل الاستمارة عند الضغط على زر إرسال Send، أي يحزَّم محتوى حقلها في طلب HTTP وينتقل المتصفح إلى نتيجة ذلك الطلب. تضاف المعلومات التي في الاستمارة إلى نهاية رابط action على أساس سلسلة استعلام نصية إذا كانت سمة العنصر method الخاص بالاستمارة <form> هي GET -أو إذا أُهملت-، وقد ينشئ المتصفح طلبًا إلى هذا الرابط: GET /example/message.html?name=Jean&message=Yes%3F HTTP/1.1 تحدِّد علامة الاستفهام نهاية جزء المسار من الرابط وبداية الاستعلام، كما تُتبع بأزواج من الأسماء والقيم تتوافق مع سمة name في عناصر حقول الاستمارة ومحتوى تلك العناصر على الترتيب، ويُستخدَم محرف الإضافة ampersand أي & لفصل تلك الأزواج، في حين تكون الرسالة الفعلية المرمَّزة في الرابط هي "Yes?‎"، لكن ستُستبدَل شيفرة غريبة بعلامة الاستفهام، كما يجب تهريب بعض المحارف في سلاسل الاستعلامات النصية، فعلامة الاستفهام الممثلة بـ ‎%3F هي أحد تلك المحارف، وسنجد أن هناك شبه قاعدة غير مكتوبة تقول أنّ كل صيغة تحتاج إلى طريقتها الخاصة في تهريب المحارف، وهذه التي بين أيدينا تُسمى ترميز الروابط التشعبية URL encoding، حيث تستخدِم علامة النسبة المئوية ويليها رقمين ست-عشريين يرمِّزان شيفرة المحرف، وفي حالتنا تكون 3F -التي هي 63 في النظام العشري- شيفرة محرف علامة الاستفهام، وتوفِّر جافاسكربت الدالتين encodeURIComponent وdecodeURIComponent من أجل ترميز تلك الصيغة وفك ترميزها أيضًا. console.log(encodeURIComponent("Yes?")); // → Yes%3F console.log(decodeURIComponent("Yes%3F")); // → Yes? إذا غيرنا السمة method لاستمارة HTML في المثال الذي رأيناه إلى POST، فسيستخدِم طلب HTTP الذي أنشئ لإرسال الاستمارة التابع POST ويضع سلسلة الاستعلام النصية في متن الطلب بدلًا من إضافتها إلى الرابط. POST /example/message.html HTTP/1.1 Content-length: 24 Content-type: application/x-www-form-urlencoded name=Jean&message=Yes%3F يجب استخدام طلبات GET للطلبات التي تطلب معلومات فقط وليس لها تأثيرات جانبية؛ أما الطلبات التي تغيِّر شيئًا في الخادم مثل إنشاء حساب جديد أو نشر رسالة، فيجب التعبير عنها بتوابع أخرى مثل POST، كما تدرك برامج العميل مثل المتصفحات أنها يجب ألا تنشئ طلبات POST عشوائيًا وإنما تنشئ طلبات GET أولًا ضمنيًا لجلب المصدر التي تظن أنّ المستخدِم سيحتاجه قريبًا، كما سنعود إلى كيفية التفاعل مع الاستمارات من جافاسكربت لاحقًا في هذا المقال. واجهة Fetch تُسمى الواجهة التي تستطيع جافاسكربت الخاصة بالمتصفح إنشاء طلبات HTTP من خلالها باسم fetch، وبما أنها جديدة نسبيًا فستستخدم الوعود promises وهو الأمر النادر بالنسبة لواجهات المتصفحات. fetch("example/data.txt").then(response => { console.log(response.status); // → 200 console.log(response.headers.get("Content-Type")); // → text/plain }); يعيد استدعاء fetch وعدًا يُحل إلى كائن Response حاملًا معلومات عن استجابة الخادم مثل شيفرة حالته وترويساته، وتغلَّف الترويسات في كائن شبيه بالخارطة ‎Map-like الذي يهمل حالة الأحرف في مفاتيحه -أي أسماء الترويسات- لأنه لا يفترض أن تكون أسماء الترويسات حساسةً لحالة الأحرف. هذا يعني أن كلا من الآتي: ‎headers.get("Content-Type")‎0. ‎headers.get("content-TYPE")‎. سيُعيدان القيمة نفسها. لاحظ أن الوعد الذي تعيده fetch يُحل بنجاح حتى لو استجاب الخادم برمز خطأ، وقد يُرفض إذا كان ثمة خطأ في الشبكة أو لم يوجد الخادم الذي أرسل إليه الطلب، كما يجب أن يكون أول وسيط لواجهة fetch هو الرابط الذي يراد طلبه، فإذا لم يبدأ الرابط باسم البروتوكول - http‎:‎ مثلًا- فسيعامَل نسبيًا، أي يفسَّر وفق المستند الحالي، وإذا بدأ بشرطة مائلة / فسيستبدل المسار الحالي الذي يكون جزء المسار الذي يلي اسم الخادم، أما إذا لم يبدأ بالشرطة المائلة، فسيوضع جزء المسار الحالي إلى آخر محرف شرطة مائلة -مع الشرطة نفسها- أمام الرابط النسبي. يُستخدَم التابع text للحصول على المحتوى الفعلي للاستجابة، ويعيد هذا التابع وعدًا لأن الوعد الأولي يُحَل عند استقبال ترويسات الاستجابة، ولأنّ قراءة متن الاستجابة قد تستغرق وقتًا أطول. fetch("example/data.txt") .then(resp => resp.text()) .then(text => console.log(text)); // → This is the content of data.txt يعيد التابع json -وهو تابع شبيه بالسابق- وعدًا يُحل إلى القيمة التي تحصل عليها حين تحلل المتن مثل JSON أو يُرفض إذا لم يكن JSON صالحًا، كما تستخدِم واجهة fetch التابع GET افتراضيًا لإنشاء طلبها ولا تدرِج متن الطلب، كما يمكنك إعدادها لغير ذلك بتمرير كائن له خيارات إضافية على أساس وسيط ثاني، فهذا الطلب مثلًا يحاول حذف example/data.txt: fetch("example/data.txt", {method: "DELETE"}).then(resp => { console.log(resp.status); // → 405 }); يعني رمز الحالة 405 أنّ "الطلب غير مسموح به" وهو أسلوب خادم HTTP ليقول "لا أستطيع فعل هذا"، كما يمكن إضافة الخيار body لإضافة متن الطلب، كما يُستخدَم الخيار headers لضبط الترويسات، فهذا الطلب مثلًا يضمِّن الترويسة Range التي تخبر الخادم بإعادة جزء من الاستجابة فقط. fetch("example/data.txt", {headers: {Range: "bytes=8-19"}}) .then(resp => resp.text()) .then(console.log); // → المحتوى سيضيف المتصفح بعض ترويسات الطلب تلقائيًا مثل Host وتلك المطلوبة كي يعرف الخادم حجم المتن، لكن ستكون إضافة ترويساتك الخاصة مفيدةً إذا أردنا إضافة أشياء مثل معلومات التوثيق، أو لنخبر الخادم بصيغة الملف التي نريد استقبالها. صندوق اختبارات HTTP لا شك أنّ إنشاء طلبات HTTP في سكربتات صفحة الويب سيرفع علامات استفهام حول الأمان، فالشخص الذي يتحكم بالسكربت قد لا تكون لديه دوافع الشخص نفسها التي يشغلها على حاسوبه، فإذا زرنا الموقع themafia.org مثلًا، فلا نريد لسكربتاته أن تكون قادرةً على إنشاء طلب إلى mybank.com باستخدام معلومات التعريف من متصفحنا مع تعليمات بتحويل جميع أموالنا إلى حساب عشوائي، لهذا تحمينا المتصفحات من خلال عدم السماح للسكربتات بإنشاء طلبات HTTP إلى نطاقات أخرى (أسماء نطاقات مثل themafia.org وmybank.com)، وتُعَدّ هذه مشكلةً مؤرقةً عند بناء أنظمة تريد الوصول إلى عدة نطاقات من أجل أسباب مشروعة ومنطقية، ولحسن الحظ تستطيع الخوادم إدراج ترويسة لهذا الغرض في استجابتها لإخبار المتصفح صراحةً أن هذا الطلب يمكن أن يأتي من نطاق آخر: Access-Control-Allow-Origin: * تقدير HTTP هناك عدة طرق مختلفة لنمذجة التواصل بين برامج جافاسكربت العاملة في المتصفح -جانب العميل- والبرنامج الذي على الخادم -أي جانب الخادم-، وإحدى أكثر تلك الطرق استخدامًا هي استدعاءات الإجراءات البعيدة remote procedure calls، إذ يتبع التواصل في هذا النموذج أنماط استدعاءات الدوال العادية عدا أنّ الدالة تعمل فعليًا على حاسوب آخر، حيث يتطلب استدعاؤها إنشاء طلب إلى الخادم الذي يتضمن اسم الدالة والوسائط، كما تحتوي استجابة ذلك الطلب على القيمة المعادة. عند التفكير في شأن استدعاءات الإجراء البعيد، لا يكون HTTP أكثر من أنه وسيلة تواصل، وستكتب على الأرجح طبقةً مجردةً تخفيه كليًا، كما يوجد هناك منظور آخر نبني فيه التواصل حول مفهوم الموارد وتوابع HTTP، فبدلًا من addUser المستدعى استدعاءًا بعيدًا، فإننا سنستخدم طلب PUT إلى ‎/users/larry، وبدلًا من ترميز خصائص ذلك المستخدِم في وسائط دالة، فإنك تعرِّف صيغة مستند JSON من أجل تمثيل المستخدِم أو تستخدم صيغةً موجودةً من قبل لذلك. كما يُجلَب المورد بإنشاء طلب GET إلى رابط المورد -‎/users/larry مثلًا- والذي يُعيد المستند الممثل للمورد، إذ يسهل هذا المنظور استخدام بعض المزايا التي يوفرها HTTP مثل دعم تخزين الموارد -أي إنشاء نسخة مؤقتة عند العميل من أجل تسريع الوصول-، كما توفر المفاهيم المستخدَمة في HTTP مجموعة مبادئ مفيدة في تصميم واجهة الخوادم الخاصة بك بما أنها جيدة التصميم. الأمان وHTTP تمر البيانات المتنقلة عبر الانترنت في طرق محفوفة بالمخاطر، إذ تمر خلال أي طريق نتواجد فيه سواء كان نقطة اتصال في مقهى أو شبكات تتحكم بها شركات أو دول بغية الحصول على وجهتها، وقد تُعتَرض وتفتَّش في أي نقطة في طريقها وقد تُعدَّل أيضًا، وهنا لا يكفي بروتوكول HTTP العادي بما أنّ بعض البيانات سرية مثل كلمات مرور بريدك أو يجب عليها الوصول إلى وجهتها دون تعديل مثل رقم الحساب الذي تحوِّل المال إليه من خلال موقع البنك الذي تتعامل معه. نستخدِم هنا بروتوكولًا أحدث هو HTTPS الذي نجده في الروابط التي تبدأ بـ https://‎، إذ يغلِّف حركة مرور HTTP بطريقة تصعب قراءتها والتعديل عليها، ويؤكد الطرف العميل قبل إرسال البيانات أنّ الخادم الذي يطلبها هو نفسه وليس منتحلًا له من خلال التأكد من شهادة مشفرة مصدَرة من جهة توثيق يعتمدها المتصفح، ثم تشفَّر جميع البيانات بطريقة تمنع استراق النظر إليها أو التعديل عليها، وعليه يمنع HTTPS أيّ جهة خارجية من انتحال الموقع الذي تريد التواصل معه ومن اختلاس النظر أو التجسس على تواصلكما، لكنه ليس مثاليًا بالطبع فقد وقعت عدة حوادث فشل فيها HTTPS بسبب شهادات مزورة أو مسروقة وبسبب برامج مخترَقة أو معطوبة، لكنه أكثر أمانًا من HTTP العادي. حقول الاستمارات صُمِّمت الاستمارات ابتداءً للويب قبل مجيء جافاسكربت من أجل السماح لمواقع الويب إرسال البيانات التي يدخلها المستخدِم في هيئة طلب HTTP، حيث يفترِض هذا التصميم أنّ التفاعل مع الخادم سيحدث دائمًا من خلال الانتقال إلى صفحة جديدة، غير أنّ عناصرها جزء من نموذج كائن مستند DOM مثل بقية الصفحة، كما تدعم عناصر DOM التي تمثِّل حقول الاستمارة عددًا من الخصائص والأحداث التي ليست موجودةً في العناصر الأخرى، حيث تمكننا من فحص حقول الإدخال تلك والتحكم فيها ببرامج جافاسكربت وأمور أخرى مثل إضافة وظيفة جديدة إلى استمارة أو استخدام استمارات وحقول على أساس وحدات بناء في تطبيق جافاسكربت. تتكون استمارة الويب من عدد من حقول الإدخال تُجمع في وسم <form>، وتسمح HTML بعدة تنسيقات من الحقول بدايةً من أزرار الاختيار checkboxes إلى القوائم المنسدلة وحقول إدخال النصوص، ولن نناقش كل أنواع الحقول في هذا الكتاب لكن سنبدأ بنظرة عامة عليها. تستخدِم أكثر أنواع الحقول وسم <input> وتُستخدَم السمة type الخاصة بهذا الوسم من أجل اختيار تنسيق الحقل، وفيما يلي أكثر أنواع <input> المستخدَمة: table { width: 100%; } thead { vertical-align: middle; text-align: center; } td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } أجل تمثيل المستخدِم أو تستخدم صيغةً موجودةً من قبل لذل text حقل نصي ذو سطر واحد password حقل نصي مثل text لكن يخفي النص الذي يُكتَب فيه checkbox مفتاح تشغيل/إغلاق radio جزء من حقل اختيار من متعدد file يسمح للمستخدِم اختيار ملف من حاسوبه يمكن وضع حقول الاستمارات في أي مكان في الصفحة ولا يشترط ظهورها في وسوم <form> وحدها، كما لا يمكن إرسال تلك الحقول التي تكون مستقلة بذاتها وخارج استمارة، فالاستمارات وحدها هي التي ترسَل، لكن على أيّ حال لا نحتاج إلى إرسال محتوى حقولنا بالطريقة التقليدية عند استخدام جافاسكربت في الاستجابة للمدخلات. <p><input type="text" value="abc"> (text)</p> <p><input type="password" value="abc"> (password)</p> <p><input type="checkbox" checked> (checkbox)</p> <p><input type="radio" value="A" name="choice"> <input type="radio" value="B" name="choice" checked> <input type="radio" value="C" name="choice"> (radio)</p> <p><input type="file"> (file)</p> تختلف واجهة جافاسكربت لمثل تلك العناصر باختلاف نوع العنصر. تمتلك الحقول النصية متعددة الأسطر وسمًا خاصًا بها هو <textarea>، وذلك بسبب غرابة استخدام سمة لتحديد قيمة ابتدائية لسطر متعدد، كما يجب إغلاق هذا الوسم بأسلوب الإغلاق المعتاد في HTML بإضافة ‎</textarea>‎، حيث يُستخدَم النص الموجود بين هذين الوسمين على أساس نص ابتدائي بدلًا من سمة value. <textarea> one two three </textarea> أخيرًا، يُستخدَم الوسم <select> لإنشاء حقل يسمح للمستخدِم بالاختيار من عدد من الخيارات المعرَّفة مسبقًا، ويُطلَق الحدث "change" كلما تغيرت قيمة حقل من حقول الاستمارة. <select> <option>Pancakes</option> <option>Pudding</option> <option>Ice cream</option> </select> التركيز Focus تستطيع حقول الاستمارات الحصول على نشاط لوحة المفاتيح عند النقر أو تفعيلها بأيّ شكل آخر على عكس أغلب عناصر مستندات HTML، بحيث تصبح هي العنصر المفعَّل الحالي ومستقبل إدخال لوحة المفاتيح، وعلى ذلك نستطيع الكتابة في الحقل النصي حين يكون مركَّزًا فقط؛ أما الحقول الأخرى فتختلف في استجابتها لأحداث لوحة المفاتيح، إذ تحاول قائمة <select> مثلًا الانتقال إلى الخيار الذي يحتوي النص الذي كتبه المستخدِم وتستجيب لمفاتيح الأسهم عبر تحريك اختيارها لأعلى وأسفل. يمكننا التحكم في التركيز باستخدام جافاسكربت من خلال التابعَين focus وblur، حيث ينقل focus التركيز إلى عنصر DOM الذي استدعي عليه؛ أما الثاني فسيزيل التركيز منه، وتتوافق القيمة التي في document.activeElement مع العنصر المركَّز حاليًا. <input type="text"> <script> document.querySelector("input").focus(); console.log(document.activeElement.tagName); // → INPUT document.querySelector("input").blur(); console.log(document.activeElement.tagName); // → BODY </script> يُتوقَّع من المستخدِم في بعض الصفحات أن يرغب في التفاعل مع أحد حقول الاستمارة فورًا، ويمكن استخدام جافاسكربت لتركيز ذلك الحقل عند تحميل المستند، لكن توفر HTML السمة autofocus أيضًاـ، والتي تعطينا التأثير نفسه وتخبر المتصفح بما نحاول فعله، وهذا يعطي المتصفح خيار تعطيل السلوك إذا كان غير مناسب كما في حالة محاولة المستخدِم تركيز حقل آخر أو عنصر آخر، وقد تعارفت المتصفحات على تمكين المستخدم من نقل التركيز خلال المستند بمجرد ضغط زر جدول أو tab على لوحة المفاتيح، ونستطيع هنا التحكم في الترتيب الذي تستقبل به العناصر ذلك التركيز باستخدام السمة tabindex، وسيجعل المثال التالي التركيز يقفز من المدخلات النصية إلى زر OK بدلًا من المرور على رابط المساعدة أولًا: <input type="text" tabindex=1> <a href=".">(help)</a> <button onclick="console.log('ok')" tabindex=2>OK</button> السلوك الافتراضي لأغلب عناصر HTML أنها لا يمكن تركيزها، غير أنك تستطيع إضافة سمة tabindex إلى أي عنصر لجعله قابلًا للتركيز؛ أما إذا جعلنا قيمتها ‎-1 فسيتم تخطي العنصر حتى لو كان قابلًا للتركيز. الحقول المعطلة يمكن تعطيل أيّ حقل من حقول الاستمارات من خلال السمة disabled الخاصة بها، وهي سمة يمكن تحديدها دون قيمة، إذ يعطِّل وجودها الحقل مباشرةً، ولا يمكن تركيز الحقول المعطَّلة أو تغييرها، كما ستظهرها المتصفحات بلون رمادي وباهت. <button>أنا مركَّز الآن</button> <button disabled>خرجت!‏</button> إذا عالج البرنامج إجراءً سببه زر أو تحكم آخر قد يحتاج إلى تواصل مع الخادم وسيستغرق وقتًا، فمن الأفضل تعطيل التحكم حتى انتهاء ذلك الإجراء، وهكذا لن يتكرر الإجراء إذا نفذ صبر المستخدِم ونقر عليه مرةً أخرى. الاستمارات على أساس عنصر كامل إذا احتوى الحقل على العنصر <form> فسيحتوي عنصر DOM الخاص به على الخاصية form التي تربطه إلى عنصر DOM الخاص بالاستمارة، ويحتوي العنصر <form> بدوره على خاصية تسمى elements تحوي تجميعةً شبيهةً بالمصفوفة من الحقول التي بداخلها. كذلك تحدِّد السمة name الموجودة في حقل الاستمارة الطريقة التي تعرَّف بها قيمتها عند إرسال الاستمارة، كما يمكن استخدامها على أساس اسم خاصية عند الوصول إلى الخاصية elements الخاصة بالاستمارة والتي تتصرف على أساس كائن شبيه بالمصفوفة -يمكن الوصول إليه بعدد-، وخريطة map -يمكن الوصول إليها باسم-. <form action="example/submit.html"> Name: <input type="text" name="name"><br> Password: <input type="password" name="password"><br> <button type="submit">Log in</button> </form> <script> let form = document.querySelector("form"); console.log(form.elements[1].type); // → password console.log(form.elements.password.type); // → password console.log(form.elements.name.form == form); // → true </script> يرسل الزر الذي فيه سمة type الخاصة بـ submit الاستمارة عند الضغط عليه، كما سنحصل على التأثير نفسه عند الضغط على زر الإدخال Enter وإذا كان حقل الاستمارة مركَّزًا، ويعني إرسال الاستمارة غالبًا أنّ المتصفح ينتقل إلى الصفحة التي تحددها سمة action الخاصة بالاستمارة مستخدِمًا أحد الطلبَين GET أو POST، لكن يُطلَق الحدث "submit" قبل حدوث ذلك، وتستطيع معالجة هذا الحدث بجافاسكربت، ونمنع ذلك السلوك الافتراضي من خلال استدعاء preventDefault على كائن الحدث. <form action="example/submit.html"> Value: <input type="text" name="value"> <button type="submit">Save</button> </form> <script> let form = document.querySelector("form"); form.addEventListener("submit", event => { console.log("Saving value", form.elements.value.value); event.preventDefault(); }); </script> يملك اعتراض أحداث "submit" في جافاسكربت فوائدً عديدةً، حيث نستطيع كتابة شيفرة للتحقق من أنّ القيم التي أدخلها المستخدِم منطقية، ونخرِج رسائل خطأ له إذا وجدنا أخطاءً في تلك القيم بدلًا من إرسال الاستمارة، أو نستطيع تعطيل الطريقة المعتادة في إرسال الاستمارة بالكامل كما في المثال، ونجعل البرنامج يعالِج المدخلات باستخدام fetch لإرسالها إلى خادم دون إعادة تحميل الصفحة. الحقول النصية تشترك الحقول التي أنشئت بواسطة الوسوم <textarea> أو <input> مع النوع text وpassword واجهةً مشتركةً، كما تحتوي عناصر DOM الخاصة بها على الخاصية value التي تحمل محتواها الحالي على أساس قيمة نصية، وإذا عيّنا تلك الخاصية إلى سلسلة نصية أخرى فسيتغيَّر محتوى الحقل. تعطينا كذلك الخصائص selectionStart وselectionEnd الخاصة بالحقول النصية معلومات عن المؤشر والجزء المحدَّد في النص، فإذا لم يكن ثمة نص محدَّد، فستحمل هاتان الخاصيتان العدد نفسه والذي يشير إلى موضع المؤشر، حيث يشير 0 مثلًا إلى بداية النص، ويشير 10 إلى أنّ المؤشر بعد المحرف العاشر؛ أما إذا حدِّد جزء من الحقل، فستختلف هاتين الخاصيتين لتعطينا بداية النص المحدَّد ونهايته، كما يمكن الكتابة فيهما مثل value تمامًا. لنفترض أنك تكتب مقالةً عن الفرعون "خع سخموي Khasekhemwy" لكن لا نستطيع تهجئة اسمه، لذا تساعدنا هنا الشيفرة التالية التي توصل وسم <textarea> بمعالج حدث يُدخل النص Khasekhemwy لك إذا ضغطت على مفتاح F2. <textarea></textarea> <script> let textarea = document.querySelector("textarea"); textarea.addEventListener("keydown", event => { // The key code for F2 happens to be 113 if (event.keyCode == 113) { replaceSelection(textarea, "Khasekhemwy"); event.preventDefault(); } }); function replaceSelection(field, word) { let from = field.selectionStart, to = field.selectionEnd; field.value = field.value.slice(0, from) + word + field.value.slice(to); // ضع المؤشر بعد الكلمة field.selectionStart = from + word.length; field.selectionEnd = from + word.length; } </script> تستبدِل الدالة replaceSelection الكلمة الصعبة المعطاة والتي نريدها بالجزء المحدَّد حاليًا من محتوى الحقل النصي، ثم تنقل المؤشر بعد تلك الكلمة كي يستطيع المستخدِم متابعة الكتابة، ولا يُطلَق الحدث "change" للحقل النصي في كل مرة يُكتب شيء ما، بل عندما يزال التركز عن أحد الحقول بعد تغيُّر محتواه فقط، ويجب علينا تسجيل معالج للحدث "input" من أجل الاستجابة الفورية للتغيرات الحادثة في الحقل النصي، حيث يُطلَق في كل مرة يكتب المستخدِم فيها محرفًا أو يحذف نصًا أو يعدِّل في محتوى الحقل، ويظهر المثال التالي حقلًا نصيًا وعدّادًا يعرض الطول الحالي للنص في الحقل: <input type="text"> length: <span id="length">0</span> <script> let text = document.querySelector("input"); let output = document.querySelector("#length"); text.addEventListener("input", () => { output.textContent = text.value.length; }); </script> أزرار الاختيار وأزرار الانتقاء يُعَدّ حقل زر الانتقاء checkbox مخيّر فهو يخير بين اختياره من المجموعة أو لا ويمكن استخراج قيمته أو تغييرها من خلال الخاصية checked الخاصة به والتي تحمل قيمةً بوليانيةً. <label> <input type="checkbox" id="purple"> Make this page purple </label> <script> let checkbox = document.querySelector("#purple"); checkbox.addEventListener("change", () => { document.body.style.background = checkbox.checked ? "mediumpurple" : ""; }); </script> يربط وسم العنوان <label> جزءًا من المستند بحقل إدخال، فإذا نقرنا في أيّ مكان على العنوان فسنفعِّل الحقل ونغير قيمته إذا كان زر اختيار أو زر انتقاء. يشبه زر انتقاء أو زر الانتقاء زر الاختيار لكنه يرتبط ضمنيًا بأزرار انتقاء أخرى لها سمة name نفسها كي يُنتقى واحد منها فقط، وقد سميت بهذا الاسم لأنها تشبه أزرار اختيار محطات الراديو في أجهزة المذياع في شكلها ووظيفتها، حيث إذا ضغطنا على أحد تلك الأزرار فإن بقية الأزرار تقفز إلى الخارج ولا تعمل سوى المحطة صاحبة الزر المنضغط. Color: <label> <input type="radio" name="color" value="orange"> Orange </label> <label> <input type="radio" name="color" value="lightgreen"> Green </label> <label> <input type="radio" name="color" value="lightblue"> Blue </label> <script> let buttons = document.querySelectorAll("[name=color]"); for (let button of Array.from(buttons)) { button.addEventListener("change", () => { document.body.style.background = button.value; }); } </script> تُستخدَم الأقواس المربعة في استعلام CSS المعطى إلى querySelectorAll لمطابقة السمات، وهي تختار العناصر التي تكون سمة name الخاصة بها هي "color". حقول التحديد تسمح حقول التحديد select fields للمستخدِم الاختيار من بين مجموعة خيارات كما في حالة أزرار الانتقاء، لكن مظهر الوسم <select> يختلف بحسب المتصفح على عكس أزرار الانتقاء التي نتحكم في مظهرها، كما تحتوي هذه الحقول على متغير يشبه قائمة أزرار الاختيار أكثر من أزرار الانتقاء، فإذا أعطينا وسم <select> السمة multiple، فسيسمح للمستخدِم اختيار أيّ عدد من الخيارات التي يريدها بدلًا من خيار واحد، وسيكون مظهر هذا مختلفًا باختلاف المتصفح، لكنه سيختلف عن حقل التحديد العادي الذي يكون تحكمًا منسدلًا drop-down control لا يعرض الخيارات إلا عند فتحه. يحتوي كل وسم <option> على قيمة يمكن تعريفها باستخدام السمة value، وإذا لم تعطى تلك القيمة، فسيُعَدّ النص الذي بداخل الخيار هو قيمته، كما تعكس خاصية value الخاصة بالعنصر <select> الخيار المحدَّد حاليًا، لكن لن تكون هذه الخاصية ذات شأن في حالة الحقل multiple بما أنها ستعطي قيمة خيار واحد فقط من الخيارات المحدَّدة الحالية، ويمكن الوصول إلى وسوم <option> الخاصة بحقل <select> على أساس كائن شبيه بالمصفوفة من خلال خاصية الحقل options، كما يحتوي كل خيار على خاصية تسمى selected توضِّح هل الخيار محدَّد حاليًا أم لا، ويمكن كتابة الخاصية لتحديد خيار ما أو إلغاء تحديده. يستخرِج المثال التالي القيم المحدَّدة من حقل التحديد multiple ويستخدِمها لتركيب عدد ثنائي من بِتّات منفصلة، لتحديد عدة خيارات اضغط باستمرار على زر control -أو command على ماك Mac-. <select multiple> <option value="1">0001</option> <option value="2">0010</option> <option value="4">0100</option> <option value="8">1000</option> </select> = <span id="output">0</span> <script> let select = document.querySelector("select"); let output = document.querySelector("#output"); select.addEventListener("change", () => { let number = 0; for (let option of Array.from(select.options)) { if (option.selected) { number += Number(option.value); } } output.textContent = number; }); </script> حقول الملفات صُمِّمت حقول الملفات ابتداءً على أساس طريقة لرفع الملفات من حاسوب المستخدِم من خلال استمارة؛ أما في المتصفحات الحديثة، فهي توفر طريقةً لقراءة تلك الملفات، ولكن من خلال برامج جافاسكربت، إذ يتصرف الحقل على أساس حارس لبوابة، بحيث لا تستطيع السكربت البدء بقراءة ملفات خاصة بالمستخدِم من حاسوبه، لكن إذا اختار المستخدِم ملفًا في حقل كهذا، فسيفسِّر المتصفح ذلك الإجراء على أنه سماح للسكربت بقراءة الملف، ويبدو حقل الملف على أساس زر عليه عنوان مثل "اختر الملف" أو "تصفح الملف" مع معلومات عن الملف المختار تظهر إلى جانبه. <input type="file"> <script> let input = document.querySelector("input"); input.addEventListener("change", () => { if (input.files.length > 0) { let file = input.files[0]; console.log("You chose", file.name); if (file.type) console.log("It has type", file.type); } }); </script> تحتوي الخاصية files لعنصر حقل الملف على الملفات المختارة في الحقل، وهي كائن شبيه بالمصفوفة وليست مصفوفةً حقيقيةً، كما تكون فارغةً في البداية، والسبب في عدم وجود خاصية مستقلة باسم file، هو دعم الحقول لسمة multiple التي تجعل من الممكن تحديد عدة ملفات في الوقت نفسه، كما تحتوي الكائنات في كائن files على خاصيات مثل name لاسم الملف وsize لحجمه مقدَّرًا بالبايت -الذي هو وحدة قياس تخزينية تتكون من 8 بِتّات-، كما تحتوي على الخاصية type التي تمثِّل نوع وسائط media الملف التي قد تكون نصًا عاديًا text/plain أو صورةً image/jpeg، لكن ليس لتلك الكائنات خاصيةً يكون فيها محتوى الملف، وبما أنّ قراءة الملف من القرص ستستغرق وقتًا، فيجب أن تكون الواجهة غير تزامنية لتجنب تجميد أو تعليق المستند أثناء قراءته. <input type="file" multiple> <script> let input = document.querySelector("input"); input.addEventListener("change", () => { for (let file of Array.from(input.files)) { let reader = new FileReader(); reader.addEventListener("load", () => { console.log("File", file.name, "starts with", reader.result.slice(0, 20)); }); reader.readAsText(file); } }); </script> تتم عملية قراءة الملف من خلال إنشاء كائن FileReader الذي يسجِّل معالج الحدث "load" له، ويستدعي التابع readAsText الخاص به ليعطيه الملف الذي نريد قراءته، كما ستحتوي الخاصية result الخاصة بالقارئ على محتويات الملف بمجرد انتهاء التحميل، ويطلق الكائن FileReader أيضًا حدث خطأ "error" عند فشل قراءة الملف لأيّ سبب، إذ سيؤول كائن الخطأ نفسه في خاصية error الخاصة بالقارئ، ورغم تصميم تلك الواجهة قبل أن تصبح الوعود promises جزءًا من جافاسكربت، إلا أنك تستطيع تغليفها بوعد كما يلي: function readFileText(file) { return new Promise((resolve, reject) => { let reader = new FileReader(); reader.addEventListener( "load", () => resolve(reader.result)); reader.addEventListener( "error", () => reject(reader.error)); reader.readAsText(file); }); } تخزين البيانات في جانب العميل ستكون صفحات HTML البسيطة التي فيها قليل من جافاسكربت صيغةً رائعةً من أجل التطبيقات المصغَّرة، وهي برامج مساعِدة صغيرة تؤتمت مهامًا أساسية عبر توصيل بعض حقول الاستمارات بمعالِجات الأحداث، حيث يمكنك فعل أيّ شيء بدءًا من التحويل بين وحدات القياس المختلفة إلى حساب كلمات المرور من كلمة مرور رئيسية واسم موقع. لكن لا نستطيع استخدام رابطات جافاسكربت إذا احتاج مثل ذلك التطبيق إلى تذكر أمر بين جلساته sessions، ذلك أنّ هذه الرابطات تُحذَف عند كل إغلاق للصفحة، غير أنه يمكن إعداد خادم وتوصيله بالانترنت وجعل التطبيق يخزن هناك، وسنرى كيفية فعل ذلك في مقال لاحق، لكن المقام يقصر هنا عن شرحه لتعقيده، وعلى أيّ حال، من المفيد أحيانًا إبقاء البيانات في المتصفح، كما يمكن استخدام الكائن localStorage لتخزين البيانات بطريقة تبقيها موجودةً حتى مع إعادة تحميل الصفحات، حيث يسمح لك ذلك الكائن بتصنيف قيم السلاسل النصية تحت أسماء. localStorage.setItem("username", "marijn"); console.log(localStorage.getItem("username")); // → marijn localStorage.removeItem("username"); تبقى القيمة الموجودة في localStorage إلى أن يُكتَب فوقها، كما تُحذَف باستخدام removeItem أو بمحو المستخدِم لبياناته المحلية، وتحصل المواقع التي هي من نطاقات مختلفة على أقسام تخزين مختلفة، ويعني هذا نظريًا عدم إمكانية قراءة وتعديل البيانات المخزَّنة في localStorage من قِبل موقع ما إلا بسكربتات من نفس الموقع، كما تضع المتصفحات حدًا لحجم البيانات التي يمكن للمتصفح أن يخزِّنها في localStorage، وذلك لمنع تلك الخاصية من ملء أقراص المستخدِمين الصلبة ببيانات عديمة الفائدة واستخدام مساحات كبيرة بها، وتنفِّذ الشيفرة التالية تطبيقًا لكتابة الملاحظات، حيث يحتفظ بمجموعة من الملاحظات المسماة ويسمح للمستخدِم بتعديل الملاحظات وإنشاء الجديدا منها. Notes: <select></select> <button>Add</button><br> <textarea style="width: 100%"></textarea> <script> let list = document.querySelector("select"); let note = document.querySelector("textarea"); let state; function setState(newState) { list.textContent = ""; for (let name of Object.keys(newState.notes)) { let option = document.createElement("option"); option.textContent = name; if (newState.selected == name) option.selected = true; list.appendChild(option); } note.value = newState.notes[newState.selected]; localStorage.setItem("Notes", JSON.stringify(newState)); state = newState; } setState(JSON.parse(localStorage.getItem("Notes")) || { notes: {"shopping list": "Carrots\nRaisins"}, selected: "shopping list" }); list.addEventListener("change", () => { setState({notes: state.notes, selected: list.value}); }); note.addEventListener("change", () => { setState({ notes: Object.assign({}, state.notes, {[state.selected]: note.value}), selected: state.selected }); }); document.querySelector("button") .addEventListener("click", () => { let name = prompt("Note name"); if (name) setState({ notes: Object.assign({}, state.notes, {[name]: ""}), selected: name }); }); </script> تحصل هذه السكربت على حالتها من القيمة "Notes" المخزَّنة في localStorage، وإذا لم تكن موجودة، فستنشئ حالة مثال وليس فيها إلا قائمة تسوق، ونحصل على القيمة nullعند محاولة قراءة حقل غير موجود من localStorage، كما أنّ تمرير null إلى JSON.parse سيجعله يحلل السلسلة النصية "null" ويُعيد null، وعلى ذلك يمكن استخدام العامِل || لتوفير قيمة افتراضية في مثل هذه المواقف؛ أما التابع setState فيتأكد أنّ DOM يُظهِر حالةً معطاةً ويخزِّن الحالة الجديدة في localStorage، كما تستدعي معالِجات الأحداث هذه الدالة للانتقال إلى حالة جديدة. كان استخدام Object.assign في المثال السابق من أجل إنشاء كائن جديد يكون نسخة من state.notes القديم، لكن مع خاصية واحدة مضافة أو مكتوب فوقها، وتأخذ Object.assign وسيطها الأول وتضيف جميع الخاصيات من أيّ وسائط آخرين إليه، فإذا أعطيناها كائنًا فارغًا فسنجعلها تملأ كائنًا جديدًا، وتُستخدَم الأقواس المربعة في الوسيط الثالث لإنشاء خاصية اسمها يكون مبنيًا على قيمة ديناميكية، كما لدينا كائن آخر يشبه localStorage اسمه sessionStorage، والاختلاف بين الاثنين هو أنّ محتوى الأخير يُنسى بنهاية كل جلسة، حيث يحدث عند إغلاق المتصفح وذلك بالنسبة لأغلب المتصفحات. خاتمة ناقشنا في هذا المقال كيفية عمل بروتوكول HTTP، وقلنا أنّ العميل يرسل الطلب الذي يحتوي على تابع يكون GET في الغالب ومسار يحدِّد المصدر، ثم يقرر الخادم بعدها ماذا يفعل بذلك الطلب ويستجيب بشيفرة حالة ومتن استجابة، وقد يحتوي كل من الطلب والاستجابة على ترويسات توفر لنا معلومات إضافية، كما تُسمى الواجهة التي تستطيع جافاسكربت التي في المتصفح إنشاء طلبات HTTP منها باسم fetch، إذ يبدو إنشاء الطلب كما يلي: fetch("/18_http.html").then(r => r.text()).then(text => { console.log(`The page starts with ${text.slice(0, 15)}`); }); كما عرفنا من قبل، تنشئ المتصفحات طلبات GET لجلب الموارد المطلوبة لعرض صفحة ويب، وقد تحتوي الصفحة على استمارات تسمح للمستخدِم بإدخال المعلومات التي سترسلها بعدها في صورة طلب إلى الصفحة الجديدة عند إرسال الاستمارة، كما تستطيع HTML تمثيل عدة أنواع من حقول الاستمارات مثل الحقول النصية وأزرار الاختيار وحقول الاختيار من متعدد ومختارات الملفات file pickers، إذ يمكن فحص مثل تلك الحقول وتعديلها باستخدام جافاسكربت، وهي تطلق الحدث "change" عند تعديلها والحدث "input" عند كتابة نص فيها، كما تستقبل أحداث لوحة المفاتيح عند انتقال نشاط لوحة المفاتيح إليها. عرفنا أيضًا أنّ الخاصيات مثل value -المستخدَمة في النصوص وحقول التحديد- أو checked -المستخدَمة في أزرار الاختيار وأزرار الانتقاء-، تُستخدَم من أجل قراءة أو تعيين محتوى الحقل، كما رأينا أن الحدث "submit" يُطلَق عند إرسال الاستمارة عليها، ويستطيع معالِج جافاسكربت استدعاء preventDefault على ذلك الحدث من أجل تعطيل السلوك الافتراضي للمتصفح، كما قد توجد عناصر حقول الاستمارات خارج وسم الاستمارة نفسه. إذا اختار المستخدم ملفًا من نظام ملفاته المحلي في حقل مختار الملفات، فيمكن استخدام الواجهة FileReader من أجل الوصول إلى محتويات ذلك الملف من برنامج جافاسكربت، كما يُستخدم الكائنان localStorage وsessionStorage لحفظ المعلومات حتى مع إعادة التحميل، إذ يحتفظ الكائن الأول بالبيانات احتفاظًا دائمًا أو إلى أن يقرر المستخدِم محوها؛ أما الثاني فيحفظها إلى حين إغلاق المتصفح. تدريبات التفاوض على المحتوى أحد الأمور التي يفعلها بروتوكول HTTP هو التفاوض على المحتوى content negotiation، حيث تُستخدَم ترويسة الطلب Accept لإخبار الخادم بنوع المستند الذي يريده العميل، وتتجاهل العديد من الخوادم هذه الترويسة، لكن إذا عرف الخادم عدة طرق لترميز أحد الموارد، فسينظر حينها في هذه الترويسة ويرسل نوع الملف الذي يريده العميل. لقد هُيء الرابط https://eloquentjavascript.net/author ليستجيب للنصوص المجردة أو HTML أو JSON وفقًا لما يطلبه العميل، وتعرَّف تلك الصيغ بأنواع وسائط قياسية هي text/plain وtext/html وapplication/json. أرسِل طلبات لجلب هذه الصيغ الثلاث من ذلك الرابط، واستخدم الخاصية headers في كائن الخيارات الممرَّر إلى fetch لضبط الترويسة Accept إلى نوع الوسائط media المفضل، ثم حاول طلب نوع الوسائط application/rainbows+unicorns، وانظر إلى شيفرة الحالة التي تنتجها. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. // شيفرتك هنا. إرشادات الحل ابن شيفرتك على أمثلة fetch الموجودة في المقال أعلاه. إذا طلبت أنواع وسائط كاذبة فستحصل على استجابة بالرمز 406، بعنى "غير مقبول" أو Not acceptable، وهو الرمز الذي يجب أن يعيده الخادم إذا لم يستطع تحقيق الترويسة Accept. طاولة عمل جافاسكربت ابن واجهةً تسمح للناس بكتابة شيفرات جافاسكربت ويشغلونها، وضَع زرًا بجانب حقل <textarea>، بحيث إذا ضُغط عليه يمرِّر الباني Function الذي رأيناه في مقال الوحدات Modules في جافاسكريبت لتغليف النص في دالة واستدعائها، ثم حوِّل القيمة التي تعيدها الدالة أو أيّ خطأ ترفعه إلى سلسلة نصية واعرضها تحت الحقل النصي. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <textarea id="code">return "hi";</textarea> <button id="button">Run</button> <pre id="output"></pre> <script> // شيفرتك هنا. </script> إرشادات الحل استخدم document.querySelector أو document.getElementById من أجل الوصول إلى العناصر المعرَّفة في HTML لديك، وبالنسبة للحدَثين "click" و"mousedown"، فسيستطيع معالِج الحدث الحصول على الخاصية value للحقل النصي واستدعاء Function عليها، وتأكد من تغليف كل من الاستدعاء إلى Function والاستدعاء إلى نتيجته في كتلة try كي تستطيع التقاط الاستثناءات التي ترفعها، كما أننا لا نعرف في حالتنا هذه نوع الاستثناء الذي لدينا، لذا يجب التقاط كل شيء. يمكن استخدام الخاصية textContent الخاصة بعنصر الخرج لملئها برسالة نصية؛ أما في حالة الرغبة في الحفاظ على المحتوى القديم، فأنشئ عقدةً نصيةً جديدةً باستخدام document.createTextNode وألحقها بالعنصر، وتذكّر إضافة محرف سطر جديد إلى النهاية كي لا يظهر كل الخرج على سطر واحد. لعبة حياة كونويل تُعَدّ لعبة حياة كونويل Conway game of life محاكاةً بسيطةً تنشئ حياةً صناعيةً على شبكة، بحيث تكون كل خلية في تلك الشبكة إما حيةً أو ميتةً، وتطبق القواعد التالية في كل جيل -منعطف-: تموت أيّ خلية حية لها أكثر من ثلاثة جيران أحياء أو أقل من اثنين. تبقى أيّ خلية حية على قيد الحياة حتى الجيل التالي إذا كان لها جاران أحياء أو ثلاثة. تعود أيّ خلية ميتة إلى الحياة إذا كان لها ثلاثة جيران أحياء حصرًا. يُعرَّف الجار على أنه أيّ خلية مجاورة بما في ذلك الخلايا المجاورة له قطريًا. لاحظ أنّ تلك القواعد تطبَّق على كامل الشبكة مرةً واحدةً وليس على مربع واحد في المرة، وهذا يعني أنّ عدد الجيران مبني على الموقف الابتدائي، ولا تؤثر التغيرات الحادثة في الخلايا المجاورة في هذا الجيل على الحالة الجديدة للخلية. نفِّذ هذه اللعبة باستخدام أيّ هيكل بيانات تجده مناسبًا، واستخدم Math.random لتوليد عشوائي لأماكن الخلايا في الشبكة في أول مرة، واعرضها على هيئة شبكة من حقول أزرار الاختيار مع زر بجانبها للانتقال إلى الجيل التالي، كما يجب عند حساب الجيل التالي إدراج التغييرات الحادثة عند اختيار المستخدم لزر الاختيار أو إلغاء الاختيار له. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <div id="grid"></div> <button id="next">Next generation</button> <script> // شيفرتك هنا. </script> إرشادات الحل حاول النظر إلى حساب الجيل على أنه دالة محضة تأخذ شبكةً واحدةً وتنتج شبكةً جديدةً تمثِّل الدورة التالية. يمكن تمثيل المصفوفة matrix بالطريقة المبينة في مقال الحياة السرية للكائنات في جافاسكريبت، حيث تَعد الجيران الأحياء بحلقتين تكراريتين متشعبتَين تكرران على إحداثيات متجاورة في كلا البعدين. لا تَعد الخلايا التي تكون خارج الحقل وتجاهل الخلية التي تكون في المركز عند عَد خلايا مجاورة لها. يمكن ضمان حدوث التغييرات على أزرار الاختيار في الجيل التالي بطريقتين؛ إما أن يلاحظ معالج حدث تلك التغييرات ويحدِّث الشبكة الحالية لتوافق ذلك، أو نولد شبكةً جديدةً من القيم التي في أزرار الاختيار قبل حساب الدورة التالية. إذا اخترت أسلوب معالج الحدث فربما يجب عليك إلحاق سمات تعرِّف الموضع الموافق لكل زر اختيار كي يسهل معرفة الخلية التي يجب تغييرها؛ أما لرسم شبكة من أزرار الاختيار، فيمكن استخدام العنصر <table> أو وضعها جميعًا في العنصر نفسه ووضع عناصر <br> -أي فواصل الأسطر- بين الصفوف. ترجمة -بتصرف- للفصل الثامن عشر من كتاب Elequent Javascript لصاحبه Marijn Haverbeke. اقرأ أيضًا المقال السابق: الرسم على لوحة في جافاسكربت رموز الإجابة فيHTTP
  16. نقصد بمعالجة الأخطاء عملية التقاط الأخطاء التي تولدها برامجنا وإخفائها عن المستخدم، فرسائل الأخطاء لا تخيف المبرمجين بل يمكن توقعها أحيانًا، إلا أن المستخدم لا يتوقع رؤيتها، وهي تربكه وتسبب له حيرةً، فإن كان سيرى رسالة خطأ للضرورة، فلتكن رسالةً سهلة الفهم، وحتى في هذه الحالة سيرغب المستخدم في أن يحل المبرمج المشكلة، وهنا يأتي دور التعامل مع الأخطاء ومعالجتها، حيث توفر كل لغة تقريبًا آليةً لالتقاط الأخطاء عند حدوثها لمعرفة الأجزاء التي تعطلت، واتخاذ الإجراء المناسب لإصلاح المشكلة، وقد تطورت عدة طرق لمعالجة هذه الأخطاء، والتي سننظر فيها هنا متبعين تطورها التاريخي مع تطور التقنية لنعرف الأسباب التي أدت إلى ظهور منظور جديد رغم وجودها سابقًا، ونرجو أن تكون قادرًا في نهاية المقال على كتابة برامج لا تسمح بظهور رسائل خطأ للمستخدم. تجدر الإشارة إلى أن لغة VBScript هي أكثر لغة غريبة من اللغات الثلاثة التي ندرسها في معالجة الأخطاء، لأنها بُنيت على لغة BASIC، التي إحدى أوائل لغات البرمجة التي خرجت في عام 1963، وسنرى كيف ألقى تراثها بظلاله على VBScript فيما يتعلق بمعالجة الأخطاء، لكن هذا لا يؤثر على سياق شرحنا، بل سيعطينا الفرصة لشرح سبب سلوك VBScript بتعقب تاريخ معالجة الأخطاء، بدءًا من لغة BASIC، مرورًا بلغة Visual Basic حتى VBSCript، ثم ننظر بعدها إلى منظور أحدث، ونرى أمثلةً له في بايثون وجافاسكربت. لقد كُتبَت البرامج في لغة BASIC مع أرقام للأسطر لتمييزها، حيث يُنقل التحكم بالقفز إلى سطر بعينه باستخدام تعليمة GOTO التي رأينا مثالًا لها في مقال مقدمة في البرمجة الشرطية، وقد كانت هذه صورة التحكم الوحيدة المتاحة وقتها، حيث كان الأسلوب الشائع لمعالجة الأخطاء حينئذ هو التصريح عن متغير errorcode الذي يخزن قيمةً عدديةً، وكلما حدث خطأ في البرنامج، سيُضبط المتغير errorcode ليعكس المشكلة. فإما أن يخبرنا أنه لم يستطع فتح الملف، أو أن النوع غير متطابق، أو حدث طفح للعوامل operator overflow، أو غير ذلك، وقد أدى هذا إلى شيفرة تشبه المثال التالي من برنامج وهمي: 1010 LET DATA = INPUT FILE 1020 CALL DATA_PROCESSING_FUNCTION 1030 IF NOT ERRORCODE = 0 GOTO 5000 1040 CALL ANOTHER_FUNCTION 1050 IF NOT ERRORCODE = 0 GOTO 5000 1060 REM CONTINUE PROCESSING LIKE THIS ... 5000 IF ERRORCODE = 1 GOTO 5100 5010 IF ERRORCODE = 2 GOTO 5200 5020 REM MORE IF STATEMENTS ... 5100 REM HANDLE ERROR CODE 1 HERE ... 5200 REM HANDLE ERROR CODE 2 HERE يفحص نصف البرنامج الرئيسي وجود خطأ، لكن ظهرت مع الوقت آلية أفضل، حيث تولى مفسر اللغة عملية التقاط الأخطاء ومعالجتها؛ ولو جزئيًا، كما يلي: 1010 LET DATA = INPUTFILE 1020 ON ERROR GOTO 5000 1030 CALL DATA_PROCESSING_FUNCTION 1040 CALL ANOTHER_FUNCTION ... 5000 IF ERRORCODE = 1 GOTO 5100 5010 IF ERRORCODE = 2 GOTO 5200 سمح هذا بالإشارة إلى المكان الذي توجد فيه شيفرة الخطأ بواسطة سطر واحد، ورغم أننا لا زلنا بحاجة إلى الدوال التي اكتشفت الخطأ لضبط قيمة ERRORCODE، إلا أنها جعلت كتابة الشيفرة وقراءتها أسهل بكثير، لكن كيف يتأثر المبرمجون بهذا الأمر؟ توفر Viusal Basic إلى الآن هذا النوع من معالجة الأخطاء -على الرغم من استخدامنا حاليًا طريقةً أفضل من أرقام الأسطر-، وبما أن VBScript تنحدر من Visual Basic، فإنها توفر نسخةً مختصرةً للغاية من هذه الطريقة، وهي تخيّرنا بين معالجة الأخطاء محليًا أو تجاهلها تمامًا، ونستخدم الشيفرة التالية لتجاهل الأخطاء: On Error Goto 0 ' 0 implies go nowhere SomeFunction() SomeOtherFunction() .... أما لمعالجتها محليًا فنستخدم ما يلي: On Error Resume Next SomeFunction() If Err.Number = 42 Then ' handle the error here SomeOtherFunction() ... يبدو هذا المنطق معكوسًا، لكنه يوضح العملية تاريخيًا كما أوضحنا أعلاه، فالسلوك الافتراضي للمفسر هو توليد رسالة إلى المستخدم، وإيقاف تنفيذ البرنامج إذا اكتشف خطأً ما، وهذا ما يحدث مع معالجة خطأ GoTo 0، فما هي إلا طريقة لإيقاف التحكم المحلي والسماح للمفسر بالعمل كالمعتاد. تسمح لنا تعليمة Resume Next بالتظاهر وكأن الخطأ لم يحدث، أو أن التحقق من كائن الخطأ -الذي يسمى Err-، وسمة العدد -مثل تقنية errorcode الأولى-، كما أن للكائن Err أجزاء معلومات أخرى قد تفيدنا في التعامل مع الموقف بطريقة أفضل من مجرد إيقاف البرنامج، بحيث نستطيع معرفة مصدر الخطأ مثلًا، سواءً كان كائنًا أم دالةً أم غير ذلك، كما نستطيع الحصول على وصف نصي نستخدمه في تعبئة رسالة تخبر المستخدم بما يحدث، أو كتابة ملاحظة في ملف السجل. كما يمكن تغيير نوع الخطأ باستخدام التابع Raise للكائن Err، إلى جانب استخدامنا له لتوليد أخطائنا من داخل دوالنا، لننظر في مثال حالة القسمة على الصفر؛ وهي حالة شائعة، لنرى معالجة الأخطاء في VBScript: <script type="text/vbscript"> Dim x,y,Result x = Cint(InputBox("Enter the number to be divided")) y = CINt(InputBox("Enter the number to divide by")) On Error Resume Next Result = x/y If Err.Number = 11 Then ' Divide by zero Result = Null End If On Error GoTo 0 ' turn error handling off again If VarType(Result) = vbNull Then MsgBox "ERROR: Could not perform operation" Else MsgBox CStr(x) & " divided by " & CStr(y) & " is " & CStr(Result) End If </script> هذا الأسلوب غير مثالي، ورغم أن تقدير التراث البرمجي هنا جميل ولطيف، إلا أن لغات البرمجة الحديثة -بما فيها بايثون وجافاسكربت- لديها طرق أفضل لمعالجة الأخطاء، كما سنشرح في الجزئية الموالية من المقال، لكن قبل ذلك، ننصحك بالاطلاع على الفيديو الآتي لفهم الأخطاء البرمجية والتعرف على كيفية التعامل معها مهما اختلف نوعها ولغتها: معالجة الأخطاء في بايثون سنعرض فيما يلي آليات التعامل مع الأخطاء والاستثناءات التي تحصل أثناء تنفيذ شيفرة البرنامج وكيفية معالجتها في بايثون. التعامل مع الاستثناءات تتعامل لغات البرمجة الحديثة مع الاستثناءات exceptions وتعالجها بجعل الدوال ترفع الاستثناء raise أو تلقيه throw، ثم يفرض النظام قفزةً إلى خارج كتلة التعليمات البرمجية الحالية إلى أقرب كتلة معالجة استثناءات، ويوفر النظام معالجًا افتراضيًا يلتقط جميع الاستثناءات التي لم تعالَج في مكان آخر، كما يطبع رسالة خطأ ثم يخرج. انظر إلى مقال بداية رحلة تعلم البرمجة لمراجعة كيفية قراءة رسائل الخطأ في بايثون وتفسيرها، حيث تتمثل إحدى مزايا هذا النمط من معالجة الأخطاء في سهولة رؤية الوظيفة الأساسية للبرنامج، لأنها غير مختلطة بشيفرة معالجة الأخطاء، إذ نستطيع قراءة الكتلة الرئيسية دون الحاجة إلى النظر إلى شيفرة الخطأ مطلقًا. لننظر في كيفية عمل هذا النمط عمليًا: استثناءات Try/Except تُكتب كتلة معالجة الاستثناءات على شكل كتلة if ...then...else: try: # منطق البرنامج هنا except ExceptionType: # معالجة الاستثناءات للاستثناء المسمى هنا except AnotherType: # معالجة الاستثناءات لاستثناءات أخرى هنا else: # هنا نقوم بالترتيب إذا لم تُرفع استثناءات تحاول بايثون أن تنفذ التعليمات بين try وأول تعليمة except، فإذا واجهت خطأً ما، فستوقف تنفيذ شيفرة block وتقفز إلى تعليمات except حتى تجد واحدةً تطابق نوع الخطأ أو الاستثناء، فإذا وجدت مطابقةً، فستنفذ الشيفرة التي في الكتلة التي بعد هذا الاستثناء مباشرةً؛ أما إذا لم توجد تعليمة except مطابِقة، فسيُنشر الخطأ إلى المستوى التالي للبرنامج، إلى أن توجد مطابقة، أو أن يكتشف مفسر المستوى الأعلى في بايثون هذا الخطأ ويعرض رسالة خطأ ويوقف تنفيذ البرنامج، وهو ما رأيناه في برامجنا إلى الآن. أما إذا لم يوجد خطأ في كتلة try، فستنفَّذ كتلة else الأخيرة، رغم أن هذه الخاصية لا تُستخدم إلا نادرًا. لاحظ أن تعليمة except التي ليس فيها نوع خطأ محدد، إذ ستلتقط كل أنواع الأخطاء التي لم تعالَج بعد، وهذا سيئ إلا في حالة المستوى الأعلى في برنامجك حين تريد تجنب عرض رسائل بايثون التقنية إلى المستخدمين، حيث يمكن استخدام تعليمة استثناء عامة لالتقاط أي أخطاء غير ملتقطة، وعرض رسالة "إغلاق" مناسبة للمستخدم، ويجب الانتباه إلى تسجيل بيانات الخطأ في ملف السجل للتحليل في المستقبل. توفر لغة بايثون وحدة traceback التي تمكنك من استخراج أجزاء من المعلومات من مصدر الخطأ، وقد يكون هذا مفيدًا في إنشاء ملفات السجلات وما شابهها، لكننا لن نشرح هذه الوحدة، فإذا احتجت إليها فسيوفر توثيق الوحدات القياسي قائمةً كاملةً من المزايا والخصائص المتوفرة لها. لننظر الآن في مثال حقيقي لتوضيح الشرح: value = input("Type a divisor: ") try: value = int(value) print( "42 / %d = %d" % (value, 42/value) ) except ValueError: print( "I can't convert the value to an integer" ) except ZeroDivisionError: print( "Your value should not be zero" ) except: print( "Something unexpected happened" ) else: print( "Program completed successfully" ) إذا شغلنا هذا البرنامج وأدخلنا قيمةً ليست برقم مثل إدخال سلسلة نصية في المحث، فسنحصل على رسالة ValueError؛ أما إذا أدخلنا 0 فنحصل على رسالة ZeroDivisionError، وإذا ضغطنا Ctrl+C فسنرفع استثناء KeyboardInterrupt ونرى رسالةً تقول "Something unexpected happened"؛ أما إذا كتبنا عددًا صالحًا فسنحصل على النتيجة مع رسالة "Program Completed successfully". استثناءات Try/Finally ثمة نوع آخر من كتل الاستثناءات التي تسمح لنا بالترتيب بعد حدوث خطأ ما، وتسمى try...finally، كما تُستخدم لإغلاق الملفات واتصالات الشبكات أو قواعد البيانات، وتنفَّذ كتلة finally في النهاية بغض النظر عما يحدث في قسم try: try: # المنطق المعتاد للبرنامج finally: # try هنا نرتب بغض النظر عن نجاح كتلة # أو فشلها تصبح الكتلة قويةً للغاية إذا جمعناها مع try/except: print( "Program starting" ) try: data = open("data.dat") print( "data file opened" ) value = int(data.readline().split()[2]) print( "The calculated value is %s" % (value/(42-value)) ) except ZeroDivisionError: print( "Value read was 42" ) finally: data.close() print( "data file closed" ) print( "Program completed" ) لاحظ أن ملف البيانات يجب أن يحتوي على سطر مع رقم في الحقل الثالث، كما يلي: Foo bar 42 هنا يُغلق ملف البيانات دومًا بغض النظر عن رفع الاستثناء في كتلة try/except أو لا. لاحظ أن هذا السلوك مختلف عن شرط else لكتلة try/except، لأنه يُستدعى فقط عند عدم رفع استثناءات، كما يعني وضع الشيفرة خارج كتلة try/except أن الملف لم يغلَق إذا كان الاستثناء شيئًا غير ZeroDivisionError، ولا نضمن أن الملف مغلق إلا بإضافة كتلة finally. كذلك وضعنا تعليمة open()‎ داخل كتلة try/except، فإذا أردنا التقاط خطأ فتح ملف، فسنحتاج إلى إضافة كتلة except أخرى لـ IOError. جرب هذا بنفسك ثم افتح ملفًا غير موجود لترى ذلك عمليًا. توليد الأخطاء إذا أردنا توليد استثناءات ليلتقطها غيرنا في وحدة ما، فنستخدم الكلمة المفتاحية raise في بايثون: numerator = 42 denominator = int( input("What value will I divide 42 by?") ) if denominator == 0: raise ZeroDivisionError يرفع هذا استثناء ZeroDivisionError الذي يمكن التقاطه بواسطة كتلة try/except، أما بالنسبة لبقية البرنامج فسيبدو كما لو أن بايثون ولّدت هذا الخطأ داخليًا. يمكن استخدام كلمة raise في توليد خطأ لمستوى أعلى في البرنامج من داخل كتلة الاستثناء، فقد نرغب في أخذ إجراء محلي، مثل تسجيل خطأ في ملف، ثم نسمح للمستوى الأعلى من البرنامج أن يقرر الإجراء النهائي، كما يلي: def div127by(datum): try: return 127/(42-datum) except ZeroDivisionError: logfile = open("errorlog.txt","a") logfile.write("datum was 42\n") logfile.close() raise try: div127by(42) except ZeroDivisionError: print( "You can't divide by zero, try another value" ) لاحظ كيف تلتقط الدالة div127by()‎ الخطأ، وتسجل رسالةً في ملف الخطأ، ثم تمرر الاستثناء مرةً أخرى إلى كتلة try/except الخارجية لتتعامل معه باستدعاء raise دون كائن خطأ محدد. لنجمع هذين الجزأين معًا في برنامج واحد يوضح معالجة الأخطاء عمليًا: def div127by(datum): try: return 127/(42-datum) except ZeroDivisionError: logfile = open("errorlog.txt","a") logfile.write("datum was 42\n") logfile.close() raise try: divisor = int( input("What value will I divide by?") ) if divisor == 0: raise ZeroDivisionError print( "The result is: ", div127by(divisor) ) except ZeroDivisionError: print( "You can't divide by zero, try another value" ) فإذا أدخل المستخدم 42 أو 0 فسينتِج ZeroDivisionError، مع أن 0 قيمة آمنة في هذه الحالة؛ أما غير ذلك فنطبع نتيجة القسمة ونسجل قيمة الدخل في الملف errorlog.txt. الاستثناءات المعرفة من قبل المستخدم توفر بايثون نطاقًا واسعًا من أنواع الأخطاء القياسية، ويجب أن نعيد استخدام هذه الأخطاء ما أمكن، لكن قد لا نجد خطأً يناسب احتياجنا، عندئذ نستطيع تعريف أنواع الاستثناءات الخاصة بنا للتحكم في برامجنا تحكمًا دقيقًا، وقد مررنا على تعريف الأصناف في مقال البيانات وأنواعها مرورًا سريعًا، وسنعود إليها مرةً أخرى في جزئية لاحقة من هذه السلسلة. لا يحتوي صنف الاستثناء عادةً على محتوىً خاص به، وإنما نعرف صنفًا فرعيًا من Exception، ونستخدمه مثل نوع من الملصقات الذكية التي يمكن التقاطها بواسطة تعليمات except. لننظر في هذا المثال القصير: >>> class BrokenError(Exception): pass ... >>> try: ... raise BrokenError ... except BrokenError: ... print( "We found a Broken Error" ) ... لاحظ أننا نستخدم اصطلاح تسمية نضيف فيه Error إلى نهاية اسم الصنف، وأننا نكتسب سلوك صنف Exception العام بإدراجه في أقواس بعد الاسم، كما سنتعلم الاكتساب أو الوراثة inheritance في البرمجة كائنية التوجه. يجب ملاحظة نقطة أخيرة في رفع الأخطاء، وهي أننا كنا ننهي برامجنا باستيراد sys واستدعاء الدالة exit()‎، لكن يمكن استخدام أسلوب آخر يحقق نفس النتيجة، عن طريق رفع خطأ SystemExit()‎ كما يلي: >>> raise SystemExit وميزة هذا الأسلوب أننا لا نحتاج إلى import sys في البداية. جافاسكربت تعالج جافاسكربت الأخطاء بطريقة تشبه طريقة بايثون، باستخدام الكلمات المفتاحية try وcatch وthrow مقابل كلمات بايثون try وexcept وraise، وسننظر الآن في بعض الأمثلة، كما سنرى استخدام المبادئ نفسها التي كانت في بايثون، وقد أدخلت الإصدارات الأخيرة من جافاسكربت بنية finally، كما يمكن استخدام شرط finally في جافاسكربت مع كتلة try/catch في بنية واحدة. انظر توثيق جافاسكربت لمزيد من التفاصيل. التقاط الأخطاء تُلتقَط الأخطاء باستخدام كتلة try مع مجموعة من تعليمات catch تكاد تكون مطابقةً لما رأيناه في بايثون: <script type="text/javascript"> try{ var x = NonExistentFunction(); document.write(x); } catch(err){ document.write("We got an error in the code"); } </script> يكمن الاختلاف الأساسي في أننا نستخدم تعليمة catch واحدةً فقط لكل بنية try، وعلينا أن نفحص الخطأ الممرَّر داخل كتلة catch لنرى نوعه، وهذا فوضوي موازنةً بأسلوب except المتعدد في بايثون والمبني على نوع الاستثناء، وسنرى مثالًا بسيطًا لاختبار قيم الأخطاء في الشيفرة التالية. رفع الأخطاء يمكن رفع الأخطاء باستخدام الكلمة throw كما استخدمنا كلمة raise في بايثون، كما نستطيع إنشاء أنواع الخطأ الخاصة بنا في جافاسكربت كما فعلنا في بايثون، لكن الأسهل هو استخدام سلسلة نصية: <script type="text/javascript"> try{ throw("New Error"); } catch(e){ if (e == "New Error") document.write("We caught a new error"); else document.write("An unexpected error found"); } </script> هذا كل ما سنشرحه حول معالجة الأخطاء، وسنرى أمثلةً عمليةً لها في المقالات التالية، كما سنرى بعض المفاهيم الأساسية التي تحدثنا عنها من قبل، مثل التسلسلات والحلقات التكرارية والفروع، مما يعني أن لديك جميع الأدوات اللازمة لإنشاء برامج قوية. يفضل الآن أن تأخذ وقتًا تحاول فيه إنشاء بعض البرامج بنفسك -ربما بضعة برامج فقط-، لتثبت تلك المفاهيم في رأسك قبل الانتقال إلى مجموعة المقالات التالية، وتستطيع البدء بالبرامج التالية: لعبة بسيطة مثل OXO. قاعدة بيانات بسيطة، ربما مبنية على دليل جهات الاتصال الخاص بنا، ولكن لتخزين مجموعة أقراصك أو مقاطع الفيديو. أداة تسجيل يوميات تتيح لك إمكانية تخزين الأحداث أو التواريخ المهمة، وربما تخرج لك إشعارًا تذكيريًا. ستحتاج إلى استخدام جميع الخصائص التي شرحناها من قبل، وربما بعض وحدات اللغات كذلك، وهنا تذكر أن تعود إلى التوثيق كل فترة، إذ ستجد أدوات أخرى تعينك على إنشاء مثل هذه البرامج، ولا تنسى أيضًا قوة محث بايثون. جرب برامجك هناك إلى أن تفهم كيفية عملها، ثم انقل ذلك إلى البرنامج الخاص بك. خاتمة نرجو في نهاية المقال أن تكون تعلمت ما يلي: التحقق من شيفرات أخطاء VBScript باستخدام تعليمة if. التقاط الاستثناءات بشرط except في بايثون أو catch في جافاسكربت. توليد الاستثناءات باستخدام كلمة raise المفتاحية في بايثون أو throw في جافاسكربت. أنه يمكن أن تكون أنواع الأخطاء أصنافًا في بايثون أو سلسلةً بسيطةً في جافاسكربت. ترجمة -بتصرف- للفصل الرابع عشر: Handling Errors من كتاب Learning To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: فضاءات الأسماء Namespaces في البرمجة المقال السابق: كيفية التعامل مع النصوص في البرمجة التعامل مع الملفات في البرمجة تعلم البرمجة الزلات البرمجية والأخطاء في جافاسكريبت
  17. تعطينا المتصفحات طرقًا عدة لعرض الرسوميات على الشاشة، وأبسط تلك الطرق هي استخدام التنسيقات لموضعة وتلوين عناصر شجرة DOM العادية، ويمكن فعل الكثير بهذا كما رأينا في اللعبة التي في المقال السابق، كما يمكننا جعل العقد كما نريد بالضبط من خلال إضافة صور خلفية شبه شفافة إليها، أو أن نديرها أو نزخرفها باستخدام التنسيق transform، لكننا سنستخدِم عناصر DOM هكذا لغير الغرض الذي صُممت له، كما ستكون بعض المهام مثل رسم سطر بين نقطتين عشوائيتين غريبةً إذا نفذناها باستخدام عناصر HTML عادية. لدينا بديلين هنا، حيث أن البديل الأول مبني على DOM ويستخدِم الرسوميات المتجهية القابلة للتحجيم Scalable Vector Graphics -أو SVG اختصارًا- بدلًا من HTML، كما يمكن النظر إلى SVG على أنها صيغة توصيف مستندات تركِّز على الأشكال بدلًا من النصوص، ويمكن تضمين مستند SVG مباشرةً في مستند HTML أو إدراجه باستخدام الوسم <img>؛ أما البديل الثاني فيدعى اللوحة Canvas، وهو عنصر DOM واحد يغلف صورةً ما، ويوفر واجهةً برمجيةً لرسم الأشكال على مساحة تشغلها عقدة ما. الفرق الأساسي بين اللوحة وصورة SVG هو أن الوصف الأصلي للشكل في الأخيرة محفوظ بحيث يمكن نقله أو إعادة تحجيمه في أيّ وقت؛ أما اللوحة فتحوِّل الأشكال إلى بكسلات -وهي النقاط الملونة على الشاشة- بمجرد رسمها، كما لا تتذكر ما تمثله تلك البكسلات، والطريقة الوحيدة لنقل شكل على لوحة هي بمسح اللوحة أو الجزء الذي يحيط بالشكل، ثم إعادة رسمه بالشكل في موضع جديد. الرسوميات المتجهية القابلة للتحجيم SVG لن تخوض هذه السلسلة في تفاصيل SVG وإنما سنشرح كيفية عملها باختصار، كما سنعود إلى عيوبها في نهاية المقال، والتي يجب وضعها في حسبانك حين تريد اتخاذ قرار بشأن آلية الرسم المناسبة لتطبيق ما. فيما يلي مستند HTML مع صورة SVG بسيطة: <p>Normal HTML here.</p> <svg xmlns="http://www.w3.org/2000/svg"> <circle r="50" cx="50" cy="50" fill="red"/> <rect x="120" y="5" width="90" height="90" stroke="blue" fill="none"/> </svg> تغيِّر السمة xmlns عنصرًا ما -وعناصره الفرعية- إلى فضاء اسم XML مختلف، حيث يحدِّد ذلك الفضاء المعرَّف بواسطة رابط تشعبي URL الصيغة التي نستخدِمها الآن، وعلى ذلك يكون للوسمين <circle> و<rect> معنىً هنا في SVG، رغم أنهما لا يمثِّلان شيئًا في لغة HTML، كما يرسمان هنا الأشكال باستخدام التنسيق والموضع اللذين يحدَّدان بواسطة سماتهما. تنشئ هذه الوسوم عناصر DOM وتستطيع السكربتات أن تتفاعل معها كما تفعل وسوم HTML تمامًا، إذ تغيِّر الشيفرة التالية مثلًا عنصر <circle> ليُلوَّن باللون السماوي Cyan: let circle = document.querySelector("circle"); circle.setAttribute("fill", "cyan"); عنصر اللوحة يمكن رسم رسوميات اللوحة على عنصر <canvas>، كما تستطيع إعطاء مثل هذا العنصر سمات عرض width وطول height لتحديد حجمها بالبكسلات، وتكون اللوحة الجديدة فارغةً تمامًا، مما يعني أنها شفافة وتظهر مثل مساحة فارغة في المستند، كما يسمح الوسم <canvas> بتنسيقات مختلفة من الرسم، ونحتاج إلى إنشاء سياق context أولًا للوصول إلى واجهة الرسم الحقيقية، وهو كائن توفر توابعه واجهة الرسم. لدينا حاليًا تنسيقَين من أنماط الرسم المدعومَين دعمًا واسعًا هما "2d" للرسم ثنائي الأبعاد و"webgl" للرسم ثلاثي الأبعاد من خلال واجهة OpenGL، كما أننا لن نناقش واجهة OpenGL هنا، وإنما سنقتصر على الرسم ثنائي الأبعاد، لكن إذا أردت النظر في الرسم ثلاثي الأبعاد فاقرأ في WebGL، إذ توفر واجهةً مباشرةً لعتاد الرسوميات، وتسمح لك بإخراج مشاهد معقدة بكفاءة عالية باستخدَام جافاسكربت. نستطيع إنشاء سياق بواسطة التابع getContext على <canvas> لعنصر DOM كما يلي: <p>Before canvas.</p> <canvas width="120" height="60"></canvas> <p>After canvas.</p> <script> let canvas = document.querySelector("canvas"); let context = canvas.getContext("2d"); context.fillStyle = "red"; context.fillRect(10, 10, 100, 50); </script> يرسم المثال مستطيلًا أحمرًا بعرض 100 بكسل وارتفاع 50 بكسل بعد إنشاء كائن السياق، ويكون الركن الأيسر العلوي في الإحداثيات هو (10,10)، كما يضع نظام الإحداثيات في عنصر اللوحة الإحداثيات الصفرية (0,0) في الركن الأيسر العلوي كما في HTML وSVG، بحيث يتجه محور الإحداثي y لأسفل من هناك، وبالتالي يكون (10,10) مزاحًا عشرة بكسلات إلى الأسفل وإلى يمين الركن الأيسر العلوي. الأسطر والأسطح نستطيع ملء الشكل في واجهة اللوحة، مما يعني أننا سنعطي مساحته لونًا أو نقشًا بعينه، أو يمكن تحديده stroked بأن يُرسَم خطًا حول حوافه، وما قيل هنا سيقال في شأن SVG أيضًا، كما يملأ التابع fillRect مستطيلًا ويأخذ إحداثيات x وy للركن العلوي الأيسر للمستطيل ثم عرضه ثم ارتفاعه، ويرسم التابع strokeRect بالمثل الخطوط الخارجية للمستطيل، لكن لا يأخذ هذان التابعان معاملات أخرى، فلا يحدَّد وسيط ما لون الملء ولا سماكة التحديد ولا غيرها، كما قد يُتوقَّع في مثل هذه الحالة، والذي يحدِّد تلك العناصر هي خصائص سياق الكائن، حيث تتحكم الخاصية fillStyle بطريقة ملء الأشكال، ويمكن تعيينها لتكون سلسلةً نصيةً تحدِّد لونًا ما باستخدام ترميز الألوان في CSS؛ أما الخاصية strokeStyle فهي شبيهة بأختها السابقة، لكن تحدد اللون المستخدَم في التحديد، كما يُحدَّد عرض الخط بواسطة الخاصية lineWidth التي قد تحتوي أي عدد موجب. <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.strokeStyle = "blue"; cx.strokeRect(5, 5, 50, 50); cx.lineWidth = 5; cx.strokeRect(135, 5, 50, 50); </script> إذا لم تُحدَّد سمة عرض width أو طول height كما في المثال، فسيحصل عنصر اللوحة على عرض افتراضي مقداره 300 بكسل وطول مقداره 150 بكسل. المسارات المسار هو متسلسلة من الأسطر، ويأخذ عنصر اللوحة ثنائي الأبعاد منظورًا استثنائيًا لوصف مثل تلك المسارات من خلال التأثيرات الجانبية بالكامل، كما لا تُعَدّ المسارات قيمًا يمكن تخزينها وتمريرها من مكان إلى آخر، بل إذا أردنا فعل شيء بمسار ما، فيمكن إنشاء متسلسلة من استدعاءات التوابع لوصف شكله. <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); for (let y = 10; y < 100; y += 10) { cx.moveTo(10, y); cx.lineTo(90, y); } cx.stroke(); </script> ينشئ هذا المثال مسارًا فيه عدد من أجزاء أسطر أفقية ثم يحدِّدها باستخدام التابع stroke، ويبدأ كل جزء أُنشئ بواسطة lineTo عند الموضع الحالي للمسار، كما يكون ذلك الموضع عادةً في نهاية الجزء الأخير، إلا إذا استدعيت moveTo، حيث سيبدأ الجزء التالي في تلك الحالة عند الموضع الممرَّر إلى moveTo. يُملأ كل شكل لوحده عند ملء المسار باستخدام التابع fill، وقد يحتوي المسار على عدة أشكال، بحيث تبدأ كل حركة moveTo شكلًا جديدًا، ولكن سيحتاج المسار إلى أن يغلَق قبل إمكانية ملئه، بحيث تكون بدايته ونهايته في الموضع نفسه، فإذا لم يكن المسار مغلقًا، فسيضاف السطر من نهايته إلى بدايته، ويُملأ الشكل المغلَّف بالمسار المكتمِل. <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); cx.moveTo(50, 10); cx.lineTo(10, 70); cx.lineTo(90, 70); cx.fill(); </script> يرسم هذا المثال مثلثًا مملوءًا. لاحظ أنّ ضلعيَن فقط من أضلاع المثلث هما اللذان رُسما صراحةً؛ أما الثالث الذي يبدأ من الركن السفلي الأيمن إلى القمة فيُضمَّن ولن يكون موجودًا عند تحديد المسار، كما يُستخدَم التابع closePath لغلق المسار صراحةً من خلال إضافة جزء سطر حقيقي إلى بداية المسار، وسيُرسم هذا الجزء عند تحديد المسار. المنحنيات قد يحتوي المسار على خطوط منحنية، وتكون هذه الخطوط أعقد في رسمها من الخطوط المستقيمة، حيث يرسم التابع quadraticCurveTo منحني إلى نقطة ما، كما يُعطى التابع نقطة تحكم ونقطة وجهة لتحديد انحناء الخط، ويمكن تخيل نقطة التحكم على أنها تسحب الخط لتعطيه انحناءه؛ أما الخط نفسه فلن يمر عليها وإنما سيكون اتجاهه عند نقطتي البدء والنهاية، بحيث إذا رُسم خط مستقيم في ذلك الاتجاه فسيشير إلى نقطة التحكم، ويوضِّح المثال التالي ذلك: <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); cx.moveTo(10, 90); // control=(60,10) goal=(90,90) cx.quadraticCurveTo(60, 10, 90, 90); cx.lineTo(60, 10); cx.closePath(); cx.stroke(); </script> سنرسم المنحنى التربيعي من اليسار إلى اليمين، وتكون نقطة التحكم هي (60,10)، ثم نرسم خطين يمران بنقطة التحكم تلك ويعودان إلى بداية الخط. سيكون الشكل الناتج أشبه بشعار أفلام ستار تريك Star Trek، كما تستطيع رؤية تأثير نقطة التحكم، بحيث تبدأ الخطوط تاركة الأركان السفلى في اتجاه نقطة التحكم ثم تنحني مرةً أخرى إلى هدفها. يرسم التابع bezierCurveTo انحناءً قريبًا من ذلك، لكن يكون له نقطتي تحكم أي واحدة عند كل نهاية خط بدلًا من نقطة تحكم واحدة، ويوضِّح المثال التالي سلوك هذا المنحنى: <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); cx.moveTo(10, 90); // control1=(10,10) control2=(90,10) goal=(50,90) cx.bezierCurveTo(10, 10, 90, 10, 50, 90); cx.lineTo(90, 10); cx.lineTo(10, 10); cx.closePath(); cx.stroke(); </script> تحدد نقطتا التحكم الاتجاه عند كلا النهايتين للمنحنى، وكلما ابتعدنا عن النقطة الموافقة لهما زاد انتفاخ المنحنى في ذلك الاتجاه، كما ستكون تلك المنحنيات أصعب من حيث العمل عليها، فليس من السهل معرفة كيفية إيجاد نقاط التحكم التي توفر الشكل الذي تبحث عنه، حيث نستطيع حسابها أحيانًا، لكن سيكون علينا إيجاد قيمة مناسبة من خلال التجربة والخطأ أحيانًا أخرى. يُستخدَم التابع arc على أساس طريقة لرسم خط ينحني على حواف دائرة، كما يأخذ زوجًا من الإحداثيات من أجل مركز القوس، ونصف قطر، ثم زاوية بداية وزاوية نهاية. نستطيع من خلال هذين المعاملَين الأخيرين رسم جزء من الدائرة فقط دون رسمها كلها، كما تقاس الزوايا بالراديان radian وليس بالدرجات، ويعني هذا أن الدائرة الكاملة لها زاوية مقدارها 2π أو 2‎ * Math.PI، وهي تساوي 6.28 تقريبًا، كما تبدأ الزاوية العد عند النقطة التي على يمين مركز الدائرة وتدور باتجاه عقارب الساعة من هناك، وهنا تستطيع استخدام 0 للبداية ونهاية تكون أكبر من 2π -لتكن 7 مثلًا- من أجل رسم الدائرة كلها. <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.beginPath(); // center=(50,50) radius=40 angle=0 to 7 cx.arc(50, 50, 40, 0, 7); // center=(150,50) radius=40 angle=0 to ½π cx.arc(150, 50, 40, 0, 0.5 * Math.PI); cx.stroke(); </script> رسم المخطط الدائري لنقل أننا نريد رسم مخطط دائري لنتائج استبيان رضا العملاء عن شركة ما ولتكن EconomiCorp مثلًا، بحيث تحتوي رابطة results على مصفوفة من الكائنات التي تمثل نتائج الاستبيان. const results = [ {name: "Satisfied", count: 1043, color: "lightblue"}, {name: "Neutral", count: 563, color: "lightgreen"}, {name: "Unsatisfied", count: 510, color: "pink"}, {name: "No comment", count: 175, color: "silver"} ]; سنرسم عددًا من الشرائح الدائرية يتكون كل منها من قوس وزوج من الخطوط ينتهيان إلى مركز ذلك القوس، كما نستطيع حساب الزاوية التي يأخذها كل قوس من خلال قسمة الدائرة الكلية 2π على العدد الكلي للاستجابات، ومن ثم ضرب ذلك العدد -زاوية الاستجابة- في عدد الأشخاص الذين اختاروا عنصرًا ما. <canvas width="200" height="200"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let total = results .reduce((sum, {count}) => sum + count, 0); // ابدأ من القمة let currentAngle = -0.5 * Math.PI; for (let result of results) { let sliceAngle = (result.count / total) * 2 * Math.PI; cx.beginPath(); // center=100,100, radius=100 // من الزاوية الحالية، باتجاه عقارب الساعة بحذاء زاوية الشريحة cx.arc(100, 100, 100, currentAngle, currentAngle + sliceAngle); currentAngle += sliceAngle; cx.lineTo(100, 100); cx.fillStyle = result.color; cx.fill(); } </script> لا يخبرنا المخطط ماذا تعني تلك الشرائح، لذا سنحتاج إلى طريقة نرسم بها نصوصًا على اللوحة. النصوص يوفر سياق رسم اللوحة ثنائي الأبعاد التابعَين fillText وstrokeText، حيث يُستخدَم الأخير في تحديد الأحرف، لكن الذي نحتاج إليه هو fillText عادةً، إذ سيملأ حد النص المعطى بتنسيق fillStyle الحالي. <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.font = "28px Georgia"; cx.fillStyle = "fuchsia"; cx.fillText("I can draw text, too!", 10, 50); </script> تستطيع تحديد حجم النص وتنسيقه ونوع خطه أيضًا باستخدام الخاصية font، ولا يعطينا هذا المثال إلا حجم الخط واسم عائلته، كما من الممكن إضافة ميل الخط italic أو سماكته bold إلى بداية السلسلة النصية لاختيار تنسيق ما، في حين يوفر آخر وسيطين لكل من fillText وstrokeText الموضع الذي سيُرسم فيه الخط، كما يشيران افتراضيًا إلى موضع بداية قاعدة النص الأبجدية التي تكوِّن السطر الذي تقف الحروف عليه، لكن لا تحسب الأجزاء المتدلية من الأحرف مثل حرف j أو p، ونستطيع تغيير الموضع الأفقي ذاك بضبط الخاصية textAlign لتكون "end" أو "center"، وتغيير الموضع الرأسي كذلك من خلال ضبط textBaseline لتكون "top" أو "middle" أو "bottom". الصور يُفرَّق عادةً في رسوميات الحواسيب بين الرسوميات المتجهية vector graphics والرسوميات النقطية bitmap graphics، فالأولى هي التي شرحناها في بداية هذا المقال والتي تصف الصورة وصفًا منطقيًا لشكلها؛ أما الرسوميات النقطية فلا تصف الأشكال الحقيقية، بل تعمل مع بيانات البكسلات الخاصة بالصورةk والتي هي مربعات من النقاط الملونة على الشاشة. يسمح لنا التابع drawImage برسم بيانات البكسلات على اللوحة، ويمكن استخراج تلك البيانات من عنصر <img> أو من لوحة أخرى، كما ينشئ المثال التالي عنصر <img> منفصل ويحمِّل ملف الصورة إليه، لكنه لا يستطيع البدء بالرسم مباشرةً من تلك الصورة بما أنّ المتصفح قد لا يكون حمَّلها بعد، ولحل هذا فإننا نسجل معالج الحدث "load" لتنفيذ الرسم بعد تحميل الصورة. <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let img = document.createElement("img"); img.src = "img/hat.png"; img.addEventListener("load", () => { for (let x = 10; x < 200; x += 30) { cx.drawImage(img, x, 10); } }); </script> سيرسم التابع drawImage الصورة بحجمها الحقيقي افتراضيًا، لكن يمكن إعطاؤه وسيطَين إضافيين لتحديد عرض وطول مختلفَين، فإذا أعطي drawImage تسعة وسائط، فيمكن استخدامه لرسم جزء من الصورة، ويوضِّح الوسيط الثاني حتى الخامس -أي x وy والعرض والطول- المستطيل الذي في الصورة المصدرية والتي يجب نسخها؛ أما الوسيط السادس حتى التاسع فتعطينا المستطيل على اللوحة الذي سيُنسخ، كما يمكن استخدام هذا لتحزيم عدة شرائح أو عناصر من صورة (تسمى sprites أي عفاريت) في ملف صورة واحد، ثم رسم الجزء الذي نحتاج إليه فقط، فلدينا مثلًا هذه الصورة التي تحتوي على شخصية لعبة في عدة وضعيات: إذا غيرنا الوضع الذي نرسمه فسنستطيع عرض تحريك يبدو مثل شخصية تمشي، في حين نستخدِم التابع clearRect لتحريك صورة على اللوحة، وهو يمثِّل fillRect، لكنه يجعل المستطيل شفافًا بدلًا من تلوينه حاذفًا البكسلات المرسومة سابقًا، ونحن نعرف أنّ كل عفريت وكل صورة فرعية يكون عرضها 24 بكسل وارتفاعها 30 بكسل، وعلى ذلك تحمِّل الشيفرة التالية الصورة ثم تضبط فترةً زمنيةً متكررةً لرسم الإطار التالي: <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let img = document.createElement("img"); img.src = "img/player.png"; let spriteW = 24, spriteH = 30; img.addEventListener("load", () => { let cycle = 0; setInterval(() => { cx.clearRect(0, 0, spriteW, spriteH); cx.drawImage(img, // المستطيل المصدر cycle * spriteW, 0, spriteW, spriteH, // مستطيل الوجهة 0, 0, spriteW, spriteH); cycle = (cycle + 1) % 8; }, 120); }); </script> تتعقب رابطة cycle موضعنا في هذا التحريك وتتزايد مرةً لكل إطار، ثم تقفز عائدةً إلى المجال 0 إلى 7 باستخدام عامِل الباقي، بعدها تُستخدَم هذه الرابطة بعد ذلك لحساب الإحداثي x الذي يحتوي عليه العفريت الذي في الوضع الحالي في الصورة. التحول ماذا لو أردنا جعل الشخصية تمشي إلى اليسار بدلًا من اليمين؟ لا شك أننا نستطيع رسم مجموعة أخرى من عناصر العفاريت، لكننا نستطيع توجيه اللوحة أيضًا لترسم الصورة بعكس الطريقة التي ترسمها بها، كما سيزيد استدعاء التابع scale حجم أيّ شيء يُرسم بعده، وهو يأخذ معاملَين أحدهما لضبط المقياس الأفقي والآخر للعمودي. <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); cx.scale(3, .5); cx.beginPath(); cx.arc(50, 50, 40, 0, 7); cx.lineWidth = 3; cx.stroke(); </script> سيتسبب تغيير حجم الصورة في تمديد كل شيء فيها أو ضغطه بما فيها عرض الخط، وإذا غيّرنا المقياس ليكون بقيمة سالبة، فستنقلب الصورة معكوسة، حيث يحدث الانعكاس حول النقطة (0,0) التي تعني أننا سنقلب أيضًا اتجاه نظام الإحداثيات، فحين نطبِّق مقياسًا أفقيًا مقداره ‎-1‎، فسيكون الشكل المرسوم عند الموضع 100 على إحداثي x في الموضع الذي كان ‎‎‎‎-100‎ من قبل، لذا لا نستطيع إضافة ‎‎‎cx.scale(-1, 1)‎‎‎ من أجل عكس الصورة وحسب قبل استدعاء drawImage، لأنه سيجعل الصورة تتحرك خارج اللوحة بحيث تكون غير مرئية، ونعدّل الإحداثيات المعطاة إلى drawImage من أجل ضبط هذا برسم الصورة في الموضع ‎-50‎ على الإحداثي x بدلًا من 0. هناك حل آخر لا يحتاج إلى الشيفرة التي تنفذ الرسم كي يدرك تغير المقياس، وهو تعديل المحور الذي يحدث تغيير الحجم حوله، كما يمكن استخدام عدة توابع أخرى غير scale للتأثير في نظام إحداثيات اللوحة، حيث تستطيع تدوير الأشكال المرسومة تاليًا باستخدام التابع rotate ونقلها باستخدام translate، لكن المثير في الأمر والمحير أيضًا هو أنّ تلك التحويلات تُكدَّس، بمعنى أنّ كل واحد يُحدِث نسبةً إلى ما قبله من تحولات، وبناءً عليه فإذا استخدمنا translate لتحريك 10 بكسلات مرتين أفقيًأ، فسيُرسم كل شيء مزاحًا إلى اليمين بمقدار 20 بكسل؛ أما إذا أزحنا مركز نظام الإحداثيات أولًا إلى (50,50) ثم دوّرنا بزاوية 20 درجة -أي 0.1π راديان-، فسيَحدث التدوير حول النقطة (50,50). لكن إذا نفذّنا التدوير بمقدار عشرين درجة أولًا ثم أزحنا بمقدار (50,50)، فسيحدث الإزاحة عند نظام الإحداثيات المدوَّر، وعليه سيعطينا اتجاهًا مختلفًا، ونستنتج من هذا أنّ ترتيب تطبيق التحويلات مهم. وتعكس الشيفرة التالية الصورة حول الخط العمودي عند الموضع x: function flipHorizontally(context, around) { context.translate(around, 0); context.scale(-1, 1); context.translate(-around, 0); } ننقل المحور y إلى حيث نريد لمرآتنا أن تكون ونطبِّق المرآة، ثم نعيد المحور مرةً أخرى إلى موضعه المناسب في العالم المعكوس، وتوضِّح الصورة التالية ذلك: توضِّح الصورة نظام الإحداثيات قبل وبعد الانعكاس على طول الخط المركزي وتُرقَّم المثلثات لتوضيح كل خطوة، فإذا رسمنا مثلثًا عند الموضع x الموجب، فسيكون حيث يكون المثلث 1، إذ نستدعي flipHorizontally لينفِّذ الإزاحة إلى اليمين أولًا لنصل إلى المثلث 2، ثم يغيِّر الحجم ويعكس المثلث إلى الموضع 3، غير أنه لا يُفترض أن يكون هناك إذا عُكِس في الخط المعطى، فيأتي استدعاء translate الثاني ليصلح ذلك، بحيث يلغي الإزاحة الأولى ويُظهِر المثلث 4 في الموضع الذي يُفترض أن يكون فيه تمامًا، ونستطيع الآن رسم الشخصية المعكوسة في الموضع (100,0) من خلال عكس العالم حول المركز العمودي للشخصية. <canvas></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let img = document.createElement("img"); img.src = "img/player.png"; let spriteW = 24, spriteH = 30; img.addEventListener("load", () => { flipHorizontally(cx, 100 + spriteW / 2); cx.drawImage(img, 0, 0, spriteW, spriteH, 100, 0, spriteW, spriteH); }); </script> تخزين التحويلات ومحوها تظل التحويلات قائمةً حتى بعد رسمنا لتلك الشخصية المعكوسة، إذ سيكون كل شيء نرسمه بعد ذلك معكوسًا أيضًا وقد لا نريد هذا، حيث من الممكن هنا حفظ التحويل الحالي ثم إجراء بعض الرسم والتحويل، وبعدها استعادة التحويل القديم مرةً أخرى، فغالبًا يُعَدّ ما سبق الإجراء الأفضل لإجراء وظيفة دالة ما تحتاج إلى تحويل نظام الإحداثيات لفترة مؤقتة، حيث سنحفظ أيّ تحويل استخدمته الشيفرة التي استدعت الدالة، ثم تضيف الدالة ما تشاء من التحويلات فوق التحويل الحالي، وبعد ذلك نرجع إلى التحول الذي بدأنا به مرةً أخرى. يدير عملية التحول تلك التابعَين save وrestore على سياق اللوحة ثنائية الأبعاد لأنهما يحتفظان بمكدس من حالات التحول، وحين نستدعي save، فستُدفَع الحالة الراهنة إلى المكدس، ثم تؤخَذ الحالة التي على قمة المكدس مرةً أخرى عند استدعاء restore وتُستخدم على أنها التحول الحالي للسياق، كما نستطيع كذلك استدعاء resetTransform من أجل إعادة ضبط التحول بالكامل. توضِّح الدالة branch في المثال التالي ما يمكن فعله بدالة تغير التحول، ثم تستدعي دالةً -هي نفسها في هذه الحالة- تتابع الرسم بالتحول المعطى، حيث ترسم تلك الدالة شكلًا يشبه الشجرة من خلال رسم خط ثم نقل مركز نظام الإحداثيات إلى نهاية ذلك الخط، ثم استدعاء نفسها مرتين، مرةً مدارةً إلى اليسار، ثم مرةً أخرى مدارة إلى اليمين، إذ يقلل كل استدعاء من طول الفرع المرسوم ثم يتوقف التعاود حين يقل الطول عن 8. <canvas width="600" height="300"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); function branch(length, angle, scale) { cx.fillRect(0, 0, 1, length); if (length < 8) return; cx.save(); cx.translate(0, length); cx.rotate(-angle); branch(length * scale, angle, scale); cx.rotate(2 * angle); branch(length * scale, angle, scale); cx.restore(); } cx.translate(300, 0); branch(60, 0.5, 0.8); </script> إذا لم توجد استدعاءات إلى save وrestore فسيكون موضع ودران الاستدعاء الذاتي للمرة الثانية للدالة branch هما اللذان أُنشئا بالاستدعاء الأول، إذ لن تتصل بالفرع الحالي وإنما بالفرع الداخلي الأقصى إلى اليمين والمرسوم بالاستدعاء الأول، وعلى ذلك يكون الشكل الناتج ليس شجرةً أبدًا. عودة إلى اللعبة عرفنا الآن ما يكفي عن الرسم على اللوحة وسنعمل الآن على نظام عرض مبني على لوحة من أجل اللعبة التي من المقال السابق، إذ لم تعُد الشاشة الجديدة تعرض صناديق ملونة فحسب، وإنما سنستخدِم drawImage من أجل رسم صور تمثِّل عناصر اللعبة، كما سنعرِّف كائن عرض جديد يدعى CanvasDisplay ويدعم الواجهة نفسها مثل DOMDisplay من المقال السابق خاصةً التابعَين syncState وclear، حيث سيحتفظ هذا الكائن بمعلومات أكثر قليلًا من DOMDisplay، فبدلًا من استخدام موضع التمرير لعنصر DOM الخاص به، فهو يتتبع نافذة رؤيته التي تخبرنا بالجزء الذي ننظر إليه في المستوى، ثم يحتفظ بالخاصية flipPlayer التي تمكّن اللاعب من مواجهة الاتجاه الذي كان يتحرك فيه حتى لو كان واقفًا لا يتحرك. class CanvasDisplay { constructor(parent, level) { this.canvas = document.createElement("canvas"); this.canvas.width = Math.min(600, level.width * scale); this.canvas.height = Math.min(450, level.height * scale); parent.appendChild(this.canvas); this.cx = this.canvas.getContext("2d"); this.flipPlayer = false; this.viewport = { left: 0, top: 0, width: this.canvas.width / scale, height: this.canvas.height / scale }; } clear() { this.canvas.remove(); } } يحسب التابع syncState أولًا نافذة رؤية جديدة ثم يرسم مشهد اللعبة عند الموضع المناسب. CanvasDisplay.prototype.syncState = function(state) { this.updateViewport(state); this.clearDisplay(state.status); this.drawBackground(state.level); this.drawActors(state.actors); }; سيكون على هذا التنسيق من العرض إعادة رسم الخلفية عند كل تحديث على عكس DOMDisplay، ولأن الأشكال التي على اللوحة ما هي إلا بكسلات، فليس لدينا طريقةً لتحريكها أو حتى حذفها بعد رسمها، والطريقة الوحيدة لدينا لتحديث شاشة اللوحة هي مسحها ثم إعادة رسم المشهد.، وقد يحدث أن نكون قد مرّرنا نافذة الرؤية من الشاشة، وهذا يتطلب أن تكون الخلفية في موضع مختلف. يتحقق التابع updateViewport إذا كان اللاعب قريبًا للغاية من حافة الشاشة أم لا، وإذا كان كذلك فسينقل نافذة الرؤية، وهو في هذا يشبه التابع scrollPlayerIntoView الخاص بـ DOMDisplay. CanvasDisplay.prototype.updateViewport = function(state) { let view = this.viewport, margin = view.width / 3; let player = state.player; let center = player.pos.plus(player.size.times(0.5)); if (center.x < view.left + margin) { view.left = Math.max(center.x - margin, 0); } else if (center.x > view.left + view.width - margin) { view.left = Math.min(center.x + margin - view.width, state.level.width - view.width); } if (center.y < view.top + margin) { view.top = Math.max(center.y - margin, 0); } else if (center.y > view.top + view.height - margin) { view.top = Math.min(center.y + margin - view.height, state.level.height - view.height); } }; تتأكد الاستدعاءات إلى Math.max وMath.min من أنّ نافذة الرؤية لا تعرض مساحةً خارج المستوى، حيث تضمن ‎Math.max(x, 0)‎ أنّ العدد الناتج ليس أقل من صفر، كما تضمن Math.min بقاء القيمة تحت الحد المعطى، وسنستخدم عند مسح الشاشة لونًا مختلفًا وفقًا لحالة اللعب إذا فازت أو خسرت، بحيث يكون لونًا فاتحًا في حالة الفوز، وداكنًا في الخسارة. CanvasDisplay.prototype.clearDisplay = function(status) { if (status == "won") { this.cx.fillStyle = "rgb(68, 191, 255)"; } else if (status == "lost") { this.cx.fillStyle = "rgb(44, 136, 214)"; } else { this.cx.fillStyle = "rgb(52, 166, 251)"; } this.cx.fillRect(0, 0, this.canvas.width, this.canvas.height); }; نمر على المربعات المرئية في نافذة الرؤية الحالية من أجل رسم الخلفية باستخدام الطريقة نفسها التي اتبعناها في التابع touches في المقال السابق. let otherSprites = document.createElement("img"); otherSprites.src = "img/sprites.png"; CanvasDisplay.prototype.drawBackground = function(level) { let {left, top, width, height} = this.viewport; let xStart = Math.floor(left); let xEnd = Math.ceil(left + width); let yStart = Math.floor(top); let yEnd = Math.ceil(top + height); for (let y = yStart; y < yEnd; y++) { for (let x = xStart; x < xEnd; x++) { let tile = level.rows[y][x]; if (tile == "empty") continue; let screenX = (x - left) * scale; let screenY = (y - top) * scale; let tileX = tile == "lava" ? scale : 0; this.cx.drawImage(otherSprites, tileX, 0, scale, scale, screenX, screenY, scale, scale); } } }; ستُرسم المربعات غير الفارغة باستخدام drawImage، وتحتوي الصورة otherSprites على الصور المستخدَمة للعناصر سوى اللاعب، فهي تحتوي من اليسار إلى اليمين على مربع الحائط ومربع الحمم البركانية وعفريت للعملة. تكون مربعات الخلفية بقياس 20*20 بكسل بما أننا سنستخدم نفس المقياس الذي استخدمناه في DOMDisplay، وعلى ذلك ستكون إزاحة مربعات الحمم البركانية هي 20 -وهي قيمة رابطة scale-؛ أما إزاحة الجدران فستكون صفرًا، كما لن ننتظر تحميل عفريت الصورة لأنّ استدعاء drawImage بصورة لم تحمَّل بعد فلن يُحدث شيئًا، وبالتالي فقد نفشل في رسم اللعبة في أول بضعة إطارات أثناء تحميل تلك الصورة لكنها ليست تلك بالمشكلة الكبيرة، وسيظهر المشهد الصحيح بمجرد انتهاء التحميل بما أننا نظل نحدث الشاشة. ستُستخدم الشخصية الماشية التي رأيناها سابقًا لتمثل اللاعب، ويجب على الشيفرة التي ترسمها اختيار العفريت الصحيح والاتجاه الصحيح كذلك لحركة اللاعب الآنية، إذ تحتوي أول ثمانية عفاريت على تأثير المشي، كما نكرر تلك العناصر عند مشي اللاعب على الأرضية وفقًا للزمن الحالي، وبما أننا نبدِّل الإطارات كل 60 ميلي ثانية، فسنقسم الوقت على 60 أولًا؛ أما حين يقف اللاعب ساكنًا فسنرسم العفريت التاسع، ونستخدِم العنصر العاشر من أقصى اليمين من أجل رسم تأثير القفز الذي نعرفه من خلال كون السرعة العمودية لا تساوي صفرًا. يجب على التابع تعديل إحداثيات x والعرض بمقدار معطى (playerXOverlap) لمعادلة عرض العفاريت بما أنها أعرض من كائن اللاعب -24 بكسل بدلًا من 16- لتسمح ببعض المساحة للأذرع والأقدام. let playerSprites = document.createElement("img"); playerSprites.src = "img/player.png"; const playerXOverlap = 4; CanvasDisplay.prototype.drawPlayer = function(player, x, y, width, height){ width += playerXOverlap * 2; x -= playerXOverlap; if (player.speed.x != 0) { this.flipPlayer = player.speed.x < 0; } let tile = 8; if (player.speed.y != 0) { tile = 9; } else if (player.speed.x != 0) { tile = Math.floor(Date.now() / 60) % 8; } this.cx.save(); if (this.flipPlayer) { flipHorizontally(this.cx, x + width / 2); } let tileX = tile * width; this.cx.drawImage(playerSprites, tileX, 0, width, height, x, y, width, height); this.cx.restore(); }; يُستدعى التابع drawPlayer بواسطة drawActors التي تكون مسؤولةً عن رسم جميع الكائنات الفاعلة في اللعبة. CanvasDisplay.prototype.drawActors = function(actors) { for (let actor of actors) { let width = actor.size.x * scale; let height = actor.size.y * scale; let x = (actor.pos.x - this.viewport.left) * scale; let y = (actor.pos.y - this.viewport.top) * scale; if (actor.type == "player") { this.drawPlayer(actor, x, y, width, height); } else { let tileX = (actor.type == "coin" ? 2 : 1) * scale; this.cx.drawImage(otherSprites, tileX, 0, width, height, x, y, width, height); } } }; عند رسم أيّ شيء غير اللاعب فإننا ننظر أولًا في نوعه لنعرف إزاحة العفريت الصحيح، فمربع الحمم إزاحته 20، اما عفريت العملة فإزاحته 40، أي أنه ضعف مقدار scale، كما يجب طرح موضع نافذة الرؤية على لوحتنا لتتوافق مع أعلى يسار نافذة الرؤية وليس أعلى يسار المستوى، كما يمكن استخدام translate كذلك، فكلاهما يصلح. يركِّب المستند التالي الشاشة الجديدة بـ runGame: <body> <script> runGame(GAME_LEVELS, CanvasDisplay); </script> </body> اختيار واجهة الرسوميات لدينا عدة خيارات يمكن استخدامها لتوليد الرسوميات في المتصفح من HTML إلى SVG إلى اللوحة، وليس هناك واحد يفضُلها جميعًا في كل حالة، فكل واحد له نقاط قوته وضعفه، فلغة HTML مثلًا تمتاز بالبساطة، كما تتكامل جيدًا مع النصوص، بينما تسمح لك كل من SVG واللوحة برسم النصوص، لكنها لن تمكّنك من موضعة تلك النصوص أو تغليفها إذا أخذت أكثر من سطر واحد؛ أما في الصور المبنية على HTML فسيكون من السهل إدراج كتل نصية فيها. يمكن استخدام SVG من ناحية أخرى لإنتاج رسوميات واضحة مهما كان مستوى التكبير، فهي مصممة للرسم على عكس HTML، وعليه فهي ملائمة أكثر لهذا الغرض، كذلك تستطيع كل من SVG وHTML بناء هيكل بيانات مثل شجرة DOM تمثل صورتنا، وهذا يمكِّننا من تعديل العناصر بعد رسمها، لذا سيكون من الصعب استخدام اللوحة من أجل تغيير جزء صغير في صورة كبيرة كل حين للاستجابة لأفعال المستخدِم أو بسبب تحريك ما، كما يسمح DOM لنا بتسجيل معالجات أحداث الفأرة على كل عنصر في الصورة حتى الأشكال المرسومة باستخدام SVG؛ أما اللوحة فلا يمكن فعل ذلك فيها. غير أنه يمكن استخدام نهج المنظور البكسلي pixel-oriented للوحة عند رسم أعداد كبيرة جدًا من عناصر صغيرة، إذ تكون تكلفة الشكل الواحد فيها تافهةً بما أنها لا تبني هياكل بيانات وإنما تكرِّر الرسم على مساحة البكسل نفسها، كما يمكن تنفيذ تأثيرات مثل إخراج مشهد بسرعة بكسل واحد في كل مرة -باستخدام متعقب أشعة ray tracer مثلًا-، أو معالجة لاحقة لصورة باستخدام جافاسكربت مثل تأثير الضباب أو التشويش، وذلك لا يمكن معالجته بواقعية إلا من المنظور البكسلي. قد نرغب أحيانًا في جمع بعض تلك التقنيات معًا، فقد نرسم مخططًا باستخدام SVG أو اللوحة، لكن نُظهر المعلومات النصية عن طريق وضع عنصر HTML فوق الصورة؛ أما بالنسبة للتطبيقات التي لا تتطلب موارد كثيرة، فليس من المهم أيّ واجهة نختارها، إذ يمكن استخدام العرض الذي بنيناه في هذا المقال من أجل لعبتنا، كان يمكن تنفيذه باستخدام أي من التقنيات الرسومية الثلاثة بما أنه لا يحتاج إلى رسم نصوص أو معالجة تفاعلات للفأرة أو التعامل مع عدد ضخم من العناصر. خاتمة لقد ناقشنا في هذا المقال تقنيات رسم التصاميم المرئية والرسوميات في المتصفح مع تناول عنصر <canvas> بالتفصيل، كما عرفنا أنّ عقدة اللوحة تمثِّل مساحةً في مستند قد يرسم برنامجنا عليها، وينفَّذ هذا الرسم من خلال رسم كائن سياقي ينشئه التابع getContext، كما تسمح لنا واجهة الرسم ثنائية الأبعاد بملء وتخطيط أشكال كثيرة، وتحدد خاصية السياق fillStyle كيفية ملء الأشكال، في حين تتحكم الخاصيتان strokeStyle وlineWidth في طريقة رسم الخطوط. تُرسم المستطيلات وأجزاء النصوص باستدعاء تابع واحد، حيث يرسم التابعان fillRect وstrokeRect مستطيلات، بينما يرسم كل من fillText وstrokeText نصوصًا؛ أما إذا أردنا إنشاء أشكال فيجب علينا بناء مسار أولًا، كما ينشئ استدعاء beginPath مسارًا جديدًا، كما يمكن إضافة خطوط ومنحنيات إلى المسار الحالي باستخدام عدة توابع أخرى، حيث يضيف التابع lineTo مثلًا خطًا مستقيمًا، وإذا انتهى المسار، فيمكن استخدام التابع fill لملئه أو التابع stroke لتحديده. تُنقل البكسلات من صورة أو لوحة أخرى إلى لوحتنا باستخدام التابع drawImage، حيث يرسم هذا التابع الصورة المصدرية كلها افتراضيًا، لكن يمكن إعطاؤه معاملات إضافية من أجل نَسخ جزء محدد من الصورة، وقد استخدَمنا ذلك في لعبتنا بنسخ أوضاع شخصية اللاعب المختلفة من صورة تحتوي على عدة أوضاع. تسمح لنا التحولات برسم شكل في اتجاهات مختلفة، فسياق الرسم ثنائي الأبعاد به تحول راهن يمكن تغييره باستخدام التوابع translate وscale وrotate، إذ ستؤثِّر على جميع عمليات الرسم اللاحقة، كما يمكن حفظ حالة التحول باستخدام التابع save واستعادتها باستخدام التابع restore، وأخيرًا يُستخدَم التابع clearRect عند عرض تحريك على اللوحة من أجل مسح جزء من اللوحة قبل إعادة رسمه. تدريبات الأشكال اكتب برنامجًا يرسم الأشكال التالية على لوحة: شبه منحرف وهو مستطيل أحد جوانبه المتوازية أطول من الآخر. ماسة حمراء وهي مستطيل مُدار بزاوية 45 درجة مئوية، أو ¼π راديان. خط متعرِّج Zigzag. شكل حلزوني من 100 جزء من خطوط مستقيمة. نجمة صفراء. ربما تود الرجوع إلى شرح كل من Math.cos وMath.sin في مقال نموذج كائن المستند في جافاسكريبت عند رسم آخر شكلين لتعرف كيف تحصل على إحداثيات على دائرة باستخدام هاتين الدالتين، كما ننصحك بإنشاء دالة لكل شكل وتمرير الموضع إليها و-إذا أردت- مرر إليها الخصائص الأخرى مثل الحجم وعدد النقاط على أساس معاملات، كما يوجد حل بديل بأن تكتب أرقامًا ثابتةً في شيفرتك مما سيجعل الشيفرة أصعب في القراءة والتعديل. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <canvas width="600" height="200"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); // شيفرتك هنا. </script> إرشادات الحل يمكن رسم شبه المنحرف ببساطة باستخدام مسار، لذا اختر إحداثيات مركزية مناسبة وأضف كل ركن من الأركان الأربعة حول المركز؛ أما الماسة فيمكن رسمها بطريقة بسيطة باستخدام مسار أو باستخدام تحول rotate، وإذا أردت تجربة التحول فسيكون عليك تطبيق طريقة تشبه ما فعلنا في دالة flipHorizontally، كما يجب أن تنتقل translate إلى مركز المستطيل بدلًا من الإحداثي الصفري بما أنك ستدور حول مركزه، ثم تدور ثم تُعاد إلى مكانها مرةً أخرى، وتأكد من إعادة ضبط التحول بعد رسم أيّ شكل ينشئ تحولًا. أما بالنسبة للخط المتعرِّج فليس من المنطقي كتابة استدعاء جديد إلى lineTo في كل جزء من أجزاء الخط، وإنما يجب استخدام حلقة تكرارية، بحيث تجعل كل تكرار فيها يرسم جزأين -اليمين ثم اليسار مرةً أخرى- أو جزءًا واحدًا من أجل تحديد اتجاه الذهاب إلى اليمين أم اليسار والذي يكون بالاعتماد على عامل حالة من فهرس الحلقة i (مثل إذا كان i % 2 == 0 اذهب لليسار وإلا، لليمين)، كما ستحتاج إلى حلقة تكرارية من أجل الشكل الحلزوني، فإذا رسمت سلسلةً من النقاط تتحرك فيها كل نقطة إلى الخارج على دائرة حول مركز الشكل فستحصل على دائرة؛ أما إذا استخدمت الحلقة التكرارية وغيرت نصف قطر الدائرة التي تضع النقطة الحالية عليها ونفّذت عدة حركات فستحصل على شكل حلزوني. تُبنى النجمة المصورة من خطوط quadraticCurveTo، لكن يمكن رسمها باستخدام الخطوط المستقيمة أيضًا من خلال تقسيم دائرة إلى ثمانية أجزاء إذا أردت رسم نجمة بثمانية نقاط، أو إلى أي عدد من الأجزاء تريد، ثم ارسم خطوطًا بين تلك النقاط لتجعلها تنحني نحو مركز النجمة؛ أما إذا استخدمت quadraticCurveTo، فستستطيع جعل المركز على أساس نقطة تحكم. المخطط الدائري رأينا في هذا المقال مثالًا لبرنامج يرسم مخططًا دائريًا، عدِّل هذا البرنامج ليظهر اسم كل تصنيف بجانب الشريحة التي تمثله، وحاول إيجاد طريقة مناسبة مرئيًا لموضعة ذلك النص تلقائيًا بحيث تصلح لأنواع البيانات الأخرى أيضًا، كما تستطيع الافتراض أنّ التصانيف كبيرة بحيث تترك مساحةً كافيةً لعناوينها، وقد تحتاج إلى دالتي Math.sin وMath.cos مرةً أخرى من مقال نموذج كائن المستند في جافاسكريبت. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <canvas width="600" height="300"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let total = results .reduce((sum, {count}) => sum + count, 0); let currentAngle = -0.5 * Math.PI; let centerX = 300, centerY = 150; // Add code to draw the slice labels in this loop. for (let result of results) { let sliceAngle = (result.count / total) * 2 * Math.PI; cx.beginPath(); cx.arc(centerX, centerY, 100, currentAngle, currentAngle + sliceAngle); currentAngle += sliceAngle; cx.lineTo(centerX, centerY); cx.fillStyle = result.color; cx.fill(); } </script> إرشادات الحل ستحتاج إلى استدعاء fillText وضبط خصائص السياق textAlign وtextBaseline بطريقة تجعل النص يكون حيث تريد، وستكون الطريقة المناسبة لموضعة العناوين هي وضع النصوص على الخطوط الذاهبة من مركز المخطط إلى منتصف الشريحة، كما لا تريد وضع النصوص مباشرةً على جانب المخطط على بعد مقدار ما من البكسلات، وتكون زاوية هذا الخط هي currentAngle + 0.5 * sliceAngle، كما تبحث الشيفرة التالية عن موضع عليه بحيث يكون على بعد 120 بكسل من المركز: let middleAngle = currentAngle + 0.5 * sliceAngle; let textX = Math.cos(middleAngle) * 120 + centerX; let textY = Math.sin(middleAngle) * 120 + centerY; أما بالنسبة لـ textBaseline، فإنّ القيمة "middle" مناسبة عند استخدام ذلك المنظور، إذ يعتمد ما تستخدِمه لـ textAlign على الجانب الذي تكون فيه من الدائرة، فإذا كنت على اليسار، فيجب أن تكون "right" والعكس بالعكس، وذلك كي يكون موضع النص بعيدًا عن الدائرة. إذا لم تعرف كيف تجد الجانب الذي عليه زاوية ما من الدائرة، فانظر في شرح Math.cos في نموذج كائن المستند في جافاسكريبت، إذ يخبرك جيب التمام cosine لتلك الدالة بالإحداثي x الموافق لها، والذي يخبرنا بدوره على أي جانب من الدائرة نحن. الكرة المرتدة استخدام تقنية requestAnimationFrame التي رأيناها في مقال نموذج كائن المستند في جافاسكريبت والمقال السابق من أجل رسم صندوق فيه كرة مرتدة تتحرك بسرعة ثابتة وترتد عن جوانب الصندوق عندما تصطدم بها. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <canvas width="400" height="400"></canvas> <script> let cx = document.querySelector("canvas").getContext("2d"); let lastTime = null; function frame(time) { if (lastTime != null) { updateAnimation(Math.min(100, time - lastTime) / 1000); } lastTime = time; requestAnimationFrame(frame); } requestAnimationFrame(frame); function updateAnimation(step) { // شيفرتك هنا. } </script> إرشادات الحل يسهل رسم الصندوق باستخدام strokeRect، لذا عرِّف رابطةً تحمل حجمه أو عرِّف رابطتَين إذا كان عرض الصندوق يختلف عن طوله؛ أما لإنشاء كرة مستديرة، فابدأ مسارًا واستدعي ‎arc(x, y, radius, 0, 7)‎ الذي ينشئ قوسًا من الصفر إلى أكثر من دائرة كاملة ثم املأ المسار. استخدِم الصنف Vec من المقال السابق ليصف نموذج سرعة الكرة وموضعها، وأعطه سرعةً ابتدائيةً يفضَّل أن تكون عموديةً فقط أو أفقيةً فقط، ثم اضرب تلك السرعة بمقدار الوقت المنقضي لكل إطار، وحين تقترب الكرة جدًا من حائط عمودي، اعكس المكوِّن x في سرعتها، وبالمثل اعكس المكوِّن y إذا اصطدمت بحائط أفقي، وبعد إيجاد موضع الكرة وسرعتها الجديدين، استخدم clearRect لحذف المشهد وإعادة رسمه باستخدام الموضع الجديد. الانعكاس المحسوب مسبقا إنّ أحد عيوب التحولات أنها تبطئ عملية الرسومات النقطية bitmaps، إذ يجب تحويل موضع وحجم كل بكسل، ويتسبب ذلك في زيادة كبيرة في وقت الرسم في المتصفحات، كما لا تمثِّل تلك مشكلةً في لعبتنا التي نرسم فيها عفريتًا واحدًا؛ أما رسم مئات الشخصيات أو آلاف الجزيئات التي تدور في الهواء نتيجة انفجار مثلًا، فستكون تلك معضلةً حقيقيةً. فكِّر في طريقة تسمح برسم شخصية معكوسة دون تحميل ملفات صور إضافية، ودون الحاجة إلى إنشاء استدعاءات drawImage متحولة لكل إطار. إرشادات الحل تستطيع استخدام عنصر اللوحة على أساس صورة مصدرية عند استخدام drawImage، حيث يمكن إنشاء عنصر <canvas> آخر دون إضافته إلى المستند وسحب عناصر العفاريت المعكوسة إليه مرةً واحدةً، وعند رسم إطار حقيقي ننسخ تلك المعكوسة إلى اللوحة الأساسية، لكن يجب توخي الحذر لأن الصور لا تحمَّل فورًا، فنحن ننفِّذ الرسم المعكوس مرةً واحدةً فقط، وإذا نفَّذناه قبل تحميل الصورة، فلن ترسم أي شيء، كما يمكن استخدام معالج "load" الذي على الصورة على أساس مصدر رسم مباشرةً، وسيكون فارغًا إلى أن نرسم الشخصية عليه. ترجمة -بتصرف- للفصل السابع عشر من كتاب Elequent Javascript لصاحبه Marijn Haverbeke. اقرأ أيضًا مشروع بناء لغة برمجة خاصة مشروع تطبيقي لبناء رجل آلي (روبوت) عبر جافاسكريبت
  18. لا شك أن التعامل مع النصوص هو أكثر ما يفعله المبرمج في عمله لأنه يبرمج بلغات تتكون من كلمات ورموز، وهي نصوص في النهاية، لذلك توجد أدوات كثيرة جدًا في أغلب لغات البرمجة لتسهيل التعامل مع هذه النصوص، وسننظر في كيفية استخدام تلك الأدوات في تنفيذ المهام البرمجية المعتادة التي تشمل ما يلي: تقسيم الأسطر إلى مجموعات من المحارف. البحث عن سلاسل نصية داخل سلاسل أخرى. استبدال سلسلة نصية بأخرى. تغيير حالة الأحرف. سننظر في كيفية تنفيذ كل مهمة من هذه المهام باستخدام بايثون، ثم نمر عليها سريعًا في جافاسكربت وVBScript، وتُستخدم توابع السلاسل النصية في بايثون للتعامل مع السلاسل النصية، فلعلك تذكر من مقال البيانات وأنواعها أن التوابع تشبه الدوال المرتبطة ببيانات، ونستطيع الوصول إلى التوابع باستخدام نفس الترميز النقطي dot notation الذي نستخدمه للوصول إلى الدوال في الوحدات modules، لكننا سنستخدم البيانات نفسها بدلًا من استخدام اسم الوحدة. لننظر الآن في ذلك. تقسيم السلاسل النصية أولًا سنقسم سلسلةً نصيةً إلى عدة أجزاء، والذي نحتاج إليه عند معالجة الملفات لأننا نقرأ الملف سطرًا سطرًا، لكن قد يحتوي جزء من السطر فقط على البيانات المطلوبة. من أمثلة ذلك برنامج دليل جهات الاتصال الذي نعود إليه كل حين، فقد نرغب في الوصول إلى حقول بعينها من مدخل ما، دون الحاجة لطباعة المدخل كله، وسنستخدم لهذا الغرض التابع split()‎ في بايثون كما يلي: >>> aString = "Here is a (short) String" >>> print( aString.split() ) ['Here', 'is', 'a', '(short)', 'String'] لاحظ كيف حصلنا على قائمة تحتوي الكلمات التي في aString مع حذف جميع المسافات، لأن الفاصل الافتراضي للدالة ‎''.split()‎ هو المسافة البيضاء، سواءً كانت سطرًا جديدًا، أم مسافةً عادية، أم مسافة جدول tab. لنجرب الآن استخدامه مرةً أخرى بجعل الفاصل قوسًا افتتاحيًا: >>> print( aString.split('(') ) ['Here is a ', 'short) String'] يكمن الفرق هنا في أننا حصلنا على عنصرين فقط في القائمة، وقد حُذف القوس الافتتاحي من بداية ‎'short)'‎، وهذه ملاحظة مهمة حول ‎''.split()‎، وهي حذفه للمحارف الفاصلة، الذي نريده غالبًا مع استثناءات قليلة. كذلك لدينا التابع ‎''.join()‎ الذي يأخذ قائمةً -أو أي نوع آخر- من التسلسلات النصية ويدمجها معًا، لكن له خاصية قد تسبب حيرةً عند استخدامه، وهو استخدامه السلسلة التي نستدعي التابع عليها محرفًا للدمج، كما يلي: >>> lst = ['here','is','a','list','of','words'] >>> print( '-+-'.join(lst) ) here-+-is-+-a-+-list-+-of-+-words >>> print( ' '.join(lst) ) here is a list of words رغم منطقية هذا السلوك، إلا أنه يبدو غريبًا عند رؤيته لأول مرة، كما أنه سلوك مناقض لما هو موجود في جافاسكربت التي تحوي تابع مصفوفة اسمه join، وتكون السلسلة الدامجة فيه معامِلًا. عد الكلمات سنعيد النظر مرةً أخرى في برنامج عد الكلمات الذي أوردناه في مقال البرمجة باستخدام الوحدات، الذي كانت الشيفرة الوهمية فيه كما يلي: def numwords(aString): list = split(aString) # قائمة بكل عنصر وكلمة return len(list) # تعيد عددًا من العناصر في القائمة for line in file: total = total + numwords(line) # accumulate totals for each line print( "File had %d words" % total ) لننظر إلى متن دالة numwords()‎ بما أننا شرحنا كيفية جلب الأسطر من الملف، حيث نريد أن ننشئ قائمةً من الكلمات في سطر، وذلك باستخدام التابع ‎''.split()‎ الافتراضي. وإذا نظرنا في توثيق بايثون فسنجد أن دالة len()‎ المضمَّنة تعيد عدد العناصر في قائمة ما، ويجب أن يكون ذلك العدد في حالتنا عدد الكلمات في السلسلة النصية، وهو ما نريده بالضبط، وعلى ذلك تبدو الشيفرة النهائية كما يلي: def numwords(aString): lst = aString.split() # split() aString هو تابع كائن السلسلة return len(lst) # أعد عدد العناصر في القائمة with open("menu.txt","r") as inp: total = 0 # initialize to zero; also creates variable for line in inp: total += numwords(line) # راكم إجمالي كل سطر print( "File had %d words" % total ) لكن هذه الشيفرة تحسب محارفًا مثل & على أنها كلمات، وهذا ليس صحيحًا، كما أنه لا يمكن استخدامها إلا على ملف واحد فقط هو menu.txt، رغم إمكانية تحويلها لتقرأ اسم الملف من سطر الأوامر argv[1]‎، أو عبر input()‎ كما رأينا في مقال كيفية قراءة البرامج لمدخلات المستخدم، وسنترك هذا تدريبًا للقارئ. البحث في النصوص ستكون العملية التالية التي ننظر فيها هي البحث عن سلسلة فرعية داخل سلسلة أكبر منها، وتدعم بايثون هذا من خلال تابع السلسلة ‎''.find()‎ الخاص بها، وأبسط استخدامات هذا التابع تزويده بسلسلة للبحث، وتعيد بايثون فهرس أول محرف من السلسلة الفرعية إذا وجدتها داخل السلسلة الرئيسية، أما إذا لم تجدها فستعيد ‎-1: >>> aString = "here is a long string with a substring inside it" >>> print( aString.find('long') ) 10 >>> print( aString.find('oxen') ) -1 >>> print( aString.find('string') ) 15 لقد كان المثالان الأولان واضحين ومباشرين، فالأول يعيد فهرس بداية ‎'long'‎، أما الثاني فيعيد -1، وذلك لأن ‎'oxen'‎ غير موجودة داخل aString؛ بينما المثال الثالث ففيه أمر مثير للاهتمام، إذ لا تحدد find إلا الورود الأول لسلسلة البحث فقط، لكن ماذا لو كانت سلسلة البحث مكررةً أكثر من مرة في السلسلة الأصلية؟ من الممكن هنا أن نستخدم فهرس المرة الأولى لورود سلسلة البحث لنقسم السلسلة الأصلية إلى جزأين ونبحث مرةً أخرى، ونكرر ذلك إلى أن نحصل على النتيجة ‎-1، كما يلي: aString = "Bow wow says the dog, how many ow's are in this string?" temp = aString[:] # استخدم التقسيم لصنع نسخة count = 0 index = temp.find('ow') while index != -1: count += 1 temp = temp[index + 1:] # استخدم التقسيم هنا. index = temp.find('ow') print( "We found %d occurrences of 'ow' in %s" % (count, aString) ) وهنا نكون قد عددنا مرات الحدوث فقط، لكن كان بإمكاننا جمع نتائج الفهرس في قائمة من أجل المعالجة لاحقًا. يستطيع التابع find()‎ أن يسرّع من هذه العملية قليلًا باستخدام أحد المعامِلات الاختيارية الخاصة به، وهو موضع البداية في السلسلة الأصلية: aString = "Bow wow says the dog, how many ow's are in this string?" count = 0 index = aString.find('ow') # استخدم البدء الافتراضي while index != -1: count += 1 index = aString.find('ow', index+1) # اضبط بدءًا جديدًا print( "We found %d occurrences of 'ow' in %s" % (count, aString) ) يلغي هذا الحل الحاجة إلى إنشاء سلسلة نصية جديدة في كل مرة، وهو الأمر الذي قد يبطئ العملية إذا كانت السلسلة طويلةً، وإذا علمنا أن السلسلة الفرعية ستكون في بداية السلسلة الأصلية، أو لم نهتم لمرات الحدوث الأخرى، فمن الممكن أن نحدد قيمتي بداية ونهاية البحث، كما يلي: >>> # قصر البحث على أول 20 محرف >>> aString = "Bow wow says the dog, how many ow's are in the string?" >>> print( aString.find('the',0,20) ) توفر بايثون عدة توابع أخرى لمواقف البحث الشائعة، مثل ‎''.startswith()‎ و‎''.endswith()‎، ونستطيع أن نخمن وظائف هذه التوابع بمجرد قراءة أسمائها، وهي تعيد إما True أو False بناءً على بدء السلسلة بسلسلة نصية معطاة أو انتهائها بها، كما يلي: >>> print( "Python rocks!".startswith("Perl") ) False >>> print( "Python rocks!".startswith('Python') ) True >>> print( "Python rocks!".endswith('sucks!') ) False >>> print( "Python rocks!".endswith('cks!') ) True لاحظ أنها تعطينا نتائج بوليانيةً، حيث سنعلم أين نبحث إذا كانت الإجابة True. كذلك لاحظ أن سلسلة البحث لا يجب أن تكون كلمةً كاملة، بل تكفي سلسلة نصية فرعية. يمكن تزويد موضعي البدء start والانتهاء stop داخل السلسلة النصية، تمامًا مثل ‎''.find()‎، لنتحقق من وجود سلسلة نصية فرعية في أي موضع معطىً داخل السلسلة النصية، ولا تستخدَم هذه الخاصية الأخيرة في البرمجة العملية كثيرًا. كما يمكن استخدام عامل in الخاص ببايثون لإجراء اختبار بسيط للتحقق من وجود سلسلة نصية فرعية في أي مكان داخل سلسلة أخرى: >>> if 'foo' in 'foobar': print( 'True' ) True >>> if 'baz' in 'foobar': print( 'True' ) >>> if 'bar' in 'foobar': print( 'True' ) True استبدال النصوص بما أننا عثرنا على النص الذي نريده، فلنغيره الآن إلى شيء آخر. توفر توابع السلاسل النصية في بايثون لهذا حلًا متمثلًا في التابع ‎''.replace()‎ الذي يأخذ وسيطين، هما سلسلتا البحث والاستبدال، وتكون القيمة المعادة هي السلسلة الجديدة الناتجة عن الاستبدال. >>> aString = "Mary had a little lamb, its fleece was dirty!" >>> print( aString.replace('dirty','white') ) "Mary had a little lamb, its fleece was white!" إن الفرق الأساسي بين ‎''.find()‎ و‎''.replace‎ هو أن الأخير يستبدل جميع مرات الحدوث في سلسلة البحث، وليس المرة الأولى فقط، ويُستخدم الوسيط الاختياري count لتقييد عدد مرات الاستبدال، كما يلي: >>> aString = "Bow wow wow said the little dog" >>> print( aString.replace('ow','ark') ) Bark wark wark said the little dog >>> print( aString.replace('ow','ark',1) ) # واحد فقط Bark wow wow said the little dog من الممكن إجراء عمليات بحث واستبدال معقدة باستخدام ما يسمى بالتعبير النمطي regular expression، لكنها معقدة وسنتحدث عنها في مقال آخر لاحقًا. تغيير حالة الأحرف مما يجب مراعاته في النصوص هو كيفية التحويل من الحالة الصغرى للحروف إلى الحالة الكبرى، وإن كان هذا غير شائع، إلا أن بايثون توفر بعض التوابع المساعدة لذلك على أي حال: >>> print( "MIXed Case".lower() ) mixed case >>> print( "MIXed Case".upper() ) MIXED CASE >>> print( "MIXed Case".swapcase() ) mixED cASE >>> print( "MIXed Case".capitalize() ) Mixed case >>> print( 'MIXed Case'.title() ) Mixed Case >>> print( "TEST".isupper() ) True >>> print( "TEST".islower() ) False لاحظ أن التابع ‎''.capitalize()‎ يغير حالة الأحرف إلى الحالة الكبرى للسلسلة النصية كلها، وليس لكل كلمة فيها؛ أما تغيير حالة كل كلمة فينفذها التابع title()‎. انتبه أيضًا إلى سلوك دالتي الاختبار ‎''.isupper()‎ و‎''.islower()‎، إذ توفر بايثون دوالًا توكيديةً مثل هذه لاختبار السلاسل النصية، منها: ‎''.isdigit()‎ و‎''.isalpha()‎ و‎''.isspace()‎، وتتحقق الدالة الأخيرة من جميع أنواع المسافات البيضاء، لا محارف المسافة العادية فقط. وسنستخدم عدة توابع من هذا النمط، خاصةً في عد الكلمات. التعامل مع النصوص في VBScript تنحدر VBScript من لغة BASIC القديمة، لذا تحتوي على الكثير من دوال معالجة النصوص المدمجة فيها، وقد تجد 20 دالةً أو تابعًا في توثيقها، بالإضافة إلى الدوال الموجودة لمعالجة محارف اليونيكود، مما يعني أننا نستطيع فعل كل ما فعلناه في بايثون باستخدام VBScript، وسنمر عليها سريعًا فيما يلي: تقسيم النصوص نبدأ بدالة split: <script type="text/vbscript"> Dim s Dim lst s = "Here is a string of words" lst = Split(s) ' returns an array MsgBox lst(1) </script> كما في بايثون، نستطيع إضافة قيمة فاصلة إذا أردنا، غير فاصل المسافة البيضاء العادي، كذلك لدينا دالة Join لعكس هذه العملية. البحث عن النصوص واستبدالها نبحث باستخدام InStr، وهو اختصار لـ In String: <script type="text/vbscript"> Dim s,n s = "Here is a long string of text" n = InStr(s, "long") MsgBox "long is found at position: " & CStr(n) </script> القيمة المعادة هنا هي الموضع الذي تبدأ فيه السلسلة الفرعية داخل السلسلة الأصلية، فإذا لم توجد السلسلة الفرعية فستعيد صفرًا، وهذه ليست مشكلةً بما أن VBScript تبدأ فهارسها بواحد وليس صفر، لذا لا يُعَد الصفر هنا فهرسًا صالحًا؛ أما إذا كانت إحدى السلسلتين Null، فستعيد القيمة Null، مما يجعل عملية اختبار شروط الخطأ أكثر صعوبةً وتحتاج إلى عدة اختبارات معًا. يمكن تحديد نطاق فرعي من السلسلة الأصلية للبحث فيه باستخدام قيمة بادئة، كما فعلنا في بايثون، كما يلي: <script type="text/vbscript"> Dim s,n s = "Here is a long string of text" n = InStr(6, s, "long") ' start at position 6 If n = 0 or n = Null Then ' check for errors MsgBox "long was not found" Else MsgBox "long is found at position: " & CStr(n) End If </script> ونستطيع تحديد كون البحث حساسًا لحالة الأحرف أم لا، وهذا غير موجود في بايثون، والوضع الافتراضي للبحث هو أن يكون حساسًا لحالة الأحرف. تستبدَل النصوص باستخدام الدالة Replace كما يلي: <script type="text/vbscript"> Dim s s = "The quick yellow fox jumped over the log" MsgBox Replace(s, "yellow", "brown") </script> ونستطيع توفير وسيط اختياري ليحدد عدد مرات الحدوث في سلسلة البحث التي يجب استبدالها، والوضع الافتراضي هو استبدالها جميعًا، لكن يمكن تحديد موضع بدء كما في InStr أعلاه. تغيير الحالة تغيّر الحالة في VBScript بواسطة UCase وLCase، لكن لا يوجد فيها ما يكافئ التابعين capitalize وtitle الموجودين في بايثون: <script type="text/vbscript"> Dim s s = "MIXed Case" MsgBox LCase(s) MsgBox UCase(s) </script> وهذا ما سنغطيه من معالجة النصوص في VBScript، فإذا أردت المزيد فارجع إلى ملف مساعدة VBScript لترى القائمة الكاملة للدوال. التعامل مع النصوص في جافاسكربت تُعَد جافاسكربت أقل اللغات الثلاث تجهيزًا للتعامل مع النصوص، لكن العمليات الأساسية موجودة إلى حد ما، رغم افتقارها إلى العدد الكبير من التوابع والدوال الموجودة في بايثون وVBScript للتعامل مع النصوص، وهي تعوض ذلك بدعم قوي للتعابير النمطية، حيث تعوض بدائية الدوال الموجودة كثيرًا، لكن على حساب تعقيد التعابير النمطية، وهي تأخذ المنظور كائني التوجه، مثل بايثون في التعامل مع السلاسل النصية، حيث تُنفَّذ المهام باستخدام توابع الصنف String. تقسيم النصوص تقسَّم النصوص في جافاسكربت باستخدام التابع split: <script type="text/javascript"> var aList, aString = "Here is a short string"; aList = aString.split(" "); document.write(aList[1]); </script> لاحظ أن جافاسكربت تتطلب تزويدها بمحرف الفصل، فليس لديها قيمة افتراضية له، والفاصل هنا هو تعبير نمطي، مما يجعل عمليات الفصل المعقدة ممكنةً. وقد ذكرنا سابقًا أن النصوص تُدمج باستخدام تابع المصفوفة Join، لذا فإن عكس عملية التقسيم أعلاه سيكون كما يلي: aList.join(" ") // ادمج عناصر مفصولة بمسافات البحث في النصوص نبحث في النصوص في جافاسكربت باستخدام التابع search()‎: <script type="text/javascript"> var aString = "Round and Round the ragged rock ran a rascal"; document.write( "ragged is at position: " + aString.search("ragged")); </script> وهنا يكون وسيط سلسلة البحث تعبيرًا نمطيًا أيضًا كي نتمكن من إجراء عمليات بحث معقدة، لكن انتبه إلى عدم وجود طريقة لتقييد نطاق السلسلة الأصلية الذي يُبحث فيه، وذلك من خلال تمرير موضع بدء، رغم إمكانية محاكاة ذلك باستخدام التعابير النمطية على أي حال. استبدال النصوص يُستخدم التابع replace()‎ لاستبدال النصوص كما يلي: <script type="text/javascript"> var aString = "Humpty Dumpty sat on a cat"; document.write(aString.replace("cat","wall")); </script> يمكن أن تكون سلسلة البحث تعبيرًا نمطيًا بحيث نرى هنا السلوك المتوقع، إذ تستبدل عملية الاستبدال جميع نسخ سلسلة البحث، ونستطيع القول أنه لا توجد طريقة تقيد عملية الاستبدال في جزء من السلسلة أو مرة حدوث واحدة دون تقسيم السلسلة أولًا ثم إعادة دمجها مرةً أخرى. تغيير الحالة تغيَّر حالة الأحرف في جافاسكربت باستخدام دالتين هما toLowerCase()‎ وtoUpperCase()‎: <script type="text/javascript"> var aString = "This string has Mixed Case"; document.write(aString.toLowerCase()+ "<BR>"); document.write(aString.toUpperCase()+ "<BR>"); </script> تغني بساطة هذين التابعين عن شرحهما، والجدير فقط بالذكر هنا أن جافاسكربت على عكس اللغات الأخرى، توفر دوالًا كثيرةً لمعالجة HTML، لأنها لغة برمجة ويب بالأساس، ويمكن الرجوع إلى تلك الدوال في توثيق اللغة، إذ هي خارج مجال حديثنا. خاتمة مررنا على الدوال والتوابع المستخدمة للتعامل مع النصوص التي قد تراها في مشاريعك، ونود الإشارة هنا إلى وجوب النظر في التوثيق الرسمي للغة التي تعمل بها عند معالجة النصوص، إذ توجد أدوات قوية لمثل هذه العمليات والمهام الضرورية في البرمجة، وننصح بالبحث أولًا في التوثيقات المتوفرة في موسوعة حسوب، وهي توثيقات عربية مترجمة من التوثيقات الرسمية للغات أو من أمهات الكتب فيها، ونود أن تخرج من هذا المقال بما يلي: معالجة النصوص عملية شائعة لها دعم قوي مضمَّن في أغلب اللغات. أكثر المهام شيوعًا هي تقسيم النصوص والبحث فيها، واستبدالها، وتغيير حالة الأحرف فيها. توفر كل لغة مستويات مختلفة من الدعم، لكن العمليات الثلاث الأساسية متاحة دومًا. ترجمة -بتصرف- للفصل الثالث عشر: Manipulating Text من كتابة Learning To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: كيفية التعامل مع الأخطاء البرمجية المقال السابق: التعامل مع الملفات في البرمجة تعلم البرمجة ما هي البرمجة ومتطلبات تعلمها؟
  19. بدأ ولع الكثير من العاملين في مجالات التقنية بالحواسيب منذ الصغر من خلال ألعاب الحاسوب، خاصةً تلك التي فيها عوالم افتراضية يستطيع اللاعب فيها التحكم في سير اللعبة وأحداث القصة التي فيها، ورغم تشابه مجال برمجة الألعاب مع مجال الموسيقى من حيث نسبة العرض إلى الطلب، إلا أن التناقض العجيب بين كمية المبرمجين الصغار الذي يرغبون في العمل فيها ومقدار الطلب الحقيقي على أولئك المبرمجين يخلق بيئة عمل في غاية السوء، لكن بأي حال فذاك لا يمنع أنّ كتابة الألعاب قد تكون مسليةً للبعض. سنتعلم هنا كيفية كتابة لعبة منصة صغيرة، وتُعَدّ لعب المنصات platform games -أو ألعاب "اقفز واركض"- ألعابًا تتوقع من اللاعب تحريك شخصية في عالم افتراضي يكون ثنائي الأبعاد غالبًا، كما يكون منظور اللعبة من الجانب مع القفز على عناصر وكائنات أو القفز خلالها. اللعبة ستبنى لعبتنا بصورة ما على Dark Blue التي كتبها توماس بالِف Thomas Palef، وقد اخترنا هذه اللعبة لأنها مسلية وصغيرة الحجم في نفس الوقت، كما يمكن بناؤها دون كتابة الكثير من الشيفرات وستبدو في النهاية هكذا: يمثَّل اللاعب بالصندوق الداكن الذي تكون مهمته جمع الصناديق الصفراء -أي العملات النقدية- مع تجنب الكائنات الحمراء -أي الحمم البركانية-، ويكتمل المستوى حين تُجمع كل العملات النقدية. يستطيع اللاعب التحرك في اللعبة باستخدام أسهم اليمين واليسار، كما يستطيع القفز باستخدام زر السهم الأعلى، ويكون للقفز هنا ميزةً ليست واقعيةً لكنها تعطي اللاعب إحساس التحكم بالشخصية التي على الشاشة، وهي أن الشخصية تستطيع القفز لمسافة أكبر بعدة مرات من طولها، كما تستطيع تغيير اتجاهها في الهواء. تتكون اللعبة من خلفية ثابتة توضع في هيئة شبكة خلفية، وتكون العناصر المتحركة فوق تلك الخلفية، كما يكون كل حقل في تلك الشبكة إما خاليًا أو مصمتًا أو يحوي حممًا؛ أما العناصر المتحركة فتكون اللاعب أو العملات أو أجزاء من الحمم، كما لا تتقيد مواضع تلك العناصر بالشبكة بل قد تكون إحداثياتها أعدادًا كسْريةً لتسمح لها بالحركة بنعومة. التقنية سنستخدم نموذج كائن المستند DOM الخاص بالمتصفح لعرض اللعبة، وسنقرأ مدخلات المستخدِم من خلال معالجة أحداث المفاتيح. تمثِّل الشيفرة المتعلقة بالشاشة ولوحة المفاتيح جزءًا صغيرًا من العمل الذي علينا تنفيذه لبناء هذه اللعبة، ولن يكون رسم اللعبة صعبًا بما أنها تتكون من صناديق ملونة في الغالب، حيث سننشئ عناصر DOM وسنستخدم التنسيقات لنعطيها لون خلفية وحجمًا وموضعًا أيضًا. تمثَّل الخلفية على أساس جدول بما أنها شبكة مربعات ثابتة؛ أما العناصر التي تتحرك بحرية فتمثَّل باستخدام عناصر مطلقة الموضع absolutely positioned، وبما أننا نريد تمثيل مدخلات اللاعب والتجاوب معها دون تأخير ملحوظ فستكون الكفاءة مهمة هنا، ورغم أن DOM لم يُصمم للرسوميات عالية الأداء، إلا أن أداءه أفضل مما هو متوقع، فقد رأينا بعض ذلك في مقال نموذج كائن المستند في جافاسكريبت، وسيكون أداء مثل هذه اللعبة ممتازًا على حاسوب حديث حتى لو لم نحسِّن أداءها كثيرًا. لكن مع هذا فسننظر في تقنية أخرى للمتصفحات في المقال التالي، وهي وسم <canvas> الذي يوفر طريقةً تقليديةً أكثر لتصميم الرسوميات، إذ يتعامل مع الأشكال والبكسلات بدلًا من عناصر DOM. المستويات نريد طريقةً لتحديد مستويات اللعبة بحيث يسهل للمستخدِم قراءتها وتعديلها أيضًا، وبما أن كل شيء هنا يمكن إنشاؤه على شبكة، فسنستخدم سلاسل نصية طويلة يمثِّل كل محرف فيها عنصرًا، بحيث يكون إما جزءًا من شبكة الخلفية أو عنصرًا متحركًا، كما سيبدو السطح الذي يمثل أحد المستويات الصغيرة كما يلي: let simpleLevelPlan = ` ...................... ..#................#.. ..#..............=.#.. ..#.........o.o....#.. ..#.@......#####...#.. ..#####............#.. ......#++++++++++++#.. ......##############.. ......................`; تمثِّل النقاط المساحات الفارغة، وتمثِّل محارف الشباك # الحوائط؛ أما علامات الجمع فتمثِّل الحمم البركانية، ويكون موضع بدء اللاعب عند العلامة @، كما يمثِّل كل محرف O في المستوى هنا عملة نقدية، وتمثل علامة = كتلةً من الحمم تتحرك جيئة وذهابًا بصورة أفقية. سنضيف نوعين آخرين من الحمم المتحركة، حيث سيمثِّل الأول محرف الأنبوب | للحمم المتحركة رأسيًا، ومحرف v للحمم المتساقطة، وهي حمم متحركة رأسيًا لا تتردد بين نقطتين، وإنما تتحرك للأسفل فقط قافزةً إلى نقطة بدايتها حين تصل إلى القاع. تتكون اللعبة كلها من مستويات عدة يجب على اللاعب إكمالها، ويكتمل المستوى حين تُجمع كل العملات كما ذكرنا؛ أما إذا لمس اللاعب حممًا بركانيةً فسيعود المستوى الحالي إلى نقطة البداية ليحاول اللاعب مرةً أخرى. قراءة المستوى يخزن الصنف التالي كائن المستوى، وسيكون وسيطه هو السلسلة النصية التي تعرِّف المستوى. class Level { constructor(plan) { let rows = plan.trim().split("\n").map(l => [...l]); this.height = rows.length; this.width = rows[0].length; this.startActors = []; this.rows = rows.map((row, y) => { return row.map((ch, x) => { let type = levelChars[ch]; if (typeof type == "string") return type; this.startActors.push( type.create(new Vec(x, y), ch)); return "empty"; }); }); } } يُستخدَم التابع trim لحذف المسافات الفارغة في بداية ونهاية السلسلة النصية لسطح المستوى level plan، وهذا يسمح للسطح في مثالنا أن يبدأ بسطر جديد كي تكون جميع الأسطر تحت بعضها مباشرةً، ثم تقسَّم السلسلة الباقية بمحارف أسطر جديدة، وينتشر كل سطر في مصفوفة لتكون عندنا مصفوفات من المحارف، وبناءً عليه تحمل rows مصفوفةً من مصفوفات المحارف تكون هي صفوف سطح المستوى، كما نستطيع أخذ عرض المستوى وطوله منها، لكن لا زال علينا فصل العناصر المتحركة من شبكة الخلفية. سنسمي العناصر المتحركة باسم الكائنات الفاعلة أو actors، والتي ستخزَّن في مصفوفة من الكائنات؛ أما الخلفية فستكون مصفوفةً من مصفوفات سلاسل نصية تحمل أنواعًا من الحقول مثل "empty" أو "wall" أو "lava". سنمر على الصفوف ثم على محتوياتها من أجل إنشاء تلك المصفوفات، وتذكَّر أنّ map تمرِّر فهرس المصفوفة على أنه الوسيط الثاني إلى دالة الربط mapping function التي تخبرنا إحداثيات x وy لأي عنصر، كما ستخزَّن المواضع في اللعبة على أساس أزواج من الإحداثيات بحيث يكون الزوج الأعلى إلى اليسار هو 0,0، ثم يكون عرض وارتفاع كل مربع في الخلفية هو وحدة واحدة. يستخدِم الباني level كائن levelChars لاعتراض الكائنات في سطح المستوى، وهو يربط عناصر الخلفية بالسلاسل، ويربط المحارف الفاعلة actor characters بالأصناف. وحين يكون type صنفَ كائن فاعل actor، فسيُستخدم التابع الساكن create الخاص به لإنشاء كائن يضاف إلى startActors، ثم تعيد دالة الربط "empty" لمربع الخلفية ذاك. يخزَّن موضع الكائن الفاعل على أساس كائن Vec الذي هو متجه ثنائي الأبعاد، أي كائن له خصائص x وy كما رأينا في مقال الحياة السرية للكائنات في جافاسكريبت. ستتغير مواضع الكائنات الفاعلة مع تشغيل اللعبة لتكون في أماكن مختلفة أو حتى تختفي تمامًا كما في حال العملات عند جمعها، ولهذا سنستخدم الصنف State لتتبّع حالة اللعبة أثناء عملها. class State { constructor(level, actors, status) { this.level = level; this.actors = actors; this.status = status; } static start(level) { return new State(level, level.startActors, "playing"); } get player() { return this.actors.find(a => a.type == "player"); } } ستتغير الخاصية status لتكون "lost" أو "won" عند نهاية اللعبة، ونكون هنا مرةً أخرى أمام هيكل بيانات ثابت، إذ ينشئ تحديث حالة اللعبة حالةً جديدةً ويترك القديمة كما هي. الكائنات الفاعلة Actors تمثل الكائنات الفاعلة الموضع والحالة الحاليَين لعنصر معطى في اللعبة، وتعمل كلها بالواجهة نفسها، كما تحمل الخاصية pos الخاصة بها إحداثيات الركن العلوي الأيسر للعنصر، في حين تحمل خاصية size حجمه. ثم إن لديها التابع update الذي يُستخدَم لحساب حالتها الجديدة وموضعها كذلك بعد خطوة زمنية معطاة، ويحاكي الإجراء الذي يأخذه الكائن الفاعل -الاستجابة لأزرار الأسهم والتحرك وفقها بالنسبة للاعب، والقفز للأمام أو الخلف بالنسبة للحمم-، ويعيد كائن فاعل actor جديدًا ومحدَّثًا. تحتوي الخاصية type على سلسلة نصية تعرف نوع الكائن الفاعل سواءً كان "player" أو "coin" أو "lava"، وهذا مفيد عند رسم اللعبة، حيث سيعتمد مظهر المستطيل المرسوم من أجل كائن فاعل على نوعه. تحتوي أصناف الكائنات الفاعلة على التابع الساكن create الذي يستخدمه الباني Level لإنشاء كائن فاعل من شخصية في مستوى السطح، كما يعطى إحداثيات الشخصية والشخصية نفسها، إذ هي مطلوبة لأن صنف Lava يعالج عدة شخصيات مختلفة. لدينا فيما يلي الصنف Vec الذي سنستخدمه من أجل قيمنا ثنائية البعد مثل موضع الكائنات الفاعلة وحجمها. class Vec { constructor(x, y) { this.x = x; this.y = y; } plus(other) { return new Vec(this.x + other.x, this.y + other.y); } times(factor) { return new Vec(this.x * factor, this.y * factor); } } يغيِّر التابع times حجم المتجه بعدد معيّن، والذي سيفيدنا حين نحتاج إلى زيادة متجه السرعة بضربه في مدة زمنية لنحصل على المسافة المقطوعة خلال تلك المدة. تحصل الأنواع المختلفة من الكائنات الفاعلة على أصنافها الخاصة بما أنّ سلوكها مختلف. دعنا نعرِّف تلك الأصناف وسننظر لاحقًا في توابع update الخاصة بها. سيكون لصنف اللاعب الخاصية speed التي تخزن السرعة الحالية لتحاكي قوة الدفع والجاذبية. class Player { constructor(pos, speed) { this.pos = pos; this.speed = speed; } get type() { return "player"; } static create(pos) { return new Player(pos.plus(new Vec(0, -0.5)), new Vec(0, 0)); } } Player.prototype.size = new Vec(0.8, 1.5); يكون الموضع الابتدائي للاعب فوق الموضع الذي يظهر فيه محرف @ بنصف مربع، وذلك لأن طول اللاعب يساوي مربعًا ونصف، وتكون بهذه الطريقة قاعدته بمحاذاة قاعدة المربع الذي يظهر فيه. كذلك تكون الخاصية size هي نفسها لجميع نُسخ Player، لذا نخزنها في النموذج الأولي بدلًا من النُسخ نفسها، وقد كان بإمكاننا استخدام جالبة مثل type، ولكنه سينشئ ويعيد ذلك كائن Vec جديد في كل مرة تُقرأ فيها الخاصية، وهو هدر لا حاجة له، إذ لا نحتاج إلى إعادة إنشاء السلاسل النصية في كل مرة نقيِّمها بما أنها غير قابلة للتغير immutable. عند إنشاء الكائن الفاعل Lava سنحتاج إلى تهيئته initialization تهيئةً مختلفةً وفقًا للمحرف المبني عليه، فالحمم الديناميكية تتحرك بسرعتها الحالية إلى أن تصطدم بعائق، فإذا كانت لديها الخاصية reset فستقفز إلى موضع البداية -أي تقطير dripping-؛ أما إذا لم تكن لديها، فستعكس سرعتها وتكمل في الاتجاه المعاكس -أي ارتداد bouncing-. ينظر التابع create في المحرف الذي يمرره الباني Level وينشئ كائن الحمم الفاعل المناسب. class Lava { constructor(pos, speed, reset) { this.pos = pos; this.speed = speed; this.reset = reset; } get type() { return "lava"; } static create(pos, ch) { if (ch == "=") { return new Lava(pos, new Vec(2, 0)); } else if (ch == "|") { return new Lava(pos, new Vec(0, 2)); } else if (ch == "v") { return new Lava(pos, new Vec(0, 3), pos); } } } Lava.prototype.size = new Vec(1, 1); أما كائنات Coin الفاعلة فهي بسيطة نسبيًا، إذ تظل في مكانها لا تتحرك، لكن سنعطيها تأثيرًا متمايلًا بحيث تتحرك رأسيًا جيئةً وذهابًا، كما يخزن كائن العملة موضعًا أساسيًا وخاصية wobble تتتَّبع مرحلة حركة الارتداد من أجل تتبعها، ويحددان معًا الموضع الحقيقي للعملة ويُخزَّن في الخاصية pos. class Coin { constructor(pos, basePos, wobble) { this.pos = pos; this.basePos = basePos; this.wobble = wobble; } get type() { return "coin"; } static create(pos) { let basePos = pos.plus(new Vec(0.2, 0.1)); return new Coin(basePos, basePos, Math.random() * Math.PI * 2); } } Coin.prototype.size = new Vec(0.6, 0.6); رأينا في نموذج كائن المستند في جافاسكريبت أنّ Math.sin تعطينا إحداثية y لنقطة ما في دائرة، وتتذبذب تلك الإحداثية في حركة موجية ناعمة أثناء الحركة على الدائرة، مما يعطينا دالةً جيبيةً نستفيد منها في نمذجة الحركة الموجية. سنجعل مرحلة بدء كل عملة عشوائية، وذلك لكي نتفادى صنع حركة متزامنة للعملات، ويكون عرض الموجة التي تنتجها Math.sin هو 2π وهي مدة الموجة، ثم نضرب القيمة المعادة بـ Math.random بذلك العدد لتعطي العملة موضع بدء عشوائي على الموجة. نستطيع الآن تعريف كائن levelChars الذي يربط محارف السطح لأنواع شبكة الخلفية أو لأصناف الكائنات الفاعلة. const levelChars = { ".": "empty", "#": "wall", "+": "lava", "@": Player, "o": Coin, "=": Lava, "|": Lava, "v": Lava }; يعطينا ذلك جميع الأجزاء التي نحتاجها لإنشاء نسخة Level. let simpleLevel = new Level(simpleLevelPlan); console.log(`${simpleLevel.width} by ${simpleLevel.height}`); // → 22 by 9 ستكون المهمة التالية هي عرض تلك المستويات على الشاشة وضبط الوقت والحركة فيها. مشكلة التغليف لا يؤثر التغليف encapsulation على أغلب الشيفرات الموجودة في هذا المقل لسببين، أولهما أنه يأخذ جهدًا إضافيًا فيجعل البرامج أكبر ويتطلب مفاهيم وواجهات إضافية، وبما أن تلك شيفرات كثيرة على القارئ، فقد اجتهدنا في تصغير البرنامج وتبسيطه؛ أما الثاني أن ثمة عناصر عديدة في اللعبة ترتبط ببعضها، بحيث إذا تغير سلوك أحدها، فمن الصعب بقاء غيرها كما هو، وسيجعل ذلك الواجهات تطرح الكثير من الافتراضات عن طريقة عمل اللعبة، مما يقلل من فائدتها، فإذا غيرنا جزءًا من النظام، فسيكون علينا مراقبة تأثير ذلك في الأجزاء الأخرى بما أنّ واجهاتها لن تغطي الموقف الجديد. بعض النقاط الفاصلة تقبل الانفصال من خلال واجهات صارمة، لكن هذا ليس حال كل النقاط لدينا، كما سنهدر طاقةً كبيرةً في محاولة تغليف شيء لا يكون حدًا مناسبًا، وسنلاحظ إذا ارتكبنا ذلك الخطأ أنّ واجهاتنا صارت كبيرةً وكثيرة التفاصيل، كما ستحتاج إلى التغيير كل حين كلما تغير البرنامج. لكن ثمة شيء سنغلفه هو النظام الفرعي للرسم، وذلك لأننا سنعرض اللعبة نفسها بطريقة مختلفة في المقال التالي، فإذا وضعنا الرسم خلف واجهة، فسنتمكن من تحميل برنامج اللعبة نفسه، ونلحقه بوحدة العرض الجديدة. الرسم تُغلَّف شيفرة الرسم من خلال تعريف كائن عرض display object يعرض مستوى ما وحالته، وسيكون اسم نوع العرض الذي نعرّفه في هذا المقال هو DOMDisplay بما أنه يستخدم عناصر DOM لعرض المستوى، كما سنستخدم ورقة أنماط style sheet لضبط الألوان الفعلية والخصائص الثابتة الأخرى للعناصر التي ستشكِّل اللعبة، كذلك يمكن تعيين الخاصية style للعناصر مباشرةً حين ننشئها لكن سينتج ذلك برامجًا أكثر إسهابًا. توفر الدالة المساعدة التالية طريقةً موجزةً لإنشاء عنصر وتعطيه بعض السمات والعقد الفرعية: function elt(name, attrs, ...children) { let dom = document.createElement(name); for (let attr of Object.keys(attrs)) { dom.setAttribute(attr, attrs[attr]); } for (let child of children) { dom.appendChild(child); } return dom; } يُنشأ العرض بإعطائه كائن مستوى وعنصرًا أبًا parent element ليلحِق نفسه به. class DOMDisplay { constructor(parent, level) { this.dom = elt("div", {class: "game"}, drawGrid(level)); this.actorLayer = null; parent.appendChild(this.dom); } clear() { this.dom.remove(); } } تُرسم شبكة الخلفية للمستوى مرةً واحدةً بما أنها لن تتغير، ويعاد رسم الكائنات الفاعلة في كل مرة يُحدّث فيها العرض بحالة ما، كما سنستخدم الخاصية actorLayer لتتبّع العنصر الذي يحمل الكائنات الفاعلة، بحيث يمكن حذفها واستبدالها بسهولة؛ كما ستُتتبّع إحداثياتنا وأحجامنا بوحدات الشبكة، حيث يشير الحجم أو المسافة التي تساوي 1 إلى كتلة شبكة واحدة، ويجب أن نزيد حجم الإحداثيات حين نضبط أحجام البكسلات، إذ سيبدو كل شيء صغيرًا في اللعبة إذا جعلنا كل بكسل يقابل مربعًا واحدًا، وسيعطينا الثابت scale عدد البكسلات التي تأخذها واحدةً واحدة single unit على الشاشة. const scale = 20; function drawGrid(level) { return elt("table", { class: "background", style: `width: ${level.width * scale}px` }, ...level.rows.map(row => elt("tr", {style: `height: ${scale}px`}, ...row.map(type => elt("td", {class: type}))) )); } تُرسم الخلفية على أساس عنصر <table>، ويتوافق ذلك بسلاسة مع هيكل الخاصية rows للمستوى، فقد حُول كل صف في الشبكة إلى صف جدول -أي عنصر <tr>-؛ أما السلاسل النصية في الشبكة فتُستخدم على أساس أسماء أصناف لخلية الجدول -أي <td>-، كما يُستخدم عامل النشر spread operator -أي النقطة الثلاثية- لتمرير مصفوفات من العقد الفرعية إلى elt لفصل الوسائط. يوضح المثال التالي كيف نجعل الجدول يبدو كما نريد من خلال شيفرة CSS: .background { background: rgb(52, 166, 251); table-layout: fixed; border-spacing: 0; } .background td { padding: 0; } .lava { background: rgb(255, 100, 100); } .wall { background: white; } تُستخدم بعض الوسوم مثل table-layout وborder-spacing وpadding، لمنع السلوك الافتراضي غير المرغوب فيه، فلا نريد لتخطيط الجدول أن يعتمد على محتويات خلاياه، كما لا نريد مسافات بين خلايا الجدول أو تبطينًا padding داخلها. تضبط قاعدة background لون الخلفية، إذ تسمح CSS بتحديد الألوان على أساس كلمات -مثل white- أو بصيغة مثل ‎rgb(R, G, B)‎‎‎، حيث تُفصل مكونات اللون الحمراء والخضراء والزرقاء إلى ثلاثة أعداد من 0 إلى 255. ففي اللون ‎rgb(52, 166, 251)‎ مثلًا، سيكون مقدار المكون الأحمر 52 والأخضر 166 والأزرق 251، وبما أن مقدار الأزرق هو الأكبر، فسيكون اللون الناتج مائلًا للزرقة، ويمكن رؤية ذلك في القاعدة ‎.lava، إذ أنّ أول عدد فيها -أي الأحمر- هو الأكبر. سنرسم كل كائن فاعل بإنشاء عنصر DOM له وضبط موضع وحجم ذلك العنصر بناءً على خصائص الكائن الفاعل، ويجب ضرب القيم في scale لتُحوَّل من وحدات اللعبة إلى بكسلات. function drawActors(actors) { return elt("div", {}, ...actors.map(actor => { let rect = elt("div", {class: `actor ${actor.type}`}); rect.style.width = `${actor.size.x * scale}px`; rect.style.height = `${actor.size.y * scale}px`; rect.style.left = `${actor.pos.x * scale}px`; rect.style.top = `${actor.pos.y * scale}px`; return rect; })); } تُفصل أسماء الأصناف بمسافات كي نعطي العنصر الواحد أكثر من صنف، ففي شيفرة CSS أدناه سنرى أن الصنف actor يعطي الكائنات الفاعلة موضعها المطلق، كما يُستخدم اسم نوعها على أساس صنف إضافي ليعطيها لون، ولا نريد تعريف صنف lava مرةً أخرى بما أننا سنعيد استخدامه من أجل مربعات شبكة الحمم التي عرّفناها سابقًا. .actor { position: absolute; } .coin { background: rgb(241, 229, 89); } .player { background: rgb(64, 64, 64); } يُستخدَم التابع syncState لجعل العرض يظهر حالةً ما، وهو يحذف رسوميات الكائن الفاعل القديم أولًا إذا وُجدت، ثم يعيد رسم الكائنات الفاعلة في مواضعها الجديدة. قد يكون من المغري استخدام عناصر DOM للكائنات الفاعلة، لكننا سنحتاج إلى الكثير من الحسابات الإضافية إذا أردنا إنجاح ذلك من أجل ربطها مع عناصر DOM، ولضمان حذف العناصر حين تختفي كائناتها الفاعلة، وعلى أيّ حال فليست لدينا كائنات فاعلة كثيرة في اللعبة، وبالتالي لن تكون إعادة رسمها مكلفةً. DOMDisplay.prototype.syncState = function(state) { if (this.actorLayer) this.actorLayer.remove(); this.actorLayer = drawActors(state.actors); this.dom.appendChild(this.actorLayer); this.dom.className = `game ${state.status}`; this.scrollPlayerIntoView(state); }; نستطيع تخصيص الكائن الفاعل للاعب تخصيصًا مختلفًا حين تفوز اللعبة أو تخسر بإضافة حالة المستوى الحالية، مثل اسم صنف إلى المغلِّف، وذلك من خلال إضافة قاعدة CSS لا تأخذ ذلك التأثير إلا عندما يكون للاعب عنصر سلف بصنف ما. .lost .player { background: rgb(160, 64, 64); } .won .player { box-shadow: -4px -7px 8px white, 4px -7px 8px white; } يتغير لون اللاعب إلى الأحمر الداكن بعد لمس الحمم ليشير إلى الحرق، كما نضيف إليه هالةً بيضاء حوله إذا جمع كل العملات، وذلك بإضافة ظلَّين أبيضين ضبابيين، بحيث يكون واحدًا أعلى يساره والثاني أعلى يمينه. لا يمكن افتراض تلاؤم المستوى مع نافذة الرؤية على الدوام، ونافذة الرؤية هي العنصر الذي سنرسم اللعبة داخله، لذا نحتاج إلى استدعاء scrollPlayerIntoView الذي يضمن أننا سنمرر scroll نافذة الرؤية إذا كان المستوى سيخرج عنها إلى أن يصير اللاعب قريبًا من مركزها. تعطي شيفرة CSS التالية قيمة حجم عظمى لعنصر DOM المغلِّف الخاص باللعبة، ويضمن ألا يُرى شيء خارج صندوق العنصر، كما سنعطيه موضعًا نسبيًا كي تكون مواضع الكائنات الفاعلة داخلها منسوبةً إلى الركن العلوي الأيسر من المستوى. .game { overflow: hidden; max-width: 600px; max-height: 450px; position: relative; } نبحث عن موضع اللاعب في التابع scrollPlayerIntoView ونحدِّث موضع التمرير للعنصر المغلِّف، كما نغير موضع التمرير بتعديل الخصائص scrollLeft وscrollTop الخاصة بالعنصر حين يقترب اللاعب من الحافة. DOMDisplay.prototype.scrollPlayerIntoView = function(state) { let width = this.dom.clientWidth; let height = this.dom.clientHeight; let margin = width / 3; // The viewport let left = this.dom.scrollLeft, right = left + width; let top = this.dom.scrollTop, bottom = top + height; let player = state.player; let center = player.pos.plus(player.size.times(0.5)) .times(scale); if (center.x < left + margin) { this.dom.scrollLeft = center.x - margin; } else if (center.x > right - margin) { this.dom.scrollLeft = center.x + margin - width; } if (center.y < top + margin) { this.dom.scrollTop = center.y - margin; } else if (center.y > bottom - margin) { this.dom.scrollTop = center.y + margin - height; } }; تُظهِر الطريقة التي نحدد بها مركز اللاعب كيفية سماح التوابع التي على نوع Vec بكتابة حسابات الكائنات بطريقة قابلة للقراءة نوعًا ما، ونفعل ذلك بإضافة موضع الكائن الفاعل -وهنا ركنه العلوي الأيسر- ونصف حجمه، ويكون هذا هو المركز في إحداثيات المستوى، لكننا سنحتاج إليه في إحدائيات البكسل أيضًا، لذا نضرب المتجه الناتج بمقياس العرض. تبدأ بعد ذلك سلسلة من التحقُّقات للتأكد من أن موضع اللاعب داخل المجال المسموح به، وقد يؤدي ذلك أحيانًا إلى تعيين إحداثيات تمرير غير منطقية، كأن تكون قيمًا سالبة أو أكبر من مساحة العنصر القابلة للتمرير، ولا بأس في هذا، إذ ستقيد عناصر DOM تلك القيم لتكون في نطاق مسموح به، فإذا ضُبطت srollLeft لتكون ‎-10 مثلًا، فإنها ستتغير لتصير 0. قد يقال أن الأسهل يكون بجعل اللاعب يُمرَّر دائمًا ليكون في منتصف الشاشة، غير أنّ هذا -وإذا كان أسهل- سيُحدِث أثرًا عكسيًا من حيث تجربة استخدام للعبة، فكلما قفز اللاعب، ستتغير نافذة الرؤية للأعلى والأسفل معه، ولهذا من الأفضل حينها إبقاء منطقة محايدة ثابتة في منتصف الشاشة كي نتحرك دون التسبب في أي تمرير. نستطيع الآن عرض المستوى الصغير الذي أنشأناه. <link rel="stylesheet" href="css/game.css"> <script> let simpleLevel = new Level(simpleLevelPlan); let display = new DOMDisplay(document.body, simpleLevel); display.syncState(State.start(simpleLevel)); </script> يُستخدَم الوسم <link> مع ‎‎rel="stylesheet"‎ لتحميل ملف CSS إلى الصفحة، ويحتوي ملف game.css على الأنماط الضرورية للعبتنا. الحركة والتصادم نحن الآن في مرحلة نستطيع فيها إضافة الحركة، وهي لا شك بأنها أكثر جزء مثير في اللعبة، والطريقة التي تتبعها أغلب الألعاب التي تشبه لعبتنا هي تقسيم الوقت إلى خطوات صغيرة ونحرك الكائنات الفاعلة بمسافة تتوافق مع سرعتها ومضروبة في حجم الخطوة الزمنية، كما سنحسب الزمن بالثواني، وعليه سيعبَّر عن السرعات بوحدات لكل ثانية. يُعَدّ تحريك الكائنات أمرًا يسيرًا؛ أما التعامل مع التفاعلات التي يجب حدوثها بين العناصر فهو الأمر الصعب، فإذا اصطدم اللاعب بجدار أو أرضية، فلا يجب المرور من خلالها، بل تنتبه اللعبة إذا تسببت حركة ما في ارتطام كائن بآخر، ثم تتصرف وفق الحالة، فتتوقف الحركة مثلًا بالنسبة للجدران؛ أما إذا اصطدم بعملة ما فيجب جمعها، وإذا لمس حممًا بركانية فيجب خسارة اللعبة. لا شك أن هذه عملية معقدة إذا أردنا ضبط قوانينها، لذا ستجد مكتبات يُطلق عليها عادةً محركات فيزيائية physics engines تحاكي التفاعل بين الكائنات الفيزيائية في بعدين أو ثلاثة أبعاد، ولكن بأي حال سنلقي نظرةً عليها في هذا المقال لنتعرف إليها، حيث سنعالج التصادمات بين الكائنات المستطيلة فقط على أساس تدريب عملي عليها. ننظر أولًا قبل تحريك اللاعب أو كتلة الحمم هل تأخذه الحركة داخل جدار أم لا، فإذا أخذته؛ فإننا نلغي الحركة بالكلية. تعتمد الاستجابة لمثل ذلك التصادم على نوع الكائن الفاعل نفسه، فاللاعب مثلًا سيقف، بينما ترتد كتلة الحمم في الاتجاه المعاكس. يتطلب هذا الأسلوب أن تكون خطواتنا الزمنية صغيرةً إلى حد ما بما أنها ستوقف الحركة قبل أن تتلامس الكائنات، وإلا سنجد اللاعب يحوم لمسافة ملحوظة فوق الأرض إذا كانت الخطوات الزمنية كبيرةً -وخطوات الحركة بناءً عليها-، والأفضل هنا هو أن نجد نقطة التصادم بالتحديد وننتقل إليها، لكن هذا أعقد، لذا سنأخذ الأسلوب البسيط ونتلافى مشاكله بالتأكد من أن الحركة مستمرة في خطوات صغيرة. يخبرنا التابع التالي هل يلمس المستطيل -المحدَّد بموضع وحجم- عنصر شبكة من نوع ما أم لا. Level.prototype.touches = function(pos, size, type) { let xStart = Math.floor(pos.x); let xEnd = Math.ceil(pos.x + size.x); let yStart = Math.floor(pos.y); let yEnd = Math.ceil(pos.y + size.y); for (let y = yStart; y < yEnd; y++) { for (let x = xStart; x < xEnd; x++) { let isOutside = x < 0 || x >= this.width || y < 0 || y >= this.height; let here = isOutside ? "wall" : this.rows[y][x]; if (here == type) return true; } } return false; }; يحسب التابع مجموعة مربعات الشبكة التي يتداخل الجسم معها من خلال استخدام Math.floor وMath.ceil على إحداثياته، وتذكَّر أنّ مربعات الشبكة حجمها 1 * 1 وحدة، فإذا قربنا جوانب الصندوق لأعلى وأسفل سنحصل على مجال مربعات الخلفية التي يلمسها الصندوق. سنمر حلقيًا على كتلة مربعات الشبكة التي خرجنا بها من الإحداثيات السابقة، ونعيد القيمة true إذا وجدنا مربعًا مطابِقًا. تُعامَل المربعات التي خارج المستوى على أساس جدار "wall" لضمان عدم خروج اللاعب من العالم الافتراضي، وأننا لن نقرأ أي شيء خارج حدود مصفوفتنا rows بالخطأ. تابع الحالة update يستخدِم touches لمعرفة هل لمس اللاعب حممًا بركانية أم لا. State.prototype.update = function(time, keys) { let actors = this.actors .map(actor => actor.update(time, this, keys)); let newState = new State(this.level, actors, this.status); if (newState.status != "playing") return newState; let player = newState.player; if (this.level.touches(player.pos, player.size, "lava")) { return new State(this.level, actors, "lost"); } for (let actor of actors) { if (actor != player && overlap(actor, player)) { newState = actor.collide(newState); } } return newState; }; تُمرَّر كل من الخطوة الزمنية وهيكل البيانات إلى التابع، حيث يخبرنا هيكل البيانات بالمفاتيح المضغوط عليها حاليًا، ويستدعي التابعَ update على جميع الكائنات الفاعلة ليُنتج لنا مصفوفةً من نسخها المحدثة، كما تحصل الكائنات الفاعلة على الخطوة الزمنية والمفاتيح والحالة أيضًا لتتمكن من بناء تحديثها عليها، لكن تلك المفاتيح لا يقرؤها إلا اللاعب نفسه بما أنه هو الكائن الفاعل الوحيد الذي تتحكم به لوحة المفاتيح. إذا انتهت اللعبة، فلا يكون قد بقي شيء من المعالجة لفعله، أي أن اللعبة يستحيل أن تفوز بعد خسارتها أو العكس؛ أما إذا لم تنتهي، فسيختبر التابع هل لمس اللاعب حمم الخلفية أم لا، فإذا لمسها تخسر اللعبة وتنتهي. أخيرًا، إذا كانت اللعبة لا تزال قائمةً فسيرى هل تداخلت كائنات فاعلة أخرى مع اللاعب أم لا، ويُكتشف التداخل بين الكائنات الفاعلة باستخدام الدالة overlap التي تأخذ كائنين فاعلين وتعيد true إذا تلامسا فقط، وهي الحالة التي يتداخلا فيها على محوري x وy معًا. function overlap(actor1, actor2) { return actor1.pos.x + actor1.size.x > actor2.pos.x && actor1.pos.x < actor2.pos.x + actor2.size.x && actor1.pos.y + actor1.size.y > actor2.pos.y && actor1.pos.y < actor2.pos.y + actor2.size.y; } فإذا تداخل كائن فاعل، فسيحصل التابع collide الخاص به على فرصة لتحديث حالته، ويضبط لمس كائن الحمم حالة اللعبة إلى "lost"؛ أما العملات فستختفي حين نلمسها، وعند لمس العملة الأخيرة تُضبَط حالة اللعبة إلى "won". Lava.prototype.collide = function(state) { return new State(state.level, state.actors, "lost"); }; Coin.prototype.collide = function(state) { let filtered = state.actors.filter(a => a != this); let status = state.status; if (!filtered.some(a => a.type == "coin")) status = "won"; return new State(state.level, filtered, status); }; تحديثات الكائنات الفاعلة تأخذ توابع update الخاصة بالكائنات الفاعلة الخطوة الزمنية وكائن الحالة وكائن keys على أساس وسائط لها، مع استثناء تابع الكائن الفاعل Lava، إذ يتجاهل كائن keys. Lava.prototype.update = function(time, state) { let newPos = this.pos.plus(this.speed.times(time)); if (!state.level.touches(newPos, this.size, "wall")) { return new Lava(newPos, this.speed, this.reset); } else if (this.reset) { return new Lava(this.reset, this.speed, this.reset); } else { return new Lava(this.pos, this.speed.times(-1)); } }; يحسب التابع update موضعًا جديدًا بإضافة ناتج الخطوة الزمنية والسرعة الحالية إلى الموضع القديم، فإذا لم تحجب ذلك الموضع الجديد أية عوائق فسينتقل إليه؛ أما إذا وُجد عائق فسيعتمد السلوك حينها على نوع كتلة الحمم، فالحمم المتساقطة لديها الموضع reset الذي تقفز إليه حين تصطدم بشيء ما؛ أما الحمم المرتدة، فتعكس سرعتها وتضربها في ‎-1 كي تبدأ بالحركة في الاتجاه المعاكس. تستخدِم العملات كذلك التابع update من أجل تأثير التمايل، فتتجاهل التصادمات مع الشبكة بما أنها تتمايل في المربع نفسه. const wobbleSpeed = 8, wobbleDist = 0.07; Coin.prototype.update = function(time) { let wobble = this.wobble + time * wobbleSpeed; let wobblePos = Math.sin(wobble) * wobbleDist; return new Coin(this.basePos.plus(new Vec(0, wobblePos)), this.basePos, wobble); }; تتزايد الخاصية wobble لتراقب الوقت، ثم تُستخدَم على أساس وسيط لـ Math.sin لإيجاد الموضع الجديد على الموجة، ثم يُحسب موضع العملة الحالي من موضعها الأساسي وإزاحة مبنية على هذه الموجة. يتبقى لنا اللاعب نفسه، إذ تُعالَج حركة اللاعب معالجةً مستقلةً لكل محور، ذلك أنه ينبغي على اصطدامه بالأرض ألا يمثِّل مشكلةً وألا يمنع الحركة الأفقية، كما أن الاصطدام بحائط لا يجب ألا يوقف حركة السقوط أو القفز. const playerXSpeed = 7; const gravity = 30; const jumpSpeed = 17; Player.prototype.update = function(time, state, keys) { let xSpeed = 0; if (keys.ArrowLeft) xSpeed -= playerXSpeed; if (keys.ArrowRight) xSpeed += playerXSpeed; let pos = this.pos; let movedX = pos.plus(new Vec(xSpeed * time, 0)); if (!state.level.touches(movedX, this.size, "wall")) { pos = movedX; } let ySpeed = this.speed.y + time * gravity; let movedY = pos.plus(new Vec(0, ySpeed * time)); if (!state.level.touches(movedY, this.size, "wall")) { pos = movedY; } else if (keys.ArrowUp && ySpeed > 0) { ySpeed = -jumpSpeed; } else { ySpeed = 0; } return new Player(pos, new Vec(xSpeed, ySpeed)); }; تُحسب الحركة الأفقية وفقًا لحالة مفاتيح الأسهم اليمين واليسار، فإذا لم يكن هناك حائط يحجب الموضع الجديد الذي سينشأ بسبب تلك الحركة، فسيُستخدَم؛ وإلا نظل على الموضع القديم. كذلك الأمر بالنسبة للحركة الرأسية، لكن يجب محاكاة القفز والجاذبية، فتتزايد سرعة اللاعب الرأسية ySpeed أولًا لتعادل الجاذبية (تراجع)، ثم نتحقق من الحوائط مرةً أخرى، فإذا لم نصطدم بحائط فسنستخدم الموضع الجديد؛ أما إذا اصطدمنا بحائط فلدينا احتمالان، وهما إما أن يُضغط زر السهم الأعلى ونحن نتحرك إلى الأسفل، أي أن ما اصطدمنا به كان تحتنا، فتُضبط السرعة على قيمة كبيرة سالبة، وهذا يجعل اللاعب يقفز لأعلى، ويُعَد ما سوى ذلك اصطدام اللاعب بشيء في طريقه، وهنا تتغير السرعة إلى صفر؛ أما قوة الجاذبية وسرعة القفز وغيرها من الثوابت في تلك اللعبة، فتُضبط بالتجربة والخطأ، حيث اختبرنا القيم حتى وصلنا إلى قيم متوافقة مع بعضها بعضًا ومناسبة. مفاتيح التعقب لا نريد للمفاتيح أن تُحدِث تأثيرًا واحدًا لكل نقرة عليها، بل نريد أن يظل تأثيرها عاملًا طالما كان المفتاح مضغوطًا، وذلك مفيد في شأن لعبة مثل التي نكتبها من أجل إجراء ما مثل تحريك اللاعب. سنُعِدّ معالِج مفتاح يخزن الحالة الراهنة لمفاتيح الأسهم الأربعة، كما سنستدعي preventDefault لتلك المفاتيح كي لا تتسبب في تمرير الصفحة. تعيد الدالة في المثال أدناه كائنًا عند إعطائها مصفوفةً من أسماء المفاتيح، حيث يتعقب الموضع الحالي لتلك المفاتيح ويسجل معالجات أحداث للأحداث "keydown" و"keyup"، وإذا كانت شيفرة المفتاح التي في الحدث موجودةً في مجموعة الشيفرات التي تتعقبها، فستحدِّث الكائن. function trackKeys(keys) { let down = Object.create(null); function track(event) { if (keys.includes(event.key)) { down[event.key] = event.type == "keydown"; event.preventDefault(); } } window.addEventListener("keydown", track); window.addEventListener("keyup", track); return down; } const arrowKeys = trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]); تُستخدَم الدالة المعالج نفسها لنوعي الأحداث، إذ تنظر في الخاصية type لكائن الحدث لتحدِّد هل يجب تحديث حالة المفتاح إلى القيمة true -أي "keydown"- أو القيمة false -أي "keyup"-. تشغيل اللعبة توفِّر الدالة requestAnimationFrame التي رأيناها في مقال نموذج كائن المستند في جافاسكريبت طريقةً جيدةً لتحريك اللعبة، لكن واجهتها بدائية للغاية، إذ نحتاج إلى تعقب الوقت الذي استدعيت فيه دالتنا في آخر مرة، ثم نستدعي requestAnimationFrame مرةً أخرى بعد كل إطار ولهذا سنعرِّف دالةً مساعدةً تغلّف هذه الأجزاء المملة في واجهة مريحة وعملية، وتسمح لنا باستدعاء runAnimation ببساطة لتعطيها دالةً تتوقع فرق الوقت على أساس وسيط وترسم إطارًا واحدًا، كما تتوقف الحركة إذا أعادت دالة الإطار القيمة false. function runAnimation(frameFunc) { let lastTime = null; function frame(time) { if (lastTime != null) { let timeStep = Math.min(time - lastTime, 100) / 1000; if (frameFunc(timeStep) === false) return; } lastTime = time; requestAnimationFrame(frame); } requestAnimationFrame(frame); } ضبطنا القيمة العظمى لخطوة الإطار لتكون مساويةً لـ 100 ميلي ثانية -أي عُشر ثانية-، فإذا أُخفيت نافذة المتصفح أو التبويب الذي فيه صفحتنا، فستتوقف استدعاءات requestAnimationFrame إلى أن يُعرَض التبويب أو النافذة مرةً أخرى، ويكون الفرق في هذه الحالة بين lastTime وtime هو الوقت الكلي الذي أُخفيت فيه الصفحة. لا شك في أن تقدُّم اللعبة بذلك المقدار في خطوة واحدة سيكون سخيفًا وقد يسبب آثارًا جانبيةً غريبةً، كأن يسقط اللاعب داخل الأرضية. تحوِّل الدالة كذلك الخطوات الزمنية إلى ثواني، وهي أسهل في النظر إليها على أساس كمية عنها إذا كانت بالمللي ثانية، وتأخذ الدالة runLevel الكائن Level وتعرض بانيًا وتُعيد وعدًا، كما تعرض المستوى -في document.body-، وتسمح للمستخدِم باللعب من خلاله، وإذا انتهى المستوى بالفوز أو الخسارة، فستنتظر runLevel زمنًا قدره ثانيةً واحدةً إضافيةً ليتمكن المستخدِم من رؤية ما حدث، ثم تمحو العرض وتوقف التحريك، وتحل الوعد لحالة اللعبة النهائية. function runLevel(level, Display) { let display = new Display(document.body, level); let state = State.start(level); let ending = 1; return new Promise(resolve => { runAnimation(time => { state = state.update(time, arrowKeys); display.syncState(state); if (state.status == "playing") { return true; } else if (ending > 0) { ending -= time; return true; } else { display.clear(); resolve(state.status); return false; } }); }); } تتكون اللعبة من سلسلة من المستويات، بحيث يعاد المستوى الحالي إذا مات اللاعب، وإذا اكتمل مستوى، فسننتقل إلى المستوى التالي، ويمكن التعبير عن ذلك بالدالة التالية التي تأخذ مصفوفةً من أسطح المستويات (سلاسل نصية) وتعرض بانيًا: async function runGame(plans, Display) { for (let level = 0; level < plans.length;) { let status = await runLevel(new Level(plans[level]), Display); if (status == "won") level++; } console.log("You've won!"); } لأننا جعلنا runLevel تعيد وعدًا، فيمكن كتابة runGame باستخدام دالة async كما هو موضح في الحادي عشر، وهي تُعيد وعدًا آخرًا يُحَل عندما يُنهي اللاعب اللعبة. ستجد مجموعةً من أسطح المستويات متاحةً في رابطة GAME_LEVELS في صندوق الاختبارات الخاص بهذا المقال، وتغذي تلك الصفحة المستويات إلى runGame لتبدأ اللعبة الحقيقية. <link rel="stylesheet" href="css/game.css"> <body> <script> runGame(GAME_LEVELS, DOMDisplay); </script> </body> جرب بنفسك لترى ما إذا كنت تستطيع تجاوز هذه المستويات. تدريبات انتهاء اللعبة من المتعارف عليه في ألعاب الحاسوب أنّ اللاعب يبدأ بعدد محدود من فرص الحياة التي تنقص بمقدار حياة واحدة كلما مات في اللعبة، وإذا انتهت الفرص المتاحة، فستعيد اللعبة التشغيل من البداية. عدِّل runGame لتضع فيها خاصية الحيوات، واجعل اللاعب يبدأ بثلاثة حيوات، ثم أخرج عدد الحيوات الحالي باستخدام console.log في كل مرة يبدأ فيها مستوى. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <link rel="stylesheet" href="css/game.css"> <body> <script> // دالة runGame القديمة، عدّلها ... async function runGame(plans, Display) { for (let level = 0; level < plans.length;) { let status = await runLevel(new Level(plans[level]), Display); if (status == "won") level++; } console.log("You've won!"); } runGame(GAME_LEVELS, DOMDisplay); </script> </body> الإيقاف المؤقت للعبة أضف خاصية الإيقاف المؤقت للعبة والعودة إليها من خلال مفتاح Esc، ويمكن تنفيذ هذا بتغيير دالة runLevel لتستخدِم معالِج حدث لوحة مفاتيح آخر، وتعترض الحركة أو تستعيدها كلما ضغط اللاعب على زر Esc. قد لا تبدو واجهة runAnimation مناسبةً لهذه الخاصية، لكنها ستكون كذلك إذا أعدت ترتيب الطريقة التي تستدعيها runLevel بها. إذا تمكنت من تنفيذ ذلك فثمة شيء آخر قد تستطيع فعله، ذلك أنّ الطريقة التي نسجل بها معالِجات الأحداث تسبب لنا مشكلة، فالكائن arrowKeys حاليًا هو رابطة عامة global binding، وتظل معالجات أحداثه باقيةً حتى لو لم تكن هناك لعبة تعمل، فتستطيع القول أنها تتسرب من نظامنا. وسِّع trackKeys من أجل توفير طريقة لتسجيل معالجاتها عندما تبدأ ثم تلغي تسجيلها مرةً أخرى عند انتهائها. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <link rel="stylesheet" href="css/game.css"> <body> <script> // The old runLevel function. Modify this... function runLevel(level, Display) { let display = new Display(document.body, level); let state = State.start(level); let ending = 1; return new Promise(resolve => { runAnimation(time => { state = state.update(time, arrowKeys); display.syncState(state); if (state.status == "playing") { return true; } else if (ending > 0) { ending -= time; return true; } else { display.clear(); resolve(state.status); return false; } }); }); } runGame(GAME_LEVELS, DOMDisplay); </script> </body> إرشادات الحل يمكن اعتراض الحركة بإعادة false من الدالة المعطاة لـ runAnimation، ويمكن متابعتها باستدعاء runAnimation مرةً أخرى، وهكذا سنحتاج إلى إبلاغ الدالة المعطاة لـ runAnimation أننا سنوقف اللعبة مؤقتًا؛ ولفعل هذا، استخدِم رابطةً يستطيع كل من معالج الحدث والدالة الوصول إليها. عند البحث عن طريقة لإلغاء تسجيل المعالجات المسجَّلة بواسطة trackKeys، تذكر أنّ قيمة الدالة الممررة نفسها إلى addEventListener يجب تمريرها إلى removeEventListener من أجل حذف معالج بنجاح، وعليه يجب أن تكون قيمة الدالة handler المنشأة في trackKeys متاحةً في الشيفرة التي تلغي تسجيل المعالِجات. تستطيع إضافة خاصية إلى الكائن المعاد بواسطة trackKeys تحتوي على قيمة الدالة أو على تابع يعالج إلغاء التسجيل مباشرةً. الوحش من الشائع أيضًا في ألعاب المنصة أن تحتوي على أعداء تستطيع القفز فوقها لتتغلب عليها، ويطلب منك هذا التدريب إضافة مثل نوع الكائن الفاعل ذلك إلى اللعبة. سنطلق عليه اسم الوحش وتتحرك تلك الوحوش أفقيًا فقط، كما تستطيع جعلها تتحرك في اتجاه اللاعب وتقفز للأمام والخلف مثل الحمم الأفقية، أو يكون لها أي نمط حركة تختاره، ولا تحتاج إلى جعل الصنف يعالج السقوط، لكن يجب التأكد من أن الوحش لا يسير خلال الجدران. يتوقف التأثير الواقع على اللاعب إذا لمسه أحد الوحوش بكون اللاعب يقفز فوق الوحش أم لا، ويمكنك تقريب الأمر بالتحقق من قاعدة اللاعب هل هي قريبة من قمة الوحش أم لا، فإذا كانت قريبةً فسيختفي الوحش، وإذا كانت بعيدةً فستخسر اللعبة. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <link rel="stylesheet" href="css/game.css"> <style>.monster { background: purple }</style> <body> <script> // أكمل التوابع التالية: constructor وupdate وcollide class Monster { constructor(pos, /* ... */) {} get type() { return "monster"; } static create(pos) { return new Monster(pos.plus(new Vec(0, -1))); } update(time, state) {} collide(state) {} } Monster.prototype.size = new Vec(1.2, 2); levelChars["M"] = Monster; runLevel(new Level(` .................................. .################################. .#..............................#. .#..............................#. .#..............................#. .#...........................o..#. .#..@...........................#. .##########..............########. ..........#..o..o..o..o..#........ ..........#...........M..#........ ..........################........ .................................. `), DOMDisplay); </script> </body> إرشادات الحل إذا أردت تنفيذ نوع حركة حالي stateful مثل الارتداد، فتأكد من تخزين الحالة المطلوبة في الكائن الفاعل، بأن تضمِّنها على أساس وسيط باني وتضيفها على أساس خاصية. تذكر أنّ update تعيد كائنًا جديدًا بدلًا من تغيير الكائن القديم، وابحث عن اللاعب في state.actors عند معالجة اصطدام ووازن موضعه مع موضع الوحش. للحصول على قاعدة اللاعب يجب عليك إضافة حجمه الرأسي إلى موضعه الرأسي، وسيمثل إنشاء حالة محدثة إما التابع collide الخاص بـ Coin، وهو ما يعني حذف الكائن الفاعل، أو ذلك الخاص بـ Lava، والذي سيغير الحالة إلى "lost" وفقًا لموضع اللاعب. ترجمة -بتصرف- للفصل السادس عشر من كتاب Elequent Javascript لصاحبه Marijn Haverbeke. اقرأ أيضًا المقال السابق: معالجة الأحداث في جافسكربت مشروع بناء لغة برمجة خاصة تعرف على أشهر لغات برمجة الألعاب مشروع تطبيقي لبناء رجل آلي (روبوت) عبر جافاسكريبت
  20. لا تختلف الملفات من منظور برمجي عن الملفات التي نستخدمها في برامج معالجة الكلمات أو أي برامج أخرى، والتي نفتحها ونكتب فيها بعض المهام أو التعليمات ثم نغلقها مرةً أخرى، لكن أحد الفروق الرئيسية هنا هو أننا نقرأ الملف تتابعيًا، أي نقرأ سطرًا واحدًا في كل مرة من بدايته، ورغم أن برامج معالجة الكلمات تنتهج هذا النهج أيضًا، إلا أنها تحتفظ بالملف كله في الذاكرة أثناء عملنا عليه، ثم تنفذ عملية الكتابة كلها عند إغلاقها، وهناك فرق آخر بين الملفات البرمجية والعادية، وهو أننا حين نبرمج فإننا نفتح الملف للقراءة فقط أو الكتابة فقط، وننفذ الكتابة بإنشاء ملف جديد من الصفر أو إلغاء ملف موجود والكتابة فوقه، أو بإلحاق الملف الجديد إلى ملف موجود من قبل، كما يمكن العودة في الملفات البرمجية إلى بداية الملف أثناء معالجته. الدخل والخرج بالملفات لنطبق مثالًا عمليًا، ولنفترض وجود ملف اسمه menu.txt، يحتوي على قائمة من الوجبات: steak & eggs steak & chips steak & steak لنكتب برنامجًا يقرأ الملف ويعرض الخرج كما يفعل أمر cat في صدفة يونكس، وأمر type في صدفة ويندوز. # (r) افتح الملف للقراءة inp = open("menu.txt","r") # اقرأ الملف سطرًا سطرًا for line in inp: print( line ) # أغلق الملف مرة أخرى inp.close() تأخذ open()‎ وسيطين، الأول هو اسم الملف الذي يمكن أن يكون متغيرًا أو سلسلةً نصيةً مجردةً literal string كما فعلنا هنا، والوسيط الثاني هو الوضع الذي نفتح به الملف، حيث يمكن فتحه للقراءة فقط r أو للكتابة w، كما نحدد هل هو للاستخدام النصي أم للاستخدام الثنائي binary، وذلك بإضافة محرف b إلى r أو w في حالة الاستخدام الثنائي، كما يلي: open(fn,"rb") لاحظ أننا نقرأ الملف في حلقة for، التي تتصرف في بايثون مثل حلقة foreach في تجميعة، إذ تعيد كل عنصر في التجميعة، ويمكن النظر إلى الملف النصي على أنه تجميعة من الأسطر، وهكذا تقرأ الحلقة كل سطر على حدة. ثم نغلق الملف باستخدام دالة مسبوقة بمتغير ملف، وتُعرف هذه الصياغة باستدعاء التابع method invocation كما شرحنا في المقال السابق حول البرمجة باستخدام الوحدات، ويمكن تقريب مفهوم متغير الملف بأنه وحدة تحتوي الدوال التي تعمل على الملفات، ونستوردها في كل مرة ننشئ متغيرًا من نوع ملف. تُغلَق الملفات تلقائيًا في بايثون في نهاية البرنامج، لكن من الأفضل أن نتعود على إغلاق الملفات إغلاقًا صريحًا، لأن نظام التشغيل قد لا يكتب البيانات إلى الملف إلا عند إغلاقه لأسباب تتعلق بتسريع أدائه، مما يعني أن بياناتنا قد لا تكتَب إلى الملف إذا خرج البرنامج فجأةً، لذا يفضَّل التعود على إغلاق الملف بمجرد الانتهاء من الكتابة فيه. كما أننا لم نحدد المسار الكامل للملف الموجود في الشيفرة أعلاه، لذا سنتعامل مع الملف على أنه موجود في المجلد الحالي، لكن يمكن تمرير الاسم الكامل للمسار إلى open()‎ بدلًا من اسم الملف مجردًا. قد نواجه مشكلةً بسيطةً في هذا الشأن في نظام ويندوز، إذ يُستخدم المحرف \ لفصل المجلدات في مسارات ويندوز، لكن نفس المحرف له معنىً خاص آخر في سلاسل بايثون، لذلك يفضل استخدام المحرف / عند تحديد المسارات في بايثون لضمان عملها على أي نظام تشغيل بما فيها ويندوز. عند التعامل مع ملف طويل لن نستطيع عرضه كاملًا على الشاشة مرةً واحدةً، وعلينا التوقف بعد كل شاشة كاملة، لذا نستخدم المتغير line_count ونزيده بعد قراءة كل سطر، ثم نتحقق هل يساوي 25 -إذا كانت الشاشة تحتوي على 25 سطرًا- أم لا، ثم سنطلب من المستخدم أن يضغط على زر ما عندما يساوي 25، وليكن زر الإدخال مثلًا، قبل إعادة ضبط عداد line_count إلى الصفر والمتابعة بعد ذلك. توجد طريقة أخرى لقراءة الملفات باستخدام حلقة while وتابع لكائن الملف اسمه readline()‎، وتمتاز هذه الطريقة بأننا نستطيع التوقف عن معالجة الملف بمجرد العثور على البيانات التي نريدها، مما يسرع الأداء كثيرًا إذا كنا نتعامل مع ملفات طويلة، لكنها أعقد، لذا سننظر في مثالنا السابق باستخدام هذه الطريقة لنشرحها: # افتح الملف للقراءة inp = open("menu.txt","r") # اقرأ الملف واطبع كل سطر while True: line = inp.readline() if not line: break print( line ) # أغلق الملف inp.close() لاحظ أننا استخدمنا تقنية break التي ذكرناها في مقال مقدمة في البرمجة الشرطية، وذلك للخروج من الحلقة إذا كان السطر فارغًا، إذ يَعُد السطر الفارغ بالاصطلاح البولياني قيمة false، ثم طبعنا كل سطر وكررنا الحلقة مرةً أخرى، ثم أغلقنا الملف بعد الخروج من حلقة while. وإذا رغبنا في التوقف عند نقطة ما في الملف، فسيكون ذلك بشرط فرع branch condition داخل حلقة while، فإذا اكتشفنا شرط الإيقاف فسنستدعي break أيضًا لتنتهي الحلقة. هذا كل ما يتعلق بفتح الملف وقراءته والتعامل معه بالطريقة التي تريدها مما سبق، لكن في المثال السابق ملاحظة صغيرة، فالأسطر المقروءة من الملف لها محرف سطر جديد في نهايتها، لذا سيكون لدينا أسطر فارغة إذا استخدمنا print()‎ التي تضيف محرف السطر الجديد الخاص بها، ولتجنب هذا الأمر وفّرت بايثون تابع سلسلة نصية اسمه strip()‎ يحذف المسافات البيضاء أو المحارف التي لا تُطبع من كلا طرفي السلسلة النصية، كما توجد توابع مثل rstrip وlstrip اللذين يُحذفان المسافات من جانب واحد فقط، وبناءً عليه نستطيع إصلاح مشكلة المسافات إذا استبدلنا السطر التالي بسطر print()‎ السابق: print( line.rstrip() ) # احذف الجانب الأيمن فقط end إذا أردنا إنشاء أمر نسخ في بايثون، فإننا نفتح ملفًا جديدًا في وضع الكتابة ثم نكتب الأسطر إليه بدلًا من طباعتها، سننشئ ملفًا اسمه MENU.BAK يكافئ الملف MENU.TXT: # افتح الملفين للقراءة والكتابة على الترتيب inp = open("menu.txt","r") outp = open("menu.bak","w") # اقرأ الملف ناسخًا كل سطر إلى الملف الجديد for line in inp: outp.write(line) print( "1 file copied..." ) # أغلق الملفات inp.close() outp.close() لاحظ أننا أضفنا تعليمة print()‎ في النهاية كي نطمئن المستخدم بحدوث شيء ما، لأن التغذية الراجعة للمستخدم تنفع ولا تضر، ولم نتعرض هنا لمشاكل مع محارف الأسطر الجديدة لأننا كتبنا نفس السطر line الذي قرأناه، لكن إذا كنا نكتب سلاسل نصيةً ننشئها بأنفسنا، أو سلاسل حذفنا منها محارف الأسطر الجديدة من قبل، فسيكون علينا إضافة سطر جديد إلى نهاية سلسلة الخرج، كما يلي: outp.write(line + '\n') # \n => سطر جديد لننظر في كيفية تطبيق هذا في برنامج النسخ. سنضيف تاريخ اليوم في الأعلى بدلًا من مجرد نسخ القائمة، وبهذا نستطيع إنشاء قائمة يومية من ملف نصي للوجبات يسهل تعديله، وكل ما علينا فعله هو كتابة بعض الأسطر في بداية الملف الجديد قبل نسخ ملف menu.txt، كما يلي: import time # MENU.TXT أنشئ القائمة اليومية وفقًا لـ # افتح الملفات للقراءة والكتابة على الترتيب inp = open("menu.txt","r") outp = open("menu.prn","w") # أنشئ سلسلة تاريخ اليوم today = time.localtime(time.time()) theDate = time.strftime("%A %B %d", today) # أضف نص الإعلان وسطرًا فارغًا outp.write("Menu for %s\n\n" % theDate) # إلى ملف جديد menu.txt انسخ كل سطر من for line in inp: outp.write(line) print( "Menu created for %s..." % theDate ) # أغلق الملفات inp.close() outp.close() لاحظ أننا نستخدم وحدة time للحصول على تاريخ اليوم time.time()‎، وتحويله إلى صف tuple من القيم ‎(time.localtime())‎ التي تُستخدم لاحقًا بواسطة time.strftime()‎ -انظر توثيق الوقت والتاريخ في بايثون من موسوعة حسوب- لإنتاج سلسلة نصية تبدو بالشكل التالي عندما ندخلها في رسالة عنوان باستخدام تنسيق السلاسل النصية: Menu for Sunday September 19 Spam & Eggs Spam &... لم يُطبع إلا سطر واحد فارغ رغم إضافتنا لمحرفين ‎\n في نهاية السلسلة النصية، وذلك لأن أحدهما كان السطر الجديد عند نهاية العنوان نفسه، وهذا يظهر جانبًا مزعجًا في إدارة عملية إنشاء محارف الأسطر الجديدة وحذفها. إلحاق البيانات ثمة أمر آخر في معالجة الملفات، إذ قد نرغب في إلحاق بيانات بنهاية ملف موجود، ويمكن فعل هذا بفتح الملف للإدخال، وقراءة البيانات إلى قائمة، ثم إلحاق البيانات إليها، ثم كتابة القائمة كلها إلى إصدار جديد من الملف القديم، ولن يُحدث هذا مشكلةً إذا كان الملف قصيرًا؛ أما إذا كان كبيرًا -أكبر من 100 ميجا بايت مثلًا-، فستنفد الذاكرة التي يجب أن تحتفظ بالقائمة، وسيستغرق الأمر زمنًا طويلًا. لحسن الحظ لدينا وضع يسمى "a" والذي نستطيع تمريره إلى open()‎، فيسمح لنا بإلحاق البيانات مباشرةً إلى ملف موجود بمجرد كتابتها، وإذا لم يكن الملف موجودًا، فسيُفتح ملف جديد كما لو كنا قد حددنا الوضع "w". لنفترض أن لدينا ملف سجل نستخدمه لالتقاط رسائل الخطأ، ولا نريد أن نحذف رسائل الخطأ الموجودة كلما جاءت رسالة جديدة، لذا يمكن إلحاق الخطأ في الملف كما يلي: def logError(msg): err = open("Errors.log","a") err.write(msg) err.close() قد نرغب -من الناحية العملية- بتقييد حجم الملف بشكل ما، والتقنية الشائعة لذلك هي إنشاء اسم ملف مبني على التاريخ، فإذا تغير التاريخ فسننشئ ملفًا جديدًا، مما يسهل على مشرفي النظام أن يجدوا أخطاء يوم بعينه، وأرشفة ملفات الأخطاء القديمة إذا لم نعد بحاجة إليها. تذكر أن وحدة time من مثال القائمة أعلاه يمكن استخدامها لإيجاد التاريخ الحالي. بنية With في بايثون قدم الإصدار الثالث من بايثون طريقةً جديدةً سهلةً للعمل مع الملفات، لا سيما عند التكرار على محتوياتها، وتستخدم هذه الطريقة بنيةً جديدةً اسمها with، بالشكل التالي: with open('Errors.log',"r") as inp: for line in inp: print( line ) لاحظ أننا لم نستخدم close()‎، إذ تضمن with إغلاق الملف في نهايتها، وهكذا يكون التعامل مع الملفات أكثر موثوقيةً، وهذه الطريقة هي التي يُنصح بها لفتح الملفات في الإصدار الثالث من بايثون، وقد اخترنا استخدام الأسلوب القديم open/close لأن أغلب لغات البرمجة تستخدمه، وهو أكثر وضوحًا وصراحةً من أسلوب with، لكن إذا أردت أن تستخدم بايثون خاصةً فمن الأفضل استخدام with. بعض العثرات في أنظمة التشغيل تتعامل أنظمة التشغيل مع الملفات بطرق مختلفة، مما قد يسبب مشاكل في برامجنا إذا أردناها أن تعمل على عدة أنظمة تشغيل، ونخص بالذكر منها مشكلتين اثنتين: الأسطر الجديدة تشكل الملفات النصية والأسطر الجديدة منطقةً غامضةً تختلف فيها أنظمة التشغيل من حيث كيفية تنفيذها، وتمتد جذور تلك الاختلافات إلى الأيام الأولى لتواصل البيانات والتحكم في الآلات الكاتبة البرقية teleprinters الميكانيكية، وملخص الأمر أن لدينا ثلاثة طرق مختلفة للإشارة إلى السطر الجديد: ‎\r: محرف إعادة العربة (CR: Carriage Return). ‎\n: محرف تغذية السطر (LF: Line Feed). ‎\r\n زوج CR/LF. تُستخدم التقنيات الثلاث في أنظمة تشغيل مختلفة، فنظام MS DOS مثلًا -وويندوز بالتبعية- يستخدم التقنية الثالثة، أما يونكس -بما في ذلك لينكس- فيستخدم الطريقة الثانية، بينما تستخدم أبل الطريقة الأولى في نظام ماك أو إس القديم، وتستخدم الطريقة الثانية في نظام MacOS X وما بعده بما أن هذا النظام ما هو إلا يونكس. وعلى المبرمج أن يجري اختبارات كثيرةً ويتخذ إجراءات مختلفةً لكل نظام تشغيل، ليتعامل مع هذا التعدد لنهايات الأسطر. لكن اللغات الحديثة -بما في ذلك بايثون- توفر تسهيلات للتعامل مع هذه الفوضى بالنيابة عنا، وتحل بايثون هذه المشكلة في وحدة os التي تعرّف متغيرًا اسمه linesep يُضبط على محرف السطر الجديد الذي يستخدمه نظام التشغيل، مما يسهل عملية إضافة الأسطر الجديدة، كما ينتبه التابع rstrip()‎ إلى نظام التشغيل عندما يحذف هذه المحارف، وهكذا نستخدم هذا التابع لنريح أنفسنا من عناء التفكير في الأسطر الجديدة التي تُحذف من الأسطر المقروءة من الملف، كما نضيف os.linesep إلى السلاسل النصية التي تُكتب إلى الملف. فإذا أنشأنا ملفًا على نظام تشغيل ثم عالجناه على نظام تشغيل آخر غير متوافق مع الأول، فلا نستطيع إلا أن نوازن نهاية السطر مع os.linesep لنعرف الفرق. تحديد المسارات هذه المشكلة تخص مستخدمي ويندوز أكثر من غيرهم، فقد ذكرنا سابقًا أن كل نظام تشغيل يحدد مسارات الملفات باستخدام محارف مختلفة لفصل الأقراص والمجلدات والملفات عن بعضها، وأن الحل العام لهذا هو استخدام وحدة os التي توفر متغير os.sep لتعريف محرف فصل المسار الخاص بالمنصة الحالية، ولن نحتاج إلى ذلك كثيرًا في المواقف العملية، لأن المسار سيختلف لكل حاسوب على أي حال، لذا سنُدخِل المسار الكامل مباشرةً في سلسلة نصية -ربما سلسلة لكل نظام تشغيل نعمل عليه-، لكن لهذا عقبة كبيرة بالنسبة لمستخدمي ويندوز، فقد رأينا في القسم السابق أن بايثون تتعامل مع السلسلة ‎'\n'‎ على أنها محرف السطر الجديد، أي أنها تأخذ محرفين وتعاملهما مثل محرف واحد، ولدينا كثير من مثل هذه التسلسلات الخاصة من المحارف التي تبدأ بشرطة مائلة خلفية \، ومنها: ‎\n: سطر جديد. ‎\r: إعادة العربة. ‎\t: جدول أفقي. ‎\v: جدول رأسي، يعني أحيانًا صفحةُ جديدة. ‎\b: زر backspace. ‎\0nn: أي شيفرة ثمانية عشوائية، مثل ‎\033 التي تشير إلى زر الهروب Esc. فإذا كان لدينا ملف اسمه test.dat ونريد فتحه في بايثون من خلال تحديد مسار ويندوز كامل، فستكون الشيفرة: >>> f = open('C:\test.dat') لكن ما يحدث هو أن بايثون سترى الزوج ‎\t على أنه محرف جدول وستخبرنا أنها لا تستطيع إيجاد ملف باسم 😄 est.dat، ولحل هذه المشكلة لدينا ثلاث طرق، هي: نضع r أمام السلسلة النصية، لنخبر بايثون أن تتجاهل أي شرطة مائلة خلفية، وتعاملها على أنها سلسلة نصية خام. نستخدم شرطات مائلة أمامية / بدلًا من الخلفية، وستتوافق بايثون مع ويندوز ليخرجا لنا المسار، وهذا الحل يجعل الشيفرة قابلةً للعمل على أنظمة التشغيل الأخرى. استخدام شرطتين خلفيتين \\ بما أن بايثون ترى محرفي الشرطتين المزدوجتين على أنهما شرطة خلفية واحدة. وعلى ذلك سيفتح أي سطر مما يلي ملف البيانات الخاص بنا بشكل سليم: >>> f = open(r'C:\test.dat') >>> f = open('C:/test.dat') >>> f = open('C:\\test.dat') لاحظ أن هذه المشكلة مقصورة على السلاسل المجردة التي نكتبها في شيفرة برنامجنا، أما إذا قرئت سلاسل المسار من ملف أو من المستخدم، فستفسر بايثون محارف \ وتستخدمها كما هي دون مشاكل. دليل جهات الاتصال لقد كتبنا دليل جهات اتصال في مقال البيانات وأنواعها، ثم زدنا عليه وطورناه في فصل قراءة البيانات من المستخدم، وسنجعله في هذا المقال تطبيقًا مفيدًا بحفظه في ملف، إلى جانب قراءة ذلك الملف عند بدء التشغيل، وبما أننا سنفعل ذلك من خلال كتابة بعض الدوال، فسنستخدم بعضًا مما شرحناه في المقالات السابقة من هذه السلسلة. سيحتاج التصميم الأولي دالةً لقراءة الملف عند بدء التشغيل، ودالةً أخرى عند نهاية البرنامج، كما سننشئ دالةً تزود المستخدم بقائمة من الخيارات، ودالةً مستقلة لكل خيار في تلك القائمة، ستسمح القائمة للمستخدم بما يلي: إضافة مدخل في دليل جهات الاتصال. حذف مدخل منه. البحث عن مدخل موجود من قبل وعرضه. الخروج من البرنامج. تحميل دليل جهات الاتصال import os filename = "addbook.dat" def readBook(book): if os.path.exists(filename): with open(filename,'r') as store: for line in store: name = line.rstrip() entry = next(store).rstrip() book[name] = entry نلاحظ هنا أننا نستورد الوحدة os التي نستخدمها للتحقق من أن مسار الملف موجود قبل فتحه، ونعرِّف اسم الملف مثل متغير مستوى وحدة module level variable لنستخدمه في تحميل البيانات وحفظها. سنستخدم أيضًا rstrip()‎ لحذف محرف السطر الجديد من نهاية السطر، ودالة next()‎ التي تجلب السطر التالي من الملف إلى داخل الحلقة، وهذا يعني أننا نقرأ سطرين في نفس الوقت أثناء عمل الحلقة، ودالة next هي جزء من خاصية في بايثون تسمى بالمكرر، وهي خاصية لن نشرحها في هذه السلسلة بما أنها خاصة ببايثون كلغة برمجة، لكننا سنقول أن كل تجميعات بايثون وملفاتها وبعض الأمور الأخرى يُنظر إليها على أنها مكرَّرات أو أنواع قابلة للتكرار، ويمكن معرفة المزيد عن هذه الخاصية في توثيق بايثون. حفظ دليل جهات الاتصال def saveBook(book): with open(filename, 'w') as store: for name,entry in book.items(): store.write(name + '\n') store.write(entry + '\n') لاحظ أننا نحتاج إلى إضافة محرف السطر الجديد ‎'\n'‎ عندما نكتب البيانات، وأننا نكتب سطرين لكل إدخال، فهذا يعكس حقيقة أننا عالجنا سطرين عند قراءة الملف. الحصول على مدخلات المستخدم def getChoice(menu, length): print( menu ) prompt = "Select a choice(1-%d): " % length choice = int( input(prompt) ) return choice نلاحظ أننا نستقبل معامِل طول يخبرنا عدد المداخل الموجودة، مما يسمح لنا بإنشاء محث يحدد نطاق الأعداد المناسب. إضافة مدخل def addEntry(book): name = input("Enter a name: ") entry = input("Enter street, town and phone number: ") book[name] = entry حذف مدخل def removeEntry(book): name = input("Enter a name: ") del(book[name]) العثور على مدخل def findEntry(book): name = input("Enter a name: ") if name in book: print( name, book[name] ) else: print( "Sorry, no entry for: ", name ) الخروج من البرنامج لن نكتب دالةً مستقلةً للخروج من البرنامج، بل سنجعل خيار الإنهاء اختبارًا في حلقة while الخاصة بالقائمة، لذا سيكون البرنامج الرئيسي كما يلي: def main(): theMenu = ''' 1) إضافة مدخل 2) حذف مدخل 3) العثور على مدخل 4) الخروج والحفظ ''' theBook = {} readBook(theBook) while True: choice = getChoice(theMenu, 4) if choice == 4: break if choice == 1: addEntry(theBook) elif choice == 2: removeEntry(theBook) elif choice == 3: findEntry(theBook) else: print( "Invalid choice, try again" ) saveBook(theBook) لم يبق الآن إلا استدعاء main()‎ عند تشغيل البرنامج، وهنا سنستخدم القليل من سحر بايثون: if __name__ == "__main__": main() تسمح لنا هذه الشيفرة الغامضة باستخدام أي ملف بايثون مثل وحدة، وذلك باستيراده import أو مثل برنامج عبر تشغيله، والفرق هنا هو أن بايثون تضبط المتغير الداخلي __name__ عند استيراد البرنامج على اسم الوحدة، لكن إذا شغّلنا الملف مثل برنامج، فستُضبط قيمة __name__ على "__main__"، هذا يعني أن دالة main()‎ لا تُستدعى إلا إذا شغلنا الملف مثل برنامج وليس عند استيراده. إذا كتبنا هذه الشيفرة في ملف نصي وحفظناه باسم addressbook.py، فيجب أن يكون قابلًا للتشغيل في أي محث لنظام تشغيل بكتابة ما يلي: C:\PROJECTS> python addressbook.py أو بالنقر المزدوج عليه مثل أي ملف في مدير الملفات في ويندوز، ليشغَّل في نافذة CMD خاصة به، تُغلَق عند تحديد خيار الإغلاق، أو باستخدام ما يلي في لينكس: $ python addressbook.py يُعَد هذا البرنامج المكون من ستين سطرًا نموذجًا لما يجب أن تكون قادرًا على كتابته الآن بنفسك، وهو أداة مفيدة على حالته تلك، رغم أننا سنضيف إليه بعض الأشياء التي ستحسّنه في المقال التالي. جافاسكربت وVBScript ليس لدى جافاسكربت وVBScript القدرة على معالجة الملفات، وذلك لمنع أي أحد من قراءة ملفاتك عند تحميلك لصفحة ويب مثلًا، لكن هذا يقيد فائدة اللغة نفسها ونطاق استخدامها من ناحية أخرى. توجد طريقة لجعلهما تعالجان الملفات باستخدام WSH كما فعلنا في الوحدات القابلة لإعادة الاستخدام من قبل، إذ توفر WSH كائن FileSystem يسمح لأي لغة WSH بقراءة الملفات. سننظر في مثال جافاسكربت بالتفصيل، ثم نعرض شيفرةً مشابهةً من لغة VBScript من أجل الموازنة بينهما، وكما رأينا من قبل فإن العناصر الأساسية ما هي إلا استدعاءات لكائنات WScript. لربما يجب أن نشرح نموذج الكائن FileSystem قبل أن ننظر في الشيفرة، فنموذج الكائن هو مجموعة من الكائنات المرتبطة ببعضها، والتي يمكن للمبرمج استخدامها. يتكون نموذج كائن FileSystem من كائن FSO وعدد من كائنات File، بما في ذلك كائن TextFile الذي سنستخدمه، كما توجد بعض الكائنات المساعدة مثل TextStream. ما سنفعله هنا هو إنشاء نسخة من كائن FSO ثم نستخدمها لإنشاء كائنات TextFile، ثم ننشئ كائنات TextStream كي نستطيع قراءة النصوص فيها وكتابتها أيضًا، كذلك فكائنات TextStream نفسها هي التي نقرؤها من الملفات أو نكتبها. اكتب الشيفرة أدناه في ملف باسم testFiles.js وشغله باستخدام cscript كما ذكرنا في قسم WSH من المقال السابق. فتح ملف يجب أن ننشئ كائن FSO أولًا، ثم ننشئ كائن TextFile منه كي نتمكن من فتح ملف في WSH: var fileName, fso, txtFile, outFile, line; // احصل على اسم الملف fso = new ActiveXObject("Scripting.FileSystemObject"); WScript.Echo("What file name? "); fileName = WScript.StdIn.Readline(); // للقراءة inFile افتح // للكتابة outFile افتح inFile = fso.OpenTextFile(fileName, 1); // mode 1 = Read fileName = fileName + ".BAK" outFile = fso.CreateTextFile(fileName); إغلاق الملفات inFile.close(); outFile.close(); شرح المثال في VBScript احفظ ما يلي في testFiles.ws وشغله باستخدام: cscript testfiles.ws أو ضع الجزء الذي بين وسوم script في ملف باسم testFile.vbs وشغله، حيث تسمح صيغة ws. بدمج شيفرة جافاسكربت وVBScript في نفس الملف باستخدام عدة وسوم script: <?xml version="1.0"?> <job> <script type="text/vbscript"> Dim fso, inFile, outFile, inFileName, outFileName Set fso = CreateObject("Scripting.FileSystemObject") WScript.Echo "Type a filename to backup" inFileName = WScript.StdIn.ReadLine outFileName = inFileName & ".BAK" ' open the files Set inFile = fso.OpenTextFile(inFileName, 1) Set outFile = fso.CreateTextFile(outFileName) ' read the file and write to the backup copy Do While not inFile.AtEndOfStream line = inFile.ReadLine outFile.WriteLine(line) Loop ' close both files inFile.Close outFile.Close WScript.Echo inFileName & " backed up to " & outFileName </script> </job> التعامل مع الملفات غير النصية يُعَد التعامل مع الملفات النصية أكثر ما يفعله المبرمج، غير أننا قد نحتاج إلى معالجة بيانات ثنائية أحيانًا، وسنشرح هذا الجزء في بايثون فقط لندرة حدوثه في جافاسكربت وVBScript. الترميز الثنائي للبيانات يجب أن ننظر في كيفية تمثيل البيانات وتخزينها على الحاسوب قبل أن نتحدث عن كيفية الوصول إليها داخل ملف ثنائي، حيث تُخزَّن البيانات في هيئة تسلسلات من الأرقام الثنائية أو البتات، والتي تُجمع في مجموعات من 8 بتات تسمى البايت، أو 16بتًا تسمى الكلمة، في حين أن المجموعة التي تتكون من 4 بتات تسمى أحيانًا بالحلمة. قد يكون للبايت الواحد نمط من 256 نمط للبتات، وتعطى هذه الأنماط قيمًا من 0 إلى 255، كما يجب أن تُحوَّل المعلومات التي نعالجها في برامجنا من سلاسل نصية وأعداد وغيرها إلى سلاسل من تلك البايتات، لذا يخصَّص نمط بايت معين لكل محرف نستخدمه في السلاسل النصية، ورغم وجود عدة نظم ترميز للبيانات، إلا أن أشهرها هو ترميز آسكي ASCII، لكن هذا الترميز لا يحوي إلا 128 محرفًا فقط، وهذا بالكاد يكفي للغة الإنجليزية فقط، لذا طوِّر معيار ترميز جديد سمي بالترميز الموحد أو اليونيكود Unicode، والذي يَستخدم كلمات البيانات لتمثيل المحارف بدلًا من البايتات، ويشتمل على أكثر من مليون محرف، ثم يمكن بعد ذلك ترميز تلك المحارف في مجرى بيانات مضغوط أكثر. إن أكثر ترميزات اليونيكود شيوعًا هو UTF-8، ويتوافق توافقًا كبيرًا مع آسكي، بحيث أن أي ملف متوافق مع آسكي يتوافق أيضًا مع UTF-8، رغم أن العكس قد لا يكون صحيحًا. يوفر اليونيكود عددًا من الترميزات التي يعرِّف كل منها البايت الذي يمثل قيمةً عدديةً من اليونيكود، أو "نقطةً رمزيةً" وفق اصطلاح اليونيكود. وهذا النظام المعقد هو الثمن الذي كان يجب دفعه من أجل بناء شبكة حواسيب عالمية تعمل بمختلف اللغات التي يستخدمها البشر، لكن المتحدثين بالإنجليزية لم يكن عليهم القلق بشأن اليونيكود إلا عند قراءة البيانات من ملف ثنائي، على الرغم من أن الإصدار الثالث لبايثون يَعُد السلاسل النصية سلاسل يونيكود، لذا يجب أن نعلم الترميز المستخدم من أجل تفسير البيانات الثنائية تفسيرًا صحيحًا. تدعم بايثون نصوص اليونيكود دعمًا كاملًا، فتنظر إلى سلسلة المحارف المرمَّزة على أنها سلسلة بايتات لها النوع bytes، بينما يكون للسلسلة غير المرمزة النوع str، ويكون الترميز الافتراضي هو UTF-8، ولهذا بعض شواذ، نظريًا على الأقل، ولن نشرح استخدام المحارف التي ليست UTF-8 في هذه السلسلة، لكن يمكن مراجعة مستند How-To في موقع بايثون. ما نريد الإشارة إليه من كل هذا هو أن المجرى الثنائي لنص اليونيكود المرمَّز يعامَل مثل سلسلة من البايتات، وتوفر بايثون دوالًا لتحويل (أو فك ترميز) قيم bytes لتكون قيم str، بالمثل يجب أن تحوَّل الأعداد إلى ترميزات ثنائية أيضًا، فعلى الرغم من أن قيم البايتات تكفي في حالة الأعداد الصغيرة، إلا أن الأعداد الأكبر من 255 أو الأعداد السالبة أو الكسور تحتاج إلى عمل آخر، وقد ظهرت عدة ترميزات معيارية للبيانات العددية، والتي تستخدمها أغلب لغات البرمجة ونظم التشغيل، فمثلًا: يعرِّف المعهد الأمريكي للهندسة الكهربية والإلكترونية IEEE عدة ترميزات للأعداد ذات الفاصلة العائمة floating point numbers، وتهدف هذه الجهود إلى حل مشكلة التفسير الصحيح للبيانات الثنائية، فمن المهم للغاية أن نحولها إلى النوع الصحيح عند قراءتها، بما أنه يتعين علينا تفسير أنماط البتات الخام إلى النوع الصحيح المناسب للبرنامج الذي نعمل عليه، وعلى ذلك من الممكن أن نفسر مجرى بيانات كُتب أصلأ على أنه سلسلة نصية في صورة مجموعة من الأعداد ذات الفاصلة العائمة، ورغم أن المعنى الأصلي له سيُفقد، إلا أن أنماط البتات يمكن أن تمثل أي واحد منهما. فتح الملفات الثنائية وإغلاقها يتمثل الفرق الجوهري بين الملفات النصية والثنائية في أن الملفات النصية تتكون من ثمانيات octets -جمع ثُماني، وهو الشيء المكون من ثمانية أجزاء-، لكن الاسم الأشهر لها هو بايتات، ويمثل كل بايت حرفًا. تُحدَّد نهاية الملف بنمط بايت خاص يُعرف باسم EOF، وهو اختصار لعبارة نهاية الملف End Of File؛ بينما يحتوي الملف الثنائي على بيانات ثنائية عشوائية، ومن ثم لا يمكن استخدام قيمة بعينها لتحديد نهاية الملف، لذا نحتاج إلى وضع عمليات مختلف لقراءة تلك الملفات، فعند فتح ملف ثنائي في بايثون أو في أي لغة أخرى، فيجب أن نحدد أنه يُفتَح في الوضع الثنائي، أو المخاطرة بقطع البيانات التي نقرؤها عند أول محرف eof تجده بايثون في تلك البيانات. يمكن تنفيذ ذلك في بايثون بإضافة b إلى معامِل الوضع mode parameter كما يلي: binfile = open("aBinaryFile.bin","rb") لا يختلف هذا عن فتح ملف نصي إلا في قيمة الوضع "rb"، ونستطيع استخدام أي وضع آخر، لكن مع إضافة حرف b، فنستخدم "wb" للكتابة، و"ab" للإلحاق، كما لا يختلف إغلاق الملف الثنائي عن الملف النصي، فنستدعي التابع close()‎ لكائن الملف المفتوح: binfile.close() وبما أننا فتحنا الملف في الوضع الثنائي، فلا داعي لإعطاء بايثون أي معلومات إضافية، فهي تعرف كيف تغلق الملف. الوحدة struct توفّر بايثون وحدةً اسمها struct لترميز البيانات الثنائية وفك ترميزها، وهي تتصرف مثل سلاسل التنسيق التي استخدمناها لطباعة البيانات المختلطة، إذ توفر سلسلةً تمثل البيانات التي نقرؤها وتطبقها على مجرى البايتات الذي نحاول تفسيره، ومن الممكن استخدام هذه الوحدة لتحويل مجموعة من البيانات إلى مجرى البايت للكتابة، إما إلى ملف ثنائي أو حتى خط اتصالات communications line. هناك الكثير من رموز تنسيق التحويل المختلفة، لكننا لن نستخدم إلا رموز الأعداد الصحيحة integers والسلاسل النصية strings هنا؛ أما البقية فيمكن قراءة المزيد عنها في توثيق بايثون الرسمي. إن رمز تنسيق التحويل للأعداد الصحيحة هو i، ورمز السلاسل النصية s، وتتكون سلاسل تنسيق الوحدة struct من سلاسل من الرموز فيها أرقام مسبقة التعليق pre-pended، حيث توضح عدد العناصر التي نحتاج إليها، مع استثناء لرمز s الذي يشير العدد مسبق التعليق فيه إلى طول السلسلة النصية، حيث تعني 4s مثلًا سلسلةً من أربعة محارف (أربعة محارف وليس أربعة سلاسل نصية). لنفترض أننا نريد كتابة تفاصيل العنوان، في برنامج دليل جهات الاتصال الذي شرحناه أعلاه، في صورة بيانات ثنائية، حيث يكون رقم الشارع فيها عددًا صحيحًا والبقية سلسلةً نصيةً. رغم أن هذا الاختيار سيء لأن أرقام الشوارع تتضمن حروفًا أحيانًا، إلا أن سلسلة التنسيق ستكون كما يلي: 'i34s' # نفترض وجود 34 محرف في العنوان ولكي نعالج مسألة الأطوال المختلفة للعناوين، فسنكتب دالةً تنشئ السلسلة الثنائية كما يلي: def formatAddress(address): fields = address.split() number = int(fields[0]) rest = bytes(' '.join(fields[1:],'utf8') # أنشئ سلسلة بايت format = "i%ds" % len(rest) # أنشئ سلسلة التنسيق return struct.pack(format, number, rest) لقد استخدمنا تابع السلسلة split()‎ أعلاه لتقسيم سلسلة العنوان النصية إلى أجزائها، وقسمناها في حالتنا إلى قائمة من الكلمات، ثم استخرجنا الجزء الأول ليكون رقم الشارع، بعد ذلك استخدمنا تابع سلسلةٍ نصية آخر هو join لدمج الحقول المتبقية معًا مع الفصل بينها بمسافة. كذلك نحتاج إلى تحويل السلسلة النصية إلى مصفوفة bytes لأن هذا هو ما تستخدمه وحدة srtuct، ويكون طول تلك السلسلة هو ما نحتاج إليه في سلسلة تنسيق struct، لذا نستخدم دالة len()‎ بالتوازي مع سلسلة تنسيق عادية لبناء سلسلة تنسيق struct. ستعيد formatAddress()‎ تسلسلًا من البايتات يحتوي على التمثيل الثنائي لعنواننا، وبما أن لدينا البيانات الثنائية فسنرى كيف نستطيع كتابتها إلى ملف ثنائي، ثم قراءتها مرةً أخرى. القراءة والكتابة باستخدام struct لننشئ ملفًا ثنائيًا يحتوي على سطر عنوان واحد باستخدام الدالة formatAddress()‎ المعرَّفة أعلاه، وهنا سنحتاج إلى فتح الملف للكتابة في وضع 'wb'، وترميز البيانات وكتابتها إلى الملف، ثم إغلاق الملف. import struct f = open('address.bin','wb') data = "10 Some St, Anytown, 0171 234 8765" bindata = formatAddress(data) print( "Binary data before saving: ", repr(bindata) ) f.write(bindata) f.close() نستطيع التحقق من أن البيانات في صورة ثنائية بفتح address.bin في محرر نصي، وسنرى حينها أن المحارف لا زالت قابلةً للقراءة لكن الرقم اختفى، فإذا فتحنا الملف في محرر نصي يدعم الملفات الثنائية مثل ViM أو Emacs، فسنجد أن بداية الملف فيها 4 بايت، حيث سيبدو الأول منها مثل محرف سطر جديد، أما البقية فتبدو أصفارًا، وذلك لأن القيمة العددية للسطر الجديد هي 10، كما نرى هنا باستخدام بايثون: >>> ord('\n') 10 تعيد الدالة ord()‎ في هذا المثال القيمة العددية لأي محرف، لذا فإن أول أربعة بايتات هي 10,0,0,0 في النظام العشري، أو 0xA,0x0,0x0,0x0 في النظام الست عشري، وهو النظام المستخدم عادةً في عرض البيانات الثنائية بما أنه أكثر اختصارًا من استخدام الأرقام الثنائية الخالصة. يُأخذ العدد الصحيح في حاسوب ذي معمارية 32 بت أربعة بايتات، لذا فإن القيمة العددية "10" قد حُولت بواسطة وحدة struct إلى تسلسل من 4 بايتات هو 10,0,0,0. وتضع معالجات إنتل البايت الأقل أهميةً في البداية، وهنا سنحصل على القيمة الثنائية الحقيقية من خلال قراءة التسلسل عكسيًا 0,0,0,10، وهي القيمة الصحيحة 10 ممثلةً بأربعة بايتات عشرية؛ أما بقية البيانات فهي السلسلة النصية الأصلية، لذا تظهر بصيغتها المحرفية العادية. يجب الانتباه إلى عدم حفظ الملف من داخل محرر Notepad، فرغم أنه يستطيع تحميل بعض الملفات الثنائية، إلا أنه لا يستطيع حفظ الملفات الثنائية، لذا سيحول القيم الثنائية إلى نصوص، مما سيؤدي إلى تخريب البيانات الحقيقية. لم يكن امتداد الملف الذي استخدمناه .bin إلا للشرح، إذ ليس له تأثير حقيقي في كون الملف نصيًا أم ثنائيًا، وتستخدم بعض أنظمة التشغيل هذه الامتدادات لتحديد البرنامج الذي يفتح الملفات، لكن يمكن تغيير الامتداد بإعادة تسمية الملف ببساطة، ولن يتغير المحتوى الذي فيه، إذ سيظل ثنائيًا أو نصيًا كما كان، فإذا أعدنا تسمية ملف نصي بحيث نجعل امتداده .exe مثلًا، فسيعامله ويندوز على أنه ملف ثنائي تنفيذي، لكننا سنحصل على خطأ عند محاولة تشغيله مثل ملف تنفيذي، وذلك لأن النص الموجود فيه ليس شيفرةً ثنائيةً تنفيذية، فإذا أرجعناه إلى التسمية الأصلية له ثم فتحناه في Notepad، فسنجده كما كان قبل التسمية بالضبط، بل لو فتحناه في Notepad أثناء تسميته بامتداد .exe لفتحه المحرر مثل ملف نصي أيضًا. نحتاج إلى أن فتح الملف في وضع rb لقراءة بياناتنا الثنائية مرةً أخرى، فنقرأ البيانات في تسلسل من البايتات ثم نغلق الملفK ونفك البيانات باستخدم سلسلة تنسيق struct. سيبقى السؤال هنا حول كيفية معرفة شكل سلسلة التنسيق، والإجابة هنا هي أننا نحتاج إلى إيجاد السلسلة الثنائية من تعريف الملف، وتوفر عدة مواقع على الويب هذه المعلومات، فشركة أدوبي مثلًا تنشر تعريف تنسيقها الثنائي لصيغة PDF الخاصة بها، وفي حالتنا هذه سنعرف أنها يجب أن تكون السلسلة التي أنشأناها في formatAddress()‎، وهي 'iNs'، حيث N رقم متغير. توفر وحدة struct بعض الدوال المساعدة التي تعيد حجم كل نوع من أنواع البيانات، لذلك فإن شغلنا محث بايثون وأجرينا بعض التجارب، فسنستطيع معرفة عدد بايتات البيانات التي سنحصل عليها لكل نوع بيانات، وهكذا نستطيع تحديد قيمة N: >>> import struct >>> print struct.calcsize('i') 4 >>> print struct.calcsize('s') 1 وبما أننا نعرف أن بياناتنا ستترك 4 بايتات للعدد وبايتًا واحدًا لكل محرف، فستكون N هي إجمالي طول البيانات ناقصًا منه 4، لنجرب استخدام هذا لقراءة ملفنا: import struct f = open('address.bin','rb') data = f.read() f.close() fmtString = "i%ds" % (len(data) - 4) number, rest = struct.unpack(fmtString, data) rest = rest.decode('utf8') #convert bytes to string address = ' '.join((str(number),rest)) print( "Address after restoring data:", address ) لاحظ أننا اضطررنا إلى تحويل rest إلى سلسلة نصية باستخدام دالة decode()‎ لأن بايثون رأتها من النوع bytes الذي لن يعمل مع join()‎. وهكذا نكون قد شرحنا ملفات البيانات الثنائية بما يكفي في هذا المقال، وقد بينا أنها تأتي بعدة تعقيدات، ولا ننصح باستخدامها ما لم يكن ثمة سبب مقنع، لكن هذا الشرح كاف لتستطيع قراءة الملفات الثنائية عند الحاجة إلى ذلك، طالما عرفت ما هي البيانات الممثلة في تلك الملفات ابتداءً. الوصول العشوائي إلى الملفات يشير الوصول العشوائي إلى التحرك مباشرةً إلى جزء بعينه من الملف، دون قراءة البيانات التي بين نقطة البدء والبيانات المطلوبة، وتوفر بعض لغات البرمجة نوع ملف مفهرس خاص يستطيع فعل ذلك بسرعة عالية، لكنه مبني في أغلب اللغات على الوصول المتتابع للملفات، الذي كنا نستخدمه منذ بداية هذه السلسلة إلى الآن. يقوم الوصول العشوائي على مفهوم مؤشر يشير إلى الموضع الحالي في الملف، أي عدد البايتات التي تفصلنا عن البداية حرفيًا، ونستطيع تحريك هذا المؤشر بالنسبة إلى موضعه الحالي أو نسبةً إلى بداية الملف، كما نستطيع أن نطلب من الملف أن يخبرنا بالمكان الحالي للمؤشر. ونستخدم طول سطر ثابت -ربما بحشو سلاسل البيانات الخاصة بنا بمسافات أو بعض المحارف الأخرى عند الحاجة- كي نقفز إلى بداية سطر ما، من خلال ضرب طول السطر بعدد الأسطر، وهذا ما يوحي بالوصول العشوائي للبيانات في الملف. أين أنا؟ لتحديد مكاننا في ملف ما نستخدم التابع tell()‎ الخاص بالملف، فإذا فتحنا ملفًا وقرأنا ثلاثة أسطر، فعندها سنستطيع أن نسأل الملف حينها كم قرأنا من الملف حتى الآن. لننظر في مثال ليتضح المعنى، حيث سننشئ ملفًا بخمسة أسطر نصية لها نفس الطول -وتساوي الطول هنا ليس ضروريًا وإنما هو لتوضيح المثال-، بعدها سنقرأ ثلاثة أسطر ونسأل أين نحن، ثم نعود إلى البداية ونقرأ سطرًا واحدًا ونقفز إلى السطر الثالث ونطبعه، ثم نعود إلى السطر الثاني، كما يلي: # (+ \n) أنشئ 5 أسطر من عشرين محرفًا testfile = open('testfile.txt','w') for i in range(5): testfile.write(str(i) * 20 + '\n') testfile.close() # اقرأ ثلاثة أسطر واسأل أين نحن testfile = open('testfile.txt','r') for line in range(3): print( testfile.readline().strip() ) position = testfile.tell() print( "At position: ", position, "bytes" ) # عد إلى البداية testfile.seek(0) print( testfile.readline().strip() ) # كرر السطر الأول lineLength = testfile.tell() testfile.seek(2*lineLength) # اذهب إلى نهاية السطر 2 print( testfile.readline().strip() ) # السطر الثالث testfile.close() لقد استخدمنا الدالة seek()‎ لتحريك المؤشر، والعملية الافتراضية هنا هي تحريكه إلى رقم البايت المحدد كما رأينا هنا، لكن يمكن إضافة وسطاء آخرين لتغيير تابع الفهرسة المستخدم. لاحظ أيضًا أن القيمة التي طبعتها tell()‎ الأولى تعتمد على طول السطر الجديد على نظامك، فنظام وندوز 10 مثلًا يطبع 66 ليشير إلى أن تسلسل السطر الجديد طوله 2 بايت، لكن بما أن هذه القيمة تتوقف على نظام التشغيل ونحن نريد أن نجعل الشيفرة محمولةً قدر الإمكان؛ فقد استخدمنا tell()‎ مرةً ثانيةً بعد قراءة سطر واحد لحساب طول كل سطر، وسترى أن مثل تلك الحيل ضرورية عند التعامل مع مشاكل المنصات المختلفة. خاتمة في نهاية هذا المقال نرجو أن تكون تعلمت ما يلي: ضرورة فتح الملفات قبل استخدامها. قراءة الملفات أو الكتابة فيها، إذ لا يمكن تنفيذ العمليتين معًا. الدالة readlines()‎ في بايثون التي تقرأ جميع الأسطر الموجودة في ملف ما، والدالة readline()‎ التي تقرأ سطرًا واحدًا في كل مرة، وهذا يوفر في الذاكرة. عدم الحاجة إلى استخدام أي من الدالتين السابقتين بما أن دالة open الخاصة ببايثون تعمل مع حلقات for. ضرورة غلق الملفات بعد استخدامها. ضرورة إضافة b إلى نهاية راية الوضع mode flag الخاصة بالملفات الثنائية، وتحتاج البيانات إلى تفسير بعد قراءتها، وذلك بواسطة وحدة struct عادةً. تفعّل كل من tell()‎ وseek()‎ الوصول شبه العشوائي pseudo-random إلى الملفات متسلسلة الوصول sequential files. ترجمة -بتصرف- للفصل الثاني عشر: Handling Files من كتاب Learning To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: كيفية التعامل مع النصوص في البرمجة المقال السابق: البرمجة باستخدام الوحدات تعلم البرمجة ما هي البرمجة ومتطلبات تعلمها؟
  21. تتعامل بعض البرامج مع مدخلات المستخدِم المباشرة مثل مدخلات لوحة المفاتيح والفأرة، ومثل تلك المدخلات ليس لها هيكل منظَّم بل تكون لحظية جزءًا جزءًا، ويجب أن يتعامل البرنامج معها أثناء حدوثها. معالجات الأحداث تخيل أن هناك واجهة لا تحوي طريقةً لمعرفة المفتاح الذي ضغطت عليه على لوحة المفاتيح، إلا بقراءة حالة المفتاح الحالية، فإذا أردت التفاعل مع ضغطات المفاتيح فسيكون عليك قراءة حالة المفتاح باستمرار كي تلتقط تغيرها قبل أن يترك إصبعك المفتاح، وسيكون من الخطير إجراء أي حسابات قد تستغرق وقتًا، إذ قد تفوتك هذه الضغطة. إنّ هذا الأسلوب متَّبع في بعض الآلات البدائية، وأفضل منه أن نجعل العتاد أو نظام التشغيل يلاحظان ضغطات المفاتيح ويضعانها في رتل، ثم يتفقد برنامج ما هذا الرتل لينظر في الأحداث المستجِدة ويتعامل مع ما يجده هناك. ويجب أن لا يهمل هذا البرنامج قراءة الرتل، بل يجب أن يتفقّده بصورة دورية، وإلا فستلاحظ أن البرنامج الذي تتعامل معه أنت غير متجاوب، ويسمى هذا الأسلوب بالاقتراع polling، لكن يفضِّل المبرمجون تجنبه، والأفضل منهما جميعًا هو جعل النظام ينبِّه شيفرة برنامجنا كلما وقع حدث ما، وتفعل المتصفحات ذلك بالسماح لنا بتسجيل الدوال على أساس معالِجات handlers لأحداث بعينها. <p>اضغط على هذا المستند لتفعيل المعالِج</p> <script> window.addEventListener("click", () => { console.log("You knocked?"); }); </script> تشير رابطة window إلى كائن مضمَّن built-in يوفره المتصفح، يمثل نافذة المتصفح التي تحتوي على المستند، كما يسجل استدعاء التابع addEventListener الخاص بها الوسيط الثاني ليُستدعَى كلما وقع الحدث الموصوف في الوسيط الأول. الأحداث وعقد DOM يُسجَّل كل معالِج حدثًا لمتصفح في سياق ما، فقد استدعينا addEventListener في المثال السابق على كائن window لتسجيل معالِج للنافذة كلها، ويمكن العثور على مثل هذا التابع في عناصر DOM أيضًا، وفي بعض أنواع الكائنات الأخرى. لا تُستدعى مستمِعات الأحداث event listeners إلا عند وقوع الحدث في سياق الكائن الذي تكون مسجلة عليه. <button>اضغط هنا</button> <p>لا يوجد معالِج هنا</p> <script> let button = document.querySelector("button"); button.addEventListener("click", () => { console.log("Button clicked."); }); </script> يربط هذا المثال المعالج بعقدة زر، وأيّ ضغطة على هذا الزر تشغِّل المعالج، بينما لا يحدث شيء عند الضغط على بقية المستند. يعطي إلحاق سمة onclick لعقدة ما التأثير نفسه، وهذا يصلح لأغلب أنواع الأحداث، إذ تستطيع إلحاق معالج من خلال سمة يكون اسمها هو اسم الحدث مسبوقًا بـ on، غير أن العقدة تحتوي على سمة onclick واحدة فقط، لذا تستطيع تسجيل معالج واحد فقط لكل عقدة بهذه الطريقة. يسمح التابع addEventListener لك بأن تضيف أي عدد من المعالجات، بحيث لا تقلق من إضافتها حتى لو كان لديك معالجات أخرى للعنصر؛ أما التابع removeEventListener الذي تستدعيه وسائط تشبه addEventListener، فإنه يحذف المعالج، انظر: <button>زر الضغطة الواحدة</button> <script> let button = document.querySelector("button"); function once() { console.log("Done."); button.removeEventListener("click", once); } button.addEventListener("click", once); </script> يجب أن تكون الدالة المعطاة لـ removeEventListener لها قيمة الدالة نفسها التي أعطيت إلى addEventListener، بحيث إذا أردت إلغاء تسجيل معالج ما، فعليك أن تعطي الدالة الاسم once في المثال كي تستطيع تمرير نفس قيمة الدالة لكلا التابعين. كائنات الأحداث يُمرَّر كائن الحدث إلى دوال معالجات الأحداث على أساس وسيط، ويحمل معلومات إضافية عن ذلك الحدث، فإذا أردنا معرفة أي زر قد ضُغِط عليه في الفأرة مثلًا، فإننا سنبحث في خاصية button لكائن الحدث. <button>اضغط عليّ كيفما شئت</button> <script> let button = document.querySelector("button"); button.addEventListener("mousedown", event => { if (event.button == 0) { console.log("Left button"); } else if (event.button == 1) { console.log("Middle button"); } else if (event.button == 2) { console.log("Right button"); } }); </script> الانتشار Propagation تستقبل المعالجات المسجَّلة مع فروع children على العقد أحداثًا تقع في هذه الفروع أيضًا، فإذا تم النقر على زر داخل فقرة ما، فسترى معالجات الأحداث في تلك الفقرة حدث النقر أيضًا. لكن إذا كان كل من الفقرة والزر لهما معالج، فإنّ المعالج الأكثر خصوصيةً -أي المعالج الذي على الزر مثلًا- هو الذي يعمل، ويقال هنا أن الحدث ينتشر propagate إلى الخارج outward، أي من العقدة التي حدث فيها إلى جذر المستند، وبعد أن تحصل جميع المعالجات المسجلة على عقدة ما على فرصة للاستجابة للحدث، فستحصل المعالجات المسجَّلة على النافذة بأكملها على فرصتها في الاستجابة للحدث هي أيضًا. يستطيع معالج الحدث استدعاء التابع stopPropagation على كائن الحدث في أي وقت لمنع المعالجات من استقبال الحدث، وهذا مفيد إذا كان لديك مثلًا زر داخل عنصر آخر قابل للنقر. ولم ترد أن تتسبب نقرات الزر في نقرات ذلك العنصر الخارجي أيضًا. يسجِّل المثال التالي معالجات "mousedown" على كل من الزر والفقرة التي حوله، فحين تنقر بالزر الأيمن سيستدعي معالج الزر الذي في الفقرة التابع stopPropagation الذي سيمنع المعالج الذي على الفقرة من العمل، وإذا نُقر الزر بزر آخر للفأرة فسيعمل كلا المعالجين، انظر كما يلي: <p>فقرة فيها <button>زر</button>.</p> <script> let para = document.querySelector("p"); let button = document.querySelector("button"); para.addEventListener("mousedown", () => { console.log("Handler for paragraph."); }); button.addEventListener("mousedown", event => { console.log("Handler for button."); if (event.button == 2) event.stopPropagation(); }); </script> تحتوي أغلب كائنات الأحداث على خاصية target التي تشير إلى العقدة التي أُنشئت فيها، ونستخدم هذه الخاصية لضمان أننا لا نعالج شيئًا انتشر من عقدة لا نريد معالجتها، كما من الممكن استخدام هذه الخاصية لإلقاء شبكة كبيرة على نوع حدث معيَّن، فإذا كانت لديك عقدة تحتوي على قائمة طويلة من الأزرار مثلًا، فقد يكون أسهل أن تسجِّل معالج نقرة منفردة على العقدة الخارجية وتجعله يستخدِم خاصية target ليعرف إذا كان الزر قد نُقر أم لا بدلًا من تسجيل معالجات فردية لكل الأزرار. <button>A</button> <button>B</button> <button>C</button> <script> document.body.addEventListener("click", event => { if (event.target.nodeName == "BUTTON") { console.log("Clicked", event.target.textContent); } }); </script> الإجراءات الافتراضية إذا نقرت على رابط ما، فستذهب إلى الهدف المرتبط بهذا الرابط؛ أما إذا نقرت على السهم المشير للأسفل، فسيهبط المتصفح بالصفحة للأسفل؛ بينما إذا نقرت بالزر الأيمن، فستحصل على القائمة المختصرة، وهكذا فإن كل حدث له إجراء افتراضي مرتبط به. وتُستدعى معالجات الأحداث جافاسكربت قبل حدوث السلوك الافتراضي في أغلب أنواع الأحداث، فإذا لم يرد المعالج وقوع هذا السلوك الاعتيادي لأنه قد عالج الحدث بالفعل فإنه يستدعي التابع preventDefault على كائن الحدث. يمكن استخدام هذا لتطبيق اختصارات لوحة المفاتيح الخاصة بك أو القائمة المختصرة، كما يمكن استخدامه ليتدخل معارضًا السلوك الذي يتوقعه المستخدِم. انظر المثال التالي لرابط لا يذهب بالمستخدِم إلى الموقع الذي يحمله: <a href="https://developer.mozilla.org/">MDN</a> <script> let link = document.querySelector("a"); link.addEventListener("click", event => { console.log("Nope."); event.preventDefault(); }); </script> لا تفعل شيئًا كهذا إلا إن كان لديك سبب مقنع، إذ سينزعج مستخدِمو صفحتك من مثل هذا السلوك المفاجئ لهم. بعض الأحداث لا يمكن اعتراضها أبدًا في بعض المتصفحات، إذ لا يمكن معالجة اختصار لوحة المفاتيح الذي يغلق اللسان الحالي -أي ctrl+w في ويندوز أو ⌘+w في ماك- باستخدام جافاسكربت مثلًا. أحداث المفاتيح يطلِق المتصفح الحدث "keydown" في كل مرة تضغط فيها مفتاحًا من لوحة المفاتيح، وكلما رفعت يدك عن المفتاح، ستحصل على الحدث "keyup". <p> v تصير هذه الصفحة بنفسجية إذا ضغطت مفتاح.</p> <script> window.addEventListener("keydown", event => { if (event.key == "v") { document.body.style.background = "violet"; } }); window.addEventListener("keyup", event => { if (event.key == "v") { document.body.style.background = ""; } }); </script> يُطلَق الحدث "keydown" إذا ضغطت على المفتاح وتركته أو إذا ظللت ضاغطًا عليه، حيث يُطلق في كل مرة يُكرر فيها المفتاح، وانتبه لهذا إذ أنك لو أضفت زرًا إلى DOM حين يُضغط مفتاح، ثم حذفته حين يُترك المفتاح، فقد تضيف مئات الأزرار خطأً إذا ضُغط على المفتاح ضغطًا طويلًا. ينظر المثال في خاصية key لكائن الحدث ليرى عن أي مفتاح هو، إذ تحمل هذه الخاصية سلسلةً نصيةً تتوافق مع الشيء الذي يُطبع على الشاشة إذا ضُغط ذلك المفتاح، عدا بعض الحالات الخاصة التي تحمل الخاصية اسم المفتاح الذي يُضغط مثل زر الإدخال "Enter". إذا ظللت ضاغطًا على مفتاح "عالي" shift ثم ضغطت على مفتاح v مثلًا، فإن ذلك قد يتسبب في حمل الخاصية لاسم المفتاح أيضًا، وعندها تتحول "v" إلى "V"، وتتغير "1" إلى "!" إذا كان هذا ما يخرجه الضغط على shift+1 على حاسوبك. تولِّد مفاتيح التحكم مثل shift وcontrol وalt وغيرها أحداث مفاتيح مثل المفاتيح العادية، وتستطيع معرفة إذا كانت هذه المفاتيح مضغوط عليها ضغطًا مستمرًا عند البحث عن مجموعات المفاتيح من خلال النظر إلى خصائص shiftKey وctrlKey وaltKey وmetaKey لأحداث لوحة المفاتيح والفأرة. <p>Press Control-Space to continue.</p> <script> window.addEventListener("keydown", event => { if (event.key == " " && event.ctrlKey) { console.log("Continuing!"); } }); </script> تعتمد عقدة DOM حيث بدأ حدث المفتاح على العنصر الذي كان نشطًا عند الضغط على المفتاح، ولا تستطيع أغلب العقد أن تكون نشطةً إلا إذا أعطيتها سمة tabindex على خلاف الروابط والأزرار وحقول الاستمارات، كما سنعود لحقول الاستمارات في مقال لاحق، وإذا لم يكن ثمة شيء بعينه نشطًا، فستتصرف document.body على أساس عقدة هدف لأحداث المفاتيح. لا نفضِّل استخدام أحداث المفاتيح إذا كتب المستخدِم نصًا وأردنا معرفة ما يكتبه، فبعض المنصات لا تبدأ تلك الأحداث هنا كما في حالة لوحة المفاتيح الافتراضية على هواتف الأندرويد، لكن حتى لو كانت لديك لوحة مفاتيح قديمة، فإنّ بعض أنواع النصوص المدخلة لا تتطابق مع ضغطات المفاتيح تطابقًا مباشرًا، مثل برنامج محرر أسلوب الإدخال input method editor -أو IME اختصارًا- الذي يستخدمه الأشخاص الذين لا تتناسب نصوصهم مع لوحة المفاتيح، حيث تُدمج عدة نقرات لإنشاء المحارف. إذا أرادت العناصر التي تستطيع الكتابة فيها معرفة ما يكتبه المستخدِم كما في وسوم <input> و<textarea>، فإنها تطلق أحداث "input" كلما غيّر المستخدِم محتواها، ومن الأفضل قراءة المحتوى الفعلي المكتوب من الحقل النشط إذا أردنا الحصول عليه. أحداث المؤشر توجد حاليًا طريقتان مستخدَمتان على نطاق واسع للإشارة إلى الأشياء على الشاشة: الفأرات -بما في ذلك الأجهزة التي تعمل عملها مثل لوحات اللمس touchpads وكرات التتبع- وشاشات اللمس touchscreens، وتُنتج هاتان الطريقتان نوعين مختلفَين تمامًا من الأحداث. ضغطات الفأرة يؤدي الضغط على زر الفأرة إلى إطلاق عدد من الأحداث، ويتشابه حدثَي "mouseup" و"mousedown" مع حدثَي "keydown" و"keyup"، وتنطلق عند الضغط على الزر وتركه، كما تحدث هذه على عقد DOM الموجودة أسفل مؤشر الفأرة مباشرةً عند وقوع الحدث. ينطلق حدث "click" بعد حدث "mouseup" على العقدة الأكثر تحديدًا التي تحتوي على كل من ضغط الزر وتحريره، فإذا ضغطت على زر الفأرة في فقرة مثلًا ثم حركت المؤشر إلى فقرة أخرى وتركت الزر، فسيقع حدث "click" على العنصر الذي يحتوي على هاتين الفقرتين،؛ أما في حالة حدوث نقرتين بالقرب من بعضهما، فسينطلق حدث "dblclick" -وهو النقرة المزدوجة- بعد حدث النقرة الثانية. يمكنك النظر إلى الخاصيتَين clientX وclientY إذا أردت الحصول على معلومات دقيقة حول هذا المكان الذي وقع فيه حدث الفأرة، إذ تحتويان على إحداثيات الحدث -بالبكسل- نسبةً إلى الركن العلوي الأيسر من النافذة، أو pageX وpageY نسبةً إلى الركن العلوي الأيسر من المستند كله، وقد تكون هذه مختلفةً عن تلك عند تمرير النافذة. ينفِّذ المثال التالي برنامج رسم بدائي، حيث توضع نقطة أسفل مؤشر الماوس في كل مرة تنقر فيها على المستند. <style> body { height: 200px; background: beige; } .dot { height: 8px; width: 8px; border-radius: 4px; /* rounds corners */ background: blue; position: absolute; } </style> <script> window.addEventListener("click", event => { let dot = document.createElement("div"); dot.className = "dot"; dot.style.left = (event.pageX - 4) + "px"; dot.style.top = (event.pageY - 4) + "px"; document.body.appendChild(dot); }); </script> حركة الفأرة ينطلق حدث "mousemove" في كل مرة يتحرك مؤشر الفأرة، ويمكن استخدام هذا الحدث لتتبع موضع المؤشر، مثل أن نحتاج إلى تنفيذ بعض المهام المتعلقة بخاصية السحب drag للمؤشر. يوضح المثال التالي برنامجًا يعرض شريطًا ويضبط معالجات أحداث كي يتحكم السحب يمينًا ويسارًا في عرض الشريط: <p>اسحب الشريط لتغيير عرضه:</p> <div style="background: orange; width: 60px; height: 20px"> </div> <script> let lastX; // Tracks the last observed mouse X position let bar = document.querySelector("div"); bar.addEventListener("mousedown", event => { if (event.button == 0) { lastX = event.clientX; window.addEventListener("mousemove", moved); event.preventDefault(); // Prevent selection } }); function moved(event) { if (event.buttons == 0) { window.removeEventListener("mousemove", moved); } else { let dist = event.clientX - lastX; let newWidth = Math.max(10, bar.offsetWidth + dist); bar.style.width = newWidth + "px"; lastX = event.clientX; } } </script> لاحظ أنّ معالج "mousemove" يُسجَّل على النافذة كلها، حتى لو خرج المؤشر عن الشريط أثناء تغيير عرضه، وذلك طالما أن الزر مضغوط عليه ونكون لا زلنا نريد تعديل العرض. لكن يجب أن يتوقف تغيير الحجم فور تركنا لزر الفأرة، ولضمان ذلك فإننا نستخدم خاصية buttons -لاحظ أنها جمع وليست مفردة-، والتي تخبرنا عن الأزرار التي نضغط عليها الآن، فإذا كانت صِفرًا، فهذا يعني أن الأزرار كلها متروكة وحرة؛ أما إذا كانت ثمة أزرار مضغوط عليها، فستكون قيمة الخاصية هي مجموع رموز هذه الأزرار، إذ يحصل الزر الأيسر على الرمز 1 والأيمن على الرمز 2، والأوسط على 4، فإذا كان الزران الأيمن والأيسر مضغوطًا عليهما معًا، فستكون قيمة buttons هي 3. لاحظ أن ترتيب هذه الرموز يختلف عن الترتيب الذي تستخدمه button، حيث يأتي الزر الأوسط قبل الأيمن، وذلك لِما ذكرنا من قبل أنّ واجهة برمجة المتصفح تفتقر إلى التناسق. أحداث اللمس صُمِّم أسلوب المتصفح ذو الواجهة الرسومية في الأيام التي كانت فيها شاشات اللمس نادرةً جدًا في السوق، ولهذا لم توضع في الحسبان كثيرًا، لذا فقد كان على المتصفحات التي جاءت في أولى الهواتف ذات شاشات اللمس التظاهر بأن أحداث اللمس هي نفسها أحداث الفأرة -وإن كان إلى حد ما-، فإذا نقرت على شاشتك فستحصل على الأحداث "mousedown" و"mouseup" و"click". لكن هذا المنظور ركيك بما أنّ شاشة اللمس تعمل بأسلوب مختلف تمامًا عن الفأرة، فلا توجد هنا أزرار متعددة ولا يمكن تتبع الإصبع إذا لم يكن على الشاشة فعلًا لمحاكاة "mousemove"، كما تسمح الشاشة بعدة أصابع عليها في الوقت نفسه. لا تغطي أحداث الفأرة شاشات اللمس إلا في حالات مباشرة، فإذا أضفت معالج "click" إلى زر ما، فسيستطيع المستخدِم الذي يستعمل شاشة لمس استخدام الزر هنا، لكن لن يعمل مثال الشريط السابق على شاشة لمس. كما أن هناك أنواعًا بعينها من الأحداث تنطلق عند التفاعل باللمس فقط، فحين يلمس الإصبع الشاشة، فستحصل على حدث "touchstart"، وإذا تحرك أثناء اللمس فستُطلَق أحداث "touchmove"؛ أما إذا ابتعد عن الشاشة فستحصل على حدث "touchend". تمتلك كائنات هذه الأحداث خاصية touches التي تحمل كائنًا شبيهًا بالمصفوفة من نقاط لكل منها خصائص cientX وclientY وpageX وpageY، وذلك لأنّ كثيرًا من شاشات اللمس تدعم اللمس المتعدد في الوقت نفسه، فلا يكون لتلك الأحداث مجموعةً واحدةً فقط من الأحداث. تستطيع فعل شيء مشابه لتظهر دوائر حمراء حول كل إصبع يلمس الشاشة: <style> dot { position: absolute; display: block; border: 2px solid red; border-radius: 50px; height: 100px; width: 100px; } </style> <p>Touch this page</p> <script> function update(event) { for (let dot; dot = document.querySelector("dot");) { dot.remove(); } for (let i = 0; i < event.touches.length; i++) { let {pageX, pageY} = event.touches[i]; let dot = document.createElement("dot"); dot.style.left = (pageX - 50) + "px"; dot.style.top = (pageY - 50) + "px"; document.body.appendChild(dot); } } window.addEventListener("touchstart", update); window.addEventListener("touchmove", update); window.addEventListener("touchend", update); </script> قد ترغب في استدعاء preventDefault في معالجات أحداث اللمس لتنسخ -أي تُعدِّل- سلوك المتصفح الافتراضي الذي قد يشمل تمرير الشاشة عند تحريك الإصبع للأعلى أو الأسفل لتمرير الصفحة، ولمنع أحداث المؤشر من الانطلاق، والتي سيكون لديك معالج لها أيضًا. أحداث التمرير ينطلق حدث "scroll" كلما مُرِّر عنصر ما، ونستطيع استخدام ذلك في معرفة ما الذي ينظر إليه المستخدِم الآن كي نوقف عمليات التحريك أو الرسوم المتحركة التي خرجت من النطاق المرئي للشاشة -أو لإرسال هذه البيانات إلى جامعي بيانات المستخدِمين من مخترقي الخصوصية-، أو لإظهار تلميح لمدى تقدم المستخدِم في الصفحة بتظليل عنوان في الفهرس أو إظهار رقم الصفحة أو غير ذلك. يرسم المثال التالي شريط تقدم أعلى المستند ويحدِّثه ليمتلئ كلما مررت للأسفل: <style> #progress { border-bottom: 2px solid blue; width: 0; position: fixed; top: 0; left: 0; } </style> <div id="progress"></div> <script> // اكتب محتوى هنا document.body.appendChild(document.createTextNode( "supercalifragilisticexpialidocious ".repeat(1000))); let bar = document.querySelector("#progress"); window.addEventListener("scroll", () => { let max = document.body.scrollHeight - innerHeight; bar.style.width = `${(pageYOffset / max) * 100}%`; }); </script> إذا كان الموضع position الخاص بالعنصر ثابتًا fixed، فسيتصرف كما لو كان له موضع مطلق absolute، لكنه يمنعه من التمرير مع بقية المستند، ويُترجَم هذا التأثير في الحالات الواقعية على أساس حالة شريط التقدم في مثالنا، إلا أننا نريد جعل الشريط ظاهرًا في أعلى الصفحة أو المستند بغض النظر عن موضع التمرير فيه، ويتغير عرضه ليوضِّح مدى تقدمنا في المستند، كما سنستخدم % بدلًا من px لضبط وحدة العرض كي يكون حجم العنصر نسبيًا لعرض الصفحة. تعطينا الرابطة العامة innerheight ارتفاع النافذة التي يجب طرحها من الارتفاع الكلي الذي يمكن تمريره، بحيث لا يمكن التمرير بعد الوصول إلى نهاية المستند، ولدينا بالمثل innerwidth للعرض الخاص بالنافذة؛ وتحصل على النسبة الخاصة بشريط التمرير عبر قسمة موضع التمرير الحالي pageYoffset على أقصى موضع تمرير وتضرب الناتج في 100. كذلك لا يُستدعى معالج الحدث إلا بعد وقوع التمرير نفسه، وبالتالي لن يمنع استدعاء preventDefault وقوع حدث التمرير. أحداث التنشيط Focus Events إذا كان عنصر ما نشطًا، فسيطلق المتصفح حدث "focus" عليه، وإذا فقد نشاطه ذلك بانتقال التركيز منه إلى غيره فإنه يحصل على حدث "blur". لا ينتشر هذان الحدثان على عكس الأحداث السابقة، ولا يُبلَّغ المعالج الذي على العنصر الأصل حين يكون عنصر فرعي نشطًا أو حين يفقد نشاطه. يوضح المثال التالي نص مساعدة لحقل نصي نشط: <p>الاسم: <input type="text" data-help="اسمك الكامل"></p> <p>العمر: <input type="text" data-help="عمرك بالأعوام"></p> <p id="help"></p> <script> let help = document.querySelector("#help"); let fields = document.querySelectorAll("input"); for (let field of Array.from(fields)) { field.addEventListener("focus", event => { let text = event.target.getAttribute("data-help"); help.textContent = text; }); field.addEventListener("blur", event => { help.textContent = ""; }); } </script> سيستقبل كائن النافذة حدثي "focus" و"blur" كلما تحرك المستخدِم من وإلى نافذة المتصفح أو اللسان النشط الذي يكون المستند معروضًا فيه. حدث التحميل Load Event ينطلق حدث "load" على النافذة وكائنات متن المستند إذا أتمت صفحة ما تحميلها، وهو يُستخدَم عادةً لجدولة إجراءات التهيئة initialization actions التي تكون في حاجة إلى بناء المستند كاملًا. تذكَّر أنّ محتوى وسوم <script> يُشغَّل تلقائيًا إذا قابل الوسم، وقد يكون هذا قبل أوانه إذا احتاجت السكربت إلى فعل شيء بأجزاء المستند التي تظهر بعد وسم <script> مثلًا. تمتلك العناصر التي تحمِّل ملفًا خارجيًا -مثل الصور ووسوم السكربت- حدث "load" كذلك، حيث يوضِّح تحميل الملفات التي تشير إليها، وهذه الأحداث -أي أحداث التحميل- لا تنتشر propagate أي مثل الأحداث المتعلقة بالنشاط focus. حين نغلق صفحةً ما أو نذهب بعيدًا عنها إلى غيرها عند فتح رابط مثلًا، فسينطلق حدث "beforeunload"، والاستخدام الرئيسي لهذا الحدث هو منع المستخدِم من فقد أي عمل كان يعمله إذا أُغلق المستند، فإذا منعت السلوك الافتراضي لهذا الحدث وضبطت خاصية returnvalue على كائن الحدث لتكون سلسلة نصية؛ فسيظهر المتصفح للمستخدِم صندوقًا حواريًا يسأله إذا كان يرغب في ترك الصفحة حقًا. قد يحتوي هذا الصندوق الحواري سلسلتك النصية التي تحاول الحفاظ على بيانات المستخدِم فعليًا، لكن كثيرًا من المواقع كانت تستخدِم هذا الأسلوب من أجل وضع المستخدِمين في حيرة وخداعهم ليبقوا على صفحات هذه المواقع ويشاهدوا الإعلانات الموجودة هناك، لكن المتصفحات لم تَعُد تظهر هذه الرسائل في الغالب. الأحداث وحلقات الأحداث التكرارية تتصرف معالجات أحداث المتصفح في سياق حلقة الحدث التكرارية مثل إشعارات غير متزامنة كما ناقشنا في البرمجة غير المتزامنة في جافاسكريبت، وتُجدوَل حين يقع الحدث، لكن عليها الانتظار حتى تنتهي السكربتات العاملة أولًا قبل أن تعمل هي. يعني هذا أنه إذا كانت حلقة الحدث التكرارية مرتبطةً بمهمة أخرى، فإن أي تفاعل مع الصفحة -وهو ما يحدث أثناء الأحداث- سيتأخر حتى نجد وقتًا لمعالجته، لذلك إذا كانت لديك مهام كثيرة مجدولة إما مع معالج حدث يستغرق وقتًا طويلًا أو مع معالجات لأحداث قصيرة لكنها كثيرة جدًا، فستصير الصفحة بطيئةً ومزعجةً في الاستخدام. أما إذا أردت فعل شيء يستغرق وقتًا في الخلفية دون التأثير على أداء الصفحة، فستوفِّر المتصفحات شيئًا اسمه عمّال الويب web workers، ويُعَدّ ذاك العامل في جافاسكربت مهمةً تعمل إلى جانب السكربت الرئيسية على الخط الزمني الخاص بها. تخيَّل أنّ تربيع عدد ما يمثل عمليةً حسابيةً طويلةً وثقيلة، وأننا نريد إجراءها في خيط thread منفصل، فنكتب حينها ملفًا اسمه code/squareworker.js يستجيب للرسائل بحساب التربيع وإرساله في رسالة. addEventListener("message", event => { postMessage(event.data * event.data); }); لا تشارك العمال نطاقها العام أو أي بيانات أخرى مع البيئة الرئيسية للسكربت لتجنب مشاكل الخيوط المتعددة التي تتعامل مع البيانات نفسها، وعليك التواصل معها عبر إرسال الرسائل ذهابًا وعودة. ينتج المثال التالي عاملًا يشغِّل تلك السكربت ويرسل بعض الرسائل إليها ثم يخرج استجاباتها: let squareWorker = new Worker("code/squareworker.js"); squareWorker.addEventListener("message", event => { console.log("The worker responded:", event.data); }); squareWorker.postMessage(10); squareWorker.postMessage(24); ترسِل دالة postMessage رسالةً تطلق حدث "message" في المستقبِِل، كما ترسل السكربت التي أنشأت العامل رسائلًا، وتستقبلها من خلال كائن Worker؛ في حين يخاطب العامل السكربت التي أنشأته عبر الإرسال مباشرةً على نطاقها العام والاستماع إليه. يمكن للقيم التي تمثل على أساس JSON أن تُرسَل على أساس رسائل، وسيَستقبل الطرف المقابل نسخةً منها بدلًا من القيمة نفسها. المؤقتات Timers رأينا دالة setTimeout في البرمجة غير المتزامنة في جافاسكريبت وكيف أنها تجدوِل دالةً أخرى لتُستدعى لاحقًا بعد وقت محدد يُحسب بالميلي ثانية، لكن أحيانًا قد تحتاج إلى إلغاء دالة جدولتها بنفسك سابقًا، ويتم هذا بتخزين القيمة التي أعادتها setTimeout واستدعاء clearTimeout عليها. let bombTimer = setTimeout(() => { console.log("بووم!"); }, 500); if (Math.random() < 0.5) { // 50% احتمال console.log("Defused."); clearTimeout(bombTimer); } تعمل الدالة cancelAnimationFrame بالطريقة نفسها التي تعمل بها clearTimeout، أي أنّ استدعاءها على قيمة أعادتها requestAnimationFrame، سيلغي هذا الإطار، وذلك على افتراض أنه لم يُستدعى بعد، كما تُستخدَم مجموعة مشابهة من الدوال هي setInterval وclearInterval لضبط المؤقتات التي يجب أن تتكرر كل عدد معيّن X من الميللي ثانية. let ticks = 0; let clock = setInterval(() => { console.log("tick", ticks++); if (ticks == 10) { clearInterval(clock); console.log("stop."); } }, 200); الارتداد Debouncing بعض أنواع الأحداث لها قابلية الانطلاق بسرعة وعدة مرات متتابعة مثل حدثي "mousemove" و"scroll"، وحين نعالج هذه الأحداث، يجب الحذر من فعل أيّ شيء يستغرق وقتًا كبيرًا وإلا فسيأخذ معالجنا وقتًا طويلًا بحيث يبطئ التفاعل مع المستند. إذا أردت فعل شيء مهم بهذا المعالج، فيمكنك استخدام setTimeout للتأكد أنك لا تفعله كثيرًا، ويسمى هذا بارتداد الحدث event debouncing. إذا كتب المستخدم شيئًا ما فإننا نريد التفاعل معه في المثال الأول هنا، لكن لا نريد فعل ذلك فور كل حدث إدخال، فإذا كان يكتب بسرعة، فسنريد الانتظار حتى يتوقف ولو لبرهة قصيرة، كما نضبط مهلةً بدلًا من تنفيذ إجراء مباشرةً على معالج الحدث، إلى جانب حذفنا لأي مهلة زمنية timeout سابقة أقرب من تأخير مهلتنا الزمنية، كما ستُلغى المهلة الزمنية التي من الحدث السابق. <textarea>اكتب شيئًا هنا...</textarea> <script> let textarea = document.querySelector("textarea"); let timeout; textarea.addEventListener("input", () => { clearTimeout(timeout); timeout = setTimeout(() => console.log("Typed!"), 500); }); </script> إنّ إعطاء قيمة غير معرَّفة لـ clearTimeout أو استدعاءها على مهلة زمنية أُطلِقت سلفًا، ليس له أي تأثير، وعليه فلا داعي لأن تخشى شيئًا إذا أردت استدعاءها، بل افعل ذلك لكل حدث إذا شئت. تستطيع استخدام نمط pattern مختلف قليلًا إذا أردت المباعدة بين الاستجابات بحيث تكون مفصولةً بأقل مدة زمنية محددة، لكن في الوقت نفسه تريد إطلاقها أثناء سلسلة أحداث -وليس بعدها-، حيث يمكنك مثلًا الاستجابة إلى أحداث "mousemove" بعرض الإحداثيات الحالية للفأرة، لكن تعرضها كل 250 مللي ثانية، انظر منا يلي: <script> let scheduled = null; window.addEventListener("mousemove", event => { if (!scheduled) { setTimeout(() => { document.body.textContent = `Mouse at ${scheduled.pageX}, ${scheduled.pageY}`; scheduled = null; }, 250); } scheduled = event; }); </script> خاتمة تمكننا معالجات الأحداث من استشعار الأحداث التي تحدث في صفحة الويب والتفاعل معها، ويُستخدَم التابع addEventListener لتسجيل مثل تلك المعالِجات. كل حدث له نوع يعرِّفه مثل "keydown" و"focus" وغيرهما، وتُستدعى أغلب الأحداث على عنصر DOM بعينه، ثم ينتشر إلى أسلاف هذا العنصر سامحًا للمعالجات المرتبطة بتلك العناصر أن تعالجها. عندما يُستدعى معالج حدث ما، فسيُمرَّر إليه كائن حدث بمعلومات إضافية عن الحدث، وذلك الكائن له تابع يسمح لنا بإيقاف الانتشار stopPropagation، وآخر يمنع معالجة المتصفح الافتراضية للحدث preventDefault. إذا ضغطنا مفتاحًا فسيطلق هذا حدثَي "keydown" و"keyup"؛ أما الضغط على زر الفأرة فسيطلق الأحداث "mousedown"، و"mouseup"، و"click"، في حين يطلق تحريك المؤشر أحداث "mousemove"، كما يطلق التفاعل مع شاشات اللمس الأحداث "touchstart" و"touchmove" و"touchend". يمكن استشعار التمرير scrolling من خلال حدث "scroll"، كما يمكن استشعار تغيرات النافذة محل التركيز أو النافذة النشطة من خلال حدثَي "focus" و"blur"، وإذا أنهى المستند تحميله، فسينطلق حدث "load" للنافذة. تدريبات بالون اكتب صفحةً تعرض بالونًا باستخدام الصورة الرمزية للبالون balloon emoji?، بحيث يكبر هذا البالون بنسبة 10% إذا ضغطت السهم المشير للأعلى، ويصغر إذا ضغطت على السهم المشير للأسفل بنسبة 10%. تستطيع التحكم في حجم النص -بما أن الصورة الرمزية ما هي إلا نص- بضبط font-size لخاصية style.fontSize على العنصر الأصل لها، وتذكر ألا تنسى ذكر وحدة القياس في القيمة مثل كتابة 10px. تأكد من أن المفاتيح تغير البالون فقط دون تمرير الصفحة، وأن أسماء مفاتيح الأسهم هي "ArrowUp" و"ArrowDown". وإذا نجح ذلك فأضف ميزةً أخرى هي انفجار البالون عند بلوغه حجمًا معينًا، ويعني هذا هنا استبدال الصورة الرمزية للانفجار ? بالصورة الرمزية للبالون، ويُزال معالج الحدث هنا كي لا تستطيع تغيير حجم الانفجار. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <p>?</p> <script> // شيفرتك هنا </script> إرشادات للحل يجب تسجيل معالج لحدث "keydown" وأن تنظر في event.key لتعرف هل ضُغِط السهم الأعلى أم الأسفل، ويمكن الحفاظ على الحجم الحالي في رابطة binding كي تستطيع بناء الحجم الجديد عليه، وسيكون هذا نافعًا -سواءً الرابطة، أو نمط البالون في الـ DOM- في تعريف الدالة التي تحدث الحجم، وذلك كي تستدعيها من معالج الحدث الخاص بك، وربما كذلك بمجرد البدء لضبط الحجم الابتدائي. تستطيع تغيير البالون إلى انفجار عبر استبدال عقدة النص بأخرى باستخدام replaceChild، أو بضبط خاصية textContent لعقدتها الأصل على سلسلة نصية جديدة. ذيل الفأرة كان يعجب الناس في الأيام الأولى لجافاسكربت أن تكون صفحات المواقع ملأى بالصور المتحركة المبهرجة والتأثيرات البراقة، وأحد هذه التأثيرات هو إعطاء ذيل لمؤشر الفأرة في صورة سلسلة من العناصر التي تتبع المؤشر في حركته في الصفحة، وفي هذا التدريب نريدك تنفيذ ذيل للمؤشر. استخدم عناصر <div> التي لها مواضع مطلقة بحجم ثابت ولون خلفية -حيث يمكنك النظر في فقرة ضغطات الفأرة لتكون مرجعًا لك-، وأنشئ مجموعةً من هذه العناصر واعرضها عند تحرك المؤشر لتكون في عقبه مباشرةً. لديك عدة طرق تحل بها هذا التدريب، والأمر إليك إذا شئت جعل الحل سهلًا أو صعبًا؛ فالحل السهل هو أن تجعل عددًا ثابتًا من العناصر وتجعلها في دورة لتحرك العنصر التالي إلى الموضع الحالي للفأرة في كل مرة يقع حدث "mousemove". تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <style> .trail { /* className for the trail elements */ position: absolute; height: 6px; width: 6px; border-radius: 3px; background: teal; } body { height: 300px; } </style> <script> // ضع شيفرتك هنا. </script> إرشادات للحل يفضَّل إنشاء العناصر باستخدام حلقة تكرارية، وتُلحق العناصر بالمستند كي تظهر، كما ستحتاج إلى تخزين هذه العناصر في مصفوفة كي تستطيع الوصول إليها لاحقًا لتغيير موقعها. يمكن تنفيذ الدورة عليها بتوفير متغير عدّاد وإضافة 1 إليه في كل مرة ينطلق فيها حدث "mousemove"، بعدها يمكن استخدام عامل ‎% elements.length للحصول على فهرس مصفوفة صالحة لاختيار العنصر الذي تريد موضعته خلال الحدث الذي لديك. تستطيع أيضًا تحقيق تأثير جميل عبر نمذجة نظام فيزيائي بسيط باستخدام حدث "mousemove" لتحديث زوج من الرابطات التي تتبع موضع الفأرة، ثم استخدام requestAnimationFrame لمحاكاة العناصر اللاحقة التي تنجذب إلى موضع مؤشر الفأرة. حدِّث موضعها في كل خطوة تحريك وفقًا لموضعها النسبي للمؤشر وربما سرعتها أيضًا إذا خزنتها لكل عنصر، وسنترك لك التفكير في طريقة جيدة لفعل ذلك. التبويبات Tabs تُستخدم اللوحات المبوَّبة في واجهات المستخدِم بكثرة، إذ تسمح لك باختيار لوحة من خلال اختيار التبويب الذي في رأسها، وفي هذا التدريب ستنفذ واجهةً مبوبةً بسيطةً. اكتب الدالة asTabs التي تأخذ عقدة DOM وتنشئ واجهةً مبوبةً تعرض العناصر الفرعية من تلك العقدة، ويجب إدخال قائمة من عناصر <buttons> في أعلى العقدة، بحيث يكون عندك واحد لكل عنصر فرعي، كما يحتوي على نص يأتي من سمة data-tabname للفرع. يجب أن تكون كل العناصر الفرعية الأصلية مخفيةً عدا واحدًا منها -أي تعطيها قيمة none لنمط display-، كما يمكن اختيار العقدة المرئية الآن عبر النقر على الأزرار. وسِّع ذلك إذا نجح معك لتنشئ نمطًا لزر التبويب المختار يختلف عما حوله ليُعلم أي تبويب تم اختياره. <tab-panel> <div data-tabname="one">التبويب الأول</div> <div data-tabname="two">التبويب الثاني</div> <div data-tabname="three">التبويب الثالث</div> </tab-panel> <script> function asTabs(node) { // ضع شيفرتك هنا. } asTabs(document.querySelector("tab-panel")); </script> إرشادات الحل إحدى المشاكل التي قد تواجهها هي أنك لن تستطيع استخدام خاصية childNodes الخاصة بالعقدة استخدامًا مباشرًا مثل تجميعة لعقد التبويب، ذلك لأنك حين تضيف الأزرار، إذ أنها ستصبح عقدًا فرعيةً كذلك وتنتهي في ذلك الكائن لأنه هيكل بيانات حي، كذلك فإنّ العقد النصية المنشأة للمسافات الفارغة بين العقد هي عناصر فرعية childNodes أيضًا، ويجب ألا تحصل على تبويبات خاصة بها، وبالتالي استخدم children بدلًا من childNodes لتجاهل العقد النصية. قد تبدأ ببناء مصفوفة تبويبات كي يكون لديك وصول سهل لها، وتستطيع تخزين الكائنات التي تحتوي كلًا من لوحة التبويب والزر الخاص بها لتنفيذ التنسيق styling الخاص بالأزرار، كما ننصحك بكتابة دالة منفصلة لتغيير التبويبات؛ فإما تخزين التبويب المختار سابقًا وتغيير الأنماط المطلوب إخفاؤها وعرض الجديدة فقط، أو تحديث نمط جميع التبويبات في كل مرة يُختار تبويب جديد فيها. قد تريد استدعاء هذه الدالة فورًا لتبدأ الواجهة مع أول تبويب مرئي. ترجمة -بتصرف- للفصل الخامس عشر من كتاب Elequent Javascript لصاحبه Marijn Haverbeke. اقرأ أيضًا المقال السابق: نموذج كائن المستند في جافاسكريبت أحداث المؤشر والتعامل معها في جافاسكربت إنشاء أحداث مخصصة في المتصفح عبر جافاسكربت أحداث الفأرة في المتصفح والتعامل معها في جافاسكربت مدخل إلى أحداث المتصفح وكيفية التعامل معها عبر جافاسكربت
  22. إن العنصر الرابع من عناصر البرمجة هو الوحدات، ومع أنك تستطيع كتابة بعض البرامج الرائعة بما تعلمته من بداية هذه الدروس إلى الآن دون تعلم الوحدات، إلا أن تتبع ما يحدث في البرامج -مع زيادة حجمها- يصبح صعبًا جدًا، وسنحتاج إلى طريقة لاستخلاص بعض التفاصيل لنستطيع التفكير في المشكلات التي تواجهنا ونريد حلها، بدلًا من صرف الجهد والوقت في التفاصيل الدقيقة المتعلقة بكيفية عمل الحاسوب. توفر بايثون وجافاسكربت وVBScript ذلك إلى حد ما بما فيها من إمكانيات مضمّنة تلقائيًا، فهي تجنبنا التعامل مع عتاد الحواسيب والنظر في كيفية قراءة كل مفتاح على لوحة المفاتيح على حدة، وغير ذلك من الأمور التي تستنزف موارد المبرمجين. وتعتمد فكرة البرمجة باستخدام الوحدات على السماح للمبرمج بتوسيع الإمكانيات الموجودة في لغة البرمجة، عبر تجميع أجزاء من البرنامج في وحدات يمكن توصيلها ببرامجنا، وقد كانت الصورة الأولى للوحدات هي البرامج الفرعية subroutines التي تمثل كتلةً من الشيفرة نستطيع القفز إليها، بدلًا من استخدام GOTO التي ذكرناها في المقال السابق، لكنها تقفز عند تمام الكتلة عائدةً إلى المكان الذي استدعيت منه، ويُعرف هذا الأسلوب من الوحدات بالإجراء procedure أو الدالة function، بل إن مفهوم الوحدة module نفسه يتغير في بعض اللغات مثل بايثون، ليأخذ معنىً أكثر دقة. استخدام الدوال لننظر أولًا في كيفية استخدام الدوال functions الكثيرة التي تأتي في أي لغة برمجة، حيث تسمى مجموعة الدوال القياسية المضمّنة في اللغة باسم المكتبة القياسية، وقد رأينا استخدام بعض الدوال، وذكرناه في قسم العوامل في مقال البيانات وأنواعها؛ أما الآن فسننظر في الأمور المشتركة بينها، وكيف يمكن أن نستخدمها في برامجنا. للدالة الهيكل الأساسي التالي: aValue = someFunction ( anArgument, another, etc... ) يأخذ المتغير aValue القيمة التي نحصل عليها باستدعاء الدالة someFunction، والتي تقبل عدة وسطاء arguments بين القوسين -وقد لا يوجد أي وسيط-، وتعاملها على أنها متغيرات داخلية، وتستطيع الدوال أن تستدعي دوالًا أخرى داخلها. تفرض بعض اللغات وضع القوسين للدالة عند استدعائها حتى لو لم يكن لها وسطاء، وقد ذكرنا أنه يفضل تعويد النفس على استخدام الأقواس حتى في لغات مثل VBScript التي لا تشترط استخدامها حتى مع وجود الوسطاء. لننظر الآن في بعض الأمثلة في لغاتنا الثلاث، ولنرى الدوال عمليًا: الدالة Mid في VBScript تعيد الدالة Mid(aString, start, length)‎ مجموعة المحارف من aString بدءًا من start وبعدد محارف length، حيث ستعرض الشيفرة التالية "Good EVENING" مثلًا. <script type="text/vbscript"> Dim time time = "MORNING EVENING AFTERNOON" MsgBox "Good" & Mid(time, 8, 8) </script> نلاحظ أن VBScript لا تتطلب أقواسًا لجمع وسطاء الدالة بل تكتفي بالمسافات كما كنا نفعل مع MsgBox، لكن عند جمعنا دالتين -كما فعلنا هنا- فيجب أن تستخدم الدالة الداخلية أقواسًا، والنصيحة العامة هي استخدام الأقواس عندما نشك هل يجب استخدامها أم لا. الدالة Date في VBScript تعيد الشيفرة التالية التاريخ الحالي للنظام: <script type="text/vbscript"> MsgBox Date </script> بما أن المثال بسيط فلا يوجد ما يمكن شرحه هنا، لكن تجدر الإشارة إلى وجود مجموعة كاملة من دوال التاريخ الأخرى التي تستخرج اليوم والأسبوع والساعة. الدالة startString.replace في جافاسكربت تعيد دالة startString.replace(searchString, newString)‎ سلسلةً نصيةً جديدةً مع وضع newString بدلًا من searchString‎ في startString. <script type="text/javascript"> var r,s = "A long and winding road"; document.write("Original = " + s + "<BR>"); r = s.replace("long", "short"); document.write("Result = " + r); </script> لاحظ أن الدوال في جافاسكربت ما هي إلا مثال لنوع خاص يسمى بالتابع method، وهو دالة ترتبط بكائن، كما شرحنا في مقال البيانات وأنواعها المذكور أعلاه، وكما سنشرح لاحقًا. وما نريد قوله، هو أن الدالة ترتبط بالسلسلة النصية s بواسطة عامل النقطة .، وهذا يعني أن s هي السلسلة التي سننفذ عليها الاستبدال، وهذا أمر عرضناه من قبل باستخدام التابع write()‎ الخاص بالكائن document الذي استخدمناه لعرض الخرج من برامج جافاسكربت باستخدام document.write()‎، لكننا لم نشرح السبب وراء صيغة الاسم المزدوجة حتى الآن. الدالة Math.pow في جافاسكربت نستخدم الدالة pow(x,y)‎ في وحدة Math -التي ذكرناها في مقال الخامس: البيانات وأنواعها- لرفع x إلى الأس y: <script type="text/javascript"> document.write( Math.pow(2,3) ); </script> الدالة pow في بايثون تستخدم بايثون دالة pow(x,y)‎ لرفع x إلى الأس y: >>> x = 2 # سنستخدم 2 كرقم قاعدة >>> for y in range(0,11): ... print( pow(x,y) ) # y ارفع 2 إلى الأس # أي من 0-10 نولد هنا قيم y من 0 إلى 10، ونستدعي الدالة المضمنة pow()‎ لتمرير وسيطين هما x وy، ونستبدل القيم الحالية لكل منهما في استدعاء pow()‎ في كل مرة، ثم نطبع النتيجة. الدالة dir في بايثون إحدى الدوال المفيدة المضمنة في بايثون هي دالة dir، والتي تعرض جميع الأسماء المصدَّرة داخل وحدة ما إذا مررنا اسم الوحدة إليها، بما في ذلك جميع المتغيرات والدوال التي يمكن استخدامها، وعلى الرغم من أننا لم نشرح إلا بضع وحدات في بايثون، إلا أنها تأتي بوحدات كثيرة. تعيد الدالة dir قائمةً من الأسماء الصالحة في الوحدة، والتي تكون دوالًا في الغالب. لنجرب هذه الدالة على بعض الدوال المضمنة: >>> print( dir(__builtins__) ) لاحظ أن builtins هي إحدى الكلمات السحرية في بايثون، وعلى ذلك يجب إحاطتها بزوج من شرطتين سفليتين على كل جانب. وإذا أردنا استخدام dir()‎ على أي وحدة، فسنحتاج إلى استيرادها أولًا باستخدام import، وإلا فستخبرنا بايثون أنها لا تتعرف عليها. >>> import sys >>> dir(sys) لقد استوردنا وحدة sys في المثال أعلاه، وهي الوحدة التي ذكرناها أول مرة في مقال التسلسلات البسيطة، ونستطيع أن نرى كلًا من exit وargv وstdin وstdout في الخرج الأخير لدالة dir، بين الأشياء الأخرى الموجودة في وحدة sys. لننظر الآن في وحدات بايثون بقليل من التفصيل قبل أن ننتقل إلى ما بعدها. استخدام الوحدات في بايثون تتميز لغة بايثون بقابليتها الكبيرة للتوسع، حيث نستطيع إضافة وحدات جديدة باستيرادها بواسطة import، وفيما يلي بعض الوحدات التي تأتي افتراضيًا مع بايثون. الوحدة sys رأينا sys سابقًا عند الخروج من بايثون، وهي تحتوي على مجموعة من الدوال المفيدة كدالة dir التي سبق شرحها، ويجب أن نستورد وحدة sys أولًا لنصل إلى تلك الدوال: import sys # نجعل الدوال متاحة print( sys.path ) # نرى أين تبحث بايثون عن الدوال sys.exit() # 'sys' أسبقها بـ وإذا علمنا أننا سنستخدم الدوال بكثرة وأنها لن تحمل نفس أسماء الدوال التي استوردناها أو أنشأناها؛ فعندئذ نستطيع فعل ما يلي: from sys import * # sys استورد جميع الأسماء في print( path ) # يمكن استخدامها دون تحديد السابقة 'sys' exit() يتمثل خطر هذا النهج في إمكانية وجود دوال بنفس الاسم في وحدتين مختلفتين، وبالتالي لن نستطيع الوصول إلا إلى الدوال التي استوردناها ثانيًا، لأننا بهذه الطريقة سنلغي الدالة التي استوردناها أولًا، فإذا أردنا أن نستخدم بعض العناصر فقط، فيفضل أن ننفذ ذلك كما يلي: from sys import path, exit # استورد الدوال التي تحتاجها فقط exit() # استخدم دون تحديد السابقة 'sys' لاحظ أن الأسماء التي نحددها لا تحتوي على أقواسًا تليها، ففي تلك الحالة سنحاول تنفيذ الدوال بدلًا من استيرادها، ولا يُعطى هنا إلا اسم الدالة فقط. نريد الآن أن نشرح طريقةً مختصرةً توفر علينا القليل من الكتابة، وهي أننا نستطيع إعادة تسمية الوحدة عند استيرادها إذا كان لها اسم طويل جدًا، مثلًا: import SimpleXMLRPCServer as s s.SimpleXMLRPCRequestHandler() لاحظ كيف أخبرنا بايثون أن تعُد s اختصارًا لـ SimpleXMLRPCServer، ولن نحتاج بعد ذلك إلا إلى كتابة s كي نستخدم دوال الوحدة، مما قلل من الكتابة. وحدات بايثون الأخرى يمكن استيراد أي وحدة من وحدات بايثون واستخدامها بالطريقة السابقة، وهذا يشمل الوحدات التي ننشئها بأنفسنا، لنلق الآن نظرةً على بعض الوحدات القياسية في بايثون، وما توفره من وظائف: وحدة sys: تسمح بالتفاعل مع نظام بايثون: exit()‎: للخروج. argv: للوصول إلى وسطاء سطر الأوامر. path: للوصول إلى مسار البحث الخاص بوحدة النظام. ps1: لتغيير محث بايثون ‎>>>‎. وحدة os: تسمح بالتفاعل مع نظام التشغيل: name: تعطينا اسم نظام التشغيل الحالي، وهي مفيدة في البرامج المحمولة التي لا تحتاج إلى تثبيت. system()‎: تنفذ أمرًا من أوامر نظام التشغيل. mkdir()‎: تنشئ مجلدًا. getcwd()‎: تعطينا مجلد العمل الحالي. وحدة re: تسمح هذه الوحدة بتعديل السلاسل النصية باستخدام التعابير النمطية regular expressions لنظام يونكس: search()‎: تبحث عن النمط في أي موضع في السلسلة النصية. match()‎: تبحث في البداية فقط. findall()‎: تبحث عن جميع مرات الورود في سلسلة نصية. split()‎: تقسيم السلسلة النصية إلى حقول مفصولة بالنمط. ()sub وsubn()‎: استبدال السلاسل النصية. وحدة math: تسمح بالوصول إلى العديد من الدوال الرياضية. ()sin وcos()‎: دوال حساب المثلثات. log(), log10()‎: اللوغاريتمات الطبيعية والعشرية. ()ceil وfloor()‎: دالتا الجزء الصحيح floor والمتمم الصحيح الأعلى ceiling. pi وe: الثوابت الطبيعية. وحدة time: دوال التاريخ والوقت. time()‎: تحصل على الوقت الحالي بالثواني. gmtime()‎: تحول الوقت بالثواني إلى التوقيت العالمي UTC (أو GMT). localtime()‎: التحويل إلى التوقيت المحلي. mktime()‎: معكوس التوقيت المحلي. strftime()‎: تنسيق السلسلة النصية للوقت، مثل YYYYMMDD أو DDMMYYYY. sleep()‎: إيقاف البرنامج مؤقتًا لمدة n ثانية. وحدة random: تولد أرقامًا عشوائية، وهي مفيدة في برمجة الألعاب. randint()‎: تولد عددًا صحيحًا عشوائيًا بين نقطتين تضمَّنان في التوليد. sample()‎: تولد قائمةً فرعيةً عشوائيًا من قائمة أكبر. seed()‎: إعادة ضبط مفتاح توليد الأعداد. هذه الوحدات على كثرتها ليست إلا شيئًا يسيرًا من الوحدات التي توفرها بايثون، والتي تزيد على مئة واحدة وأكثر يمكن تحميلها. تذكر أننا نستطيع استخدام dir()‎ وhelp()‎ للحصول على معلومات عن كيفية استخدام الدوال المختلفة، وأن المكتبة القياسية موثقة جيدًا في فهرس وحدات بايثون، ومن المصادر الجيدة للوحدات الإضافية فهرس حزم بايثون، الذي ضم عند كتابتنا لهذا المقال أكثر من مئة ألف حزمة، وكذلك مشروع SciPy الذي يستضيف مئات وحدات المعالجة العلمية والعددية، ولا غنى عنه لمن يعمل على مشاريع تحليل علمية، وأفضل وسيلة للوصول إلى تلك الحزم هو تثبيت إحدى إصدارات بايثون التي تحتوي على SciPy كإضافة قياسية فيها، مثل موقع anaconda.org، كما يحتوي sourceforge ومواقع التطوير مفتوح المصدر الأخرى على عدة مشاريع لبايثون فيها وحدات نافعة، ويمكنك الاستعانة بمحرك البحث نظرًا لتعدد المصادر، وتستطيع تصفحها وغيرها إذا أضفت كلمة python في بحثك، ولا تنس قراءة توثيق بايثون للمزيد من المعلومات عن البرمجة للإنترنت والرسوميات وبناء قواعد البيانات وغيرها. والمهم هنا إدراك أن أغلب لغات البرمجة تحوي هذه الدوال، والوظائف الأساسية إما مضمّنة فيها أو تكون جزءًا من مكتبتها القياسية، فابحث أولًا في توثيقك قبل كتابة دالة ما، فربما تكون موجودةً ولا تحتاج إلى كتابتها. تعريف الدوال الخاصة بنا يمكن إنشاء دوال خاصة بنا من خلال تعريفها، أي بكتابة تعليمة تخبر المفسر أننا نعرِّف كتلةً من الشيفرة، ويجب عليه تنفيذها عندما نطلبها في أي مكان في البرنامج. VBScript سننشئ الآن دالةً تطبع مثال جدول الضرب الخاص بنا لأي قيمة نعطيها إليها كوسيط، ;ستبدو هذه الدالة في VBScript كما يلي: <script type="text/vbscript"> Sub Times(N) Dim I For I = 1 To 12 MsgBox I & " x " & N & " = " & I * N Next End Sub </script> sنبدأ هنا في هذا المثال بالكلمة المفتاحية Sub -وهي اختصار برنامج فرعي Subroutine-، التي تلي محدد VBScript لكتل الشيفرات مباشرةً في السطر الثاني، ثم نعطيها قائمةً من المعامِلات بين قوسين. أما الشيفرة التي داخل الكتلة المعرَّفة فهي شيفرة VBScript عادية مع استثناء أنها تعامل المعامِلات على أنها متغيرات محلية، لذا تسمى الدالة في المثال أعلاه Times، وتأخذ معامِلًا واحدًا هو N، كما تعرِّف المتغير المحلي I، ثم تنفذ حلقةً تكراريةً تعرض جدول الضرب الخاص بالعدد N باستخدام المتغيرين N و I، نستدعي هذه الدالة الجديدة كما يلي: <script type="text/vbscript"> MsgBox "Here is the 7 times table..." Times 7 </script> لاحظ أننا عرّفنا معاملًا اسمه N ومررنا إليه الوسيط 7، وقد أخذ المعامل أو المتغير المحلي N داخل الدالة القيمة 7 عندما استدعيناه، ويمكن تعريف أي عدد نريده من المعامِلات في تعريف الدالة، وعلى البرامج المستدعية أن توفر قيمةً لكل معامِل. تسمح بعض لغات البرمجة بتعريف قيم افتراضية للمعامل في حالة عدم توفير قيمة، لتستخدم الدالة تلك القيمة الافتراضية. كذلك فقد غلفنا المعامل N بقوسين أثناء تعريف الدالة، لكن هذا غير ضروري في VBScript عند استدعاء الدالة، كما قلنا من قبل. لا تعيد هذه الدالة أي قيمة، وهو ما نسميه بالإجراء في البرمجة، وما هو إلا دالة لا تعيد قيمةً، وتفرّق لغة VBScript بين الدوال والإجراءات باستخدام أسماء مختلفة لتعريفاتهما، فمثلًا: تعيد الدالة التالية في VBScript جدول الضرب الخاص بنا في سلسلة نصية طويلة واحدة: <script type="text/vbscript"> Function TimesTable (N) Dim I, S S = N & " times table" & vbNewLine For I = 1 to 12 S = S & I & " x " & N & " = " & I*N & vbNewLine Next TimesTable = S End Function Dim Multiplier Multiplier = InputBox("Which table would you like?") MsgBox TimesTable (Multiplier) </script> تكاد صيغة هذه الشيفرة أن تطابق صيغة Sub، مع استثناء أننا استخدمنا الكلمة Function بدلًا من Sub السابقة، ويجب أن نسند النتيجة إلى اسم الدالة داخل التعريف، لهذا ستعيد الدالة أي قيمة يحتويها اسمها عند خروجها: ... TimesTable = S End Function فإذا لم نسند قيمةً لاسم الدالة بشكل صريح، فستعيد الدالة القيمة الافتراضية، والتي تكون في الغالب صفرًا أو سلسلةً نصيةً فارغةً. لاحظ أننا وضعنا أقواسًا حول الوسيط في سطر MsgBox، لأن MsgBox لن يعرف -لولا هذه الأقواس- هل يجب أن يُطبع Multiplier أم يُمرّره إلى الوسيط الأول والذي هو TimesTable، فلما وضعناه داخل الأقواس فهم المفسر أن القيمة هي وسيط للدالة TimesTable بدلًا من MsgBox. بايثون ستكون دالة جدول الضرب في بايثون كما يلي: def times(n): for i in range(1,13): print( "%d x %d = %d" % (i, n, i*n) ) وتُستدعى كما يلي: print( "Here is the 9 times table..." ) times(9) لاحظ أن الإجراءات في بايثون لا تميَّز عن الدوال، ويُستخدم def لتعريفهما، والفرق الوحيد هو أن الدالة التي تعيد قيمةً تستخدم تعليمة return، كما يلي: def timesTable(n): s = "" for i in range(1,13): s = s + "%d x %d = %d\n" % (i,n,n*i) return s الأمر بسيط، إذ تعيد الدالة النتيجة باستخدام تعليمة return، أما إذا لم يكن لدينا تعليمة return صريحة فستعيد بايثون تلقائيًا قيمةً افتراضيةً تسمى None، والتي نتجاهلها في العادة. نستطيع الآن طباعة نتيجة الدالة كما يلي: print( timesTable(7) ) يفضل عدم وضع تعليمات print داخل الدوال، وإنما جعل الدالة تعيد النتيجة باستخدام return، ثم طباعتها من خارج الدالة، ورغم أننا لم نتبع هذا الأسلوب في أمثلتنا، إلا أن هذا يجعل الدوال قابلةً لإعادة الاستخدام في نطاق أوسع من الحالات. يبقى أمر مهم للغاية يجب أن نذكره حول تعليمة return، وهو أنها لا تعيد قيمةً من الدالة فحسب، بل تعيد التحكم إلى الشيفرة التي استدعت الدالة، وتكمن أهمية ذلك في أن الإعادة لا يجب أن تكون آخر سطر في الدالة، فقد تكون هناك تعليمات أكثر، وقد لا تنفَّذ أبدًا، كما يمكن أن يحتوي متن الدالة الواحدة على عدة تعليمات return. تُنهي التعليمة التي نصل إليها أولًا الدالة وتعيد القيمة إلى الشيفرة المستدعية، سنعرض مثالًا عن دالة لها عدة تعليمات return، وهي تعيد أول عدد زوجي تجده في القائمة المزودة بها أو None إذا لم تجد أي عدد زوجي: def firstEven(aList): for num in aList: if num % 2 == 0: # تحقق من كونه زوجيًا return num # اخرج من الدالة فورًا return None # لا نصل إليها إلا إذا لم نجد عددًا زوجيًا ملاحظة بشأن المعاملات قد يصعب على المبتدئين فهم دور المعامِلات في تعريف الدالة، فهل يجب تعريف الدالة كما يلي: def f(x): # داخل الدالة x يمكن استخدام... أم تعريفها بالشكل: x = 42 def f(): # داخل الدالة x يمكن استخدام... يعرِّف المثال الأول هنا المعامِل x ويستخدمه داخل الدالة، بينما يستخدم المثال الثاني متغيرًا معرَّفًا من خارج الدالة مباشرةً، وبما أن المثال الثاني صالح ويعمل دون مشاكل، فلم نتكبد عناء تعريف المعامل؟، والجواب هو أن المعامِلات تتصرف مثل متغيرات محلية، أي مثل المتغيرات التي لا تُستخدم إلا داخل الدالة، وقلنا إن مستخدم الدالة يستطيع أن يمرر وسطاءً إلى هذه المعامِلات، وعلى ذلك تتصرف قائمة المعامِلات مثل بوابة للبيانات التي تتحرك بين البرنامج الرئيسي والدالة. تستطيع الدالة أن ترى بعض البيانات خارجها، لكن إذا أردنا للدالة أن تكون قابلةً لإعادة الاستخدام في أكثر من برنامج، فيجب أن نقلل اعتمادها على البيانات الخارجية، بل يجب أن تُمرَّر البيانات المطلوبة لأداء وظيفة الدالة بكفاءة إليها من خلال معامِلاتها، فإذا عُرِّفت الدالة داخل ملف وحدة، فيجب أن تكون لها صلاحية قراءة البيانات المعرَّفة داخل نفس الوحدة، لكن هذه الخاصية ستقلل من مرونة الدالة التي نعرِّفها. نريد أن نقلل عدد المعامِلات المطلوبة لتشغيل الدالة إلى حد يمكن السيطرة عليه وإدارته، وهذا يراعى في حالة البيانات الكثيرة التي تحتاج إلى معامِلات أكثر، وذلك من خلال استخدام تجميعات البيانات مثل القوائم وصفوف tuples والقواميس وغيرها، كما نستطيع تقليل عدد قيم المعامِلات الفعلية التي يجب أن نوفرها، باستخدام ما يسمى بالوسيط الافتراضي. الوسيط الافتراضي يشير هذا المصطلح إلى طريقة لتعريف معامِلات الدالة التي تأخذ قيمًا افتراضيةً إذا لم تمرَّر كوسطاء صراحةً، وأحد استخدامات هذا الوسيط المنطقية في دالة تعيد يوم الأسبوع، فإذا استدعيناها بدون قيمة فيكون قصدنا اليوم الحالي، وإلا فإننا نوفر رقم اليوم كوسيط، كما يلي: import time # None قيمة اليوم هي def dayOfWeek(DayNum = None): # طابق ترتيب اليوم مع قيم إعادة بايثون days = ['Saturday','Sunday', 'Monday','Tuesday', 'Wednesday', 'Thursday', 'Friday'] # تحقق من القيمة الافتراضية if DayNum == None: theTime = time.localtime(time.time()) DayNum = theTime[6] # استخرج قيمة اليوم return days[DayNum] لا نحتاج إلى استخدام وحدة الوقت إلا إذا كانت قيمة المعامِل الافتراضي مطلوبةً، وبناءً عليه نستطيع تأجيل عملية الاستيراد إلى أن نحتاج إليها، وسيحسن هذا من الأداء تحسينًا طفيفًا إذا لم نضطر إلى استخدام خاصية القيمة الافتراضية للدالة، لكن هذا التحسن يُعَد طفيفًا كما ذكرنا، ويلغي اصطلاح الاستيراد الذي ذكرناه أعلاه، لذا لا يستحق هذا التشوش. نستطيع الآن أن نستدعي ذلك كما يلي: print( "Today is: %s" % dayOfWeek() ) Saturday. print( "The third day is %s" % dayOfWeek(2) ) تذكر أننا نبدأ العد من الصفر في عالم الحواسيب، وأننا افترضنا أن اليوم الأول في الأسبوع هو السبت. عد الكلمات أحد الأمثلة الأخرى على دالة تعيد قيمةً، هو الدالة التي تَعُد الكلمات في سلسلة نصية، ويمكن استخدامها لحساب الكلمات في ملف ما بجمع كلمات كل سطر، وتكون شيفرة هذه الدالة كما يلي: def numwords(s): s = s.strip() # أزل المحارف الزائدة list = s.split() # قائمة كل عنصر فيها يمثل كلمة return len(list) # عدد كلمات القائمة هو عدد كلمات s لقد عرَّفنا الدالة في المثال أعلاه مستفيدين من بعض توابع السلاسل النصية المضمَّنة التي ذكرناها في مقال البيانات وأنواعها، ويمكن استخدام الدالة الآن كما يلي: for line in file: total = total + numwords(line) # راكم مجموع كل سطر print( "File had %d words" % total ) إذا حاولنا كتابة ذلك فلن تنجح الشيفرة، لأن مثل هذه الشيفرة تُعرف باسم الشيفرة الوهمية pseudocode، وهي تقنية تصميم شائعة للشيفرات لتوضيح الفكرة العامة لها، وليست مثالًا لشيفرة حقيقية صحيحة التركيب، وتُعرف أحيانًا باسم لغة وصف البرامج Program Description Language. يتضح لنا سبب أفضلية إعادة قيمة من دالة وطباعة النتيجة خارج الدالة بدلًا من داخلها، فإذا طبعت الدالة الطول بدلًا من إعادته فلم نكن لنستخدمها في عدّ إجمالي الكلمات في الملف، بل كنا سنحصل على قائمة طويلة فيها طول كل سطر، لكننا بإعادة القيمة نستطيع الاختيار بين استخدام القيمة كما هي، أو تخزينها في متغير بهدف المعالجة اللاحقة كما فعلنا هنا من أجل أخذ العدد الإجمالي، وهذه نقطة في غاية الأهمية من ناحية التصميم لفصل عرض البيانات من خلال الطباعة عن معالجها داخل الدالة. هناك ميزة أخرى وهي أننا إذا طبعنا الخرج فلن يكون مفيدًا إلا في بيئة سطر أوامر، أما بإعادة القيمة فنستطيع عرضها في صفحة ويب أو واجهة رسومية، فلفصل عرض البيانات عن معالجتها فائدة كبيرة، لذا اجتهد في إعادة القيم من الدوال بدلًا من طباعتها ما استطعت، وليس ثمة استثناء لهذه القاعدة إلا عند إنشاء دالة مخصصة لطباعة بعض البيانات، ففي تلك الحالة يجب توضيح هذا باستخدام كلمة print أو عرض اسم الدالة. دوال جافاسكربت يمكن إنشاء دوال داخل جافاسكربت باستخدام أمر function، كما يلي: <script type="text/javascript"> var i, values; function times(m) { var results = new Array(); for (i = 1; i <= 12; i++) { results[i] = i * m; } return results; } // استخدم الدالة values = times(8); for (i=1;i<=12;i++){ document.write(values[i] + "<br />"); } </script> لن نستطيع الاستفادة كثيرًا من هذه الدالة بتلك الحالة، لكن هذا المثال يوضح كيف يشبه الهيكل الأساسي لإنشاء الدالة هنا تعريفات الدوال في بايثون وVBSCript، وسننظر في دوال أكثر تعقيدًا في جافاسكربت لاحقًا، لأنها تستخدم الدوال لتعريف الكائنات والدوال أيضًا، وهو الأمر الذي يبدو مربكًا للقارئ أو لمستخدم اللغة. تنبيه تنبع قوة الدوال من سماحها بتوسيع نطاق الوظائف والمهام التي تستطيع اللغة تنفيذها، كما أنها تتيح لنا إمكانية تغيير اللغة من خلال تعريف معنىً جديد لدالة موجودة مسبقًا -لا يُسمح بهذا في بعض اللغات-، ولكن هذا أمر غير محمود العاقبة، إلا إذا تحكمنا فيه بحذر شديد، لأن تغيير السلوك القياسي للغة يجعل قراءة الشيفرة وفهمها يُعَد صعبًا للغاية على غيرك، بل عليك أنت نفسك فيما بعد، وبما أن القارئ يتوقع من الدالة أن تنفذ سلوكًا معينًا لكنك غيرت هذا السلوك إلى شيء آخر، لذا يفضل عدم تغيير السلوك الافتراضي للدوال المضمّنة في اللغة. يمكن التغلب على هذا التقييد للإبقاء على السلوك المضمّن للغة مع الاستمرار في استخدام أسماء ذات معنىً لدوالنا، من خلال وضع الدوال داخل كائن أو وحدة توفر سياقها المحلي. إنشاء الوحدات الخاصة رأينا كيف ننشئ دوالًا خاصةً بنا وكيف نستدعيها من أجزاء أخرى من البرنامج، وهذا أمر جيد لأنه يوفر علينا كثيرًا من الكتابة المتكررة، ويجعل برامجنا سهلة الفهم، لأننا ننسى بعض التفاصيل بعد إنشاء دالة تخفيها، وهو مبدأ متبع في البرمجة عند الحاجة إلى إخفاء بعض التفاصيل، ويسمى إخفاء المعلومات، حيث تغلَّف المعلومات والتفاصيل في دالة ننشئها لها، لكن كيف نستخدم هذه الدوال في البرامج الأخرى؟ الإجابة على هذا هي إنشاء وحدة module لهذا الغرض. وحدات بايثون الوحدة في بايثون ما هي إلا ملف نصي بسيط فيه تعليمات برمجية مكتوبة بلغة بايثون، وتكون تلك التعليمات عادةً تعريفات دوال، فمثلًا عند كتابة: import sys فإننا نخبر مفسر بايثون أن يقرأ هذه الوحدة وينفذ الشيفرة الموجودة فيها، ويتيح لنا الأسماء التي تولدها في ملفنا، ويبدو هذا كأننا ننشئ نسخةً من محتويات sys.py في برنامجنا، على أن بعض الوحدات مثل sys في البرمجة العملية ليس لها ملف sys.py أصلًا، لكننا سنتجاهل هذا الآن، وتوجد لغات برمجة مثل C و++C، التي ينسخ فيها المترجم أو المصرِّف ملفات الوحدة إلى البرنامج الحالي حسب الطلب. ننشئ الوحدة بإنشاء ملف بايثون يحتوي الدوال التي نريد إعادة استخدامها في برامج أخرى، ثم نستورد الوحدة كما نستورد الوحدات القياسية، ولتنفيذ هذا عمليًا، انسخ الدالة التالية إلى ملف، واحفظه باسم timestab.py. يمكنك فعل هذا باستخدام IDLE أو Notepad، أو أي محرر آخر يحفظ الملفات النصية العادية، لكن لا تستخدم برامج معالجة نصوص مثل مايكروسوفت وورد، لأن تلك البرامج تدخل شيفرات تنسيق كثيرةً لن تفهمها بايثون. def print_table(multiplier): print( "--- Printing the %d times table ---" % multiplier ) for n in range(1,13): print( "%d x %d = %d" % (n, multiplier, n*multiplier) ) ثم اكتب في محث بايثون: >>> import timestab >>> timestab.print_table(12) وهكذا تكون قد أنشأت وحدةً واستوردتها واستخدمت الدالة المعرَّفة فيها. لاحظ أنك إن لم تبدأ بايثون من نفس المجلد الذي خزنت فيه ملف timestab.py، فلن تستطيع بايثون أن تجد الملف وستعطيك خطأً، وعندها يمكن إنشاء متغير بيئة اسمه PYTHONPATH يحمل قائمةً من المجلدات الصالحة للبحث فيها عن وحدات، إضافةً إلى الوحدات القياسية التي تأتي مع بايثون، ويفضل تعريف مجلد داخل PYTHONPATH وتخزين جميع ملفات الوحدات القابلة لإعادة الاستخدام داخله، ولا تنسى اختبار الوحدات جيدًا قبل نقلها إليه. يجب التأكد من عدم استخدام اسم تحمله وحدة قياسية في بايثون، لئلا تجعل بايثون تستورد ملفك أنت بدلًا من القياسي، مما سينتج عنه سلوك غريب جدًا كما ذكرنا من قبل في شأن التلاعب في لغة البرمجة. ولا تستخدم اسم إحدى الوحدات التي تحاول استيرادها إلى نفس الملف، فهذا سيؤدي أيضًا إلى حدوث مشاكل. الوحدات في جافاسكربت وVBScript يُعَد إنشاء الوحدات في VBScript أكثر تعقيدًا من بايثون، فلم يكن مفهوم الوحدات موجودًا في هذه اللغة ولا في اللغات التي بنفس عمرها، بل كانت تعتمد على إنشاء الكائنات لإعادة استخدام الشيفرة بين المشاريع، وسننظر الآن في كيفية النسخ من المشاريع السابقة واللصق في مشروعنا الحالي باستخدام المحرر النصي. أما جافاسكربت فتوفر آليةً لإنشاء وحدات من ملفات الشيفرة القابلة لإعادة الاستخدام، لكنها آلية معقدة وتستخدم صيغةً غامضةً تخرج عن نطاق عملنا في هذه المرحلة، غير أن لدينا حلًا أسهل، وهو استخدام وحدات كتبها أشخاص آخرون، باستخدام الصيغة التالية: <script type=text/JavaScript src="mymodule.js"></script> نستطيع الوصول إلى جميع تعريفات الدوال الموجودة في ملف mymodule.js بمجرد إدراج السطر السابق داخل قسم <head> في صفحة الويب الخاصة بنا، وتوجد وحدات عديدة من الطرف الثالث متاحة لمبرمجي الويب ويمكن استيرادها بهذه الطريقة، ولعل أشهرها هي وحدة JQuery؛ أما في المواضع التي تُستخدم فيها جافاسكربت خارج المتصفحات -انظر قسم Windows Script Host اللاحق- فهناك آليات أخرى متاحة، ويُرجع في ذلك إلى التوثيق. تقنية Windows Script Host نظرنا حتى الآن إلى جافاسكربت وVBScript على أنهما لغات للبرمجة داخل الويب، ولكن توجد طريقة أخرى لاستخدامهما داخل بيئة ويندوز، وهو تقنية مضيف سكربت ويندوز Windows Script Host أو WSH اختصارًا، وهي تقنية أتاحتها مايكروسوفت لتمكين المستخدمين من برمجة حواسيبهم بنفس الطريقة التي استخدم بها مستخدمو نظام DOS قديمًا ملفات باتش Batch Files، فهي توفر آليات لقراءة الملفات والسجل والوصول إلى الحواسيب والطابعات التي في الشبكة وغيرها. في الإصدار الثاني من WSH إمكانية تضمين ملف WSH آخر، ومن ثم توفير وحدات قابلة لإعادة الاستخدام، وذلك بإنشاء ملف وحدة أولًا اسمه SomeModule.vbs يحتوي على ما يلي: Function SubtractTwo(N) SubtractTwo = N - 2 End function ننشئ الآن ملف سكربت WSH اسمه testModule.wsf مثلًا، كما يلي: <?xml version="1.0" encoding="UTF-8"?> <job> <script type="text/vbscript" src="SomeModule.vbs" /> <script type="text/vbscript"> Dim value, result WScript.Echo "Type a number" value = WScript.StdIn.ReadLine result = SubtractTwo(CInt(value)) WScript.Echo "The result was " & CStr(result) </script> </job> يمكن تشغيل هذا في ويندوز ببدء جلسة DOS وكتابة ما يلي: C:\> cscript testModule.wsf تسمى الطريقة التي تمت هيكلة ملف (wsf.) بها باسم XML، ويتواجد البرنامج داخل زوج من وسوم <job></job> بدلًا من وسم <html></html> الذي رأيناه في لغة HTML من قبل. يشير أول وسم سكربت في الداخل إلى ملف وحدة اسمه SomeModule.vbs، أما وسم السكربت الثاني فيحتوي على برنامجنا الذي يصل إلى SubtractTwo داخل ملف SomeModule.vbs؛ بينما ملف ‎.vbs فيحتوي على شيفرة VBScript عادية ليس فيها وسوم XML أو HTML. لاحظ أن علينا تهريب محرف & من أجل ضم السلاسل النصية لتعليمة WScript.Echo، لأن التعليمة جزء من ملف XML، وهذا المحرف مستخدم في لغة XML كرمز محدِّد. نستخدم WScript.Stdin لقراءة مدخلات المستخدم، وهو تطبيق لما تحدثنا عنه في مقال قراءة البيانات من المستخدم. تصلح هذه التقنية مع جافاسكربت أيضًا، أو لنكون أدق، مع نسخة مايكروسوفت من جافاسكربت التي تسمى JScript، وذلك بتغيير سمة type=‎، بل يمكن دمج اللغات في WSH بأن نستورد وحدةً مكتوبةً بجافاسكربت ونستخدمها في شيفرة VBScript أو العكس. فيما يلي سكربت WSH مكافئ لما سبق، حيث تُستخدم جافاسكربت للوصول إلى وحدة من VBScript: <?xml version="1.0" encoding="UTF-8"?> <job> <script type="text/vbscript" src="SomeModule.vbs" /> <script type="text/javascript"> var value, result; WScript.Echo("Type a number"); value = WScript.StdIn.ReadLine(); result = SubtractTwo(parseInt(value)); WScript.Echo("The result was " + result); </script> </job> نستطيع رؤية مدى تقارب هاتين النسختين، فإذا استثنينا بعض الأقواس الزائدة فسيمكننا القول أنهما متشابهتان للغاية؛ أما أغلب الأمور الفنية فتجري من خلال كائنات WScript. لن نستخدم WSH كثيرًا، لكن قد ننظر فيها بين الحين والآخر إذا رأينا أنها توفر لنا مزايا لا يمكن شرحها باستخدام بيئة المتصفح الأكثر تقييدًا، فعلى سبيل المثال، سنستخدم WSH في المقال التالي لبيان كيف يمكن تعديل الملفات باستخدام جافاسكربت وVBScript، وتوجد بعض الكتب التي تتحدث عن WSH إذا كنت مهتمًا بتعلم المزيد عنها، ويحتوي موقع مايكروسوفت على قسم خاص بها مع أمثلة لبرامج وأدوات تطوير وغير ذلك. خاتمة نأمل أن تخرج من هذا المقال وقد تعلمت: الدوال التي هي شكل من أشكال الوحدات. تعيد الدوال قيمًا، أما الإجراءات فلا تعيد شيئًا. تتكون الوحدات في بايثون في العادة من تعريفات للدوال داخل ملف. يمكن إنشاء دوال جديدة في بايثون باستخدام الكلمة المفتاحية def. استخدام Sub أوFunction في VBScript، وfunction في جافاسكربت. ترجمة -بتصرف- للفصل الحادي عشر: Programming with Modules من كتاب Learning To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: التعامل مع الملفات في البرمجة المقال السابق: مقدمة في البرمجة الشرطية تعلم البرمجة المدخل الشامل لتعلم علوم الحاسوب
  23. تصف التعليمات الشرطية في البرمجة قدرتنا على تنفيذ تسلسل واحد من بين عدة تسلسلات محتملة من الشيفرات -التي تمثل الفروع- وفقًا لوقوع حدث ما، وقد كانت أبسط صورة لتلك التعليمات الشرطية في الأيام الأولى لاستخدام لغة التجميع Assembly هي تعليمة JUMP التي تجعل البرنامج يقفز إلى عنوان ما في الذاكرة، غالبًا عندما يكون ناتج التعليمة السابقة صفرًا، وقد استُخدمت هذه التعليمة في برامج بالغة التعقيد، دون استخدام أي صورة شرطية أخرى وقتها، ليؤكد هذا قول ديكسترا Dijkstra عن الحد الأدنى المطلوب للبرمجة، ثم ظهرت نسخة جديدة من تعليمة JUMP مع تطور لغات البرمجة عالية المستوى، وهي GOTO، التي لا تزال متاحةً حتى الآن في لغة QBASIC، والتي يمكن تحميلها واستخدامها من www.qbasic.net، لننظر إلى الشيفرة التالية مثلًا -بعد تثبيت QBASIC-: 10 PRINT "Starting at line 10" 20 J = 5 30 IF J < 10 GOTO 50 40 PRINT "This line is not printed" 50 STOP لاحظ كيف تحتاج إلى بضع ثوان لتعرف الخطوة التالية رغم صغر البرنامج، إذ لا يوجد هيكل للشيفرة، بل عليك أن تكتشف ما يفعله البرنامج بينما تقرؤه بنفسك، وهذا الأمر مستحيل في البرامج كبيرة الحجم، لهذا لا تحتوي أغلب لغات البرمجة الحديثة بما فيها لغة بايثون وجافاسكربت وVBScript، على تعليمة JUMP أو GOTO بحيث يمكن استخدام أي منهما مباشرةً، وإن وجدت فستحثك اللغة على عدم استخدامها، فما العمل إذًا في حالة البرمجة الشرطية؟. تعليمة if الشرطية إن أول ما يرد إلى الذهن عند التفكير في حالات شرطية هي البنية if.. then.. else، والتي تعني "إذا حدث س، فسيكون ص، وإلا سيكون ع"، فهي تتبع منطقًا لغويًا بحيث إذا تحقق شرط بولياني ما -أي كان true-، فستُنفَّذ كتلة من التعليمات، وإلا ستنفَّذ كتلة أخرى. دورة تطوير التطبيقات باستخدام لغة Python احترف تطوير التطبيقات مع أكاديمية حسوب والتحق بسوق العمل فور انتهائك من الدورة اشترك الآن بايثون إذا أردنا كتابة مثال GOTO السابق بلغة بايثون، فسيبدو كما يلي: import sys # فقط exit لنستطيع استخدام print( "Starting here" ) j = 5 if j > 10: print( "This is never printed" ) else: sys.exit() هذه الصورة أسهل من سابقتها في القراءة وفهم المراد منها، ونستطيع وضع أي شرط اختباري نريده بعد تعليمة if، طالما أنه يقيَّم إلى True أو False، جرب تغيير علامتي ‎>‎ و ‎<‎ وانظر ما سيحدث. لاحظ النقطتين الرأسيتين : في نهاية سطري التعليمتين if وelse، إذ تخبرنا هاتان النقطتان أن ما يليهما كتلة مستقلة من الشيفرة، بينما تخبرنا إزاحة تلك الكتلة ببدايتها ونهايتها. VBScript سيبدو المثال السابق في VBScript كما يلي: <script type="text/vbscript"> MsgBox "Starting Here" DIM J J = 5 If J > 10 Then MsgBox "This is never printed" Else MsgBox "End of Program" End If </script> يكاد يكون هذا المثال مطابقًا لمثال بايثون، مع فرق أن كلمة Then مطلوبة للكتلة الأولى، وأننا نستخدم End If لنعلن نهاية بنية if/then، ولعلك تذكر أننا استخدمنا كلمة Loop لإنهاء حلقة Do While في مثال VBScript من مقال سابق، ذلك أن VBScript تستخدم في اصطلاحها محددًا لنهاية التعليمة. جافاسكربت يبدو مثال if في جافاسكربت كما يلي: <script type="text/javascript"> var j; j = 5; if ( j > 10 ){ document.write("This is never printed"); } else { document.write("End of program"); } </script> لاحظ أن جافاسكربت تستخدم أقواسًا معقوصةً لتحديد كتل الشيفرات الموجودة داخل الجزء الخاص بتعليمة if والجزء الخاص بتعليمة else، وأن الاختبار البولياني يوجد بين قوسين، وليس هناك استخدام صريح لكلمة then. من المنظور الجمالي للتنسيق، يمكن وضع الأقواس المعقوصة في أي مكان، لكننا اخترنا صفها كما في المثال لإبراز بنية الكتلة البرمجية، ويمكن إهمال الأقواس المعقوصة كليًا إذا كان لدينا سطر واحد فقط داخل الكتلة كما في حالتنا هذه، لكن يفضل بعض المبرمجين وضعها للحفاظ على اتساق الكتابة، ولاحتمال إضافة سطر آخر مستقبلًا، فوضعها الآن أفضل من نسيانها لاحقًا. التعابير البوليانية لعلك تذكر أننا ذكرنا في مقال البيانات وأنواعها، نوعًا من البيانات اسمه النوع البولياني، وقلنا إن هذا النوع لا يحوي إلا قيمتين فقط، هما True وFalse، ورغم أننا لا ننشئ متغيرًا بوليانيًا إلا نادرًا، إلا أننا نحتاج إلى إنشاء قيم بوليانية مؤقتة باستخدام التعابير، والتعبير هو تجميعة من المتغيرات و/أو القيم، تجمعها معًا عوامل لإخراج قيمة ناتجة، لننظر إلى المثال التالي: if x < 5: print( x ) التعبير هنا هو x < 5، والنتيجة ستكون True إذا كانت x أقل من 5، وFalse إذا كانت x أكبر من (أو تساوي) 5. قد تكون التعابير معقدةً بما أنها تقيَّم إلى قيمة نهائية وحيدة، إذ يجب أن تكون تلك القيمة True أو False في حالة الفرع، لكن يختلف تعريف هاتين القيمتين من لغة لأخرى، فأغلب اللغات تساوي بين القيمة False وبين الصفر أو القيمة غير الموجودة أو غير المعرفة والتي تدعى NULL أو Nil أو None، وبناءً على ذلك تقيَّم القائمة الفارغة أو السلسلة النصية الفارغة إلى false في السياق البولياني وتعتمد بايثون هذا السلوك، مما يعني أننا نستطيع استخدام حلقة while لمعالجة القائمة إلى أن تصبح فارغةً، بالشكل التالي: while aList: # افعل شيئًا هنا أو يمكن استخدام تعليمة if لننظر هل القائمة فارغة أم لا، دون اللجوء إلى دالة len()‎ كما يلي: if aList: # افعل شيئًا هنا يمكن جمع عدة تعابير بوليانية باستخدام عوامل بوليانية كذلك، والتي تقلل من عدد تعليمات if التي علينا كتابتها، كما في المثال التالي: if value > maximum: print( "Value is out of range!" ) else if value < minimum: print( "Value is out of range!" ) لاحظ أن كتلة الشيفرة التي يجب تنفيذها متطابقة في شرطي المثال أعلاه، ويمكن أن نوفر على أنفسنا بعض الكتابة وعلى الحاسوب بعض العمل، بأن نجمع الاختبارين في اختبار واحد كما يلي: if (value < minimum) or (value > maximum): print( "Value is out of range!" ) لاحظ كيف جمعنا الاختبارين باستخدام عامل or البولياني، وبما أن بايثون تقيِّم مجموعة الاختبارات المجمعة تلك إلى نتيجة واحدة؛ فنستطيع القول أن هذين التعبيرين إنما هما تعبير واحد، ونستطيع استيعاب هذا الأسلوب إذا عرفنا أننا نقيّم المجموعة الأولى من الأقواس أولًا، ثم نقيّم المجموعة الثانية، ثم نجمع القيمتين الناتجتين لنشكل قيمةً نهائيةً وحيدةً تكون إما True أو False. تستخدم بايثون أسلوبًا أكثر كفاءةً من هذا يُعرف بالتقييم المقصور أو تقييم الدارة المقصورة short-circuit evaluation، وإذا تأملنا في هذه الاختبارات، فسنجد أننا نفكر فيها بالمنطق اللغوي البشري الذي يستخدم حروف العطف مثل ("و" "and") و("أو" "or") و("النفي" "not")، مما يتيح لنا كتابة اختبار واحد مجمَّع بدلًا من عدة اختبارات منفصلة، كما سنرى فيما يلي. تعليمات if المتسلسلة يمكن سَلسَلة تعليمات "if..then..else" معًا بجعلها متشعبةً، بحيث تتداخل كل واحدة مع الأخرى، لننظر مثالًا على ذلك في بايثون: # افترض أن السعر قد حُدد مسبقًا... price = int(input("What price? ")) if price == 100: print( "I'll take it!" ) else: if price > 500: print( "No way!" ) else: if price > 200: print( "How about throwing in a free mouse mat?" ) else: print( "price is an unexpected value!" ) لاحظ أننا استخدمنا == المزدوجة لاختبار التساوي في تعليمة if الأولى، بينما استخدمنا علامة = مفردةً لإسناد القيم إلى المتغيرات، وهذا الخطأ شائع جدًا في الكتابة بلغة بايثون، لأنه يعارض المنطق الرياضي الذي تعودنا عليه، وبايثون تحذرك من أن هذا خطأ لغوي syntax error، وعليك النظر والبحث لإيجاده. ننفذ اختبار "أكبر من" بدءًا من القيمة العظمى إلى الصغرى، فإذا عكسنا هذا المنطق، أي بدأنا من price > 200 فستتحقق التعليمة دومًا ولن ننتقل إلى اختبار ‎> 500، وبالمثل يجب أن يبدأ استخدام تسلسل اختبارات "أقل من" من القيمة الصغرى ثم ننتقل إلى العظمى، وهذا أيضًا أحد الأخطاء الشائعة في البرمجة ببايثون. جافاسكربت وVBScript يمكن أن نسلسل تعليمات if في جافاسكربت وVBScript أيضًا، لكننا لن نشرح إلا VBScript لأن المثال واضح: <script type="text/vbscript"> DIM Price price = InputBox("What's the price?") price = CInt(price) If price = 100 Then MsgBox "I'll take it!" Else If price > 500 Then MsgBox "No way Jose!" Else If price > 200 Then MsgBox "How about throwing in a free mouse mat too?" Else MsgBox "price is an unexpected value!" End If End If End If </script> يجب ملاحظة وجود تعليمة End If مطابقة لكل تعليمة If، واستخدامنا لدالة التحويل CInt الخاصة بلغة VBScript لتحويل الدخل من سلسلة نصية إلى عدد صحيح. تعليمات الحالة Switch/Case تتسبب إزاحة الشيفرات بملء الصفحة بسرعة، وهو أحد مساوئ سلسلة تعليمات if/else أو تشعبها، لكن شيوع هذا التسلسل المتشعب في البرمجة جعل كثيرًا من اللغات توفر نوعًا آخر من التفريع خاصًا بها، ويشار إليه عادةً باسم تعليمة Case أو Switch، التي تبدو في جافاسكربت كما يلي: <script type="text/javascript"> function doArea(){ var shape, breadth, length, area; shape = document.forms["area"].shape.value; breadth = parseInt(document.forms["area"].breadth.value); len = parseInt(document.forms["area"].len.value); switch (shape){ case 'Square': area = len * len; alert("Area of " + shape + " = " + area); break; case 'Rectangle': area = len * breadth; alert("Area of " + shape + " = " + area); break; case 'Triangle': area = len * breadth / 2; alert("Area of " + shape + " = " + area); break; default: alert("No shape matching: " + shape) }; } </script> <form name="area"> <label>Length: <input type="text" name="len"></label&lgt; <label>Breadth: <input type="text" name="breadth"></label> <label>Shape: <select name="shape" size=1 onChange="doArea()"> <option value="Square">Square <option value="Rectangle">Rectangle <option value="Triangle">Triangle </select> </label> </form> لا تقلق من حجم الشيفرة أعلاه، فما هي إلا امتداد لما شرحناه في المقالات السابقة وإن زاد حجمه قليلًا. تحتوي استمارة HTML التي في آخر الشيفرة على حقلين نصيين لإدخال الطول length و العرض breadth، أما حقل الإدخال الثالث فهو قائمة منسدلة من القيم التي ألحقناها بسمة onChange التي تستدعي دالةً ما، وكل تلك الحقول لديها عناوين labels مرتبطة. تسمح شيفرة الاستمارة في HTML بالتقاط التفاصيل والبيانات، ثم تستدعي دالة جافاسكربت حين يختار المستخدم أحد الأشكال، وبما أننا لم نشرح الدوال بعد، فيكفي أن تعلم الآن أن الدالة هي برنامج صغير تستدعيه البرامج الأخرى، وفي حالتنا هذه عرَّفنا تلك الدالة في أول الشيفرة. تنشئ الأسطر الأولى بعض المتغيرات المحلية، وتحوِّل سلاسل الإدخال النصية إلى أعداد صحيحة عند الحاجة، وما نريده هو تعليمة switch، التي تختار الإجراء المناسب وفقًا لقيمة الشكل. لاحظ أن الأقواس التي حول shape ضرورية، ولا تُحدَّد كتل الشيفرة التي داخل بنية case باستخدام الأقواس المعقوصة كما هو متوقع، فإذا أردنا إنهاءها، فسنستخدم تعليمة break؛ أما مجموعة تعليمات case الخاصة بـ switch فهي مرتبطة معًا مثل كتلة واحدة بين قوسين معقوصين. يلتقط الشرط الأخير default أي شيء لم تلتقطه تعليمات case السابقة. جرب توسيع المثال السابق ليشمل الدوائر، وتذكر أن تضيف خيارًا جديدًا إلى القائمة المنسدلة في استمارة HTML وتعليمة case جديدة إلى switch. بنية Case الاختيارية في VBScript تحتوي لغة VBscript على بنية case كما يلي: <script type="text/vbscript"> Dim shape, length, breadth, SQUARE, RECTANGLE, TRIANGLE SQUARE = 0 RECTANGLE = 1 TRIANGLE = 2 shape = CInt(InputBox("Square(0),Rectangle(1) or Triangle(2)?")) length = CDbl(InputBox("Length?")) breadth = CDbl(InputBox("Breadth?")) Select Case shape Case SQUARE area = length * length MsgBox "Area = " & area Case RECTANGLE area = length * breadth MsgBox "Area = " & area Case TRIANGLE area = length * breadth / 2 MsgBox "Area = " & area Case Else MsgBox "Shape not recognized" End Select </script> تجمع الأسطر الأولى البيانات من المستخدم وتحولها إلى النوع المناسب، تمامًا مثلما يحدث في جافاسكربت، وتظهر تعليمة select بنية case الخاصة بلغة VBScript، حيث تنهي كل تعليمة Case الكتلة التي سبقتها، كذلك لدينا فقرة Case Else التي تلتقط أي شيء لم تلتقطه تعليمات Case التي سبقتها، كما في حالة default في جافاسكربت. وكما تعودنا في أسلوب VBScript في التنسيق، فإن الشيفرة تغلَق بتعليمة End select. ربما تجب الإشارة إلى استخدام الثوابت الرمزية بدلًا من الأعداد، أي استخدام المتغيرات SQUARE وRECTANGLE وTRIANGLE، لأنها تجعل الشيفرة أسهل في القراءة، كما أن استخدام الأحرف الكبيرة هو اصطلاح لبيان كونها قيمًا ثابتةً وليست متغيرات، مع أن VBScript تسمح بأي أسماء نريدها للمتغيرات. الاختيار المتعدد في بايثون لا توفر بايثون بنية case صراحةً، وإنما تعوضنا عن ذلك بتوفير صيغة مضغوطة من if/else-if/else: menu = """ Pick a shape(1-3): 1) Square 2) Rectangle 3) Triangle """ shape = int(input(menu)) if shape == 1: length = float(input("Length: ")) print( "Area of square = ", length ** 2 ) elif shape == 2: length = float(input("Length: ")) width = float(input("Width: ")) print( "Area of rectangle = ", length * width ) elif shape == 3: length = float(input("Length: ")) width = float(input("Width: ")) print( "Area of triangle = ", length * width/2 ) else: print( "Not a valid shape, try again" ) لاحظ استخدام elif، وعدم تغير الإزاحة على عكس مثال تعليمة if المتشعبة، وتجدر الإشارة إلى أنه لا فرق بين استخدام if المتشعبة أو elif، فكلاهما صالح، كما أنهما تؤديان نفس الغرض، غير أن elif أيسر في القراءة إذا كان لدينا عدة اختبارات. أما الشرط الأخير فهو else التي تلتقط أي شيء لم تلتقطه الاختبارات السابقة، كما في default في جافاسكربت وCase Else في VBScript. توفر VBScript نسخةً معقدةً من هذه التقنية هي ElseIf...Then التي تُستخدم بنفس طريقة elif في بايثون، لكنها غير شائعة بما أن Select Case أسهل استخدامًا. إيقاف الحلقة التكرارية يغطي هذا الجزء استخدامًا خاصًا للتفريع كنا نريد شرحه في مقال الحلقات التكرارية، لكن اضطررنا إلى الانتظار حتى شرح تعليمات if، لذا نعود الآن إلى موضوع الحلقات التكرارية لنرى كيف يمكن أن يحل التفريع مشكلةً شائعةً فيها، فقد نريد الخروج من حلقة تكرارية قبل أن تنتهي. توفر أغلب اللغات آليةً لإيقاف تنفيذ الحلقة، وفي بايثون تكون تلك الآلية هي كلمة break، ويمكن استخدامها في حلقتي for وwhile. تُستخدم هذه الكلمة غالبًا عند البحث في قائمة عن قيمة ما وإيجادها، عندها لا نريد متابعة البحث إلى نهاية الحلقة، كما تُستخدم إذا اكتشفنا خللًا في البيانات، أو إذا قابلنا شرط خطأ error condition؛ أما أكثر الاستخدامات شيوعًا خاصةً في بايثون التي اعتُمد فيها هذا السلوك لكثرة استخدامه، فهو عند قراءة الدخل باستخدام حلقة while. في مثالنا التالي سنستخدم كلمة break للخروج من الحلقة عندما نقابل أول قيمة صفرية في قائمة من الأعداد: nums = [1,4,7,3,0,5,8] for num in nums: print( num ) if num == 0: break # اخرج من الحلقة فورًا print("We finished the loop") في المثال السابق نستطيع رؤية كيفية اختبار شرط الخروج في تعليمة if، ثم استخدام break للقفز خارج الحلقة. تحتوي جافاسكربت على الكلمة المفتاحية break لإيقاف الحلقات التكرارية، وتُستخدم بنفس الطريقة المستخدمة في بايثون؛ أما VBScript، فتستخدم الكلمات Exit For أو Exit Do للخروج من حلقاتها التكرارية. الجمع بين الشروط والحلقات التكرارية بما أن الأمثلة التي أوردناها سابقًا كانت نظريةً وتجريديةً، فسننظر الآن في مثال يستخدم كل ما تعلمناه سابقًا لنشرح تقنيةً شائعةً في البرمجة، وهي عرض القوائم للتحكم في مدخلات المستخدم، لنرى الشيفرة أولًا ثم نشرحها: menu = """ Pick a shape(1-3): 1) Square 2) Rectangle 3) Triangle 4) Quit """ shape = int(input(menu)) while shape != 4: if shape == 1: length = float(input("Length: ")) print( "Area of square = ", length ** 2 ) elif shape == 2: length = float(input("Length: ")) width = float(input("Width: ")) print( "Area of rectangle = ", length * width ) elif shape == 3: length = float(input("Length: ")) width = float(input("Width: ")) print( "Area of triangle = ", length * width / 2 ) else: print( "Not a valid shape, try again" ) shape = int(input(menu)) لقد زدنا ثلاثة أسطر على مثال بايثون السابق، وهي: 4) Quit while shape != 4: shape = int(input(menu)) لكن هذه الأسطر الثلاثة جعلت البرنامج أكثر سهولةً، فقد مكنّا المستخدم من استمرار حساب أحجام الأشكال المختلفة إلى أن يجمع كل المعلومات التي يريدها، وذلك بإضافة الخيار Quit إلى القائمة وحلقة while، كما لا نحتاج الآن إلى إعادة تشغيل البرنامج يدويًا في كل مرة. أما السطر الثالث الذي أضفناه فقد كان لتكرار عملية اختيار الشكل input(menu)‎ ليتمكن المستخدم من تغيير الشكل والخروج من البرنامج إذا أراد. يوهم هذا البرنامج المستخدم بأنه يعرف ما يرغب به وينفذه له، ويختلف سلوك البرنامج في كل مرة باختلاف مدخلات المستخدم، مما يجعل المستخدم يبدو وكأنه هو المتحكم بينما يكون المتحكم الحقيقي هو المبرمج، لأنه توقع جميع المدخلات الصالحة وجعل البرنامج يتفاعل معها، فتكون العبقرية الحقيقية هنا للمبرمج وليس للبرنامج أو الحاسوب، فالحواسيب غبية كما ذكرنا من قبل. تتضح الآن سهولة توسعة وظائف برنامجنا بمجرد إضافة بعض الأسطر ودمج تسلسلات -الكتل البرمجية التي تحسب مساحات الأشكال-، والحلقات التكرارية -حلقة while-، والتعليمات الشرطية -بنية if / elif-، وبهذا نكون قد أنهينا الوحدات الثلاث الأساسية التي تتكون منها البرمجة وفقًا لديكسترا، ونستطيع الآن برمجة أي شيء، من الناحية النظرية، بما أننا تعلمنا هذه الوحدات، غير أننا لا زلنا بحاجة إلى تقنيات أخرى لنتعرف على كيفية تسهيل كتابة البرامج على أنفسنا. مبدأ DRY: لا تكرر نفسك إحدى المزايا التي نريد الإشارة إليها في هذا البرنامج هو أننا اضطررنا إلى تكرار سطر input()‎ مرةً قبل الحلقة ومرةً داخلها؟ ولا يفضَّل تكرار الشيفرة البرمجية نفسها أكثر من مرة، لأننا سنضطر إلى تذكر تغيير كلا السطرين إذا احتجنا إلى تعديلها أو تغييرها، مما يفسح مجالًا للخطأ والنسيان، وهنا يمكن استخدام حيلة مبنية على خاصية break التي تحدثنا عنها في مقال الحلقات التكرارية في البرمجة، حيث نجعل في هذه الحيلة حلقة while حلقةً لا نهائية، ثم نختبر شرط الخروج، ونستخدم break للخروج منها، انظر إلى المثال التالي: menu = """ Pick a shape(1-3): 1) Square 2) Rectangle 3) Triangle 4) Quit """ while True: shape = int(input(menu)) if shape == 4: break if shape == 1: length = float(input("Length: ")) print( "Area of square = ", length ** 2 ) elif shape == 2: length = float(input("Length: ")) width = float(input("Width: ")) print( "Area of rectangle = ", length * width ) elif shape == 3: length = float(input("Length: ")) width = float(input("Width: ")) print( "Area of triangle = ", length * width / 2 ) else: print( "Not a valid shape, try again" ) يسمى تقليل التكرار هذا، والذي يبدو بدهيًا في أوساط المبرمجين، باسم مبدأ DRY، وهو اختصار لـ: "لا تكرر نفسك Dont Repeat Yourself". التعابير الشرطية توجد صورة أخرى من التفريع شائعة جدًا في البرمجة، عندما نريد إسناد قيمة مختلفة إلى متغير وفقًا لشرط ما، ويمكن فعل هذا بسهولة باستخدام شرط if/else كما يلي: if someCondition: value = 'foo' else: value = 'bar' إلا أن لغات البرمجة توفر اختصارًا لذلك، وهو ما يسمى ببنية التعبير الشرطي، ويبدو في بايثون كما يلي، وهو مطابق تمامًا في وظيفته للشيفرة أعلاه: value = 'foo' if شرط ما else 'bar' لا تحتوي VBScript على مثل هذه البنية، وتوفر جافاسكربت شيئًا شبيهًا بها، لكن البنية اللغوية له مبهمة قليلًا: <script type="text/javascript"> var someCondition = true; var s; s = (someCondition ? "foo" : "bar"); document.write(s); </script> لاحظ البنية الغريبة للشرط بين القوسين ()، الذي له نفس وظيفة الأمثلة السابقة في بايثون، إلا أنه يستخدم مجموعةً مختصرةً من الرموز، وهو يقول: "إن تحقق التعبير الذي يسبق علامة الاستفهام وكان true، فأعد القيمة التي بعد علامة الاستفهام، وإلا فأعد القيمة التي بعد النقطتين الرأسيتين". لا يُعَد القوسان ضروريين في هذا المثال رغم استخدامنا لهما، لأنهما يوضحان للقارئ ما يحدث في الشيفرة، ويُنصح باستخدامهما حتى في التعبيرات الشرطية في بايثون. إن مثل هذه الاختصارات تبدو جماليةً مريحةً في استخدامها، لكن كثيرًا من المبرمجين ينأون عنها ويرونها غير عملية، لذا يجب استخدمها متى كان وجودها ضروريًا ومتى دعت الحاجة إليها، ويُفضل تجنبها إذا جعلت الشيفرة تبدو معقدةً أكثر من اللازم. تعديل التجميعات من داخل الحلقات التكرارية لقد ذكرنا في مقال الحلقات التكرارية في البرمجة أن تعديل التجميعة collection من داخل حلقة تكرارية أمر صعب، لكن لم نشرح كيفية تعديلها وقتها، إذ أردنا الانتظار حتى نشرح التفريع أولًا، وقد أتى وقت بيان ذلك، يمكن استخدام حلقة while لتنفيذ التعديلات أثناء التكرار على التجميعة نفسها، وهذا ممكن لأننا نستطيع التحكم الصريح في الفهرس داخل بنية while على عكس حلقة for التي يحدَّث الفهرس فيها تلقائيًا. لننظر الآن كيف نحذف جميع الأصفار من قائمة ما: myList = [1,2,3,0,4,5,0] index = 0 while index < len(myList): if myList[index] == 0: del(myList[index]) else: index += 1 print( myList ) تجدر الإشارة هنا إلى أننا لا نزيد الفهرس عند حذف عنصر، بل تحرك عملية الحذف باقي العناصر للأعلى كي يشير الفهرس القديم إلى العنصر التالي في التجميعة. سنستخدم فرع if/else لنتحكم في وقت زيادة الفهرس، ويسهل هنا ارتكاب خطأ في تنفيذ مثل هذا التعديل، لذا احرص على اختبار شيفرتك جيدًا، هناك مجموعة أخرى من دوال بايثون مصممة خصيصًا لتعديل محتويات القائمة، وسننظر فيها في مقال لاحق. خاتمة في نهاية هذا المقال نريد التذكير بما يلي: استخدم if/else لتفريع مسار البرنامج. استخدام else أمر اختياري يعود إليك. يمكن تمثيل القرارات المتعددة باستخدام بنية Case أو if/elif. تعيد التعابير البوليانية القيمة True أو False، ويمكن دمجها باستخدام and أو or. دمج القوائم باستخدام البنية Case يسمح لنا ببناء تطبيقات يتحكم فيها المستخدم. ترجمة -بتصرف- للفصل العاشر: Decisions, Decisions من كتاب Learning To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: البرمجة باستخدام الوحدات المقال السابق: كيفية قراءة البرامج لمدخلات المستخدم تعلم البرمجة المدخل الشامل لتعلم علوم الحاسوب العمليات الشرطية في مايكروسوفت إكسل باستخدام VBA أسهل لغات البرمجة
  24. حين تفتح صفحة ويب في متصفحك فإن المتصفح يجلب نص HTML الخاص بها ويحلله كما يفعل المحلل parser الذي أنشأناه في المقال الثاني عشر مع البرامج، كما يبني المتصفح نموذجًا لهيكل المستند ويستخدم هذا النموذج لإظهار الصفحة كما تراها على الشاشة. يُعَدّ هذا التمثيل للمستند طريقةً في صناديق الاختبار sandboxes التي في برامج جافاسكربت، وهو هيكل بيانات يمكنك قراءته وتعديله، كما يتصرف على أساس هيكل بيانات حي تتغير الصفحة بتعديله لتعكس هذه التغييرات. هيكل المستند تخيَّل مستند HTML أنه مجموعة متشعِّبة من الصناديق، وتغلِّف فيها وسوم مثل <body> و<‎/body> وسومًا أخرى، وهذه الوسوم تحتوي بدورها على وسوم أو نصوص أخرى. انظر هذا المثال من المقال االسابق: جافاسكربت والمتصفحات. <!doctype html> <html> <head> <title>My home page</title> </head> <body> <h1>My home page</h1> <p>Hello, I am Marijn and this is my home page.</p> <p>I also wrote a book! Read it <a href="http://eloquentjavascript.net">here</a>.</p> </body> </html> ستظهر الصفحة التي في هذا المثال كما يلي: يتبع هيكل البيانات الذي يستخدمه المتصفح لتمثيل هذا المستند هذا الشكل، فهناك كائن لكل صندوق يمكننا التفاعل معه لمعرفة أشياء، مثل وسم HTML التي يمثلها والصناديق والنصوص التي يحتوي عليها، ويسمى هذا التمثيل بنموذج كائن المستند Document Object Model -أو DOM اختصارًا-. وتعطينا رابطة document العامة وصولًا إلى هذه الكائنات، وتشير خاصية documentElement إلى الكائن الذي يمثل وسم <html>، وبما أيّ مستند HTML فيه ترويسة Head ومتن Body، فسيحتوي على خاصيتي head وbody اللتين تشيران إلى هذين العنصرين أيضًا. الأشجار لو أنك تذكر أشجار البُنى syntax trees التي تحدثنا عنها في المقال الثاني عشر والتي تشبه هياكلها هياكل المستندات التي في المتصفح شبهًا كبيرا؛ فكل عُقدة node تشير إلى عُقد أخرى وقد يكون للفروع children فروعًا أخرى، وهذا الشكل هو نموذج للهياكل المتشعبة حيث تحتوي العناصر على عناصر فرعية تشبهها. نقول على هيكل البيانات أنه شجرة إذا احتوي على هيكل ذي بنية متفرعة branching وليس فيه دورات cycles -بحيث لا يمكن للعُقدة أن تحتوي نفسها مباشرةً أو بصورة غير مباشرة-، وله جذر واحد معرَّف جيدًا، وهذا الجذر في حالة DOM هو document.documentElement. نتعرض للأشجار كثيرًا في علوم الحاسوب، فهي تُستخدَم للحفاظ على مجموعات مرتبة من البيانات، حيث يكون إدخال العناصر أو العثور عليها أسهل داخل شجرة من لو كان في مصفوفة مسطحة، وذلك إضافة إلى استخدامات أخرى مثل تمثيل الهياكل التعاودية recursive structures مثل مستندات HTML أو البرامج. تمتلك الشجرة عدة أنواع مختلفة من العُقد، فشجرة البُنى للغة Egg التي أنشأناها في المقال الثاني عشر من هذه السلسلة كانت لها معرِّفات identifiers وقيم values وعُقد تطبيقات application nodes، كما يمكن أن يكون لعُقد التطبيقات تلك فروعًا، في حين يكون للمعرِّفات وللقيم أوراقًا leaves أو عُقدًا دون فروع. ينطبق المنطق نفسه على DOM، فعُقد العناصر التي تمثِّل وسوم HTML تحدِّد هيكل المستند، ويمكن أن يكون لها عُقدًا فرعيةً child nodes، وأحد الأمثلة على تلك العُقد هو document.body. كذلك فإن تلك العُقد الفرعية قد تكون عُقدًا ورقيةً leaf nodes مثل النصوص أو عُقد التعليقات comment nodes. يملك كل كائن عُقدة في DOM خاصية nodeType تحتوي على رمز -أو عدد- يعرِّف نوع العُقدة، فتحمل العناصر الرمز 1 الذي يُعرَّف أيضًا على أساس خاصية ثابتة لـ Node.ELEMENT_NODE؛ أما عُقد النصوص التي تمثِّل أجزاءً من النصوص داخل المستند فتحصل على الرمز 3 وهو Node.TEXT_NODE، في حين تحمل التعليقات الرمز 8 الذي هو Node.COMMENT_NODE. يوضِّح الشكل التالي شجرة مستندنا بصورة أفضل: العُقد النصية هنا هي الأوراق، والأسهم توضح علاقة الأصل والفرع بين العُقد. المعيار لا يتلاءم استخدام رموز عددية مبهمة لتمثيل أنواع العُقد مع طبيعة جافاسكربت، وسنرى في هذا المقال أجزاءً أخرى من واجهة DOM ستبدو متعِبة ومستهجنة، وذلك لأن DOM لم يصمَّم من أجل جافاسكربت وحدها، بل يحاول أن يكون واجهة غير مرتبطة بلغة بعينها ليُستخدم في أنظمة أخرى، فلا يكون من أجل HTML وحدها بل لـ XML كذلك، وهي صيغة بيانات عامة لها بنية تشبه HTML. لكن مزية المعيارية هنا ليست مقنعة ولا مبررة، فالواجهة التي تتكامل تكاملًا حسنًا مع اللغة التي تستخدمها ستوفر عليك وقتًا موازنة بالواجهة التي تكون موحدة على اختلاف اللغات، وانظر خاصية childNodes التي في عُقد العناصر في DOM لتكون مثالًا على هذا التكامل السيء، فتلك الخاصية تحمل كائنًا شبيهًا بالمصفوفة array-like object مع خاصية length وخصائص معنونة بأعداد للوصول إلى العُقد الفرعية، لكنه نسخة instance من النوع NodeList وليس مصفوفةً حقيقيةً، لذا فليس لديه توابع مثل slice وmap. ثم هناك مشاكل ليس لها مراد إلا سوء التصميم، فليست هناك مثلًا طريقةً لإنشاء عقدة جديدة وإضافة فروع أو سمات إليها، بل يجب عليك إنشاء العُقدة ثم إضافة الفروع والسمات واحدة واحدة باستخدام الآثار الجانبية side effects، وعلى ذلك ستكون الشيفرة التي تتعامل مع DOM طويلةً ومتكررةً وقبيحةً أيضًا. لكن هذه المشاكل والعيوب ليست حتميةً، فمن الممكن تصميم طرق مطوَّرة وأفضل للتعبير عن العمليات التي تنفذها أنت طالما تسمح لنا جافاسكربت بإنشاء تجريداتنا الخاصة، كما تأتي العديد من المكتبات الموجهة للبرمجة للمتصفحات بمثل تلك الأدوات. التنقل داخل الشجرة تحتوي عُقد DOM على روابط link كثيرة جدًا تشير إلى العُقد المجاورة لها، انظر المخطط التالي مثلًا: رغم أن المخطط لا يظهر إلا رابطًا واحدًا من كل نوع إلا أنّ كل عُقدة لها خاصية parentNode التي تشير إلى العُقدة التي هي جزء منها إن وجدت، وبالمثل فكل عُقدة عنصر -التي تحمل النوع 1- لها خاصية childNodes التي تشير إلى كائن شبيه بالمصفوفة يحمل فروعه. تستطيع نظريًا التحرك في أي مكان داخل الشجرة باستخدام روابط الأصول والفروع هذه، لكن جافاسكربت تعطيك أيضًا وصولًا إلى عدد من الروابط الإضافية الأخرى، فتشير الخاصيتان firstChild وlastChild إلى العنصرين الفرعيين الأول والأخير، أو تكون لهما القيمة null للعُقد التي ليس لها فروع، وبالمثل أيضًا تشير previousSibling وnextSibling إلى العُقد المتجاورة، وهي العُقد التي لها الأصل نفسه أو الأصل الذي يظهر قبل أو بعد العُقدة مباشرةً، وستحمل previousSibling القيمة null لأول فرع لعدم وجود شيء قبله، وكذلك ستحمل nextSibling القيمة null لآخر فرع. لدينا أيضًا الخاصية children التي تشبه childNodes لكن لا تحتوي إلا عناصر فرعية -أي ذات النوع 1- ولا شيء آخر من بقية أنواع العُقد الفرعية، وذلك مفيد إذا لم تكن تريد العُقد النصية. نفضِّل استخدام الدوال التعاودية recursive functions عند التعامل مع هيكل بيانات متشعب كما في المثال أدناه، حيث تقرأ الدالة التالية المستند بحثًا عن العُقد النصية التي تحتوي على نص معطى وتُعيد true إذا وجدته: function talksAbout(node, string) { if (node.nodeType == Node.ELEMENT_NODE) { for (let child of node.childNodes) { if (talksAbout(child, string)) { return true; } } return false; } else if (node.nodeType == Node.TEXT_NODE) { return node.nodeValue.indexOf(string) > -1; } } console.log(talksAbout(document.body, "book")); // → true تحمل الخاصية nodeValue للعُقدة النصية السلسلة النصية التي تمثلها. البحث عن العناصر رغم أنّ التنقل بين الروابط سابقة الذكر يصلح بين الأصول parents والفروع children والأشقاء siblings، إلا أننا سنواجه مشاكل إذا أردنا البحث عن عُقدة بعينها في المستند. فمن السيء اتباع الطريق المعتاد من document.body عبر مسار ثابت من الخصائص، إذ يسمح هذا بوضع فرضيات كثيرة في برنامجنا عن الهيكل الدقيق للمستند، وهو الهيكل الذي قد تريد تغييره فيما بعد. تُنشأ كذلك العُقد النصية للمسافات الفارغة بين العُقد الأخرى، فوسم <body> يحمل أكثر من ثلاثة فروع والذين هم عنصر <h1> وعنصرين <p>، وإنما المسافات الفارغة بينها وقبلها وبعدها أيضًا، وبالتالي يكون سبعة فروع. إذا أردنا الوصول إلى سمة href للرابط الذي في ذلك المستند فلن نكتب "اجلب الفرع الثاني للفرع السادس من متن المستند"، بل الأفضل هو قول "اجلب الرابط الأول في المستند"، ونحن نستطيع فعل ذلك، انظر كما يلي: let link = document.body.getElementsByTagName("a")[0]; console.log(link.href); تحتوي جميع عُقد العناصر على التابع getElementsByTagName الذي يجمع العناصر التي تحمل اسم وسم ما، وتكون منحدرة -فروعًا مباشرةً أو غير مباشرة- من تلك العُقدة ويُعيدها على أساس كائن شبيه بالمصفوفة. لإيجاد عُقدة منفردة بعينها، أعطها سمة id واستخدم document.getElementById، أي كما يلي: <p>My ostrich Gertrude:</p> <p><img id="gertrude" src="img/ostrich.png"></p> <script> let ostrich = document.getElementById("gertrude"); console.log(ostrich.src); </script> هناك تابع ثالث شبيه بما سبق هو getElementsByClassName يبحث في محتويات عُقدة العنصر مثل getElementsByTagName ويجلب جميع العناصر التي لها السلسلة النصية المعطاة في سمة class. تغيير المستند يمكن تغيير كل شيء تقريبًا في هيكل البيانات الخاص ب DOM، إذ يمكن تعديل شكل شجرة المستند من خلال تغيير علاقات الأصول والفروع. تحتوي العُقد على التابع remove لإزالتها من عُقدة أباها، ولكي تضيف عُقدة فرعية إلى عُقدة عناصرية element node فيمكننا استخدام appendChild التي تضعها في نهاية قائمة الفروع، أو insertBefore التي تدخِل العُقدة المعطاة على أساس أول وسيط argument قبل العُقدة المعطاة على أساس وسيط ثاني. <p>One</p> <p>Two</p> <p>Three</p> <script> let paragraphs = document.body.getElementsByTagName("p"); document.body.insertBefore(paragraphs[2], paragraphs[0]); </script> لا يمكن للعُقدة أن توجد في المستند إلا في مكان واحد فقط، وعليه فإنّ إدخال فقرة Three في مقدمة الفقرة One سيزيلها أولًا من نهاية المستند ثم يدخلها في أوله، لنحصل على Three|One|Two، وبناءً على ذلك ستتسبب جميع العمليات التي تدخل عُقدة في مكان ما -على أساس أثر جانبي- في إزالتها من موقعها الحالي إن كان لها واحد. يُستخدَم التابع replaceChild لاستبدال عُقدة فرعية بأخرى، ويأخذ عُقدتين على أساس وسيطين، واحدة جديدة والعُقدة التي يراد تغييرها، ويجب أن تكون العُقدة المراد تغييرها عُقدة فرعية من العنصر الذي استُدعي عليه التابع، لاحظ أنّ كلًا من replaceChild وinsertBefore تتوقعان العُقدة الجديدة على أساس وسيط أول لهما. إنشاء العقد لنقل أنك تريد كتابة سكربت يستبدل جميع الصور -أي وسوم <img>- في المستند ويضع مكانها نصوصًا في سمات alt لها، والتي تحدِّد نصًا بديلًا عن الصور، حيث سيحذف الصور وسيضيف عُقدًا نصيةً جديدةً لتحل محلها، كما ستُنشأ العُقد النصية باستخدام تابع document.createTextNode كما يلي: <p>The <img src="img/cat.png" alt="Cat"> in the <img src="img/hat.png" alt="Hat">.</p> <p><button onclick="replaceImages()">Replace</button></p> <script> function replaceImages() { let images = document.body.getElementsByTagName("img"); for (let i = images.length - 1; i >= 0; i--) { let image = images[i]; if (image.alt) { let text = document.createTextNode(image.alt); image.parentNode.replaceChild(text, image); } } } </script> إذا كان لدينا سلسلة نصية، فستعطينا createTextNode عُقدةً نصية نستطيع إدخالها إلى المستند لنجعلها تظهر على الشاشة، وستبدأ الحلقة التكرارية التي ستمر على الصور من نهاية القائمة، لأن قائمة العُقد التي أعادها تابع مثل getElementsByTagName -أو سمة مثل childNodes- هي قائمة حية بمعنى أنها تتغير كلما تغير المستند، وإذا بدأنا من المقدمة وحذفنا أول صورة فسنُفقِد القائمة أول عناصرها كي تتكرر الحلقة التكرارية الثانية، حيث i تساوي 1، وستتوقف لأن طول المجموعة الآن صار 1 كذلك. أما إذا أردت تجميعة ثابتة solid collection من العُقد -على النقيض من العُقد الحية- فستستطيع تحويل التجميعة إلى مصفوفة حقيقية باستدعاء Array.from كما يلي: let arrayish = {0: "one", 1: "two", length: 2}; let array = Array.from(arrayish); console.log(array.map(s => s.toUpperCase())); // → ["ONE", "TWO"] استخدم التابع document.createElement لإنشاء عُقد عناصر، حيث يأخذ هذا التابع اسم الوسم ويعيد عُقدةً جديدةً فارغةً من النوع المعطى. انظر المثال التالي الذي يعرِّف الأداة elt التي تنشئ عُقدة عنصر وتعامل بقية وسائطها على أساس فروع لها، ثم تُستخدَم هذه الدالة لإضافة خصائص إلى اقتباس نصي، أي كما يلي: <blockquote id="quote"> No book can ever be finished. While working on it we learn just enough to find it immature the moment we turn away from it. </blockquote> <script> function elt(type, ...children) { let node = document.createElement(type); for (let child of children) { if (typeof child != "string") node.appendChild(child); else node.appendChild(document.createTextNode(child)); } return node; } document.getElementById("quote").appendChild( elt("footer", "—", elt("strong", "Karl Popper"), ", preface to the second edition of ", elt("em", "The Open Society and Its Enemies"), ", 1950")); </script> السمات Attributes يمكن الوصول إلى بعض سمات العناصر مثل href الخاصة بالروابط من خلال خاصية الاسم نفسه على كائن DOM الخاص بالعنصر وهذا شأن أغلب السمات القياسية المستخدَمة، لكن تسمح لك HTML بإسناد set أيّ عدد من السمات إلى العُقد، وذلك مفيد لأنه يسمح لك بتخزين معلومات إضافية في المستند، فإذا ألّفت أسماء سمات خاصة بك فلن تكون موجودة على أساس خصائص في عُقدة العنصر، بل يجب أن تستخدِم التابعَين getAttribute وsetAttribute لكي تتعامل معها. <p data-classified="secret">The launch code is 00000000.</p> <p data-classified="unclassified">I have two feet.</p> <script> let paras = document.body.getElementsByTagName("p"); for (let para of Array.from(paras)) { if (para.getAttribute("data-classified") == "secret") { para.remove(); } } </script> يفضَّل أن تسبق أسماء هذه السمات التي تنشئها أنت بـ data‎-‎ كي تتأكد أنها لن تتعارض مع أي سمة أخرى. فمثلًا، لدينا سمة class شائعة الاستخدام وهي كلمة مفتاحية في لغة جافاسكربت، كما كانت بعض تطبيقات جافاسكربت القديمة -لأسباب تاريخية- لا تستطيع التعامل مع أسماء الخصائص التي تطابق كلمات مفتاحية، وقد كانت الخاصية التي تُستخدَم للوصول إلى هذه السمة هي className، لكن تستطيع الوصول إليها تحت اسمها الحقيقي "class" باستخدام التابعَين getAttribute وsetAttribute. مخطط المستند Layout لعلك لاحظت أن الأنواع المختلفة من العناصر توضع بتخطيط مختلف، فبعضها -مثل الفقرات <p> أو الترويسات <h1>- يأخذ عرض المستند بأكمله وتُخرَج على أسطر مستقلة، وتسمى هذه العناصر بالعناصر الكتلية block elements؛ في حين بعضها الآخر مثل الروابط <a> والخط السميك <strong> تُخرَج على السطر نفسه مع النص المحيط بها، وتسمى هذه العناصر بـ: العناصر السطرية inline elements. يستطيع المتصفح أن يضع مخططًا لأي مستند، بحيث يعطي كل عنصر فيه حجمًا وموضعًا وفقًا لنوعه ومحتواه، بعدها يُستخدَم هذا المخطط لرسم المستند في ما يعرضه المتصفح. يمكن الوصول إلى حجم وموضع أي عنصر من خلال جافاسكربت، إذ تعطيك الخاصيتان offsetWidth وoffsetHeight المساحة التي تأخذها العناصر مقاسةً بالبكسلات pixels، وتُعَدّ البكسل أصغر وحدة قياس في المتصفح، وقد كانت تساوي أصغر نقطة تستطيع الشاشة عرضها، لكن الشاشات الحديثة التي تستطيع رسم نقاط صغيرة للغاية لا ينطبق عليها هذا المقياس، حيث يساوي البكسل الواحد عدة نقاط فيها، وبالمثل تعطي clientWidth وclientHeight حجم المساحة داخل العنصر متجاهلة عرض الإطار. <p style="border: 3px solid red"> أنا موجود داخل إطار </p> <script> let para = document.body.getElementsByTagName("p")[0]; console.log("clientHeight:", para.clientHeight); console.log("offsetHeight:", para.offsetHeight); </script> أفضل طريقة لمعرفة الموضع الدقيق لأي عنصر على الشاشة هي باستخدام التابع getBoundingClientRect، حيث يُعيد كائنًا فيه خصائص top وbottom وleft وright، مشيرةً إلى مواضع البكسلات لجوانب العنصر نسبةً إلى أعلى يسار الشاشة، فإذا أردتها منسوبةً إلى المستند كله، فيجب إضافة موقع التمرير الحالي في المستند والذي ستجده في الرابطتين pageXoffset وpageYoffset. قد يكون تخطيط المستند مجهدًا لكثرة تفاصيله، لذا لا تعيد محركات المتصفحات إعادة تخطيط المستند في كل مرة تغيره بل تنتظر أطول فترة ممكنة، فحين ينتهي برنامج جافاسكربت من تعديل مستند، فسيكون على المتصفح أن يحسب تخطيطًا جديدًا لرسم المستند الجديد على الشاشة. كذلك إذا طلب برنامج ما موضع أو حجم شيء من خلال قراءة خاصية مثل offsetHeight أو استدعاء getBoundingClientRect، ذلك أن توفير المعلومات الصحيحة يتطلب حساب التخطيط. أما إذا كان البرنامج ينتقل بين قراءة معلومات مخطط DOM وتغيير DOM، فسيتطلب الكثير من حسابات التخطيط وعليه سيكون بطيئًا جدًا، كما تُعَدّ الشيفرة التالية مثالًا على ذلك، إذ تحتوي على برنامجين مختلفين يبنيان سطرًا من محارف X بعرض 2000 بكسل، ويقيسان الوقت الذي يستغرقه كل واحد منهما. <p><span id="one"></span></p> <p><span id="two"></span></p> <script> function time(name, action) { let start = Date.now(); // Current time in milliseconds action(); console.log(name, "took", Date.now() - start, "ms"); } time("naive", () => { let target = document.getElementById("one"); while (target.offsetWidth < 2000) { target.appendChild(document.createTextNode("X")); } }); // → naive took 32 ms time("clever", function() { let target = document.getElementById("two"); target.appendChild(document.createTextNode("XXXXX")); let total = Math.ceil(2000 / (target.offsetWidth / 5)); target.firstChild.nodeValue = "X".repeat(total); }); // → clever took 1 ms </script> التنسيق Styling رأينا أنّ عناصر HTML المختلفة تُعرَض على الشاشة بطرق مختلفة، فبعضها يُعرَض في كتل مستقلة، وبعضها يكون داخل السطر نفسه، كما يضاف تخصيص مثل <strong> إلى بعض النصوص لجعلها سميكة، وكذلك يُضاف <a> إلى بعضها الآخر كي تظهر بلون أزرق وتحتها خط دلالةً على كونها رابطًا تشعبيًا. ترتبط الطريقة التي يعرض بها وسم <img> صورة ما، أو يجعل وسم <a> رابطًا يذهب إلى صفحة أخرى عند النقر عليه ارتباطًا وثيقًا إلى نوع العنصر، لكن نستطيع تغيير التنسيق المرتبط بالعنصر مثل لون النص أو وضع خط أسفله. انظر المثال التالي على استخدام خاصية style: <p><a href=".">Normal link</a></p> <p><a href="." style="color: green">Green link</a></p> يمكن لسمة التنسيق style attribute أن تحتوي تصريحًا واحدًا أو أكثر، وهو خاصية -مثل color- متبوعة بنقطتين رأسيتين وقيمة -مثل green في المثال أعلاه-، وإذا كان لدينا أكثر من تصريح واحد فيجب فصل التصريحات بفواصل منقوطة كما في "color: red; border: none". يتحكم التنسيق كما ترى في جوانب كثيرة من المستند. فمثلًا، تتحكم خاصية display في عرض العنصر على أنه كتلة مستقلة أو عنصر سطري، أي كما يلي: يُعرض هذا النص <strong>في السطر كما ترى</strong>, <strong style="display: block">مثل كتلة</strong>, و <strong style="display: none">لا يُعرض على الشاشة</strong>. سيُعرض وسم block في المثال السابق في سطر منفصل بما أن عناصر الكتل لا تُعرض داخل سطر مع نصوص حولها؛ أما الوسم الأخير فلن يُعرض مطلقًا بسبب none التي تمنع العنصر من الظهور على الشاشة، وتلك طريقة لإخفاء العناصر وهي مفضَّلة على الحذف النهائي من المستند لاحتمال الحاجة إليها في وقت لاحق. يمكن لشيفرة جافاسكربت أن تعدّل مباشرةً على تنسيق عنصر ما من خلال خاصية style لذلك العنصر، وهذه الخاصية تحمل كائنًا له خصائص لكل خصائص التنسيق المحتملة، كما تكون قيم هذه الخصائص سلاسل نصية نكتبها كي نغيِّر جزءًا بعينه من تنسيق العنصر. <p id="para" style="color: purple"> هذا نص جميل </p> <script> let para = document.getElementById("para"); console.log(para.style.color); para.style.color = "magenta"; </script> تحتوي بعض أسماء خصائص التنسيقات على شرطة - مثل font-family، وبما أنّ أسماء هذه الخصائص يصعب التعامل معها في جافاسكربت إذ يجب كتابة style["font-family"]‎، فإن الأسماء التي في كائن style لتلك الخصائص تُحذف منها الشُّرَط التي فيها وتُجعل الأحرف التي بعدها أحرف كبيرة كما في style.fontFamily. التنسيقات المورثة Cascading Styles يسمى نظام تصميم وعرض العناصر في HTML باسم CSS، وهي اختصار لعبارة Cascading Style Sheets أو صفحات التنسيقات المُورَّثة، وتُعَدّ صفحة التنسيق style sheet مجموعةً من القوانين التي تحكم مظهر العناصر في مستند ما، ويمكن كتابتها داخل وسم <style>. <style> strong { font-style: italic; color: gray; } </style> <p>الآن <strong>النص السميك </strong>صار مائلًا ورماديًا.</p> وتشير المُورَّثة التي في هذه التسمية إلى إمكانية جمع عدة قواعد معًا لإنتاج التنسيق النهائي لعنصر ما. تعطَّل أثر التنسيق الافتراضي لوسوم <strong> في المثال السابق التي تجعل الخط سميكًا بسبب القاعدة الموجودة في وسم <style> التي تضيف تنسيق الخط font-style ولونه color. وإذا عرَّفت عدة قواعد قيمةً لنفس الخاصية، فإن أحدث قاعدة قُرِئت ستحصل على أسبقية أعلى وتفوز، لذا فإذا كان وسم <style> يحتوي على font-weight: normal وعارض قاعدة font-weight الافتراضية، فسيكون النص عاديًا وليس سميكًا، فالتنسيقات التي في سمة style والتي تُطبَّق مباشرةً على العُقدة لها أولوية أعلى وتكون هي الفائزة دائمًا. من الممكن استهداف أشياء غير أسماء الوسوم في قواعد CSS، إذ سستُطبَّق قاعدة موجهة لـ ‎.abc على جميع العناصر التي فيها "abc" في سمة class الخاصة بها، وكذلك قاعدة لـ ‎#xyz ستُطبق على عنصر له سمة id بها "xyz"، والتي يجب أن تكون فريدةً ولا تتكرر في المستند. .subtle { color: gray; font-size: 80%; } #header { background: blue; color: white; } /* p elements with id main and with classes a and b */ p#main.a.b { margin-bottom: 20px; } لا تنطبق قاعدة الأولوية التي تفضِّل أحدث قاعدة معرَّفة إلا حين تكون جميع القواعد لها النوعية specificity نفسها، ونوعية القاعدة مقياس لدقة وصف العناصر المتطابقة، وتُحدِّد بعدد جوانب العنصر التي يتطلبها ونوعها -أي الوسم أو الصنف أو المعرِّف ID. فمثلًا، تكون القاعدة التي تستهدف p.a أكثر تحديدًا من قاعدة تستهدف p أو ‎.a فقط، وعليه تكون لها الأولوية. تطبّق الصيغة p > a {...}‎ التنسيقات المعطاة على جميع وسوم <a> التي تكون فروعًا مباشرةً من وسوم <p>، وبالمثل تطبّق p a {...}‎ على جميع وسوم <a> الموجودة داخل وسوم <p> سواءً كانت فروعًا مباشرةً أو غير مباشرة. محددات الاستعلامات Query Selectors لن نستخدم صفحات التنسيقات كثيرًا في هذه السلسلة، إذ يحتاج تعقيدها وتفصيلها إلى سلسلة خاصة بها، لكن فهمها ينفعك عند البرمجة في المتصفح، والسبب الذي جعلنا نذكر بُنية المحدِّد هنا -وهي الصيغة المستخدَمة في صفحات التنسيقات لتحديد العناصر التي تنطبق عليها مجموعة تنسيقات بعينها- هو أننا نستطيع استخدام التركيب اللغوي نفسه على أساس طريقة فعالة للعثور على عناصر DOM. يأخذ التابع querySelectorAll المعرَّف في كائن document وفي عُقد العناصر، ويأخذ سلسلةً نصيةً لمحدِّد ويُعيد NodeList تحتوي جميع العناصر المطابقة. <p>And if you go chasing <span class="animal">rabbits</span></p> <p>And you know you're going to fall</p> <p>Tell 'em a <span class="character">hookah smoking <span class="animal">caterpillar</span></span></p> <p>Has given you the call</p> <script> function count(selector) { return document.querySelectorAll(selector).length; } console.log(count("p")); // All <p> elements // → 4 console.log(count(".animal")); // Class animal // → 2 console.log(count("p .animal")); // Animal inside of <p> // → 2 console.log(count("p > .animal")); // Direct child of <p> // → 1 </script> لا يكون الكائن المعاد من querySelectorAll حيًا على عكس توابع مثل getElementsByTagName، كما لن يتغير إذا غيرت المستند، إذ لا يزال مصفوفةً غير حقيقية، لذا ستحتاج إلى استدعاء Array.from إذا أردت معاملته على أنه مصفوفة. يعمل التابع querySelector -دون All- بأسلوب مشابه، وهو مفيد إذا أردت عنصرًا منفردًا بعينه، إذ سيعيد أول عنصر مطابق أو null إذا لم يكن ثمة مطابقة. التموضع والتحريك تؤثر خاصية التنسيق position على شكل التخطيط تأثيرًا كبيرًا، ولها قيمة static افتراضيًا، أي أن العنصر يظل في موضعه العادي في المستند، وحين تُضبط على relative فسيأخذ مساحةً في المستند أيضًا لكن مع اختلاف أنّ الخصائص التنسيقية top وleft يمكن استخدامها لتحريكه نسبة إلى ذلك الموضع العادي له. أما حين تُضبط position على absolute فسيُحذَف العنصر من التدفق الاعتيادي للمستند normal flow، أي لا يأخذ مساحة، وإنما قد يتداخل مع عناصر أخرى، وتُستخدم top وleft هذه المرة لموضعة العنصر بصورة مطلقة هذه المرة نسبةً إلى الركن الأيسر العلوي لأقرب عنصر مغلِّف تكون خاصية position له غير static، أو نسبة إلى المستند ككل إن لم يوجد عنصر مغلِّف. نستخدِم ما سبق عند إنشاء تحريك animation، كما يوضح المستند التالي الذي يعرض صورة قطة تتحرك في مسار قطع ناقص ellipse. <p style="text-align: center"> <img src="img/cat.png" style="position: relative"> </p> <script> let cat = document.querySelector("img"); let angle = Math.PI / 2; function animate(time, lastTime) { if (lastTime != null) { angle += (time - lastTime) * 0.001; } cat.style.top = (Math.sin(angle) * 20) + "px"; cat.style.left = (Math.cos(angle) * 200) + "px"; requestAnimationFrame(newTime => animate(newTime, time)); } requestAnimationFrame(animate); </script> تكون صورتنا في منتصف الصفحة ونضبط خاصية position لتكون relative، وسنحدِّث تنسيقي الصورة top وleft باستمرار من أجل تحريك الصورة. تستخدِم السكربت requestAnimationFrame لجدولة دالة animate كي تعمل كلما كان المتصفح جاهزًا لإعادة رسم الشاشة أو تغيير المعروض عليها، وتستدعي دالة animate نفسها requestAnimationFrame لجدولة التحديث التالي، وحين تكون نافذة المتصفح كلها نشطةً أو نافذة اللسان (تبويب) فقط، فإن ذلك يتسبب في جعل معدل التحديثات نحو 60 تحديثًا في الثانية، مما يجعل مظهر العرض ناعمًا وجميلًا، فإذا حدَّثنا DOM في حلقة تكرارية فستتجمد الصفحة ولن يظهر شيء على الشاشة، إذ لا تحدِّث المتصفحات العرض الخاص بها أثناء تشغيل برنامج جافاسكربت ولا تسمح لأيّ تفاعل مع الصفحة، من أجل ذلك نحتاج إلى requestAnimationFrame، إذ تسمح للمتصفح أن يعرف أننا انتهينا من هذه المرحلة، ويستطيع متابعة فعل المهام الخاصة بالمتصفحات، مثل تحديث الشاشة والتجاوب مع تفاعل المستخدم. يُمرَّر الوقت الحالي إلى دالة التحريك على أساس وسيط، ولكي نضمن أن حركة القطة ثابتة لكل ميلي ثانية، فإنها تبني السرعة التي تتغير بها الزاوية على الفرق بين الوقت الحالي وبين آخر وقت عملت فيه الدالة، فإذا حرَّكتَ الزاوية بمقدار ثابت لكل خطوة، فستبدو الحركة متعثرةً وغير ناعمة إذا كان لدينا مهمة أخرى كبيرة تعمل على نفس الحاسوب مثلًا، وتمنع الدالة من العمل حتى ولو كانت فترة المنع تلك جزء من الثانية. تُنفَّذ الحركة الدائرية باستخدام دوال حساب المثلثات Math.cos وMath.sin، كما سنشرح هذه الدوال إذا لم يكن لك بها خبرة سابقة بما أننا سنستخدمها بضعة مرات في هذه السلسلة. تُستخدَم هاتان الدالتان لإيجاد نقاط تقع على دائرة حول نقطة الإحداثي الصفري (0,0) والتي لها نصف قطر يساوي 1، كما تفسِّران وسيطها على أساس موضع على هذه الدائرة مع صفر يشير إلى النقطة التي على أقصى يمين الدائرة، ويتحرك باتجاه عقارب الساعة حتى يقطع محيطها الذي يساوي 2 باي -أي 2π- والتي يكون مقدارها هنا 6.28 تقريبًا. تخبرك Math.cos بإحداثية x للنقطة الموافقة للموضع الحالي، في حين تخبرك Math.sin بإحداثية y، وأيّ موضع أو زاوية أكبر من 2 باي 2π -أي محيط الدائرة- أو أقل من صفر يكون صالحًا ومقبولًا، ويتكرر الدوران إلى أن تشير a+2π إلى نفس الزاوية التي تشير إليها a. تسمى هذه الوحدة التي تقاس بها الزوايا باسم الزاوية نصف القطرية أو راديان radian، والدائرة الكاملة تحتوي على ‎2 π راديان، ويمكن الحصول على الثابت الطبيعي باي π في جافاسكربت من خلال Math.PI. يكون لشيفرة تحريك القطة مقياسًا يدعى angle للزاوية الحالية التي عليها التحريك، بحيث يتزايد في كل مرة تُستدعى فيها دالة animate، ثم يمكن استخدام هذه الزاوية لحساب الموضع الحالي لعنصر الصورة. يُحسب التنسيق العلوي top باستخدام Math.sin ويُضرب في 20، وهو نصف القطر الرأسي للقطع الناقصة في مثالنا، وبالمثل يُبنى تنسيق left على Math.cos، ويُضرب في 200 لأن القطع الناقص عرضه أكبر من ارتفاعه. لاحظ أن التنسيقات تحتاج إلى وحدات في الغالب، وفي تلك الحالة فإننا نحتاج أن نلحق "px" إلى العدد ليخبر المتصفح أن وحدة العدّ التي نستخدمها هي البكسل -وليس سنتيمترات أو ems أو أي شيء آخر-، وهذه النقطة مهمة لسهولة نسيانها، حيث ستتسبب كتابة أعداد دون وحدات في تجاهل التنسيق الخاص بك، إلا إن كان العدد صفرًا، وذلك لأن معناه لا يختلف مهما اختلفت الوحدات. خاتمة تستطيع البرامج المكتوبة بجافاسكربت فحص المستند الذي يعرضه المتصفح والتدخل فيه بالتعديل، من خلال هيكل بيانات يسمى DOM، حيث يمثِّل هذا الهيكل نموذج المتصفح للمستند، ويعدّله برنامج جافاسكربت من أجل التعديل في المستند المعروض على الشاشة. يُنظَّم DOM في هيئة شجرية، بحيث تُرتَّب العناصر فيها هرميًا وفقًا لهيكل المستند، والكائنات التي تمثل العناصر لها خصائص مثل parentNode وchildNodes التي يمكن استخدامها للتنقل في الشجرة، كما يمكن التأثير على طريقة عرض المستند من خلال التنسيقات، إما عبر إلحاق تنسيقات بالعُقد مباشرةً، أو عبر تعريف قواعد تتطابق مع عُقد بعينها، ولدينا العديد من خصائص التنسيقات مثل color وdisplay، كما تستطيع شيفرة جافاسكربت التعديل في تنسيق العنصر مباشرةً من خلال خاصية style. تدريبات بناء جدول يُبنى الجدول في لغة HTML بهيكل الوسم التالي: <table> <tr> <th>name</th> <th>height</th> <th>place</th> </tr> <tr> <td>Kilimanjaro</td> <td>5895</td> <td>Tanzania</td> </tr> </table> ويحتوي وسم <table> على وسم <tr> يمثل الصف الواحد، ونستطيع في كل صف وضع عناصر الخلايا سواءً كانت خلايا ترويسات <th> أو عادية <td>. ولِّد هيكل DOM لجدول يَعُد الكائنات إذا أُعطيت مجموعة بيانات لجبال ومصفوفة من الكائنات لها الخصائص name وheight وplace، بحيث يجب أن تحتوي على عمود لكل مفتاح وصَفّ لكل كائن، إضافةً إلى صف ترويسة بعناصر <th> في الأعلى لتسرد أسماء الأعمدة. اكتب ذلك بحيث تنحدر الأعمدة مباشرةً من الكائنات، من خلال أخذ أسماء الخصائص للكائن الأول في البيانات، وأضف الجدول الناتج إلى العنصر الذي يحمل سمة id لـ "mountains" كي يصبح ظاهرًا في المستند. بمجرد أن يعمل هذا، اجعل محاذاة الخلايا التي تحتوي قيمًا عدديةً إلى اليمين من خلال ضبط خاصية style.textAlign لها لتكون "right". تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <h1>Mountains</h1> <div id="mountains"></div> <script> const MOUNTAINS = [ {name: "Kilimanjaro", height: 5895, place: "Tanzania"}, {name: "Everest", height: 8848, place: "Nepal"}, {name: "Mount Fuji", height: 3776, place: "Japan"}, {name: "Vaalserberg", height: 323, place: "Netherlands"}, {name: "Denali", height: 6168, place: "United States"}, {name: "Popocatepetl", height: 5465, place: "Mexico"}, {name: "Mont Blanc", height: 4808, place: "Italy/France"} ]; // ضع شيفرتك هنا </script> إرشادات للحل استخدِم document.createElement لإنشاء عُقد عناصر جديدة، وdocument.createTextNode لإنشاء عُقد نصية، والتابع appendChild لوضع العُقد داخل عُقد أخرى. قد تريد التكرار على أسماء المفاتيح مرةً كي تملأ الصف العلوي، ثم مرةً أخرى لكل كائن في المصفوفة لتضع بيانات الصفوف، كما يمكنك استخدام Object.keys للحصول على مصفوفة أسماء المفاتيح من الكائن الأول. استخدِم document.getElementById أو document.querySelector لإيجاد العُقدة التي لها سمة id الصحيحة، إذا أردت إضافة الجدول إلى العُقدة الأصل المناسبة. جلب العناصر بأسماء وسومها يُعيد التابع document.getElementsByTagName جميع العناصر الفرعية التي لها اسم وسم معيَّن. استخدِم نسختك الخاصة منه على أساس دالة تأخذ عُقدةً وسلسلةً نصيةً -هي اسم الوسم- على أساس وسائط، وتُعيد مصفوفةً تحتوي على عُقد العناصر المنحدرة منه، والتي لها اسم الوسم المعطى. استخدِم خاصية nodeName لعنصر ما كي تحصل على اسم الوسم الخاص به، لكن لاحظ أن هذا سيعيد اسم الوسم بأحرف إنجليزية من الحالة الكبيرة capital، لذا يمكنك استخدام التابعين النصيين toLowerCase أو toUpperCase لتعديل حالة تلك الحروف كما تريد. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <h1>Heading with a <span>span</span> element.</h1> <p>A paragraph with <span>one</span>, <span>two</span> spans.</p> <script> function byTagName(node, tagName) { // ضع شيفرتك هنا. } console.log(byTagName(document.body, "h1").length); // → 1 console.log(byTagName(document.body, "span").length); // → 3 let para = document.querySelector("p"); console.log(byTagName(para, "span").length); // → 2 </script> إرشادات للحل يمكن حل هذا التدريب بسهولة باستخدام دالة تعاودية كما فعلنا في دالة talksAbout التي تقدم شرحها هنا. استدع byTagname نفسها تعاوديًا للصق المصفوفات الناتجة ببعضها لتكون هي الخرج، أو تستطيع إنشاء دالة داخلية تستدعي نفسها تعاوديًا ولها وصول إلى رابطة مصفوفة معرَّفة في الدالة الخارجية، بحيث يمكنها إضافة العناصر التي تجدها إليها، ولا تنسى استدعاء الدالة الداخلية من الدالة الخارجية كي تبدأ العملية. يجب أن تتحقق الدالة التعاودية من نوع العُقدة، وما يهمنا هنا هو العُقدة التي من النوع 1 أي Node.ELEMENT_NODE، كما علينا في مثل تلك العُقد علينا التكرار على فروعها، وننظر في كل فرع إن كان يطابق الاستعلام في الوقت نفسه الذي نستدعيه تعاوديًا فيه للنظر في فروعه هو. قبعة القطة وسِّع مثال تحريك القطة الذي سبق كي تدور القطة على جهة مقابلة من القبعة <img src="img/hat.png"> في القطع الناقص أو اجعل القبعة تدور حول القطة أو أي تعديل يعجبك في طريقة حركتيهما. لتسهيل موضعة الكائنات المتعددة، من الأفضل استخدام التموضع المطلق absolute positioning، وهذا يعني أن top وleft تُحسبان نسبةً إلى أعلى يسار المستند. أضف عددًا ثابتًا من البكسلات إلى قيم الموضع كي تتجنب استخدام الإحداثيات السالبة التي ستجعل الصورة تتحرك خارج الصفحة المرئية. تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen. <style>body { min-height: 200px }</style> <img src="img/cat.png" id="cat" style="position: absolute"> <img src="img/hat.png" id="hat" style="position: absolute"> <script> let cat = document.querySelector("#cat"); let hat = document.querySelector("#hat"); let angle = 0; let lastTime = null; function animate(time) { if (lastTime != null) angle += (time - lastTime) * 0.001; lastTime = time; cat.style.top = (Math.sin(angle) * 40 + 40) + "px"; cat.style.left = (Math.cos(angle) * 200 + 230) + "px"; // ضع شيفرتك هنا. requestAnimationFrame(animate); } requestAnimationFrame(animate); </script> إرشادات للحل تقيس الدالتان Math.cos وMath.sin الزوايا بصورة نصف دائرية أي بواحدة الراديان، فإذا كانت الدائرة تساوي 2 باي 2π كما تقدَّم، فستستطيع الحصول على الزاوية المقابلة بإضافة نصف هذه القيمة -والتي تساوي باي أو π- باستخدام Math.PI، وبالتالي سيسهل عليك وضع القبعة على الجهة المقابلة من القطة. ترجمة -بتصرف- للفصل الرابع عشر من كتاب Elequent Javascript لصاحبه Marijn Haverbeke. اقرأ أيضًا الكائن XMLHttpRequest في جافاسكريبت كائنات URL في جافاسكريبت الدوال في جافاسكريبت
  25. تعاملت البرامج التي كتبناها في المقالات السابقة مع بيانات ثابتة، نستطيع فحصها -عند الحاجة- قبل أن يستخدمها البرنامج، ونصمم البرنامج ليناسب تلك البيانات، لكن الواقع يقول أن أغلب البرامج تتصرف وفقًا لمدخلات المستخدم، حيث يخبر المستخدم البرنامج بالملف التي يجب عليه فتحه أو تعديله مثلًا، وقد يطلب البرنامج من المستخدم بيانات ضروريةً، ويشار إلى مثل هذا المنظور البرمجي باسم واجهة المستخدم، وتصميم وبناء هذه الواجهة في السوق البرمجي وظيفة متخصصين في التفاعل بين الآلة والبشر، وفي كيفية تصميم بيئات العمل؛ أما المبرمج العادي فليس لديه تلك الرفاهية، فهو يتصرف بما لديه من منطق، ويفكر مليًا في كيفية تفاعل المستخدمين مع برنامجه أثناء استخدامه. وأبسط ميزة لواجهة المستخدم هي عرض المخرجات، وقد شرحنا ذلك من قبل بطرق بدائية وبسيطة، باستخدام الدالة print في بايثون، والدالة write()‎ في جافاسكربت، وصندوق MsgBox الحواري في VBScript. تتمثل الخطوة التالية في تصميم واجهة المستخدم في أخذ المدخلات من المستخدم مباشرةً، وأبسط طريقة لفعل ذلك هي جعل البرنامج يطلب الدخل في وقت التشغيل، أو جعل المستخدم يمرر البيانات عندما يشغِّل البرنامج، أو -في الحالة المتقدمة- تكون لدينا واجهة رسومية فيها صناديق إدخال للنصوص مثلًا. سنشرح في هذا المقال الطريقتين الأولى والثانية فقط، ونترك الواجهة الرسومية لوقت لاحق من هذه السلسلة لأنها أكثر تعقيدًا، غير أن هناك وحدةً تسمح لنا بتنفيذ صناديق حوارية رسومية بسيطة، وسندرِجها في هذا المقال أيضًا. سنرى الآن كيف نستطيع الحصول على البيانات من المستخدم في جلسة بايثون تفاعلية عادية تعمل في IDLE، أو في طرفية نظام التشغيل، ثم نحاول الحصول على نفس البيانات داخل برنامج. دخل المستخدم في بايثون نحصل على ما يدخله المستخدم في بايثون بالشكل التالي: >>>> print( input("Type something: ") ) تعرض input()‎ المحث المعطى، وهو "Type something" في حالتنا، وتلتقط أي شيء يكتبه المستخدم، ثم تعرض print()‎ تلك الإجابة، ونستطيع إسناد ما يدخله المستخدم إلى متغير: >>> resp = input("What's your name? ") يمكننا طباعة أي قيمة يلتقطها ذلك المتغير: >>> print( "Hi " + resp + ", nice to meet you" ) لاحظ أننا لم نستخدم هذه المرة عامل تنسيق السلسلة النصية لعرض القيمة المخزَّنة في المتغير resp، واكتفينا بإدراج القيمة بين سلسلتين نصيتين، ودمجنا السلاسل الثلاث باستخدام عامل إضافة السلاسل النصية +، وقيمة المتغير resp هي التي التُقطت من المستخدم بواسطة input()‎. لاحظ كيف استخدمنا المسافات داخل السلاسل النصية التي في المحث المعطى إلى input()‎ وسلسلة الخرج، وانظر الجزء الثالث من سلسلة الخرج الذي يبدأ بالفاصلة المتبوعة بمسافة، إذ يخطئ الكثيرون في أماكن تلك المسافات عند إنتاج خرج كهذا، لذا تفحص مثل هذه المواضع جيدًا عند اختبارك للبرامج. قرأ المثال السابق السلاسل النصية، لكن ماذا عن أنواع البيانات الأخرى؟ نجيب على هذا السؤال بأن بايثون تأتي بمجموعة كاملة من دوال تحويل البيانات، التي تستطيع تحويل سلسلة نصية إلى نوع بيانات آخر، مع وجوب أن تكون البيانات التي في السلسلة النصية متوافقةً مع ذلك النوع وإلا فسنحصل على خطأ، ولنعدل مثلًا جدول الضرب الذي ذكرناه من قبل، ليقرأ قيمة المضاعف من المستخدم: >>> multiplier = input("Which multiplier do you want? Pick a number ") >>> multiplier = int(multiplier) >>> for j in range(1,13): ... print( "%d x %d = %d" % (j, multiplier, j * multiplier) ) نقرأ في هذا المثال القيمة من المستخدم، ثم نحولها إلى عدد صحيح باستخدام دالة التحويل int()‎، كما يمكننا تحويلها إلى عدد ذي فاصلة عائمة باستخدام float()‎ عند الحاجة إلى ذلك، ونفذنا التحويل هنا في سطر منفصل لنفصّل الخطوات لتبسيط الشرح، أما في البرمجة في سوق العمل من الشائع تغليف استدعاء input()‎ داخل التحويل كما يلي: >>> multiplier = int( input("Which multiplier do you want? Pick a number ") ) >>> for j in range(1,13): ... print( "%d x %d = %d" % (j, multiplier, j * multiplier) ) غلفنا استدعاء input()‎ داخل استدعاء int()‎، لنجرب تطبيق هذا في برنامج حقيقي، وسنستخدم برنامج دليل جهات الاتصال الذي أنشأناه باستخدام قاموس في مقال البيانات وأنواعها بما أننا نستطيع كتابة الحلقات التكرارية وقراءة بيانات الإدخال: # أنشئ قاموس فارغًا لدليل جهات اتصال addressBook = {} # اقرأ المدخلات إلى أن تصل إلى سلسلة فارغة print() # print a blank line name = input("Type the Name(leave blank to finish): ") while name != "": entry = input("Type the Street, Town, Phone.(Leave blank to finish): ") addressBook[name] = entry name = input("Type the Name(leave blank to finish): ") # والآن، اطلب عرض واحد منها name = input("Which name to display?(leave blank to finish): ") while name != "": print( name, addressBook[name] ) name = input("Which name to display?(leave blank to finish): ") لعل هذا البرنامج هو أكبر برنامج كتبناه حتى الآن، إذ يشمل تسلسلات وحلقتين تكراريتين، وعلى الرغم من أن تصميم واجهة المستخدم فيه ليس مثاليًا؛ إلا أنه يؤدي الغرض المطلوب منه، وسنرى كيفية تطوير هذه الواجهة في مقالات تالية؛ أما الآن فنريد الإشارة إلى استخدام الاختبار البولياني في حلقات while لتحديد رغبة المستخدم في التوقف، لاحظ أيضًا أننا مع استخدامنا لقائمة في مقال مدخل إلى البيانات وأنواعها: أنواع البيانات الأساسية لتخزين البيانات في حقول منفصلة، إلا أننا خزنّاها هنا في سلسلة نصية واحدة، لأننا لم نشرح كيفية تقسيم السلسلة النصية إلى حقول منفصلة بعد. الإدخال في لغة VBScript تقرأ تعليمات InputBox في لغة VBScript دخل المستخدم كما يلي: <script type="text/vbscript"> Dim Input Input = InputBox("Enter your name") MsgBox ("You entered: " & Input) </script> تمثل دالة InputBox صندوقًا حواريًا مع محث وحقل إدخال، وتعيد محتويات حقل الإدخال، وهناك عدة قيم يمكن تمريرها إليها، مثل سلسلة عنوان الصندوق الحواري، إضافةً إلى المحث، فإذا ضغط المستخدم زر الإلغاء Cancel، فستعيد الدالة سلسلةً فارغةً بغض النظر عن محتويات حقل الإدخال. سيبدو مثال دليل الاستخدام في لغة VBScript كما يلي: <script type="text/vbscript"> Dim dict,name,entry ' Create some variables. Set dict = CreateObject("Scripting.Dictionary") name = InputBox("Enter a name", "Address Book Entry") Do While name <> "" entry = InputBox("Enter Details - Street, Town, Phone number", "Address Book Entry") dict.Add name, entry ' Add key and details. name = InputBox("Enter a name","Address Book Entry") Loop ' Now read back the values name = InputBox("Enter a name","Address Book Lookup") Do While name <> "" MsgBox(name & " - " & dict.Item(name)) name = InputBox("Enter a name","Address Book Lookup") Loop </script> لاحظ أن الهيكل الأساسي هنا مطابق تمامًا لبرنامج بايثون، مع أن بعض الأسطر أطول هنا بسبب الحاجة إلى التصريح المسبق عن المتغيرات باستخدام Dim في VBScript، وبسبب الحاجة إلى تعليمة Loop لإنهاء كل حلقة. قراءة المدخلات في جافاسكربت تمثل جافاسكربت تحديًا في هذا المجال لأنها تعمل أساسًا داخل المتصفحات، ولقراءة المدخلات يمكننا استخدام صندوق إدخال بسيط كما في VBScript باستخدام دالة prompt()‎، أو قراءة المدخلات من عنصر استمارة في HTML، أو استخدام تقنية Active Scripting الخاصة بمايكروسوفت -وذلك في إنترنت إكسبلورر فقط- لتوليد صندوق InputBox كما في VBSCript، ولتنويع الأمثلة سنشرح كيفية استخدام تقنية عنصر الاستمارة في HTML، فإذا لم تكن تعرضت للغة HTML من قبل فانظر توثيقها في موسوعة حسوب، أو انسخ ما سنكتبه هنا إذا شئت. <form id='entry' name='entry'> <p>Type a value then click outside the field with your mouse</p> <input type='text' name='data' onChange='alert("We got a value of " + document.forms["entry"].data.value);'> </form> تتكون شيفرة HTML أعلاه من عنصر استمارة form الذي يحتوي على فقرة <p> من سطر واحد، حيث يمثل رسالةً إلى المستخدم، وحقل إدخال نصي input، يحتوي على شيفرة من سطر واحد مرتبطة به، تنفَّذ في كل مرة تتغير فيها القيمة المدخلة، ووظيفة هذه الشيفرة ببساطة، أن تخرِج صندوق رسالة alert يشبه ذاك الذي في VBScript، ويحتوي على القيمة التي في الحقل النصي. كما نرى في أول سطر في الشيفرة، فإن للاستمارة خاصيتين هما id و name، وتحتويان على القيمة entry، وتُخزَّن الاستمارات في سياق المستند document داخل مصفوفة مفهرسة بالاسم، لأنه يمكن استخدام مصفوفات جافاسكربت مثل القواميس كما ذكرنا من قبل، ويحتوي حقل input على الخاصية name التي تحمل القيمة data داخل سياق الاستمارة، ومع ذلك نستطيع الإشارة إلى قيمة value حقل ما داخل برنامج جافاسكربت كما يلي: document.forms["entry"].data.value لن نشرح هنا مثال دليل جهات الاتصال في جافاسكربت، لأن HTML ستتعقَّد وسنحتاج إلى شرح الدوال، لذا سنؤجل هذا إلى مقال تال. مجاري الدخل والخرج القياسية ننظر الآن في أحد المفاهيم الأساسية في حوسبة سطر الأوامر، وهو مفهوم مجاري البيانات data streams، فمصطلح stdin هو أحد المصطلاحات الحاسوبية التي تشير إلى جهاز الدخل القياسي standard input device، وهو عادةً لوحة المفاتيح، وبالمثل يشير stdout إلى جهاز الخرج القياسي، وهو الشاشة عادةً، وسنرى إشارات كثيرةً إلى هذين المصطلحين عند الحديث عن البرمجة، كما يوجد مصطلح ثالث لا يُستخدم كثيرًا، وهو stderr الذي يشير إلى المكان الذي ترسَل إليه جميع أخطاء الطرفية، ويظهر عادةً في نفس مكان stdout، ويطلق على هذه المصطلحات اسم مجاري البيانات، لأن البيانات تظهر في صورة مجارٍ من البايتات التي تتدفق إلى الأجهزة، وقد أُعدَّ كل من stdin وstdout ليشبها الملفات، ليتوافقا مع شيفرة معالجة الملفات. وتوجد هذه المصطلحات في لغة بايثون داخل الوحدة sys، وتسميان sys.stdin وsys.stdout، وتستخدم الدالة input()‎ تلقائيًا stdin، بينما تستخدم print()‎ الخرج القياسي stdout. نستطيع أن نقرأ من الدخل القياسي stdin، وأن نكتب مباشرةً في الخرج القياسي stdout، مما يسمح لنا بتحكم دقيق في الدخل والخرج، لننظر في المثال التالي إلى القراءة من الدخل القياسي: import sys print( "Type a value: ", end='') # تمنع السطر الجديد value = sys.stdin.readline() # صراحة stdin استخدم print( value ) تطابق الشيفرة أعلاه تقريبًا الشيفرة التالية: print( input("Type a value: ") ) إن ميزة النسخة الصريحة هنا أننا نستطيع جعل الدخل القياسي يشير إلى ملف حقيقي، ليقرأ البرنامج الدخل الخاص به من الملف عوضًا عن الطرفية، وهذه الطريقة مفيدة في حالة جلسات الاختبار الطويلة التي يقرأ البرنامج فيها مدخلاته من ملف بدلًا من الجلوس وكتابة الدخل يدويًا كلما طلبه البرنامج، مما يضمن تكرار تشغيل الاختبار مع ثبات الدخل في كل مرة، وكذلك الخرج، وتسمى هذه التقنية -التي تكرر الاختبارات السابقة لضمان عدم تعطل شيء في الشيفرة- باسم الاختبار الارتدادي regression testing. وأخيرًا لدينا مثال عن خرج مباشر إلى sys.stdout، ويمكن إعادة توجيهه إلى ملف كذلك، وتكافئ الدالة print ما يلي: sys.stdout.write("Hello world\n") # \n= newline لا شك أننا نستطيع تحقيق نفس النتيجة باستخدام سلاسل التنسيق النصية، إذا كنا نعرف الشكل الذي ستكون البيانات عليه، لكن إذا لم نعرف شكلها إلا وقت التشغيل، فمن الأسهل أن نرسلها إلى الخرج القياسي، بدلًا من محاولة بناء سلسلة تنسيق معقدة وقت التشغيل. إعادة توجيه الدخل والخرج القياسيين يمكن توجيه الدخل والخرج القياسيين إلى ملفات من داخل برنامجنا مباشرةً، باستخدام تقنيات بايثون المعتادة للتعامل مع الملفات، والتي سنشرحها فيما يلي، لكن الطريقة الأسهل هي من خلال نظام التشغيل، تتصرف أوامر النظام عند استخدام إعادة التوجيه في سطر الأوامر كما يلي: C:> dir C:> dir > dir.txt يطبع الأمر الأول قائمةً من المجلدات على الشاشة -والتي تمثل الخرج القياسي-، بينما يطبع الأمر الثاني تلك القائمة إلى ملف، فقد أخبرنا البرنامج أن يوجه الخرج القياسي إلى الملف dir.txt، ويمكن فعل نفس الشيء مع برنامج بايثون كما يلي: $ python myprogram.py > result.txt ستشغّل الشيفرة السابقة البرنامج myprogram.py، وستكتب الخرج إلى الملف result.txt بدلًا من كتابته على الشاشة، ونستطيع رؤية الخرج لاحقًا باستخدام محرر نصي، لاحظ أن محث علامة الدولار $ هو المحث القياسي لمستخدمي لينكس وماك. نستخدم علامة ‎<‎ بدلًا من ‎>‎ لتوجيه الدخل القياسي إلى ملف، ننشئ في المثال التالي ملفًا باسم echoinput.py يحتوي على الشيفرة: import sys inp = input() while inp != '': print( inp ) inp = input() نستطيع الآن تشغيل الملف من سطر الأوامر كما يلي: $ python echoinput.py يجب أن تكون النتيجة برنامجًا يعيد طباعة أي شيء تكتبه إلى أن تكتب سطرًا فارغًا، والآن أنشئ ملفًا نصيًا بسيطًا باسم input.txt يحتوي بعض الأسطر النصية، وشغّل البرنامج الأخير مرةً أخرى معيدًا توجيه الدخل من input.txt: $ python echoinput.py < input.txt ستعيد بايثون طباعة الأسطر النصية الموجودة في الملف. نستطيع اختبار عدة سيناريوهات على برامجنا بسهولة إذا استخدمنا تلك التقنية على عدة ملفات، مثل قيم البيانات المعطوبة أو أنواع البيانات الخاطئة، وننفذ ذلك بأسلوب موثوق قابل للتكرار، كما نستطيع استخدامها لمعالجة البيانات كبيرة الحجم من ملف، مع السماح بالإدخال اليدوي للبيانات صغيرة الحجم باستخدام نفس البرنامج، وهكذا نرى أن الدخل والخرج القياسيين مفيدان جدًا للمبرمجين. معاملات سطر الأوامر لدينا نوع آخر من الإدخال، وهو الإدخال من سطر الأوامر، كما في حالة تشغيل المحرر النصي من سطر أوامر النظام: $ edit Foo.txt يستدعي نظام التشغيل هنا البرنامج الذي يحمل الاسم edit، ليمرر إليه اسم الملف الذي نريد تعديله، وهو Foo.txt هنا. لكن كيف يقرأ المحرر اسم الملف؟، يوفر نظام التشغيل في أغلب لغات البرمجة مصفوفةً أو قائمةً من سلاسل نصية تحتوي كلمات سطر الأوامر، وبناءً عليه سيحتوي العنصر الأول على الأمر نفسه، ثم يحتوي العنصر التالي على الوسيط الأول وهكذا، وقد توجد بعض المتغيرات السحرية هنا والتي يُطلق عليها argc، وهي اختصار argument count، وتحمل عدد العناصر الموجودة في القائمة، وتحتفظ الوحدة sys في بايثون بهذه القائمة وتسميها argv، وهي اختصار argument values، وأول عنصر فيها argv[0]‎ هو اسم ملف السكربت الذي ينفَّذ، ولا تحتاج بايثون قيمةً من النوع argc لأننا نستطيع استخدام التابع len()‎ لإيجاد طول السلسلة، بل لا نحتاج إلى ذلك أصلًا في أغلب الحالات، لأننا نمر على القائمة باستخدام حلقة for الخاصة ببايثون: import sys for item in sys.argv: print( item ) print( "The first argument was:", sys.argv[1] ) لاحظ أن هذه الشيفرة لا تعمل إلا إذا وُضعت في ملف مثل args.py، ونُفذت من محث نظام التشغيل كما يلي: C:\PYTHON\PROJECTS> python args.py 1 23 fred args.py 1 23 fred The first argument was: 1 C:\PYTHON\PROJECTS> يجب إحاطة اسم الوسيط بعلامات اقتباس إذا احتوى على مسافات، كما يلي: C:\PYTHON\PROJECTS> python args.py "Alan Gauld" fred args.py Alan Gauld fred The first argument was: Alan Gauld C:\PYTHON\PROJECTS> جافاسكربت وVBscript لا نرى مفهوم وسائط سطر الأوامر في هاتين اللغتين لأنهما موجهتان للعمل داخل المتصفحات، فإذا استخدمناهما داخل بيئة Windows Script Host الخاصة بمايكروسوفت، فستوفر بيئة WSH آليةً لاستخراج مثل تلك الوسائط من كائن WshArguments الذي تملؤه WSH في وقت التشغيل. خاتمة لن نذهب أبعد من هذا الحد في شأن مدخلات المستخدم في هذه السلسلة، وهو الحد الذي يمكننا من كتابة برامج مفيدة، وقد كان ذلك كل ما يستطيعه المبرمج في الأيام الأولى لأنظمة يونكس أو لمبرمجي الحواسيب الشخصية، ولا شك أن البرامج الرسومية تقرأ المدخلات من المستخدم، لكن التقنيات التي تستخدمها مختلفة كليًا، لهذا سنؤجلها إلى مقالات لاحقة من هذه السلسلة. تعلمنا في هذا المقال استخدام الدالة input()‎ لقراءة السلاسل النصية، وعرفنا أنها تعرض سلسلةً تطلب إدخالًا من المستخدم، كما تعرفنا إلى الدخل والخرج القياسيين لمجاري البيانات، وكيفية إعادة توجيههما ليكونا في صورة ملفات، وعرفنا أن input()‎ تعمل مع stdin، وأن print()‎ تعمل مع stdout. ويمكن الحصول على وسطاء سطر الأوامر من قائمة argv المستوردة من وحدة sys في بايثون، حيث يكون العنصر الأول هو اسم البرنامج، ورأينا أن جافاسكربت وVBScript تستطيعان قراءة المدخلات من استمارات الويب forms أو من خلال الصناديق الحوارية، لكن ليس لهما وصول إلى stdin، كما تستطيعان عرض الخرج بكتابته إلى المستند document أو من خلال الصناديق الحوارية، وليس لهما وصول إلى stdout. ترجمة -بتصرف للفصل التاسع: Conversing with the user من كتاب Learning To Program لصاحبه Alan Gauld. اقرأ أيضًا المقال التالي: مقدمة في البرمجة الشرطية المقال السابق: أسلوب كتابة الشيفرات البرمجية وتحقيق سهولة قراءتها دليلك الشامل لتعلم البرمجة ترميز النصوص والتعامل مع كائنات الملفات في جافاسكريبت النسخة الكاملة لكتاب تعلم البرمجة للمبتدئين
×
×
  • أضف...