Личный блог
EN RU

Как я запилил своё портфолио

Ура! Вот я и закончил писать своё портфолио. Теперь любой желающий может зайти на zhevak.name, посмотреть на красивые эффекты, изучить мой опыт работы и даже сделать предложение, от которого невозможно отказаться включающее ДМС.

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

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

Планирование

Работа менеджера начинается с daily scrum meeting, а работа здорового человека — с постановки цели и декомпозиции задач на атомарные. Моя цель — создать собственное портфолио, чтобы в итоге получить работу в компании мечты, где за интеллектуальный труд платят достойные деньги. Это должен быть сайт, где в интерактивной форме описывается весь мой предыдущий опыт. Он должен выглядеть современно, быть поддерживаемым, использовать передовые технологии.

Не буду врать, концепция «операционной системы» в браузере у меня была давно, так что вопрос стоял только в реализации. Первый шаг перед планированием любого серьёзного web-сайта — разбиение контента на страницы. Выше страниц в иерархии только оболочка: контроллер этих страниц. Под страницей я понимаю такой компонент, который занимает собой всё пространство. В нашем случае намечается 4 таких компонента:

  • BiosScreen — имитация экрана работы BIOS.
  • GrubScreen — имитация экрана загрузчика GNU GRUB.
  • EffectScreen — неимоверно крутая заставка, подогревающая интерес пользователя.
  • DesktopScreen — форма входа и рабочий стол со всей важной информацией.

Что дальше? Надо определиться с технологиями. На сегодняшний день есть несколько интересных фреймворков для клиентской части:

  • Angular от Google. Я не написал ничего серьёзного с использованием этого фреймворка, и это неспроста. Первая версия была ужасной в структурном плане, было совершенно не очевидно, какие сущности за что отвечают. В те времена BEM от Яндекс выглядел гораздо логичнее, на нём я и писал. Затем появилась вторая версия, где работало не всё, а документация была настолько сырой, что часто приходилось читать исходный код. Потом четвёртая, восьмая... Но я так и не понял одного: зачем мне использовать эту библиотеку, если уже давно существуют другие решения, гораздо более гибкие и удобные для разработчика, с отличным open-source сообществом. Пока я не работаю в Google, не буду использовать Angular.
  • Vue.js от Evan You. Неплохой фреймворк с богатой инфраструктурой, за рамки которой, однако, выйти довольно сложно. Кроме того, во Vue довольно мутно реализованы концепции ООП, поэтому существуют некоторые проблемы при SSR и использовании нескольких экземпляров библиотеки. Я ничего рисовать на сервере не планирую, но без надобности использовать Vue не буду.
  • React от Facebook. На мой взгляд это лучший фреймворк на рынке, предоставляющий наибольшую гибкость при разработке интерактивных web-приложений. Кроме того, он имеет огромную поддержку сообщества: множество готовых библиотек сомнительного качества написаны или адаптированы под React. И, конечно, использование jsx-разметки вместо строковых шаблонов делает его просто незаменимым.
  • Svetle? Polymer? MyGrandmaFramework? Нет, спасибо, мне нужно решение, которое лучше и быстрее решает задачи бизнеса. У меня нет времени писать всё с нуля.

А какой язык выбрать? WebAssembly я пока не рассматриваю, а кроме него есть 3 основных кандидата:

  • JavaScript без типизации, неплохой вариант для маленьких и средних проектов, но для долгоживущих монстров лучше выбрать что-нибудь построже.
  • Flow от Facebook, который всё ещё безбожно тормозит под Linux, создавая множество процессов. Я не знаю, что с ним не так, но у меня на ноутбуке эта штука работает довольно медленно.
  • TypeScript от Microsoft, безоговорочный лидер в типизации JS на сегодняшний день. И хотя я не могу назвать TS стабильным, с ним можно работать, особенно в Visual Studio Code. К тому же, язык развивается, и новые версии стали значительно лучше предыдущих.

CSS будем писать на Stylus. Здесь нечего обсуждать: препроцессор имеет самый лаконичный синтаксис, замечательно работает на проектах любого масштаба, позволяет писать сложные прозрачные миксины, требует минимальной конфигурации. Многие могут спросить, почему не PostCSS? Потому что он имеет плавающий баг в механизме разрешения зависимостей, который проявляется только на больших проектах, потому что я не хочу тратить несколько часов на настройку конфликтующих друг с другом плагинов, потому что его гибкость не нужна при разработке стандартных сайтов.

И, напоследок, необходимо выбрать решение для управления состоянием. Можно взять популярный redux, но зачем себя мучить? Давайте попробуем завести MobX на большом проекте.

BiosScreen

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

Логотипы в SVG

Все мы помним логотипы Award Modular BIOS и Energy Star, мелькающие во время загрузки старых компьютеров. Однако где их взять в SVG? Ну, скажем, один из них можно скачать с Википедии и немного подретушировать... Но надо ли нам видеть эти плавные линии? Нет, нам нужны пикселы, но не размытые антиалиасингом, а в хорошем качестве!

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

 1 const logoNode = document.createElement('img');
 2 const canvasNode = document.createElement('canvas');
 3 
 4 logoNode.onload = () => {
 5   const {naturalHeight, naturalWidth} = logoNode;
 6   canvasNode.height = naturalHeight;
 7   canvasNode.width = naturalWidth;
 8   document.body.appendChild(canvasNode);
 9 
10   const canvasContext = canvasNode.getContext('2d');
11   canvasContext.drawImage(logoNode, 0, 0);
12   const imageData = canvasContext.getImageData(
13     0, 0,
14     naturalWidth, naturalHeight,
15   );
16 
17   let svgOutput = `<svg
18     xmlns="http://www.w3.org/2000/svg"
19     viewBox="0 0 ${naturalWidth} ${naturalHeight}"
20   >`;
21   for (let w = 0; w < naturalWidth; w++) {
22     for (let h = 0; h < naturalHeight; h++) {
23       const index = (w + h * imageData.width) * 4;
24       const [r, g, b, a] = imageData.data.slice(index, index + 4);
25       if (r + g + b < 128) {
26         // Filter black pixels
27         continue;
28       } else {
29         const color = r > 128 ? '#ff0' : '#0f0';
30         svgOutput += `<rect
31           x="${w}"
32           y="${h}"
33           width="1"
34           height="1"
35           fill="${color}"
36         />`;
37       }
38     }
39   }
40   svgOutput += `</svg>`;
41 
42   // Copy output to logo.svg manually
43   console.log(svgOutput);
44 };
45 
46 // Logo https://www.hardwaresecrets.com/wp-content/uploads/original1.gif
47 logoNode.src = `data:image/gif;base64,R0lGODlhiQBVAJEAAP//////AAD/
48 AAAAACwAAAAAiQBVAAAC/5yPqcvtD6OctNqLgt68bwyG4nh45omS6kqigeWy8tyYs0
49 3nqqcnfA+cdIKMIfGY4SAfxqWu6XRAoy0lVTK9Xqxa4acL+4Ir3DFTYwaJ04oym7x+
50 x99btHxOr9vH+Lx6r9Xn9/cSCDi4c4gkiBjCmPPYSLgUKTlJVGl52ZOpGRHXqeeZCK
51 pIOkqqGOqFmjhgaioS2xr2Clt4SiuLe4s7MquLtYcHTFEcDAHYd5zMjFzja1sUXfe8
52 abBMbaxtLdw267wQ3u2jDc7dTC76jf6nXjstM/5ui756pu6iD61/Ap3fz5+UgDi+vS
53 P4gx/CWPforXCTxGGVduzazZNYbhzEiP+6KJZrNm8jRwwiIZX8OO1iPY8q62Hz6Mql
54 j4ziWq78BNNgiZzppI08tGZOw53MVAJ9+NLXMKWlmPJMmfMi0Kc7P3JZSjToUqo1w4
55 WkxhWKFTu8yr5Q9mFoxqcawTLF9yoJWrJnxWA1MvRkTVYoq/rkuNWtElB+tXJNCofv
56 yJeIZ/5Fo4qmY7rRbP71hkUn0ZsR75ozK7hQU0dtMfvdHHcq1styRYoWajmUM6dr6U
57 p+nM6iaHak2cJc3Rr4WODxzjHu606xlMaAnaYwVuIfNt4KBx62fot5UqXwGJalXtG1
58 1K7ZTt+IXmSx9qwJ5YaJ0VX9w9fx0LuvHRy8lyaf25j/tI+cKqpx44Zae6F2hG3iOH
59 ZbFvmt94uDQPBS33F96ZWVefNRoaB/GaS0Ek9DGEgHZAS+FV5LBXVTUmVQxSZhR8Cg
60 iN9ElrXS4W3zXRcMhhj9CGSQQtIhQJFGHolkkkouyWSTTj4JZZRSQjlklVZeqQ6SAx
61 yJgAALcGmAkWEm4GWXB3ippZhbFjlmm2eqCWaaZ64pZplb0slmmmqSyaebdpo5pp1o
62 9jmnn4AKuqabhiL6552Bklkmooo6SmmhlkbKJqGDPgropZY6ymikmlK6qaeNZipppa
63 QqEOeepb6J6pynjqqoqI++imeYcLoKKpx3ptpqpoROqmWniw67aq3HOiZq7K+fcjor
64 s8nKykC0njbLKLLAHkqttMS22aihh27LLatcWmstrLrWGWynu9qqKrp4AnvutfMmWw
65 AAOw==`.replace(/\n/g, '');
66 
67 document.body.appendChild(logoNode);

Что там получилось? 95 Кб, многовато. Запустим svgo logo.svg и оптимизируем на 68%! Уже 30 Кб, но можно ли ещё что-то сделать? Открываем любой SVG-редактор и объединяем все квадраты-пикселы одного цвета в единый путь. Сохраняем, снова запускаем svgo... 3.8 Кб, меня устраивает. А теперь повторяем манипуляции со вторым логотипом.

beep

Как проиграть звук динамика во время «загрузки» операционной системы? Ну тут всё просто!

1 const sound = 'data:audio/mp3;base64,...';
2 const beep = () => {
3   const audio = new Audio(sound);
4   audio.play();
5 };
6 beep();

Запускаем! Работает. Или не работает? Работает не всегда. А всё потому, что в некоторых случаях вываливается ошибка DOMException: play() failed because the user didn't interact with the document first, то есть браузер не хочет воспроизводить звук до тех пор, пока пользователь не совершит какое-нибудь действие. Можно, конечно, просто обернуть вызов play() в Promise, но пищать динамик от этого чаще не станет. Поэтому мы идём на StackOverflow и читаем рекомендации.

Среди прочего там советуют засунуть вызов beep() в обработчик мышиных событий. Так и поступим, только ведь пищать надо не тогда, когда курсором по сайту поводили, а в определённый момент времени. Так мы ещё setTimeout добавим! А на случай, если mouseenter так и не случилось, попробуем пропищать внутри Promise.

Со стороны браузера логично было бы воспроизводить звук в любом случае, если произошло какое-то взаимодействие, но почему-то писк работает только из обработчика событий. Видимо, имеет значение цепочка вызовов до метода play.

GrubScreen

Мы только разобрали самую простую страницу с BIOS, а уже столько проблем. А по-другому и не бывает. Теперь нужно «запрограммировать» GNU GRUB. Можно было бы углубиться в детали, создать собственную консоль, поднять настоящий эмулятор на JavasSript... Но зачем? Это же не суть портфолио, а «интересных» задач ещё прибавится, поверьте ;-) Так что я решил пойти простым путём: при выборе любого пункта меню, кроме самого портфолио, показывать ошибку. А что, может кто-то grub.cfg испортил? Всякое бывает...

Поскольку верстать этот экран было сложнее, чем писать его бизнес-логику, я здесь расскажу о подключении компонентов к MobX. На первый взгляд всё просто: есть специальные обёртки observer и inject. Пиши inject('$store') (observer(Component)) и пользуйся! Всё так, но есть нюансы.

mobx-react

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

Вторая особенность касается декоратора inject, и она гораздо более коварная. К тому же, об этом ничего не написано в документации. Дело в том, что при обновлении какого-либо observable или computed свойства, inject формирует новый объект с хранилищами. Этот объект содержит те же поля, что и предыдущий (набор хранилищ остаётся неизменным), но всё же он другой. Так что, если применить inject не напрямую к observer, то можно получить перерисовку половины jsx-дерева при обновлении любого свойства. Подробнее об этом можно прочитать в этой теме. Т.к. в большинстве случаев inject применяется непосредственно к observer, то и делать с этим ничего не планируют. В остальных случаях достаточно обернуть компонент перед инъекцией в React.memo.

HOC в HOC

Чтобы не вызывать каждый раз друг за другом inject и observer, чтобы нормально типизировать использование хранилищ через props и чтобы отписываться от всего сразу одним методом, я написал свои декораторы. Советую поступить так же всем, кто использует MobX и TypeScript вместе.

EffectScreen

BIOS нарисовали, GNU GRUB нарисовали, теперь необходим загрузчик самой «операционной системы». И если на предыдущих страницах стояла задача аккуратно скопировать «дизайн», то здесь можно и нужно использовать любые графические средства, чтобы создать вау-эффект.

Конечно, надо 3D-графику! Пусть пользователь видит красивую заставку, а потом вжжжж, камера отлетает от монитора и он оказывается в комнате, а там свет мигает, и много компьютеров... То есть мониторов. А потом в конце камера опять залетает в монитор! И надо что-нибудь хакерское, например, дракона из Kali Linux. И пусть он дышит огнём! Круто получится? Не факт, ведь всё зависит от реализации. А как такую сцену красиво собрать в браузере, ведь всё тормозить будет? Ну, если ничего не делать, то и оптимизировать ничего не придётся ;-) Только кого это впечатлит?

Зарисовка

Не так-то просто держать в голове стены, окна и 12 мониторов на 4 столах. Поэтому перед созданием 3D-сцены лучше сделать набросок от руки: что и куда расставить, как двигать камеру и т.д. Это не займёт много времени, зато потом позволит быстро выставить необходимые размеры и координаты.

Three.js

Поскольку моделлер из меня никакой, вряд ли я построю сцену сложнее, чем набор параллелепипедов, даже в каком-нибудь сложном 3D-редакторе. Поэтому я решил создавать всё из кода, тем более в Three.js для этого есть множество вспомогательных классов. Так даже лучше, успокаиваю я себя, не придётся асинхронно загружать модели, будет меньше полигонов. А что с текстурами? Текстуры придётся подождать в любом случае.

К счастью, Three.js уже позаботился об асинхронной загрузке ресурсов. Для этого у него имеется специальный LoadingManager, а если лениво создавать экземпляр и прокидывать его во все загрузчики, взгляните на DefaultLoadingManager.

Другой особенностью Three.js является активное потребление памяти :-D Но если вызывать метод dispose в componentWillUnmount, то проблем быть не должно.

SkyCube

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

Texture + Material + Geometry = Mesh

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

Кстати, уже после окончания работы, я наткнулся на интересную библиотеку react-three-fiber. Судя по API, которое она предоставляет, я мог бы написать код сцены в декларативном стиле. Что-нибудь вроде того:

 1 <Canvas>
 2   <DirectionalLight />
 3   <SpotLight />
 4   <SkyCube />
 5   <House>
 6     <Table>
 7       <Monitor letter='Z' />
 8       <Monitor letter='H' />
 9       <Monitor letter='E' />
10     </Table>
11     <Table>
12       <Monitor letter='V' />
13       <Monitor letter='A' />
14       <Monitor letter='K' />
15     </Table>
16   </House>
17 </Canvas>

Освещение

Изначально в моей сцене было 3 источника света. С DirectionalLight всё понятно, его никуда не деть, а вот два других, красный и синий, заметно подтормаживали. В итоге я оставил один мигающий SpotLight, который в середине представления меняет цвет. Это дало некоторый прирост производительности и почти не сказалось на визуальной составляющей.

Камера

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

Мониторы

Сам монитор состоит из 7 параллелепипедов, и обсуждать их нет никакого смысла. Гораздо интереснее поговорить про дисплей, то есть PlaneGeometry с текстурой CanvasTexture. Фабрика монитора принимает элемент canvas, а возвращает Group из кубов и экрана.

На всех мониторах я решил скромно отобразить по одной букве из строки STEPANZHEVAK. Это мои имя и фамилия, всего 12 букв, как и мониторов (бывают же совпадения). Чтобы ускорить рендеринг, я установил очень низкое разрешение у всех канвасов, всего 160x90 пикселов. У всех, кроме первого и последнего. Ведь первый монитор будет показывать intro-заставку, а последний outro-заставку. Кстати, никогда в кадре не присутствуют сразу оба монитора, поэтому я буду рисовать на них один и тот же канвас. На производительность это вряд ли повлияет, а память точно сэкономит.

Кстати, Three.js ничего не знает про содержимое всех этих канвасов, а обновлять каждый кадр все CanvasTexture на сцене нет никакой возможности (это может быть слишком медленно). Поэтому при каждом изменении картинки нужно вручную сообщить, что неплохо бы обновить текстуру. Делается это очень просто:

1 // Typings in @types/three are a bit broken
2 (mesh as any).material.map.needsUpdate = true;

Вроде всё ясно, кроме одного момента. Мониторы у нас все как один имеют пропорции 16x9, а вот экран пользователя (а точнее окно его браузера) может быть любой ширины и высоты. То есть если просто направить камеру на первый монитор, то intro-заставка либо обрежется, либо, наоборот, захватит края монитора. Конечно, будут и счастливчики, у которых монитор чётко встал в окно браузера, но как сделать счастливыми всех пользователей, а не только избранных? Похоже, придётся показывать заставки на 2D-канвасе, а потом делать плавный переход к 3D-сцене. Оххх... Так я и сделал, и в итоге ни разу не пожалел.

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

Вешаем обработчик на mousemove и рисуем курсор на всех мониторах, кроме первого и последнего. Чтобы наверняка не тормозило, оборачиваем его в throttle на 16 * 4 мс (примерно 4 кадра). Теперь пользователь может водить мышкой по экрану, а курсор будет двигаться на всех мониторах. Однозначно стало веселее.

Intro

Казалось бы, заставка уже готова: камера летит по комнате с мониторами, а на них написано моё имя. Но такая сцена даже близко не напоминает процесс загрузки операционной системы. Именно поэтому на первом и последнем мониторах необходимо создать какую-то более осмысленную анимацию. Для intro подойдёт дракон, который выдыхает с огнём букву S.

Дракон по своей сути — это 4 пути из SVG-картинки. Чтобы анимировать их появление, мы будем обводить каждую линию пунктиром длиной в весь путь и таким же отступом. А смещение зададим от 0 до length. Это весьма распространённый приём при работе с SVG, но от этого не менее эффектный, особенно если покрутить разные опции обводки (то есть контекста, на котором рисуем).

1 const path = new Path2D('YOUR SVG PATH HERE');
2 const length = 5000; // empirically measured length of path
3 const progress = 0.5; // from 0 to 1
4 context.setLineDash([length, length]);
5 context.lineDashOffset = length * progress;
6 context.stroke(path);

Как же нарисовать огонь? Пока не требуется ничего сверхреалистичного, довольно просто. В нашем случае в роли огня выступят 50 частиц (кругов) цвета огня. Они будут менять свой размер, позицию и прозрачность. После того, как частица отлетит от дракона достаточно далеко и полностью «остынет» (станет прозрачной), она вернётся к нему в пасть и вылетит оттуда с прежним пылом.

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

Наконец, последним штрихом должна стать буква S, плавно наполняемая огнём. Здесь можно своровать позаимствовать много интересных решений из motion-дизайна, но я выбрал самое-самое простое из всех возможных. Я решил наложить на букву S жирную маску-синусоиду и постепенно убирать её. А для пущего реализма добавил анимацию цвета: от огненного до белого. Чтобы граница маски не была слишком резкой, я наложил ещё одну полупрозрачную маску, идущую с опережением. Можно было бы подумать и про градиент, но его будет сложнее сделать, и он будет медленнее работать.

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

Outro

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

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

Функциональное программирование?

Чтобы не создавать ложного впечатления простоты, я напишу прямо: делать заставку от intro и до outro было сложно, долго и больно. Это здесь, в статье, всё красиво разложено по полочкам, а там, в истории git, десятки неудачных попыток и столько же исправлений. Любой сложный проект — это множество итераций. Заставка длится 18 секунд, и если бы я смотрел её каждый раз от начала до конца, то сейчас доделывал бы дракона, а не писал эту статью.

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

TypeScript

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

global

Начнём с простого. Если чего-то не хватает в глобальной области видимости, можно добавить:

 1 declare global {
 2   const requestIdleCallback: (
 3     callback: () => void,
 4     options?: {timeout?: number},
 5   ) => number;
 6   const cancelIdleCallback: (callbackId: number) => void;
 7 
 8   interface Window {
 9     requestIdleCallback: typeof requestIdleCallback,
10     cancelIdleCallback: typeof cancelIdleCallback,
11   }
12 }

Типизированный CSS и форк foovar

Теперь задача посложнее: написать типы для CSS. Типизированные CSS классы давно можно получить с помощью typed-css-modules, однако консольную утилиту tcm придётся запускать в параллельном терминале перед webpack, это крайне неудобно. Можно прикрутить typed-css-modules-loader, но из-за асинхронной записи на диск, TypeScript может ругаться на несуществующие модули. Что же делать? Я просто написал свой загрузчик, который записывает *.d.ts файлы синхронно. Я уже слышу, как эксперты кричат, что синхронная запись блокирует остальные операции и ни в коем случае не должна использоваться в загрузчиках webpack, однако я, со своими 60 файлами стилей, вообще никакой разницы в скорости сборки не заметил. Открою небольшой секрет: я даже DtsCreator из typed-css-modules не использовал, а названия классов для генерации типов распарсил регулярным выражением. Примерно так это выглядит:

