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

تتبع تقدم عملية تنزيل البيانات عبر Fetch ومقاطعتها في جافاسكربت


ابراهيم الخضور

تتيح الدالة fetch تتبع عملية التنزيل download. لاحظ أنه لا توجد حاليًا طريقة تسمح للدالة fetch بتتبع عملية الرفع upload، نستخدم لهذه الغاية الكائن XMLHttpRequest الذي سنغطيه لاحقًا.

تُستخدم الخاصية response.body لتتبع تقدم التنزيل، وتمثل هذه الخاصية كائن ReadableStream، وهو كائن خاص يزودنا عند وصوله بجسم الطلب كتلةً بكتلة chunk-by-chunk، ستجد وصفًا لمجاري التدفق القابلة للقراءة Readable streams في توصيف الواجهة Streams API، وتمنح الخاصية response.body تحكمًا كاملًا بعملية القراءة على خلاف التابعين ()response.text و()response.json وغيرهما. كما تمنح إمكانية تقدير الوقت المستغرق في أية لحظة. إليك مثالُا عن شيفرة تقرأ الاستجابة من response.body:

// والطرق الأخرى  response.json() بدلا من 
const reader = response.body.getReader();

// حلقة لا نهائية حتي يكتمل التنزيل
while(true) {
  // عند آخر جزء true القيمة done ستحمل 
  //لبايتات كل جزء Unit8Array هو value 
  const {done, value} = await reader.read();

  if (done) {
    break;
  }

  console.log(`Received ${value.length} bytes`)
}

ستكون نتيجة الاستدعاء ()await reader.read كائنًا له الخاصيتان التاليتان:

  • done : تأخذ القيمة true عندم اكتمال عملية القراءة، وإلا فستكون قيمتها false.
  • value : مصفوفة من النوع Uint8Array.
اقتباس

تصف الواجهة البرمجية المرور iteration غير المتزامن الذي يمكن تطبيقه على ReadableStream باستخدام الحلقة for await..of، لكنه لا يُدعم بشكل واسع (راجع browser issues)، لذلك نستخدم الحلقة while، حيث سنحصل على أجزاء متتالية من الاستجابة عبرها حتى انتهاء التحميل، أي حتى تحمل الخاصية done القيمة true، وللحصول على سجل العملية سنحتاج إلى كل جزء value استقبلناه، وذلك لإضافة حجمه إلى عداد الحلقة، إليك المثال الكامل الذي يحصل على الاستجابة، ويصل إلى سجلات تقدم عملية التنزيل عبر الطرفية:

//واحصل على قارئ للبيانات Fetch الخطوة1: إبدأ تنفيذ الدالة 
let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits?per_page=100');

const reader = response.body.getReader();

// الخطوة2: احصل على الحجم الكلي
const contentLength = +response.headers.get('Content-Length');

// الخطوة3 : إقرأ البيانات
let receivedLength = 0; // حجم البايتات المستقبلة حتى اللحظة
let chunks = []; // مصفوفة الأجزاء المستلمة التي تمثل جسم الاستجابة
while(true) {
  const {done, value} = await reader.read();

  if (done) {
    break;
  }

  chunks.push(value);
  receivedLength += value.length;

  console.log(`Received ${receivedLength} of ${contentLength}`)
}

// الخطوة 4: ضم الأجزاء في مصفوفة واحدة
let chunksAll = new Uint8Array(receivedLength); // (4.1)
let position = 0;
for(let chunk of chunks) {
  chunksAll.set(chunk, position); // (4.2)
  position += chunk.length;
}

// الخطوة5: الترميز في سلسلة نصية
let result = new TextDecoder("utf-8").decode(chunksAll);

// النهاية
let commits = JSON.parse(result);
alert(commits[0].author.login);

المخرجات:

"Received 258566 of 0"
"Received 444982 of 0"

