Русский
Русский
English
Статистика
Реклама

$mol

Recovery mode mol_func_sandbox взломай меня, если сможешь!.

22.06.2020 12:13:23 | Автор: admin

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



https://sandbox.js.hyoo.ru/


А далее я расскажу как работает песочница и подкину несколько идей для взлома.


Как это работает


Итак, первым делом нам надо спрятать все глобальные переменные. Сделать это просто достаточно замаскировать их одноимёнными локальными переменными:


for( let name in window ) {    context_default[ name ] = undefined}

Однако, многие свойства (например, window.constructor) являются неитерируеммыми. Поэтому необходимо итерироваться по всем пропертям объекта:


for( let name of Object.getOwnPropertyNames( window ) ) {    context_default[ name ] = undefined}

Но Object.getOwnPropertyNames возвращает лишь собственные свойства объекта, игнорируя всё, что он наследует от прототипа. Поэтому надо аналогичным образом пройтись по всей цепочке прототипов и собрать имена всех возможных свойств глобального объекта:


function clean( obj : object ) {    for( let name of Object.getOwnPropertyNames( obj ) ) {        context_default[ name ] = undefined    }    const proto = Object.getPrototypeOf( obj )    if( proto ) clean( proto )}clean( win )

И всё бы хорошо, да только этот код падает, ибо в строгом режиме нельзя объявлять локальную переменную с именем eval:


'use strict'var eval // SyntaxError: Unexpected eval or arguments in strict mode

А вот использовать пожалуйста:


'use strict'eval('document.cookie') // password=P@zzW0rd

Ну, ничего, благо глобальный eval можно просто удалить:


'use strict'delete window.evaleval('document.cookie') // ReferenceError: eval is not defined

А для надёжности лучше пройтись по всем собственным свойствам и всё поудалять:


for( const key of Object.getOwnPropertyNames( window ) ) delete window[ key ]

Зачем нам вообще строгий режим? Да потому что без него можно использовать arguments.callee.caller чтобы получить любую функцию выше по стеку и натворить дел:


function unsafe(){ console.log( arguments.callee.caller ) }function safe(){ unsafe() }safe() //  safe(){ unsafe() }

Кроме того, в нестрогом режиме легко получить глобальный неймспейс просто взяв this при вызове функции не как метода:


function get_global() { return this }get_global() // window

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


var Function = ( ()=>{} ).constructorvar hack = new Function( 'return document.cookie' )hack() // password=P@zzW0rd

Что делать? Удаляем небезопасные конструкторы:


Object.defineProperty( Function.prototype , 'constructor' , { value : undefined } )

Этого было бы достаточно для какого-то древнего яваскрипта, но сейчас у нас есть разные виды функций и каждый вариант следует обезопасить:


var Function = Function || ( function() {} ).constructorvar AsyncFunction = AsyncFunction || ( async function() {} ).constructorvar GeneratorFunction = GeneratorFunction || ( function*() {} ).constructor

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


for( const Class of [    String , Number , BigInt , Boolean , Array , Object , Promise , Symbol , RegExp ,     Error , RangeError , ReferenceError , SyntaxError , TypeError ,    Function , AsyncFunction , GeneratorFunction ,] ) {    Object.freeze( Class )    Object.freeze( Class.prototype )}

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


Особенности воркера:


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

Особенности фрейма:


  • Можно передавать во фрейм любые объекты и функции, но по ссылкам можно случайно предоставить доступ к тому, к чему не стоило бы.
  • Бесконечный цикл в песочнице вешает всё приложение.
  • Вся коммуникация строго синхронная.

Реализация RPC для воркера дело не хитрое, но его ограничения не всегда приемлемы. Поэтому рассмотрим вариант с фреймом.


Если в песочницу передать какой-либо объект из которого по ссылкам доступен хоть какой-то изменяемый объект, то из песочницы можно будет его поменять и сломать наше приложение:


numbers.toString = ()=> { throw 'lol' }

Но это ещё цветочки. Передача в во фрейм любой функции тут же откроет кулхацкеру настеж все двери:


var Function = random.constructorvar hack = new Function( 'return document.cookie' )hack() // password=P@zzW0rd

Ну ничего, прокси спешит на помощь:


const safe_derived = ( val : any ) : any => {    const proxy = new Proxy( val , {        get( val , field : any ) {            return safe_value( val[field] )        },        set() { return false },        defineProperty() { return false },        deleteProperty() { return false },        preventExtensions() { return false },        apply( val , host , args ) {            return safe_value( val.call( host , ... args ) )        },        construct( val , args ) {            return safe_value( new val( ... args ) )        },    }    return proxy})

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


config.__proto__.__defineGetter__( 'toString' , ()=> ()=> 'rofl' )({}).toString() // rofl

Поэтому все значения принудительно прогоняем через промежуточную сериализацию в JSON:


const SafeJSON = frame.contentWindow.JSONconst safe_value = ( val : any ) : any => {    const str = JSON.stringify( val )    if( !str ) return str    val = SafeJSON.parse( str )    return val}

Таким образом из песочницы будут доступны только объекты и функции которые мы передали туда явно. Но порой нужно и неявно передавать некоторые объекты. Для них заведём whitelist в который будем автоматически добавлять все объекты, что заворачиваются в безопасный прокси, проходят обезвреживание или приходят из песочницы:


const whitelist = new WeakSetconst safe_derived = ( val : any ) : any => {    const proxy = ...    whitelist.add( proxy )    return proxy}const safe_value = ( val : any ) : any => {    if( whitelist.has( val ) ) return val    const str = JSON.stringify( val )    if( !str ) return str    val = SafeJSON.parse( str )    whitelist.add( val )    return val}

И на случай, если разработчик по невнимательности предоставит доступ к какой-либо функции позволяющей интерпретировать строку как код, заведём ещё blacklist, с перечислением того, что в песочницу нельзя передавать ни при каких обстоятельствах:


const blacklist = new Set([    ( function() {} ).constructor ,    ( async function() {} ).constructor ,    ( function*() {} ).constructor ,    eval ,    setTimeout ,    setInterval ,])

В результате у нас получилась довольно безопасная песочница со следующими характеристиками:


  • Можно исполнять произвольный JS код.
  • Код исполняется синхронно и не требует делать все функции выше по стеку асинхронными.
  • Нельзя прочитать данные к которым не предоставлен доступ.
  • Нельзя изменить поведение использующего песочницу приложения.
  • Нельзя сломать работоспособность собственно песочницы.
  • Можно подвесить приложение бесконечным циклом.

Если есть идеи как это можно улучшить или хотите вступить в ТехноГильдию пишите телеграммы.


Ссылочки


  • https://sandbox.js.hyoo.ru/ онлайн песочница с примерами потенциально опасного кода.
  • https://calc.hyoo.ru/ электронная таблица, позволяющая использовать в ячейках произвольный JS код.
  • https://t.me/mol_news канал с новостями об экосистеме $mol и открытых проектах ТехноГильдии.
Подробнее..

Автоматическая виртуализация рендеринга произвольной вёрстки

14.01.2021 20:16:39 | Автор: admin

Здравствуйте, меня зовут Дмитрий Карловский и я.. прибыл к вам из недалёкого будущего. Недалёкого, потому что там уже всё и все тормозят. Писец подкрался к нам незаметно: сначала перестали расти мощности компьютеров, потом пропускная способность сетей. А пользователи они продолжали генерировать контент как не в себя. В итоге, за считанные годы UX интерфейсов деградировал настолько, что ими стало невозможно пользоваться и многие пользователи поспешили перейти на облачный стриминг своих браузеров, которые работают на суперкомпьютерах, принадлежащих корпорациям, которые не дают людям устанавливать на них блокировщики рекламы. Поэтому я пришёл к вам именно сейчас, в этот момент, когда проблема уже заметна, но ещё можно всё исправить, пока не стало слишком поздно.

Это - текстовая расшифровка выступления на HolyJS'20 Moscow. Вы можете либо посмотреть видео запись, либо открыть в интерфейсе проведения презентаций, либо читать как статью Короче, где угодно лучше, чем на Хабре, ибо новый редактор плохо импортирует статьи..

Тяжёлое прошлое: Огромные списки задач

Сперва расскажу немного о своём опыте. Мой релевантный опыт начался с разработки огромных списков задач, состоящих из нескольких десятков тысяч задач. Они образовывали иерархию и имели довольно причудливый дизайн, что осложняло применение virtal scroll. А таких списков задач на экране могло быть десятки разом. И всё это в реальном времени обновлялось, дрегендропилось и анимировалось.

Рекламная пауза: Богатый редактор

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

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

Альтернативная линия времени: $mol

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

mol.hyoo.ru

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

Типичный цикл разработки

Типичный цикл разработки выглядит так:

  • Написали код.

  • Проверили в тепличных условиях.

  • Пришли пользователи и всё заспамили.

  • Оптимизировали.

  • Пользователи всё не унимаются.

Мы уже взрослые ребята мы не пишем тяп-ляп и в продакшен. Теперь мы пишем код, проверяем его на тестовых данных, и если все хорошо, выкладываем в прод. Но тут приходят пользователи генерируют кучу контента и всё начинает тормозить. Тогда мы закатываем рукава и начинаем оптимизировать. Но приходят пользователи и генерят ещё больше контента и тормоза возвращаются вновь.

Наивный рендеринг: Скорость загрузки и Отзывчивость

Этот порочный круг приводит к таким вот последствиям:

Сейчас вы видите timeline загрузки и рендера двух с половиной тысяч комментариев. Скрипту требуется 50 секунд на формирование DOM, после чего ещё 5 секунд нужно браузеру чтобы рассчитать стили, лейаут и дерево слоёв.

И это не картинка из будущего. Это самое натуральное настоящее. Замечу, что это хоть и мобильная версия, но открытая на ноуте. На мобилке все куда печальнее.

Наивный рендеринг: Потребление памяти

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

На моем телефоне (потряхивает своим тапком) её меньше 3 гигов. И я думаю не надо объяснять откроется ли она у меня вообще. Тут мы плавно переходим к следующему риску..

Наивный рендеринг: Риск неработоспособности

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

  • Не влезли по памяти - приложение закрывается.

  • Обрыв соединения - страница обрывается.

  • Браузер может заглючить на больших объёмах.

Наивный рендеринг: Резюме

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

  • Медленная загрузка.

  • Плохая отзывчивость.

  • Высокое потребление памяти.

  • Риск неработоспособности.

Первый подопытный: Статья на Хабре

Недавно я опубликовал статью, где рассказывал как можно ускорить Хабр раз в десять, отказавшись от SSR.

Вырезаем SSR и ускоряем Хабр в 10 раз

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

https://nin-jin.github.io/habrcomment/#article=423889

Второй подопытный: Ченьжлог на GitLab

На сей раз мы разберём новый кейс - страница коммита на GitLab.

Я просто взял второй попавшийся коммит в первом попавшемся репозитории. Это всего порядка 5 тысяч строк в 100 файлах. Казалось бы - совсем не много. Однако, грузится всё довольно долго. Сначала 10 секунд ждём ответа сервера, потом ещё пол минуты любуемся мёртвыми кусками кода без подсветки синтаксиса. Короче, приходится ждать почти минуту, прежде чем приложением становится возможно пользоваться.

Перенос рендеринга HTML на сервер

Что ж, давайте откроем таймлайн и увидим там следующую картину.

Пол минуты HTML выдаётся браузеру. Обратите внимание что она вся фиолетовая. Это значит что каждый раз, когда браузер получает очередной чанк HTML и подкрепляет его в документ, то пересчитывает стили, лейаут и дерево слоёв. А это весьма не быстрый процесс. И чем больше DOM, тем медленнее он становится.

Наконец, подключаются скрипты и сравнительно быстро добавляют к коду подсветку синтаксиса. Но чтобы переварить такое массовое изменение огромного дерева, браузеру требуется потом ещё целых 3 секунды непрерывной работы.

Страдания Ильи Климова по GitLab-у

Ну да ладно со скоростью загрузки. Один раз подождать ведь не проблема? Как бы не так! Браузер делает пересчёт стилей, лейаута и слоёв чуть ли не на каждый чих. И если вы попробуете поработать со страницей, то заметите, что на наведение указателя она реагирует весьма не рьяно, а вводить текст с лютыми задержками крайне не комфортно.

Год назад Илья Климов как раз рассказывал в своём выступлении страшные истории про работу как раз над этой страницей. Например, прикручивание спинера заняло 3 месяца работы не самого дешёвого разработчика. А сворачивание небольшого файла вешало вкладку на 10 секунд. Но это, правда, уже оптимизировали - теперь на это требуется всего лишь пара секунд!

Причина этих ужастиков в том, что изначально была выбрана такая себе архитектура с такими себе технологиями, и писалось много типового кода пяткой левой ноги, а теперь вот нужно вкладывать кучу денег в Фонд Оплаты Труда, чтобы делать простейшие вещи долго и без существенного профита.

На мой вопрос "что ж вы на $mol всё это не перепишите, чтобы у вас всё летало?" Илья ответил, что не знает как его продать в компании. Что ж, надеюсь дальнейший разбор техник виртуализации поможет ему в этом нелёгком деле.

Оптимизации вёрстки

Первое что приходит на ум - а давайте упростим вёрстку и DOM уменьшится. В качестве примера - кейс от Альфа-банка, который я разбирал в своей статье об истории $mol.

$mol: 4 года спустя

Там был компонент вывода денежных сумм, который рендерился в 8 DOM элементов вместо 3.

<div class="amount">    <h3 class="heading ...">        <span>            <span class="amount__major">1233</span>            <div class="amount__minor-container">                <span class="amount__separator">,</span>                <span class="amount__minor">43</span>            </div>            <span class="amount__currency"></span>        </span>    </h3></div>
<h3 class="amount">    <span class="amount__major">1233</span>    <span class="amount__minor">,43</span></h3>

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

Если так поработать и над остальными компонентами, думаю можно уменьшить размер DOM раза в 2. Однако

Достоинства оптимизации вёрстки

Важно понимать, что асимптотика таким образом не меняется. Допустим страница грузилась 30 секунд. Вы оптимизировали, и она сала грузиться 10 секунд. Но пользователи сгенерируют в 3 раза больше контента и страница снова грузится 30 секунд. А оптимизировать вёрстку уже не куда.

  • Кратное ускорение

  • Асимптотика не меняется

Короче, годится эта техника лишь как временная мера, пока вы прикручиваете что-то по серьёзней.

Прикладная оптимизация

Можно пошаманить над вашим прикладным кодом - кодом приложения, кодом ваших компонент. Тут есть несколько вариантов с разными ограничениями..

  • Пагинация

  • Экспандеры

  • Бесконечный скролл

  • Виртуальный скролл

Прикладная оптимизация: Пагинация

Первое, что приходит на ум - это пагинация.

Не буду рассказывать что это такое - все и так с ней прекрасно знакомы. Поэтому сразу к достоинствам

Достоинства пагинации

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

Бывает, что элемент перескакивает между страницами при переключении между ними. Это позволяет, например, ещё раз прочитать один и тот же комментарий, чтобы лучше его понять. Или наоборот, пропустить незамеченным - мало ли там какой-то негатив. А так он сохранит душевное равновесие.

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

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

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

  • Много кликать

  • Ожидание загрузки каждой страницы

  • Теряется контекст

  • Элементы скачут между страницами

  • Вероятность пропустить элемент

  • Применимо лишь для плоских списков

  • Большой элемент возвращает тормоза

  • Слепые вас ненавидят

  • Работает быстрее, чем всё скопом рендерить

В общем, думаю вы уже поняли какое тёплое у меня отношение к пагинации. Я даже отдельную статью ей посвятил..

Популярные анти паттерны: пагинация

Прикладная оптимизация: Экспандеры

Можно выводить и на одной странице, но свернув каждый элемент под спойлером, а показывать его содержимое уже по клику.

https://nin-jin.github.io/my_gitlab/#collapse=true

Достоинства экспандеров

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

  • Очень много кликать

  • Ожидание загрузки каждой ветки

  • Если не закрывать, то снова тормоза

  • Слепые вас проклинают

  • Открывается быстро

  • Применимо не только для плоских списков

Прикладная оптимизация: Бесконечный скролл

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

И сейчас вы видите скриншот Яндекс.Диска. У меня есть там директория состоящая из пяти тысяч файлов. И если открытие её происходит относительно быстро, то, чтобы домотать до самого низа, требуется 3 с лишним минуты реального времени. Всё потому, что по мере скролла вниз, DOM становится всё больше и больше, от чего добавление очередного куска данных становится всё медленнее. В итоге, добавление последнего куска, например, занимает уже несколько секунд.

Достоинства бесконечного скролла

  • Применимо лишь для плоских списков

  • Ожидание загрузки каждой ветки

  • Увеличение тормозов по мере прокрутки

  • Быстрое появление

Прикладная оптимизация: Виртуальный скролл

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

https://bvaughn.github.io/react-virtualized/#/components/WindowScroller

Достоинства виртуального скролла

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

  • Применимо лишь для плоских списков

  • Размеры элементов должны быть предсказуемы

  • Работает быстро

Прикладная оптимизация: Резюме

  • Ухудшение пользовательского опыта

  • Не решают проблему полностью

  • Ограниченная применимость

  • Полный контроль где какую применять

  • Нужно не забыть

  • Нужно продавить

  • Нужно реализовать

  • Нужно оттестировать

  • Нужно поддерживать

Оптимизация инструментов

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

  • Тайм слайсинг

  • Прогрессивный рендеринг

  • Ленивый рендеринг

  • Виртуальный рендеринг

Оптимизация инструментов: Тайм слайсинг

Можно попробовать так называемый time slicing. На одной из прошлых Holy JS я рассказывал про технику квантования вычислений, где долгое вычисление разбивается на кванты по 16 миллисекунд, и между этими квантами браузер может обрабатывать какие-то события делать плавную анимацию и так далее. То есть вы не блокируете поток на долгое время, получая хорошую отзывчивость..

Квантовая механика вычислений в JS

Достоинства тайм слайсинга

Звучит вроде бы не плохо. Но тут есть такая проблема, как замедление общего времени работы всего вычисления. Например, если просто взять и выполнить вычисление без квантования занимает полсекунды, то если его постоянно прерывать каждые 16 мс, позволяя браузеру делать свои дела, то до завершения может пройти и пара секунд. Для пользователя это может выглядеть как замедление работы. Ну и другой аспект заключается в том что javascript не поддерживает файберы, то есть такие потоки исполнения, которые можно останавливать и возобновлять в любое время. Их приходится эмулировать тем или иным способом, а это всегда костыли, замедление работы и некоторые ограничения на то, как пишется код. В общем, с этим подходом всё не очень хорошо, поэтому в $mol мы и отказались от квантования.

  • Хорошая отзывчивость

  • Замедленность работы

  • Эмуляция файберов в JS

Оптимизация инструментов: Прогрессивный рендеринг

Частным случаем тайм слайсинга является прогрессивный рендеринг, где DOM генерируется и подклеивается по кусочкам. Это позволяет очень быстро да ещё и анимированно показать первый экран и в фоне дорендерить страницу до конца. Такой подход реализован, например, во фреймворке Catberry..

catberry.github.io

Достоинства прогрессивного рендеринга

В $mol мы тоже экспериментировали с подобным подходом, но в итоге отказались, так как время появления даже первого экрана существенно замедлялось, а если страница была по настоящему большой, то всё вообще умирало, ибо по мере роста размеров DOM добавление даже маленького кусочка в него становилось всё медленнее и медленнее.

  • Хорошая отзывчивость в процессе появления

  • Эмуляция файберов в JS

  • На больших объёмах всё встаёт колом

Оптимизация инструментов: Ленивый рендеринг

Вообще, изначально в $mol у нас был так называемый ленивый рендер. Суть его в том, что мы сначала рендерим первый экран и по мере прокрутки добавляем столько контента снизу, чтобы видимая область была гарантированно накрыта. А при прокрутке вверх, наоборот, удаляем снизу тот контент, что гарантированно на экран не попадает, чтобы минимизировать размер DOM для лучшей отзывчивости.

https://nin-jin.github.io/my_gitlab/#lazy=true

Достоинства ленивого рендеринга

Чтобы понимать сколько рендерить элементов, необходимо знать заранее минимальные размеры элементов, которые мы ещё не отрендерили. Но это решаемая проблема. А вот другая - не очень. Хоть появляется первая страница и быстро, но по мере прокрутки вниз увеличивается размер DOM, что неизбежно приводит к снижению отзывчивости. Так что если пользователь домотал до самого низа, то нам всё равно придётся отрендерить весь DOM целиком. То есть проблема отзывчивости решена не полностью.

  • Размеры элементов должны быть предсказуемы

  • Увеличение тормозов по мере прокрутки

  • Быстрое появление

Оптимизация инструментов: Виртуальный рендеринг

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

https://nin-jin.github.io/my_gitlab/

Достоинства виртуального рендеринга

  • Размеры элементов должны быть предсказуемы

  • Работает, наконец, быстро

Оптимизация инструментов: Резюме

На уровне инструментов поддержка сейчас есть лишь в полутора фреймворках: time slicing в React, прогрессивный рендер в catberry и виртуальный рендер в $mol. Зато, такую оптимизацию инструмента достатоxно реализовать один раз? и далее наслаждаться ею во всех своих приложениях не тратя дополнительное время на оптимизацию.

  • Поддерживает полтора фреймворка

  • Работает само по себе

Оптимизации: Резюме

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

Оптимизация

Стоит того?

Вёрстка

Прикладной код

Инструментарий

Виртуализация браузера

Самое простое, что можно сделать, - это воспользоваться браузерной виртуализацией, которая появилась сравнительно недавно. Итак, открываем гитлаб и видим как всё лагает при движении мыши и вводе текста. Теперь произносим пару волшебных слов в девтулзах вокруг стилей "файла"..

content-visibility: auto;contain-intrinsic-size: 1000px;

И всё начинает летать. Устанавливая эти свойства, мы говорим браузеру, что он может пересчитывать layout только для видимой части, а для не видимой он будет брать ту оценку, что мы предоставили. Тут есть, конечно же, ограничение из-за которого иконка добавления нового комментария обрезается, но это решаемая проблема. А вот нерешаемая - это то, что нам всё-равно нужно потратить кучу времени на формирование огромного DOM. То есть таким образом мы можем обеспечить хорошую отзывчивость, но не быстрое открытие. Поэтому мы реализуем виртуализацию именно на стороне яваскрипта.

Логика рендеринга

Для каждого компонента нам нужно получить следующие данные.

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

Далее мы пройдёмся по этим шагам подробнее.

Оценка размеров

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

Можно попробовать брать усреднённое значение. То есть мы отрендерили, например, пять элементов из тысячи, вычислили среднее, и считаем что все остальные тоже в среднем такие же. Но это средняя температура по больнице получается, ибо в общем случае размеры элементов могут отличаться на несколько порядков. Среднее значение годится лишь в частном случае плоских виртуализированных списков, когда нам заранее известно, что все элементы примерно одинакового размера. В общем же случае, для виртуализации на уровне фреймворка это совсем не подходит.

Так что самое оптимальное - это минимальная оценка. То есть сколько элемент точно занимает места. Он может быть больше, но точно не меньше. Это позволяет для заданной видимой области рассчитать сколько нам нужно отрендерить элементов, чтобы точно накопить суммарной высоты больше, чем размер видимой области. Отрендерим чуть больше - ничего страшного. Важно лишь, что видимой области не будет дырок и пользователь не увидит, что мы немного сэкономили на рендеринге.

  • Последняя

  • Усреднённая

  • Минимальная

Типы компонент: Атомарный

Теперь о том как рассчитать размеры. Во первых мы можем просто напрямую задать эти размеры.

Например, допустим все иконки у нас умеют размер 24х24, или любая строка текста имеет минимальную высоту в 24 пикселя.

Типы компонент: Стек наложения

Если компонент составной, то нам скорее всего хочется, чтобы его оценка размера исходила из того, что мы поместили внутри него. Например, возьмём компонент "стек наложения" - этот компонент, который содержит другие компоненты, которые накладываются друг на друга, и его размер определяется максимальными габаритами среди всех вложенных в него элементов.

Соответственно мы просто берём максимальное значение минимального значения его элементов по соответствующим осям.

Типы компонент: Вертикальный список

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

Типы компонент: Горизонтальный список

С горизонтальным списком всё аналогично, но возможно вам ещё потребуется учитывать смещение элементов относительно базовой линии, для вычисления минимальной высоты.

