JSON Toolkit

تحليل وعرض وتدفق JSON الكبير

الخلاصة: غالباً ليس التحليل هو عنق الزجاجة؛ بل العرض والنسخ. حسّن على طول الطبقات الأربع "تحليل ← اجتياز ← عرض ← نقل" حتى لا يضيع جهدك.

متوسطقراءة نحو 13 دقيقةنُشر في 2026-10-30حُدِّث في 2026-10-31
غلاف تحليل وعرض وتدفق JSON الكبير

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

استنتاج "JSON.parse بطيء" خاطئ في ثماني حالات من عشر. ما هو بطيء فعلاً هو ما تفعله بعد التحليل.

1. قِس أولاً، ثم حسّن

لملف JSON نفسه بحجم 10MB، يختلف الوقت المصروف في المراحل المختلفة برتبة مقدار:

المرحلة المدة النموذجية التكلفة الرئيسية
JSON.parse (أصلي) عشرات إلى ما يزيد قليلاً على مئة ميلي ثانية مسح خطي واحد زائد بناء الكائنات
نسخة عميقة (structuredClone / عودية) ربما 1–3 أضعاف التحليل إعادة بناء الرسم البياني كاملاً
مقارنة عميقة / diff ربما الضعف مرة أخرى اجتياز عودي زائد مقارنة نصوص
العرض في شجرة DOM يتجمد بمجرد عشرات الآلاف من العقد التخطيط وإعادة التدفق
دمج النصوص ثم التسلسل نسبي إلى حجم الخرج يضاعف الذاكرة

لذا قِس قبل أن تتصرف: في المتصفح، غلّف كل مرحلة بـperformance.now()، أو استخدم console.time؛ وفي Node، استخدم process.memoryUsage() لمراقبة نمو الكومة. لا خط أساس، لا تحسين.

2. طبقة التحليل: ثلاث استراتيجيات

2.1 التحليل الكامل (الخيار الافتراضي)

const data = JSON.parse(text);

التنفيذ الأصلي يقوم بالمسح وبناء الكائنات بـC++، لذا يصعب على مكتبة JS خالص التغلب عليه. استخدمه ما دامت الشروط الثلاثة التالية قائمة:

  • النص يتسع بالكامل في الذاكرة (عادةً < 100MB)؛
  • معظم الحقول مطلوبة فعلاً بعد التحليل؛
  • درجة عدم الثقة في البيانات قابلة للإدارة.

2.2 التحليل التزايدي التدفقي

مع محلل بنمط SAX مثل stream-json، يمكنك مسح الحقول المستهدفة دون بناء شجرة كائنات كاملة:

import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';

const pipeline = createReadStream('huge.json')
  .pipe(parser())
  .pipe(pick({ filter: 'items' }))
  .pipe(streamArray());

for await (const { value } of pipeline) {
  // عالج عنصراً واحداً في كل مرة؛ الذاكرة مستقلة عن عدد العناصر
  handleItem(value);
}

التكاليف:

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

قاعدة القرار: التدفق يستحق فقط عندما يكون الملف أكبر من أن يتسع، أو عندما تحتاج مجموعة فرعية صغيرة جداً من حقوله.

2.3 غيّر التنسيق

إذا كانت البيانات "دفعة من السجلات" لا "وثيقة واحدة"، فكان يجب أن تستخدم JSON Lines من البداية. تنسيق مفصول بالأسطر يحوّل "التدفق" إلى "قراءة سطراً بسطر"، ما يخفض تعقيد التنفيذ برتبة مقدار.

3. طبقة الاجتياز: نسخة أقل تكسب نصف المعركة

// نمط مرفوض: نسخ عميق أولاً "من أجل الأمان"
const safeCopy = structuredClone(bigData);   // الذاكرة تتضاعف فوراً