1 const source = '.red {color: blue}';
2 const classNames = source
3   .replace(/('|").*?\1/g, '')
4   .match(/\.-?[_a-zA-Z]+[_a-zA-Z0-9-]*/gm);
5 const uniqueClassNames = Array.from(new Set(classNames || []));

Люблю всё простое. Этот велосипед ни разу не подвёл меня за всю работу над проектом.

Осталось как-то пробросить данные из Stylus в TypeScript. Есть кустарная библиотека foovar, которая записывает переменные из *.styl в *.js, но делает это весьма нетривиально. Вместо объекта она предоставляет сложную структуру с функциями-геттерами. Я создал форк и добавил режим tree. Он записывает в файл обычный объект, а рядом (при указании опции types) генерирует типы. Работает, и на том спасибо.

Как написать HOC

Сколько же я прочитал увлекательных статей, рассказывающих о процессе создания higher-order components под React на TypeScript. Такое ощущение, что это была одна статья, пересказанная разными людьми, потому что в итоге мне всё равно пришлось изобрести свой собственный способ. Как же так? Дело в том, что зачастую статьи пишут одни люди, а код — другие. Правильный HOC должен удовлетворять целому ряду условий:

  • HOC должен работать как с классами, так и с функциями.
  • HOC должен сочетаться с другими HOC, как в примере withClassName (withI18n(Component)).
  • HOC должен сохранять статические методы и свойства компонента, а также их типы.
  • HOC должен сохранять публичные методы и свойства компонента, а также их типы.
  • HOC должен иметь возможность добавить статические методы и свойства.
  • HOC должен иметь возможность добавить публичные методы и свойства.
  • Если в HOC передан ref, то он должен содержать итоговый компонент со всеми модификациями.
  • Должна быть возможность указать тип компонента для React.createRef.

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

  1 import React, {PureComponent} from 'react';
  2 import hoistNonReactStatics from 'hoist-non-react-statics';
  3 
  4 type ConstructorInstanceType<
  5   TProps extends {},
  6   TComponent extends React.JSXElementConstructor<TProps>
  7 > = TComponent extends (
  8   new (props: TProps) => React.Component<TProps, any>
  9 )
 10   ? InstanceType<TComponent>
 11   : React.ReactElement<TProps, TComponent>;
 12 
 13 type Wrapped<TProps extends {}> = React.JSXElementConstructor<TProps>
 14   | {
 15     WrappedComponent: React.JSXElementConstructor<TProps>,
 16   }
 17   | {
 18     WrappedComponent: {
 19       WrappedComponent: React.JSXElementConstructor<TProps>,
 20     }
 21   }
 22   | {
 23     WrappedComponent: {
 24       WrappedComponent: {
 25         WrappedComponent: React.JSXElementConstructor<TProps>,
 26       }
 27     }
 28   };
 29 
 30 type WrappedComponent<W extends Wrapped<any>> = (
 31     W extends {
 32       WrappedComponent: {
 33         WrappedComponent: {
 34           WrappedComponent: infer TComponent
 35         }
 36       }
 37     }
 38       ? TComponent
 39       : (
 40         W extends {
 41           WrappedComponent: {
 42             WrappedComponent: infer TComponent
 43           }
 44         }
 45           ? TComponent
 46           : (
 47             W extends {
 48               WrappedComponent: infer TComponent
 49             }
 50               ? TComponent
 51               : (W extends infer TComponent
 52                 ? TComponent
 53                 : never
 54               )
 55           )
 56       )
 57   )
 58 );
 59 
 60 type WrappedProps<W extends Wrapped<any>> = (
 61   W extends {
 62     WrappedComponent: {
 63       WrappedComponent: {
 64         WrappedComponent: React.JSXElementConstructor<infer TProps>
 65       }
 66     }
 67   }
 68     ? TProps
 69     : (
 70       W extends {
 71         WrappedComponent: {
 72           WrappedComponent: React.JSXElementConstructor<infer TProps>
 73         }
 74       }
 75         ? TProps
 76         : (
 77           W extends {
 78             WrappedComponent: React.JSXElementConstructor<infer TProps>
 79           }
 80             ? TProps
 81             : (
 82               W extends React.JSXElementConstructor<infer TProps>
 83                 ? TProps
 84                 : never
 85             )
 86         )
 87     )
 88 );
 89 
 90 type WrappedInstanceType<
 91   W extends Wrapped<any>
 92 > = ConstructorInstanceType<
 93   WrappedProps<W>,
 94   WrappedComponent<W>
 95 >;
 96 
 97 
 98 type AaaProps = {aaa: number};
 99 
100 const withAaa = <
101   TStaticProps extends {},
102   TProps extends AaaProps = AaaProps
103 >(
104   Component: React.JSXElementConstructor<TProps> & TStaticProps,
105 ) => {
106   type Props = Omit<TProps, keyof AaaProps> & Partial<AaaProps>;
107   const TypedComponent: React.JSXElementConstructor<
108     TProps
109   > = Component;
110   const Wrapper: React.FunctionComponent<Props> = (
111     props,
112     ref?: React.Ref<ConstructorInstanceType<TProps, typeof Component>>
113   ) => {
114     const componentProps = {
115       ...props,
116       aaa: 777,
117     } as TProps;
118     return <TypedComponent {...componentProps} ref={ref} />
119   };
120 
121   const WrapperWithRef = React.forwardRef<
122     WrappedInstanceType<typeof Component>,
123     React.PropsWithChildren<Props>
124   >(Wrapper);
125 
126   return Object.assign(
127     hoistNonReactStatics(WrapperWithRef, Component),
128     {
129       aaaStaticNumber: 123,
130       WrappedComponent: Component,
131     },
132   );
133 };
134 
135 
136 type BbbProps = {bbb: number};
137 
138 const withBbb = <
139   TStaticProps extends {},
140   TProps extends BbbProps = BbbProps
141 > (
142   Component: React.JSXElementConstructor<TProps> & TStaticProps,
143 ) => {
144   type Props = Omit<TProps, keyof BbbProps> & Partial<BbbProps>;
145   const TypedComponent: React.JSXElementConstructor<
146     TProps
147   > = Component;
148   const Wrapper: React.FunctionComponent<Props> = (
149     props,
150     ref?: React.Ref<ConstructorInstanceType<TProps, typeof Component>>
151   ) => {
152     const componentProps = {
153       ...props,
154       bbb: 999,
155     } as TProps;
156     return <TypedComponent {...componentProps} ref={ref} />
157   };
158 
159   const WrapperWithRef = React.forwardRef<
160     WrappedInstanceType<typeof Component>,
161     React.PropsWithChildren<Props>
162   >(Wrapper);
163 
164   return Object.assign(
165     hoistNonReactStatics(WrapperWithRef, Component),
166     {
167       bbbStaticNumber: 321,
168       WrappedComponent: Component,
169     },
170   );
171 };
172 
173 
174 type TestProps = AaaProps & BbbProps & {
175   test: string,
176 };
177 
178 class Test extends PureComponent<TestProps> {
179   static staticProperty = 999;
180 
181   static printStaticProperty () {
182     console.log(this.staticProperty);
183     return this.staticProperty;
184   }
185 
186   publicProperty = 888;
187 
188   printPublicProperty () {
189     console.log(this.props.aaa, this.props.bbb, this.publicProperty);
190     return this.publicProperty;
191   }
192 
193   render () {
194     return (
195       <h1>
196         {this.props.test}
197         {this.props.children}
198       </h1>
199     );
200   }
201 }
202 
203 const WrappedTest = withBbb(withAaa(Test));
204 
205 
206 type InlineProps = AaaProps & BbbProps & {
207   inline: number,
208 };
209 
210 const Inline: React.FunctionComponent<InlineProps> = (props) => {
211   return (
212     <span>
213       {props.aaa}
214       {props.bbb}
215       {props.children}
216     </span>
217   );
218 };
219 
220 const WrappedInline = withBbb(withAaa(Inline));
221 
222 
223 class Parent extends PureComponent {
224   private wrappedTestRef = React.createRef<
225     WrappedInstanceType<typeof WrappedTest>
226   >();
227 
228   render () {
229     return (
230       <WrappedTest test='abc' ref={this.wrappedTestRef}>
231         Hello world!
232         <WrappedInline inline={123} />
233       </WrappedTest>
234     );
235   }
236 
237   componentDidMount () {
238     const wrappedTestComponent = this.wrappedTestRef.current;
239     if (!wrappedTestComponent) {
240       throw 'WrappedTest cannot be used';
241     }
242 
243     WrappedTest.printStaticProperty();
244     console.log(1, WrappedTest.aaaStaticNumber);
245     console.log(2, WrappedTest.bbbStaticNumber);
246 
247     wrappedTestComponent.printPublicProperty();
248     console.log(3, wrappedTestComponent.props.aaa);
249     console.log(4, wrappedTestComponent.props.bbb);
250     console.log(5, wrappedTestComponent.props.test);
251   }
252 }
253 
254 export default Parent;

Что за дичь здесь происходит? Есть два компонента, один Test, другой Inline. И тот, и другой — обёрнуты в декораторы withAaa и withBbb. Эти декораторы добавляют публичные свойства aaa и bbb, статические aaaStaticNumber и bbbStaticNumber, но при этом сохраняют собственные свойства и методы компонентов. В итоге Parent рендерит их и проверяет, что всё работает правильно. Особое внимание стоит обратить на вспомогательный тип WrappedInstanceType. Он нужен, чтобы извлекать правильный тип обёрнутого компонента.

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

helpers & hocs

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

Color

Класс для работы с цветами. Конструктор принимает либо строку, либо 3-4 компонента цвета. Умеет возвращать строку в формате rgb и rgba, умеет менять альфа-канал. При загрузке сайта все цвета из CSS сразу превращаются в экземпляры Color.

EventBus

Просто типизированная шина событий со стандартным интерфейсом: on, off и emit. Проверяет обработчики событий на соответствие своему интерфейсу. От EventBus наследует, например, GlobalEventBus, которая работает с событиями window и document.

GlobalStorage

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

eventWithThreshold

Часто нужно выполнить обработчик mousedown или touchstart только после того, как курсор сдвинулся на некоторое расстояние. Чтобы каждый раз не считать дистанцию, я вынес это поведение в декоратор. На вход принимает функцию-обработчик, расстояние и ось (x, y или xy). Пришлось им воспользоваться целых 7 раз.

extractCoordinates

Этой функции можно скормить MouseEvent или TouchEvent, а взамен получить координаты. Пригодилась 15 раз.

getRandom

Math.random вызывается 9 раз, а getRandom целых 10. Разница в том, что мой вариант принимает seed и для одинаковых входных данных генерирует одинаковые псевдослучайные числа.

sendAnalytics

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

withI18n

Этот HOC добавляет любому компоненту два новых свойства: language и i18n. С его помощью сделана вся локализация проекта:

 1 import withI18n, {I18nProps} from 'hocs/withI18n';
 2 const dictionary = {
 3   en: {hello: 'Hello world!'},
 4   ru: {hello: 'Привет, мир!'},
 5 };
 6 
 7 class Component extends PureComponent<I18nProps<typeof dictionary>> {
 8   render () {
 9     return this.props.i18n.hello;
10   }
11 }
12 
13 export default withI18n(dictionary)(Component);

withClassName

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

webpack

Всем интересно, кто и как собирает проекты. Наверное, скоро будут появляться новые вакансии вроде Front-End DevOps, ведь настройка окружения, среды разработки и доставки кода занимают всё больше и больше времени. И это правильно, ведь автоматизация любого повторяющегося процесса многократно окупается в будущем.

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

development VS production

В моём проекте помимо 5 бандлов для самого сайта, собирается 50 бандлов для 50 работ, которые открываются в виртуальном браузере (и это без учёта CSS). То есть у меня есть конфигурации для app и для iframe, а также для development и для production окружений. Многие части этих конфигураций повторяются с некоторыми изменениями. Эти повторяющиеся куски я выношу в отдельные объекты, которые потом объединяю с помощью webpack-merge, а для плагинов создаю фабрики. Чтобы было понятнее, напишу некоторые названия. Среди прочих у меня есть baseConfigScheme, appConfigScheme, iframeConfigScheme, productionAppConfigScheme, extractStylesConfigScheme и, к примеру, функция createHtmlPlugin.

webpackProxy

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

С тех пор я стал немного счастливее:

  1 // WebpackProxy.ts
  2 const undefinedSymbol = Symbol();
  3 
  4 class WebpackProxy<TTree extends Record<string, Record<string, any>>> {
  5   static expect <T extends any>() {
  6     return undefinedSymbol as any as T;
  7   }
  8 
  9   private valueBranches: TTree | undefined;
 10   private symbolBranches: TTree;
 11 
 12   constructor (symbolBranches: TTree) {
 13     this.symbolBranches = symbolBranches;
 14   }
 15 
 16   use <TId extends keyof TTree, TRecord extends Record<string, any>>(
 17     id: TId,
 18     target: TRecord,
 19   ) {
 20     const symbolBranch = this.symbolBranches[id];
 21     return new Proxy<
 22       TRecord & TTree[TId]
 23     >(target as TRecord & TTree[TId], {
 24       get: (target, key: keyof TTree[TId]) => {
 25         if (!this.valueBranches) {
 26           throw 'WebpackProxy must be configured!';
 27         }
 28 
 29         const valueBranch = this.valueBranches[id];
 30         if (symbolBranch[key] !== undefinedSymbol) {
 31           return target[key];
 32         } else if (valueBranch && valueBranch.hasOwnProperty(key)) {
 33           return valueBranch[key];
 34         } else {
 35           throw `Value ${id}['${key}'] is not defined!`;
 36         }
 37       },
 38 
 39       getOwnPropertyDescriptor: (target, key: keyof TTree[TId]) => {
 40         return undefined
 41           || Object.getOwnPropertyDescriptor(symbolBranch, key)
 42           || Object.getOwnPropertyDescriptor(target, key);
 43       },
 44 
 45       has: (target, key: keyof TTree[TId]) => {
 46         return key in symbolBranch || key in target;
 47       },
 48 
 49       enumerate: (target) => {
 50         return Object.keys(target).concat(Object.keys(symbolBranch));
 51       },
 52 
 53       ownKeys: (target) => {
 54         return Reflect.ownKeys(target).concat(
 55           Reflect.ownKeys(symbolBranch)
 56         );
 57       },
 58     });
 59   }
 60 
 61   configure (valueBranches: TTree) {
 62     this.valueBranches = valueBranches;
 63   }
 64 }
 65 
 66 export default WebpackProxy;
 67 
 68 // webpack.config.ts
 69 import WebpackProxy from './WebpackProxy.ts';
 70 
 71 const webpackProxy = new WebpackProxy({
 72   cssModules: {
 73     localIdentName: WebpackProxy.expect<string>(),
 74   },
 75 });
 76 
 77 const appConfigScheme = {
 78   module: {
 79     rules: [{
 80       test: /\.styl$/,
 81       use: [{
 82         loader: 'css-loader',
 83         options: {
 84           modules: webpackProxy.use('cssModules', {mode: 'local'}),
 85         },
 86       }],
 87     }],
 88   },
 89 };
 90 
 91 export {webpackProxy, appConfigScheme};
 92 
 93 // webpack.development.ts
 94 import {webpackProxy} from './webpack.config';
 95 webpackProxy.configure({
 96   cssModules: {
 97     localIdentName: '[name]-[hash:base64:2]-[local]',
 98   },
 99 });
100 
101 // webpack.production.ts
102 import {webpackProxy} from './webpack.config';
103 webpackProxy.configure({
104   cssModules: {
105     localIdentName: '[hash:base64:8]',
106   },
107 });

Обратите внимание, что типы внутри ловушек ничего не значат, т.к. мы их поломали, обозначив target как TRecord & TTree[TId] (что, очевидно, неправда). Всё для того, чтобы обращаясь к объекту, TypeScript подсказывал нам проксированные свойства.

Если я забуду выставить значение для какой-то переменной, TypeScript сообщит мне об этом. Если я проигнорирую и его замечания, то WebpackProxy сразу выкинет runtime-ошибку.

Разделение кода

Понять, как лучше всего разделить свой код на бандлы, можно с помощью плагина webpack-bundle-analyzer. Ясно, что директорию node_modules следует отделить от основного кода, а вот всё остальное не так очевидно.

Я вынес зависимости в отдельный файл vendor.js, основной код содержится в bootstrap.js, который сразу начинает асинхронно подгружать компоненты Smoke.js и EffectScreen.js, использующие Three.js. Также в отдельный файл переехал компонент OfferApp.js, т.к. он использует очень длинный и тяжёлый список городов и стран.

Для отображения компонентов, загружаемых через dynamic import, я использовал React.lazy и React.Suspense. Ничего сложного в этом нет, но следует помнить одну деталь: загрузка файла начнётся только после отрисовки HOC. Если хочется начать загрузку сразу, необходимо вызвать import за пределами обёртки, вот так:

1 import(
2   /* webpackChunkName: 'EffectScreen' */
3   'components/EffectScreen/EffectScreen'
4 );
5 const EffectScreen = React.lazy(() => {
6   return import('components/EffectScreen/EffectScreen');
7 });

DesktopScreen

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

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

Переиспользуемые компоненты

Некоторые компоненты используются в проекте несколько раз. О самых интересных из них я расскажу ниже.

FancyInput

Все поля ввода в портфолио реализованы с помощью FancyInput. Этот компонент анимирует каретку и каждый символ, вводимый и удаляемый пользователем. Чтобы понять, какие буквы нужно показать или скрыть, приходится с учётом выделения текста высчитывать разницу между предыдущим и текущим состояниями. Чтобы правильно установить позицию каретки, приходится суммировать ширину всех введённых символов (конечно, используется кеширование).

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

FancySelect

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

Когда руки дойдут, FancySelect тоже уедет в open source.

FancyCheckbox

Самое простое поле ввода. При реализации флажков следует помнить только про фокус и работу с тегом label.

FancyNumber

Обёртка вокруг FancyInput, которая работает только с числами.

TouchArea

Весьма интересный компонент, предназначенный для обработки mousemove и touchmove событий. TouchArea может быть использована для создания слайдера или боковой панели, она сообщает обо всех изменениях currentProgress. Если отпустить кнопку мыши или убрать палец от смартфона, TouchArea анимирует значение currentProgress до targetProgress. В моём портфолио компонент использован в форме авторизации, в календаре и каталоге, в галерее и плеере, на виртуальном рабочем столе для мобильных устройств.

SmartSurface

Я использую в своём портфолио для работы с шейдерами gl-react, и, в частности, gl-react-dom. Это замечательная библиотека, но есть у неё пара недостатков, которые чуть не заставили меня делать форк.

Во-первых, она всегда вызывает requestAnimationFrame, даже если ничего не происходит (то есть даже если uniforms не обновляются). Для этого мне пришлось написать обёртку, которая в активном состоянии отображает Surface, а в неактивном — обычный canvas. Но что рисовать на этом канвасе? Перед тем, как скрыть Surface, пришлось делать снимок (есть специальный метод surface.capture()) и рисовать его на CanvasContext2D.

Во-вторых, я хочу иметь только один обработчик requestAnimationFrame, именно поэтому я использую globalEventBus (см. выше). Более того, мой обработчик учитывает тот факт, что браузер может тормозить (пропускает кадр, если предыдущий рисовался дольше 17 мс). К счастью, разработчики библиотеки предоставили вспомогательную функцию createSurface, в которой можно переопределить requestAnimationFrame и cancelAnimationFrame.

 1 import globalEventBus from 'constants/globalEventBus';
 2 import GLViewDOM from 'gl-react-dom/GLViewDOM';
 3 import {createSurface} from 'gl-react';
 4 import {Surface} from 'gl-react-dom';
 5 
 6 const CustomSurface = createSurface({
 7   GLView: GLViewDOM,
 8   RenderLessElement: 'span',
 9   mapRenderableContent: (node: any) => {
10     return node instanceof Element ? node.firstElementChild : null;
11   },
12   requestFrame: (handler: (deltaTime: number) => void) => {
13     const once = (deltaTime: number) => {
14       globalEventBus.off('animationframe', once);
15       handler(deltaTime);
16     };
17     globalEventBus.on('animationframe', once);
18     return once;
19   },
20   cancelFrame: (once: () => void) => {
21     once && globalEventBus.off('animationframe', once);
22   },
23 }) || Surface;

Как видим, гибкость библиотеки позволила мне воспользоваться ей и сэкономить уйму времени.

Desktop Environment

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

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

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

Архитектура

Вся оконная система состоит из трёх основных сущностей:

  • AppFrame — компонент окна, который просто отображает текущее состояние и рисует приложение из props.children. Через обработчики событий вызывает различные методы своего $appFrame.
  • $appFrame — экземпляр AppFrameStore, который хранит и меняет текущее состояние окна. Доступ к этому хранилищу имеют AppFrame, само приложение и $interface (см. ниже).
  • $interface — экземпляр InterfaceStore, который хранит в себе все экземпляры AppFrameStore (по одному на каждое приложение) и управляет ими. Он также распоряжается фокусом и виртуальными рабочими столами.

У каждого $appFrame имеется менеджер, в моём случае это всегда $interface. Менеджер предоставляет всем окнам информацию о виртуальных рабочих столах (сколько их, какой активен, включён ли режим сетки), а также интерфейс для открытия, закрытия, сворачивания, фокусировки окна. Таким образом все эти операции происходят только через $interface.

Давайте откроем плеер, а потом сапёр. Поиграем в сапёр и закроем его. Сперва может показаться, что закрытие окна влияет только на само окно, а значит должно обрабатываться внутри AppFrameStore. Но кто получит фокус закрытого сапёра? Конечно, открытый плеер, и отвечает за такое положение вещей никто иной как $interface. Поэтому всё, что связано с фокусом, идёт через него.

Открытие и закрытие

Для ускорения работы сайта я решил не рендерить закрытые приложения. Всё DOM-дерево приложения появляется только при открытии окна. Но за один кадр создать и вставить гигантскую структуру в DOM не получится, анимация открытия будет жутко тормозить или просто пропускаться. Поэтому между запросом на открытие приложения и появлением окна я выжидаю некоторое время (использую requestIdleCallback, и обычно задержка незаметна для глаза). Пусть пользователь думает, что приложение загружается в оперативную память :-) Это почти правда.

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

ExpandMode

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

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

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

Допустим, получилось посчитать новую позицию, и окно удачно схлопывается из всех трёх режимов (full, left и right), но есть и вторая проблема: пользователь после начала схлопывания как назло продолжает водить своей проклятой мышкой! Позиция окна постоянно меняется, но в то же самое время CSS анимирует свой transition. Таким образом окно двигается очень медленно, а пользователь понимает, что интерфейс делал фронтендер, а не настоящий программист.

С точки зрения анимации есть два способа решения проблемы:

  1. анимировать transform, а изменять при движении курсора left и top;
  2. обернуть окно в ещё один контейнер, у которого будет свой transform.

Второй способ будет работать быстрее, его я и выбрал. Но CSS transition — это лишь вершина айсберга. Какие координаты выставлять этому внешнему контейнеру? Выходит, что в обычном состоянии пользователь изменяет координаты самого окна, а в состоянии схлопывания — его контейнера. И, конечно же, когда схлопывание закончится, необходимо без анимации прибавить к окну координаты контейнера, а контейнер вернуть на прежнее место.

Изменение размера

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

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

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

Другой интересный вопрос — адаптация какого-нибудь приложения под размеры своего окна, ведь CSS правила @media не заработают. Чтобы не вызывать render-метод приложения каждый раз при изменении ширины, я создал computed свойство, которое меняется каждые 100 пикселов. Это вполне позволительно с точки зрения производительности.

Изменение позиции

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

Рабочий стол

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

VirtualDesktopSwitcher

Почти разобрались с оконной системой. Теперь нужно сделать переключатель рабочих столов. Кликнув по нему правой кнопкой мыши, пользователь попадёт в режим сетки. Кликнув по рабочему столу, он окажется на рабочем столе. Получается, нужно просто добавить к стилям transform: scale(0.5) и transition: transform. Всё гениальное просто, переключатель действительно так работает.

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

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

Календарь

Знаете, за что я люблю Microsoft? Помимо TypeScript и сапёра они создали нормальный календарь, в котором можно приблизить год и увидеть месяцы, приблизить месяц и увидеть дни, в котором можно листать непрерывную ленту, где один месяц перетекает в другой. В своё время это была революция на рынке календарей! Жаль, что никакого рынка календарей не существует, и никто даже не заметил блестящий интерфейс календаря в Windows 7.

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

CatalogApp

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

Все свои проекты я разбил по времени, в итоге получилось 9 категорий (8 лет + лучшие). В каждой категории до 12 проектов. И ещё я решил добавить поиск. И ещё 3D-переключение между категориями. И ещё дым с прожекторами. Но обо всём по порядку.

Категории