Типы компонент: Горизонтальный список с переносами

А вот с горизонтальным списком с переносами все несколько сложнее. Во первых нам нужно вычислить максимальную ширину нашего контейнера. Самая грубая оценка - это, например, ширина окна. Если у вас есть более точная оценка - замечательно. Но даже грубая оценка - лучше, чем ничего.

Далее мы берём все компоненты все вложенные компоненты и рассчитываем их суммарную ширину. Поделив одно значение на другое, мы получаем количество строк, которые точно будут в нашем списке с переносами.

В данном примере у нас будет два переноса, а следовательно после рендеринга он расползётся не менее чем три строки.

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

К сожалению, полную виртуализацию такого компонента сделать скорее невозможно, потому что не известно в каких местах произойдёт перенос. И если мы будем виртуализировать в обе стороны, то элементы будут скакать по горизонтали. Поэтому для таких компонент применим лишь ленивый рендеринг. То есть снизу добавляем и удаляем элементы, а сверху ничего не меняем.

Типы компонент: Грид и Буклет

Такие компоненты, как гриды и буклеты, - это просто композиция упомянутых ранее.

Грид - это вертикальный список из горизонтальных списков. А буклет - это горизонтальный список из вертикальных списков.

Типы компонент: Резюме

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

  • Атомарный

  • Стек наложения

  • Вертикальный список

  • Горизонтальный список

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

Отслеживание положения: onScroll

Теперь перейдем к вопросу о том, когда производить обновление DOM. Самое очевидное - это реагировать на событие scroll..