// نمط جيد: مرّر المراجع مباشرة في السيناريوهات للقراءة فقط
function renderSummary(data: OrderDocument): Summary {
  return { total: data.items.length, amount: data.total };
}

نسخ خفية شائعة:

  • JSON.parse(JSON.stringify(x)) يُستخدم كـ"نسخة عميقة" — وهذا أغلى نوع؛
  • { ...data } ينسخ مستوى واحداً فقط، لكن لكائن كبير ما زال ينسخ مصفوفة المراجع لكل المفاتيح في المستوى الأعلى؛
  • Array.prototype.map ينشئ مصفوفة جديدة، وهو مكلف أيضاً عندما تكون العناصر كثيرة؛
  • وكلاء التفاعلية في الأطر (مثل reactive في Vue) يمرّرون الكائنات الكبيرة عودياً، وقد تكون التكلفة أعلى من التحليل نفسه.

نصيحة عملية: أبقِ "البيانات الخام" ككائن عادي غير قابل للتغيير ولا تغلّفه في التفاعلية؛ واحسب وذاكر البيانات المشتقة التي تحتاج إلى عرضها بشكل منفصل.

4. طبقة العرض: لا تحشُ الشجرة كاملة في DOM

ملف JSON بحجم 5MB فيه نحو 30،000 إلى 100،000 عقدة؛ وعرضها كلها كعناصر DOM سيجعل المتصفح يتوقف عن الاستجابة.

ثلاثة مناهج عملية:

4.1 التوسيع عند الطلب

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

type TreeNode = { key: string; value: unknown; expanded: boolean };

/**
 * يعيد العقد الظاهرة حالياً فقط، متجنباً بناء الشجرة كاملة دفعة واحدة.
 * @param rootNode العقدة الجذرية
 * @param expandedPaths مجموعة المسارات التي تم توسيعها
 * @returns قائمة العقد الظاهرة
 */
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
  const visibleNodes: TreeNode[] = [];

  /**
   * يجمع عودياً العقد الفرعية للفروع الموسعة.
   * @param currentNode العقدة الحالية
   * @param currentPath المسار الحالي
   */
  const walk = (currentNode: TreeNode, currentPath: string): void => {
    visibleNodes.push(currentNode);
    if (!expandedPaths.has(currentPath) || typeof currentNode.value !== 'object' || currentNode.value === null) {
      return;
    }
    for (const [childKey, childValue] of Object.entries(currentNode.value)) {
      walk({ key: childKey, value: childValue, expanded: false }, `${currentPath}/${childKey}`);
    }
  };

  walk(rootNode, '');
  return visibleNodes;
}

4.2 التمرير الافتراضي

بمجرد أن تتجاوز العقد الموسعة بضع مئات، اعرض فقط البضع عشرات داخل نافذة العرض واحتفظ بمساحة للبقية بارتفاعات نائبة. وهو الأكثر فعالية في العروض بأسلوب القائمة (الجداول، السجلات).

4.3 اعرض الملخصات فقط

اعرض أولاً "البنية + النوع + العدد" (مثلاً items: Array(12000))؛ ولا تجلب القيمة الفعلية إلا عندما يفتح المستخدم عنصراً. هذه الحيلة تعمل أفضل في سيناريو "أريد فقط التأكد من أن الحقول صحيحة".

5. الخيط الرئيسي: انقل العمل إلى Worker عندما يجب ذلك

التحليل والتنسيق والمقارنة لأي شيء أكبر من 1MB يجب أن تنتقل إلى Web Worker:

// json.worker.ts
self.onmessage = (event: MessageEvent<{ text: string }>) => {
  try {
    const parsed = JSON.parse(event.data.text);
    self.postMessage({ ok: true, data: parsed });
  } catch (parseError) {
    self.postMessage({ ok: false, message: (parseError as Error).message });
  }
};
/**
 * يحلل JSON داخل Worker حتى لا يُحجب الخيط الرئيسي.
 * @param text نص JSON
 * @returns نتيجة التحليل
 */
function parseInWorker(text: string): Promise<unknown> {
  return new Promise((resolvePromise, rejectPromise) => {
    const worker = new Worker(new URL('./json.worker.ts', import.meta.url), { type: 'module' });
    worker.onmessage = (event) => {
      worker.terminate();
      if (event.data.ok) {
        resolvePromise(event.data.data);
      } else {
        rejectPromise(new Error(event.data.message));
      }
    };
    worker.postMessage({ text });
  });
}

انتبه لتكلفة الاتصال في Workers: postMessage يستخدم structured cloning افتراضياً، واستنساخ كائن بحجم 10MB مرة واحدة يستغرق أيضاً عشرات الميلي ثانية. إذا كان كل ما تحتاجه هو "حلّله وسلّمه للخيط الرئيسي"، فافعل العمل الثقيل داخل Worker (تحليل + تحويل + تحقق) وأعد ملخص النتيجة.

6. طبقة النقل: تحسين الحجم

التقنية المكسب النموذجي التكلفة
gzip / brotli الخرج المضغوط غالباً 10%–25% من الحجم الأصلي يتطلب دعم الخادم
إزالة الإزاحة والمسافات تخفيض 20%–40% غير قابل للقراءة البشرية
اختصار أسماء الحقول مكاسب ملحوظة مع التداخل العميق قابلية قراءة أقل؛ يتطلب جدول تعيين
أرقام بدلاً من نصوص enum صغير يتطلب قاموساً
ترقيم الصفحات / الحقول عند الطلب الأكبر إعادة تصميم API

للصفحات المخصصة للبشر، التركيبة الصحيحة هي: ترسل API JSON مضغوطاً ومضغوطاً بالنقل، واجهة المستخدم تنسّقه للعرض فقط. كثير من مواقع الأدوات ترسل افتراضياً نصاً منسّقاً إلى الواجهة مرة أخرى، فتشحن 30% بايتات إضافية بلا سبب.

7. كيف تقيس الأداء

/**
 * يقيس المدة الوسيطة لروتين متزامن حتى لا تُحرّف نتيجة واحدة متذبذبة النتيجة.
 * @param runOnce الدالة تحت الاختبار
 * @param repeatCount عدد التكرارات
 * @returns المدة الوسيطة بالمللي ثانية
 */
function measureMedian(runOnce: () => void, repeatCount = 9): number {
  const durations: number[] = [];
  for (let roundIndex = 0; roundIndex < repeatCount; roundIndex += 1) {
    const startedAt = performance.now();
    runOnce();
    durations.push(performance.now() - startedAt);
  }
  durations.sort((firstValue, secondValue) => firstValue - secondValue);
  return durations[Math.floor(durations.length / 2)];
}

ثلاث قواعد انضباط:

  1. استخدم عينة ثابتة ولا تولّد البيانات عشوائياً، وإلا فالأرقام غير قابلة للمقارنة؛
  2. شغّل 9 مرات على الأقل وخذ الوسيط؛ التشغيلة الأولى تتضمن تسخين JIT ويجب إهمالها؛
  3. سجّل بيئة التشغيل (إصدار Node، CPU)، وإلا فالمقارنات بين الأجهزة بلا معنى.

8. قائمة الفحص

  • [ ] توجد أرقام خط أساس حتى يمكن مقارنة قبل وبعد؛
  • [ ] تحليل أي شيء أكبر من 1MB يعمل في Worker؛
  • [ ] عروض الشجرة تتوسع عند الطلب والقوائم تستخدم التمرير الافتراضي؛
  • [ ] لا نسخ عميقة بلا معنى ولا تغليف تفاعلي؛
  • [ ] يُستخدم JSON Lines عندما تكون البيانات "دفعة من السجلات"؛
  • [ ] ترسل API JSON مضغوطاً ويحدث التنسيق للعرض فقط.