JSON Toolkit

لماذا تتقطّع أشجار JSON الكبيرة: التمرير الافتراضي واستراتيجيات الطي

الخلاصة: يبدأ التقطّع عند إنشاء عقد DOM لا عند التمرير. يغطي العرض بالطي أولاً، والقوائم الافتراضية، وتمييز البحث وحدود Worker، مع مقاييس.

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

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

التقطّع يأتي عادةً من مئات الآلاف من عقد DOM التي أنشأتها لتلك الـ5 ميغابايت، لا من الـ5 ميغابايت نفسها.

1. قسّم "البطء" إلى أربع مراحل

عندما يقول مستخدم "إنه يتجمد عند الفتح"، يمكن أن تكون المشكلة في أربع مراحل مختلفة تماماً، لكل منها علاج مختلف:

المرحلة المحفز النموذجي عتبة الإدراك التكلفة المهيمنة
التحليل وبناء الكائنات فتح ملف، لصق نص أي مهمة تتجاوز 50ms مسح النصوص، بناء الرسم البياني للكائنات
إنشاء عقد DOM عرض الشجرة كاملة دفعة واحدة عشرات الآلاف من العقد تخصيص عناصر وعقد نصوص وكائنات أنماط
التخطيط والرسم العرض الأول، التمرير أي إطار يتجاوز 16.7ms الهندسة، إعادة التدفق، إعادة الرسم
استجابة التفاعل توسيع، طي، بحث أكثر من 100ms يُحس كتأخير إعادة التسطيح وإعادة العرض

الخيط الرئيسي في المتصفح يفعل شيئاً واحداً في كل مرة. إذا شغلت أي مرحلة عليه أكثر من 50ms، تسقط إطارات التمرير والكتابة؛ وإذا تكررت عدة مهام كهذه على التوالي تبدو الصفحة ميتة. لذا فالتحسين الأول ليس requestAnimationFrame؛ بل قياس المرحلة التي تصرف الوقت فعلاً.

2. عدد العقد هو العدو الحقيقي

عارضات الشجرة تضاعف أعداد العقد بحسب تصميمها. سطر JSON واحد يصبح عادةً "إزاحة + مفتاح + نقطتان + قيمة + فاصلة + سهم"، لذا فالتقدير المتحفظ هو 3 إلى 6 عقد DOM لكل سطر:

حجم الملف الأسطر التقريبية عقد DOM لعرض الشجرة (تقدير) كيف يبدو العرض دفعة واحدة
100 KB 2،500 10،000 مقبول عادةً
1 MB 25،000 100،000 متقطع بوضوح، وربما ثوانٍ من شاشة فارغة
5 MB 125،000 500،000 غير مستجيب على الأرجح
20 MB 500،000 أكثر من مليونين انهيار الذاكرة والتخطيط معاً

كل عنصر أكثر من بضع عشرات من البايتات: فهو يحمل قواعد أنماط، ومربع تخطيط، وتعليمات رسم. وبمجرد إنشاء العقدة، تظل تكلفة التخطيط والأنماط قائمة حتى لو أخفاها لاحقاً أب مطويّ، طالما لم تُزل فعلياً. "اعرض كل شيء ثم أخفه" ليس تحسيناً؛ إنه يؤجل التكلفة فقط إلى إعادة التدفق التالية.

3. الطي أولاً، التمرير الافتراضي ثانياً

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

استراتيجية طي عملية:

  • وسّع المستويين الأولين فقط افتراضياً. الجذر وحقوله المباشرة ظاهرة؛ والمستويات الأعمق تعرض ملخصاً مثل items: Array(12000)؛
  • ضع النوع والعدد في الملخص. المستخدمون الذين يتحققون من "هل أسماء الحقول صحيحة" نادراً ما يحتاجون عشرة آلاف سجل؛
  • خزّن الصفوف مؤقتاً لكل شجرة فرعية. كل عقدة موسعة تملك مصفوفة صفوفها المسطّحة؛ والطي يتجاهلها، والتوسيع يعيد حساب تلك الشجرة الفرعية فقط؛
  • لا تمسح الشجرة كاملة عند كل تبديل. إذا حدث التوسيع عند الصف 3،000، استبدل ذلك النطاق فقط وأبقِ الصفوف المحيطة سليمة.
/**
 * إدراج صفوف شجرة فرعية بعد أبيها بدلاً من إعادة تسطيح الشجرة كاملة.
 * @param rows مصفوفة الصفوف الظاهرة الحالية
 * @param parentIndex فهرس صف الأب
 * @param childRows صفوف الشجرة الفرعية بعد تسطيحها
 * @returns مصفوفة صفوف ظاهرة جديدة
 */
function replaceSubtreeRows(
  rows: TreeRow[],
  parentIndex: number,
  childRows: TreeRow[],
): TreeRow[] {
  const nextRows = rows.slice();
  nextRows.splice(parentIndex + 1, 0, ...childRows);
  return nextRows;
}

