Обработка специальных символов

При реализации автодополнения в интерфейсах ввода значительную проблему создают специальные символы. Пользователь может вводить:

  • символы регулярных выражений;
  • HTML-разметку;
  • Unicode-символы;
  • символы экранирования;
  • диакритику;
  • управляющие последовательности;
  • нестандартные пробелы;
  • символы различных алфавитов.

В библиотеке Awesomplete обработка специальных символов особенно важна при:

  • фильтрации списка;
  • подсветке совпадений;
  • пользовательских функциях filter;
  • пользовательских функиях replace;
  • работе с регулярными выражениями;
  • динамической загрузке данных;
  • вставке HTML;
  • использовании многоязычных данных.

Проблема специальных символов в регулярных выражениях

Awesomplete активно использует регулярные выражения для поиска совпадений. Это создаёт потенциальные ошибки, если ввод пользователя напрямую передаётся в RegExp.

Опасный пример

new Awesomplete(input, {
    filter(text, inputValue) {
        return new RegExp(inputValue, "i").test(text);
    }
});

Если пользователь введёт:

+

или:

[

произойдёт ошибка:

Uncaught SyntaxError: Invalid regular expression

Причина заключается в том, что символы:

. * + ? ^ $ { } ( ) | [ ] \

имеют специальное значение в регулярных выражениях.


Экранирование специальных символов

Для безопасной работы необходимо экранировать пользовательский ввод.

Универсальная функция экранирования

function escapeRegExp(string) {
    return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

Безопасное использование

new Awesomplete(input, {
    filter(text, inputValue) {
        const escaped = escapeRegExp(inputValue);
        return new RegExp(escaped, "i").test(text);
    }
});

Теперь пользователь может вводить:

C++
Node.js
[admin]
(тест)

без возникновения ошибок.


Обработка символа обратного слеша

Символ \ представляет отдельную сложность, поскольку он одновременно:

  • является управляющим символом JavaScript;
  • используется в регулярных выражениях;
  • применяется для экранирования строк.

Ошибочный вариант

const value = "\";

Такой код приведёт к синтаксической ошибке.

Корректное экранирование

const value = "\\";

Проверка ввода с обратным слешем

new Awesomplete(input, {
    filter(text, value) {
        const escaped = escapeRegExp(value);
        return new RegExp(escaped).test(text);
    }
});

Символы HTML и XSS-уязвимости

При отображении подсказок нельзя напрямую вставлять пользовательские данные через innerHTML.

Небезопасный пример

item(text) {
    const li = document.createElement("li");
    li.innerHTML = text;
    return li;
}

Если список содержит:

<script>alert("XSS")</script>

код выполнится в браузере.


Безопасная вставка текста

Следует использовать textContent.

Безопасный вариант

item(text) {
    const li = document.createElement("li");
    li.textContent = text;
    return li;
}

Теперь HTML будет отображаться как обычный текст.


Подсветка совпадений и HTML-экранирование

Awesomplete использует HTML для выделения совпадений. Например:

<mark>jav</mark>ascript

Если пользовательский текст содержит HTML, возможны проблемы.

Экранирование HTML

function escapeHTML(str) {
    return str
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
}

Использование при рендеринге

item(text, inputValue) {
    const li = document.createElement("li");

    const safeText = escapeHTML(text);

    li.innerHTML = safeText;

    return li;
}

Работа с Unicode-символами

Awesomplete корректно работает с Unicode, однако некоторые особенности требуют дополнительной обработки.

Пример данных

[
    "Привет",
    "こんにちは",
    "你好",
    "مرحبا",
    "? Emoji"
]

Стандартная фильтрация может работать непредсказуемо при:

  • составных Unicode-символах;
  • суррогатных парах;
  • emoji;
  • диакритике.

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

Разные Unicode-представления могут выглядеть одинаково.

Пример

é

может храниться как:

"é"

или:

"é"

Во втором случае символ состоит из двух кодовых точек.


Использование normalize()

function normalizeText(text) {
    return text.normalize("NFC");
}

Применение в фильтрации

new Awesomplete(input, {
    filter(text, value) {
        return normalizeText(text)
            .toLowerCase()
            .includes(
                normalizeText(value).toLowerCase()
            );
    }
});

Игнорирование диакритики

Во многих интерфейсах:

cafe

должно находить:

café

Удаление диакритики

function removeDiacritics(str) {
    return str.normalize("NFD")
        .replace(/[\u0300-\u036f]/g, "");
}

Поиск без учёта диакритики

new Awesomplete(input, {
    filter(text, value) {
        const normalizedText =
            removeDiacritics(text.toLowerCase());

        const normalizedValue =
            removeDiacritics(value.toLowerCase());

        return normalizedText.includes(normalizedValue);
    }
});

Обработка emoji

Emoji состоят из сложных Unicode-комбинаций.

Проблема длины строки

"?".length

Результат:

2

Причина — использование суррогатных пар UTF-16.


Безопасная работа с emoji

Для корректной обработки следует использовать:

Array.from()

Пример

const chars = Array.from("???");

console.log(chars.length);

Результат:

3

Спецсимволы в пользовательском фильтре

При создании собственных фильтров важно учитывать:

  • регистронезависимость;
  • пробелы;
  • управляющие символы;
  • переносы строк;
  • Unicode.

Продвинутый фильтр

new Awesomplete(input, {
    filter(text, value) {

        const cleanValue =
            value
                .trim()
                .replace(/\s+/g, " ")
                .normalize("NFC")
                .toLowerCase();

        const cleanText =
            text
                .trim()
                .replace(/\s+/g, " ")
                .normalize("NFC")
                .toLowerCase();

        return cleanText.includes(cleanValue);
    }
});

Нестандартные пробелы

Пользователь может вставлять:

  • неразрывный пробел;
  • Unicode-пробелы;
  • табуляцию;
  • перенос строки.

Пример проблемы

"hello world"

не совпадёт с:

"hello world"

где используется &nbsp;.


Нормализация пробелов

function normalizeSpaces(str) {
    return str.replace(/\s+/g, " ").trim();
}

Использование

new Awesomplete(input, {
    filter(text, value) {
        return normalizeSpaces(text)
            .includes(normalizeSpaces(value));
    }
});

Символы новой строки

Некоторые данные могут содержать:

"\n"
"\r"
"\t"

Очистка управляющих символов

function removeControlChars(str) {
    return str.replace(/[\r\n\t]/g, " ");
}

Применение

const cleaned =
    removeControlChars(data);

Обработка URL-символов

При автодополнении URL необходимо учитывать:

:
/
?
&
=
#
%

Пример списка

[
    "https://example.com",
    "https://github.com",
    "https://google.com/search?q=test"
]

Без экранирования регулярных выражений фильтрация может ломаться.

Безопасный поиск

new Awesomplete(input, {
    filter(text, value) {
        const escaped = escapeRegExp(value);

        return new RegExp(escaped, "i")
            .test(text);
    }
});

Символы Markdown

При работе с Markdown возможны специальные конструкции:

# Заголовок
* список
`код`
[ссылка]

Безопасный вывод

item(text) {
    const li = document.createElement("li");
    li.textContent = text;
    return li;
}

Обработка JSON-данных

Данные для Awesomplete часто загружаются через API.

Пример

fetch("/api/tags")
    .then(r => r.json())
    .then(data => {
        awesomplete.list = data;
    });

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

  • повреждение Unicode;
  • ошибки JSON;
  • проблемы фильтрации.

UTF-8 и кодировки

Сервер должен возвращать:

Content-Type: application/json; charset=utf-8

Иначе:

Привет

может превратиться в:

Привет

Специальные символы в replace()

Функция replace() позволяет изменять вставляемое значение.

Пример преобразования

new Awesomplete(input, {
    replace(text) {
        this.input.value =
            text.replace(/#/g, "");
    }
});

Результат

#javascript

превратится в:

javascript

Удаление опасных символов

Иногда необходимо исключать специальные символы.

Санитизация

function sanitize(str) {
    return str.replace(/[<>]/g, "");
}

Использование

replace(text) {
    this.input.value = sanitize(text);
}

Работа с SQL-подобными строками

Некоторые данные содержат:

SELECT *
WHERE id = 1

или:

name'; DR OP   TABLE users;

Хотя Awesomplete не взаимодействует напрямую с SQL, данные могут передаваться на сервер.


Базовая очистка данных

function safeQuery(str) {
    return str.replace(/['";]/g, "");
}

Символы шаблонных строк

Проблемы возможны с:

${value}

Безопасное хранение

const text =
    String(userInput);

Обработка RTL-языков

Языки с письмом справа налево:

  • арабский;
  • иврит;
  • персидский.

Настройка направления

input.dir = "auto";

или:

<input dir="auto">

Смешанные алфавиты

Пользователь может вводить:

JavaScript
жаваскрипт
جاڤاسكريبت
javascript

Универсальная фильтрация

new Awesomplete(input, {
    filter(text, value) {

        const preparedText =
            text.normalize("NFC")
                .toLocaleLowerCase();

        const preparedValue =
            value.normalize("NFC")
                .toLocaleLowerCase();

        return preparedText.includes(preparedValue);
    }
});

Производительность при обработке специальных символов

Сложная нормализация может снижать производительность.

Особенно затратны:

  • normalize();
  • большие регулярные выражения;
  • множественные replace();
  • Unicode-обработка;
  • очистка HTML.

Оптимизация обработки

Предварительная подготовка списка

const prepared = data.map(item => ({
    original: item,
    normalized:
        removeDiacritics(
            item.toLowerCase()
        )
}));

Быстрый поиск

new Awesomplete(input, {
    filter(item, value) {

        const normalizedValue =
            removeDiacritics(
                value.toLowerCase()
            );

        return item.normalized
            .includes(normalizedValue);
    }
});

Комплексная безопасная конфигурация

function escapeRegExp(str) {
    return str.replace(
        /[.*+?^${}()|[\]\\]/g,
        "\\$&"
    );
}

function normalizeInput(str) {
    return str
        .normalize("NFC")
        .replace(/\s+/g, " ")
        .trim()
        .toLowerCase();
}

new Awesomplete(input, {

    filter(text, value) {

        const safeText =
            normalizeInput(text);

        const safeValue =
            normalizeInput(value);

        return safeText.includes(safeValue);
    },

    item(text, value) {

        const li =
            document.createElement("li");

        li.textContent = text;

        return li;
    },

    replace(text) {

        this.input.value =
            normalizeInput(text);
    }
});