توقّف عن ضغط JSON بترتيب خاطئ: minify مقابل gzip مقابل brotli، بقياس فعلي
الخلاصة: الضغط يقلل 30–50% من JSON الخام، لكن gzip يمحو معظم هذه المكاسب. أرقام مقاسة عبر أشكال حقيقية، إضافة إلى الحالات الأربع التي يجعل فيها minify الأمور أسوأ.

الخلاصة: افصل ضغط النقل عن التشذيب البنيوي أولاً. إذا لم يكن gzip أو brotli مفعّلاً بعد، فأصلح إعداد الخادم — gzip -6 وحده يصل إلى 94.5% ويترك لـminify قيمة 0.9 نقطة فقط. أما نسبة minify الكاملة 46.3% فلا تتحقق إلا عندما تُسجَّل فاتورة البايت محلياً: أجسام الطلبات، أو حد 6 MB في Lambda، أو حد 1 MiB في Firestore، أو JSON المكتوب على القرص والسجلات.
شارة "وفرت 35%" في أداة الضغط المفضلة لديك صادقة — إنها تقيس البايتات الخام. لكن API الخاص بك يعمل فوق gzip أو brotli، وكلاهما يأكل التكرار نفسه بالضبط. إذا ضربت الرقمين فستحصل على الجواب الخاطئ.
1. افصل "الضغط" إلى أمرين مختلفين
ما يسميه الناس ضغط JSON هو في الحقيقة عمليتان غير مرتبطتين، وخلط بينهما هو كيف يبدأ كل استنتاج خاطئ.
| minify (إزالة المسافات) | gzip / brotli (ضغط النقل) | |
|---|---|---|
| الطبيعة | تحويل للوثيقة | تحويل للنقل |
| الخرج | نص JSON ما زال صالحاً وقابلاً للقراءة | ثنائي؛ غير قابل للاستخدام حتى يُفكّ |
| أين تتحقق المكسب | القرص، الذاكرة، السجلات، حدود الحجم | فقط على الشبكة وفي ذاكرات التخزين المؤقت |
| بعد فك التشفير | تبقى البايتات أصغر | تعود البايتات إلى الحجم الأصلي |
| المسؤول عنه | سكربت البناء، معاملات المسلسل | Nginx، CDN، إطار الخادم |
الصفّان الأخيران هما المهمان. حدود المنصات تُقاس بعد فك التشفير: حد 6 MB لأجسام الطلبات في AWS Lambda، وحد 1 MiB لكل وثيقة في Firestore. تفعيل gzip لا يشتري لك بايتاً واحداً هناك، لأن كليهما يحسب النتيجة بعد فك التشفير.
2. minify يزيل شيئاً واحداً بالضبط: المسافات البنيوية
يسمح RFC 8259 بالمسافات (space، \t، \n، \r) حول أي محرف بنيوي. ولا شيء آخر في وثيقة JSON اختياري: أسماء المفاتيح بيانات، والأرقام تحتفظ بكل رقم، ومحتويات النصوص غير قابلة للمساس بايتاً ببايت.
وهذا يجعل العملية كلها سطراً واحداً:
const minified = JSON.stringify(JSON.parse(pretty));
// RFC 8259 يسمح بالمسافات حول المحارف البنيوية فقط،
// لذا المسافات داخل نص هي بيانات لا تنسيق
JSON.stringify(JSON.parse('{"note": "two spaces"}'));
// {"note":"two spaces"} - المسافتان الداخليتان محفوظتان
تستحق ثلاثة عمليات أن تُذكر لأنها تُصنَّف تحت "الضغط" لكنها فعلياً تعدّل الوثيقة: حذف المفاتيح التي قيمتها null، وتقريب الأعداد العشرية، وإعادة ترتيب المفاتيح. هذه تغيّر الوثيقة بدلاً من إعادة تنسيقها، لذا أي أداة تعلن "ضغطاً عميقاً" تحتاج موافقة مستهلكك قبل تمكينها.
ولأن minify يزيل المسافات فقط، فإن مردوده يعتمد تقريباً بالكامل على مقدار الإزاحة في ملفك. توقّع أن يظهر ذلك مراراً في الجداول أدناه.
3. القياس: أربع وثائق، ثلاثة مستويات gzip، إضافة إلى brotli
البيئة: Node v22.13.1، zlib.gzipSync عند المستوى 1 / 6 / 9، وbrotliCompressSync بجودة الافتراضي (11). تغطي العينات الأربع عمداً الأشكال التي تنشرها فعلاً: 50 طلباً متداخلاً (بنية عميقة، مفاتيح متكررة)، وإعداد من 6 مستويات (مفاتيح طويلة، قيم قصيرة)، ومصفوفة سجلات من 200 سطر (نصوص طويلة، إزاحة شبه معدومة)، وملف media-manifest.json الحقيقي من هذا الموقع (شكل مختلط، نصوص مليئة بالروابط).
الخطوة الأولى: البايتات الخام.
| العينة | pretty (مسافتان) | pretty (4 مسافات) | بعد minify | ما يوفره minify |
|---|---|---|---|---|
| 50 طلباً متداخلاً | 29،551 B | 40،761 B | 15،881 B | 46.3% |
| إعداد من 6 مستويات | 25،450 B | 38،012 B | 11،679 B | 54.1% |
| مصفوفة سجلات من 200 سطر | 14،582 B | 14،982 B | 13،981 B | 4.1% |
| media-manifest.json | 25,149 B | 28,114 B | 21,222 B | 15.6% |
من هنا تأتي شارة "وفرت 35%"، وهي لا تكذب. المشكلة تظهر في الخطوة الثانية.
الخطوة الثانية: اضغط كل نسخة، ثم اسأل كم يضيف minify بعد ذلك.
| العينة | فوق gzip -1 | فوق gzip -6 | فوق gzip -9 | فوق brotli |
|---|---|---|---|---|
| 50 طلباً متداخلاً | 21.0% | 17.0% | 8.9% | 10.6% |
| إعداد من 6 مستويات | 74.2% | 59.1% | 58.0% | 56.4% |
| مصفوفة سجلات من 200 سطر | -0.7% | -0.4% | 0.6% | -0.3% |
| media-manifest.json | 1.5% | 1.0% | 1.0% | 1.6% |
الوثيقة نفسها ذات الطلبات المتداخلة التي تفقد 46.3% من بايتاتها الخام تحتفظ بـ17.0% فقط من ذلك عندما يدخل gzip -6 في اللعبة. أما مصفوفة السجلات فتصبح سلبية صراحةً: minify ثم الضغط أكبر قليلاً من ضغط الأصل المنسّق. السالب ليس خللاً، بل هو بالضبط ما يجب أن تتوقعه، والقسم التالي يشرح السبب.
الخطوة الثالثة: التخفيض الكلي مقابل الوثيقة الأصلية المنسّقة — الرقم الذي يسافر فعلاً عبر الشبكة.
| العينة | gzip -6 فقط | minify + gzip -6 | minify + brotli |
|---|---|---|---|
| 50 طلباً متداخلاً | 94.5% | 95.4% | 96.9% |
| إعداد من 6 مستويات | 96.0% | 98.4% | 99.0% |
| مصفوفة سجلات من 200 سطر | 89.9% | 89.9% | 95.0% |
| media-manifest.json | 83.5% | 83.6% | 86.1% |
4. لماذا يأكل gzip رصيد minify
gzip هو LZ77 يتبعه ترميز Huffman. حركة LZ77 الأساسية هي "انظر إلى الخلف عبر نافذة 32 KB، وابحث عن تسلسل متكرر، واستبدله بمؤشر (مسافة، طول)". الإزاحة هي أسهل هدف ممكن لتلك الحركة: كل مستوى تداخل يُصدر سلسلة مسافات متطابقة، وعشرات الأسطر المتجاورة تكرر التركيبة نفسها. ثم يعيّن Huffman رموزاً قصيرة للرموز عالية التكرار، فنمط مثل \n مع ثماني مسافات — قد يتكرر ألف مرة في ملف واحد — ينتهي إلى تكلفة اثنين أو ثلاثة بتات.
لذا فالبايتات التي يحذفها minify هي بالضبط البايتات التي كان الضاغط سيطويها تقريباً مجاناً. القيام بذلك بنفسك يعني إعادة عمل يتفوّق فيه الضاغط أصلاً، و46% منك تنهار إلى 17%. تلك هي القصة كلها وراء الجدول أعلاه.
النتيجة السلبية لمصفوفة السجلات تستحق شرحاً خاصاً. تلك العينة نصوص طويلة تقريباً بالكامل (أسطر سجلات، روابط، base64)، أي تكرار قليل وإنتروبيا عالية — تكرار قليل يزيله الضاغط من الأصل. تصادف أن الأسطر الجديدة في النسخة المنسّقة تمنح المطابق حدوداً مستقرة. أزلها ويفقد LZ77 تلك المراسي: في هذه العينة، ضغط الأصل المنسّق عند gzip -1 يعطي 1،508 بايت، بينما minify ثم الضغط يعطي 1،518 بايت. عشرة بايتات أسوأ.
مرتبة حسب الحجم، تصطف الرافعات هكذا:
| الرافعة | ما توفره مقابل الوثيقة الأصلية المنسّقة (الطلبات المتداخلة) | التكلفة |
|---|---|---|
| minify | 46.3% (البايتات الخام فقط) | اختفاء قابلية القراءة؛ تحتاج خطوة بناء |
| gzip -6 | 94.5% | القليل من CPU الخادم؛ إعداد شبه معدوم |
| minify + gzip -6 | 95.4% | الاثنان أعلاه، مجموعان |
| minify + brotli | 96.9% | يحتاج HTTPS ووحدة brotli؛ ضغط مسبق للأصول الثابتة |
القفزة من 0 إلى 94.5% هي عمل gzip. والقفزة من 94.5% إلى 95.4% — تسعة أعشار نقطة — هي عمل minify. هذا الجدول يحسم أين ينتمي جهدك.
5. مستوى الضغط هو المتغير الخفي
أي حجة تفترض مستوى gzip ثابتاً واحداً تقف على رمل، لأن المستوى يغيّر مباشرة القيمة الحدية لـminify:
| إعداد النقل | البايتات الإضافية التي ما زال minify يوفرها (الطلبات المتداخلة) |
|---|---|
gzip -1 (الافتراضي لـgzip_comp_level في Nginx) |
21.0% |
| gzip -6 (الافتراضي في معظم المكتبات القياسية) | 17.0% |
| gzip -9 | 8.9% |
| brotli (الجودة الافتراضية) | 10.6% |
كلما كان ضغط النقل أضعف، زاد مردود minify. وهذا يعطيك قاعدة عملية: في تثبيت Nginx قياسي حيث gzip_comp_level ما زال 1، يمنحك minify 21%. وبمجرد أن تضبطه إلى 9 أو تنتقل إلى brotli، ينخفض إلى 9–11% ويمكنك عادةً تخطيه.
قبل أن تجادل في أي اتجاه، قِس أي فئة أنت عليها فعلاً:
# اجلب الاستجابة نفسها بثلاث قيم Accept-Encoding وقارن ما وصل
for enc in identity gzip br; do
size=$(curl -s -o /dev/null -w '%{size_download}' \
-H "Accept-Encoding: $enc" https://api.example.com/v1/orders)
echo "$enc -> $size bytes"
done
ثلاثة أرقام تجيب عن ثلاثة أسئلة في آن واحد: هل الضغط مفعّل أصلاً، وعلى أي مستوى، وكم مساحة تبقى لـminify. إذا عاد identity وgzip متطابقين تقريباً، فالضغط غير نشط إطلاقاً — والشيء الذي يجب إصلاحه هو Nginx، لا JSON الخاص بك:
gzip on;
gzip_comp_level 6;
gzip_min_length 256; # تجاوز الاستجابات الصغيرة، فهي تكبر فقط
gzip_types application/json application/javascript text/css;
6. عندما يستحق minify فعلاً
اختبار واحد يحسم الأمر: هل ستُسجَّل البايتات التي وفرتها في مكان ما، أم ستصطدم بحد يحسب البايتات الخام؟ إذا كانت الإجابة نعم، تتحقق الـ46% كاملة. وإذا كانت الإجابة "إنها موجودة على الشبكة فقط"، فقيمتها الحقيقية 17%.
أولاً، أجسام طلبات HTTP. معظم الخوادم لا تفكّ ضغط أجسام الطلبات، وgzip يُعدّ تقريباً حصراً للاستجابات. JSON الذي ترسله بـPOST عادةً لا حماية له في طبقة النقل إطلاقاً، لذا تصل وفورات minify بالسعر الكامل. الأمر نفسه ينطبق على ردود webhook، وتليمتري MQTT، وإطارات WebSocket التي لم تتفاوض على permessage-deflate.
ثانياً، القنوات ذات السقف الصلب بالبايت — وكل واحدة منها يحسب بايتات بعد فك التشفير:
| القناة | الحد | هل يساعد gzip؟ |
|---|---|---|
| الحمولة المتزامنة في AWS Lambda | 6 MB | لا، تُقاس بعد فك التشفير |
| وثيقة Firestore واحدة | 1 MiB | لا |
| رسالة Kafka واحدة (الافتراضي) | 1 MB | لا، ضغط جانب الوسيط طبقة مختلفة |
| حصة localStorage | ~5 MB (تُحسب بالمحارف) | لا |
| رابط طلب GET | يعتمد على الخادم، 8 KB شائع | لا |
في هذه الفئة، minify ليس تحسيناً بل الفرق بين طلب يُرسل وطلب يفشل. تضمين JSON في سلسلة استعلام URL هو الحالة الأحدّ: الروابط لا تُضغط أبداً ولها حد طول، لذا اضغط أولاً ثم قرر هل تحتاج base64 أصلاً.
ثالثاً، JSON المكتوب على القرص أو في السجلات. السجلات والفهارس تخزّن بايتات حرفية دون طبقة نقل. هناك تحفظ يُوقع الناس في الخطأ: minify يساعد فقط إذا كنت تكتب JSON منسّقاً أصلاً. عينة السجلات ذات 200 سطر وفّرت 4.1% فقط، لأن أسطرها كانت مضغوطة بالفعل والإزاحة شكلت تقريباً لا شيء من الملف. وبالمقابل، إذا كان المسجّل يستدعي JSON.stringify(obj, null, 2)، فحذف الوسيط الثالث مال مجاني.
ملاحظة أخيرة: عندما تكون البايتات الخام ضيقة حقاً، أسماء المفاتيح عادةً رافعة أكبر من الإزاحة. في media-manifest.json، استبدال مفاتيحه الـ14 الفريدة بـk0 حتى k13 ينقل الشكل المضغوط من 21،222 B إلى 18،887 B — أي 11.0% إضافية. وبعد الضغط، تتقلص تلك الأفضلية المتبقية إلى 2.4% تحت gzip -6 و1.3% تحت brotli. الآلية نفسها ككل ما سبق: الإزاحة وأسماء المفاتيح كلتاهما معلومات بنيوية متكررة، والضاغط يحصّل التوزيع نفسه في الحالتين.
7. أربع حالات يرتد فيها الأمر
لا تضغط ملفات تُلتزم بها. غيّر "qty": 2 إلى 3 وسيعرض الملف المنسّق سطراً نظيفاً واحداً في git diff؛ أما الملف المضغوط فيعيد كتابة السطر كله، ومع مئات الحقول لا يمكنك العثور على التغيير إطلاقاً. هذه هي الآلية نفسها المشروحة في كيفية حل تعارضات دمج JSON — أدوات diff تعمل سطراً بسطر، لذا JSON أحادي السطر يحوّل كل تعديل إلى "السطر كله مختلف". احتفظ بالملفات المنسّقة في المستودع واضغط فقط في مخرج البناء أو عند الكتابة إلى التخزين.
لا تضغط الاستجابات الصغيرة. استجابة تحتوي فقط على {"ok": true} تساوي 16 بايتاً بعد إضافة الإزاحة، و36 بايتاً بعد gzip -6 — أي أكبر بعشرين بايتاً، لأن قاموس gzip وتراويسه تشغل مساحة. هذا ليس نظرياً؛ إنه بالضبط سبب وجود gzip_min_length. قيمته الافتراضية 20 بايتاً في Nginx منخفضة جداً للإنتاج، حيث 256 هو الحد الأدنى المعتاد.
لا تتوقع تحليلاً أسرع. عند تكبير نفس مجموعة الطلبات إلى 4،000 سجل، تكون الوثيقة المنسّقة 2،374،378 بايتاً مقابل 1،282،358 مضغوطة — أصغر بـ46% — ومع ذلك يتحرك وسيط زمن JSON.parse من 5.7 ms إلى 5.0 ms فقط، أي أسرع بنحو 12%. خفض الحجم إلى النصف لا ينصف الوقت، لأن المحلل يصرف معظم ميزانيته على تقطيع النصوص وتحويل الأرقام؛ تخطي المسافات أمر عابر. ما يقود تكلفة التحليل فعلاً هو عدد المفاتيح ومحتوى النصوص، وهو ما يفصّله تحليل وعرض وتدفق ملفات JSON الكبيرة بالتفصيل.
لا تُرجعها إلى خدمة تعمل أصلاً بـbrotli أو gzip -9. ينخفض المردود إلى 9–11%، بينما تبقى التكلفة — فقدان دائم لقابلية القراءة أثناء التصحيح — كما هي. تحقق بدلاً من ذلك مما إذا كان gzip_types يغطي application/json أصلاً. كثير من المواقع يفعّل gzip ويضغط text/html فقط، تاركاً JSON غير مضغوط تماماً؛ في تلك الحالة minify ليس الخطوة الأولى، بل إصلاح الإعداد هو.
8. جدول القرار: أي خطوة أولاً
| موقفك | افعل هذا أولاً | أين ترتب minify |
|---|---|---|
| لا ضغط نقل إطلاقاً | اضبط gzip_types في Nginx أو فعّل الضغط التلقائي في CDN |
تخطّه؛ تسعة أعشار المكسب يتبخر |
| يعمل gzip -1 (افتراضي Nginx) | ارفع gzip_comp_level إلى 6 |
ثانياً، بقيمة 21% |
| على brotli أو gzip -9 بالفعل | راجع المفاتيح الطويلة، وأضف ترقيم صفحات أو قلّص الحقول | تخطّه؛ تبقى 9–11% فقط |
| تصطدم بحد 6 MB في Lambda أو 1 MiB في Firestore | اضغط مباشرة؛ تُحسب الـ46% كاملة | أولاً |
| أجسام POST، webhooks، MQTT | اضغط مباشرة؛ النقل نادراً ما ينطبق هنا | أولاً |
| ملفات إعداد ذاهبة إلى Git | أبقها منسّقة | في مخرج البناء فقط |
| سجلات، تليمتري، نسخ دون اتصال | احذف وسيط null, 2 في المسجّل |
تلك هي الخطوة الأولى |
القاعدة بجملة واحدة: انظر إلى أي دفتر بايتات تحاول تصغير — دفتر مسجّل محلياً (قرص، حصة، حد منصة، جسم طلب) أو دفتر موجود على الشبكة فقط. المحلي أولاً يعني minify؛ الشبكة فقط تعني تفعيل brotli أو gzip أولاً.
عملياً، شغّل حلقة curl أعلاه لتأكيد أن ضغط النقل نشط، ثم مرّر عينة عبر أداة JSON Minify واقرأ رقم وفوراتها. إذا كان ذلك الرقم فوق 40% بينما يبلغ curl عن identity وgzip بحجم متقارب، فعنق الزجاجة إعداد الخادم لا JSON. وإذا كان رقمَا curl متقاربين أصلاً، فلم يبق لهذه الوثيقة ما تقدّمه ويجب أن تقلّص الحقول أو ترقّم الصفحات بدلاً من ذلك — وهو موضوع أساسيات تصميم عقد JSON API.
هل يبقى JSON المضغوط صالحاً؟
نعم. minify يزيل فقط المسافات التي يسمح بها RFC 8259 حول المحارف البنيوية ولا يلمس محتويات النصوص أبداً، لذا ينتج JSON.parse قيمة مطابقة للأصل. الضحية الوحيدة هي قابلية القراءة البشرية.
brotli أم gzip؟
على العينة المضغوطة نفسها، يوفر brotli 96.9% ويوفر gzip -6 ما نسبته 95.4% — فجوة ضيقة. استخدم gzip -6 للاستجابات الديناميكية للحفاظ على انخفاض CPU، واحتفظ بـbrotli للأصول الثابتة المسلّمة عبر HTTPS التي يمكنك ضغطها مسبقاً إلى ملفات .br.
ضغطت JSON لكن API لم يصبح أسرع. لماذا؟
تحقق من الاستجابة بـcurl -sI بحثاً عن ترويسة Content-Encoding: gzip. كثير من إعدادات Nginx تجعل gzip_types افتراضياً text/html وحده، لذلك لم يُغطَّ JSON أبداً — وأقل من خُمس الـ46% وصل إلى الشبكة.
هل تُلتزم ملفات الإعداد مضغوطة؟
لا. أدوات diff تقارن سطراً بسطر، لذا JSON أحادي السطر يحوّل كل تعديل إلى "السطر كله تغيّر"، وحل التعارضات يكلف أكثر بكثير من البايتات التي وفرتها. احتفظ بالنسخ المنسّقة في المستودع واضغط في وقت البناء.
كم يجب أن يكون حجم الاستجابة قبل أن يستحق gzip؟
العتبة المعتادة من 256 بايتاً إلى 1 KB. استجابة صغيرة مثل {"ok": true} — 16 بايتاً مع الإزاحة — تنمو إلى 36 بايتاً تحت gzip -6، لأن القاموس والتراويس تكلف أكثر مما تستعيد. هذا بالضبط ما يتحكم فيه gzip_min_length.