Как победить scroll в javascript

от автора

Привет, Хабр!

Начать данную статью хотелось бы с небольшого лирического отступления. Недавно, в очередной раз столкнувшись со сложным кейсом на работе и прошерстив добрую половину интернета в поисках истины, попутно приобретя с десяток седых волос, у меня начало болеть сердечко за начинающих специалистов, которые выбрали нелегкий путь разработчика JS. Как вы, вероятно, догадались, сам я не отношусь к категории людей ‘В детстве мама включала мне вместо мультиков курсы Javascript для самых маленьких, в 5 я написал свой интернет магазин,а в 10 в первый раз взломал сайт Пентагона’. Удивительно, но на похожие резюме я не раз натыкался на просторах интернета, те самые 25-ти летние специалисты с 22-х летним опытом на самом деле существуют и, наверное, не имеют тех проблем, с которыми приходится сталкиваться мне — человеку, которому разработка даётся довольно нелегко и который до недавнего времени считал, что нормальный код могут писать только люди со сверхразумом и поэтому боялся даже попробовать. Я считаю, что таких как я на самом деле очень много, особенно учитывая безумный рост популярности программирования (и Javascript в частности) в широких массах в последнее время. Поэтому решил периодически публиковать здесь свои заметки и кейсы, с которыми я сталкивался в работе и которые вызывали у меня определенные трудности, чтобы несколько облегчить жизнь разработчикам, встречающим схожие проблемы.

P.S. я не претендую на экспертность знаний, поэтому, с определенной вероятностью могут найтись куда более оптимальные решения. Помните: ‘Я художник — я так вижу’.

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

  • Как сделать таблицу с фиксированной шапкой и скроллом в body?

  • Как быть, когда cодержимое таблицы съезжает относительно шапки при появлении скролла? Установка css-переменной scroll-width.

  • Оптимизация и кастомизация скролла: плавность, scroll margin, изменение цвета и формы.

Как сделать таблицу с фиксированной шапкой и скроллом в body?

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

Код
import React from 'react'; import { render } from 'react-dom'; import faker from 'faker'; import './index.css';  // генерируем данные const users = Array(100).fill(null).map(() => ({   id: faker.random.uuid(),   first: faker.name.firstName(),   last: faker.name.lastName(),   email: faker.internet.email(), }));  const App = () => (         <table >           <thead>             <tr>               <th>First</th>               <th>Last</th>               <th>Email</th>             </tr>           </thead>           <tbody>             {users.map(x => (               <tr key={x.id}>                 <td>{x.first}</td>                 <td>{x.last}</td>                 <td>{x.email}</td>               </tr>             ))}           </tbody>         </table> );  render(<App />, document.getElementById('root'));
table {   width: 100%;   border-collapse: collapse;   border-spacing: 0; }  thead {   background-color: teal; }  td, th {   padding: 15px;   text-align: left; }  th {   color: white; }

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

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

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

  2. Немного подшаманить CSS, чем мы сейчас и займемся

Это основная причина, почему я советую пользоваться библиотеками
Это основная причина, почему я советую пользоваться библиотеками

Первое,что мне лично пришло на ум, когда я в первый раз столкнулся с подобной задачей — нужно всего лишь поменять свойство display у тегов tbody и thead на ‘block’ и накинуть на tbody ограничение по высоте (здесь я считаю необходимым прояснить небольшой момент, вызывающий трудности у начинающих: многие пытаются просто задать высоту для body, а потом не могут понять, почему это не работает. Нужно просто не забыть еще прописать свойство overflow, и все получится).

Зачем нам менять display? Дело в том, что свойство display у элементов thead и tbody имеет значение row-group(table-header-group и table-row-group соответственно), что в свою очередь не позволяет нам вводить ограничение по высоте, а значит и скролла у них не будет, поэтому мы меняем их на понятный нам display: ‘block’, которым проще управлять .

Напоминалка про поведение атрибута height в таблицах

Когда в документе задан <!DOCTYPE>, браузеры игнорируют высоту таблицы, заданную через атрибут height. По умолчанию высота вычисляется на основе содержимого таблицы.

Здесь есть несколько выжных моментов, о которых не стоит забывать:

  1. Меняя свойство display на ‘block’, мы как бы ломаем семантику, и наш браузер уже больше не считает нашу таблицу таблицей, что также оказывает влияние на скринридеры.

  2. Теперь наш тег tr уже не занимает все свободное пространство контейнера и теперь нам нужно все это дело выровнять.

Для наглядности добавил border.
Для наглядности добавил border.
Код
import React from 'react'; import { render } from 'react-dom'; import faker from 'faker'; import './index.css';  // генерируем данные const users = Array(100).fill(null).map(() => ({   id: faker.random.uuid(),   first: faker.name.firstName(),   last: faker.name.lastName(),   email: faker.internet.email(), }));  const App = () => (         <table >           <thead>             <tr>               <th>First</th>               <th>Last</th>               <th>Email</th>             </tr>           </thead>           <tbody>             {users.map(x => (               <tr key={x.id}>                 <td>{x.first}</td>                 <td>{x.last}</td>                 <td>{x.email}</td>               </tr>             ))}           </tbody>         </table> );  render(<App />, document.getElementById('root')); 
table {   width: 100%;   border-collapse: collapse;   border-spacing: 0; }  thead {   display: block;   background-color: teal; }  tbody {   display: block;   overflow-y: auto;   overflow-x: hidden;   max-height: 200px;   min-height: 100px; }  tr {   border: 1px solid black; }  td, th {   text-align: left;   padding: 15px; }  th {   color: white; }

Как будем выравнивать?

Ну, я думаю, вы поняли
Ну, я думаю, вы поняли

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

Готовый вариант с display: ‘Flex’
import React from 'react'; import { render } from 'react-dom'; import faker from 'faker'; import './index.css';  // генерируем данные const users = Array(100).fill(null).map(() => ({   id: faker.random.uuid(),   first: faker.name.firstName(),   last: faker.name.lastName(),   email: faker.internet.email(), }));  const App = () => (         <table className='table'>           <thead>             <tr className='header'>               <th className='firstColumn'>First</th>               <th className='secondColumn'>Last</th>               <th className='thirdColumn'>Email</th>             </tr>           </thead>           <tbody>             {users.map(x => (               <tr className='body' key={x.id}>                 <td className='firstColumn'>{x.first}</td>                 <td className='secondColumn'>{x.last}</td>                 <td className='thirdColumn'>{x.email}</td>               </tr>             ))}           </tbody>         </table> );  render(<App />, document.getElementById('root')); 
table {   width: 100%;   border-collapse: collapse;   border-spacing: 0; }  thead {   background-color: teal;   color: white; }  thead, tbody, th, td {   display: block;   text-align: left; }  tbody {   overflow-y: auto;   overflow-x: hidden;   max-height: 200px;   min-height: 100px; }  tr {   display: flex; }  .firstColumn, .secondColumn {   flex: 1; }  .thirdColumn {   flex: 3; }  td, th {   padding: 15px;   overflow: hidden;   text-overflow: ellipsis;   white-space: nowrap; }
Готовый вариант с display ‘Grid’
import React from 'react'; import { render } from 'react-dom'; import faker from 'faker'; import './index.css';  // генерируем данные const users = Array(100).fill(null).map(() => ({   id: faker.random.uuid(),   first: faker.name.firstName(),   last: faker.name.lastName(),   email: faker.internet.email(), }));  const App = () => (         <table className='table'>           <thead>             <tr className='header'>               <th>First</th>               <th>Last</th>               <th>Email</th>             </tr>           </thead>           <tbody>             {users.map(x => (               <tr className='body' key={x.id}>                 <td>{x.first}</td>                 <td>{x.last}</td>                 <td>{x.email}</td>               </tr>             ))}           </tbody>         </table> );  render(<App />, document.getElementById('root'));
table {   width: 100%;   border-collapse: collapse;   border-spacing: 0; }  tr {   display: grid;   grid-template-columns: 1fr 1fr 2fr; }  thead {   background-color: teal;   color: white; }  thead, tbody, th, td {   display: block;   text-align: left; }  tbody {   overflow-y: auto;   overflow-x: hidden;   max-height: 200px;   min-height: 100px; }  td, th {   padding: 15px;   overflow: hidden;   text-overflow: ellipsis;   white-space: nowrap; } 

Чуть не забыл отметить одну очень важную вещь, с которой я в свое время достаточно долго пытался разобраться: НА МАКАХ ЕСТЬ ФУНКЦИЯ АВТОМАТИЧЕСКОГО СКРЫТИЯ СКРОЛЛБАРА В БРАУЗЕРАХ, КОТОРАЯ ВЫКЛЮЧАЕТСЯ В НАСТРОЙКАХ. Если вы выставляете overflow: scroll, и ждете, что scroll теперь будет виден постоянно, то на маке он по умолчанию может пропадать и быть видимым только в момент прокрутки. Плюс на маках скролл у нас как бы парит в воздухе над контентом, аналогично position: absolute и не занимает места, если соответствующая настройка включена (по умолчанию она всегда включена).

Так выглядит на макбуке
Так выглядит на макбуке
А вот так тот же код выглядит на windows
А вот так тот же код выглядит на windows

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

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

Как быть, когда cодержимое таблицы съезжает относительно шапки при появлении скролла? Установка css-переменной scroll-width

Мы отчетливо видим, что на windows скролл при появлении съедает часть пространства, что в свою очередь приводит к тому, что контент нашей таблицы съезжает относительно шапки. Это лишь один из примеров, но есть и другие кейсы, когда скролл негативно влияет на качество верстки. Казалось бы, проблема очевидная, и наверняка должен быть способ как-то с этим бороться, однако, к сожалению, на данный момент есть только костыльные решения (во всяком случае я не нашел того, что бы меня устроило). Ниже я расскажу о самом распространенном варианте решения данной проблемы, плюс затрону относительно новое CSS свойство, которое должно потенциально избавить разработчиков от этой головной боли.

Ну что ж, приступим! Алгоритм прост и содержит всего лишь 2 шага:

  1. Задаем для overflow-y значение scroll, чтобы в любом состоянии скролл у нас присутствовал в таблице (отмечу, что часто дизайнеры не в восторге от такого поворота событий, не по фэншую это всё и всю красоту портит).

  2. Высчитываем ширину скролла в браузере и двигаем шапку на нужное расстояние.

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

Как посчитать размер скролла и положить его в css-переменную?

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

const setScrollbarWidth = () => {   // Создаем контейнер-болванку   const outerContainer = document.createElement('div');      //Накидываем стили   outerContainer.style.visibility = 'hidden';    outerContainer.style.overflow = 'scroll';      //Добавляем его к body   document.body.appendChild(outerContainer);      // Создаем еще один контейнер и помещаем его внутрь outerContainer   const innerContainer = document.createElement('div');    outerContainer.appendChild(innerContainer);        // Высчитываем ширину нашего скроллбара   const scrollbarWidth =    (outerContainer.offsetWidth - innerContainer.offsetWidth);     // Создаем css-переменную    document.documentElement.style.setProperty('--scroll-width',    `${scrollbarWidth}px`);       // Удаляем наш контейнер-болванку   outerContainer.parentNode.removeChild(outerContainer);  }

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

padding-right: var(--scroll-width)

Применяя все вышеописанное к моему изначальному коду получаем:

Код
import React, { useEffect } from "react"; import { render } from "react-dom"; import faker from "faker"; import "./index.css";  const setScrollbarWidth = () => {   const outerContainer = document.createElement("div");    outerContainer.style.visibility = "hidden";   outerContainer.style.overflow = "scroll";    document.body.appendChild(outerContainer);    const innerContainer = document.createElement("div");   outerContainer.appendChild(innerContainer);    const scrollbarWidth =     outerContainer.offsetWidth - innerContainer.offsetWidth;    document.documentElement.style.setProperty(     "--scroll-width",     `${scrollbarWidth}px`   );    outerContainer.parentNode.removeChild(outerContainer); };  // генерируем данные const users = Array(100)   .fill(null)   .map(() => ({     id: faker.random.uuid(),     first: faker.name.firstName(),     last: faker.name.lastName(),     email: faker.internet.email()   }));  const App = () => {   useEffect(() => setScrollbarWidth(), []);    return (     <table className="table">       <thead>         <tr className="header">           <th>First</th>           <th>Last</th>           <th>Email</th>         </tr>       </thead>       <tbody>         {users.map((x) => (           <tr className="body" key={x.id}>             <td>{x.first}</td>             <td>{x.last}</td>             <td>{x.email}</td>           </tr>         ))}       </tbody>     </table>   ); };  render(<App />, document.getElementById("root")); 
table {   width: 100%;   border-collapse: collapse;   border-spacing: 0; }  tr {   display: grid;   grid-template-columns: 1fr 1fr 2fr; }  thead {   padding-right: var(--scroll-width);   background-color: teal;   color: white; }  thead, tbody, th, td {   display: block;   text-align: left; }  tbody {   overflow-y: scroll;   overflow-x: hidden;   max-height: 200px;   min-height: 100px; }  td, th {   padding: 15px;   overflow: hidden;   text-overflow: ellipsis;   white-space: nowrap; } 

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

Раньше было еще одно решение данной проблемы — использование overflow в значении overlay, что позволяло сделать скроллбар примерно таким же, как сейчас на маках, т.е. не занимающим места (поверх контента), однако это свойство устаревшее и использовать его — это очень плохая идея.

Можно ли каким-то образом все-таки избежать использования overflow: scroll?

Ответ: в данной ситуации можно, нужно всего лишь немного усовершенствовать наш костыль.

Алгоритм действий:

  1. Объявляем отдельный класс с нашим padding

  2. Меняем overflow на auto (чтобы скролл появлялся только по необходимости)

  3. При загрузке страницы определяем ширину шапки и body

  4. Используем classnames, чтобы установить условие на добавление нашего новго класса к шапке: ширина body не должна быть равна ширине шапки (то есть мы добавляем сдвиг, только если в body есть scroll). Для удобства храним результат проверки в стэйте.

Финальный код
import React, { useEffect, useState } from "react"; import { render } from "react-dom"; import faker from "faker"; import cx from "classnames"; import "./index.css";  const setScrollbarWidth = () => {   const outerContainer = document.createElement("div");    outerContainer.style.visibility = "hidden";   outerContainer.style.overflow = "scroll";    document.body.appendChild(outerContainer);    const innerContainer = document.createElement("div");   outerContainer.appendChild(innerContainer);    const scrollbarWidth =     outerContainer.offsetWidth - innerContainer.offsetWidth;    document.documentElement.style.setProperty(     "--scroll-width",     `${scrollbarWidth}px`   );   outerContainer.parentNode.removeChild(outerContainer); };  // генерируем данные const users = Array(30)   .fill(null)   .map(() => ({     id: faker.random.uuid(),     first: faker.name.firstName(),     last: faker.name.lastName(),     email: faker.internet.email()   }));  const App = () => {   const [isShiftAllowed, setIsShiftAllowed] = useState(false);    useEffect(() => {     setScrollbarWidth();     const headerWidth = document.querySelector(".header").clientWidth;     const bodyWidth = document.querySelector(".body").clientWidth;     headerWidth - bodyWidth !== 0       ? setIsShiftAllowed(true)       : setIsShiftAllowed(false);   }, []);    return (     <table className="table">       <thead className={cx({ shift: isShiftAllowed })}>         <tr className="header">           <th>First</th>           <th>Last</th>           <th>Email</th>         </tr>       </thead>       <tbody>         {users.map((x) => (           <tr className="body" key={x.id}>             <td>{x.first}</td>             <td>{x.last}</td>             <td>{x.email}</td>           </tr>         ))}       </tbody>     </table>   ); };  render(<App />, document.getElementById("root")); 
 table {   width: 100%;   border-collapse: collapse;   border-spacing: 0; }  .shift {   padding-right: var(--scroll-width); }  tr {   display: grid;   grid-template-columns: 1fr 1fr 2fr; }  thead {   background-color: teal;   color: white; }  thead, tbody, th, td {   display: block;   text-align: left; }  tbody {   overflow-y: auto;   overflow-x: hidden;   max-height: 200px;   min-height: 100px; }  td, th {   padding: 15px;   overflow: hidden;   text-overflow: ellipsis;   white-space: nowrap; } 

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

Я упоминал о том, что есть новое css свойство, которое может стать настоящим спасением в данном вопросе, имя ему scrollbar-gutter. Еще раз повторюсь, это новое свойство, и на данный момент оно поддерживается только браузерами Opera, Chrome и Edge, так сказать still work in progress. Как объясняется в MDN, scrollbar gutter — это собственно и есть контейнер, в котором лежит наш бегунок скроллбара (если конечно overflow не находится в значении overlay). Соответственно, комбинирование значений данного свойства со значениями свойства overflow позволит гибко управлять поведением этого контейнера, резервировать место и т.д. Очень надеюсь, что в ближайшем будущем его наконец полноценно допилят.

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

Оптимизация и кастомизация скролла.

Большую часть информации для данного раздела я подчеркнул из статей, автором которых является Josh W Comeau, всем рекоммендую посетить его вебсайт https://www.joshwcomeau.com/, там вы найдете очень много полезностей.

Добавляем плавность

Возможно, глядя на заголовок, кто-то из вас сейчас сидит в недоумении: ‘Плавность? Скролл ведь и так плавный, о чем ты?’. Так-то оно так, но я вот о что имею ввиду: все мы прекрасно знаем о том, что в html можно сделать ссылку-якорь, чтобы при клике на заголовок перемещаться к нужному фрагменту текста, самый базовый пример можно найти на всеми любимой википедии:

Как мы видим, перемещение происходит мгновенно, однако мы можем сделать этот переход плавным с помощью CSS (возможно это для вас конечно не новость, но кому-то точно будет полезно). Для этого нам нужно использовать вот этот кусочек кода:

@media (prefers-reduced-motion: no-preference) {   html {     scroll-behavior: smooth;   } }

Теперь то же действие на страничке википедии происходит таким образом:

Кстати говоря, если вдруг кто-то не знал, в Chrome devtools можно также добавлять media queries. Вот здесь можно найти подробную инструкцию о том, как это сделать: https://daveceddia.com/inspector-stylesheet-chrome/. Кейс супер редкий, но мало ли, вдруг понадобится.

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

Отмечу также важный момент: если вы добавляете scroll-behavior: smooth в html, то имейте ввиду, что это автоматически также аффектит поведение метода window.scrollTo (тоже становится плавным), который используется, например, для того чтобы переместить пользователя наверх страницы в ответ на submit. Если вдруг вы раньше не сталкивались с этим методом, то обязательно ознакомьтесь и также не забудьте про метод scrollIntoView (разница между ними в том, что первый оперирует пикселями, а второй перемещает к конкретному элементу).

Scroll margin

Как мы видели на примере выше, при клике на ссылку-якорь наша страничка прокручивается таким образом, что нужный нам текст оказывается в самом верху экрана. Однако, если у нас имеется header со свойством position в значении sticky или fixed, то часть нужного нам контента обязательно заползет под него и нам придется после этого скроллить страничку вверх, чтобы этот самый контент стало видно.

Обратите внимание на заголовки
Обратите внимание на заголовки

Здесь нам поможет использование свойства scroll-margin-top, которое определяет, где элемент должен находиться относительно верхней части окна при прокрутке. Со значением нужно поэкспериментировать и подобрать подходящее под вашу конкретную ситуацию.

Изменение цвета.

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

Возможно вы уже сталкивались со свойтвом scrollbar-color, которое позволяет изменить цвет скроллбара, только вот к сожалению поддерживается оно только браузером Firefox.

body {   scrollbar-color: color1 color2; }

Это конечно здорово, но сразу возникает вопрос: ‘Есть ли альтернатива для остальных браузеров? ‘ Альтернатива есть и имя ей: Vendor-prefixes.

::-webkit-scrollbar {   /* Цвет контейнера */   background-color: color2; } ::-webkit-scrollbar-thumb {   /* Цвет ползунка */   background-color: color1; }

Однако, стоит отметить, что если мы изменим цвет таким образом, то результат будет достаточно топорным для остальных браузеров, так как мы затираем стандартные свойства скроллбара. Вот вам для примера сравнение результата в браузерах Chrome и Firefox:

Чтобы сделать картинку получше и добиться примерно одинакового внешнего вида в разных браузерах, нам нужно накинуть дополнительных стилей (на наше счастье псевдоэлемент -webkit-scrollbar позволяет нам это сделать):

<style>   html {     --background: hsl(210deg, 15%, 6.25%);     --text: hsl(210deg, 10%, 90%);     --gray-300: hsl(210deg, 10%, 40%);     --gray-500: hsl(210deg, 8%, 50%);      /* Official styles (Firefox) */     scrollbar-color:       var(--gray-300)       var(--background);     scrollbar-width: thin;   }    ::-webkit-scrollbar {     width: 10px;     background-color: var(--background);   }   ::-webkit-scrollbar-thumb {     border-radius: 1000px;     background-color: var(--gray-300);     border: 2px solid var(--background);   }   /*     Little bonus: on non-Firefox browsers,     the thumb will light up on hover!   */   ::-webkit-scrollbar-thumb:hover {     background-color: var(--gray-500);   } </style>
Josh W Comeau просто лучший)
Josh W Comeau просто лучший)

Ну вот и все, друзья мои, я рассказал вам обо всех моментах, которые раньше у меня вызывали определенные трудности. Данная заметка — это определенно всего лишь ‘Капля в море’ и у вас 100% найдется еще большое количество вопросов и разного рода уточнений, a также замечаний, надеюсь увидеть их в комментариях (я пока еще учусь, так что не судите строго).

В дальнейшем попробую еще написать набольшую заметку про 2 другие интересные темы, касающиеся скролла: Cumulative Layout Shifts и Scroll Snapping.

Очень надеюсь, что вам было хоть немного полезно и интересно!


ссылка на оригинал статьи https://habr.com/ru/post/645259/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *