Аккуратно внедряем Emoji, не ломая все остальное Image source

Аккуратно внедряем Emoji, не ломая все остальное

Emoji стали неотъемлемой частью нашей культуры. И я их активно использую в своих статьях. Ранее я использовал плагин для jekyll jemoji, но набор смайликов был весьма скудный, поэтому я отказался от него в пользу js-emoji. Но и тут не было все гладко…

Проблема #

Сначала эта библиотека загружалась синхронно со всей страницей. Это значит то, что индикатор загрузки не исчезнет, пока смайлики не отрисуются. Для рендера использовался простейший скрипт, взятый из официального репозитория, который выглядел следующим образом:

$(function () {
    var emoji = new EmojiConvertor();
    emoji.img_sets.apple.path = '/assets/images/emoji/';
    emoji.include_title = true;
    emoji.include_text = true;
    emoji.replace_mode = 'css';
    emoji.allow_native = true;

    emoji.addAliases({
        'fox': '1f98a',
    });

    $('.js-emojify').each(function () {
        $(this).find(':not(script)').html(function (i, oldHtml) {
            return emoji.replace_unified(emoji.replace_emoticons(oldHtml));
        });
    });
});

Все понятно и очевидно: вытягиваем весь html, вставляем туда иконки, заменяем старый html новым. И все это работало хорошо… до поры. Проблемы начались тогда, когда я решил, что смайлики должны отрисовываться после загрузки страницы, а не во время. Это значительно ускоряло отображение страниц, но создавало некоторые проблемы.

Самая основная выглядела следующим образом:

  1. Загружалась страница;
  2. Отрисовывался ReactJS виджет;
  3. Происходил стандартный набор действий:
    • вытягивался весь html;
    • вставлялись смайлы;
    • заменялся старый html новым;

И после этого всего виджеты переставали работать. Почему? Потому что мы заменили старый html совершенно новым, после чего все обработчики форм переставали работать совсем. Хотя визуально для нас ничего не поменялось, с точки зрения браузера это совсем другие эллементы.

Искусственно добиться этого эффекта можно следующим образом:

  • Перейти в этот пост;
  • Спуститься до формы и проверить, что все работает;
  • Открыть “Инструменты разработчика”;
  • Выполнить:
      $('#social-post-generator-container').html(
          $('#social-post-generator-container').html()
      )
    

    Таким образом мы заменили оригинальный html новым

  • Убедиться, что ничего не работает.

Еще одна проблема была в том, что он заменял emoji даже в теге script. Но это уже не так критично и легко решается.

Решение #

Предположим, что у нас уже есть функция emojificator, которая добавляет эмотиконы в текст. Работает она ровно также, как в указанном выше куске кода.

Чего нам нужно добиться:

  1. Обновляться должен только текст. Существующие html объекты должны оставаться прежними.
  2. Не трогать script теги.
  3. Не трогать textarea и прочие “input” элементы.

Мне потребовалось несколько часов на исследование и реализацию этого дела, но результатом я остался доволен.

Обход всех текстовых нод #

DOM состоит не только из обычных элементов, но и из текста, комментариев и п.р. Наша задача состоит в том, чтобы рекурсивно пройтись по всем текстовым нодам и внедрить туда смайлы.

Выглядит это так:

const blacklist = [
    HTMLScriptElement,
    HTMLTextAreaElement,
    HTMLInputElement,
    HTMLSelectElement,
    HTMLButtonElement,
];

const skip = (node) => {
    for (let cls of blacklist) {
        if (node instanceof cls) {
            return true;
        }
    }

    return false;
}

const recursiveEmojification = (node) => {
    if (node.nodeType !== Node.TEXT_NODE) {
        if (node.nodeType === Node.ELEMENT_NODE && !skip(node)) {
            node.childNodes.forEach(_ => recursiveEmojification(_))
        }

        return;
    }
    
    // Do something with 'node.nodeValue'
}

document
    .querySelectorAll(".js-emojify")
    .forEach(_ => recursiveEmojification(_));

В общем, ничего сложного. Обратите особое внимание на функцию skip. Именно она гарантирует “невредимость” некоторых элементов страницы. А именно теги script, textarea и подобные им.

Внедрение Emoji в текст #

Осталось дело за малым. Или нет? :thinking_face:

Теперь у нас есть все куски текстов, в которых потенциально могут быть emoji. Если нам нужна совместимость только с девайсами Apple, то мы можем заменить все :emoji: смайлы на обычный unicode и на этом успокоиться. Но у меня Линукс. И многих других тоже нет ничего яблочного. Поэтому нужно заменить все текстовые смайлы, на маленькие картинки. Вот тут и начинается самое интересное.

Дело в том, что мы не можем просто взять и, вместо текстовой ноды, вкорячить кусок html кода. Если попробовать сделать node.nodeValue = '<span>Hi</span>', то на выходе будет экранированный html код.

Поэтому этот код не будет работать, как этого хочется:

const emojified = emojificator(node.nodeValue);
// Пропускаем дальнейшие манипуляции, если ничего не изменилось
if (emojified === node.nodeValue) {
    return;
}
node.nodeValue = emojified;

А вот результат:

Кривой вывод Emoji

Так себе… Не то, чего я хотел. Поэтому проделываем следующий финт:

  1. Создаем новый элемент span;
  2. Наполняем его нужным нам html кодом;
  3. Вставляем его в документ после нашего исходного текста;
  4. Удаляем старый текст.

Вот так это выглядит:

let replacement = document.createElement('span'); // (1)
replacement.classList.add('js-emojified') // Для наглядности добавим класс
replacement.innerHTML = emojified; // (2)
node.parentNode.insertBefore(replacement, node); // (3)
node.parentNode.removeChild(node); // (4)

Да, мы создали еще один элемент. Но это не должно быть проблемой, так как чистый span ни на что не влияет.

Результат #

На выходе получилось достаточно аккуратное решение. Хоть и с небольшими хаками.

Соберем все воедино
const blacklist = [
    HTMLScriptElement,
    HTMLTextAreaElement,
    HTMLInputElement,
    HTMLSelectElement,
    HTMLButtonElement,
];

const skip = (node) => {
    for (let cls of blacklist) {
        if (node instanceof cls) {
            return true;
        }
    }

    return false;
}

const recursiveEmojification = (node) => {
    if (node.nodeType !== Node.TEXT_NODE) {
        if (node.nodeType === Node.ELEMENT_NODE && !skip(node)) {
            node.childNodes.forEach(_ => recursiveEmojification(_))
        }

        return;
    }
    const emojified = emojificator(node.nodeValue);
    if (emojified === node.nodeValue) {
        return;
    }

    let replacement = document.createElement('span');
    replacement.classList.add('js-emojified')
    replacement.innerHTML = emojified;
    node.parentNode.insertBefore(replacement, node);
    node.parentNode.removeChild(node);
}

document
    .querySelectorAll(".js-emojify")
    .forEach(_ => recursiveEmojification(_));

Не могу сказать, что смайлы для меня настолько важны, чтобы я стал так заморачиваться. Скорее то, что это был определенный вызов, интересная задача на исследование. Я узнал много нового и стал лучше понимать работу html и js в браузере. И сделал свой сайт чуточку лучше :blush:

Спасибо за внимание. Надеюсь, было интересно :fox: