Авторский проект IT-специалиста Олега Барабанова Персональные публикации на тему IT и не только…

Опубликовал свою версию полифила для поддержки псевдокласса :has() в методах DOM Selectors API

Под самый конец месяца выложил на GitHub свою версию полифила, для реализации CSS псевдокласса :has() в методах DOM Selectors API .querySelector(), .querySelectorAll(), .matches(), .closest(), причем как в браузерах, так и на сервере в JSDOM.

Репозиторий проекта размещен тут: https://github.com/olegbarabanov/polyfill-pseudoclass-has

Детали установки полифила описаны в прилагаемом README.md

Проект написан на TypeScript в соответствии с Google TypeScript Style Guide, а для тестирования используется Jest вместе с библиотекой JSDOM.

Далее я хочу немного рассказать про разработку этого полифила, а также с какими трудностями мне пришлось столкнуться,

Предыстория

Некоторое время назад я делал краткий обзор на CSS псевдокласс :has(), который сразу с момента появления его нативной реализаций в браузерах, стал интересовать меня с целью применения на практике в проектах.

Понятно что :has() на текущий момент поддерживается в небольшом количестве браузеров и если вы хотите его применять в относительно старых браузерах, то c одной стороны можно попытаться заручиться поддержкой полифилов, как например этот плагин для PostCSS добавляющий поддержку :has(), но понимая сложности работы с :has(), очевидно что ограничений у этих полифилов будет очень много.

В моем же случае встала проблема отсутствия более менее работающего в соответствии со стандартом полифила для работы :has() в методах DOM Selectors API (.querySelector(), .querySelectorAll() и пр.). Те что находил, только частично поддерживали :has() , при этом не было смысла дорабатывать их, поскольку всеравно пришлось бы все переписывать.

В итоге пришлось начать писать свой полифил и скажу вам честно - на деле это оказалось очень непросто.

Краткий принцип работы полифила

Полифил запускается только в случае наличие в селекторе псевдокласса :has(), а в ином случае поиск работает напрямую с нативными методами.

При обработке селектора с :has() логически выделяются основной глобальный селектор и его локальные подселекторы, находящиеся внутри :has(...), а сама обработка запроса выполняется в следующем порядке:

  1. Вначале копируем всё DOM-дерево;
  2. В копии DOM-дерева выполняется поиск с учетом каждого локального подселектора и все найденные элементы помечаются отдельным атрибутом.
  3. Заменяем в глобальном селекторе все выражение :has() атрибутом-модификатором, т.е. упрощенно говоря, выражение наподобие body > section:has(div) select:has(~ label):has(option) преобразуется в body > section[temp-attr-1] > select[temp-attr-2][temp-attr-3].
  4. Затем в копии DOM-дерева, где были добавлены атрибуты найденным элементам, производится совокупный поиск по трансформированному селектору, по итогу которого мы получим коллекцию элементов, принадлежащих копии DOM-дерева.
  5. Далее вычисляем у каждого найденного элемента его уникальное положение в копии DOM-дерева и по этому положению находим нужный нам элемент уже в оригинальном DOM-дереве.
  6. Возвращаем список найденных элементов оригинального DOM-дерева.

Данной алгоритм ужасно неоптимален и медлителен, но к сожалению на то есть немало причин, о которых я хочу далее рассказать.

Проблема создания полифила для существующего API

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

Методы .querySelectorAll() и .querySelector() в реализациях Document, DocumentFragment, Element имеют некоторые отличительные особенности

Примером проблемной ситуации является применение в селекторе псевдокласса :scope. Согласно стандарту, :scope является ссылкой на текущий элемент, но если мы его используем относительно Document, то согласно спецификации :scope ссылается к корневому элементу, например к HTMLHtmlElement (тег <html>) в случае с HTML документом.

// instanceof Document
document.querySelector(':scope'); // return HTMLHtmlElement

// instanceof Element
document.body.querySelector(':scope'); // return HTMLBodyElement

// instanceof DocumentFragment
customDocumentFragment.querySelector(':scope'); // return null