document.addEventListener( 'scroll', event => {    // few times per frame}, { capture: true } )

Достоинства отслеживания onScroll

Тут есть 2 проблемы. Во первых, событие возникает слишком часто - его нет смысла обрабатывать чаще, чем 60 раз в секунду. Во вторых, размер и положение элемента относительно вьюпорта зависит от от многих вещей, а не только от позиции скроллбара. Учитывать все это в принципе можно, но очень легко что-то пропустить и не обновить DOM. В результате, пользователь может столкнуться с ситуацией, что он видит лишь половину страницы, но не имеет никакой возможности вызвать её дорендер даже вручную.

  • Слишком часто

  • Изменения DOM

  • Изменения стилей

  • Изменения состояния элементов

  • Изменения состояния браузера

Отслеживание положения: IntersectionObserver

Может показаться что IntersectionObserver решит все наши проблемы, ведь он позволяет детектировать, когда пара элементов начинает или перестаёт визуально пересекаться. Точнее, когда изменяется процент их пересечения. Если растянуть body на размер вьюпорта, то таким образом можно отслеживать процент пересечения любого элемента с видимой областью.

const observer = new IntersectionObserver(    event => {        // calls on change of visibility percentage        // doesn't call when visibility percentage doesn't changed    },    { root: document.body  })observer.observe( watched_element )

Достоинства отслеживания IntersectionObserver

К сожалению, это не работает, когда у нас элемент выходит за границы видимой области и сверху, и снизу. При скролле, процент наложения таким образом может не меняться, а значит и событие вызваться не будет. А нам отслеживать такое перемещение все равно необходимо, чтобы понимать, что теперь нужно рендерить, например, элементы не с 5 по 10, а с 10 по 15.

  • Облом, если степень наложения не меняется

Отслеживание положения: requestAnimationFrame

Самый простой и надёжный способ отслеживать габариты элементов - это опрос по requestAnimationFrame. Обработчик вызывается 60 раз в секунду и первое, что делает, - подписывается себя на следующий фрейм.

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

И только в самом конце производим изменения DOM. Если же читать размеры элементов после любого изменения в DOM, то браузеру придётся тут же произвести относительно медленный пересчёт layout.

function tick() {    requestAnimationFrame( tick )    for( const element of watched_elements ) {        element.getBoundingClientRect()    }    render()   }

Достоинства отслеживания requestAnimationFrame

Недостатком такого подхода является постоянная фоновая загрузка процессора. То есть мы тратим где-то одну-две миллисекунды на каждый фрейм. Это примерно 5% загрузка процессора на моём ноуте, что не очень много для интерфейса с которым в данный момент идёт работа. К счастью requestAnimationFrame не вызывается для фоновых вкладок, так что открытие произвольного числа вкладок не приведёт к неконтролируемому росту потребления ресурсов. Кажется это - разумная плата за простое и надёжное решение, накладывающее минимум ограничений.

  • Постоянная фоновая нагрузка

  • Просто и надёжно

Обновление: Резюме

  • onScroll

  • IntersectionObserver

  • requestAnimationFrame

Скачки при прокрутке

Если мы все это аккуратно реализуем, то у нас получится полностью виртуальный рендеринг произвольный вёрстки. Страница открывается мгновенно. Если мотнуть в другой конец страницы - всё тоже показывается во мгновение ока.

https://nin-jin.github.io/my_gitlab/#anchoring=false

Но тут есть одна проблема. Если скроллить очень медленно, то легко заметить что контент немного скачет. Это происходит из-за несоответствия расчётной высоты элемента и фактической. И чем больше эта разница, тем сильнее будут скачки. Вплоть до невозможности пользоваться скроллом.

Привязка скролла: Предотвращает скачки

Есть классический пример демонстрирующий проблему..

https://codepen.io/chriscoyier/embed/oWgENp?theme-id=dark&default-tab=result

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

Слева же применяется новая браузерная фича, которая привязывает скроллбар к видимой области. И после добавления блоков сверху, браузер автоматически смещает скроллбар вниз, чтобы видимый контент остался на месте.

Привязка скролла: Выбор точки привязки

Чтобы выбрать элемент для привязки скролла, браузер идёт по DOM от корня в поисках первого же элемента, попадающего в видимую область. Заходит в него и снова ищет первый попавшийся. И так далее. При этом совершенно не важно как элементы друг относительно друга расположены визуально. Важно лишь расположение их в доме и попадают ли они в видимую область. Разберём этот процесс на простом примере..

Элемент 1 не видим, поэтому пропускаем. 2 видим, так что заходим в него. Тут и 2.2, и 2.3 видимы, поэтому заходим в первый. Далее ближайший видимый 2.2.2, внутри которых видимых больше нет, так что именно этот элемент становится якорем для привязки скролла. Браузер запоминает смещение верхней границы якорного элемента относительно вьюпорта и старается его сохранить постоянным.

Привязка скролла: Подавление привязки

Тут есть один нюанс - якорным элементом может быть только такой, для которого и для всех предков которого не запрещена привязка скролла. То есть элементы с запрещённой привязкой просто перескакиваются в поиске якорного элемента. А запрещена она может быть либо явно, через свойство overflow-anchor, либо неявно, при изменении css свойств влияющих на размеры и положение элемента.

  • overflow-anchor: none

  • top, left, right, bottom

  • margin, padding

  • Any width or height-related properties

  • transform

Виртуализация: Распорки

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

Виртуализация: Прокрутка вниз

Рассмотрим несколько сценариев работы виртуализации с привязкой скролла. Для примера, возьмём компонент вертикального списка. Изначально видны элементы 3, 4 и 5, а сверху и снизу - распорки.

Зелёная фаза - пользователь скроллит вниз. Как видно третий блок вышел из видимой области, а снизу образовалась дырка. Но пользователь это ещ1 не видит.

Синяя фаза - срабатывает обработчик requestAnimationFrame и мы обновляем DOM: удаляем третий узел и добавляем шестой. Как видно, контент уехал вверх относительно видимой области, но пользователь это ещё не видит.

Красная фаза - мы отдаём управление браузеру, а тот выбирает 4 элемент в качестве якоря и отскролливает чуть-назад. Так что пользователь видит лишь добавившийся снизу шестой элемент.

Виртуализация: Прокрутка вверх

Аналогичная ситуация и со скроллингом вверх. Разве что в качестве якоря выбирается четвёртый элемент, а не третий, так как третий изменил положение в DOM, то есть появился в данном случае.

Виртуализация: Расширение

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

Мы просто добавляем контент и браузер снова смещает скролл так, чтобы контент, что был изначально виден, остался на месте.

Виртуализация: Превышение

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

Виртуализация: Скачок кенгуру

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

Чтобы такого не происходило нужно просекать, что все элементы вышли из видимой области, удалять их все полностью и начинать рендеринг с нуля, отталкиваясь от текущего смещения скролла. Нам нужно пропустить столько элементов, чтобы размер распорки оказался чуть меньше расстояния до верхней границы видимой области. Таким образом первый элемент гарантированно накроет верхнюю её границу. И остаётся лишь дорендерить столько элементов, чтобы накрыть ещё и нижнюю границу.

Привязка скролла в действии

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

https://nin-jin.github.io/my_gitlab/

Привязка скролла: Поддержка

Однако, есть проблема с интернет эксплорером нашего времени, который всё ещё не поддерживает привязку с скролла.

Браузер

overflow-anchor

Chrome

Firefox

Safari

Привязка скролла: Запасный выход

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

const anchoring_support = CSS.supports( 'overflow-anchor:auto' )if( anchoring_support ) {    virtual render} else {    lazy render}

Проблема: Долгая раскладка

Если в следующем примере выключить пару CSS оптимизаций, то можно заметить, что скроллинг как-то подлагивает. Далее мы разберём суть этих оптимизаций.

https://nin-jin.github.io/habrcomment/#article=423889

Минимизация расчётов лейаута

Так как мы всё время меняем DOM внутри скроллящейся области, то это приводит к тому, что браузеру приходится постоянно пересчитывать лейаут, стили и дерево слоёв всего документа. Поэтому для скроллящихся областей имеет смысл задать следующее CSS свойство..

[mol_scroll] {    contain: content;}

Оно говорит браузеру, что содержимое скроллящейся области не влияет на раскладку и стили вне её. Это позволяет существенно уменьшить стоимость браузерных пересчётов.

Прокрутка в отдельном потоке

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

[mol_scroll] > * {    transform: translateZ(0);}

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

Плавная прокрутка (или нет)

Применив все оптимизации мы получаем плавную прокрутку..

https://nin-jin.github.io/habrcomment/#article=423889

..пока используем палец или автопрокрутку. Но стоит нам взяться за колёсико или тачпад, то оказывается, что привязка скролла порой не работает. Оказывается, что если открыть приложение во фрейме другого приложения, которое расположено на другом домене, то привязка скролла в Хроме перестаёт работать. Если для вас этот кейс имеет значение, то стоит его детектировать и фолбечиться до ленивого рендера.

Логика поиска

Скорость и отзывчивость - это, конечно, хорошо, но что насчёт поиска по странице? Он ведь ищет лишь по тому тексту, что есть в DOM, а мы тут рендерим лишь малую его часть. Делать нечего - надо перехватывать Ctrl+F, рисовать свой интерфейс поиска и искать самостоятельно. Для этого компоненты должны реализовывать метод, которому скармливаешь предикат, а он эмитит найденные пути от корня до компонент, соответствующих предикату.

*find_path(    check: ( view : View )=> boolean,    path = [] as View[],): Generator&lt; View[] > {    path = [ ... path, this ]    if( check( view ) ) return yield path    for( const item of this.kids ) {        yield* item.find_path( check, path )    }}
  • Рекурсивно спускаемся по компонентам.

  • Отбираем соответствующие запросу.

  • Рисуем интерфейс перехода между найденным.

Логика прокрутки к компоненту

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

scroll_to_view( view: View ) {    const path = this.find_path( v => v === view ).next().value    this.force_render( new Set( path ) )    view.dom_node.scrollIntoView()}

Логика форсирование рендеринга видимого

Форсирование рендера для каждого типа компонент идёт по своему. Например, для вертикального списка оно выглядит так: ищем элемент списка, который должен быть отрендерен, после чего указываем, что отрендериться должен только он.

force_render( path : Set< View > ): number {    const items = this.rows    const index = items.findIndex( item => path.has( item ) )    if( index >= 0 ) {        this.visible_range = [ index, index + 1 ]        items[ index ].force_render( path )    }    return index}

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

Работающий поиск

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

https://nin-jin.github.io/habrcomment/#article=423889/search=vin

Подсветка найденного реализуется опять же на уровне компонент. Я для этого просто использую компонент $mol_dimmer, которому скармливаешь строку текста, а он сам уже заботится о поддержке поиска и подсветки найденного.

Доступность

Но рано ещё выдыхать, надо подумать и о пользователях скрин ридеров. Дело в том, что читалки читают текст последовательно по строчкам. И читая очередную строку они фокусируют браузер именно на ней. Проблема может оказаться на последней видимой строке. Если следующая строка не видима? и мы её не отрендерим, то читалка просто скажет, что страница закончилась и остановится. Но если мы отрендерим чуть больше, то читалка сфокусирует браузер на последней отрендеренной строке, что приведёт к скроллингу так, чтобы она стала видимой, но тут отработает виртуализация и снизу отрендерится ещё одна строка. Таким образом даже слепые смогут потреблять ваш контент виртуализированно без особых проблем.

https://nin-jin.github.io/my_gitlab/

Можете сами попробовать, просто включив NVDA и закрыв глаза.

Решаемые проблемы виртуализации

Подведём итог, какие проблемы виртуализации нам удалось так или иначе порешать..

  • Оценка будущих размеров.

  • Скачки контента.

  • Тормоза при скроллинге.

  • Прокрутка к элементу.

  • Поиск по странице.

  • Доступность.

Фундаментальные особенности

Тем не менее у нас остался и ряд фундаментальных особенностей, с которыми придётся смириться..

  • Скачки скроллбара при неточной оценке размеров.

  • Scroll Anchoring может не работать в некоторых контекстах.

  • Копирование выделенного текста не работает.

Бенчмарки: Скорость открытия и Отзывчивость

Ладно, давайте погоняем бенчмарки. Понятное дело, что на огромных страницах виртуализация победит. Поэтому возьмём, что-то более типичное - небольшую мобильную страницу Хабра со 170 комментариями и откроем её на не самом слабом ноуте и жамкнем "показать 170". Таймлайн сверху показывает, что на формирование DOM через VueJS требуется три с половиной секунды, а потом ещё пол секунды требуется браузеру, чтобы всё это показать.

Снизу же вы видите таймлайн открытия реализации этой страницы на $mol с виртуализацией. Как видно, треть секунду ушла на отображения статьи, ещё треть потребовалось браузеру, чтобы её показать, потом пришли данные комментариев и ещё за треть секунды они были обработаны: сформировано дерево компонент, вычислены минимальные размеры и тд. Но благодаря виртуализации DOM почти не поменялся, поэтому браузеру ничего не стоило это обработать.

Итого: ускорение открытия не менее чем в 4 раза даже на сравнительно небольшом объёме данных.

Бенчмарки: Отзывчивость

Можем погонять и какие-нибудь синтетические бенчмарки. Например, dbmon.

https://mol.js.org/perf/dbmon/-/

Пока все реализации топчутся у меня вкруг 20 фпс, наивная реализация на $mol со встроенной виртуализацией показывает стабильные 60.

Бенчмарки: Потребление памяти

Нельзя забывать и про потребление оперативной памяти. Та реализация Хабра на VueJS на 170 комментариях отжирает 40 мегабайт хипа JS. Но если посмотреть понять вкладки, то это будет уже в 3 раза больше, так как самому браузеру нужно весь этот дом показывать. Если же открыть реализацию на $mol, где выводится статья, да ещё и две с половиной тысячи комментариев к ней, то мы получаем те же 40 мегабайт JS хипа. Но вкладка при этом кушает в два раза меньше, ибо браузеру показывать всего ничего - меньше тысячи DOM элементов.

Вариант

Память JS

Память вкладки

VueJS: 170 комментариев

40 MB

150 MB

$mol: статья + 2500 комментариев

40 MB

90 MB

И суть тут не в том, что $mol такой легковесный и быстрый, а в том, что в нём применена архитектурная оптимизация, дающая профита куда больше, чем обычная локальная оптимизация в бутылочном горлышке. Тут мы не ускорили формирование DOM, а сделали так, что скорость изменения DOM нас вообще перестала волновать. Ведь виртуализация позволяет рендерить на порядок больше данных, потребляя при этом в несколько раз меньше ресурсов.

Бенчмарки: Гулять так гулять!

Ну и, наконец, давайте сделаем немыслимое - загрузим разом 25 приложений..

https://showcase.hyoo.ru/

Некоторые из них отображают весьма не маленькие объёмы данных. Начиная ото всех существующих material design иконок. Заканчивая всеми продающимися сейчас типами лампочек. На моём ноуте всё это открывается за 6 секунд. Напомню, что одна только гитлабовская страница из начала моего выступления открывалась в 3 раза дольше. Почувствуйте, как говорится, разницу между тем, что веб представляет из себя сейчас, и каким он мог бы быть, если бы мы думали не только о том, как удовлетворить свои привычки, но и о том, какие привычки полезны.

ООП против ФП

Короче, виртуализация - классная тема. И давайте посмотрим какие у нас есть перспективы, что она появится в ныне популярных инструментах. Но сперва определимся с вопросом чем объект отличается от функции..

  • Объект: одно состояние - много действий.

  • Функция: много состояний - одно действие.

Условный Angular использует концепцию объектов: каждый компонент - объект умеющий много разных действий. А вот в React популярна тема функциональных компонент - тут компонент имеет лишь одно действие - отрендерить своё содержимое в виртуальное дерево.

Ортогональные действия

Однако, при виртуализации, нам необходимо, чтобы каждый компонент умел делать разные дела..

  • Узнать минимальные размеры без полного рендера.

  • Частично отрендерить содержимое.

  • Проверить соответствие поисковому запросу.

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

Композиция против вёрстки

Также стоит упомянуть и различные подходы к композиции компонент. Первый вариант - наиболее естественный - это прямая сборка интерфейса из компонент. Тут рендерер может спросить мол "колонка, вот какие у тебя минимальные размеры?" и колонка сможет их правильно вычислить.

Column    Row        Search        Icon    Scroll        Column            Task            Task            Task

Второй подход - более популярный - это оживление готовой HTML вёрстки. Тут фактический лейаут зависит от стилей, что применены к html элементам. Как-то проанализировать это, наверно, можно, но это весьма сложно, крайне медленно и не очень надёжно. А в вебе и так хватает точек отказа.

<main class="panel">    <div class="header">        <input class="search">        <img src="..." class="icon">    </div>    <div class="scroll">        <div class="card">        <div class="card">        <div class="card">    </div></main>

Перспективы во фреймворках

Анализируя популярные инструменты, можно заметить, что React, например идёт вообще куда-то совсем не в ту сторону. Так что к нему прикрутить виртуализацию если и можно, то крайне сложно. И скорее всего не станут.

Чуть ближе к виртуализации React Native, где нет никакого сырого HTML и всё строится из компонент. Но подражание html тут как собаке пятая нога.

К Angular, Vue, Svelte прикрутить виртуализацию скорее всего будет проще, ибо там каждый компонент - это некоторый объект. Правда ориентация на вёрстку, вместо компонент, существенно осложняет внедрение виртуализации на уровне фреймворка, а не прикладного кода.

В $mol же виртуализация работает уже давно, так что он опять всех задоминировал.

Инструмент

ООП

КОП

React

React Native

Vue

Angular

Svelte

$mol

Выбери виртуализацию

Но я не предлагаю вам использовать $mol. Потому что, ну знаете, "использовать" - это какое-то потребительское отношение. Я вам предлагаю присоединиться к его разработке и тем самым получить максимальный профит.

Ну либо вы можете разработать свой какой-то инструмент, основанный на виртуализации. В любом случае, какой бы вариант вы ни выбрали, обращайтесь к нам, мы поможем чем сможем.

Ссылочки

Обратная связь

Превосходно: 34%

  • Вроде все знакомо, но нашёл для себя пару интересных нюансов.

  • Узнал кое что новое.

  • Все ок.

  • По моему мощнейший доклад, очень круто и интересно. Один момент. У Дмитрия сквозь доклад использовалась шутка: недостатки преподносились как преимущества (в духе контент прыгает - пользователь тренируется концентрировать внимание). Она удачная и смешная (правда так считаю, не из вежливости это пишу), но ее было слишком много. Например, был слайд, где было сразу много "преимуществ", и Дмитрий проговаривал по шутке на каждый пункт. Ломало темп. Сначала весело, а потом уже как-то хочется чтобы доклад дальше двигался. Но это минорное замечание, доклад ?

  • Изначально я слышал про $mol из комментариев на Хабре, многие из которых были неуместны / подавались в странной манере. В докладе я увидел, что автор - "видел некоторое дерьмо" (простите за мем) - понимает причины - предлагает здравые рассуждения по тому, как это чинить. Совершенно точно посмотрю на эту библиотеку, чтобы увидеть воплощение принципов из доклада на практике. Но даже если не буду использовать эту библиотеку в проде, разочарован не буду - настолько ценной и качественной я считаю информацию в этом докладе.

  • Подача, глубина доклада, практическая применимость, простота восприятия, новизна.

  • На удивление было мало $mol и много полезных вещей)

Хорошо: 42%

  • Интересный доклад, но к сожалению не все было понятно, начиналось вроде все просто, но потом быстро вышло за моё понимание).

  • Карловский как всегда жжёт.

  • Очень круто и глубоко, но не очень понравилась подача. Хочется побольше "огонька".

Нормально: 18%

  • Слишком узкая специализация

  • Что смотрел помню, а про что нет.

  • Доклад на мой взгляд получился последовательным, но не очень сбалансированным с точки зрения теории и практики. Тема доклада очень общая, а по итогу практическую пользу можно получить судя по всему, если использовать конкретный фреймворк. Из плюсов - понравился разбор проблемы на примере GitLab.

Плохо: 6%

  • Интересный материал, интересная тема, большой потенциал технологии. Не понравился спикер и его отношения к другим технологиям, людям.

  • Оценка поставлена из-за несоответствия моих ожиданий и реальности =) Ожидания: я смогу применить полученные знания на своём проекте. Реальность: демонстрация своего фреймворка. Для проектов на любом другом фреймворке полученная информация неприменима. Если бы это было понятно из названия и описания, я бы не тратила время и пошла на другой доклад.

Подробнее..

Mol_strict Как же меня object Object этот ваш undefined NaN

06.04.2021 18:11:06 | Автор: admin

Здравствуйте, меня зовут Дмитрий Карловский и я не прощаю ошибок. Как только вижу оную тут же бросаю что-нибудь исключительно тяжёлое. И как же тяжела работа программиста на JS...


class Foo extends Object {}const foo = new Foo`Здравствуйте, ${ foo }!`// "Здравствуйте [object Object]!"`В этом месяце вы заработали ${ foo / 1000 } тысяч рублей.`// "В этом месяце вы заработали NaN тысяч рублей."`Ваша цель "${ 'foo'[4] }" наконец-то достигнута.`// "Ваша цель "undefined" наконец-то достигнута."`Осталось ещё ${ foo.length - 1 } целей и вы достигните успеха.`// "Осталось ещё NaN целей и вы достигните успеха."

Облегчить его страдания можно разными путями..


  1. Прикрыться тайпскриптом. Но в рантайме ноги всё равно остаются босыми, и на них кто-нибудь вечно наступает.
  2. Обложиться проверками. Но чуть замешкаешься и рантайм грабли тут же бьют по голове.
  3. Исправить JS. Даже не надейтесь.
  4. Исправить JS рантайм. Ну, давайте подумаем..

Проблемы с динамической типизацией JS возникают по 2 основным причинам:


  • Автоматическое (и порой неуместное) приведение типов, когда значение одного типа используется в контексте, предназначенном для другого.
  • Возврат undefined в качестве значения не объявленных полей.

Сначала разберёмся с первой проблемой. JS устроен так, что приведение примитивных типов мы никак исправить не сможем. А вот за приведением объектов у нас есть полный контроль. Поэтому давайте пропатчим глобальный прототип всех объектов, чтобы никакие объекты по умолчанию не допускали приведения типов:


Object.prototype[ Symbol.toPrimitive ] = function() {    throw new TypeError( `Field Symbol(Symbol.toPrimitive) is not defined` )}

Теперь, чтобы разрешить приведение типа, нужно переопределить метод Symbol.toPrimitive у нужного объекта.


Ладно, с первой проблемой разобрались. Но как-то она далась нам подозрительно легко Что-то тут не так! Не похоже это на Веб Ну да ладно, пошли к следующей.


Тут нам нужно как-то перехватывать обращения к произвольным полям объекта, даже если они ещё никак не были объявлены. В JS для этого есть только один механизм прокси. Что ж, напишем такой прокси который при обращении к любому полю громко ругается квазицензурными словами:


export let $mol_strict_object = new Proxy( {}, {    get( obj: object, field: PropertyKey, proxy: object ) {        const name = JSON.stringify( String( field ) )        throw new TypeError( `Field ${ name } is not defined` )    },})

К сожалению, поменять prototype у Object, как мы это сделали ранее, браузер нам уже не даст. Как и поменять прототип у Object.prototype он всегда будет null. Зато мы можем менять прототипы у почти всех остальных стандартных классов унаследованных от Object:



Поэтому пройдёмся по всем глобальным переменным:


for( const name of Reflect.ownKeys( $ ) ) {    // ...}

Отсеим те из них, кто не является классами:


const func = Reflect.getOwnPropertyDescriptor( globalThis, name )!.valueif( typeof func !== 'function' ) continueif(!( 'prototype' in func )) continue

Обратите внимание, что мы не используем globalThis[name] для получения значения, чтобы не триггерить ненужные варнинги.


Теперь оставляем лишь те классы, что непосредственно унаследованы от Object:


const proto = func.prototypeif( Reflect.getPrototypeOf( proto ) !== Object.prototype ) continue

И, наконец, подменяем прототип прототипа с Object.prototype на наш строгий вариант:


Reflect.setPrototypeOf( proto, $mol_strict_object )

Теперь почти все стандартные объекты будут смотреть на вас с укоризной, при обращении к свойству, которому не задано значение. Ведь если значение задано, то браузер не пойдёт по цепочке прототипов и не дойдёт до нашего прокси.


К сожалению, есть и исключения, такие как сам Object, все объектные литералы и всё, что унаследовано от EventTarget, который тоже не дают менять.


И тут CSSStyleDeclaration делает нам подножку: если подменить его прототип на прокси (даже прозрачный, не кидающий исключений), то, внезапно, в Хроме 89 он перестаёт синхронизироваться с атрибутом style dom-элемента:


( <div style={{ color: 'red' }} /> ).outerHTML // <div></div>

Поэтому его пока что приходится вносить в исключения.


Есть и ещё одна беда Если объявлять прикладные классы так:


class Foo {}

То объекты этих классов не будут строгими. Но если объявить их так:


class Foo extends Object {}

То ничего особо не изменится. Но вот если подменить глобальный объект Object на свой строгий подкласс:


globalThis.Object = function $mol_strict_object( this: object, arg: any ) {    let res = Object_orig.call( this, arg )    return this instanceof $mol_strict_object ? this : res}Reflect.setPrototypeOf( Object, Reflect.getPrototypeOf({}) )Reflect.setPrototypeOf( Object.prototype, $mol_strict_object )

То прикладные классы, явно унаследованные от Object, станут строгими.


Итак, что у нас получилось...


class Foo extends Object {}const foo = new Foo`Здравствуйте, ${ foo }!`// TypeError: Field "Symbol(Symbol.toPrimitive)" is not defined`В этом месяце вы заработали ${ foo / 1000 } тысяч рублей.`// TypeError: Field "Symbol(Symbol.toPrimitive)" is not defined`Ваша цель "${ 'foo'[4] }" наконец-то достигнута.`// TypeError: Field "4" is not defined`Осталось ещё ${ foo.length - 1 } целей и вы достигните успеха.`// TypeError: Field "length" is not defined

На случай, если это будут читать дети, подчеркну:


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


Если хотите лучше понять как всё это работает, то гляньте эту статью: Насколько JavaScript сильный?.


Полные исходники можно найти тут: $mol_strict.


Для подключения к своему NPM проекту достаточно прописать где-нибудь в начале точки входа:


import "mol_strict"

Или:


require("mol_strict")

Другие независимые сборки микробиблиотек из $mol можно найти тут: $mol: Usage from NPM ecosystem.


А если хотите обсудить подноготную JS рантайма, то присоединяйтесь к этим чатам:


  • У браузера под юбкой (Обсуждаем разработку браузерных движков. Парсинг, рендеринг, архитектура, вот это всё.)
  • UfoStation Chat (ФП Фронтенд и Программирование.)

Наконец, в твиттере _jinnin можно обнаружить много свежих мыслей на тему фронтенда, JS, UX и прочей дичи.

Подробнее..

Да хватит уже писать эти регулярки

08.06.2021 16:18:29 | Автор: admin

Здравствуйте, меня зовут Дмитрий Карловский и раньше я тоже использовал Perl для разработки фронтенда. Только гляньте, каким лаконичным кодом можно распарсить, например, имейл:


/^(?:((?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}(?:\.(?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}){0,})|("(?:((?:(?:([\u{1}-\u{8}\u{b}\u{c}\u{e}-\u{1f}\u{21}\u{23}-\u{5b}\u{5d}-\u{7f}])|(\\[\u{1}-\u{9}\u{b}\u{c}\u{e}-\u{7f}]))){0,}))"))@(?:((?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}(?:\.(?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}){0,}))$/gsu

Тут, правда, закралось несколько ошибок. Ну ничего, пофиксим в следующем релизе!


Шутки в сторону



По мере роста, регулярки очень быстро теряют свою понятность. Не зря в интернете есть десятки сервисов для отладки регулярок. Вот лишь некоторые из них:



А с внедрением новых фичей, они теряют и лаконичность:


/(?<слово>(?<буквица>\p{Script=Cyrillic})\p{Script=Cyrillic}+)/gimsu

У регулярок довольно развесистых синтаксис, который то и дело выветривается из памяти, что требует постоянное подсматривание в шпаргалку. Чего только стоят 5 разных способов экранирования:


/\t//\ci//\x09//\u0009//\u{9}/u

В JS у нас есть интерполяция строк, но как быть с регулярками?


const text = 'lol;)'// SyntaxError: Invalid regular expression: /^(lol;)){2}$/: Unmatched ')'const regexp = new RegExp( `^(${ text }){2}$` )

Ну, или у нас есть несколько простых регулярок, и мы хотим собрать из них одну сложную:


const VISA = /(?<type>4)\d{12}(?:\d{3})?/const MasterCard = /(?<type>5)[12345]\d{14}/// Invalid regular expression: /(?<type>4)\d{12}(?:\d{3})?|(?<type>5)[12345]\d{14}/: Duplicate capture group nameconst CardNumber = new RegExp( VISA.source + '|' + MasterCard.source )

Короче, писать их сложно, читать невозможно, а рефакторить вообще адски! Какие есть альтернативы?


Свои регулярки с распутным синтаксисом


Полностью своя реализация регулярок на JS. Для примера возьмём XRegExp:


  • API совместимо с нативным.
  • Можно форматировать пробелами.
  • Можно оставлять комментарии.
  • Можно расширять своими плагинами.
  • Нет статической типизации.
  • Отсутствует поддержка IDE.

В общем, всё те же проблемы, что и у нативных регулярок, но втридорога.


Генераторы парсеров


Вы скармливаете им грамматику на специальном DSL, а они выдают вам JS код функции парсинга. Для примера возьмём PEG.js:


  • Наглядный синтаксис.
  • Каждая грамматика вещь в себе и не компонуется с другими.
  • Нет статической типизации генерируемого парсера.
  • Отсутствует поддержка IDE.
  • Минимум 2 кб в ужатопережатом виде на каждую грамматику.

Пример в песочнице.


Это решение более мощное, но со своими косяками. И по воробьям из этой пушки стрелять не будешь.


Билдеры нативных регулярок


Для примера возьмём TypeScript библиотеку $mol_regexp:


  • Строгая статическая типизация.
  • Хорошая интеграция с IDE.
  • Композиция регулярок с именованными группами захвата.
  • Поддержка генерации строки, которая матчится на регулярку.

Это куда более легковесное решение. Давайте попробуем сделать что-то не бесполезное..


Номера банковских карт


Импортируем компоненты билдера


Это либо функции-фабрики регулярок, либо сами регулярки.


const {    char_only, latin_only, decimal_only,    begin, tab, line_end, end,    repeat, repeat_greedy, from,} = $mol_regexp

Ну или так, если вы ещё используете NPM


import { $mol_regexp: {    char_only, decimal_only,    begin, tab, line_end,    repeat, from,} } from 'mol_regexp'

Пишем регулярки для разных типов карт


// /4(?:\d){12,}?(?:(?:\d){3,}?){0,1}/gsuconst VISA = from([    '4',    repeat( decimal_only, 12 ),    [ repeat( decimal_only, 3 ) ],])// /5[12345](?:\d){14,}?/gsuconst MasterCard = from([    '5',    char_only( '12345' ),    repeat( decimal_only, 14 ),])

В фабрику можно передавать:


  • Строку и тогда она будет заэкранирована.
  • Число и оно будет интерпретировано как юникод кодепоинт.
  • Другую регулярку и она будет вставлена как есть.
  • Массив и он будет трактован как последовательность выражений. Вложенный массив уже используется для указания на опциональность вложенной последовательности.
  • Объект означающий захват одного из вариантов с именем соответствующим полю объекта (далее будет пример).

Компонуем в одну регулярку


// /(?:(4(?:\d){12,}?(?:(?:\d){3,}?){0,1})|(5[12345](?:\d){14,}?))/gsuconst CardNumber = from({ VISA, MasterCard })

Строка списка карт


// /^(?:\t){0,}?(?:((?:(4(?:\d){12,}?(?:(?:\d){3,}?){0,1})|(5[12345](?:\d){14,}?))))(?:((?:\r){0,1}\n)|(\r))/gmsuconst CardRow = from(    [ begin, repeat( tab ), {CardNumber}, line_end ],    { multiline: true },)

Сам список карточек


const cards = `    3123456789012    4123456789012    551234567890123    5512345678901234`

Парсим текст регуляркой


for( const token of cards.matchAll( CardRow ) ) {    if( !token.groups ) {        if( !token[0].trim() ) continue        console.log( 'Ошибка номера', token[0].trim() )        continue    }    const type = ''        || token.groups.VISA && 'Карта VISA'        || token.groups.MasterCard && 'MasterCard'    console.log( type, token.groups.CardNumber )}

Тут, правда, есть небольшое отличие от нативного поведения. matchAll с нативными регулярками выдаёт токен лишь для совпавших подстрок, игнорируя весь текст между ними. $mol_regexp же для текста между совпавшими подстроками выдаёт специальный токен. Отличить его можно по отсутствию поля groups. Эта вольность позволяет не просто искать подстроки, а полноценно разбивать весь текст на токены, как во взрослых парсерах.


Результат парсинга


Ошибка номера 3123456789012Карта VISA 4123456789012Ошибка номера 551234567890123MasterCard 5512345678901234

Заценить в песочнице.


E-Mail


Регулярку из начала статьи можно собрать так:


const {    begin, end,    char_only, char_range,    latin_only, slash_back,    repeat_greedy, from,} = $mol_regexp// Логин в виде пути разделённом точкамиconst atom_char = char_only( latin_only, "!#$%&'*+/=?^`{|}~-" )const atom = repeat_greedy( atom_char, 1 )const dot_atom = from([ atom, repeat_greedy([ '.', atom ]) ])// Допустимые символы в закавыченном имени сендбоксаconst name_letter = char_only(    char_range( 0x01, 0x08 ),    0x0b, 0x0c,    char_range( 0x0e, 0x1f ),    0x21,    char_range( 0x23, 0x5b ),    char_range( 0x5d, 0x7f ),)// Экранированные последовательности в имени сендбоксаconst quoted_pair = from([    slash_back,    char_only(        char_range( 0x01, 0x09 ),        0x0b, 0x0c,        char_range( 0x0e, 0x7f ),    )])// Закавыченное имя сендборксаconst name = repeat_greedy({ name_letter, quoted_pair })const quoted_name = from([ '"', {name}, '"' ])// Основные части имейла: доменная и локальнаяconst local_part = from({ dot_atom, quoted_name })const domain = dot_atom// Матчится, если вся строка является имейломconst mail = from([ begin, local_part, '@', {domain}, end ])

Но просто распарсить имейл эка невидаль. Давайте сгенерируем имейл!


//  SyntaxError: Wrong param: dot_atom=foo..barmail.generate({    dot_atom: 'foo..bar',    domain: 'example.org',})

Упс, ерунду сморозил Поправить можно так:


// foo.bar@example.orgmail.generate({    dot_atom: 'foo.bar',    domain: 'example.org',})

Или так:


// "foo..bar"@example.orgmail.generate({    name: 'foo..bar',    domain: 'example.org',})

Погонять в песочнице.


Роуты


Представим, что сеошник поймал вас в тёмном переулке и заставил сделать ему "человекопонятные" урлы вида /snjat-dvushku/s-remontom/v-vihino. Не делайте резких движений, а медленно соберите ему регулярку:


const translit = char_only( latin_only, '-' )const place = repeat_greedy( translit )const action = from({ rent: 'snjat', buy: 'kupit' })const repaired = from( 's-remontom' )const rooms = from({    one_room: 'odnushku',    two_room: 'dvushku',    any_room: 'kvartiru',})const route = from([    begin,    '/', {action}, '-', {rooms},    [ '/', {repaired} ],    [ '/v-', {place} ],    end,])

Теперь подсуньте в неё урл и получите структурированную информацию:


// `/snjat-dvushku/v-vihino`.matchAll(route).next().value.groups{    action: "snjat",    rent: "snjat",    buy: "",    rooms: "dvushku",    one_room: "",    two_room: "dvushku",    any_room: "",    repaired: "",    place: "vihino",}

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


// /kupit-kvartiru/v-moskveroute.generate({    buy: true,    any_room: true,    repaired: false,    place: 'moskve',})

Если задать true, то значение будет взято из самой регулярки. А если false, то будет скипнуто вместе со всем опциональным блоком.


И пока сеошник радостно потирает руки предвкушая первое место в выдаче, незаметно достаньте телефон, вызовите полицию, а сами скройтесь в песочнице.


Как это работает?


Нативные именованные группы, как мы выяснили ранее, не компонуются. Попадётся вам 2 регулярки с одинаковыми именами групп и всё, поехали за костылями. Поэтому при генерации регулярки используются анонимные группы. Но в каждую регулярку просовывается массив groups со списком имён:


// time.source == "((\d{2}):(\d{2}))"// time.groups == [ 'time', 'hours', 'minutes' ]const time = from({    time: [        { hours: repeat( decimal_only, 2 ) },        ':',        { minutes: repeat( decimal_only, 2 ) },    ],)

Наследуемся, переопределям exec и добавляем пост-процессинг результата с формированием в нём объекта groups вида:


{    time: '12:34',    hours: '12,    minutes: '34',}

И всё бы хорошо, да только если скомпоновать с нативной регуляркой, содержащей анонимные группы, но не содержащей имён групп, то всё поедет:


// time.source == "((\d{2}):(\d{2}))"// time.groups == [ 'time', 'minutes' ]const time = wrong_from({    time: [        /(\d{2})/,        ':',        { minutes: repeat( decimal_only, 2 ) },    ],)

{    time: '12:34',    hours: '34,    minutes: undefined,}

Чтобы такого не происходило, при композиции с обычной нативной регуляркой, нужно "замерить" сколько в ней объявлено групп и дать им искусственные имена "0", "1" и тд. Сделать это не сложно достаточно поправить регулярку, чтобы она точно совпала с пустой строкой, и посчитать число возвращённых групп:


new RegExp( '|' + regexp.source ).exec('').length - 1

И всё бы хорошо, да только String..match и String..matchAll клали шуруп на наш чудесный exec. Однако, их можно научить уму разуму, переопределив для регулярки методы Symbol.match и Symbol.matchAll. Например:


*[Symbol.matchAll] (str:string) {    const index = this.lastIndex    this.lastIndex = 0    while ( this.lastIndex < str.length ) {        const found = this.exec(str)        if( !found ) break        yield found    }    this.lastIndex = index}

И всё бы хорошо, да только тайпскрипт всё равно не поймёт, какие в регулярке есть именованные группы:


interface RegExpMatchArray {    groups?: {        [key: string]: string    }}

Что ж, активируем режим обезьянки и поправим это недоразумение:


interface String {    match< RE extends RegExp >( regexp: RE ): ReturnType<        RE[ typeof Symbol.match ]    >    matchAll< RE extends RegExp >( regexp: RE ): ReturnType<        RE[ typeof Symbol.matchAll ]    >}

Теперь TypeScript будет брать типы для groups из переданной регулярки, а не использовать какие-то свои захардкоженные.


Ещё из интересного там есть рекурсивное слияние типов групп, но это уже совсем другая история.


Напутствие



Но что бы вы ни выбрали знайте, что каждый раз, когда вы пишете регулярку вручную, где-то в интернете плачет (от счастья) один верблюд.


Подробнее..

Продвинутый CSS-in-TS

15.10.2020 20:21:55 | Автор: admin


Здравствуйте, меня зовут Дмитрий Карловский и я автор одного из первых фреймворков целиком и полностью написанных на тайпскрипте $mol. Он по максимуму использует возможности статической типизации. И сегодня речь пойдёт о максимально жёсткой статической фиксации стилей.


Это расшифровка выступления на PiterJS#46. Вы можете либо посмотреть видео запись, либо открыть в интерфейсе проведения презентаций, либо читать как статью...


Подопытное приложение


Разбирать мы будем весьма простое приложение $my_profile, которое состоит из двух панелей: меню и детализация. А каждая панель состоит из двух частей: заголовка и тела.



Генерация классов


Описывается такое приложение крайне простым кодом..


$my_profile $mol_view sub /    <= Menu $my_panel    <= Details $my_panel$my_panel $mol_view sub /    <= Head $mol_view    <= Body $mol_scroll

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


interface $my_profile extends $mol_view {    Menu(): $my_panel    Details(): $my_panel} )interface $my_panel extends $mol_view {    Head(): $mol_view    Body(): $mol_scroll} )

Каждый компонент это некоторый класс, который наследуется от другого компонента. И в нём, для каждого вложенного компонента генерится метод, который возвращает экземпляр соответствующего класа. Такие методы обычно пишутся с большой буквы.


Генерация DOM


Моловский компонент, когда рендерится, генерирует некоторый DOM-элемент. Имя этого элемента по умолчанию совпадает с именем инстанцированного класса компонента, что довольно удобно при обзоре сгенерированного DOM-дерева. При необходимости имя элемента может быть переопределено через свойство dom_name. Кроме того, автоматически генерируется набор атрибутов, предназначенных для подключения стилей. При этом учитывается не только собственное глобальное имя компонента, но и глобальные имена всех его предков, а так же его локальное имя во внешнем компоненте, глобальные имена внешнего компонента и его предков и так далее рекурсивно до корня приложения.


interface $my_profile extends $mol_view {    Menu(): $my_panel    Details(): $my_panel} )interface $my_panel extends $mol_view {    Head(): $mol_view    Body(): $mol_scroll} )

<mol_view    mol_view    my_panel_body    my_profile_details_body    >    <mol_button_major        mol_view        mol_button        mol_button_major        my_profile_signup        >

В данном примере мы видим следующие атрибуты:


mol_view базовый класс для всех моловских компонент, через него можно, например, сделать reset для вообще всех компонент, без риска поломать, не моловские компоненты.
my_panel_body значит это компонент с локальным именем Body внутри внешнего компонента $my_panel.
my_profile_details_body значит тот $my_panel имеет локальное имя Details в приложении $my_profile.


Наложение стилей


Таким образом мы получаем исчерпывающий набор атрибутов для стилизации компонент, без традиционного ручного прописывания классов элементам. И как бы глубоко ни располагался элемент специфичность селектора остаётся постоянной, что очень удобно, когда надо переопределять стили.


<mol_view    mol_view    my_panel_body    my_profile_details_body    >    <mol_button_major        mol_view        mol_button        mol_button_major        my_profile_signup        >

[my_profile_details_body] {    overflow: 'overlay';}[my_profile_details_body] [mol_button] {    border-radius: .5rem;}

Но у такого написания CSS есть ряд недостатков. Например, эти длинные селекторы утомительно писать руками. Среда разработки не понимает, что это имена компонент и ничего не подсказывает при наборе. Наконец, тайпскрипт тоже ничего про это соглашение не знает и не может протайпчекать селекторы.


