مستوى المقال: مبتدئ — مناسب لأي حد لسه بادئ في البرمجة وعايز يفهم النصوص صح.
لو كتبت "👨👩👧".length في JavaScript وطلعلك 8 بدل 1، ده مش غلط في الكود ولا في المتصفح. ده بالظبط ازاي الكمبيوتر بيشوف النص. في آخر المقال هتعرف تعدّ الحروف صح وتبطّل تتصدم من الأرقام دي.
ليه طول إيموجي واحد بيطلّع 8 في الكود؟
المشكلة باختصار
إنت شايف رمز واحد على الشاشة: عائلة. الكمبيوتر شايف حاجة تانية خالص. .length في JavaScript بيرجّع 8، وlen() في Python بيرجّع 5، وعينك بتشوف 1. تلات أرقام مختلفة لنفس النص. لو بنيت على .length وانت فاكره بيعدّ الحروف اللي المستخدم شايفها، هتقع في باجات حقيقية في حقول الإدخال، وقص النصوص، وعدّ الأحرف.
الأول بمثال: علبة فيها كروت
تخيّل إن عندك علبة، وكل خانة فيها بتاخد كارت صغير واحد بس. عايز تكتب كلمة "باب"، فحطيت 3 كروت: ب، ا، ب. عدّ الكروت = 3، وعدّ الحروف اللي بتشوفها = 3. تمام، كله ماشي.
دلوقتي عايز تحط صورة عائلة. الصورة دي كبيرة، مش بتدخل في خانة واحدة. فبتتقسّم على كذا خانة: خانة للأب، خانة لـ"غرّاية" خفية بتلزق الصورتين، خانة للأم، غرّاية تانية، خانة للبنت. النتيجة: 5 خانات اتملت عشان تطلع لك صورة واحدة. لو عدّيت الخانات هتلاقي 5، رغم إن عينك شايفة صورة واحدة. ده بالظبط اللي بيحصل مع الإيموجي.
الشرح العلمي: 3 طرق مختلفة للعدّ
دلوقتي نرجع للمصطلحات بدقة. النص في الذاكرة بيمر بتلات مستويات، وكل واحد بيعدّ بشكل مختلف:
- Code point (نقطة الترميز): الرقم الرسمي اللي بتديه Unicode لكل رمز. مثلًا الأب
👨= U+1F468. ده "الكارت" المنطقي. - Code unit (وحدة الترميز): ازاي الـ code point ده بيتخزّن فعليًا في الذاكرة. JavaScript بيستخدم ترميز UTF-16، واللي بيخزّن أي رمز كبير زي الإيموجي في وحدتين مش وحدة. ده "الخانة" في العلبة.
- Grapheme (العنقود المرئي): الحرف الواحد زي ما عينك بتشوفه. ده اللي إنت بتقصده لما تقول "حرف".
إيموجي العائلة 👨👩👧 مكوّن من 3 إيموجي (أب، أم، بنت) متلزّقين بفاصل خفي اسمه Zero-Width Joiner (اختصارًا ZWJ، رمزه U+200D). الـ ZWJ ده بيقول للنظام: "اعرض دول كصورة واحدة". النتيجة:
- عدد الـ code points = 5 (أب + ZWJ + أم + ZWJ + بنت). ده اللي Python بيعدّه.
- عدد الـ code units في UTF-16 = 8 (كل إيموجي = وحدتين، وكل ZWJ = وحدة: 2+1+2+1+2). ده اللي JavaScript بيعدّه في
.length. - عدد الـ graphemes = 1. ده اللي عينك بتشوفه.
الكود اللي بيحصل فعلاً
جرّب الكود ده في console المتصفح أو Node 22:
"a".length; // 1 حرف عادي
"😀".length; // 2 إيموجي واحد = وحدتين UTF-16
"👨👩👧".length; // 8 عائلة = 8 وحدات
// الـ spread بيقسّم على code points مش code units
[..."👨👩👧"].length; // 5
// عدد البايتات الفعلية في UTF-8
new TextEncoder().encode("😀").length; // 4 بايت
ونفس النص في Python 3 (اللي بيعدّ code points افتراضيًا):
len("a") # 1
len("😀") # 1 بايثون بيعدّ code points مش UTF-16
len("👨👩👧") # 5
len("😀".encode("utf-8")) # 4 بايت
لاحظ الفرق المهم: نفس الإيموجي طلع 2 في JavaScript و1 في Python، عشان كل لغة بتعدّ مستوى مختلف. مفيش رقم منهم "غلط"، بس مفيش فيهم اللي إنت محتاجه (وهو 1).
الحل: عُدّ الـ graphemes
لو عايز تعدّ زي ما العين بتشوف، استخدم Intl.Segmenter اللي بقى مدعوم في كل المتصفحات الحديثة و Node 16 فأحدث:
function countGraphemes(str) {
const seg = new Intl.Segmenter("ar", { granularity: "grapheme" });
return [...seg.segment(str)].length;
}
countGraphemes("👨👩👧"); // 1 أخيرًا الرقم الصح
countGraphemes("café"); // 4
سيناريو واقعي: حقل بحد 280 حرف
افرض إن عندك حقل تعليق بحد أقصى 280 حرف، وبتتحقق منه بـ text.length > 280. مستخدم كتب 70 إيموجي بس، وكل إيموجي عائلة بياخد 8 وحدات. يبقى .length هيطلّع 560، والنظام هيمنعه رغم إنه شايف 70 رمز بس. تويتر نفسه اتعامل مع المشكلة دي بنظام وزن خاص للأحرف. الخلاصة: لو الحد بتاعك مبني على اللي المستخدم شايفه، عُدّ graphemes مش .length.
trade-offs اللي لازم تنتبه لها
- الأداء:
.lengthفوري لأنه مجرد قراءة رقم محفوظ.Intl.Segmenterبيحلّل النص فبياخد وقت أطول. على نص قصير الفرق مهملش، لكن لو بتعدّ ملايين النصوص في loop، الفرق هيبان. بتكسب الدقة، بتخسر شوية سرعة. - الذاكرة:
[...str]وsegment()بيعملوا مصفوفة جديدة في الذاكرة. على نصوص ضخمة ده استهلاك زيادة. - التطبيع (Normalization): كلمة زي
"café"ممكن تتخزّن بشكلين: الحرف é كـ code point واحد، أو حرف e + علامة فوقه منفصلة. الشكلين شكلهم واحد بس طولهم مختلف. استخدمstr.normalize("NFC")قبل المقارنة عشان توحّدهم.
متى متشغلش بالك بالموضوع ده
لو إنت متأكد إن النص بتاعك إنجليزي وأرقام بس (زي كود منتج، أو username محصور في ASCII)، يبقى .length هيساوي عدد الحروف بالظبط، ومحتاجش تعقّد الأمور. المشكلة دي بتظهر بس مع الإيموجي والعربي بالتشكيل واللغات اللي بتستخدم رموز مركّبة. الافتراض هنا إن مدخلاتك ممكن تحتوي رموز خارج ASCII.
المصادر
- توثيق MDN لخاصية
String.lengthوشرحه إنها بتعدّ UTF-16 code units: developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length - توثيق MDN لـ
Intl.Segmenter: developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter - مواصفة Unicode الرسمية، الملحق UAX #29 الخاص بحدود الـ grapheme clusters: unicode.org/reports/tr29
- توثيق Python حول نوع
strوالتعامل مع Unicode: docs.python.org/3/howto/unicode.html - مواصفة ECMAScript بخصوص تمثيل النصوص بـ UTF-16: tc39.es/ecma262
الخطوة التالية
افتح console بتاعك دلوقتي واكتب "👨👩👧".length وبعدها countGraphemes("👨👩👧") بالدالة اللي فوق. شوف الفرق بعينك. وبعد كده روح على أي حقل إدخال في مشروعك بيتحقق من طول النص، وراجعه: لو ممكن المستخدم يكتب إيموجي أو عربي بتشكيل، حوّل العدّ لـ graphemes.