Опубликовал свою версию полифила для поддержки псевдокласса :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(...)
, а сама обработка запроса выполняется в следующем порядке:
- Вначале копируем всё DOM-дерево;
- В копии DOM-дерева выполняется поиск с учетом каждого локального подселектора и все найденные элементы помечаются отдельным атрибутом.
- Заменяем в глобальном селекторе все выражение
:has()
атрибутом-модификатором, т.е. упрощенно говоря, выражение наподобиеbody > section:has(div) select:has(~ label):has(option)
преобразуется вbody > section[temp-attr-1] > select[temp-attr-2][temp-attr-3]
. - Затем в копии DOM-дерева, где были добавлены атрибуты найденным элементам, производится совокупный поиск по трансформированному селектору, по итогу которого мы получим коллекцию элементов, принадлежащих копии DOM-дерева.
- Далее вычисляем у каждого найденного элемента его уникальное положение в копии DOM-дерева и по этому положению находим нужный нам элемент уже в оригинальном DOM-дереве.
- Возвращаем список найденных элементов оригинального 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, поскольку не все сервера отдают корректные заголовки для этих расширений.
Пришлось делать несколько сборок:
- ESM-модуль с расширением .js для браузерного и серверного окружения в которых есть поддержка ESM-модулей.
- UMD-модуль с расширением .js - необходим для возможности загрузить полифил в браузере через тег
<script src="">
или как CommonJS модуль черезrequire()
; - CommonJS модуль с расширением .cjs - необходим для работы с серверным окружением, где по умолчанию .js файлы считаются ESM-модулями но необходимо использовать CommonJS модули.
И ладно бы на этом проблемы закончились, но ведь исходный код проекта написан на TypeScript, а полифил также должен нормально работать и в JavaScript среде. И можно конечно сказать, просто скомпилируй TS в JS, но и тут все как обычно оказалось не без подводных камней...
В проекте по умолчанию используются импорты без расширения файлов как например import {SelectorHandler} from './selector-handler';
. Это вполне типичная ситуация для TypeScript, но в тоже время это не будет работать в случае с ESM-модулями в JavaScript, поскольку там расширение файлов является обязательным. Есть несколько путей решения подобной проблемы:
- Просто добавить в импорты расширение файла .js, что по мне выглядит уродливо внутри TypeScript-файла, а также может привести к проблемам с ошибками в IDE, линтере или других анализаторах кода, т.к. на момент анализа импортируемого файла c расширением .js нет.
- После компиляции, собирать файлы бандлерами (Webpack, Rollup, Vite и пр.), поскольку они подобные импорты вполне обрабатывают.
Поскольку мне и так требовалось собирать проекты в отдельные бандлы для 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-тесты для тестирования в браузерах и многое-многое другое, так что работа с проектом продолжается.