Генерация стилей


Было бы классно описать наши стили прямо в тайпскрипте в простой лаконичной форме и автоматически сгенерировать из этого CSS.


[my_profile_details_body] {    overflow: 'overlay';}[my_profile_details_body] [mol_button] {    border-radius: .5rem;}

$mol_style_define( $my_profile , {    Details: {        Body: {            overflow: 'overlay',            $mol_button: {                border: {                    radius: rem(.5),                },            },        },    },} )

Тут мы вызываем функцию $mol_style_define, которая генерит StyleSheet. Передаём в неё класс компонента $my_profile и JSON, говорящий, что внутри компонента Details и его внутреннего компонента Body стили такие-то, а для всех вложенных в него кнопок сякие-то.


CSSOM: проблема с редактированием через DevTools


Генерировать стили можно двумя способами: либо через CSSOM, либо через генерацию портянки CSS и подклеивания его через элемент style. Если использовать первый подход, то в Chrome Dev Tools такие стили становятся не редактируемыми, что очень не удобно при разработке. Поэтому приходится использовать второй подход.



Сверху на скриншоте вы видите стили, сгенеренные библиотекой aphrodite, а снизу обычный CSS. Кстати, обратите внимание на порнографию в качестве селектора и сравните с теми именами, что генерит $mol_view.


Генерация CSS довольно простая операция. У меня это заняло 3КБ кода. Так что не будем особо на этом останавливаться и перейдём к типизации..


CSSStyleDeclaration: слабая типизация


В идеальном мире мы бы взяли стандартный тип CSSStyleDeclaration, поставляемый вместе с тайпскриптом. Это просто словарь из 500 свойств, типизированных как string.