Первым делом надо реализовать эффект переключения категорий. У нашего каталога будет свойство currentProgress, из которого мы посчитаем всё остальное. Значение 0 будет значить первую категорию, 100 — вторую, 200 — третью... Воспользуемся уже упомянутым ранее компонентом TouchArea, чтобы анимировать и менять currentProgress с помощью событий мыши и касаний экрана. Посчитаем для каждой категории opacity и transform (translate3d и rotate3d) в зависимости от currentProgress. Крайние категории не должны слишком сильно уезжать в пустоту. Вот и всё!

Давайте попробуем переключить категорию с 2019 на 2018. Ура, заработало! А теперь на 2014... О, нет, всё поломалось. В процессе перехода мы увидели 2017, 2016 и 2015 годы. Чтобы избежать таких разочарований, нужно отображать только текущую и следующую категории, а ещё немного по-другому вычислять прогресс анимации для пересчёта стилей.

Поиск

Теперь разберёмся с поиском. Поиск — это, можно сказать, отдельная категория. Она активируется при изменении поля ввода с пустого на непустое. Как это происходит? Все остальные категории уезжают вверх, а категория поиска приезжает снизу. Чтобы сделать это красиво, воспользуемся CSS свойством mask-image. Оно поддерживается браузерами с октября 2017 года.

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

Дым

Осталось нарисовать красивый фон с реалистичным дымом. Как же его сделать? Зачастую в 3D-играх в роли дыма выступают спрайты, повёрнутые к камере. То есть разработчики обманывают игрока: вместо отображения физической модели, они показывают плоские картинки, крутят их, сжимают и растягивают. А люди верят, что перед ними настоящий дым.

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

Ого! Сцена оказалась настолько же красивой, насколько и медленной. Давайте попробуем что-нибудь ускорить.

  • Увеличим каждый спрайт в 10 раз и удалим 90% плоскостей. Гораздо лучше, но даже оставшиеся 10 картинок включают вентилятор ноутбука.
  • Давайте уменьшим разрешение квадратной текстуры в 2 раза. Кажется, помогло, давайте ещё в 2 раза. Опять помогло, давайте ещё раз. Нет, это уже не дым, а пятна какие-то, вернём 64 пиксела.
  • Что бы ещё оптимизировать? Давайте уменьшим разрешение канваса в 8 раз и растянем его, ведь кидать лучи в 3D-камеру дороже, чем масштабировать картинку. Сработало, но только потому, что мы рисуем дым (чёткость изображения сильно упала).
  • Уже почти можно пользоваться каталогом, но всё же иногда картинка подвисает. Давайте уменьшим скорость анимации дыма и обернём render-функцию в throttle на 100 мс.

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

ResumeApp

Резюме состоит из зашифрованных секций с информацией, загружаемой из resume.json, и камеры наружного наблюдения. Если навести курсор на секцию, она расшифруется, и пользователь увидит данные на своём языке. В верхней секции можно найти ссылки на мои аккаунты в соцсетях, а также скачать резюме в разных форматах, которое автоматически генерируется на основе того же resume.json (см. ниже раздел про AutoResume).

(Де)?шифратор

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

Каждая секция представлена сложным DOM-деревом, содержащим не связанные друг с другом строки. Мы обернём все строки в компонент Encoder, но как закодировать их одним и тем же параметром? Для таких случаев разработчики React придумали контекст, которым очень просто воспользоваться. Нужно обернуть всю секцию в context.Provider, а каждую строку в context.Consumer.

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

Чтобы всегда получать одинаковую анимацию, мы заменим Math.random на getRandom с предсказуемым значением (см. раздел helpers & hocs), а также округлим probability с точностью до 0.2. Помимо стабильного результата, детерминированные значения probability позволят нам добавить кеширование уже закодированных ранее строк.

Давайте теперь наглядно продемонстрируем всё вышесказанное:

 1 // contexts.ts
 2 export const encoderContext = createContext<number>(0);
 3 
 4 // Encoder.ts
 5 import React from 'react';
 6 import {encoderContext} from './contexts.ts';
 7 import getRandom from 'helpers/getRandom';
 8 
 9 type Props = {children: React.ReactText | React.ReactText[]};
10 const abc = [
11   'abcdefghijklmnopqrstuvwxyz',
12   'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
13   '0123456789@#$%^*()_',
14 ].join('');
15 const cache: Record<number, Record<string, string>> = {};
16 
17 const Encoder: React.FunctionComponent<Props> = React.memo(
18   ({children}) => (
19     <encoderContext.Consumer>
20       {(encoderProbability: number) => {
21         const array = children instanceof Array
22           ? children
23           : [children];
24         let source = '';
25         array.forEach((a) => {
26           if (typeof a !== 'number' && typeof a !== 'string') {
27             throw 'Invalid child type!';
28           }
29           source += a;
30         });
31 
32         const key = Math.round(encoderProbability * 20) / 20;
33         const probabilityCache = cache[key] || {};
34         if (probabilityCache.hasOwnProperty(source)) {
35           return probabilityCache[source];
36         }
37         cache[key] = probabilityCache;
38 
39         let prevCharCode = source[source.length - 1].charCodeAt(0) || 0;
40         const cipher = source.split('').map((sourceCharacter, s) => {
41           const sourceCharCode = sourceCharacter.charCodeAt(0);
42           const i = sourceCharCode + source.length * 0.01 + s * 0.1;
43           const isChanged = getRandom(i) < key;
44           const speed = 1 + 2 * getRandom(sourceCharCode - s);
45           const seed = s
46             + source.length
47             + sourceCharCode
48             + prevCharCode
49             + Math.floor(key * speed * 5);
50           const index = getRandom(seed);
51           const newCharCode = Math.floor(abc.length * index);
52           prevCharCode = newCharCode;
53           return isChanged ? abc[newCharCode] : sourceCharacter;
54         }).join('');
55 
56         probabilityCache[source] = cipher;
57         return cipher;
58       }}
59     </encoderContext.Consumer>
60   )
61 );
62 
63 export default Encoder;
64 
65 // Section.ts
66 import React from 'react';
67 import {encoderContext} from './contexts.ts';
68 
69 const Section: React.FunctionComponent = () => (
70   <encoderContext.Provider value={0.5}>
71     <Encoder>Hello world!</Encoder>
72   </encoderContext.Provider>
73 );
74 
75 export default Section;

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

Телевизор

Компонент Television показывает видео с камер наружного наблюдения. Для реалистичности переключения камер мы задействуем GLSL: будем искажать пространство и добавлять помехи. Но это требует некоторых ресурсов, поэтому нет смысла обновлять картинку, если телевизор не видно. Узнать о том, когда компонент появился в кадре или уехал из него, поможет IntersectionObserver.

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

 1 const startTime = Date.now();
 2 class Television {
 3   /* ... */
 4   private handleVideoDurationChange = (
 5     event: React.SyntheticEvent<HTMLVideoElement>,
 6   ) => {
 7     const {currentTarget} = event;
 8     if (!currentTarget.duration) {
 9       return;
10     }
11     const elapsedTime = 0.001 * (Date.now() - startTime);
12     currentTarget.currentTime = elapsedTime % currentTarget.duration;
13   }
14   /* ... */
15 }

Адаптивный дизайн

Как уже было написано, использовать директиву @media для стилизации приложений не получится, поэтому было создано computed свойство width100, изменяемое каждые 100 пикселов. При плавном изменении размеров окна приложение подстраивается под них так же, как адаптивный сайт под размеры браузера. Но если пользователь решит распахнуть или схлопнуть окно, то значение width100 установится мгновенно, а вот реальная геометрия будет меняться в процессе анимации. Это приведёт к поломанной разметке и вряд ли вызовет восторг у кого-то, кроме тестировщиков.

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

PlayerApp

Писать свой видеоплеер — то ещё удовольствие, но куда деваться? К счастью, я уже его доделал, осталось только рассказать как.

Иконка

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

Элементы управления

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

Для этой цели я прикрутил компонент react-sortable-hoc и написал для него собственную обёртку. Дело в том, что эта библиотека анимирует только элементы внутри списка. А вот если бросить перетаскиваемый объект, то он мгновенно встанет на свою новую позицию. Пользователи хотят анимацию, и они её получат!

SmartSurface и video

Пришло время заняться плеером, да не простым, а инновационным.

Хочется сразу добавить к хранилищу $player свойство isPlaying, но не стоит дублировать данные, ведь реальное состояние видео уже отражается в videoNode.paused. А вот флажок shouldBePlaying будет очень удобен. Во время загрузки или перемотки видеоряд может тормозить. Несмотря на это, чаще всего нас интересует именно состояния плеера, а не самого видео (то есть включено ли видео в плеере, надо ли его по возможности воспроизводить).

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

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

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

Во-вторых, инициализация WebGL контекста занимает некоторое время. Она проходит быстро, но не мгновенно, и эта пауза заметна невооруженным глазом. Неприятно наблюдать эффект с задержкой в 100-300 мс. Поэтому контекст мы будем держать всегда, а вот обновлять станем только при необходимости.

И, наконец, в-третьих, нужно как-то засунуть кадры в шейдер для обработки. Библиотека gl-react способна и на такое, причём прямо из коробки, но есть проблема с Firefox. То ли из-за механизма кеширования, то ли из-за особенностей рендеринга, огненный лис при перемотке видео не сразу рисует кадр, поэтому в шейдер сперва попадает пустой массив точек. Пользователь в это время видит чёрный квадрат, который через мгновение заменится обработанным кадром. Чтобы исправить проблему, нужно самостоятельно рисовать кадры на канвасе, а в шейдер отдавать содержимое этого канваса. Таким образом можно раньше времени не затирать предыдущее изображение. Сделать просто, но сколько времени надо, чтобы догадаться до этого?..

BrowserApp

Браузер у меня упрощённый, он скорее напоминает какой-нибудь WebView и не дотягивает даже до Internet Explorer, а о сходстве с Opera или Vivaldi приходится только мечтать. Тем не менее у него есть вкладки, панель быстрого доступа и адресная строка.

Панель быстрого доступа

Напомню, что все приложения могут менять размер от 320x240 и до бесконечности, а воспользоваться @media нет никакой возможности. Чтобы поменьше мучиться с адаптивной вёрсткой панели, я использовал flex-flow: row wrap. Те проекты, которые не влезают в видимые строки, просто обрезаются.

Чтобы уменьшить количество DOM-элементов и ускорить отрисовку браузера, я заменил все линии вокруг иконок проектов на SVG-картинки, после чего вместо 5 прямоугольников осталось 1 изображение... Состоящее из 5 элементов rect :-) Зато теперь можно заменить horizontalPins0.svg на horizontalPins0.jpg, но делать этого я, конечно, не буду.

Сортируемые вкладки

За анимацию при появлении и удалении вкладок отвечает компонент Animate из пакета rc-animate. Вместе с тем вкладки должны сортироваться, причём делать это плавно. Как мы помним, в плеере уже была сортировка файлов в плейлисте с помощью обёртки над react-sortable-hoc. Эту же обёртку я применил и здесь, только с горизонтальной осью.

Всё это очень занимательно, но есть гораздо более важная деталь: каждая вкладка привязана к своему хранилищу, где сохранены заголовок, адрес, состояние и другие данные страницы. Эти же хранилища управляют процессом загрузки страницы внутри iframe. При переключении вкладки адресная строка браузера пересоздаётся, и пользователь всегда видит правильный адрес. А вот неактивные элементы iframe просто скрываются, чтобы не пришлось потом повторно загружать в них страницы. Оказывается, если спрятать iframe с помощью display: none, то прокрутка страницы сбросится, а вот visibility: hidden ничего не ломает.

Загрузка iframe

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

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

Самым простым и гибким решением для моего портфолио оказалась загрузка через src страницы empty.html, и последующее общение с ней через postMessage. Таким образом встроенный в «пустышку» скрипт заранее устанавливает глобальные переменные, а при получении сообщения от родителя вставляет загруженный контент в свой body. Если пользователь грузит несуществующую страницу, то ошибка возникает ещё на этапе AJAX-запроса, а значит родительское окно может отправить postMessage с ошибкой.

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

Чтобы сделать процесс загрузки бесшовным, необходимо использовать два элемента iframe вместо одного.

Внутри iframe

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

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

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

Портфолио в портфолио

Чёрт возьми! Я слышал вам нравится смотреть портфолио, поэтому я добавил портфолио в портфолио, чтобы вы могли изучить портфолио, пока смотрите портфолио. © Xzibit

А если серьёзно, то одной из работ является само портфолио, которое можно открыть в виртуальном браузере. В iframe-режиме некоторые элементы скрыты или упрощены, но в целом никаких проблем с рекурсией не возникло. Единственная особенность, которую хотелось бы отметить — Web Font Loader не работает внутри фрейма в Firefox.

Автодополнение

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

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

OfferApp

Я подумал, что 99% рекрутеров будут искать ссылку для скачивания резюме. Они могут это сделать в 4 различных форматах на 2 языках, запустив ResumeApp. Но можно пойти ещё дальше и создать приложение для отправки предложений о работе прямо с сайта.

Ameba

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

Ладно, где-то я такое уже видел. Заверстал амёбу на SVG и обрадовался, что на всё ушёл один вечер. Получилось действительно красиво, но как-то медленно. Всё-таки 20 FPS сильно режут глаз. Я попробовал немного оптимизировать фильтры и маски, уменьшить размер амёбы, но это дало прирост только до 25 FPS. Уже лучше, но всё равно не впечатляет.

Что ж, придётся пробовать план B. Хорошо, когда он есть. Я вынес амёбу в отдельный файл Ameba.html и покрасил в белый цвет на чёрном фоне, затем изменил все тайминги на делители 12, записал видео длиной 12+ секунд и вырезал первые 12 секунд. Как вы, наверное, догадались, получился зацикленный футаж, поверх которого я наложил изображение градиента с mix-blend-mode: multiply. В итоге значение FPS выросло до 60, но эффект всё ещё выглядит медленным. Конечно, я же записывал его на том же ноутбуке, и просчёт SVG во время записи тоже тормозил. Умножаем все тайминги на 5 и записываем новое видео, чтобы потом ускорить его в 5 раз. Готово!

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

PoliticalMap

На втором шаге пользователю (рекрутеру) предстоит заполнить информацию о компании, в том числе её географическое месторасположение. По-хорошему, здесь нужна карта, но где её взять? Google Maps и Yandex.Maps не дают возможности выбирать и подсвечивать страну. Можно, конечно, определять её по координатам клика, но выглядеть это будет ужасно. Вот бы найти где-то границы всех государств в SVG... Такие карты есть на simplemaps.com и amcharts.com.

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

В общем, остаток дня я вручную составлял таблицу соответствия кодов и границ, названий государств и их столиц на двух языках. Получилось 176 стран. Если всё-таки государства не оказалось в списке, можно выбрать «другую страну». Но как организовать сортировку, чтобы этот пункт меню был первым? Можно вручную запихнуть его в начало списка после сортировки, а можно в название вставить символ \u000c, который расположен перед всеми буквами алфавита.

PinchPanZoom

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

А интересно другое. Для масштабирования я использовал CSS свойство transform: scale(5), в связи с чем в Safari возникли проблемы. Этот браузер не учитывает трансформации при отрисовке SVG, поэтому карта становится размытой, как будто мы приблизили растровое изображение. Чтобы побороть баг, нужно нарисовать гигантскую карту, а потом уменьшать её.

Фокус

Управление фокусом — это боль. Сперва кажется, что достаточно установить tabIndex={-1} для неактивных контролов, а для активных tabIndex={0}. Затем вспоминаешь, что хорошим тоном при смене шага является установка фокуса на первый активный контрол. Позже обнаруживаешь, что фокус прокручивает форму, а из-за react-custom-scrollbars свойство scrollTop меняется не у того DOM-элемента. Чтобы исправить проблему, приходится слушать scroll события, а в обработчике «переносить» значение прокрутки от одного элемента к другому.

Google Cloud Functions

Пришло время отправлять форму, но куда? До этого момента весь сайт отдавался через webpack-dev-server или nginx, то есть был полностью статичным (как бы это забавно не звучало). Что-то не хочется усложнять инфраструктуру и создавать сервер ради отправки одной формы.

Как-то раз я сделал на конкурс от банка Тинькофф игру и выиграл билет на конференцию HolyJS. К сожалению, после четырёх докладов я вышел подышать воздухом на улицу и меня вывернуло несколько раз, а потом я попал в Боткинскую больницу Санкт-Петербурга с отравлением (не подумайте плохо про конференцию, потому что отравился я творожком из магазина). Так вот среди тех докладов был один про AWS Lambda от Марины Миронович.

Я вспомнил про него и решил написать свой контейнер, только на Google Cloud Functions, который бы принимал запрос и отправлял мне на почту письмо.

 1 const nodeMailer = require('nodemailer');
 2 
 3 exports.sendJobOffer = (request, response) => {
 4   response.setHeader('Access-Control-Allow-Origin', '*');
 5 
 6   if (request.method !== 'GET' && request.method !== 'POST') {
 7     response.status(405).send(request.method);
 8     return;
 9   }
10 
11   const transport = nodeMailer.createTransport({
12     host: 'smtp.gmail.com',
13     port: 465,
14     secure: true,
15     auth: {
16       user: process.env.GMAIL_USERNAME,
17       pass: process.env.GMAIL_PASSWORD,
18     },
19   });
20 
21   const mailOptions = {
22     from: process.env.GMAIL_USERNAME,
23     to: process.env.GMAIL_USERNAME,
24     subject: 'New Job Offer',
25     html: request.body || '',
26   };
27 
28   transport
29     .sendMail(mailOptions)
30     .then(() => {
31       response.status(200).send();
32     })
33     .catch((error) => {
34       response.status(500).send(error);
35     });
36 };

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

ViewerApp

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

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

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

С одной стороны, переход от одной картинки к другой, реализованный на GLSL, даёт программисту безграничные возможности для творчества. А с другой — внутри шейдера почему-то не работает CSS, поэтому теряется контроль над вёрсткой. Так, например, если распахнуть или схлопнуть окно галереи, канвас не будет обновляться синхронно с CSS transition. Именно поэтому пользователь не видит изображение во время анимации окна :-(

MinesweeperApp

Небольшой бонус для тех, кто дочитал долистал до сюда: сейчас я расскажу, как написать свой сапёр. Такой же, как в Windows 95, только в браузере. Чтобы добиться показателя в 60 FPS, будем рисовать ячейки на канвасе. Причём не обязательно перерисовывать все клетки, часто достаточно обновить только одну. Я не буду заострять внимание на том, как загрузить спрайты, вызвать drawImage и обработать мышиные события. Вместо этого я разберу два важных алгоритма, используемых в игре.

Генерация поля

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

 1 type MinesweeperCell = {
 2   hint: number,
 3   hasMine: boolean,
 4   hasFlag: boolean,
 5   isDisclosed: boolean,
 6 };
 7 
 8 let mineCount = 0;
 9 const flagCount = 40;
10 const fieldResolution = {x: 16, y: 16};
11 const field: MinesweeperCell[][] = [];
12 
13 for (let x = 0; x < fieldResolution.x; x++) {
14   const column: MinesweeperCell[] = [];
15   for (let y = 0; y < fieldResolution.y; y++) {
16     const cell: MinesweeperCell = {
17       hint: 0,
18       hasMine: false,
19       hasFlag: false,
20       isDisclosed: false,
21     };
22     column.push(cell);
23   }
24   field.push(column);
25 }
26 
27 while (mineCount < flagCount) {
28   const x = ~~(Math.random() * fieldResolution.x);
29   const y = ~~(Math.random() * fieldResolution.y);
30   const cell = field[x][y];
31   if (!cell.hasMine) {
32     cell.hasMine = true;
33     mineCount++;
34   }
35 }

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

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

1 const probability = 1 / fieldResolution.x / fieldResolution.y;
2 let hasMine = false;
3 if (mineCount < flagCount && Math.random() < probability) {
4   hasMine = true;
5   mineCount++;
6 }

Раскрытие ячейки

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

 1 let disclosedCellCount = 0;
 2 const discloseCell = (x: number, y: number) => {
 3   const cell = field[x][y];
 4   if (cell.hasFlag || cell.isDisclosed) {
 5     return;
 6   }
 7 
 8   disclosedCellCount++;
 9   cell.isDisclosed = true;
10 
11   if (cell.hasMine) {
12     alert('You lost!');
13     return;
14   }
15 
16   const xl = Math.min(fieldResolution.x - 1, x + 1);
17   const yl = Math.min(fieldResolution.y - 1, y + 1);
18   for (let xc = Math.max(0, x - 1); xc <= xl; xc++) {
19     for (let yc = Math.max(0, y - 1); yc <= yl; yc++) {
20       const checkingCell = field[xc][yc];
21       if (checkingCell.hasMine) {
22         cell.hint++;
23       }
24     }
25   }
26 
27   const cellCount = fieldResolution.x * fieldResolution.y;
28   if (disclosedCellCount === cellCount - flagCount) {
29     alert('You won!');
30     return;
31   }
32 
33   if (cell.hint === 0) {
34     for (let xi = Math.max(0, x - 1); xi <= xl; xi++) {
35       for (let yi = Math.max(0, y - 1); yi <= yl; yi++) {
36         discloseCell(xi, yi);
37       }
38     }
39   }
40 }

В первом цикле считается количество мин вокруг клетки, и если оно равно 0, то во втором цикле все соседние ячейки тоже раскрываются. Это рекурсия!

Бум

Напоследок добавим небольшой дымок при подрыве на мине. Сделаем его с помощью обычного растрового спрайта и CSS тайминг-функции steps:

 1 .explosion
 2   display: none
 3   position: absolute
 4   size: 90px
 5   background: no-repeat url('/images/apps/minesweeper/explosion.png')
 6   background-size: 400% 300%
 7   transform: translate(-50%, -50%)
 8   pointer-events: none
 9 
10   .lose &
11     display: block
12     animation: explosionAnimation 500ms steps(1, end) forwards
13 
14 @keyframes explosionAnimation
15   for f in (0..11)
16     {8.33% * f}
17       x = (33.333 * (f - 4 * floor(f / 4)))%
18       y = (50 * floor(f / 4))%
19       background-position: x y
20 
21   100%
22     background-position: 200% 200%

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

GLSL

Жаль, что для этой статьи не собирается аналитика. Было бы интересно узнать, сколько человек дочитали до сюда сверху, а сколько — снизу. Ничего, осталось разобрать шейдеры и DesktopScreen будет готов. Чтобы лучше понимать происходящее, я рекомендую изучить этот недописанный учебник и посмотреть работы Patricio Gonzalez Vivo.

Для тех, у кого нет времени, расскажу в паре абзацев. Шейдер — это программа, скомпилированная для выполнения на GPU. Есть вершинные шейдеры, которые меняют геометрию 3D-объектов, а есть фрагментные, которые работают с пикселами. Мы здесь затронем только фрагментные шейдеры. Преимущество шейдеров в том, что одна и та же программа может параллельно выполняться для разных вершин или пикселов. При этом нет никакой возможности воспользоваться результатами вычислений для соседних пикселов.

Кроме данных, связанных с пикселом (вроде переменной gl_FragCoord с координатами), шейдер может использовать данные из памяти, одинаковые для всех пикселов (так называемые uniform variables; там же будут храниться текстуры, то есть изображения для обработки). Большинство переменных в GLSL лежат в диапазоне от 0. до 1., есть много встроенных функций, заточенных именно под эти границы. Чтобы вернуть из фрагментного шейдера цвет пиксела, нужно в процедуре void main установить переменную gl_FragColor в соответствующее значение.

Помимо GLSL есть и другие языки для написания шейдеров (ARB, Cg, MSL и другие), но в WebGL они не поддерживаются. Также имейте в виду, что для компиляции шейдера мы передаём в метод gl.shaderSource строку, и таким образом webpack не минифицирует его код. Совсем недавно одна известная компания оставила в своём вершинном шейдере пару интересных комментариев. Ничего страшного не случилось, просто об этом следует помнить.

UserAvatar

Начнём с простого: шейдер для смены пользовательских аватарок, написанный для компонента из react-gl-transition. Этот компонент принимает в качестве props два изображения, прогресс перехода и кусочек кода шейдера в виде строки. Эта строка потом вставится в шаблон библиотеки и скомпилируется как полноценный шейдер. Простейший переход для этой библиотеки будет выглядеть так:

1 vec4 transition(vec2 uv) {
2   return mix(getFromColor(uv), getToColor(uv), progress);
3 }

То есть мы написали функцию transition, принимающую координаты изображения (uv.x от 0. до 1. и uv.y от 0. до 1.) и возвращающую некоторый промежуточный цвет. Переменная progress — это uniform в диапазоне от 0. до 1., функции getFromColor и getToColor предопределены библиотекой и возвращают цвет пиксела соответствующего изображения. Ну а mix — встроенная функция GLSL, смешивающая числа (в том числе числа в векторах, представляющих цвет).

Теперь давайте разберём шейдер, используемый в портфолио:

 1 vec2 getOffset (float progress, float y) {
 2   return vec2(0.05 * progress * cos(10.0 * (progress + y)), 0.);
 3 }
 4 
 5 vec4 transition (vec2 uv) {
 6   vec4 fromColor = getFromColor(uv + getOffset(progress, uv.y));
 7   vec4 toColor = getToColor(uv + getOffset(1.0 - progress, uv.y));
 8   vec4 color = mix(fromColor, toColor, progress);
 9   float flash = sin(progress * 3.14);
10   return color * (1. + flash);
11 }

Чтобы представить эффект, надо понимать, как шейдер будет работать с разными значениями progress. Когда прогресс нулевой, мы получим во fromColor цвет пиксела первой картинки, который соответствует координатам uv. А вот в toColor будет лежать цвет пиксела второй картинки, сдвинутый влево или вправо на некоторое расстояние. Причём сдвиг будет таким, что левая и правая границы будут описывать косинусоиду. Однако мы этого не увидим, т.к. функция mix просто вернёт fromColor (потому что progress равен 0).

По мере увеличения progress первая картинка будет всё сильнее искажаться, а вторая всё слабже. От первой картинки мы будем брать всё меньше и меньше цвета, а от второй — всё больше и больше. В итоге progress достигнет значения 1 и в переменной color будет лежать toColor (цвет пиксела второй картинки без искажений).

И последним штрихом будет добавление вспышки в середине эффекта. Для этого усилим итоговый цвет color на flash. Заметьте, что синус 0., как и синус π равны 0, а вот синус π/2 равен 1. Значит, максимальная яркость будет видна, когда progress достигнет значения 0.5.

WorkLogo

Не обязательно писать все шейдеры самостоятельно. Есть пакет gl-transitions, из которого я и позаимствовал шейдер для компонента WorkLogo. Давайте попробуем разобраться, что там происходит:

 1 uniform vec2 direction; // [-1, 1]
 2 const float smoothness = 1.0;
 3 const vec2 center = vec2(0.5, 0.5);
 4 vec4 transition (vec2 uv) {
 5   vec2 v = normalize(direction);
 6   v /= abs(v.x) + abs(v.y);
 7   float d = v.x * center.x + v.y * center.y;
 8   float m = 1.0 - smoothstep(
 9     -smoothness,
10     0.0,
11     v.x * uv.x + v.y * uv.y - (d - 0.5 + progress * (1.0 + smoothness))
12   );
13   vec4 from = getFromColor((uv - 0.5) * (1.0 - m) + 0.5);
14   vec4 to = getToColor((uv - 0.5) * m + 0.5);
15   return mix(from, to, m);
16 }

Ничего не понятно! Но если поиграть с шейдером, можно обнаружить некоторые интересные закономерности. Например, если отобразить vec4(vec3(m), 1.), мы увидим ползущий диагональный градиент, отвечающий за плавный переход между картинками. Но этот же параметр используется для искажения изображения, т.к. на него умножаются центрированные uv-координаты. Вот и выяснили, что тут происходит: каждый пиксел изображения масштабируется относительно центра в соответствии с яркостью диагонального градиента. Для первой картинки градиент чёрно-белый, а для второй бело-чёрный. Этот же градиент используется для перетекания одной картинки в другую.

Но, погодите, о каких картинках идёт речь, если логотип у нас один? Да, один, но ведь никто не мешает передать в uniforms одно и то же изображение.

Television

Вместо статичной аватарки в резюме я решил снять небольшое видео и зациклить его (как в волшебных газетах из Хогвартса). Но самая фишка в том, что пользователь сможет переключать камеру и смотреть на меня с разных ракурсов. Кстати, как при этом синхронизировать время в трёх видео? Очень просто, достаточно их склеить в один футаж :-)

Пора написать шейдер без использования react-gl-transition. Вместо функции transition будем использовать процедуру main, а вместо return — присваивать значение встроенной переменной gl_FragColor.

 1 #define pi 3.1415
 2 #define magic 43758.5453123
 3 #define cameras_count 3.0
 4 
 5 varying vec2 uv;
 6 uniform float u_time;
 7 uniform float u_video_id;
 8 uniform sampler2D u_texture;
 9 
10 float get_random (float x) {
11   return fract(sin(x) * magic);
12 }
13 
14 float get_random (vec2 x) {
15   return fract(sin(dot(x, vec2(12.9898, 4.1414))) * magic);
16 }
17 
18 float gen_noise (vec2 x) {
19   vec2 d = vec2(0.0, 1.0);
20   vec2 f = floor(x);
21   vec2 c = smoothstep(vec2(0.0), vec2(1.0), fract(x));
22   return mix(
23     mix(get_random(f), get_random(f + d.yx), c.x),
24     mix(get_random(f + d.xy), get_random(f + d.yy), c.x),
25     c.y
26   );
27 }
28 
29 vec4 get_frame (float offset, vec2 uv) {
30   float id = mod(offset + cameras_count, cameras_count);
31   vec2 uv_texture = vec2((id + uv.x) / cameras_count, uv.y);
32   return texture2D(u_texture, uv_texture);
33 }
34 
35 vec2 fish_eye (vec2 uv, float power) {
36   vec2 center = vec2(0.5, 0.5);
37   vec2 uv_c = uv - center;
38   float d = length(uv_c);
39   float bind = power > 0.0 ? length(center) : center.x;
40 
41   if (power > 0.0) {
42     // fisheye
43     return center + normalize(uv_c) * (
44       tan(power * d) * bind / tan(bind * power)
45     );
46   } else if (power < 0.0) {
47     // antifisheye
48     return center + normalize(uv_c) * (
49       atan(-power * d * 10.0) * bind / atan(-power * bind * 10.0)
50     );
51   }
52   return uv;
53 }
54 
55 float gen_animated_noise (vec2 uv, float progress) {
56   return gen_noise(uv * 100. + fract(progress) * 1000.);
57 }
58 
59 float gen_line_field (vec2 uv, float progress) {
60   float field = mod(uv.y + progress, 0.3);
61   return smoothstep(0.05, 0.1, field) - smoothstep(0.2, 0.25, field);
62 }
63 
64 void main () {
65   float transition = fract(abs(u_time));
66   float offset = u_video_id + sign(u_time) * floor(abs(u_time));
67   float progress = sin(transition * pi);
68   vec2 uv_positive = fish_eye(uv, 1.5 * progress);
69   vec2 uv_negative = fish_eye(uv, -0.25 * progress);
70   vec4 color = mix(
71     get_frame(offset, uv_negative),
72     get_frame(offset + sign(u_time), uv_positive),
73     transition
74   );
75 
76   vec3 noise = vec3(
77     gen_animated_noise(uv, transition * 0.25),
78     gen_animated_noise(uv, transition * 0.25 + 0.33),
79     gen_animated_noise(uv, transition * 0.25 + 0.66)
80   );
81   vec3 mask = vec3(
82     gen_line_field(uv, transition * 0.25 - 0.2),
83     gen_line_field(uv, transition * 0.25),
84     gen_line_field(uv, transition * 0.25 + 0.2)
85   );
86 
87   vec4 noise_color = vec4(noise * mask * 0.5 * progress, 1.);
88   color = color + (1. - color) * noise_color;
89   gl_FragColor = color;
90 }

Что здесь происходит? Сперва я вычисляю в переменной offset между какими камерами происходит переход. Обратите внимание на функцию get_frame, которая по смещению и uv-координатам возвращает пиксел соответствующего видео. То есть технически видео одно, но фактически в линию выстроены три разных кадра.

Затем для кадра из прошлого видео я использую эффект fish_eye с отрицательным значением, а для следующего кадра с положительным. Сила эффекта умножается на progress, то есть достигает максимума в середине перехода (т.к. transition находится в диапазоне от 0. до 1.). Я не буду подробно разбирать функцию fish_eye, но если кому-то интересно, можно прочитать эту тему.

После смешивания кадров я генерирую шум и маски (для красного, зелёного и синего цветов). Последний шаг — наложение шума на видео.

PlayerApp

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

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

  • u_frame — очевидно, сам кадр видео.
  • u_resolution — очевидно, разрешение видео.
  • u_points — массив эпицентров волны. Каждый эпицентр имеет следующую структуру: [x, y, время, направление].
  • u_last_direction — направление последнего действия (-1. для play, 1. для pause). Это избыточная информация, которая уже содержится в u_points, но передаётся отдельно, чтобы упростить код шейдера.
 1 #define pi 3.1415
 2 #define pl 5
 3 
 4 uniform sampler2D u_frame;
 5 uniform vec2 u_resolution;
 6 uniform vec4 u_points[pl];
 7 uniform float u_last_direction;
 8 
 9 const vec2 center = vec2(0.5, 0.5);
10 
11 vec2 center_point (vec2 point) {
12   return (point - center) * u_resolution.xy / u_resolution.x;
13 }
14 
15 float gen_field (vec2 uv_c, vec4 point, float smoothness, float size) {
16   vec2 distortion = vec2(sin(uv_c.y * 32. + point.z * 32.) * 0.01, 0.);
17   return point.a * smoothstep(
18     point.z * size,
19     point.z * size - smoothness,
20     distance(uv_c + distortion, center_point(point.xy))
21   );
22 }
23 
24 vec3 gen_wave (vec2 uv_c, vec4 point) {
25   float d = gen_field(uv_c, point, 0.3, 2.);
26   float front = min(1., 2. * sin(abs(d) * pi));
27   float amplitude = (1. - point.z) * front * sin(d * pi * 2.);
28   vec2 point_c = center_point(point.xy);
29   float angle = atan(uv_c.y - point_c.y, uv_c.x - point_c.x);
30   return vec3(
31     amplitude * cos(angle),
32     amplitude * sin(angle),
33     amplitude
34   );
35 }
36 
37 void main (void) {
38   vec2 uv = gl_FragCoord.xy / u_resolution.xy;
39   vec2 uv_c = center_point(uv);
40 
41   float field = step(0., u_last_direction);
42   vec3 wave = vec3(0.);
43   for (int p = 0; p < pl; p++) {
44     float d = gen_field(uv_c, u_points[p], 0.1, 1.75);
45     field = field + d;
46     wave += gen_wave(uv_c, u_points[p]);
47   }
48   field = clamp(field, 0., 1.);
49   wave = clamp(wave, -1., 1.);
50 
51   vec3 source = vec3(texture2D(u_frame, uv + vec2(wave) * 0.05));
52   float s = (source.r + source.g + source.b) / 3.;
53   vec3 monochrome = vec3(0., 0.5 * s, s);
54 
55   vec3 color = mix(source, monochrome, field);
56   float wave_mask = (0.25 + (1. - field) * 0.75);
57   color += source * wave_mask * (
58     wave.z < 0. ? wave.z * 0.2 : wave.z * 0.5
59   );
60   color += source * sin(field * pi);
61 
62   gl_FragColor = vec4(color, 1.);
63 }

Сперва я вычисляю синее поле field и смещение координат кадра wave (то есть саму волну). Обратите внимание, что clamp вызывается уже после цикла, потому что волны и поля с разными знаками должны гасить друг друга. Затем я плавно подсвечиваю гребень волны и середину перехода от прозрачного цвета к синему.

ViewerApp

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

 1 varying vec2 uv;
 2 uniform float u_progress;
 3 uniform vec2 u_resolution;
 4 
 5 uniform vec2 u_size_from;
 6 uniform vec2 u_offset_from;
 7 uniform float u_zoom_from;
 8 uniform sampler2D u_image_from;
 9 
10 uniform vec2 u_size_to;
11 uniform vec2 u_offset_to;
12 uniform float u_zoom_to;
13 uniform sampler2D u_image_to;
14 
15 #define pi 3.1415
16 #define mask_breadth 0.02
17 
18 vec4 draw (sampler2D image, vec2 uv_fit) {
19   return texture2D(image, uv_fit)
20     * step(0., uv_fit.x)
21     * step(0., uv_fit.y)
22     * step(0., 1. - uv_fit.x)
23     * step(0., 1. - uv_fit.y);
24 }
25 
26 vec2 fit (vec2 size) {
27   return (gl_FragCoord.xy - u_resolution * 0.5 + size * 0.5) / size;
28 }
29 
30 float calc_mask (float progress, vec2 uv) {
31   float wave1 = sin(progress * pi) * (
32     sin(uv.y * pi + progress) + sin(uv.y * 2. * pi + progress * 4.)
33   );
34   float wave2 = sin(progress * pi) * (
35     sin(uv.y * 16. * pi + progress * 16. + 1.)
36   );
37   float wave3 = sin(progress * pi) * (
38     abs(cos(uv.y)) * sin(uv.y * 32. * pi + progress * 16. - 1.)
39   );
40   float edge = progress
41     + 0.03 * wave1
42     + 0.005 * wave2
43     + 0.005 * wave3;
44   return smoothstep(edge + mask_breadth, edge - mask_breadth, uv.x);
45 }
46 
47 void main () {
48   float progress = 0
49     - 2. * mask_breadth
50     + u_progress * (1. + 4. * mask_breadth);
51 
52   float mask_from = 1. - calc_mask(
53     progress,
54     uv + vec2(mask_breadth, 0.)
55   );
56   vec2 size_from = u_size_from * u_zoom_from;
57   vec2 uv_fit_from = fit(size_from) + u_offset_from;
58   vec4 color_from = draw(u_image_from, uv_fit_from);
59 
60   float mask_to = calc_mask(
61     progress,
62     uv + vec2(-mask_breadth, 0.)
63   );
64   vec2 size_to = u_size_to * u_zoom_to;
65   vec2 uv_fit_to = fit(size_to) + u_offset_to;
66   vec4 color_to = draw(u_image_to, uv_fit_to);
67 
68   vec4 color = color_from * mask_from + color_to * mask_to;
69   vec4 fire = vec4(color.r * mask_from * mask_to, 0., 0., color.a);
70   gl_FragColor = color + fire;
71 }

Функция calc_mask вычисляет маску во время перехода от одной картинки к другой. Чтобы спрятать маску в начале и в конце перехода, мы преобразуем u_progress в progress.

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

Смартфоны

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

Чтобы не запутаться в десятках компонентов, используемых в разных местах, необходимо составить список контрольных точек и пользоваться им во всех правилах @media, связанных с размерами окна. Также рекомендую для псевдокласса :hover использовать @media (hover: hover), чтобы приблизить интерфейс к родным мобильным компонентам.

Но это всё мелочи, а вот двигать «окна» на телефоне никто не будет, поэтому я превратил их в «приложения». Кнопка переключения рабочих столов превратилась в кнопку слайдера. Теперь «приложения» можно листать или закрывать жестом swipe up. За обработку touch-событий, как и везде, отвечает TouchArea. Причём на мобильных устройствах используется инстанс внутри инстанса (каждый для своего жеста). Всё это добавило ещё больше сложности к оконной системе.

Открыть слайдер можно также с помощью long tap, но в мобильном Safari не работает contextmenu. Пришлось эмулировать его самостоятельно через другие события.

Другая частая боль на мобильных ОС — виртуальная клавиатура, которая сильно меняет размер окна. В связи с некоторыми багами, мне пришлось определять, когда она появляется и когда исчезает, чтобы заставить браузер пересчитывать некоторые стили, связанные с vh.

Последние штрихи

Почти всё готово, осталось навести порядок и добавить некоторые плюшки, без которых можно спокойно обойтись, но ведь ради них всё и затевалось!

Console

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

 1 console.clear();
 2 console.log('%cblog.zhevak.name', `
 3   font-size: 24px;
 4   font-weight: bold;
 5   font-family: monospace;
 6   color: white;
 7   line-height: 192px;
 8   text-shadow: 0 0 5px #ff5e00;
 9   background: url('https://zhevak.name/background.png') no-repeat;
10   background-size: contain;
11   -webkit-text-stroke: 1px black;
12 `);

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

DeveloperAlert

Давайте добавим всплывающее сообщение со ссылкой на блог, когда пользователь открыл консоль. Но как узнать об этом событии? На сегодня есть «надёжный» способ, работающий как минимум в Chrome, Firefox и Safari.

1 let key = -1;
2 const indicator = function () {};
3 const onOpen = () => alert('Console is opened!');
4 indicator.toString = () => {
5   ++key && onOpen();
6 };
7 console.log('%c', indicator);

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

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

Workbox

Как-то раз я сам написал Service Worker для статического сайта. Прошло уже 3 года, а я всё ещё могу его открыть, находясь в оффлайне. Неплохо, но хотелось бы получить больше гибкости в обновлении контента. SW API написано так, что пользоваться им, мягко говоря, не очень удобно. Поэтому я решил прибегнуть к помощи Workbox от Google и не прогадал.

Документация библиотеки оставляет желать лучшего, без бутылки в ней не разобраться. Но как бы там ни было, всё работает из коробки. А что делать, если я хочу через 7 дней начать попытки обновления контента, и только через 30 дней полностью удалить устаревший хлам из кеша, чтобы дать пользователю время добраться до туда, где есть сеть?.. NetworkFirst не подходит, ведь свежий контент я хочу получать сразу из кеша. StaleWhileRevalidate не подходит, потому что я хочу отправлять запросы на сервер только после первых 7 дней. CacheFirst не подходит, ведь я хочу хранить старый контент до тех пор, пока он не обновится. Ничего не подходит, опять не получилось избежать трудностей :-(

Я искал статьи о том, как написать свою стратегию для Workbox, но нашёл только исходный код на GitHub. Что ж, не всё так плохо: в нашем распоряжении есть примеры стратегий, но самое главное — объект со служебными сущностями workbox.core._private. Пора писать код.

 1 /* FreshOrCache */
 2 
 3 var {cacheNames, cacheWrapper} = workbox.core._private;
 4 
 5 class FreshOrCache extends workbox.strategies.StaleWhileRevalidate {
 6   constructor (options = {}) {
 7     if ((options.freshAgeSeconds > 0) === false) {
 8       throw 'Invalid freshAgeSeconds!';
 9     }
10     super(options);
11     this._freshCacheName = '__FreshCache__:' + this._cacheName;
12     this._freshPlugins = [
13       {
14         cacheWillUpdate: async ({response}) => {
15           if (response.status === 200 || response.status === 0) {
16             return response;
17           }
18           return null;
19         },
20       },
21       new workbox.expiration.Plugin({
22         maxAgeSeconds: options.freshAgeSeconds,
23         purgeOnQuotaError: true
24       })
25     ];
26   }
27 
28   async handle ({event, request}) {
29     let response = await cacheWrapper.match({
30       cacheName: this._freshCacheName,
31       request,
32       event,
33       matchOptions: this._matchOptions,
34       plugins: this._freshPlugins
35     });
36 
37     if (response) {
38       return response;
39     } else {
40       return super.handle({event, request});
41     }
42   }
43 
44   async _getFromNetwork ({request, event}) {
45     const response = await super._getFromNetwork({request, event});
46 
47     const freshCachePutPromise = cacheWrapper.put({
48       cacheName: this._freshCacheName,
49       request,
50       response: response.clone(),
51       event,
52       plugins: this._freshPlugins,
53     });
54 
55     if (event) {
56       try {
57         event.waitUntil(freshCachePutPromise);
58       } catch (error) {
59 
60       }
61     }
62 
63     return response;
64   }
65 }

Эта новая стратегия ведёт себя как CacheFirst в течение периода freshAgeSeconds, а затем притворяется StaleWhileRevalidate, от которой и наследует все свои методы.

Как же круто унаследовать класс сторонней библиотеки, ООП во всей красе.

AutoResume

Чтобы всегда держать своё резюме в актуальном состоянии, я написал небольшой скрипт, который принимает на вход JSON с данными, список языков и список рендереров, а возвращает много файлов в разных форматах. В моём случае используется всего 2 языка: русский и английский, и 4 рендерера: TxtRenderer, MdRenderer, PdfRenderer и DocxRenderer. Чтобы внести изменения в 8 файлов, я изменяю resume.json и набираю npm run autoresume.

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

Минификация через svgo

Скрипты минифицировали, стили минифицировали, а картинки? И картинки надо минифицировать, причём лучше написать скрипт, чтобы не запускать svgo каждый раз руками:

1 for f in $(ls files/works/*/logo.svg);
2   do svgo --config=svgo.config.json $f;
3 done

Контейнеризация

Ну всё, дописали код, теперь надо развернуть 23 проекта с разными версиями PHP, Python, Node.js и прочими зависимостями. Благодаря современным технологиям контейнеризации, сделать это можно за считанные часы дни :-)

Чтобы быстро деплоить портфолио и сопутствующие проекты, я написал Bash скрипт, который устанавливает необходимый софт на хост-сервере, скачивает все репозитории, собирает из них образы и запускает один за другим контейнеры (с помощью docker-compose up).

Есть ли со всем этим табором проблемы? Ну, во-первых, все проекты кушают около 4 Гб памяти, а хостинг стоит денег. Во-вторых, если нужно обновить уже развёрнутый проект, приходится делать это вручную, потому что CI не настроена. Для парочки проектов я написал npm-скрипт, обновляющий удалённую версию, и пока мне этого хватает.

Мотивация

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

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

Однажды мои родители решили, что я слишком много времени провожу за компьютером, и спрятали его. Хорошо, что дома всегда имелись комплектующие от старого железа. Через пару дней я собрал новый PC, но у меня не было операционной системы. К счастью, один из моих одноклассников в то время начал изучать Linux и заказал себе на почту бесплатный диск с дистрибутивом Ubuntu, которым и поделился со мной.

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

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

Чем больше я знал, тем сильнее становилось моё любопытство. Вся моя карьера — это поиск новых знаний, и как только мой профессиональный рост останавливался, я покидал компанию. Ого, вы можете дать мне на 10% больше, чтобы покрыть инфляцию за прошлый год? Спасибо, но я здесь не только из-за денег. Мне гораздо интереснее строить масштабируемую архитектуру и создавать информационные системы, способные поменять мир, а не повышать искусственный KPI. Я до сих пор люблю всё, что связано с вычислительной техникой, мне интересно учиться и применять свои знания на практике, но я ненавижу, когда мои способности утилизируют не по назначению.

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

Наверх