Skip to content
العودة إلى المدوّنة
دروس تعليمية

دليل UTF-8 vs UTF-16 vs Unicode الكامل للمطورين

دليل المطور إلى UTF-8 وUTF-16 وUTF-32 — نقاط الترميز وأزواج الإحلال والـ BOM وفخاخ utf8mb4 في MySQL وأكاذيب length في JS. تعرف على كيفية اختيار الترميز الصحيح.

12 دقائق للقراءة

دليل UTF-8 vs UTF-16 vs Unicode الكامل للمطورين

الإجابة المختصرة لمعظم استعلامات utf-8 unicode encoding: Unicode وUTF-8 ليسا الشيء نفسه. Unicode جدول ضخم مرقّم يُسنِد نقطة ترميز (codepoint، رقم مثل U+1F600) لكل حرف. وUTF-8 وUTF-16 وUTF-32 ترميزات (encodings) — ثلاث طرق لتحويل تلك النقاط إلى بايتات. UTF-8 هو الخيار الافتراضي عملياً: متطابق على مستوى البايت مع ASCII للنص الإنجليزي، يتوسّع إلى أربعة بايتات للـ emoji، ومفروض بحكم المعيار في JSON وHTML5 ومعظم البروتوكولات الحديثة.

هذا الدليل موجَّه للمطوّر الذي اكتوى بنار الترميز: خطأ Incorrect string value في MySQL عند تخزين 😀، ومفاجأة JavaScript أنّ "😀".length === 2، وملف CSV يُفتح سليماً مع cat ويظهر مشوّهاً في Excel. سننتقل من نقاط الترميز إلى ميكانيكا بايتات UTF-8، ثم أزواج الإحلال (surrogate pairs)، فعلامات ترتيب البايت (BOM)، فالسلوك الافتراضي لتسع لغات برمجة، وثمانية فخاخ من بيئة الإنتاج — وننهي بمصفوفة قرار وأسئلة شائعة.

تريد التحقق من تسلسل بايتات أثناء القراءة؟ الصق أي نص في مرمّز وفاكّ ترميز Base64 — البيانات المفكوكة هي مجرى بايتات UTF-8 الذي يشرحه هذا المقال.

لماذا لا يزال الترميز يعضّك في 2026

ثلاثة سيناريوهات من تذاكر أخطاء حقيقية خلال الاثني عشر شهراً الماضية:

  1. MySQL يرفض emoji. يُدخل المستخدم Hello 😀 فيُرجِع الخادم Incorrect string value: '\xF0\x9F\x98\x80'. الجدول مُعرَّف بترميز utf8، فيسأل المطوّر: “هذا UTF-8، ما المشكلة؟” — والإجابة مدفونة في تاريخ MySQL (نتناولها في القسم 7).
  2. عدّاد أحرف يُشحن مكسوراً. يستخدم مدقِّق تغريدة من 280 حرفاً text.length، فيقبل رسالة مليئة بالـ emoji، ثم ترفضها واجهة الـ API. والعكس يحدث: منشور صحيح ترفضه الواجهة الأمامية. تشخيص هذا العَرَض في القسم 4.
  3. صفحة HTML محلّية تتحوّل إلى “中文”. يحفظ المطوّر ملفاً بترميز Windows-1252 ثم يفتحه في متصفّح يخمّن أنّه UTF-8، فينفجر الـ Mojibake. قصة الـ BOM وإعلان charset في القسم 5، وهي توازي دليل ترميز وفكّ ترميز URL حيث يُدمّر التضارب نفسه بين البايتات والأحرف سلاسل الاستعلام.

عند آخر صفحة ستتمكّن من (أ) التمييز بين Unicode وUTF-8 في جملة واحدة، (ب) الاختيار بين UTF-8 وUTF-16 وUTF-32 لأي مشروع جديد، (ج) كتابة كود يَعدُّ الـ emoji صحيحاً في كل لغة رئيسية، (د) تشخيص أي خطأ charset من مجرى البايتات وحده. جحر أرنب ترميز الأحرف عميق، وسطحه العملي صغير.

ما هو Unicode؟ نقاط الترميز مقابل الأحرف مقابل الرموز المرسومة

Unicode جدول أحرف يُسنِد رقماً فريداً — نقطة ترميز (codepoint)، مثل U+1F600 — لكل حرف. UTF-8 وUTF-16 وUTF-32 ترميزات تترجم هذه النقاط إلى بايتات. Unicode ذاته لا يخزّن بايتات؛ إنّما يحدّد التطابق بين الحرف المجرّد وعدد صحيح.

ثلاثة مصطلحات أخرى تُعكِّر الحديث لأنّها تشير غالباً إلى العلامة المرئية نفسها:

ثلاث طبقات يجب الفصل بينها

  • نقطة الترميز (codepoint) (U+0041، U+1F600): العدد الصحيح الذي يسنده Unicode. المساحة تمتدّ من U+0000 إلى U+10FFFF، أي نحو 1.1 مليون خانة، خُصِّص منها حتى الآن حوالي 150,000.
  • الحرف (أو الحرف المجرّد): الهوية الدلالية — Latin capital A، grinning face emoji.
  • الرسم (Glyph): الشكل البصري الذي يُصيّره الخط. للحرف الواحد رسوم كثيرة: A بخط ذي حواف، A مائل، A مرسوم باليد. Unicode لا يُعنى بالرسوم.
  • عنقود مَحارف الرَّسم (Grapheme cluster): ما يدركه المستخدم “حرفاً” واحداً. يكون أحياناً نقطة ترميز واحدة، وأحياناً عدّة. الحرف á قد يكون نقطة ترميز واحدة U+00E1 أو نقطتين a + U+0301 (علامة الحدّة المركَّبة) — يستكشف دليل حدود الأحرف بحسب المنصّة كيف ترسم Twitter وSMS وSEO هذا الخط بصور مختلفة.

إن لم تتذكّر سوى شيء واحد، فلتتذكّر: نقطة ترميز → ترميز → بايتات → تصيير. وكلّ سهم منها قد ينكسر مستقلاً.

تدوين نقاط الترميز — U+XXXX و\uXXXX

ستظهر نقاط الترميز بصور عدّة. U+0041 هو التدوين القانوني لـ Unicode: من أربعة إلى ستة أرقام ست عشرية مسبوقة بـ U+. في الكود المصدري:

  • JavaScript / JSON: "A" (أربعة أرقام ست عشرية، BMP فقط) و"\u{1F600}" (أقواس ES6، أي نقطة ترميز).
  • Python: "A" (4 أرقام)، "\U00000041" (8 أرقام، U كبيرة)، "\N{LATIN CAPITAL LETTER A}" (بالاسم).
  • مخرجات Shell / git log / sed: تظهر فيها بايتات UTF-8 خاماً مثل \xc3\xa9 للحرف é — ليست نقطة ترميز، بل الصورة المرمَّزة، وهي ما ينقلنا إلى القسم 3.

المستويات السبعة عشر — BMP وما بعدها

يقسّم Unicode مساحة نقاط الترميز إلى 17 مستوى (plane)، كلٌّ منها 65,536 نقطة — 17 × 2^16 = 1,114,112.

  • المستوى 0، المستوى الأساسي متعدّد اللغات (Basic Multilingual Plane، BMP): U+0000 إلى U+FFFF. اللاتينية والأيديوغرامات الصينية اليابانية الكورية والسيريلية والعربية واليونانية — معظم الكتابات التي تصادفها في النصوص القديمة تعيش هنا.
  • المستويات 1-16، المستويات التكميلية (supplementary planes): U+10000 إلى U+10FFFF. معظم الـ emoji (U+1F600 ورفاقها)، أحرف CJK النادرة، الكتابات التاريخية (الهيروغليفية المصرية، المسماريّة)، التدوين الموسيقي.

حدّ BMP / المستويات التكميلية عند U+FFFF هو أهم رقم في هذا المقال. عنده يتوقّف UTF-16 عن استخدام وحدة ترميز واحدة لكلّ حرف، وعنده يقفز UTF-8 من ثلاثة بايتات إلى أربعة، وعنده يستسلم تجميع MySQL المسمَّى خطأً utf8.

تحقّق سريع من السلامة بالـ emoji

"a"        → 1 codepoint  U+0061             → 1 grapheme
"é" (NFC)  → 1 codepoint  U+00E9             → 1 grapheme
"é" (NFD)  → 2 codepoints U+0065 U+0301      → 1 grapheme
"😀"        → 1 codepoint  U+1F600 (Plane 1)  → 1 grapheme
"👨‍👩‍👧"      → 5 codepoints (3 people + 2 ZWJ U+200D) → 1 grapheme

الصف الأخير هو المفاجأة. emoji العائلة حرف واحد بحسب إدراك المستخدم، وخمس نقاط ترميز موصولة بأربطة Zero-Width Joiners. كلّ طبقة في المكدّس قد تعدّه على نحو مختلف، والفخ السادس في القسم 7 هو تقرير الخطأ الذي يفتحه هذا الخلاف.

ميكانيكا ترميز UTF-8 — كيف تعمل البايتات الأربعة

UTF-8 يرمّز نقاط Unicode في 1 إلى 4 بايتات. ASCII (U+0000U+007F) يستخدم بايتاً واحداً وهو متطابق على مستوى البايت مع ASCII. النقاط الأعلى تستخدم تسلسلات متعدّدة البايتات: البايت الأوّل يُشير إلى الطول الكلّي، وكل بايت متابعة يبدأ بالنمط 10xxxxxx. هذا التخطيط الواصف لذاته هو سبب فوز UTF-8 في حروب الترميز.

جدول أنماط البايت — UTF-8 في مخطّط واحد

نطاق نقطة الترميزبايتات UTF-8نمط البايت
U+0000U+007F1 بايت0xxxxxxx
U+0080U+07FF2 بايت110xxxxx 10xxxxxx
U+0800U+FFFF3 بايت1110xxxx 10xxxxxx 10xxxxxx
U+10000U+10FFFF4 بايت11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

كلّ x بِتّ بيانات مستخرَج من التمثيل الثنائي لنقطة الترميز. البادئات 0 / 110 / 1110 / 11110 تخبر فاكّ الترميز بعدد البايتات الكلّي؛ والبادئة 10 تُعلِّم كلّ بايت متابعة. هذا التكرار يجعل UTF-8 ذاتي المزامنة — إذا فَقَدت بايتاً أمكنك الاستئناف عند بايت البداية التالي بدلاً من إفساد كل ما بعده.

مثال محلول — ترميز (U+4E2D)

نقطة الترميز 0x4E2D تقع في U+0800U+FFFF، لذلك نستخدم قالب الـ 3 بايتات.

  1. ثنائي: 0x4E2D = 0100 1110 0010 1101 (16 بِتّاً).
  2. اقسمها 4-6-6 لتناسب خانات x: 0100 / 111000 / 101101.
  3. عوّض في 1110xxxx 10xxxxxx 10xxxxxx: 11100100 10111000 10101101.
  4. ست عشري: 0xE4 0xB8 0xAD.

ولهذا يصبح هو %E4%B8%AD بعد ترميز URL: ترميز النسبة المئوية يلفّ كلّ بايت UTF-8 داخل %XX، ولا يرمّز نقطة الترميز مباشرة. الفخ الثالث في القسم 7 يُفصِّل السلسلة كاملة.

مثال محلول — ترميز 😀 (U+1F600)

نقطة الترميز 0x1F600 تتجاوز BMP، لذلك نستخدم قالب الـ 4 بايتات.

  1. ثنائي: 0x1F600 = 0 0001 1111 0110 0000 0000 (21 بِتّاً مع الحشو).
  2. اقسمها 3-6-6-6: 000 / 011111 / 011000 / 000000.
  3. عوّض في 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx: 11110000 10011111 10011000 10000000.
  4. ست عشري: 0xF0 0x9F 0x98 0x80.

هذه البايتات الأربع هي ما يختنق عليه تجميع MySQL المسمَّى utf8 — فهو يخصّص ثلاثة بايتات بحدّ أقصى لكلّ حرف. الفخ الأوّل في القسم 7 يقدّم الحلّ.

لماذا فاز UTF-8

  1. توافق ASCII. ملف من نصّ ASCII خالص متطابق على مستوى البايت مع ترميزه UTF-8. عقود من الأدوات التي تسبق Unicodegrep وawk وأنابيب الـ shell الكلاسيكية — تستمرّ في العمل على هذه المجموعة الفرعية.
  2. ذاتي المزامنة. بايتات المتابعة تبدأ بـ 10، ولا تتعارض مع أي بايت بداية. افقد بايتاً في نقل عبر الشبكة وستُعيد المزامنة عند حدّ الحرف التالي بدلاً من تتالي القمامة.
  3. بلا ترتيب بايت. UTF-8 مجرى من البايتات، لا وحدات من 16 أو 32 بِتّاً، فلا اعتبار لـ endianness. يحتاج UTF-16 وUTF-32 إلى علامة ترتيب بايت (Byte Order Mark) للإعلان أيّ طرف يأتي أوّلاً؛ أمّا UTF-8 فلا (وعادةً ينبغي ألّا يفعل — انظر القسم 5).

UTF-8 غير صالح — ما يمنعه المعيار

سيرفض فاكُّ ترميز صارم هذه التسلسلات:

  • تسلسلات من 5 أو 6 بايتات. سمحت بها وثائق RFC القديمة؛ ثبّت RFC 3629 (2003) UTF-8 عند 4 بايتات بحدّ أقصى ليطابق مساحة Unicode ذات الـ 21 بِتّاً.
  • الترميزات المُطوَّلة (Overlong). ترميز / بثلاثة بايتات 0xE0 0x80 0xAF بدلاً من بايت واحد 0x2F. مصدر خصب لاستغلال اجتياز المجلّدات في مدقّقات المسار التي تفكّ الترميز بعد التطهير.
  • نقاط ترميز الإحلال المنفردة (U+D800U+DFFF). محجوزة لـ UTF-16 ولا ينبغي أن تظهر في UTF-8.
  • تسلسلات مبتورة. بايت بداية من 3 بايتات يتبعه بايت متابعة واحد فقط — شائع حين يُقَصُّ إدخال المستخدم عند حدّ بايت في منتصف حرف متعدّد البايتات.

لرؤية أيّ من هذا بشكل ملموس، الصق نصّاً في مرمّز وفاكّ ترميز Base64 ورمّزه ثم فكّ ترميزه بايتات — مصفوفة البايتات بين المرمّز وفاكّ الترميز هي مجرى UTF-8 الذي يصفه هذا القسم.

UTF-16 وأزواج الإحلال — لماذا يكذب length في JavaScript

الاستعلام الأشيع حول utf-8 vs utf-16 هو في الحقيقة: “لماذا "😀".length يساوي 2 في كودي؟” الإجابة هي أزواج الإحلال (surrogate pairs)، وهو قرار من تسعينيات القرن الماضي ورِثته JavaScript وJava وC# وWindows.

UTF-16 في فقرة واحدة

يمثِّل UTF-16 Unicode باستخدام وحدات ترميز (code units) من 16 بِتّاً. الأحرف في BMP (U+0000U+FFFF) تأخذ وحدة ترميز واحدة. والأحرف في المستويات التكميلية (U+10000U+10FFFF) تأخذ وحدتي ترميز تُسمَّيان زوج الإحلال (surrogate pair): إحلال علوي (high surrogate) في U+D800U+DBFF يتبعه إحلال سفلي (low surrogate) في U+DC00U+DFFF. كتلة U+D800U+DFFF محجوزة في Unicode فلا يسكنها حرف حقيقي. UTF-16 هو صيغة السلسلة الداخلية في JavaScript وJava وC# (.NET) وواجهات نواة Windows وObjective-C NSString وQt — صُمِّمت جميعاً حين كانت 65,536 حرفاً تبدو كثيرة.

فخ String.length

"a".length          // 1   — BMP, single code unit
"é".length          // 1   — BMP (U+00E9), single code unit
"中".length         // 1   — BMP (U+4E2D), single code unit
"😀".length         // 2   — supplementary plane (U+1F600), surrogate pair!
"a😀".length        // 3   — one BMP + two surrogate units

String.prototype.length يبلِّغ بعدد وحدات ترميز UTF-16، لا بعدد الأحرف. أيّ شيء من المستوى التكميلي يُقرأ 2. الفخّ ذاته في String.length() في Java وstring.Length في C#.

عَدّ نقاط الترميز بشكل صحيح في JS

[..."😀"].length              // 1 — spread iterator walks codepoints
Array.from("😀").length       // 1 — Array.from also walks codepoints
"😀".match(/./gu).length      // 1 — /u flag = unicode-aware regex

// "😀".charAt(0) returns the lone high surrogate (visually broken)
"😀".codePointAt(0)           // 128512 — the full codepoint U+1F600

عاملا الانتشار (spread) وArray.from يستخدمان بروتوكول المُكرِّر (iterator)، الذي يعرّفه معيار اللغة بالسير على نقاط الترميز. الوصول بالفهرس العادي (str[0]، charAt) لا يزال يُرجع وحدات ترميز وسيُسلّمك نصف زوج إحلال عند الـ emoji.

Python — len() يفعل الصواب أصلاً (تقريباً)

len("😀")           # 1   — Python 3 strings are codepoint-indexed
len("👨‍👩‍👧")        # 5   — codepoints (3 humans + 2 ZWJ), not graphemes
# Python 2 was byte-indexed by default — len("😀") returned 4

تخزّن Python 3 السلاسل بتمثيل مرن من 1 أو 2 أو 4 بايت (PEP 393) وتفهرس بنقطة الترميز. len("😀") يساوي 1، وليس عَدّ عناقيد المَحارف — emoji العائلة لا يزال يُقرأ 5. لعَدّ الأحرف بحسب إدراك المستخدم تحتاج إلى مكتبة grapheme: Intl.Segmenter في JavaScript (Node 22+، وكل المتصفّحات الحديثة)، أو grapheme أو regex في Python، أو Swift، إذ String.count فيه هو اللغة الرائجة الوحيدة التي تَعدّ افتراضياً بعناقيد المَحارف.

UTF-16 مقابل UCS-2 — الانتقال الصامت

قبل عام 1996، وعد Unicode بأن يتّسع في 16 بِتّاً وكان الترميز المقابل هو UCS-2 — تطابق ثابت من 2 بايت. كسر Unicode 2.0 ذلك الوعد بإضافة المستويات التكميلية. UTF-16 هو الإصدار المُرقَّع باستخدام أزواج الإحلال. مواصفة JavaScript تستشهد بمفردات UCS-2 القديمة في مواضع، ولهذا تتسامح اللغة مع الإحلالات المنفردة التي ينبغي أن تكون غير قانونية — نِكات “WTF-16” حقيقية. واجهات منصّة الويب (DOM، fetch، TextEncoder) ترفض الإحلالات المنفردة لأنّها لا يمكن ترميزها إلى UTF-8 صالح.

UTF-32 وBOM ومسألة ترتيب البايتات

UTF-32 — البسيط المُسرِف

يستخدم UTF-32 4 بايتات ثابتة لكلّ نقطة ترميز. تُخزَّن U+0041 على هيئة 0x00000041 وU+1F600 على هيئة 0x0001F600. الميزة وصول عشوائي بزمن ثابت: نقطة الترميز رقم n تقع عند إزاحة البايت 4n. والعيب هو الحجم — نصّ ASCII خالص ينتفخ إلى أربعة أضعاف بصمته في UTF-8، وحتى نصّ CJK يتضاعف. قلّما يخزّن نظام UTF-32 على القرص. داخلياً، تختار Python 3 1 أو 2 أو 4 بايت لكل سلسلة بناءً على أعلى نقطة ترميز؛ ويستخدم مكدّس fontconfig في Linux UTF-32 لجداول الرسوم في الذاكرة.

ترتيب البايت — لماذا تهمّ endianness في UTF-16 / UTF-32

UTF-8 مجرى من بايتات منفردة، فلا يسري عليه endianness. أمّا UTF-16 وUTF-32 فيعملان بوحدات متعدّدة البايتات، وتختلف وحدات المعالجة المركزيّة في أيّ طرف من الرقم يأتي أوّلاً.

U+0041 ('A') in UTF-16 BE → 00 41
U+0041 ('A') in UTF-16 LE → 41 00

معالجا x86 وARM ذوا ترتيب little-endian؛ والـ PowerPC القديم و”ترتيب بايت الشبكة” big-endian. حين تكتب ملفّ UTF-16 عليك أن تلتزم بأحدهما وأن تخبر القارئ بذلك، وهذه وظيفة الـ BOM.

الـ BOM — ماهيتها ومتى تستخدمها

علامة ترتيب البايت (Byte Order Mark) هي U+FEFF تُوضع في بداية الملف. بعد ترميزها تعلن كلاً من الترميز و(في UTF-16 / UTF-32) ترتيب البايت.

الترميزبايتات BOM
UTF-8EF BB BF
UTF-16 BEFE FF
UTF-16 LEFF FE
UTF-32 BE00 00 FE FF
UTF-32 LEFF FE 00 00

utf-8 BOM موجود ولا يحمل معلومات ترتيب بايت لأنّ UTF-8 بلا ترتيب بايت. وظيفته الوحيدة الإعلان “هذا الملف UTF-8” — مفيد للأدوات التي ليس لديها إشارة أخرى، ضارّ للأدوات التي تتوقّع أن يبدأ الملف برقم سحري أو توجيه.

مصفوفة قرار BOM — هل أُضيفها؟

الصيغةUTF-8 BOMUTF-16 BOMUTF-32 BOM
HTMLلا (يُعطِب اكتشاف <!doctype> في المحلّلات القديمة)
JSONلا (RFC 8259 يمنعها)
مصدر JavaScript / CSSتجنّبها (Node القديم وIE يختنقان)
CSV مفتوح في Excelنعم (Excel يقرأ UTF-8 بلا BOM على أنّه ANSI ويُشوّه CJK)
XMLاختياري (إعلان XML يذكر الترميز أصلاً)مطلوبمطلوب
نصّ عادي .txtاختياري (Windows Notepad يضيفها افتراضياً)مطلوبمطلوب

القاعدة المختصرة: أسقط الـ UTF-8 BOM من أي شيء يُقدَّم على الويب؛ أضِفها إلى ملفات CSV التي تريد فتحها في Excel؛ ودَع القارئ يقرّر فيما عدا ذلك.

9 لغات جنباً إلى جنب — السلوك الافتراضي للترميز

العمل عبر اللغات هو حيث تظهر فائدة هذه المعرفة. السلسلة نفسها "a😀é" تنتج طولاً مختلفاً في كل بيئة تشغيل تستدعيها من سكربت Bash الخاص بك.

جدول السلوك عبر اللغات

اللغةترميز الملف المصدريتخزين السلسلةماذا يَعدّ length / lenترميز I/O الافتراضيآمن للـ emoji الرباعي البايت؟
JavaScript (V8 / SpiderMonkey)UTF-8UTF-16وحدات ترميز UTF-16UTF-8 (Node, Web)نعم، لكنّ .length === 2
Python 3UTF-8 (PEP 3120)ديناميكي 1 / 2 / 4 بايت (PEP 393)نقاط ترميزUTF-8 (PEP 540 since 3.7)نعم، len === 1
JavaUTF-8 (javac default)UTF-16وحدات ترميز UTF-16charset المنصّة → UTF-8 (JEP 400, JDK 18+)نعم، لكنّ .length() === 2
GoUTF-8بايتات UTF-8بايتات (utf8.RuneCountInString لنقاط الترميز)UTF-8نعم، len(s) تُرجِع بايتات
RustUTF-8بايتات UTF-8 (String ثابتة).len() بايتات، .chars().count() نقاط ترميزUTF-8نعم، صريح
C# (.NET)UTF-8 (default since .NET Core 3.0)UTF-16وحدات ترميز UTF-16UTF-8 (Encoding.Default since .NET 5)نعم، لكنّ .Length === 2
RubyUTF-8 (since 2.0)وسم ترميز لكل سلسلةنقاط ترميز (.length)UTF-8نعم، length === 1
PHP(لا ترميز مصدري)سلسلة بايتاتبايتات (strlenmb_strlen لنقاط الترميزيعتمد على default_charsetنعم، مع عائلة mb_*
MySQLcharset العمودبايتات (LENGTH)، أحرف (CHAR_LENGTH)متغيّرات النظام character_set_*فقط مع utf8mb4

ماذا يخبرك الجدول فعلاً

ثلاث فلسفات، ثلاث مجموعات من الأخطاء:

  • UTF-8 داخلياً (Go، Rust، Ruby). السلسلة الأصليّة بايتات؛ length معرَّف جيّداً ويَعدّ ما يَعدّ. حوِّل إلى نقاط ترميز أو عناقيد مَحارف فقط حين تعبر حدّ واجهة مستخدم أو تحقّق.
  • UTF-16 داخلياً (JavaScript، Java، C#). موروثة من افتراضات التسعينيات؛ length وحدات ترميز، وزوج الإحلال يُحسب 2. استخدم تكراراً واعياً لنقاط الترميز لأيّ عَدّ يواجه المستخدم.
  • مفهرسة بنقاط الترميز (Python 3). len يعطي نقاط ترميز، ويبدو ذلك صحيحاً حتى تصادف emoji الـ ZWJ — عندئذٍ تحتاج إلى مكتبة عناقيد مَحارف.

PHP حالة خاصة. دوالّ str* المضمَّنة تعمل على بايتات، وتعامل تسلسلات UTF-8 كقطع معتمة. أي مشروع غير ASCII يجب أن يستخدم عائلة mb_* (متعدّدة البايت)، وتقارير الأخطاء سنةً بعد سنة تُظهر كم يُغفَل ذلك.

التوجيه العملي: احتفظ بـ UTF-8 كصيغة الأسلاك في كل مكان — الملفات، أجسام HTTP، أعمدة قواعد البيانات — وحوِّل إلى نوع السلسلة الأصلي لبيئة تشغيلك عند الحدّ. هذه “شطيرة UTF-8” التي نعود إليها في القسم 8.

8 فخاخ هندسية من العالم الحقيقي

الأنماط أدناه تظهر في مراجعات الكود لأي قاعدة كود معولَمة.

الفخ 1: utf8 في MySQL كذبة من 3 بايتات — بدِّل إلى utf8mb4

العَرَض. INSERT INTO users (bio) VALUES ('Hello 😀'); يُرجع Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio'.

السبب الجذري. utf8 التاريخي في MySQL هو اسم بديل لـ utf8mb3: نسخة من UTF-8 مقيَّدة بثلاثة بايتات لكل حرف. أيّ نقطة ترميز فوق U+FFFF (كل emoji، آلاف من أحرف CJK النادرة، كل الكتابات التاريخية) تتطلّب أربعة بايتات UTF-8 فتُرفَض.

الحلّ.

ALTER DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4;  -- client connection
# my.cnf
[mysqld]
character-set-server = utf8mb4
collation-server     = utf8mb4_unicode_ci

MySQL 8.0 لا تزال تشحن utf8 بوصفه اسماً بديلاً لـ utf8mb3. الـ utf8mb3 مهجور ولم يُحذف بعد. استخدم utf8mb4 لكل عمود جديد، وكل قاعدة بيانات جديدة، وكل اتصال جديد — لا فائدة من النسخة القديمة.

الفخ 2: ارتداد Windows-1252 — لغز علامة الاستفهام

العَرَض. ملف .txt مُصدَّر من Notepad زميل يعمل على Windows يُقرأ "smart quotes" وشَرطة em على جهازه. على خادمك يصبح ? أو U+FFFD (حرف الاستبدال).

السبب الجذري. Notepad الأقدم يستخدم افتراضياً Windows-1252 (CP-1252)، الذي يرمّز علامة الاقتباس المنحنية " كـ 0x93. فاكُّ ترميز UTF-8 يرى 0x93 بايتَ متابعة شارداً (البِتّ العالي 10) دون بايت بداية يسبقه فيستبدله بحرف الاستبدال.

الحلّ. اكتشف ترميز المصدر (file على Unix، chardet / charset-normalizer في Python، jschardet في Node)، فكّ الترميز بالمُرمِّز الصحيح، ثم أعِد الترميز إلى UTF-8 قبل الحفظ. توحيد UTF-8 عند الاستلام يقضي على تكرار هذا الخطأ.

الفخ 3: ترميز URL بالنسبة المئوية ≠ UTF-8 (لكنّه يبنى عليه)

العَرَض. fetch("/search?q=中文") يُرجع 404 من واجهة خلفية ويعمل مع أخرى.

السبب الجذري. ترميز النسبة المئوية يعمل على بايتات لا على نقاط ترميز. نقطة ترميز واحدة وثلاثة بايتات UTF-8 (E4 B8 AD)، كلّ بايت يُرمَّز منفرداً بنسبة مئوية إلى %E4%B8%AD — تسعة أحرف ASCII في الـ URL. إطار العمل الذي يفكّ ترميز الـ URL كـ Latin-1 بدلاً من UTF-8 سيمنح المعالج البايتات الثلاثة مشوّهةً مفسَّرةً كثلاثة أحرف أحادية البايت.

الحلّ. استخدم encodeURIComponent("中文") على العميل (المتصفّحات تقوم بـ UTF-8 + ترميز النسبة المئوية في خطوة واحدة) وتأكّد من أنّ إطار عمل الخادم يفكّ ترميز عناوين URL بـ UTF-8 (الأُطُر الحديثة تتبنّاها افتراضياً). للتأكيد البصري، الصق 中文 في مرمّز وفاكّ ترميز URL وراقبه يصبح %E4%B8%AD%E6%96%87. السلسلة الكاملة مغطّاة في دليل ترميز وفكّ ترميز URL.

الفخ 4: مُدخَل Base64 بايتات، لكنّك كتبت سلسلة

العَرَض. btoa("你好") يَطرَح InvalidCharacterError: The string contains characters outside the Latin1 range.

السبب الجذري. صُمِّم btoa في عصر ASCII / Latin-1. يتوقّع أن يتّسع كل حرف إدخال في بايت واحد (نقاط ترميز 0-255). 你好 في محرّك JS نقطتا ترميز UTF-16 U+4F60 U+597D، وكلتاهما أعلى بكثير من 255.

الحلّ. رمِّز إلى بايتات UTF-8 أوّلاً، ثم رمِّز تلك البايتات بـ Base64.

// Wrong:
btoa("你好");  // throws

// Correct:
const bytes = new TextEncoder().encode("你好");
// Uint8Array(6) [228, 189, 160, 229, 165, 189]
const b64 = btoa(String.fromCharCode(...bytes));
// "5L2g5aW9"

القصّة الأطول في ما هو ترميز Base64؟ دليل المبتدئين وBase64 المتقدم؛ ومرمّز وفاكّ ترميز Base64 يجري التحويل في خطوة واحدة ويُظهر مجرى البايتات الوسيط.

الفخ 5: String.length للتحقّق (حدود Twitter / SMS)

العَرَض. محرّر من 280 حرفاً يتحقّق على جانب العميل، ثم تُرجع الـ API 422. أو العكس — منشور جيّد تماماً يرفضه العميل.

السبب الجذري. .length في JavaScript يَعدّ وحدات ترميز UTF-16؛ emoji واحد يُحسب 2. Twitter تَعدّ نقاط ترميز (emoji = 1). عدد الأحرف يخطئ في اتجاهين متعاكسين تبعاً لأي API تثق به.

الحلّ. استخدم [...text].length لعَدّ نقاط الترميز، أو Intl.Segmenter لعَدّ حقيقي لعناقيد المَحارف (نهج Bluesky / iMessage). أرقام منصّة بمنصّة وحدود SMS GSM-7 مقابل UCS-2 مفهرسة في دليل حدود الأحرف بحسب المنصّة.

الفخ 6: عائلات emoji بالـ ZWJ تُحسب N نقاط ترميز، 1 عنقود مَحرف

العَرَض. "👨‍👩‍👧".length === 8. عَدّ نقاط الترميز يعطي 5. وللمستخدم هي صورة واحدة.

السبب الجذري. الرابط ذو العرض الصفري (Zero-Width Joiner، U+200D) يلصق نقاط emoji متعدّدة في عنقود مُصيَّر واحد — ثلاث emoji أشخاص زائد ZWJين تساوي خمس نقاط ترميز، وثماني وحدات ترميز UTF-16، وعنقود مَحرف واحد.

الحلّ.

const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment("👨‍👩‍👧")].length;  // 1

Intl.Segmenter متاح في Node 22+ وكل متصفّح حديث. لبيئات التشغيل الأقدم، حزمة grapheme-splitter تنفّذ UAX #29.

الفخ 7: هروب JSON \uXXXX — نقاط الترميز فوق U+FFFF تحتاج زوج إحلال

العَرَض. حِمل JSON يحتوي "😀" وفاكّ الترميز المستقبِل إمّا يُصيِّره صحيحاً كـ 😀 أو يُظهر مربّعَين، تبعاً لفهمه أزواج الإحلال في JSON.

السبب الجذري. هروب \uXXXX في JSON يقبل أربعة أرقام ست عشرية — أي وحدة ترميز UTF-16 واحدة. ترميز 😀 (U+1F600) يتطلّب زوج الإحلال 😀. ولا يوجد بناء أقواس \u{...} في JSON.

الحلّ. إمّا قبول زوج الإحلال (كلّ محلّل ملتزم بالمواصفة يتعامل معه) أو كتابة الـ emoji حرفياً — JSON يسمح بأي حرف UTF-8 خارج بناء الهروب، ومعظم المحلّلات الحديثة تفضّل تلك الصورة.

الفخ 8: قيم Content-Type: charset= الافتراضية في HTTP ليست ما تظنّ

العَرَض. صفحة HTML بترميز UTF-8 تُصيَّر Mojibake في متصفّح وصحيحة في آخر.

السبب الجذري. RFC 2616 الأصلي فَرَض ISO-8859-1 افتراضياً لاستجابات text/* بلا charset صريح. حذف RFC 7231 (2014) ذلك الافتراضي، فترك لكلّ متصفّح أن يخمّن. بعضها يستشمّ المحتوى، وبعضها يرتدّ إلى UTF-8، وبعضها يفترض إعدادات لغة النظام.

الحلّ. أرسل Content-Type: text/html; charset=utf-8 من الخادم و <meta charset="utf-8"> في رأس المستند. أيّهما وحده يعمل؛ والاثنان معاً حزام وكتفان (احتياط مزدوج) لوسطاء قدامى يجرّدون الترويسات.

لرؤية أي من هذه الفخاخ مباشرةً على مستوى البايت، مرمّز وفاكّ ترميز Base64 هو أسرع مجهر: الصق نصّاً، رمّزه إلى Base64، والحِمل المفكوك هو مجرى UTF-8.

اختيار الترميز الصحيح — مصفوفة القرار

لسؤال utf-8 vs utf-16، الجواب هو UTF-8 في الغالب. الجدول أدناه يغطّي الحالات الحديّة.

مصفوفة القرار

السيناريواخترلماذا
صفحات الويب، API JSON، الملفات المصدريةUTF-8 (بلا BOM)متوافق مع ASCII، بلا ترتيب بايت، أصغر للنصوص اللاتينية، RFC 8259 يفرض UTF-8 لـ JSON
تخزين CJK ثقيل (قاعدة بيانات صينية، بيانات لعبة يابانية)UTF-8 (utf8mb4)UTF-8 يستخدم 3 بايتات لكل حرف CJK مقابل 2 في UTF-16، لكنّ عبء ASCII من العلامات ومفاتيح JSON لا يزال يُبقي UTF-8 في المقدّمة عملياً — والمنظومة المحيطة UTF-8
واجهة Windows الأصلية، كود Java / C# قديمUTF-16الافتراضي للمنصّة؛ التحويل عند كل استدعاء API يدعو الأخطاء
معالجة نصوص في الذاكرة كثيفة الفهرسةUTF-32وصول لنقاط الترميز بزمن ثابت؛ يستحقّ فقط لمسارات المحلّلات الساخنة
CSV مفتوح في Excel على WindowsUTF-8 مع BOMExcel يقرأ UTF-8 بلا BOM كـ ANSI ويُشوّه ترويسات CJK
مشروع جديد بلا قيودUTF-8 (بلا BOM)حروب الترميز انتهت بشكل حاسم

قاعدتان عمليتان

  1. الافتراضي UTF-8 في كل مكان ما لم تفرض المنصّة غير ذلك. يتّفق W3C وIETF وUnicode Consortium جميعاً.
  2. حوِّل عند الحدّ، لا في المنتصف. فكّ ترميز البايتات إلى نوع السلسلة الأصلي للغتك عند الاستلام. اعمل على السلاسل، لا البايتات، في منطق العمل. أعِد الترميز إلى UTF-8 عند الإخراج. “شطيرة UTF-8” هذه تقضي على كامل صنف أخطاء mojibake منتصف خطّ الأنابيب.

الأسئلة الشائعة

هل UTF-8 متوافق دائماً تراجعياً مع ASCII؟

نعم. أيّ ملف ASCII صالح متطابق على مستوى البِتّ مع تمثيله UTF-8. أوّل 128 نقطة ترميز (U+0000U+007F) تُرمَّز ببايت واحد بِتُّه العالي صفر. الأدوات القديمة المقتصرة على ASCIIgrep وsed المبكرة، أنابيب الـ shell الكلاسيكية — تعالج ملفات UTF-8 الـ ASCII-خالصة دون تعديل. المشكلة تبدأ حين تدخل بايتات غير ASCII (البِتّ العالي مرفوع) إلى المجرى.

هل ينبغي أن أستخدم UTF-8 BOM في ملفاتي؟

افتراضياً لا. ملفات HTML وJSON وJavaScript وCSS تنكسر أو تُحذِّر في بعض المحلّلات حين تظهر BOM في البداية. الاستثناء المعتاد هو CSV الموجّه لفتحه في Excel على Windows — بدون BOM يخمّن Excel ANSI ويُشوّه ترويسات الصينية أو اليابانية أو الكورية. انظر مصفوفة قرار BOM في القسم 5.

لماذا "😀".length === 2 في JavaScript؟

سلاسل JavaScript مخزَّنة كـ UTF-16، و.length يُرجع عدد وحدات الترميز، لا الأحرف. 😀 (U+1F600) يعيش في المستوى التكميلي ويتطلّب زوج إحلال — وحدتي ترميز من 16 بِتّاً — فيساوي .length 2. استخدم [..."😀"].length أو Array.from("😀").length أو Intl.Segmenter للحصول على عَدّ حقيقي.

ما الفرق بين Unicode وUTF-8؟

Unicode هو جدول الأحرف الذي يُسنِد نقطة ترميز (رقم مثل U+1F600) لكل حرف. UTF-8 أحد ترميزات عدّة تترجم تلك النقاط إلى بايتات (من 1 إلى 4 بايتات لكل نقطة ترميز). Unicode يحدّد ماهية الحرف؛ وUTF-8 يحدّد كيف يسافر عبر ملف أو شبكة. UTF-16 وUTF-32 ترميزان بديلان لجدول Unicode نفسه.

هل utf8mb4 أكثر أماناً دائماً من utf8 في MySQL؟

نعم للمشاريع الجديدة. utf8 في MySQL اسم خاطئ للنسخة المقيَّدة بـ 3 بايتات utf8mb3، التي لا يمكنها تخزين أي حرف فوق U+FFFF — كلّ emoji، كثير من أحرف CJK النادرة، كل الكتابات التاريخية. utf8mb4 هو UTF-8 كامل من 4 بايتات. التحفّظ الوحيد طول الفهرس: كلّ حرف في utf8mb4 قد يأخذ 4 بايتات، ولذا حدّ فهرس InnoDB القديم البالغ 767 بايتاً يُقيّد الفهارس الفريدة عند 191 حرفاً (يُحلّ بـ innodb_large_prefix في MySQL 5.7+ وهو الافتراضي في 8.0).

كيف أكتشف ترميز ملف مجهول؟

استخدم file على Unix، أو chardet أو charset-normalizer في Python، أو jschardet في Node. لا واحدة منها مثاليّة — فهي تخمّن إحصائياً من توزيع البايتات. اكتشاف UTF-8 موثوق بفضل نمط بايت المتابعة. أمّا Windows-1252 وISO-8859-1 وغيرها من الترميزات القديمة أحادية البايت فتكاد لا تتمايز فيما بينها، فيتحوّل الاكتشاف غالباً إلى تخمينات لغوية.

هل يستطيع UTF-16 تمثيل كل حرف Unicode؟

نعم. UTF-16 يغطّي جميع 1,114,112 نقطة ترميز. أحرف BMP (U+0000U+FFFF) تستخدم وحدة ترميز واحدة من 16 بِتّاً (2 بايت)، وأحرف المستويات التكميلية (U+10000U+10FFFF) تستخدم أزواج إحلال (4 بايتات). التغطية متطابقة مع UTF-8 وUTF-32؛ ويختلف تخطيط البايتات ودلالات المعالجة. الاختيار بينها يدور حول مناسبة المنظومة، لا القدرة.

الوسوم: unicode utf-8 utf-16 character-encoding surrogate-pair encoding

مقالات ذات صلة

عرض جميع المقالات