type CSSStyleDeclaration = {    display: string    // 500 lines}

Соответственно, вы можете легко опечататься в каком-нибудь слове, а тайпскрипт это злополучно пережуёт и даже не подавится.


{    display: 'black' // }

csstype: кривая типизация


Можно взять популярную библиотеку csstype, которая генерируется из выгрузки всех свойств и их значений из MDN. Во второй её версии генерируются не очень полезные типы..


type DisplayProperty =| Globals| DisplayOutside| DisplayInside| DisplayInternal| DisplayLegacy| "contents" | "list-item" | "none"| string

Кто знаком с тайпскриптом, понимаю, что этот код эквивалентен следующему..


type DisplayProperty = string

Всё дело в том, что строковые литералы являются подтипами строки, потому тайпскрипт оставляет лишь наиболее общий тип.


csstype@3: кривая типизация с подсказками


Но ничего, в тетьей версии они это "починили" string соединили с пустым интерфейсом, из-за чего слияния с литералами уже не происходит:


type Display =| Globals| DisplayOutside| DisplayInside| DisplayInternal| DisplayLegacy| "contents" | "list-item" | "none"| (string & {})

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


{    display: 'black' // }

Простые свойства


Мы пойдём своим путём и самостоятельно опишем типы для нужных нам свойств в том виде, который будет нам удобнее всего. Для этого заведём интерфейс Properties и будем заполнять его..


interface Properties {    /**     * Whether an element is treated as a block or inline element     * and the layout used for its children, such as flow layout, grid or flex.     */    display?: 'block' | 'inline' | 'none' | ... | Common    // etc}type Common = 'inherit' | 'initial' | 'unset'

Подсказки по свойствам


Для каждого свойства будем писать докстринг, который будет выводиться над именем свойства при наведении, что полезно тем, кто ещё не вызубрил все 500 CSS-свойств.



Группы свойств


Многие CSS свойства имеют общий префикс и было бы удобно не писать его каждый раз, а сгруппировать в один объект..


overflow: {    x: 'auto' ,    y: 'scroll',    anchor: 'none',}

interface Properties {    overflow? : {        x?:  Overflow | Common        y?:  Overflow | Common        anchor?: 'auto' | 'none' | Common    }}type Overflow = 'visible' | 'hidden' | ... | Common

Свойства размеров


Для размерностей есть не только предопределённый список значений, но произвольные юниты (1px, 2rem) и функции (calc(1rem + 1px)).


interface Properties {    width?: Size    height?: Size}type Size =| 'auto' | 'max-content' | 'min-content' | 'fit-content'| Length | Commontype Length = 0 | Unit< 'rem' | ... | '%' > | Func<'calc'>

Единицы измерения


Юнит можно объявить как просто класс, который параметризирован литералом и который умеет правильно себя сериализовывать.


class Unit< Lit extends string > {    constructor(        readonly val: number,        readonly lit: Lit,    ) { }    toString() {        return this.val + this.lit    }}

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


function rem( val : number ) {    return new Unit( val , 'rem' )}{    width: rem(1) // Unit<'rem'>}

Функции


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


Func<    Name extends string,    Value = unknown,> {    constructor(        readonly name: Name,        readonly val: Value,    ) { }    toString() {        return `${ this.name }(${ this.val })`    }}

function calc( val : string ) {    return new Func( 'calc' , val )}{    // Func< 'calc' , string >    width: calc( '1px + 1em' )}

Сокращённые свойства


Многие CSS свойства имеют как полную так и сокращённую формы. Например, для margin можно указать от 1 до 4 значений. И если для 1 и 2 всё более-менее понятно, то с болшим числом начинаются головоломки типа: если значения 4, то margin-left это последнее значение, а если 3, то предпоследнее. Чтобы такого не происходило, оставим лишь пару сокращённых форм, а если хочется большего контроля изволь написать в полной форме какому направлению какое значение. Получаем чуть больше писанины, но и улучшаем понятность кода.


interface Properties {    margin?: Directions<Length>    padding?: Directions<Length>}type Directions< Value > =| Value| [ Value , Value ]| {    top?: Value ,    right?: Value ,    bottom?: Value ,    left?: Value ,}

margin: rem(.5)padding: [ 0 , rem(.5) ]margin: {    top: 0,    right: rem(.5),    bottom: rem(.5),    left: rem(.5),}

Цвета


Для цветов у нас есть словарь $mol_colors из всех стандартных цветов просто берём из него ключи. Плюс добавляем несколько функций..


type Color =| keyof typeof $mol_colors| 'transparent' | 'currentcolor'| $mol_style_func< 'hsla' | 'rgba' | 'var' >color?: Color | Common

{    color: 'snow',    background: {        color: hsla( 0 , 0 , 50 , .1 ),    },}

hsl и rgb специально не добавлены, ибо написать лишнюю единичку для hsla и rgba не проблема, зато АПИ несколько упростили.


Списки


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


background?: {    image?: [ $mol_style_func<'url'> ][]}

background: {    image: [        [url('/foo.svg')],        [url('/bar.svg')],    ],},

Тут можно было бы запариться со специальными хелперными функциями, но писать их постоянно не очень практично, поэтому используется следующая простая эвристика: если в массиве лежат простые значения, то они просто соединяются через пробел. А если сложные (массивы, объекты), то через запятые. А вложенные в них значения уже через пробел.


Списки структур


Более сложный пример список из структур как в описании теней..


box?: {    shadow?: readonly {        inset: boolean        x: Length        y: Length        blur: Length        spread: Length        color: Color    }[]}

box: {    shadow: [        {            inset: true,            x: 0,            y: 0,            blur: rem(.5),            spread: 0,            color: 'black',        },    ],},

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


БЭМ-элементы


Наконец, мы дошли до самой мякотки. У нас есть компонент $my_profile, в котором есть элемент Details, который сам является компонентом $my_panel, в котором есть элемент Body, который является компонентом $mol_scroll. И было бы неплохо уметь стилизовать любой из этих элементов, через стили, задаваемые компоненту $my_profile.


interface $my_profile {    Details(): $my_panel} )interface $my_panel {    Body(): $mol_scroll} )

$mol_style_define( $my_profile , {    padding: rem(1),    Details: {        margin: 'auto',        Body: {            overflow: 'scroll',        },    },} )

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


Поиск всех БЭМ-элементов


У нас есть специальная типофункция $mol_type_pick, которой вы передаёте некоторый интерфейс и желаемый тип полей, а возвращает она тот же интерфейс, но без полей, которые не соответвуют желаемому типу.


$mol_type_pick<    $my_profile,    ()=> $mol_view,>

interface $my_profile extends $mol_view {    title(): string    Menu(): $my_panel    Details(): $my_panel} )        {    Menu(): $my_panel    Details(): $my_panel}

В данном примере, у компонента есть свойство title, возвращающее строку. Но было бы странно вешать стили на строку. Стили можно вешать лишь на Menu и Details ибо они возвращают экземпляры компонент.


БЭМ-блоки


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


interface $my_profile {    Menu(): $my_panel    Details(): $my_panel} )interface $mol_button {} )

$mol_style_define( $my_profile , {    $mol_button: {        color: 'red',    },    Details: {        $mol_button: {            color: 'blue',        },    },} )

Поиск всех подклассов


Найти все имена компонент не сложно, так как все классы, функции и прочие глобальные переменные объявляются в едином неймспейсе с именем $. Это позволяет воспользоваться тем же $mol_type_pick, чтобы выбрать все компоненты.


type $mol_view_all = $mol_type_pick<    typeof $,    $mol_view,>

namespace $ {    export class $mol_view {}    export class $my_panel {}    export class $mol_tree {}}        {    $mol_view: typeof $mol_view    $my_panel: typeof $my_panel}

Декларативные ограничения


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


function $mol_style_define<    View extends typeof $mol_view,    Config extends Styles< View >>(    view : View,    config : Config)

type Config< View extends $mol_view > = {    $my_panel: {        $my_panel: {            ...        }        $my_deck: {            $my_panel: {                ...            }        }        ...    }    ...}

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


Не понятные типошибки


Но даже если вы дождётесь вывода типа, то сообщения ошибки вас не обрадуют, ибо из него не будет понятно где и почему возникла ошибка.



Императивные ограничения


Лучше в таком случае использовать императивный подход, где вы берёте фактический тип написанного пользователем JSON, в типогуарде проходитесь по нему и переданному компоненту и генерируете новый не рекурсивный тип, где оставлены лишь валидные поля и значения. А на не валидных тайпскрипт не сматчится и сообщит об ошибке.


function $mol_style_define<    View extends typeof $mol_view,    Config extends StylesGuard<        View,        Config,    >>(    view: View,    config: Config)

{    Details: {        foo: 'bar',    },}        {    Details: {        foo: never,    },}

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


Всё ещё непонятные ошибки


Если просто возвращать never для не валидных значений, то сообщения об ошибке хоть и будут иметь координаты, но не будет понятна причина, почему написанный код не валиден.



Понятные ошибки


Вместо never лучше возвращать специальный тип, который ни с чем не сматчится и в котором литералом прописать суть сообщения об ошибке и дополнительную информацию.



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


Атрибуты


Кстати, об атрибутах. В $mol_view они задаются как метод возвращающий словарь из строки в примитив.


attr *    ^    mol_link_current <= current false        attr() {    return {        ... super.attr(),        mol_link_current: this.current(),    }}

Стили для атрибутов


Для описания стилей для атрибутов просто вкладываем друг в друга: собачка -> имя атрибута -> значение -> (стили, элементы и прочий фарш).


{    '@': {        mol_link_current: {            true: {                zIndex: 1            }        }    }}

Псевдоклассы и псевдоэлементы


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


{    ':hover': {        zIndex: 1    }}

Медиа запросы


Так же можно вкладывать медиа-запросы. Это гораздо удобнее, чем в CSS, где медиа-запросы пишутся вне и требуют повторения селектора внутри.


{    $mol_scroll: {        overflow: 'scroll',        '@media': {            'print': {                overflow: 'visible',            },        },    },}

Непосредственно вложенные блоки


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


{    '>': {        $mol_view: {            margin: rem(1),        },        $mol_button: {            margin: rem(0),        },    },}

Что получилось


  • Каскадные переопределения стилей
  • Тайпчек всех ключей и значений
  • Подсказки по всем ключам и значениям
  • Описание всех свойств
  • Понятные сообщения об ошибках
  • Удобство описания стилей

Что можно улучшить


  • Рантайм чтение стилей до рендеринга (полезно для виртуализации)
  • Типизация всех свойств (прогресс 10 из 500)
  • Добавить все функции
  • Поддержать анимации
  • Типизированные выражения в calc (а не строка)

Попробовать вне $mol


Ести вас заинтересовал мой расказ и вы хотели бы попробовать поиграться с этим, но не готовы ещё перейти на $mol, то можете воспользоваться библиотекой mol_style_all, позволяющей описывать CSS-свойства.


import {    $mol_style_unit,    $mol_style_func,    $mol_style_properties,} from 'mol_style_all'const { em , rem } = $mol_style_unitconst { calc } = $mol_style_funcconst props : $mol_style_properties = {    margin: [ em(1) , rem(1) ],    height: calc('100% - 1rem'),}

codesandbox.io/s/molstyleall-ked9t


Там, конечно, нет генератора CSS, так как он сильно завязан на то, что генерирует $mol_view. Тем не менее, если вы заходите добавить эту функциональность к своему любимому фреймворку, то заходите к нам в чат mam_mol вместе подумаем, как бы это сделать.


Продолжение следует...


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


  • Сравнение типов
  • Типофункции
  • Типотесты
  • Типошибки
  • Типогуарды
  • Фильтрация интерфейсов
  • Брендированные примитивы

Куда пойти



Обратная связь


  • Хороший доклад. Хотелось бы побольше конкретных примеров. Вот у нас есть отрисованный дизайн, и вот, что мы написали, чтобы получился pixel-perfect.
  • Очень быстро говорит! Не успеваешь понять и думаешь, что надо пересматривать и гуглить.
  • Не работал с ТС, поэтому не могу обьективно оценить ценность трудов Дмитрия.
  • Для меня не сильно профильно, непонятно и не очень интересно.
Подробнее..

Вырезаем SSR и ускоряем Хабр в 10 раз

06.08.2020 22:09:54 | Автор: admin

Здравствуйте, меня зовут Дмитрий Карловский и я тот ещё токсичный перец. Недавно я источал свои альфа-флюиды на Альфа-банк. Ребята в ответ поступили достойно, и не стали атаковать меня в личку объяснениями, как сильно я не прав, а завели задачу на гитхабе. И даже что-то пофиксили, сделав часть моих претензий несостоятельными. Но не ту часть, где SSR портит всё.


Время утекло, пыль улеглась, и тут история получает продолжение: недавно ко мне обратился продюсер контент-студии Хабра с предложением пропесочить их Торт. Что ж, расчехляем вентилятор!



Сложный случай


Возьмём, например, вот эту страницу, содержащую 2500 комментариев. Это настолько огромная страница, что если вы откроете её в Хроме, то он обрежет её уже на 1400 комментарии. Чтобы прочитать оставшиеся вам придётся открыть её, например, в Огнелисе. Причину этого оставим на совести разработчиков. Давайте лучше подумаем как этого не допустить. Но сперва проведём замеры:


Показатель Десктопная версия (HTML) Мобильная версия (JSON) Ускоренная универсальная версия (JSON)
Размер данных 12 MB 3.4 MB 3.4 MB
Размер сжатых данных 1000 KB 700 KB 700 KB
Время полной загрузки 45 s 42 s 5 s
Время показа первого экрана 5 s 42 s 5 s
Число DOM элементов 116K 100K < 1K
Отзывчивость при прокрутке 700 ms не удалось замерить 30 ms
Пересчёт лейаута 1800 ms не удалось замерить 30 ms
Потребление памяти 15 MB 390 MB 63 MB


Методика измерений


  • Размер данных объём HTML или JSON выдачи. Показывается в девтулзах Хрома, если включить "широкие строки". Вес указан лишь того ресурса, что выдаёт комментарии.
  • Размер сжатых данных то, что девтулзы показывают по умолчанию для загруженных ресурсов.
  • Время полной загрузки время открытия всех комментариев (да, иногда Хром всё же рендерит всё не понятно от чего зависит), обычным секундомером на глазок от нажатия F5 (или ссылки "показать комментарии") до завершения всех видимых пользователю загрузок. Кеш отключался через девтулзы.
  • Время показа первого экрана то же, что и время полной загрузки, но дожидаясь лишь появления контента, заполняющего весь экран, что позволяет начать его читать.
  • Число DOM элементов выводился document.getElementsByTagName('*').length в режиме наблюдения, приведено максимально наблюдаемое число.
  • Отзывчивость при прокрутке длительность блокировки основного потока при скроллинге на одну страницу. Замерялось через профайлер.
  • Пересчёт лейаута длительность блокировки основного потока при однократном изменении размера окна. Замерялось через профайлер.
  • Потребление памяти объём, который показывают девтулзы для основного потока, после полной загрузки и ручного запуска сборки мусора.

Для всех замеров использовался обычный домашний вайфай и ноутбук с развёрнутыми на пол экрана девтулзами. Если что-то не учёл обязательно докопайтесь к этому в комментариях.



Предварительный анализ


У Хабра есть две версии: десктопная и мобильная. Десктопная загружает статью со всеми комментариями единым HTML. Мобильная же поступает хитрее: сначала загружается статья в виде HTML, а по клику на кнопку "комментарии" она подгружает JSON с комментариями и рендерит их с помощью VueJS вместо статьи. Но если на комментарии зайти по прямой ссылке, то будет загружен пререндеренный HTML. Пререндеренный HTML ничем принципиально не отличается от десктопной версии, поэтому я замерял именно вариант с динамическим рендером, который отрабатывает в большинстве реальных сценариев использования.


Как видно, вес JSON примерно в 4 раза меньше HTML. В формате Tree эти данные весили бы ещё меньше, но парсились бы дольше, ибо кастомный парсер на яваскрипте даже более простого формата всё же медленнее нативного JSON парсера. В любом случае, после сжатия, которое применяется сейчас повсеместно, разница уже не столь существенна порядка 30%.


Пререндеренный HTML довольно долго грузится, это связано с двумя факторами:


  1. Параллельно начинают грузиться все изображения со страницы, а это 8 мегабайт несжимаемого трафика, который капитально забивает канал. Решить эту проблему могли бы через loading="lazy", но не стали.
  2. После загрузки очередного куска HTML происходит его подклейка в общее дерево, что вызывает повторное вычисление стилей, лейаута и рендеринга. Так как стоимость этого процесса растёт по мере роста DOM, то зависимость суммарной задержки загрузки от размера HTML тут экспоненциальная. Решается только одним способом уменьшением HTML.

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


Кроме того, использование VueJS даёт довольно серьёзное пенальти по памяти оно увеличивается более чем в 25 раз.


Всего на страницу выводится около 100K DOM элементов. Это в среднем около 40 элементов на каждый комментарий.


Браузеру становится очень плохо от такого большого DOM дерева и он начинает серьёзно тупить. Например, обновление экрана при скроллинге, не смотря на его аппаратное ускорение, занимает 700мс. И это чисто скроллинг, без пересчёта лейаута. С ним же почти 2 секунды. А пересчитывается лейаут почти на любое изменение DOM дерева. DevTools тоже порой сходят с ума, что осложняет профилирование.



Выбор стратегии


Основная причина тормозов размер DOM дерева. Поэтому требуется такая реализация, которая позволит иметь на пару порядков меньшее число элементов на странице. Выпишем возможные стратегии:


Сворачивание Паджинация Виртуализация
Описание Ветки комментариев изначально свёрнуты и разворачиваются вручную. На одну страницу выводится ограниченное число комментариев. Чтобы увидеть остальные нужно переходить между страницами Рендерятся только комментарии, попадающие в видимую область, а остальные удаляются.
Решается ли проблема скорости загрузки Да Да Да
Решается ли проблема отзывчивости работы Нет, пользователь с большой вероятностью откроет много веток и получит тормоза. ### Да, размер дерева на каждой странице примерно одинаков и регулируем. Да, размер дерева зависит от размера экрана.
Поведение браузерного поиска по странице Ищет только в развёрнутом. ### Ищет только по текущей странице. ## Ищет только по видимым комментариям. ###
Что может вызвать раздражение пользователя Необходимость постоянно разворачивать ветки комментариев. ## Необходимость переходить между страницами. ## Трудно уследить за иерархией ответов. ### Скачки скроллбара. # Возможные скачки контента при скроллинге. ##
Наибольший уровень недовольства пользователя ### ### ## ### ## ## ### ## #

Выводы:


  1. Виртуализация меньше всего расстроит пользователей.
  2. Так как единовременно отображаются не все комментарии, придётся вручную реализовать поиск по всем комментариям.


Прототипирование


Самый простой способ воспользоваться виртуальным рендерингом переписать страницу на $mol, что позволит нам писать код даже не думая о виртуализации, но всё равно её получить, так как в $mol она встроена под капотом.


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


const Person = Rec({    id: Int,    login: Str,    avatar: Str,})const Comment =  Rec({    id: Int,    author: Maybe( Person ),    children: List( Int ),    message: Str,    timePublished: Maybe( Moment ),})const Comments_response = Rec({    comments: Dict( Comment ),    threads: List( Int ),})const Article = Rec({    titleHtml: Str,    textHtml: Str,})

Некоторые поля опциональны в них возвращается null для сообщений, оставленных НЛО.


Теперь мы можем легко загрузить данные, проверить их и вернуть типизированную структуру. Например, так выглядит загрузка комментариев:


@ $mol_memcomments_data() {    const uri = `https://m.habr.com/kek/v2/articles/${ this.article_id() }/comments/`    const data = Comments_response( this.$.$mol_fetch.json( uri ) )    return data}

Хорошо, данные мы загрузили, осталось их показать. Текст статей и комментариев Хабр возвращает в виде строки, содержащей довольно разношёрстный HTML. Для его отображения воспользуемся компонентом $mol_html_view:


<= Article $mol_html_view    html <= article_content \    highlight <= search    image_uri!node <= image_uri!node \

(Если вы не знакомы с синтаксисом view.tree, предназначенным для декларативной композиции компонент, то можете ознакомиться с кратким или полным его описанием.)


Этот компонент берёт HTML, парсит его и для каждого элемента создаёт соответствующий $mol_view компонент, а он уже сам себя виртуализирует. Кроме того, $mol_html_view позволяет указать строку, которая будет подсвечена в тексте.


Также нам потребовалось перегрузить метод image_uri, который вызывается для каждого IMG элемента, чтобы получить ссылку на картинку. Причина в том, что в атрибуте src в выдаче Хабра изначально находится лишь ссылка на заглушку, а реальная ссылка на изображение берётся из атрибута data-src. Поэтому реализуем этот метод так:


image_uri( node : HTMLImageElement ) {    return node.dataset.src || node.src || 'about:blank'}

Рендеринг дерева комментариев не представляет из себя ничего особенного. Разве что мы добавим возможность сворачивать/разворачивать ветки комментариев.


@ $mol_mem_keycomments_visible( id : number ) : readonly number[] {    if( this.comment_expanded( id ) ) {        return this.comments_all( id )    } else {        return this.comments_filtered( id )    }}

Эта функциональность полезна и сама по себе, но нам она ещё пригодится и для поиска. Дело в том, что $mol пока ещё не умеет прокручивать сролл к заданному компоненту. Штука эта вполне реализуема, но у меня руки пока не дошли, так что если если кто-то возьмётся за это дело было бы супер.


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



Многие пользователи привыкли к хоткею Ctrl+F для поиска по странице, поэтому добавим плагин $mol_hotkey для его перехвата:


plugins /    <= Search_key $mol_hotkey        mod_ctrl true        key * F?event <=> search_focus?event null    <= Theme $mol_theme_auto

Ну а для фокусирования просто доберёмся до нужного нам компонента и скажем ему сфокусироваться:


search_focus( event : Event ) {    this.Search().Suggest().Filter().focused( true )    event.preventDefault()}

Обратите внимание, что мы так же добавили плагин $mol_theme_auto, который автоматически ставит светлую или тёмную тему в зависимости от предпочтений пользователя.


Кроме того, добавим пользователю возможность вручную менять тему, разместив на тулбаре кнопку $mol_lights_toggle:


tools /    <= Lights $mol_lights_toggle    <= Sources $mol_link_source        uri \https://github.com/nin-jin/habrcomment    <= Search $mol_search        query?val <=> search?val \

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


Наведём немного красоты, переопределив некоторые дефолтные стили, используя модуль $mol_style, который обеспечивает тайпчек и подсказки, учитывающие реальную иерархию компонент:


$mol_style_define( $my_habrcomment , {    Orig: {        flex: {            grow: 1,            shrink: 0,            basis: per(50),        },    },    Article: {        maxWidth: rem(60),    },    Comments: {        flex: {            shrink: 0,            grow: 1,        },    },    Comments_empty: {        padding: rem(1.5),    },} )

Последним штрихом добавим поддержку оффлайна:


include \/mol/offline/install

Этим не хитрым кодом мы установили кеширующий Service Worker, который в случае проблем с соединением будет выдавать данные из кеша.


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


Что ж, вот наш прототип читалки хабракомментариев и готов: https://nin-jin.github.io/habrcomment/#article=423889


Держите букмарклет, позволяющий открывать в ней любую тяжёлую статью:


document.location = document.location.href.replace( /\D+/ , 'https://nin-jin.github.io/habrcomment/#article=' )


Анализ прототипа


Код исходников уложился в 400 строк, на написание которых требуется не более пары часов. По функциональности:


  • Отображение произвольной статьи с форматированием
  • Отображение дерева комментариев к статье с форматированием
  • Возможность сворачивать/разворачивать комментарии
  • Поиск по статье/комментариям с подсветкой найденного и сворачиванием лишнего
  • Работа в оффлайне
  • Поддержка тёмной/светлой тем
  • Адаптивность к размеру экрана

Мы добились ускорения полной загрузки огромной страницы в 5 раз. А потребление памяти уменьшилось в 6 раз по сравнению с мобильной версий (и в 2 раза, если отключить виртуализацию). Для мобилок это куда актуальней, чем для десктопа. При этом мы ещё даже не приступили к собственно оптимизации кода.


Если пошаманить можно выиграть ещё несколько секунд. Например, в наивной реализации HTML всех комментариев парсится при открытии страницы для расчёта их минимальных размеров исходя из содержимого. Однако, можно руками задать какое-то константное значение, меньше которого текст комментария точно быть не может:


<= Message $mol_html_view    minimal_height 60    highlight <= search \    html <= message \    image_uri!node <= image_uri!node \

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


Итого по скорости загрузки: почти десятикратное ускорение полной загрузки при сохранении времени появления контента в 5 секунд.


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


@ $mol_memcomments_data() {    const search = encodeURIComponent( this.search() )    const uri = `https://m.habr.com/kek/v2/articles/${ this.article_id() }/comments/?search=${search}`    const data = Comments_response( this.$.$mol_fetch.json( uri ) )    return data}

Реализована сейчас, разумеется, не вся функциональность. Но всё остальное особой погоды не делает. Чтобы довести до продакшена, надо будет ещё много чего реализовать. Если кто-то готов этим заняться можете попробовать. Правда сперва придётся договориться с руководством Хабра об использовании их API. Я пробовал несколько раз подкатить с разных сторон никакого ответа не получил.


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


$mol_html_view поддерживает сейчас лишь сравнительно небольшой набор HTML-элементов далеко не всё, что может выдавать Хабр. Добавить поддержку остальных в принципе не сложно и она, конечно, будет расширяться по мере необходимости.


Кроме того, есть и технические косяки:


  1. Контент при скроллинге иногда скачет это какой-то косяк в логике виртуализации $mol_list. Не приятно, но жить можно. Как-нибудь конечно же починю.
  2. В сафари автоматически отключается виртуализация сверху, так как он не поддерживает overflow-anchor, необходимый для того, чтобы можно было менять контент сверху от видимой области без скачков видимого контента. Это означает, что страница будет открываться всё так же быстро, но по мере прокрутки вниз будет дорендеривать всё остальное содержимое, пока не отрендерит все 100K элементов, когда пользователь домотает до самого конца.

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



Резюме


  1. SSR не быстрее грамотной реализации PWA.
  2. Вместо поддержки двух абы как слепленных реализаций для разных девайсов, лучше потратить это время на одну, но толковую и адаптивную.
  3. Выбирая между двух зол, лучше сделать шаг в сторону возможно именно там расположилось добро.
  4. VueJS тупит, а $mol рулит.
  5. Браузеру становится очень плохо, когда в доме много элементалей.
  6. Стоит предпочитать те решения, что не приводят к не контролируемому увеличению числа элементалей в доме.


Ссылки


  1. Исходники читалки можете заметить, что кода там всего ничего.
  2. Страница фрейморка $mol ужаснитесь сколько всего у нас там есть.
  3. Канал с новостями о $mol и MAM подписывайтесь, чтобы быть в курсе всего важного, что с ними происходит.
  4. Канал с видео о $mol когда-нибудь тут появятся видео-туториалы.
Подробнее..

Категории

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

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru