← JavaScript/Юникод и внутреннее устройство строк#148 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Юникод и внутреннее устройство строк

Представь: пользователь вводит имя «José» или никнейм с эмодзи «Александр 🎸». Твоя функция обрезки до 20 символов работает неправильно — режет эмодзи пополам, получается кракозябра. Или счётчик символов в твиттере показывает 4 вместо 2 для «😀🎉». Это последствия работы с str.length вместо реального количества символов.

Что решает этот механизм

JavaScript строки хранятся в UTF-16, где некоторые символы занимают две «кодовые единицы». str.length считает единицы, а не видимые символы. Понимание этого позволяет корректно работать со строками, содержащими эмодзи, редкие иероглифы или диакритику.

На основе предыдущих уроков

  • строки, методы строк — slice, length, split работают с кодовыми единицами, а не символами
  • RegExp — флаг u в регулярных выражениях включает Unicode-режим
  • UTF-16 и суррогатные пары

    Юникод содержит более 1 миллиона символов. Базовая многоязычная плоскость (BMP) включает символы с кодовыми точками от U+0000 до U+FFFF — они занимают одну кодовую единицу UTF-16.

    Символы за пределами BMP (U+10000 и выше) — иероглифы, эмодзи, математические символы — кодируются парой кодовых единиц (суррогатная пара):

    const latin  = 'A'        // U+0041 — одна кодовая единица
    const emoji  = '😀'       // U+1F600 — СУРРОГАТНАЯ ПАРА (две единицы!)
    const kanji  = '𠮷'       // U+20BB7 — редкий иероглиф, суррогатная пара
    
    console.log(latin.length)  // 1 — корректно
    console.log(emoji.length)  // 2 — НЕ 1! Это ловушка
    console.log(kanji.length)  // 2
    
    // charCodeAt возвращает кодовые ЕДИНИЦЫ (не кодовые точки!)
    console.log(emoji.charCodeAt(0))  // 55357 (первая половина суррогатной пары)
    console.log(emoji.charCodeAt(1))  // 56832 (вторая половина)

    codePointAt vs charCodeAt

    const emoji = '😀'
    
    // charCodeAt — возвращает кодовую ЕДИНИЦУ (только 0..65535)
    console.log(emoji.charCodeAt(0))   // 55357 — половина суррогатной пары
    
    // codePointAt — возвращает кодовую ТОЧКУ (может быть > 65535)
    console.log(emoji.codePointAt(0))  // 128512 (0x1F600) — настоящее значение
    
    // String.fromCharCode vs String.fromCodePoint
    console.log(String.fromCharCode(65))        // 'A'
    console.log(String.fromCodePoint(128512))   // '😀' — работает с любыми кодовыми точками
    console.log(String.fromCharCode(128512))    // мусор — обрезает до 16 бит

    str.length — не то, что вы думаете

    const str = 'Hello 😀 мир 𠮷'
    console.log(str.length)  // больше, чем количество «видимых» символов!
    
    // Посчитать символы правильно:
    const chars = [...str]   // итератор строки понимает суррогатные пары
    console.log(chars.length)  // реальное количество символов
    
    // Array.from работает аналогично
    console.log(Array.from(str).length)  // то же самое

    Оператор spread ([...str]) и Array.from используют итератор строки, который понимает суррогатные пары и возвращает полные символы.

    Правильная работа со строками

    const text = 'Привет 🌍 мир!'
    
    // НЕПРАВИЛЬНО — может разрезать суррогатную пару
    const badSlice = text.slice(0, 9)     // может оборвать эмодзи
    
    // ПРАВИЛЬНО — через spread
    const chars = [...text]
    const goodSlice = chars.slice(0, 9).join('')
    
    // Разворот строки
    const reversed = [...text].reverse().join('')  // правильно
    const badReversed = text.split('').reverse().join('')  // ЛОМАЕТ эмодзи!
    
    // Итерация по символам
    for (const char of text) {
      // Корректно — for...of использует итератор
      console.log(char)
    }

    Нормализация Unicode

    Один и тот же видимый символ может быть закодирован несколькими способами:

    // 'é' можно записать двумя способами:
    const combined    = '\u00E9'      // один символ: é (NFC)
    const decomposed  = 'e\u0301'    // два символа: e + акцент (NFD)
    
    console.log(combined === decomposed)   // false — строки разные!
    console.log(combined.length)           // 1
    console.log(decomposed.length)         // 2
    
    // normalize() приводит к одной форме
    console.log(combined.normalize('NFC') === decomposed.normalize('NFC'))  // true

    Формы нормализации:

  • NFC — составная форма (предпочтительна для хранения)
  • NFD — разложенная форма (символы + диакритика по отдельности)
  • NFKC/NFKD — то же + нормализация совместимости (лигатуры → отдельные буквы)
  • localeCompare — правильное сравнение строк

    // Простое сравнение через < > не учитывает локаль
    const words = ['ёж', 'еда', 'яблоко', 'абрикос']
    console.log(words.sort())  // неправильный порядок!
    
    // Правильно
    console.log(words.sort((a, b) => a.localeCompare(b, 'ru')))
    // ['абрикос', 'еда', 'ёж', 'яблоко']
    
    // С акцентами
    const names = ['résumé', 'resume', 'Résumé']
    console.log(names.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' })))

    Реальный пример: подсчёт символов

    function countChars(str) {
      return [...str].length
    }
    
    function truncate(str, maxChars, suffix = '…') {
      const chars = [...str]
      if (chars.length <= maxChars) return str
      return chars.slice(0, maxChars).join('') + suffix
    }
    
    const post = 'Привет 🌍! Сегодня 🌞 хорошая погода 🌈'
    console.log('length (UTF-16):', post.length)       // больше реального
    console.log('countChars:', countChars(post))       // реальное число символов
    
    console.log(truncate(post, 10))    // 'Привет 🌍! С…'
    console.log(truncate(post, 100))   // вся строка без изменений

    Типичные ошибки

    1. Разворачивать строку через split('').reverse() — ломает эмодзи

    const str = 'Hi 😀!'
    // ПЛОХО — split('') делит по кодовым единицам, эмодзи разбивается
    const bad = str.split('').reverse().join('')
    console.log(bad)  // '!\uDC00\uD83D iH' — сломанный эмодзи
    
    // ХОРОШО — spread использует итератор, понимающий суррогатные пары
    const good = [...str].reverse().join('')
    console.log(good)  // '!😀 iH' — корректно

    2. Сравнивать строки с диакритикой через ===

    // ПЛОХО — визуально одинаковые строки могут быть разными
    const a = 'é'          // U+00E9 (один символ, NFC)
    const b = 'é'   // e + комбинирующий акцент (два кодовых блока, NFD)
    console.log(a === b)   // false — строки разные!
    console.log(a.length)  // 1
    console.log(b.length)  // 2
    
    // ХОРОШО — нормализуй перед сравнением
    console.log(a.normalize('NFC') === b.normalize('NFC'))  // true

    3. Сортировать строки с кириллицей через sort() без localeCompare

    // ПЛОХО — sort() использует кодовые точки, ё идёт не там
    const words = ['яблоко', 'ёж', 'абрикос', 'дерево']
    console.log(words.sort())  // ['абрикос', 'дерево', 'яблоко', 'ёж'] — ёж в конце!
    
    // ХОРОШО — localeCompare знает порядок алфавита
    console.log(words.sort((a, b) => a.localeCompare(b, 'ru')))
    // ['абрикос', 'дерево', 'ёж', 'яблоко']

    В реальных проектах

  • Twitter/Instagram: счётчик символов в твите и подписи использует Intl.Segmenter или [...str].length для корректного подсчёта
  • Поиск по тексту: Algolia и Elasticsearch нормализуют строки (NFC) перед индексацией, чтобы «résumé» и «resume» матчились корректно
  • Форматирование имён: международные приложения (Airbnb, Booking) используют localeCompare для сортировки имён пользователей
  • Примеры

    Суррогатные пары: length vs реальное количество символов, codePointAt, spread-итерация

    // Демонстрация суррогатных пар и правильной работы со строками
    
    // 1. Разница между .length и реальным числом символов
    console.log('=== length vs реальное количество ===')
    
    const examples = [
      { label: 'ASCII',      str: 'Hello' },
      { label: 'Кириллица',  str: 'Привет' },
      { label: 'Эмодзи',     str: '😀🎉🌍' },
      { label: 'Смешанная',  str: 'Hi 😀 мир 🌍!' },
      { label: 'Иероглифы',  str: '日本語' },
    ]
    
    for (const { label, str } of examples) {
      const realCount = [...str].length
      console.log(`${label.padEnd(12)}: length=${str.length}, символов=${realCount}${str.length !== realCount ? ' ⚠️ РАЗНИЦА' : ''}`)
    }
    
    // 2. codePointAt vs charCodeAt
    console.log('\n=== codePointAt vs charCodeAt ===')
    
    const emoji = '😀'
    console.log('charCodeAt(0):', emoji.charCodeAt(0))   // 55357 (суррогат)
    console.log('charCodeAt(1):', emoji.charCodeAt(1))   // 56832 (суррогат)
    console.log('codePointAt(0):', emoji.codePointAt(0)) // 128512 (0x1F600 — настоящая кодовая точка)
    
    // Восстановить символ из кодовой точки
    const cp = emoji.codePointAt(0)
    console.log('fromCodePoint:', String.fromCodePoint(cp))  // 😀
    
    // 3. Итерация: for...of корректно обрабатывает суррогатные пары
    console.log('\n=== Итерация по символам ===')
    
    const mixed = 'A😀B🌍C'
    console.log('Через for...of (правильно):')
    const fromFor = []
    for (const ch of mixed) {
      fromFor.push(ch)
    }
    console.log(fromFor)  // ['A', '😀', 'B', '🌍', 'C'] — 5 символов
    
    console.log('Через индекс (неправильно):')
    const fromIndex = []
    for (let i = 0; i < mixed.length; i++) {
      fromIndex.push(mixed[i])
    }
    console.log(fromIndex)  // 7 элементов — суррогатные пары разбиты
    
    // 4. Разворот строки
    console.log('\n=== Разворот строки ===')
    
    const str = 'Привет 🌍'
    const badReverse  = str.split('').reverse().join('')
    const goodReverse = [...str].reverse().join('')
    
    console.log('Оригинал:', str)
    console.log('Плохой разворот:', badReverse)   // 🌍 ломается
    console.log('Правильный разворот:', goodReverse)  // корректно
    
    // 5. Нормализация Unicode
    console.log('\n=== Нормализация Unicode ===')
    
    const nfc = '\u00E9'          // é — один символ (NFC)
    const nfd = 'e\u0301'        // e + комбинирующий акцент (NFD)
    
    console.log('NFC:', nfc, '| length:', nfc.length)  // 1
    console.log('NFD:', nfd, '| length:', nfd.length)  // 2
    console.log('Равны без нормализации:', nfc === nfd)  // false
    console.log('Равны после normalize:', nfc.normalize('NFC') === nfd.normalize('NFC'))  // true
    
    // 6. Корректный подсчёт и усечение
    console.log('\n=== countChars и truncate ===')
    
    function countChars(str) {
      return [...str].length
    }
    
    function truncate(str, maxChars, suffix = '…') {
      const chars = [...str]
      if (chars.length <= maxChars) return str
      return chars.slice(0, maxChars).join('') + suffix
    }
    
    const post = 'Добро пожаловать 🎉 в наш чат! Здесь весело 😄🌟'
    console.log('UTF-16 units:', post.length)
    console.log('Реальных символов:', countChars(post))
    
    console.log('\nОбрезаем до 20 символов:')
    console.log(truncate(post, 20))
    
    console.log('Обрезаем до 35 символов:')
    console.log(truncate(post, 35))
    
    console.log('Обрезаем до 200 (без изменений):')
    console.log(truncate(post, 200))

    Юникод и внутреннее устройство строк

    Представь: пользователь вводит имя «José» или никнейм с эмодзи «Александр 🎸». Твоя функция обрезки до 20 символов работает неправильно — режет эмодзи пополам, получается кракозябра. Или счётчик символов в твиттере показывает 4 вместо 2 для «😀🎉». Это последствия работы с str.length вместо реального количества символов.

    Что решает этот механизм

    JavaScript строки хранятся в UTF-16, где некоторые символы занимают две «кодовые единицы». str.length считает единицы, а не видимые символы. Понимание этого позволяет корректно работать со строками, содержащими эмодзи, редкие иероглифы или диакритику.

    На основе предыдущих уроков

  • строки, методы строк — slice, length, split работают с кодовыми единицами, а не символами
  • RegExp — флаг u в регулярных выражениях включает Unicode-режим
  • UTF-16 и суррогатные пары

    Юникод содержит более 1 миллиона символов. Базовая многоязычная плоскость (BMP) включает символы с кодовыми точками от U+0000 до U+FFFF — они занимают одну кодовую единицу UTF-16.

    Символы за пределами BMP (U+10000 и выше) — иероглифы, эмодзи, математические символы — кодируются парой кодовых единиц (суррогатная пара):

    const latin  = 'A'        // U+0041 — одна кодовая единица
    const emoji  = '😀'       // U+1F600 — СУРРОГАТНАЯ ПАРА (две единицы!)
    const kanji  = '𠮷'       // U+20BB7 — редкий иероглиф, суррогатная пара
    
    console.log(latin.length)  // 1 — корректно
    console.log(emoji.length)  // 2 — НЕ 1! Это ловушка
    console.log(kanji.length)  // 2
    
    // charCodeAt возвращает кодовые ЕДИНИЦЫ (не кодовые точки!)
    console.log(emoji.charCodeAt(0))  // 55357 (первая половина суррогатной пары)
    console.log(emoji.charCodeAt(1))  // 56832 (вторая половина)

    codePointAt vs charCodeAt

    const emoji = '😀'
    
    // charCodeAt — возвращает кодовую ЕДИНИЦУ (только 0..65535)
    console.log(emoji.charCodeAt(0))   // 55357 — половина суррогатной пары
    
    // codePointAt — возвращает кодовую ТОЧКУ (может быть > 65535)
    console.log(emoji.codePointAt(0))  // 128512 (0x1F600) — настоящее значение
    
    // String.fromCharCode vs String.fromCodePoint
    console.log(String.fromCharCode(65))        // 'A'
    console.log(String.fromCodePoint(128512))   // '😀' — работает с любыми кодовыми точками
    console.log(String.fromCharCode(128512))    // мусор — обрезает до 16 бит

    str.length — не то, что вы думаете

    const str = 'Hello 😀 мир 𠮷'
    console.log(str.length)  // больше, чем количество «видимых» символов!
    
    // Посчитать символы правильно:
    const chars = [...str]   // итератор строки понимает суррогатные пары
    console.log(chars.length)  // реальное количество символов
    
    // Array.from работает аналогично
    console.log(Array.from(str).length)  // то же самое

    Оператор spread ([...str]) и Array.from используют итератор строки, который понимает суррогатные пары и возвращает полные символы.

    Правильная работа со строками

    const text = 'Привет 🌍 мир!'
    
    // НЕПРАВИЛЬНО — может разрезать суррогатную пару
    const badSlice = text.slice(0, 9)     // может оборвать эмодзи
    
    // ПРАВИЛЬНО — через spread
    const chars = [...text]
    const goodSlice = chars.slice(0, 9).join('')
    
    // Разворот строки
    const reversed = [...text].reverse().join('')  // правильно
    const badReversed = text.split('').reverse().join('')  // ЛОМАЕТ эмодзи!
    
    // Итерация по символам
    for (const char of text) {
      // Корректно — for...of использует итератор
      console.log(char)
    }

    Нормализация Unicode

    Один и тот же видимый символ может быть закодирован несколькими способами:

    // 'é' можно записать двумя способами:
    const combined    = '\u00E9'      // один символ: é (NFC)
    const decomposed  = 'e\u0301'    // два символа: e + акцент (NFD)
    
    console.log(combined === decomposed)   // false — строки разные!
    console.log(combined.length)           // 1
    console.log(decomposed.length)         // 2
    
    // normalize() приводит к одной форме
    console.log(combined.normalize('NFC') === decomposed.normalize('NFC'))  // true

    Формы нормализации:

  • NFC — составная форма (предпочтительна для хранения)
  • NFD — разложенная форма (символы + диакритика по отдельности)
  • NFKC/NFKD — то же + нормализация совместимости (лигатуры → отдельные буквы)
  • localeCompare — правильное сравнение строк

    // Простое сравнение через < > не учитывает локаль
    const words = ['ёж', 'еда', 'яблоко', 'абрикос']
    console.log(words.sort())  // неправильный порядок!
    
    // Правильно
    console.log(words.sort((a, b) => a.localeCompare(b, 'ru')))
    // ['абрикос', 'еда', 'ёж', 'яблоко']
    
    // С акцентами
    const names = ['résumé', 'resume', 'Résumé']
    console.log(names.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' })))

    Реальный пример: подсчёт символов

    function countChars(str) {
      return [...str].length
    }
    
    function truncate(str, maxChars, suffix = '…') {
      const chars = [...str]
      if (chars.length <= maxChars) return str
      return chars.slice(0, maxChars).join('') + suffix
    }
    
    const post = 'Привет 🌍! Сегодня 🌞 хорошая погода 🌈'
    console.log('length (UTF-16):', post.length)       // больше реального
    console.log('countChars:', countChars(post))       // реальное число символов
    
    console.log(truncate(post, 10))    // 'Привет 🌍! С…'
    console.log(truncate(post, 100))   // вся строка без изменений

    Типичные ошибки

    1. Разворачивать строку через split('').reverse() — ломает эмодзи

    const str = 'Hi 😀!'
    // ПЛОХО — split('') делит по кодовым единицам, эмодзи разбивается
    const bad = str.split('').reverse().join('')
    console.log(bad)  // '!\uDC00\uD83D iH' — сломанный эмодзи
    
    // ХОРОШО — spread использует итератор, понимающий суррогатные пары
    const good = [...str].reverse().join('')
    console.log(good)  // '!😀 iH' — корректно

    2. Сравнивать строки с диакритикой через ===

    // ПЛОХО — визуально одинаковые строки могут быть разными
    const a = 'é'          // U+00E9 (один символ, NFC)
    const b = 'é'   // e + комбинирующий акцент (два кодовых блока, NFD)
    console.log(a === b)   // false — строки разные!
    console.log(a.length)  // 1
    console.log(b.length)  // 2
    
    // ХОРОШО — нормализуй перед сравнением
    console.log(a.normalize('NFC') === b.normalize('NFC'))  // true

    3. Сортировать строки с кириллицей через sort() без localeCompare

    // ПЛОХО — sort() использует кодовые точки, ё идёт не там
    const words = ['яблоко', 'ёж', 'абрикос', 'дерево']
    console.log(words.sort())  // ['абрикос', 'дерево', 'яблоко', 'ёж'] — ёж в конце!
    
    // ХОРОШО — localeCompare знает порядок алфавита
    console.log(words.sort((a, b) => a.localeCompare(b, 'ru')))
    // ['абрикос', 'дерево', 'ёж', 'яблоко']

    В реальных проектах

  • Twitter/Instagram: счётчик символов в твите и подписи использует Intl.Segmenter или [...str].length для корректного подсчёта
  • Поиск по тексту: Algolia и Elasticsearch нормализуют строки (NFC) перед индексацией, чтобы «résumé» и «resume» матчились корректно
  • Форматирование имён: международные приложения (Airbnb, Booking) используют localeCompare для сортировки имён пользователей
  • Примеры

    Суррогатные пары: length vs реальное количество символов, codePointAt, spread-итерация

    // Демонстрация суррогатных пар и правильной работы со строками
    
    // 1. Разница между .length и реальным числом символов
    console.log('=== length vs реальное количество ===')
    
    const examples = [
      { label: 'ASCII',      str: 'Hello' },
      { label: 'Кириллица',  str: 'Привет' },
      { label: 'Эмодзи',     str: '😀🎉🌍' },
      { label: 'Смешанная',  str: 'Hi 😀 мир 🌍!' },
      { label: 'Иероглифы',  str: '日本語' },
    ]
    
    for (const { label, str } of examples) {
      const realCount = [...str].length
      console.log(`${label.padEnd(12)}: length=${str.length}, символов=${realCount}${str.length !== realCount ? ' ⚠️ РАЗНИЦА' : ''}`)
    }
    
    // 2. codePointAt vs charCodeAt
    console.log('\n=== codePointAt vs charCodeAt ===')
    
    const emoji = '😀'
    console.log('charCodeAt(0):', emoji.charCodeAt(0))   // 55357 (суррогат)
    console.log('charCodeAt(1):', emoji.charCodeAt(1))   // 56832 (суррогат)
    console.log('codePointAt(0):', emoji.codePointAt(0)) // 128512 (0x1F600 — настоящая кодовая точка)
    
    // Восстановить символ из кодовой точки
    const cp = emoji.codePointAt(0)
    console.log('fromCodePoint:', String.fromCodePoint(cp))  // 😀
    
    // 3. Итерация: for...of корректно обрабатывает суррогатные пары
    console.log('\n=== Итерация по символам ===')
    
    const mixed = 'A😀B🌍C'
    console.log('Через for...of (правильно):')
    const fromFor = []
    for (const ch of mixed) {
      fromFor.push(ch)
    }
    console.log(fromFor)  // ['A', '😀', 'B', '🌍', 'C'] — 5 символов
    
    console.log('Через индекс (неправильно):')
    const fromIndex = []
    for (let i = 0; i < mixed.length; i++) {
      fromIndex.push(mixed[i])
    }
    console.log(fromIndex)  // 7 элементов — суррогатные пары разбиты
    
    // 4. Разворот строки
    console.log('\n=== Разворот строки ===')
    
    const str = 'Привет 🌍'
    const badReverse  = str.split('').reverse().join('')
    const goodReverse = [...str].reverse().join('')
    
    console.log('Оригинал:', str)
    console.log('Плохой разворот:', badReverse)   // 🌍 ломается
    console.log('Правильный разворот:', goodReverse)  // корректно
    
    // 5. Нормализация Unicode
    console.log('\n=== Нормализация Unicode ===')
    
    const nfc = '\u00E9'          // é — один символ (NFC)
    const nfd = 'e\u0301'        // e + комбинирующий акцент (NFD)
    
    console.log('NFC:', nfc, '| length:', nfc.length)  // 1
    console.log('NFD:', nfd, '| length:', nfd.length)  // 2
    console.log('Равны без нормализации:', nfc === nfd)  // false
    console.log('Равны после normalize:', nfc.normalize('NFC') === nfd.normalize('NFC'))  // true
    
    // 6. Корректный подсчёт и усечение
    console.log('\n=== countChars и truncate ===')
    
    function countChars(str) {
      return [...str].length
    }
    
    function truncate(str, maxChars, suffix = '…') {
      const chars = [...str]
      if (chars.length <= maxChars) return str
      return chars.slice(0, maxChars).join('') + suffix
    }
    
    const post = 'Добро пожаловать 🎉 в наш чат! Здесь весело 😄🌟'
    console.log('UTF-16 units:', post.length)
    console.log('Реальных символов:', countChars(post))
    
    console.log('\nОбрезаем до 20 символов:')
    console.log(truncate(post, 20))
    
    console.log('Обрезаем до 35 символов:')
    console.log(truncate(post, 35))
    
    console.log('Обрезаем до 200 (без изменений):')
    console.log(truncate(post, 200))

    Задание

    Напиши функцию countChars(str) которая возвращает реальное количество символов (не кодовых единиц UTF-16). Напиши функцию truncate(str, maxChars) которая обрезает строку до maxChars реальных символов и добавляет "…" если строка была обрезана. Напиши функцию reverseString(str) которая корректно разворачивает строку с эмодзи.

    Подсказка

    countChars: return [...str].length. truncate: const chars = [...str]; return chars.length <= maxChars ? str : chars.slice(0, maxChars).join("") + "…". reverseString: return [...str].reverse().join("")

    Загружаем среду выполнения...
    Загружаем AI-помощника...