لنشرح الشيفرة السابقة:

  1. لقد نفّذنا الدالة ftech، لكننا استخلصنا مجرى التدفق ()reader response.body.getReader بدلًا من استدعاء التابع ()response.json، ولايمكن استخدام الطريقتين معًا لقراءة الاستجابة، استخدم إحداهما للحصول على النتيجة.
  2. يمكننا قبل الشروع في قراءة الاستجابة تحديد الحجم الكلي لها عن طريق الترويسة Content-Length، وقد لا تكون الترويسة موجودةً في الطلبات ذات الأصل المختلط Cross-origin، لكن لن يُعدَّها الخادم عمليًا، وستبقى في مكانها.
  3. نستدعي التابع ()await reader.read حتى ينهي عمله، ونُجمِّع أجزاء الاستجابة في المصفوفة chunks، وهذا الأمر ضروري لأن الاستجابة ستختفي ولن نتمكن من إعادة قراءتها باستخدام ()response.json ولا بأي طريقة أخرى، وستحصل على خطأ إذا حاولت ذلك.
  4. سنحصل في النهاية على chunks وهي مصفوفة من الأجزاء لها النوع Uint8Array، وعلينا تجميعها ضمن نتيجة واحدة، ولسوء الحظ لا يوجد تابع لضمها، لهذا علينا كتابة الشيفرة التي ستنجز العملية:
  5. إنشاء المصفوفة (chunksAll = new Uint8Array(receivedLength، وهي مصفوفة من النوع Uint8Array لها حجم جميع الأجزاء.
  6. استخدام التابع (set(chunk, position. لنسخ كل جزء بدوره إليها.
  7. سنحصل على النتيجة ضمن المصفوفة chunksAll، وهي مصفوفة من البايتات وليست نصًا، ولتحويلها إلى نص لا بد من تفسير هذه البيانات عن طريق الكائن TextDecoder، ثم استخدام JSON.parse إن استدعت الحاجة.

لكن ماذا لو احتجنا إلى محتوىً ثنائي بدل النص؟ سيكون الأمر أبسط، علينا فقط استبدال الخطوتين 4 و5 بسطر وحيد يُنشئ كائن بيانات ثنائية Blob يضم كل الأجزاء.

   let blob = new Blob(chunks);

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

ولا بد من التحقق من حجم البيانات الواصلة receivedLength في كل لحظة ضمن الحلقة وإنهائها بمجرد وصولها إلى حد معين، إذا لم يكن حجم البيانات التي سنستقبلها معروفًا، وبالتالي لن تستهلك المصفوفة chunks الذاكرة.

الكائن AbortController: مقاطعة العمليات غير المتزامنة

تعيد fetch كما نعرف وعدًا promise، ولكننا نعلم أنّ JavaScript لا تقبل إلغاء الوعود عمومًا، فكيف سنلغي عملية fetch أثناء تنفيذها؟

هنالك كائن خاص مدمج لهذا الغرض هو AbortController، يمكن استخدامه لإلغاء fetch وغيرها من المهام غير المتزامنة، ويطبّق مباشرةً.

لننشئ متحكمًا بالشكل التالي:

let controller = new AbortController();

والمتحكم هو كائن شديد البساطة، له:

  • تابع وحيد هو ()abort.
  • وخاصية واحدة هي signal تسمح بإعداد مستمع حدث event listener له.

أما عند استدعاء التابع ()abort فسيحدث الآتي:

  • تحرّض الخاصية controller.signal وقوع الحدث abort.
  • تأخذ الخاصية controller.signal.aborted القيمة true.

تكون للعملية في العادة مرحلتان:

  1. المرحلة التي تُنفِّذ عمليةً قابلة للإلغاء، وتهيئ مستمع حدث للخاصية controller.signal.
  2. المرحلة التي تلغي: وتُنفَّذ باستدعاء التابع ()controller.abort عندما يتطلب الأمر.

إليك مثالًا كاملًا دون Fetch:

let controller = new AbortController();
let signal = controller.signal;

// القيام بعملية قابلة للإلغاء
// "signal" الحصول على الكائن 
// controller.abort() ضبط إطلاق المستمع عند استدعاء 
signal.addEventListener('abort', () => alert("abort!"));

// القيام بالإلغاء
controller.abort(); // abort!

// true القيمة  signal.aborted إطلاق الحدث ويصبح لــ 
alert(signal.aborted); // true

لاحظ أنّ الكائن AbortController هو مجرد أداة لتمرير الحدث abort عند استدعاء التابع ()abort، ومن الواضح أنه بالإمكان تنفيذ مستمع حدث كهذا باستخدام شيفرتنا الخاصة دون الحاجة إلى AbortController، لكن أهميته ستظهر عندما نعلم أن fetch تعرف تمامًا كيفية التعامل معه، فهو متكامل معها.

مقاطعة العملية Fetch

لإلغاء العملية fetch علينا تمرير قيمة الخاصية signal العائدة للكائن AbortController مثل خيار لها:

let controller = new AbortController();
fetch(url, {
  signal: controller.signal
});

تعلم fetch تمامًا كيفية التعامل مع AbortController، وستستمع إلى الحدث abort الذي تحرّض الخاصية signal وقوعه.

لإلغاء العملية سنستدعي التابع:

controller.abort();

وهكذا تلغى العملية، حيث تحصل fetch على الحدث abort من الخاصية signal وتلغي الطلب، عند إلغاء fetch سيُرفض الوعد الذي تعيده وسيُرمى الخطأ AbortError، وينبغي التعامل معه من خلال حلقة try..catch مثلًا.

إليك مثالًا كاملًا مع استخدام fetch، حيث تلغى العملية بعد ثانية واحدة:

// الإلغاء خلال ثانية واحدة 
let controller = new AbortController();
setTimeout(() => controller.abort(), 1000);

try {
  let response = await fetch('/article/fetch-abort/demo/hang', {
    signal: controller.signal
  });
} catch(err) {
  if (err.name == 'AbortError') { // handle abort()
    alert("Aborted!");
  } else {
    throw err;
  }
}

كائن قابل للتوسع

يسمح الكائنAbortController بإلغاء عدة عمليات معًا، إليك الشيفرة التمثيلية التالية التي تحضر عدة موارد على التوازي، ثم تستخدم كائن متحكم وحيدًا لإلغائها جميعًا:

let urls = [...]; //قائمة بالموارد التي ينبغي إحضارها

let controller = new AbortController();

// fetch مصفوفة من الوعود التي ستعيدها عمليات 
let fetchJobs = urls.map(url => fetch(url, {
  signal: controller.signal
}));

let results = await Promise.all(fetchJobs);

// بمجرد استدعاء حدث الإلغاء ستلغى جميع عمليات الإحضار

وسنتمكن أيضًا من إلغاء أي عمليات أخرى غير متزامنة مع عمليات fetch باستخدام كائن AbortController وحيد، بمجرد الاستماع إلى الحدث abort:

let urls = [...];
let controller = new AbortController();

let ourJob = new Promise((resolve, reject) => { // المهمة المطلوب إلغاءها
  ...
  controller.signal.addEventListener('abort', reject);
});

let fetchJobs = urls.map(url => fetch(url, { // عمليات الإحضار
  signal: controller.signal
}));

// انتظار إنجاز جميع العمليات
let results = await Promise.all([...fetchJobs, ourJob]);

// بمجرد استدعاء حدث الإلغاء ستلغى جميع عمليات الإحضار
// بالإضافة إلى بقية المهام

خلاصة

بهذا نكون قد تعرفنا على كيفية تتبع عملية التنزيل باستخدام Fetch، وذلك بالاعتماد على عدة خاصيات، كما تعرفنا على كيفية مقاطعة العملية Fetch، وذلك بالاعتماد على الكائنات الآتية:

  • AbortController: هو كائن بسيط يولّد الحدث abort على الخاصية signal عند استدعاء التابع ()abort، الذي يعطي الخاصية signal.aborted القيمة "true" أيضًا.
  • تتكامل fetch مع هذا الكائن، حيث تُمرر الخاصية signal كخيار لتستمع إليه، وبالتالي يصبح إلغاؤها ممكنًا.
  • يمكن استخدام AbortController في شيفرتنا، حيث يستمع التابع ()abort إلى الحدث abort بعملية بسيطة تطبق في أي مكان، كما يمكن استخدامها دون استخدام fetch.

ترجمة -وبتصرف- للفصلين Fetch: Download Progress وFetch: Abort من سلسلة The Modern JavaScript Tutorial.

اقرأ أيضًا


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

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

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



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

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

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

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

  Only 75 emoji are allowed.

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

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

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


×
×
  • أضف...