К сожалению, выяснился неприятный факт, что в JSDOM конструкция document.querySelector(':scope'); возвращает null вместо корневого элемента HTMLHtmlElement. Решив не оставлять это без внимания, пришлось раскопаться уже в самом JSDOM и выяснить, что за поиск по селекторам там отвечает библиотека NWSAPI и именно в ней присутствовала проблема, что :scope относительно Document не ссылается к корневому элементу. В итоге я оставил там issue по данной проблеме и отправил им небольшой pull request (на рассмотрении) с решением проблемы.

Проблема в отличиях серверного и браузерного глобального окружения

Отдельной проблемой встала одновременная поддержка и браузерного и серверного окружения ( в виде JSDOM). Проблема в том, что при работе на сервере, мы не можем в коде использовать глобальные Window, Element, Document, DocumentFragment и пр.. что есть в глобальном окружении браузера. Соответственно в полифиле пришлось работать только в контексте передаваемых значений и избегалось обращение к любым глобальным сущностям.

Проблема создания собственных статичных коллекций NodeList, которые возвращает querySelectorAll

Особую проблему вызвал тот факт, что querySelectorAll возвращает не просто массив, а специальную коллекцию NodeList, которая еще и статичная в его случае.

Проблема с NodeList состоит в том, что вы не можете создать свою собственную коллекцию NodeList, а обходные пути расширения NodeList приводят к определенным отличиям в работе от оригинала, что в случае нашего полифила недопустимо, поскольку другие сторонние модули могут для своих нужд проверять возвращаемое значение .querySelectorAll() на соответствие типу NodeList или как-то взаимодействовать с прототипом NodeList.

Чтобы сконвертировать полученный массив элементов в статичную коллекцию NodeList, которая может создаваться только через querySelectorAll(), приходится для каждого элемента в массиве составлять уникальный селектор вида :scope > :nth-child(2) > :nth-child(3) > ... и список этих уникальных селекторов передать в querySelectorAll. Это очень ужасное решение, но в случае с полифилом нет других вариантов, поскольку в приоритете полная обратная совместимость и необходимо возвращать идентичный тип данных, что и в оригинале.

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

В связи с этим - приходится делать пустой корневой элемент, в него вставлять содержимое фрагмента, делать поиск в корневом элементе, а затем возвращать обратно во фрагмент все содержимое корневого элемента. К сожалению, на эту процедуру извлечения и вставки элементов может отреагировать MutationObserver, о проблемах с которым как-раз описано далее.

Проблема с возможными вызовами MutationObserver при любых изменения в DOM

В полифиле при поиске элементов могут вноситься временные изменения в DOM элементы, что может приводить к нежелательным вызовам сторонних MutationObserver или любых других триггеров, реагирующих на изменения в DOM.

Для решения этой проблемы, приходится делать полную копию всего DOM-дерева, делать поиск элементов в нем, вычислить уникальные селекторы для найденных элементов и применить массив найденных уникальных селекторов уже к оригинальному DOM-дереву. Т.к. мы работаем с копией, это никак не затрагивает MutationObserver в оригинальной DOM-структуре.

Несколько неприятно еще и то, что копировать приходится весь документ даже в случае использования querySelector от отдельного элемента, поскольку в селекторе учитываются еще и внешние элементы, т.е. поиск ведется от корня всего DOM-дерева даже в случае применения querySelector по отношению к одиночному элементу, как например:

// вернет все элементы внутри HTMLBodyElement
document.body.querySelectorAll('html *');

А в случае с внешним :has(), даже такое:

/*
.querySelectorAll вернет все элементы внутри HTMLBodyElement (<body>)
в том случае, если в документе есть тег <title>
*/
document.body.querySelectorAll('html:has(head title) *');

В итоге, поскольку приходится каждый раз копировать весь документ, это к сожалению также очень негативно влияет на скорость работы полифила.

Проблема сборки полифила в виде npm-пакета

Отдельную проблему составило организовать корректную итоговую сборку полифила. Суть в том, что полифил должен работать как в новых так и старых браузерных и серверных окружениях.

