تحميل تفاعلي للملفات إلى الخادم باستخدام RxJS



لقد مر وقت طويل منذ أن كتبت مقالتي الأخيرة حول أساسيات RxJS. طُلب مني في التعليقات عرض أمثلة أكثر تعقيدًا قد تكون مفيدة عمليًا. لذا قررت أن أضعف النظرية قليلاً ، واليوم سنتحدث عن تحميل الملفات.

ماذا نفعل؟

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

لفهم هذه المقالة ، ستحتاج إلى معرفة RxJS الأساسية. ما هي عوامل التشغيل ، الملاحظة ، بالإضافة إلى مشغلي HOO

لن نقوم بسحب القطة من الذيل ونبدأ على الفور في العمل!

تدريب


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

const express = require("express");
const multer  = require("multer");

const app = express();
const upload = multer({ dest:"files" });

app.post("/upload", upload.single("file"), function (req, res) {
    const { file } = req;

    if (file) {
        res.send("File uploaded successfully");
    } else {
        res.error("Error");
    }

});

app.listen(3000);

الآن قم بإنشاء صفحة html سنضع فيها جميع العناصر الضرورية:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>File uploading</title>
</head>
<body>
    <label for="file">load file: <input id="file" type="file"></label>
    <button id="upload">Upload</button>
    <button id="cancel">Cancel</button>
    <div id="progress-bar" style="width: 0; height: 2rem; background-color: aquamarine;"></div>
    <script src="index.js" type="text/javascript"></script>
</body>
</html>

الآن في الصفحة لدينا 4 عناصر يتفاعل معها المستخدم:

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

في نهاية علامة النص الأساسي ، أضفت رابطًا إلى البرنامج النصي index.js ، والذي سنحتاج أيضًا إلى إنشائه:

//   ,     
const input = document.querySelector('#file');
const uploadBtn = document.querySelector('#upload');
const progressBar = document.querySelector('#progress-bar');
const cancelBtn = document.querySelector('#cancel');

يجب أن يبدو شيء مثل هذا:



كل شيء هو تدفق


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

كيف نفهم أن المستخدم قد اختار ملفًا؟ هناك حدث "تغيير" لذلك. بعد تشغيل الحدث ، يمكننا التحول إلى مصفوفة الملفات في الإدخال ، حيث ستتم كتابة بيانات الملف.

كيف نستمع لحدث التغيير؟ يمكنك استخدام طريقة addEventListener والعمل معها. لكننا نعمل مع RxJS ، حيث يمكن تمثيل أي حدث كتيار:

fromEvent(input, 'change').pipe(
    //    
    map(() => input.files[0])
).subscribe({
    next: data => console.log(file)
});

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

function upload(file) {
  console.log(file);
}

يجب استدعاء وظيفة التحميل بعد النقر فوق الزر uploadBtn:

fromEvent(uploadBtn, 'click').subscribe({
  next: () => upload(input.files[0])
});

دمج تيارات


الآن لا يختلف رمزنا عما نكتبه باستخدام addEventListener. نعم ، إنها تعمل ، ولكن إذا تركناها على هذا النحو ، فسوف نفقد المزايا التي تقدمها لنا RxJS.

ماذا نستطيع فعله؟ سنصف تسلسل الخطوات لتحميل ملف:

  • اختيار الملف
  • انقر فوق uploadBtn
  • استخراج الملف من ملف الإدخال
  • تحميل الملف

الآن ننقل هذا التسلسل إلى الكود. ولكن كيف تجمع بين تيارات الإدخال والتحميل؟ سيساعدنا عامل switchMap في ذلك ، مما يتيح لنا عرض موضوع واحد على آخر:

fromEvent(input, 'change').pipe(
    switchMap(() => fromEvent(uploadBtn, 'click')),
    map(() => input.files[0])
).subscribe({
    next: file => upload(file)
});

هذا الرمز مشابه جدًا لتسلسل التعليمات التي وصفناها أعلاه. يختار المستخدم ملفًا ، ويتم تشغيل switchMap ، ونشترك في uploadBtn. ولكن بعد ذلك لن يحدث شيء.

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

الشيء الأكثر إثارة للاهتمام هنا هو أن تسلسل التعليمات غير مكسور. لكي تعمل وظيفة التحميل ، يجب إطلاق حدث "التغيير" من قبل.

ولكن لا تزال هناك مشكلة واحدة. يمكن للمستخدم تحديد ملف ثم إلغاء تحديده. ثم عندما نحاول تحميل الملف ، نقوم بتمرير وظيفة التحميل - غير محددة. لتجنب هذا الموقف ، يجب إضافة شيك:

fromEvent(input, 'change').pipe(
    switchMap(() => fromEvent(uploadBtn, 'click')),
    map(() => input.files[0]),
    filter(file => !!file)
).subscribe({
    next: file => upload(file)
});

العمل مع xhr


حان الوقت لتنفيذ أصعب - عملية التفريغ. سأعرضه على مثال للعمل مع xhr ، نظرًا لأن الجلب ، في وقت كتابة هذا التقرير ، لا يعرف كيفية تتبع تقدم التحميل .
يمكنك تنفيذ التفريغ باستخدام أي مكتبة أخرى ، على سبيل المثال axios أو jQuery.ajax.


نظرًا لأنني أستخدم multer على جانب الخادم ، فسيتعين علي نقل الملف داخل النموذج (يقبل multer البيانات بهذا التنسيق فقط). لهذا ، كتبت وظيفة createFormData:

function createFormData(file) {
    const form = new FormData();
    //       file
    form.append('file', file);
    return form;
}

fromEvent(input, 'change').pipe(
    switchMap(() => fromEvent(uploadBtn, 'click')),
    map(() => input.files[0]),
    filter(file => !!file),
    map(file => createFormData(file))
).subscribe({
    next: data => upload(data)
});

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

function upload(data) {
    const xhr = new XMLHttpRequest();

    //        
    xhr.onload = () => console.log('success');

    //    
    xhr.onerror = e => console.error(e);

    //  
    xhr.open('POST', '/upload', true);

    //  
    xhr.send(data);
}

الآن لدينا مثال عملي. لكنه يحتوي على بضع عيوب:

  • لا توجد طريقة لإلغاء التنزيل
  • إذا قمت بالنقر فوق الزر uploadBtn n مرات ، فسيكون لدينا n اتصالات متوازية لتحميل ملف واحد

وذلك لأن وظيفة التحميل تعمل خارج البث. تعيش بمفردها. نحن بحاجة إلى إصلاحه. دعونا نجعل الدالة تعيد إلينا الملاحظة. ثم يمكننا التحكم في تحميل الملفات:

function upload(data) {
    return new Observable(observer => {
        const xhr = new XMLHttpRequest();

        //    ,      
        //   
        xhr.onload = () => {
            observer.next();
            observer.complete();
        };
        
        xhr.onerror = e => observer.error(e);
        
        xhr.open('POST', '/upload', true);
        xhr.send(data);

        //   -  
        return () => xhr.abort();
    });
}

لاحظ وظيفة السهم التي تم إرجاعها داخل الملاحظة. سيتم استدعاء هذه الطريقة في وقت إلغاء الاشتراك وإلغاء التحميل.

ضع مكالمة التحميل في سويتش ماب:

fromEvent(input, 'change').pipe(
    switchMap(() => fromEvent(uploadBtn, 'click')),
    map(() => input.files[0]),
    filter(file => !!file),
    map(file => createFormData(file)),
    switchMap(data => upload(data))
).subscribe({
    next: () => console.log('File uploaded')
});

الآن ، إذا نقر المستخدم على زر التحميل مرة أخرى ، فسيتم إلغاء الطلب السابق ، ولكن سيتم إنشاء طلب جديد.

قم بإلغاء طلب النقر


لا يزال لدينا زر calcelBtn. يجب علينا تنفيذ إلغاء الطلب. سيساعدك عامل TakeUntil هنا.

TakeUntil يترجم إلى "أخذ وداعا". يأخذ هذا المشغل القيم من التدفق الخارجي ويعطيها المزيد من السلسلة. طالما أن الخيط الداخلي موجود ولا يولد أي شيء. بمجرد أن يولد مؤشر الترابط الداخلي قيمة ، سيقوم TakeUntil باستدعاء طريقة إلغاء الاشتراك وإلغاء الاشتراك من مؤشر الترابط الخارجي.

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

fromEvent(input, 'change').pipe(
    switchMap(() => fromEvent(uploadBtn, 'click')),
    map(() => input.files[0]),
    filter(file => !!file),
    map(file => createFormData(file)),
    switchMap(data => upload(data).pipe(
        //    upload
        takeUntil(fromEvent(cancelBtn, 'click'))
    ))
).subscribe({
    next: () => console.log('File uploaded')
});

شريط التقدم


يبقى لإضافة شريط التقدم. لتتبع التقدم ، نحتاج إلى تعريف طريقة xhr.upload.onprogress. يتم استدعاء هذه الطريقة عند حدوث الحدث ProgressEvent. يحتوي كائن الحدث على العديد من الخصائص المفيدة لنا:

  • lengthComputable - إذا كان ذلك صحيحًا ، فإننا نعرف حجم الملف الكامل (في حالتنا ، دائمًا صحيح)
  • الإجمالي - إجمالي عدد وحدات البايت
  • تحميل - عدد وحدات البايت المرسلة

قم بإجراء تغييرات على وظيفة التحميل:

function upload(data) {
    return new Observable(observer => {
        const xhr = new XMLHttpRequest();

        xhr.upload.onprogress = e => {
            //  
            const progress = e.loaded / e.total * 100;
            observer.next(progress);
        };

        xhr.onerror = e => observer.error(e);
        xhr.onload = () => observer.complete();

        xhr.open('POST', '/upload', true);
        xhr.send(data);

        return () => xhr.abort();
    });
}