النمط الذي يجب تجنبه هو "توسيع واحد، إعادة تخطيط كاملة واحدة". إعادة اجتياز الرسم البياني الكامل للكائنات عند كل نقر يجعل التكلفة تنمو خطياً مع الوثيقة. مع التخزين المؤقت لكل شجرة فرعية، يكلف التوسيع الواحد بقدر تلك الشجرة الفرعية فقط.

4. القوائم الافتراضية: اعرض نافذة العرض فقط

بعد أن يقلل الطي عدد الصفوف، قد يبقى الباقي عشرات الآلاف من الصفوف. تضبط القائمة الافتراضية ارتفاع الحاوية على "إجمالي الصفوف × ارتفاع الصف الثابت" وتنشئ عناصر لنافذة العرض فقط، مع مخزن مؤقت صغير فوقها وتحتها.

const ROW_HEIGHT = 24;
const OVERSCAN = 8;

/**
 * حساب نطاق الصفوف التي يجب أن تعرضها نافذة العرض.
 * @param rows كل الصفوف الظاهرة
 * @param scrollTop إزاحة التمرير الحالية للحاوية
 * @param viewportHeight الارتفاع الظاهر للحاوية
 * @returns الصفوف المراد عرضها، وفهرس البداية، والإزاحة الرأسية
 */
function getViewportRows(
  rows: TreeRow[],
  scrollTop: number,
  viewportHeight: number,
): { visibleRows: TreeRow[]; startIndex: number; offsetTop: number } {
  const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
  const end = Math.min(
    rows.length,
    Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN,
  );

  const visibleNodes = rows.slice(start, end);
  return {
    visibleRows: visibleNodes,
    startIndex: start,
    offsetTop: start * ROW_HEIGHT,
  };
}

قيود غير قابلة للتفاوض:

  • يجب أن يكون ارتفاع الصف ثابتاً. عندئذ فقط يمكن لـscrollTop / ROW_HEIGHT أن يعطيك الفهرس مباشرة. السماح بصفوف ملتفة يجبرك على قياس وتخزين كل ارتفاع، وهو ما يضيف رتبة مقدار من التعقيد والاهتزاز؛
  • أزح بـtransform أو باستعمال الموضع المطلق. لا تدفع المحتوى إلى الأسفل بعنصر فاصل، وإلا فكل تمرير يعدّل التخطيط؛
  • استخدم مفاتيح مستقرة. ميّز الصفوف بالمسار لا بفهرس المصفوفة، وإلا فإن توسيع صف واحد يعيد تركيب كل صف بعده؛
  • احسب في معالج التمرير فقط. أجّل العرض إلى requestAnimationFrame واحتط ضد الجدولة المكررة؛
  • أبقِ المخزن المؤقت صغيراً. خمسة إلى عشرة صفوف على كل جانب تكفي؛ ومضاعفة المخزن تضاعف عقد DOM وعمل الأنماط.

مع نافذة عرض بـ800 بكسل وصفوف بـ24 بكسل، يكون الظاهر نحو 34 صفاً، وحتى مع المخزن المؤقت تعرض نحو خمسين. يمكن أن تحتوي الوثيقة الأساسية مئة ألف صف بينما يحتفظ DOM باستمرار ببضع مئات من العناصر، ما يُبقي تكلفة التخطيط مسطّحة.

5. الطي والتوسيع ومرساة التمرير

بمجرد أن يصبح فهرس الصف الإحداثي الوحيد، تبدأ بعض تفاصيل التفاعل في الانكسار:

  1. حافظ على المرساة المرئية عند الطي فوق نافذة العرض. تنزلق الصفوف التي تحتها إلى الأعلى؛ وإذا لم يُعدّل scrollTop في الالتزام نفسه، يبدو المحتوى وكأنه يقفز. سجّل مسار صف المرساة وأعد موضع التمرير باستخدام فهرسه الجديد.
  2. وسّع الآباء قبل القفز. عندما يصطدم بحث بحقل عميق، وسّع كل أب أولاً، ثم احسب فهرس الصف الهدف، ثم مرّر وميّز. تخطَّ الخطوة الأولى والصف الهدف لن يكون حتى في المصفوفة الظاهرة.
  3. قُد التنقل بلوحة المفاتيح من الصفوف الظاهرة. أعلى وأسفل يحركان فهرس الصف؛ ويسار ويمين يوسّعان ويطويان. لا تجتاز الشجرة كاملة داخل معالج مفتاح أبداً.
  4. اجمع التحديثات في إطار واحد. عند توسيع عدة مستويات على التوالي، ادمج تغييرات المصفوفة واعرض مرة واحدة، وإلا فإن كل مستوى يطلق إعادة تدفق كاملة.