Т.к. это может работать в старом окружении, итоговый полифил должен иметь расширение .js, а не .mjs и .cjs, поскольку не все сервера отдают корректные заголовки для этих расширений.

Пришлось делать несколько сборок:

И ладно бы на этом проблемы закончились, но ведь исходный код проекта написан на TypeScript, а полифил также должен нормально работать и в JavaScript среде. И можно конечно сказать, просто скомпилируй TS в JS, но и тут все как обычно оказалось не без подводных камней...

В проекте по умолчанию используются импорты без расширения файлов как например import {SelectorHandler} from './selector-handler'; . Это вполне типичная ситуация для TypeScript, но в тоже время это не будет работать в случае с ESM-модулями в JavaScript, поскольку там расширение файлов является обязательным. Есть несколько путей решения подобной проблемы:

Поскольку мне и так требовалось собирать проекты в отдельные бандлы для ESM, UMD и CommonJS для минимификации загрузки в браузерах, то я выбрал второй вариант и начал делать сборки с помощью Rollup.

После создания отдельных бандлов всплыла еще еще одна проблема, но уже при их использовании в TypeScript. Суть в том, что TypeScript при компиляции в JS делает декларации .d.ts для каждого файла по отдельности. И если мы потом хотим импортировать тот же ESM бандл в проект на TypeScript, то нам необходимо в единый файл собрать и все .d.ts файлы деклараций, чего бандлеры по умолчанию не делают. В таком случае нам помогут только различные плагины к бандлерам.

В итоге повозившись вначале с Rollup, я в итоге перешел на Vite (внутри которого тот же Rollup) с помощью которого я делаю сборки отдельных модулей, а с помощью плагина vite-plugin-dts, все файлы деклараций .d.ts собираются также в один файл.

Проблема краткого и логичного именования проекта

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

Проблема в том, что полифил делается для псевдокласса :has() и слово "has" является так же часто употребимой в речи формой глагола "have", а в имени репозитория нельзя использовать спецсимволы, кроме "-" и "_" и фактически никак не обозначить, что "has" является именно ключевым словом, а не простым глаголом. Поэтому изначально репозиторий назывался "DOM-pseudoclass-HAS-polyfill", где пытался обозначить ключевое слово "has" хотя бы большими буквами.

Но когда уже выкладывал готовый пакет в NPM, столкнулся с проблемой того, что там запрещены большие буквы и соответственно оставался вариант только с "dom-pseudoclass-has-polyfill", а такое название очень неочевидно, потому что если на него посмотреть буквально, то встает вопрос: "Какой еще dom-псевдокласс имеет полифил?". В общем получился бред.

Посмотрев в итоге на весь этот бардак с названием, не придумал ничего лучше, как назвать его просто как "polyfill-pseudoclass-has", т.е. чтобы "has" обязательно был в самом конце.

Поскольку в NPM нельзя просто так переименовать опубликованный пакет, было принято решение удалить его и опубликовать с новым именем. Как вы возможно знаете, в NPM удалить проект можно только в случае если ваш проект соответствует нескольким критериям или с момента первой публикации прошло не 72 часов. В моем случае, прошло всего пару часов и поэтому я спокойно удалил проект и заново опубликовал его под новым именем. Заодно также переименовал и сам репозиторий.

Заключение

Разработка полифила оказалось очень интересным опытом разработки и публикации программного пакета, который должен уметь работать и в браузерном окружении, должен иметь возможность загружаться и через оператор import (ESM) и через require() (UMD и CommonJS) и через тег <script src> (UMD), работать как с JS так и с TS, а также не вносить сторонних эффектов в окружение, где этот полифил используется.

К сожалению, ценой всего этого, является очень медленная работа полифила и хоть и есть задел для оптимизаций, но если честно, жизненный цикл этого полифила вероятно закончится уже тогда, когда нативная реализация :has() будет доступна повсеместно. А до тех пор, планирую дорабатывать его, т.к. там еще много работы, особенно в части покрытия кода unit-тестами, комментарии привести в порядок, добавить e2e-тесты для тестирования в браузерах и многое-многое другое, так что работа с проектом продолжается.