تحميل الآن يبصق حالة التحميل في الدفق. يبقى فقط لكتابة دالة من شأنها تغيير خصائص نمط عنصر ProgressBar:

function setProgressBarWidth(width) {
    progressBar.style.width = `${width}%`;
}

fromEvent(input, 'change').pipe(
   /* ..
   
   */
).subscribe({
    next: width => setProgressBarWidth(width)
});


نصيحة سريعة: حتى لا يتم تحميل ملفاتك محليًا بسرعة كبيرة ، شغّل إعداد "Fast 3G" أو "Slow 3G" في علامة التبويب "Performance" في أدوات تطوير Chrome.

ضع في اعتبارك


لقد حصلنا على تطبيق عمل كامل. يبقى لإضافة بضع ضربات. الآن ، عندما تنقر على زر uploadBtn ، نلغي التحميل السابق ونبدأ تحميلًا جديدًا. ولكن لدينا بالفعل زر إلغاء.

أريد أن لا يستجيب زر uploadBtn للنقرات اللاحقة حتى نقوم بتحميل الملف (أو حتى نلغي التحميل). ماذا يمكن ان يفعل؟

يمكنك تعليق سمة التعطيل حتى تكتمل عملية التحميل. ولكن هناك خيار آخر - عامل exhaustMap. سيتجاهل هذا البيان القيم الجديدة من مؤشر الترابط الخارجي حتى اكتمال مؤشر الترابط الداخلي. استبدال switchMap بـ exhaustMap:

exhaustMap(data => upload(data).pipe(
  takeUntil(fromEvent(cancelBtn, 'click'))
))

والآن يمكننا اعتبار طلبنا كاملاً. إعادة بيع صغيرة والحصول على النسخة النهائية:

import { fromEvent, Observable } from "rxjs";
import { map, switchMap, filter, takeUntil, exhaustMap } from "rxjs/operators";

const input = document.querySelector('#file');
const uploadBtn = document.querySelector('#upload');
const progressBar = document.querySelector('#progress-bar');
const cancelBtn = document.querySelector('#cancel');

const fromUploadBtn = fromEvent(uploadBtn, 'click');
const fromCancelBtn = fromEvent(cancelBtn, 'click');

fromEvent(input, 'change').pipe(
    switchMap(() => fromUploadBtn),
    map(() => input.files[0]),
    filter(file => !!file),
    map(file => createFormData(file)),
    exhaustMap(data => upload(data).pipe(
        takeUntil(fromCancelBtn)
    ))
).subscribe({
    next: width => setProgressBarWidth(width)
});

function setProgressBarWidth(width) {
    progressBar.style.width = `${width}%`;
}

function createFormData(file) {
    const form = new FormData();
    form.append('file', file);
    return form;
}

function upload(data) {
    return new Observable(observer => {
        const xhr = new XMLHttpRequest();

        xhr.upload.onprogress = e => {
            const progress = e.loaded / e.total * 100;
            observer.next(progress);
        };

        xhr.onerror = e => observer.error(e);
        xhr.onload = () => observer.complete();

        xhr.open('POST', '/upload', true);
        xhr.send(data);

        return () => xhr.abort();
    });
}

لقد نشرت نسختي هنا .

الزاوي و HttpClient


إذا كنت تعمل مع Angular ، فلن تحتاج إلى استخدام xhr مباشرة. لدى Angular خدمة HttpClient. يمكن لهذه الخدمة تتبع تقدم التحميل / التفريغ ، لذلك يكفي تمرير المعلمات التالية إلى طريقة النشر:

  • reportProgress: صحيح - احصل على معلومات التحميل / التنزيل
  • ملاحظة: "الأحداث" - تشير إلى أننا نريد تلقي HttpEvents من الدفق

إليك ما ستبدو عليه طريقة التحميل في Angular:

export class UploaderService {
  constructor(private http: HttpClient) { }

  public upload(data: FormData): Observable<number> {
    return this.http.post('/upload', data, { reportProgress: true, observe: 'events' })
      .pipe(
        filter(event => event.type === HttpEventType.UploadProgress),
        map(event => event as HttpProgressEvent),
        map(event => event.loaded / event.total * 100)
      );
  }
}

يقوم بيان الفلتر بتصفية أحداث التحميل فقط. أحداث أخرى لا تهمنا. علاوة على ذلك ، نقدم الحدث إلى HttpProgressEvent للوصول إلى الخصائص المحملة والإجمالية. نحن نعتبر النسبة المئوية.

HttpClient هو مجرد غلاف على xhr ينقذنا من لوحة مرجعية ويجعل العمل مع HTTP أسهل.

يمكن العثور على مثال لتطبيق على Angular هنا .

استنتاج


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

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

حظا طيبا وفقك الله

Source: https://habr.com/ru/post/undefined/


All Articles