6. البحث والتمييز: احسب مرة، واعرض قليلاً

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

نهج متعدد الطبقات:

  • أخّر الإدخال بـ150–250ms وضع رقماً تسلسلياً على كل بحث حتى تُهمل النتائج القديمة؛
  • امسح داخل Worker. اجتياز الرسم البياني للكائنات حساب خالص بلا اعتماد على DOM، لذا فلا ينبغي أن يمنع الكتابة؛
  • خزّن المسارات وفهارس الصفوف فقط. لا تنسخ كل عقدة مطابقة ولا تُبنِ HTML مُميّزاً مسبقاً؛
  • ميّز الصفوف الظاهرة فقط. نافذة العرض تحمل بضع عشرات من الصفوف؛ طبّق الأنماط بالمسار المطابق وقت العرض؛
  • أعد استخدام "توسيع الآباء ثم تحديد الفهرس" للقفزات، وهو المسار نفسه المستخدم عند النقر على سهم.

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

7. تمييز الصياغة: CodeMirror وMonaco أداتان مختلفتان

عرض الشجرة ومحرر النصوص نموذجا عرض مختلفان، لذا فسؤال "أيهما أسرع" ليس مقارنة مباشرة:

البُعد CodeMirror 6 Monaco Editor
التموضع نواة محرر معيارية محرر IDE كامل
العرض قائم على نافذة العرض قائم على نافذة العرض، لكن النماذج والزخارف أثقل
التحليل تحليل Lezer التزايدي على النطاقات المتغيرة مُرمّز Monarch، سطراً بسطر
الملفات الكبيرة يعتمد على الإضافات؛ خفيف افتراضياً مكتمل الميزات، ذاكرة وتكلفة بدء أعلى
الطي والبحث إضافات منفصلة تُحمّل عند الطلب مدمجة، مع تكلفة إضافية دائمة
الفخ النموذجي إضافات كثيرة جداً تعيد التكلفة الخريطة المصغرة، والتمرير اللاصق، والزخارف المتراكمة

استنتاجان عمليان:

  • عرض الشجرة لا يحتاج مُرمّزاً كاملاً. أعطِ المفاتيح والنصوص والأرقام والقيم المنطقية لوناً واحداً لكل منها بعقد نصوص عادية وفئات CSS؛ فذلك يكلف رتبة مقدار أقل من تشغيل تمييز الصياغة؛
  • لا تُركّب عرض شجرة ومحرراً في الوقت نفسه. صيانة نموذجين لوثيقة واحدة تضاعف الذاكرة؛ دمّر العرض القديم بدلاً من إخفائه.

إذا كان للمحرر مكان داخل العارض فعلاً، فإن CodeMirror 6 يناسب "للقراءة فقط، ملف كبير، تمييز محلي"، بينما يناسب Monaco الإكمال والتشخيص وتحرير المؤشرات المتعددة. قِس زمن أول رسم والذاكرة المقيمة قبل قراءة قائمة الميزات. لمقارنة أعمق للمحررات وأدوات CLI، انظر المحررات وأدوات سطر الأوامر والأتمتة.

8. حدود Worker: انقل التحليل وبناء الصفوف خارج الخيط الرئيسي

النصف الآخر من تقطّع العرض هو التحليل على الخيط الرئيسي. بالنسبة لـJSON أكبر من 1MB، ينتمي التحليل والتنسيق وبناء الصفوف المسطّحة إلى Web Worker؛ والخيط الرئيسي يستقبل الصفوف فقط ويرسم نافذة العرض.

Workers ليست مجانية: postMessage يستخدم structured clone، لذا إرسال رسم بياني للكائنات بحجم 50MB إلى الخيط الرئيسي يبني نسخة ثانية ويضاعف الذروة. الحد الصحيح هو التحليل والتسطيح داخل Worker، وإرجاع الصفوف التي يحتاجها العارض فقط:

worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
  rows = event.data.rows;
  requestAnimationFrame(() => renderViewport());
};

worker.postMessage({ text });

إذا كانت بيانات الصفوف ما زالت كبيرة، فاضغط الصفوف في مصفوفات متوازية (مسار، نوع، إزاحة قيمة)، وضعها في ArrayBuffer، وانقل الملكية حتى لا يدفع الخيط الرئيسي تكلفة نسخة. التكلفة هي كتابة مسلسل خاص بك، وهو ما يستحق فقط عند ملايين الصفوف.

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

9. المقاييس وترتيب التنفيذ

لا يمكن أن يتوقف التحسين عند "أشعر أنه أسرع". المجموعة التالية تصلح مباشرة لقائمة فحص القبول:

المقياس الهدف طريقة القياس
أول إطار ظاهر أقل من 300ms performance.now() من الفتح إلى أول رسم
المهام الطويلة لا شيء يتجاوز 50ms PerformanceObserver يراقب longtask
زمن إطار التمرير مستقر تحت 16.7ms تسجيل Performance في DevTools
عناصر DOM في نافذة العرض أقل من 2،000 document.querySelectorAll('*').length
استجابة التوسيع / الطي أقل من 50ms طوابع زمنية حول التفاعل
بحث على أكثر من 100،000 صف أقل من 300ms، داخل Worker قِس في Worker وأعد المدة
الكومة المقيمة أقل من 3 أضعاف حجم النص لقطة كومة Memory في DevTools

قِس بملف حقيقي لا بكائنات صغيرة مولّدة بشكل موحّد؛ فشكل البيانات مهم، والنصوص الطويلة والتداخل العميق والمصفوفات شديدة الاتساع تُنتج منحنيات مختلفة. أما دفتر الرسم البياني للكائنات نفسه، فانظر دفتر ذاكرة JSON.

نفّذ بهذا الترتيب:

  1. قِس خط أساس: زمن أول عرض، عدد العقد، زمن إطار التمرير؛
  2. اطوِ افتراضياً: وسّع مستويين، وأظهر النوع والعدد في الملخصات؛
  3. قائمة افتراضية: ارتفاع صف ثابت، مفاتيح مستقرة، نافذة عرض مع مخزن مؤقت صغير؛
  4. انقل الحساب: التحليل والبحث والتسطيح إلى Worker؛
  5. قُيّد التمييز: نمّط الصفوف الظاهرة فقط، وتجنب إعادة رسم الشجرة كاملة؛
  6. أضف حواجز: فوق عتبة معينة، اعرض "إظهار أول N صف" أو اطلب مرشح بحث.

كل خطوة تشترك في هدف واحد: تقليل العقد والكائنات التي يجب أن توجد في اللحظة نفسها، بدلاً من مطالبة المتصفح بالعمل بجهد أكبر عليها. الخلفية الكاملة عن الاستراتيجية متعددة الطبقات في تحليل وعرض وتدفق JSON الكبير.

هل يمكن للتمرير الافتراضي إصلاح تقطّع وقت التحليل؟

لا. التمرير الافتراضي يقلل عقد DOM فقط. إذا ظل التحليل يحدث على الخيط الرئيسي، فستظل الصفحة تتقطع. انقل التحليل إلى Worker للوثائق الأكبر من 1MB وعالج المشكلتين بشكل منفصل.

كل شيء موسّع وما زال يتقطع. ما الخطأ؟

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

CodeMirror أم Monaco لعارض JSON؟

تفضّل CodeMirror 6 للقراءة فقط والملفات الكبيرة والتمييز المحلي؛ فتحميله المعياري عند الطلب أخف. اختر Monaco عندما تحتاج إلى الإكمال والتشخيص وتحرير المؤشرات المتعددة. مع أي منهما، تجنّب تركيب عرض شجرة ومحرر للوثيقة نفسها في وقت واحد.

لماذا موضع التمرير خاطئ بعد قفزة بحث؟

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

FAQ

كيف أعرف من أي مرحلة يأتي التقطّع؟

قسّم "البطء" إلى التحليل وبناء الكائنات، وإنشاء عقد DOM، والتخطيط والرسم، واستجابة التفاعل، وقِس زمن كل مرحلة بـperformance.now() من الفتح إلى أول رسم. ثم راقب longtask بـPerformanceObserver — أي مهمة تتجاوز 50ms تسقط إطارات، ويعرض تسجيل Performance في DevTools ما إذا كان زمن إطار التمرير يبقى تحت 16.7ms.

لماذا يجعل عرض الشجرة كاملة دفعة واحدة الشاشة فارغة؟

وثيقة بحجم 5MB تعادل نحو 125،000 سطر، وعرض الشجرة يحوّل عادةً كل سطر إلى 3 إلى 6 عقد DOM — أي نحو 500،000 عنصر دفعة واحدة. كل عنصر يحمل أيضاً قواعد أنماط ومربع تخطيط وتعليمات رسم، لذا فعدة مهام على الخيط الرئيسي تتجاوز 50ms على التوالي تجعل الصفحة تبدو ميتة. العقد التي أخفاها أب مطويّ تظل تكلف عمل تخطيط، لذا فـ"اعرض كل شيء ثم أخفه" ليس تحسيناً.

هل يجب إرجاع رسم بياني للكائنات بحجم 50MB من Worker؟

لا. postMessage يستخدم structured clone، لذا يبني الجانب المستلم نسخة ثانية وتتضاعف الذروة. الحد الصحيح هو التحليل والتسطيح داخل Worker وإرجاع الصفوف التي يحتاجها العارض فقط؛ وعند ملايين الصفوف يمكنك حزم مصفوفات متوازية (مسار، نوع، إزاحة قيمة) في ArrayBuffer ونقل الملكية.