Category: Web

16
Июн
2020

Puppeteer: парсинг сайтов с JavaScript

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

Puppeteer: не просто
очередная библиотека для парсинга

Puppeteer – это библиотека Node.js, поддерживаемая
командой Chrome
Devtools
.
Библиотека запускает экземпляр Chrome/Chromium и предоставляет набор
высокоуровневых API.

Puppeteer используется для выполнения множества различных задач:

  • автоматизация сбора данных с веб-сайтов;
  • создание скриншотов и PDF-файлов;
  • тестирование расширений Chrome;
  • автоматизация тестирования веб-интерфейсов;
  • диагностика проблем производительности с помощью таких методов, как захват временной шкалы трассировки веб-сайта.

В сравнении с Selenium библиотека Puppeteer не обладает кросс-браузерностью, но часто выигрывает в скорости, так как не имеет промежуточного звена в виде Selenium server – команды идут напрямую в браузер.

В этой статье мы рассмотрим пример сбора данных с одной из страниц Amazon со списком товаров. Извлечем информацию из страницы списка лучших рубашек, поместим в JSON и посмотрим, как эмулировать действия пользователя (поиск товара). Полный код проекта доступен в репозитории.

Исследуемая страница товаров Amazon
Исследуемая страница товаров Amazon

Установка Puppeteer и
навигация

Puppeteer без проблем устанавливается с помощью npm:

        npm install --save puppeteer
    

Создадим экземпляр браузера и страницы, перейдем к целевому
URL-адресу:

        const puppeteer = require('puppeteer');

const url = 'https://www.amazon.in/s?k=Shirts&ref=nb_sb_noss_2';

async function fetchProductList(url) {
    const browser = await puppeteer.launch({ 
        headless: true, // false: enables one to view the Chrome instance in action
        defaultViewport: null, // (optional) useful only in non-headless mode
    });
    const page = await browser.newPage();
    await page.goto(url, { waitUntil: 'networkidle2' });
    ...
}

fetchProductList(url);
    

Назначение экземпляров интуитивно понятно:

  • browser: запускает экземпляр Chrome при вызове puppeteer.launch. Простая эмуляция браузера.
  • page: напоминает одну вкладку в браузере Chrome. Предоставляет набор методов, которые можно применить к конкретному экземпляру страницы/ Вызывается при запуске browser.newPage. Как в браузере можно создать несколько вкладок, так в Puppeteer можно одновременно обрабатывать несколько экземпляров страниц.

В качестве значения параметра waitUntil используем networkidle2 . Это гарантирует, что состояние загрузки страницы считается
окончательным, если она имеет не более 2 подключений, работающих в течение не
менее 500 мс.

Собираем данные со страницы

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

Требуемая структура блока JSON
        {
    brand: 'Brand Name', 
    product: 'Product Name',
    url: 'https://www.amazon.in/url.of.product.com/',
    image: 'https://www.amazon.in/image.jpg',
    price: '₹599',
}
    

Для запроса DOM используем метод page.evaluate() . Для обхода DOM – обычные методы JavaScript document.querySelector и document.querySelectorAll.

        async function fetchProductList(url) {
	...
    
    await page.waitFor('div[data-cel-widget^="search_result_"]');

    const result = await page.evaluate(() => {
        // counts total number of products
        let totalSearchResults = Array.from(document.querySelectorAll('div[data-cel-widget^="search_result_"]')).length;

        let productsList = [];

        for (let i = 1; i < totalSearchResults - 1; i++) {
            let product = {
                brand: '',
                product: '',
            };
            let onlyProduct = false;
            let emptyProductMeta = false;
			
            // traverse for brand and product names
            let productNodes = Array.from(document.querySelectorAll(`div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base`));

            if (productNodes.length === 0) {
                // traverse for brand and product names 
				// (in case previous traversal returned empty elements)
                productNodes = Array.from(document.querySelectorAll(`div[data-cel-widget="search_result_${i}"] .a-size-medium.a-color-base.a-text-normal`));
                productNodes.length > 0 ? onlyProduct = true : emptyProductMeta = true;
            }

            let productsDetails = productNodes.map(el => el.innerText);

            if (!emptyProductMeta) {
                product.brand = onlyProduct ? '' : productsDetails[0];
                product.product = onlyProduct ? productsDetails[0] : productsDetails[1];
            }
			
            // traverse for product image
            let rawImage = document.querySelector(`div[data-cel-widget="search_result_${i}"] .s-image`);
            product.image =rawImage ? rawImage.src : '';
			
            // traverse for product url
            let rawUrl = document.querySelector(`div[data-cel-widget="search_result_${i}"] a[target="_blank"].a-link-normal`);
            product.url = rawUrl ? rawUrl.href : '';

            // traverse for product price
            let rawPrice = document.querySelector(`div[data-cel-widget="search_result_${i}"] span.a-offscreen`);
            product.price = rawPrice ? rawPrice.innerText : '';

            if (typeof product.product !== 'undefined') {
                !product.product.trim() ? null : productsList = productsList.concat(product);
            }
        }

        return productsList;
    });
    
    ...
}
    
...
    

После изучения DOM стало ясно, что каждый
перечисленный элемент выводится с селектором
div[data-cel-widget^="search_result_"].
Данный селектор ищет все теги div с атрибутом data-cel-widget, которые имеют
значение, начинающееся с search_result_. Аналогичным образом исключаем
селекторы со следующими параметрами:

  • total listed items: div[data-cel-widget^="search_result_"]
  • brand: div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base (i обозначает номер узла в total listed items)
  • product: div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base или div[data-cel-widget="search_result_${i}"] .a-size-medium.a-color-base.a-text-normal
  • url: div[data-cel-widget="search_result_${i}"] a[target="_blank"].a-link-normal
  • image: div[data-cel-widget="search_result_${i}"] .s-image
  • price: div[data-cel-widget="search_result_${i}"] span.a-offscreen

Примечание:
мы ожидаем доступа к селектору именованных элементов div
[data-cel-widget^="search_result_"]
с помощью метода page.waitFor.

При запуске метода page.evaluate мы увидим в логе необходимые данные:

Логи запущенного метода
Логи запущенного метода

Имитируем поведение пользователя

Настройка автоматизации
Настройка автоматизации

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

Но что если, прежде чем извлечь данные, мы должны перейти по нескольким URL? Puppeteer умеет имитировать поведение юзера. Перейдем на домашнюю страницу amazon.in и найдем рубашки. Это приведет нас к странице списка продуктов, а оттуда мы сможем извлечь необходимые данные из DOM. Взглянем на код:

        ...

async function fetchProductList(url, searchTerm) {
	...
	await page.goto(url, { waitUntil: 'networkidle2' });

    await page.waitFor('input[name="field-keywords"]');
    await page.evaluate(val => document.querySelector('input[name="field-keywords"]').value = val, searchTerm);

    await page.click('div.nav-search-submit.nav-sprite');
    
    ...
}

fetchProductList('https://amazon.in', 'Shirts');
    

Ждем, пока поле поиска будет доступно, а затем
добавляем searchTerm, переданный с помощью page.evaluate. Переходим на страницу
списка продуктов, эмулируя щелчок по кнопке поиска – получаем желанное.

Некоторые нюансы в
работе

Есть
несколько моментов, с которыми вы можете столкнуться во время работы.

  • Некоторые ресурсы могут заблокировать доступ, если заподозрят странную активность. Для рандомизации user-agent в браузере используйте пакет user-agents:
        const puppeteer = require('puppeteer');
const userAgent = require('user-agents');

...

const browser = await puppeteer.launch({ headless: true, defaultViewport: null });
const page = await browser.newPage();
await page.setUserAgent(userAgent.toString());

...
    
  • Puppeteer не идеален в вопросе производительности. Повысить эффективность можно за счет троттлинга анимации, ограничения сетевых вызовов и т. д.
  • Не забывайте завершать сеанс Puppeteer, закрывая экземпляр браузера с помощью browser.close.
  • Некоторые распространенные операции, такие как console.log() не будут работать внутри методов страницы. Контекст страницы/браузера отличается от контекста ноды, в которой работает приложение.

Собираем всё вместе

Автоматизируем навигацию на странице со списком
продуктов.


Теперь у вас есть собственный и настраиваемый API для сбора данных. Остается лишь подключить это все к
серверной платформе.

Заключение

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

16
Июн
2020

Puppeteer: парсинг сайтов с JavaScript

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

Puppeteer: не просто
очередная библиотека для парсинга

Puppeteer – это библиотека Node.js, поддерживаемая
командой Chrome
Devtools
.
Библиотека запускает экземпляр Chrome/Chromium и предоставляет набор
высокоуровневых API.

Puppeteer используется для выполнения множества различных задач:

  • автоматизация сбора данных с веб-сайтов;
  • создание скриншотов и PDF-файлов;
  • тестирование расширений Chrome;
  • автоматизация тестирования веб-интерфейсов;
  • диагностика проблем производительности с помощью таких методов, как захват временной шкалы трассировки веб-сайта.

В сравнении с Selenium библиотека Puppeteer не обладает кросс-браузерностью, но часто выигрывает в скорости, так как не имеет промежуточного звена в виде Selenium server – команды идут напрямую в браузер.

В этой статье мы рассмотрим пример сбора данных с одной из страниц Amazon со списком товаров. Извлечем информацию из страницы списка лучших рубашек, поместим в JSON и посмотрим, как эмулировать действия пользователя (поиск товара). Полный код проекта доступен в репозитории.

Исследуемая страница товаров Amazon
Исследуемая страница товаров Amazon

Установка Puppeteer и
навигация

Puppeteer без проблем устанавливается с помощью npm:

        npm install --save puppeteer
    

Создадим экземпляр браузера и страницы, перейдем к целевому
URL-адресу:

        const puppeteer = require('puppeteer');

const url = 'https://www.amazon.in/s?k=Shirts&ref=nb_sb_noss_2';

async function fetchProductList(url) {
    const browser = await puppeteer.launch({ 
        headless: true, // false: enables one to view the Chrome instance in action
        defaultViewport: null, // (optional) useful only in non-headless mode
    });
    const page = await browser.newPage();
    await page.goto(url, { waitUntil: 'networkidle2' });
    ...
}

fetchProductList(url);
    

Назначение экземпляров интуитивно понятно:

  • browser: запускает экземпляр Chrome при вызове puppeteer.launch. Простая эмуляция браузера.
  • page: напоминает одну вкладку в браузере Chrome. Предоставляет набор методов, которые можно применить к конкретному экземпляру страницы/ Вызывается при запуске browser.newPage. Как в браузере можно создать несколько вкладок, так в Puppeteer можно одновременно обрабатывать несколько экземпляров страниц.

В качестве значения параметра waitUntil используем networkidle2 . Это гарантирует, что состояние загрузки страницы считается
окончательным, если она имеет не более 2 подключений, работающих в течение не
менее 500 мс.

Собираем данные со страницы

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

Требуемая структура блока JSON
        {
    brand: 'Brand Name', 
    product: 'Product Name',
    url: 'https://www.amazon.in/url.of.product.com/',
    image: 'https://www.amazon.in/image.jpg',
    price: '₹599',
}
    

Для запроса DOM используем метод page.evaluate() . Для обхода DOM – обычные методы JavaScript document.querySelector и document.querySelectorAll.

        async function fetchProductList(url) {
	...
    
    await page.waitFor('div[data-cel-widget^="search_result_"]');

    const result = await page.evaluate(() => {
        // counts total number of products
        let totalSearchResults = Array.from(document.querySelectorAll('div[data-cel-widget^="search_result_"]')).length;

        let productsList = [];

        for (let i = 1; i < totalSearchResults - 1; i++) {
            let product = {
                brand: '',
                product: '',
            };
            let onlyProduct = false;
            let emptyProductMeta = false;
			
            // traverse for brand and product names
            let productNodes = Array.from(document.querySelectorAll(`div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base`));

            if (productNodes.length === 0) {
                // traverse for brand and product names 
				// (in case previous traversal returned empty elements)
                productNodes = Array.from(document.querySelectorAll(`div[data-cel-widget="search_result_${i}"] .a-size-medium.a-color-base.a-text-normal`));
                productNodes.length > 0 ? onlyProduct = true : emptyProductMeta = true;
            }

            let productsDetails = productNodes.map(el => el.innerText);

            if (!emptyProductMeta) {
                product.brand = onlyProduct ? '' : productsDetails[0];
                product.product = onlyProduct ? productsDetails[0] : productsDetails[1];
            }
			
            // traverse for product image
            let rawImage = document.querySelector(`div[data-cel-widget="search_result_${i}"] .s-image`);
            product.image =rawImage ? rawImage.src : '';
			
            // traverse for product url
            let rawUrl = document.querySelector(`div[data-cel-widget="search_result_${i}"] a[target="_blank"].a-link-normal`);
            product.url = rawUrl ? rawUrl.href : '';

            // traverse for product price
            let rawPrice = document.querySelector(`div[data-cel-widget="search_result_${i}"] span.a-offscreen`);
            product.price = rawPrice ? rawPrice.innerText : '';

            if (typeof product.product !== 'undefined') {
                !product.product.trim() ? null : productsList = productsList.concat(product);
            }
        }

        return productsList;
    });
    
    ...
}
    
...
    

После изучения DOM стало ясно, что каждый
перечисленный элемент выводится с селектором
div[data-cel-widget^="search_result_"].
Данный селектор ищет все теги div с атрибутом data-cel-widget, которые имеют
значение, начинающееся с search_result_. Аналогичным образом исключаем
селекторы со следующими параметрами:

  • total listed items: div[data-cel-widget^="search_result_"]
  • brand: div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base (i обозначает номер узла в total listed items)
  • product: div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base или div[data-cel-widget="search_result_${i}"] .a-size-medium.a-color-base.a-text-normal
  • url: div[data-cel-widget="search_result_${i}"] a[target="_blank"].a-link-normal
  • image: div[data-cel-widget="search_result_${i}"] .s-image
  • price: div[data-cel-widget="search_result_${i}"] span.a-offscreen

Примечание:
мы ожидаем доступа к селектору именованных элементов div
[data-cel-widget^="search_result_"]
с помощью метода page.waitFor.

При запуске метода page.evaluate мы увидим в логе необходимые данные:

Логи запущенного метода
Логи запущенного метода

Имитируем поведение пользователя

Настройка автоматизации
Настройка автоматизации

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

Но что если, прежде чем извлечь данные, мы должны перейти по нескольким URL? Puppeteer умеет имитировать поведение юзера. Перейдем на домашнюю страницу amazon.in и найдем рубашки. Это приведет нас к странице списка продуктов, а оттуда мы сможем извлечь необходимые данные из DOM. Взглянем на код:

        ...

async function fetchProductList(url, searchTerm) {
	...
	await page.goto(url, { waitUntil: 'networkidle2' });

    await page.waitFor('input[name="field-keywords"]');
    await page.evaluate(val => document.querySelector('input[name="field-keywords"]').value = val, searchTerm);

    await page.click('div.nav-search-submit.nav-sprite');
    
    ...
}

fetchProductList('https://amazon.in', 'Shirts');
    

Ждем, пока поле поиска будет доступно, а затем
добавляем searchTerm, переданный с помощью page.evaluate. Переходим на страницу
списка продуктов, эмулируя щелчок по кнопке поиска – получаем желанное.

Некоторые нюансы в
работе

Есть
несколько моментов, с которыми вы можете столкнуться во время работы.

  • Некоторые ресурсы могут заблокировать доступ, если заподозрят странную активность. Для рандомизации user-agent в браузере используйте пакет user-agents:
        const puppeteer = require('puppeteer');
const userAgent = require('user-agents');

...

const browser = await puppeteer.launch({ headless: true, defaultViewport: null });
const page = await browser.newPage();
await page.setUserAgent(userAgent.toString());

...
    
  • Puppeteer не идеален в вопросе производительности. Повысить эффективность можно за счет троттлинга анимации, ограничения сетевых вызовов и т. д.
  • Не забывайте завершать сеанс Puppeteer, закрывая экземпляр браузера с помощью browser.close.
  • Некоторые распространенные операции, такие как console.log() не будут работать внутри методов страницы. Контекст страницы/браузера отличается от контекста ноды, в которой работает приложение.

Собираем всё вместе

Автоматизируем навигацию на странице со списком
продуктов.


Теперь у вас есть собственный и настраиваемый API для сбора данных. Остается лишь подключить это все к
серверной платформе.

Заключение

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

16
Июн
2020

🕵 Puppeteer: парсинг сайтов с JavaScript

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

Puppeteer: не просто
очередная библиотека для парсинга

Puppeteer – это библиотека Node.js, поддерживаемая
командой Chrome
Devtools
.
Библиотека запускает экземпляр Chrome/Chromium и предоставляет набор
высокоуровневых API.

Puppeteer используется для выполнения множества различных задач:

  • автоматизация сбора данных с веб-сайтов;
  • создание скриншотов и PDF-файлов;
  • тестирование расширений Chrome;
  • автоматизация тестирования веб-интерфейсов;
  • диагностика проблем производительности с помощью таких методов, как захват временной шкалы трассировки веб-сайта.

В сравнении с Selenium библиотека Puppeteer не обладает кросс-браузерностью, но часто выигрывает в скорости, так как не имеет промежуточного звена в виде Selenium server – команды идут напрямую в браузер.

В этой статье мы рассмотрим пример сбора данных с одной из страниц Amazon со списком товаров. Извлечем информацию из страницы списка лучших рубашек, поместим в JSON и посмотрим, как эмулировать действия пользователя (поиск товара). Полный код проекта доступен в репозитории.

Исследуемая страница товаров Amazon
Исследуемая страница товаров Amazon

Установка Puppeteer и
навигация

Puppeteer без проблем устанавливается с помощью npm:

        npm install --save puppeteer
    

Создадим экземпляр браузера и страницы, перейдем к целевому
URL-адресу:

        const puppeteer = require('puppeteer');

const url = 'https://www.amazon.in/s?k=Shirts&ref=nb_sb_noss_2';

async function fetchProductList(url) {
    const browser = await puppeteer.launch({ 
        headless: true, // false: enables one to view the Chrome instance in action
        defaultViewport: null, // (optional) useful only in non-headless mode
    });
    const page = await browser.newPage();
    await page.goto(url, { waitUntil: 'networkidle2' });
    ...
}

fetchProductList(url);
    

Назначение экземпляров интуитивно понятно:

  • browser: запускает экземпляр Chrome при вызове puppeteer.launch. Простая эмуляция браузера.
  • page: напоминает одну вкладку в браузере Chrome. Предоставляет набор методов, которые можно применить к конкретному экземпляру страницы/ Вызывается при запуске browser.newPage. Как в браузере можно создать несколько вкладок, так в Puppeteer можно одновременно обрабатывать несколько экземпляров страниц.

В качестве значения параметра waitUntil используем networkidle2 . Это гарантирует, что состояние загрузки страницы считается
окончательным, если она имеет не более 2 подключений, работающих в течение не
менее 500 мс.

Собираем данные со страницы

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

Требуемая структура блока JSON
        {
    brand: 'Brand Name', 
    product: 'Product Name',
    url: 'https://www.amazon.in/url.of.product.com/',
    image: 'https://www.amazon.in/image.jpg',
    price: '₹599',
}
    

Для запроса DOM используем метод page.evaluate() . Для обхода DOM – обычные методы JavaScript document.querySelector и document.querySelectorAll.

        async function fetchProductList(url) {
	...
    
    await page.waitFor('div[data-cel-widget^="search_result_"]');

    const result = await page.evaluate(() => {
        // counts total number of products
        let totalSearchResults = Array.from(document.querySelectorAll('div[data-cel-widget^="search_result_"]')).length;

        let productsList = [];

        for (let i = 1; i < totalSearchResults - 1; i++) {
            let product = {
                brand: '',
                product: '',
            };
            let onlyProduct = false;
            let emptyProductMeta = false;
			
            // traverse for brand and product names
            let productNodes = Array.from(document.querySelectorAll(`div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base`));

            if (productNodes.length === 0) {
                // traverse for brand and product names 
				// (in case previous traversal returned empty elements)
                productNodes = Array.from(document.querySelectorAll(`div[data-cel-widget="search_result_${i}"] .a-size-medium.a-color-base.a-text-normal`));
                productNodes.length > 0 ? onlyProduct = true : emptyProductMeta = true;
            }

            let productsDetails = productNodes.map(el => el.innerText);

            if (!emptyProductMeta) {
                product.brand = onlyProduct ? '' : productsDetails[0];
                product.product = onlyProduct ? productsDetails[0] : productsDetails[1];
            }
			
            // traverse for product image
            let rawImage = document.querySelector(`div[data-cel-widget="search_result_${i}"] .s-image`);
            product.image =rawImage ? rawImage.src : '';
			
            // traverse for product url
            let rawUrl = document.querySelector(`div[data-cel-widget="search_result_${i}"] a[target="_blank"].a-link-normal`);
            product.url = rawUrl ? rawUrl.href : '';

            // traverse for product price
            let rawPrice = document.querySelector(`div[data-cel-widget="search_result_${i}"] span.a-offscreen`);
            product.price = rawPrice ? rawPrice.innerText : '';

            if (typeof product.product !== 'undefined') {
                !product.product.trim() ? null : productsList = productsList.concat(product);
            }
        }

        return productsList;
    });
    
    ...
}
    
...
    

После изучения DOM стало ясно, что каждый
перечисленный элемент выводится с селектором
div[data-cel-widget^="search_result_"].
Данный селектор ищет все теги div с атрибутом data-cel-widget, которые имеют
значение, начинающееся с search_result_. Аналогичным образом исключаем
селекторы со следующими параметрами:

  • total listed items: div[data-cel-widget^="search_result_"]
  • brand: div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base (i обозначает номер узла в total listed items)
  • product: div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base или div[data-cel-widget="search_result_${i}"] .a-size-medium.a-color-base.a-text-normal
  • url: div[data-cel-widget="search_result_${i}"] a[target="_blank"].a-link-normal
  • image: div[data-cel-widget="search_result_${i}"] .s-image
  • price: div[data-cel-widget="search_result_${i}"] span.a-offscreen

Примечание:
мы ожидаем доступа к селектору именованных элементов div
[data-cel-widget^="search_result_"]
с помощью метода page.waitFor.

При запуске метода page.evaluate мы увидим в логе необходимые данные:

Логи запущенного метода
Логи запущенного метода

Имитируем поведение пользователя

Настройка автоматизации
Настройка автоматизации

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

Но что если, прежде чем извлечь данные, мы должны перейти по нескольким URL? Puppeteer умеет имитировать поведение юзера. Перейдем на домашнюю страницу amazon.in и найдем рубашки. Это приведет нас к странице списка продуктов, а оттуда мы сможем извлечь необходимые данные из DOM. Взглянем на код:

        ...

async function fetchProductList(url, searchTerm) {
	...
	await page.goto(url, { waitUntil: 'networkidle2' });

    await page.waitFor('input[name="field-keywords"]');
    await page.evaluate(val => document.querySelector('input[name="field-keywords"]').value = val, searchTerm);

    await page.click('div.nav-search-submit.nav-sprite');
    
    ...
}

fetchProductList('https://amazon.in', 'Shirts');
    

Ждем, пока поле поиска будет доступно, а затем
добавляем searchTerm, переданный с помощью page.evaluate. Переходим на страницу
списка продуктов, эмулируя щелчок по кнопке поиска – получаем желанное.

Некоторые нюансы в
работе

Есть
несколько моментов, с которыми вы можете столкнуться во время работы.

  • Некоторые ресурсы могут заблокировать доступ, если заподозрят странную активность. Для рандомизации user-agent в браузере используйте пакет user-agents:
        const puppeteer = require('puppeteer');
const userAgent = require('user-agents');

...

const browser = await puppeteer.launch({ headless: true, defaultViewport: null });
const page = await browser.newPage();
await page.setUserAgent(userAgent.toString());

...
    
  • Puppeteer не идеален в вопросе производительности. Повысить эффективность можно за счет троттлинга анимации, ограничения сетевых вызовов и т. д.
  • Не забывайте завершать сеанс Puppeteer, закрывая экземпляр браузера с помощью browser.close.
  • Некоторые распространенные операции, такие как console.log() не будут работать внутри методов страницы. Контекст страницы/браузера отличается от контекста ноды, в которой работает приложение.

Собираем всё вместе

Автоматизируем навигацию на странице со списком
продуктов.


Теперь у вас есть собственный и настраиваемый API для сбора данных. Остается лишь подключить это все к
серверной платформе.

Заключение

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

16
Июн
2020

🕵 Puppeteer: парсинг сайтов с JavaScript

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

Puppeteer: не просто
очередная библиотека для парсинга

Puppeteer – это библиотека Node.js, поддерживаемая
командой Chrome
Devtools
.
Библиотека запускает экземпляр Chrome/Chromium и предоставляет набор
высокоуровневых API.

Puppeteer используется для выполнения множества различных задач:

  • автоматизация сбора данных с веб-сайтов;
  • создание скриншотов и PDF-файлов;
  • тестирование расширений Chrome;
  • автоматизация тестирования веб-интерфейсов;
  • диагностика проблем производительности с помощью таких методов, как захват временной шкалы трассировки веб-сайта.

В сравнении с Selenium библиотека Puppeteer не обладает кросс-браузерностью, но часто выигрывает в скорости, так как не имеет промежуточного звена в виде Selenium server – команды идут напрямую в браузер.

В этой статье мы рассмотрим пример сбора данных с одной из страниц Amazon со списком товаров. Извлечем информацию из страницы списка лучших рубашек, поместим в JSON и посмотрим, как эмулировать действия пользователя (поиск товара). Полный код проекта доступен в репозитории.

Исследуемая страница товаров Amazon
Исследуемая страница товаров Amazon

Установка Puppeteer и
навигация

Puppeteer без проблем устанавливается с помощью npm:

        npm install --save puppeteer
    

Создадим экземпляр браузера и страницы, перейдем к целевому
URL-адресу:

        const puppeteer = require('puppeteer');

const url = 'https://www.amazon.in/s?k=Shirts&ref=nb_sb_noss_2';

async function fetchProductList(url) {
    const browser = await puppeteer.launch({ 
        headless: true, // false: enables one to view the Chrome instance in action
        defaultViewport: null, // (optional) useful only in non-headless mode
    });
    const page = await browser.newPage();
    await page.goto(url, { waitUntil: 'networkidle2' });
    ...
}

fetchProductList(url);
    

Назначение экземпляров интуитивно понятно:

  • browser: запускает экземпляр Chrome при вызове puppeteer.launch. Простая эмуляция браузера.
  • page: напоминает одну вкладку в браузере Chrome. Предоставляет набор методов, которые можно применить к конкретному экземпляру страницы/ Вызывается при запуске browser.newPage. Как в браузере можно создать несколько вкладок, так в Puppeteer можно одновременно обрабатывать несколько экземпляров страниц.

В качестве значения параметра waitUntil используем networkidle2 . Это гарантирует, что состояние загрузки страницы считается
окончательным, если она имеет не более 2 подключений, работающих в течение не
менее 500 мс.

Собираем данные со страницы

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

Требуемая структура блока JSON
        {
    brand: 'Brand Name', 
    product: 'Product Name',
    url: 'https://www.amazon.in/url.of.product.com/',
    image: 'https://www.amazon.in/image.jpg',
    price: '₹599',
}
    

Для запроса DOM используем метод page.evaluate() . Для обхода DOM – обычные методы JavaScript document.querySelector и document.querySelectorAll.

        async function fetchProductList(url) {
	...
    
    await page.waitFor('div[data-cel-widget^="search_result_"]');

    const result = await page.evaluate(() => {
        // counts total number of products
        let totalSearchResults = Array.from(document.querySelectorAll('div[data-cel-widget^="search_result_"]')).length;

        let productsList = [];

        for (let i = 1; i < totalSearchResults - 1; i++) {
            let product = {
                brand: '',
                product: '',
            };
            let onlyProduct = false;
            let emptyProductMeta = false;
			
            // traverse for brand and product names
            let productNodes = Array.from(document.querySelectorAll(`div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base`));

            if (productNodes.length === 0) {
                // traverse for brand and product names 
				// (in case previous traversal returned empty elements)
                productNodes = Array.from(document.querySelectorAll(`div[data-cel-widget="search_result_${i}"] .a-size-medium.a-color-base.a-text-normal`));
                productNodes.length > 0 ? onlyProduct = true : emptyProductMeta = true;
            }

            let productsDetails = productNodes.map(el => el.innerText);

            if (!emptyProductMeta) {
                product.brand = onlyProduct ? '' : productsDetails[0];
                product.product = onlyProduct ? productsDetails[0] : productsDetails[1];
            }
			
            // traverse for product image
            let rawImage = document.querySelector(`div[data-cel-widget="search_result_${i}"] .s-image`);
            product.image =rawImage ? rawImage.src : '';
			
            // traverse for product url
            let rawUrl = document.querySelector(`div[data-cel-widget="search_result_${i}"] a[target="_blank"].a-link-normal`);
            product.url = rawUrl ? rawUrl.href : '';

            // traverse for product price
            let rawPrice = document.querySelector(`div[data-cel-widget="search_result_${i}"] span.a-offscreen`);
            product.price = rawPrice ? rawPrice.innerText : '';

            if (typeof product.product !== 'undefined') {
                !product.product.trim() ? null : productsList = productsList.concat(product);
            }
        }

        return productsList;
    });
    
    ...
}
    
...
    

После изучения DOM стало ясно, что каждый
перечисленный элемент выводится с селектором
div[data-cel-widget^="search_result_"].
Данный селектор ищет все теги div с атрибутом data-cel-widget, которые имеют
значение, начинающееся с search_result_. Аналогичным образом исключаем
селекторы со следующими параметрами:

  • total listed items: div[data-cel-widget^="search_result_"]
  • brand: div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base (i обозначает номер узла в total listed items)
  • product: div[data-cel-widget="search_result_${i}"] .a-size-base-plus.a-color-base или div[data-cel-widget="search_result_${i}"] .a-size-medium.a-color-base.a-text-normal
  • url: div[data-cel-widget="search_result_${i}"] a[target="_blank"].a-link-normal
  • image: div[data-cel-widget="search_result_${i}"] .s-image
  • price: div[data-cel-widget="search_result_${i}"] span.a-offscreen

Примечание:
мы ожидаем доступа к селектору именованных элементов div
[data-cel-widget^="search_result_"]
с помощью метода page.waitFor.

При запуске метода page.evaluate мы увидим в логе необходимые данные:

Логи запущенного метода
Логи запущенного метода

Имитируем поведение пользователя

Настройка автоматизации
Настройка автоматизации

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

Но что если, прежде чем извлечь данные, мы должны перейти по нескольким URL? Puppeteer умеет имитировать поведение юзера. Перейдем на домашнюю страницу amazon.in и найдем рубашки. Это приведет нас к странице списка продуктов, а оттуда мы сможем извлечь необходимые данные из DOM. Взглянем на код:

        ...

async function fetchProductList(url, searchTerm) {
	...
	await page.goto(url, { waitUntil: 'networkidle2' });

    await page.waitFor('input[name="field-keywords"]');
    await page.evaluate(val => document.querySelector('input[name="field-keywords"]').value = val, searchTerm);

    await page.click('div.nav-search-submit.nav-sprite');
    
    ...
}

fetchProductList('https://amazon.in', 'Shirts');
    

Ждем, пока поле поиска будет доступно, а затем
добавляем searchTerm, переданный с помощью page.evaluate. Переходим на страницу
списка продуктов, эмулируя щелчок по кнопке поиска – получаем желанное.

Некоторые нюансы в
работе

Есть
несколько моментов, с которыми вы можете столкнуться во время работы.

  • Некоторые ресурсы могут заблокировать доступ, если заподозрят странную активность. Для рандомизации user-agent в браузере используйте пакет user-agents:
        const puppeteer = require('puppeteer');
const userAgent = require('user-agents');

...

const browser = await puppeteer.launch({ headless: true, defaultViewport: null });
const page = await browser.newPage();
await page.setUserAgent(userAgent.toString());

...
    
  • Puppeteer не идеален в вопросе производительности. Повысить эффективность можно за счет троттлинга анимации, ограничения сетевых вызовов и т. д.
  • Не забывайте завершать сеанс Puppeteer, закрывая экземпляр браузера с помощью browser.close.
  • Некоторые распространенные операции, такие как console.log() не будут работать внутри методов страницы. Контекст страницы/браузера отличается от контекста ноды, в которой работает приложение.

Собираем всё вместе

Автоматизируем навигацию на странице со списком
продуктов.


Теперь у вас есть собственный и настраиваемый API для сбора данных. Остается лишь подключить это все к
серверной платформе.

Заключение

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

11
Июн
2020

⏳ 10 способов ускорить загрузку вашего сайта

Веб-проект, над которым вы работаете, стал медленнее загружаться? Рассказываем о десятке моментов, на которые стоит обратить внимание, чтобы сократить время загрузки страниц.

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

1. Используйте CDN

CDN (Content Delivery Network) – географически распределённая сетевая инфраструктура, которая оптимизирует доставку контента конечным пользователям, давая доступ к сотням серверов по всему миру, размещающих копию
вашего сайта.

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

2. Включите gzip-сжатие

В некоторых CDN gzip-сжатие можно включить флажком Enable
compression
. Сжатие файлов обеспечит более быструю загрузку контента пользователями сайта.

3. Используйте оптимизацию изображений

Уменьшайте все изображения, которые не потеряют в качестве и не будут масштабироваться
Уменьшайте все изображения, которые не потеряют в качестве и не будут масштабироваться

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

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

4. Уменьшите количество
запросов, совершаемых страницей

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

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

5. Избегайте
перенаправлений


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

Некоторые редиректы
неизбежны, например, www -> root domain или root domain -> www, но
основная часть трафика не должна поступать через перенаправления.

6. Сократите время до первого байта

Время до первого байта (TTFB) –
это время, которое браузер тратит на ожидание данных с сервера после отправки запроса на ресурс.

На этот показатель влияют два параметра:

  1. Время, потраченное на сервере.
  2. Время, потраченное на отправку данных.

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

О втором параметре мы уже поговорили – на него мы можем повлиять, используя CDN.

7. Решите вопрос блокировки рендеринга JavaScript

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

        https://example.com/external.js
    

Можно отложить
загрузку сценариев до тех пор, пока пользователь не начнёт совершать
активные действия:

        window.addEventListener(
  'scroll',
  () =>
    setTimeout(() => {
      //insert marketing snippets here
    }, 1000),
  { once: true }
);
    

8. Минимизируйте CSS и JavaScript


Минимизация подразумевает
использование инструментов для удаления пробелов, символов перевода строки и
сокращения длины имён переменных. Как правило, это делается автоматически в рамках
процесса сборки. Есть специальные инструменты: например,
UglifyJS для JavaScript или cssnano для CSS.

9. Удалите
неиспользуемый код CSS и JavaScript

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

Начиная с Chrome 59, в Chrome DevTools
можно анализировать использование JavaScript и CSS.
Для этого откройте DevTools, перейдите во вкладку Console, нажмите на три точки и
откройте Coverage. При нажатии кнопки со значком перезагрузки будет проведен аудит использования CSS и JavaScript. Помеченные красным блоки кода загружаются, но не используются.

Аудит использования CSS и JS
Аудит использования CSS и JS
Примечание
Об использовании средств DevTools для ускорения загрузки сайта читайте в нашем подробном мануале «Ускоряем загрузку сайта с помощью Chrome DevTools».

10. Регулярно
отслеживайте скорость загрузки сайта

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

Есть бесплатные
инструменты для мониторинга скорости, например,
WebPageTest и Google Lighthouse. Но нужно не забывать запускать их до и после внесения изменений. Для автоматизации запуска GoogleLighthouse можно использовать PerfBeacon.

Заключение

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

Другие материалы по теме

11
Май
2020

Как сделать галерею в стиле Instagram

Галереи картинок с горизонтальной прокруткой выглядят современно и узнаваемо. Рассказываем, как с помощью CSS и JS перенести знакомую пользователям механику в ваши веб-приложения.

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

Теория: горизонтальные списки и галерея

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

Горизонтальный список со свободной прокруткой (free-scrolling horizontal lists)
Горизонтальный список со свободной прокруткой (free-scrolling horizontal lists)

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

Горизонтальный список со снэппингом (snapping horizontal lists)
Горизонтальный список со снэппингом (snapping horizontal lists)

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

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

Пример галереи
Пример галереи

Практическая часть

1. Горизонтальные списки со свободной прокруткой

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

            .list {
  display: flex;
  padding: 20px;
  overflow-x: scroll;
}

.item {
  height: 224px;
  width: 125px;
  flex-shrink: 0;
}

.item:not(:last-child) { margin-right: 10px; }
        

Интерактивное представление и полный код доступны в в
сэндбоксе:


Работает, но
можно улучшить.

Поведение при прокрутке влево. В некоторых браузерах свайп влево повторяет действие кнопки «Назад». Такое поведение можно предотвратить, установив для overscroll-behavior значение contain.

Полосу прокрутки можно скрыть, установив overflow-x в auto. Однако при скроллинге полоса вновь появится. Чтобы скрыть полностью, можно установить scrollbar-width в none. На момент написания статьи это работало только в Firefox. Для других браузеров придется добавить трюкачества в CSS:

            .list { -ms-overflow-style: none; }
.list::-webkit-scrollbar { display: none; }
        

Плавная прокрутка. В iOS не хватает
стандартной плавной прокрутки (momentum scrolling). Потребуется добавить -webkit-overflow-scrolling:
touch;
.

Предотвращение
вертикальной прокрутки
. Мы можем прокручивать
страницу по вертикали. Эту возможность лучше
отключить для пользователей мобильных устройств, добавив к списку touch-action:
pan-x
. Однако если список покрывает всю видимую область просмотра, а на странице есть что-то ещё, эти элементы станут недоступны. То есть такой вариант надо использовать с осторожностью.

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

            .list {
  display: flex;
  overflow-x: scroll;
  padding: 20px;

  /* Предотвращает случайное использование "Назад"*/
  overscroll-behavior: contain;

  /* Скрывает полосу прокрутки */
  scrollbar-width: none;
  -ms-overflow-style: none;

  /* Плавная прокрутка на iOS */
  -webkit-overflow-scrolling: touch;

  /* Отключает вертикальный скроллинг для тач-устройств */
  touch-action: pan-x;
}

/* Скрывает полосу прокрутки  */
.list::-webkit-scrollbar {
  display: none;
}

.item {
  height: 224px;
  width: 125px;
  flex-shrink: 0;
}

.item:not(:last-child) {
  margin-right: 10px;
}

/* фикс для отступов в конце списка */
.item:last-child {
  position: relative;
}

.item:last-child::after {
  position: absolute;
  left: 100%;
  height: 1px;
  width: 20px;
  display: block;
  content: "";
}

        

А результат выглядит вот так (там же полный код примера):


2. Горизонтальный список со снэппингом

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

            .list {
  scroll-snap-type: x mandatory;
}
        

Элементам списка добавляем опцию scroll-snap-align: start. Чтобы дать подсказку, следующий элемент может немного «выглядывать» из-за края экрана. Для этого достаточно установить отступ прокрутки: scroll-padding-inline-start:
20px
. Если хочется свайпнуть
одним движением несколько элементов,
можно добавить scroll-snap-stop: always к элементам списка, но не все браузеры пока
такое поддерживают.

Код полностью также выложен на Codesandbox:


3. Галерея в стиле Instagram

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


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

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

Вот как это будет
выглядеть:

            // ссылки на элементы DOM
const list = document.querySelector('.list');
const items = Array.from(document.querySelectorAll('.item'));
const indicators = Array.from(document.querySelectorAll('.indicator'));

// создание наблюдателя
const observer = new IntersectionObserver(onIntersectionObserved, {
  root: list,
  threshold: 0.6
});

// наблюдаем за каждым элементом
items.forEach(item => {
  observer.observe(item);
});

// когда observer обнаруживает изменение записи
// (пункт, входящий в список)
// и эта запись пересекается,
// получаем индекс пересекающегося элемента
// устанавливаем нужный индикатор в активное положение
function onIntersectionObserved(entries) {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const intersectingIndex = items.indexOf(entry.target);
      activateIndicator(intersectingIndex);
    }
  });
}

function activateIndicator(index) {
  indicators.forEach((indicator, i) => {
    indicator.classList.toggle('active', i === index);
  });
}
        

Полный результат также доступен в сэндбоксе:


Примечание
Мы установили пороговое значение 0.6. То есть если 60% элемента находится в поле зрения, то мы считаем его «пересекающимся». Если установим значение равным 1, то пересекаемым будет считать только полностью видимый элемент.

Заключение

Мы разобрали несколько
вариаций построения горизонтальных списков изображений. У приведённых решений есть и свои недостатки, и преимущества. Поскольку используется нативная
прокрутка, нет возможности настроить способ движения, мы не можем
контролировать «липкость» ползунка (в этом случае правильнее было бы использовать решение на
JavaScript). Кроме того, в нашем решении мы использовали не самые стандартные подходы в CSS. Однако объём кода невелик, и он работает даже в старых браузерах.

11
Май
2020

📸 Как сделать галерею в стиле Instagram

Галереи картинок с горизонтальной прокруткой выглядят современно и узнаваемо. Рассказываем, как с помощью CSS и JS перенести знакомую пользователям механику в ваши веб-приложения.

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

Теория: горизонтальные списки и галерея

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

Горизонтальный список со свободной прокруткой (free-scrolling horizontal lists)
Горизонтальный список со свободной прокруткой (free-scrolling horizontal lists)

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

Горизонтальный список со снэппингом (snapping horizontal lists)
Горизонтальный список со снэппингом (snapping horizontal lists)

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

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

Пример галереи
Пример галереи

Практическая часть

1. Горизонтальные списки со свободной прокруткой

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

            .list {
  display: flex;
  padding: 20px;
  overflow-x: scroll;
}

.item {
  height: 224px;
  width: 125px;
  flex-shrink: 0;
}

.item:not(:last-child) { margin-right: 10px; }
        

Интерактивное представление и полный код доступны в в
сэндбоксе:


Работает, но
можно улучшить.

Поведение при прокрутке влево. В некоторых браузерах свайп влево повторяет действие кнопки «Назад». Такое поведение можно предотвратить, установив для overscroll-behavior значение contain.

Полосу прокрутки можно скрыть, установив overflow-x в auto. Однако при скроллинге полоса вновь появится. Чтобы скрыть полностью, можно установить scrollbar-width в none. На момент написания статьи это работало только в Firefox. Для других браузеров придется добавить трюкачества в CSS:

            .list { -ms-overflow-style: none; }
.list::-webkit-scrollbar { display: none; }
        

Плавная прокрутка. В iOS не хватает
стандартной плавной прокрутки (momentum scrolling). Потребуется добавить -webkit-overflow-scrolling:
touch;
.

Предотвращение
вертикальной прокрутки
. Мы можем прокручивать
страницу по вертикали. Эту возможность лучше
отключить для пользователей мобильных устройств, добавив к списку touch-action:
pan-x
. Однако если список покрывает всю видимую область просмотра, а на странице есть что-то ещё, эти элементы станут недоступны. То есть такой вариант надо использовать с осторожностью.

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

            .list {
  display: flex;
  overflow-x: scroll;
  padding: 20px;

  /* Предотвращает случайное использование "Назад"*/
  overscroll-behavior: contain;

  /* Скрывает полосу прокрутки */
  scrollbar-width: none;
  -ms-overflow-style: none;

  /* Плавная прокрутка на iOS */
  -webkit-overflow-scrolling: touch;

  /* Отключает вертикальный скроллинг для тач-устройств */
  touch-action: pan-x;
}

/* Скрывает полосу прокрутки  */
.list::-webkit-scrollbar {
  display: none;
}

.item {
  height: 224px;
  width: 125px;
  flex-shrink: 0;
}

.item:not(:last-child) {
  margin-right: 10px;
}

/* фикс для отступов в конце списка */
.item:last-child {
  position: relative;
}

.item:last-child::after {
  position: absolute;
  left: 100%;
  height: 1px;
  width: 20px;
  display: block;
  content: "";
}

        

А результат выглядит вот так (там же полный код примера):


2. Горизонтальный список со снэппингом

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

            .list {
  scroll-snap-type: x mandatory;
}
        

Элементам списка добавляем опцию scroll-snap-align: start. Чтобы дать подсказку, следующий элемент может немного «выглядывать» из-за края экрана. Для этого достаточно установить отступ прокрутки: scroll-padding-inline-start:
20px
. Если хочется свайпнуть
одним движением несколько элементов,
можно добавить scroll-snap-stop: always к элементам списка, но не все браузеры пока
такое поддерживают.

Код полностью также выложен на Codesandbox:


3. Галерея в стиле Instagram

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


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

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

Вот как это будет
выглядеть:

            // ссылки на элементы DOM
const list = document.querySelector('.list');
const items = Array.from(document.querySelectorAll('.item'));
const indicators = Array.from(document.querySelectorAll('.indicator'));

// создание наблюдателя
const observer = new IntersectionObserver(onIntersectionObserved, {
  root: list,
  threshold: 0.6
});

// наблюдаем за каждым элементом
items.forEach(item => {
  observer.observe(item);
});

// когда observer обнаруживает изменение записи
// (пункт, входящий в список)
// и эта запись пересекается,
// получаем индекс пересекающегося элемента
// устанавливаем нужный индикатор в активное положение
function onIntersectionObserved(entries) {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const intersectingIndex = items.indexOf(entry.target);
      activateIndicator(intersectingIndex);
    }
  });
}

function activateIndicator(index) {
  indicators.forEach((indicator, i) => {
    indicator.classList.toggle('active', i === index);
  });
}
        

Полный результат также доступен в сэндбоксе:


Примечание
Мы установили пороговое значение 0.6. То есть если 60% элемента находится в поле зрения, то мы считаем его «пересекающимся». Если установим значение равным 1, то пересекаемым будет считать только полностью видимый элемент.

Заключение

Мы разобрали несколько
вариаций построения горизонтальных списков изображений. У приведённых решений есть и свои недостатки, и преимущества. Поскольку используется нативная
прокрутка, нет возможности настроить способ движения, мы не можем
контролировать «липкость» ползунка (в этом случае правильнее было бы использовать решение на
JavaScript). Кроме того, в нашем решении мы использовали не самые стандартные подходы в CSS. Однако объём кода невелик, и он работает даже в старых браузерах.

21
Апр
2020

В чем разница между интернет-магазином по сравнению с обычным сайтом?

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

Запись В чем разница между интернет-магазином по сравнению с обычным сайтом? впервые появилась Индиго.

13
Апр
2020

Docker: Удалить Контейнер — Удалить Все Контейнеры

Чтобы удалить конкретный Docker-контейнер в первую очередь необходимо узнать его CONTAINER ID или NAME сделав листинг всех Docker-контейнеров. Когда контейнер для удаления определен он может быть удален с помощью команды docker rm. Также из командной строки можно удалить только остановленные (неиспользуемые) Docker-контейнеры или, если необходимо, вы можете принудительно удалить все Docker-контейнеры Дельный Совет: Очистите Docker-хост удалив ненужные Docker-образы! Процесс аналогичен удалению Docker-контейнеров! Читать далее → Список Docker-контейнеров… Читать далее »

Запись Docker: Удалить Контейнер — Удалить Все Контейнеры впервые появилась Индиго.

27
Мар
2020

Как сделать кликабельный телефон на сайте

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

Запись Как сделать кликабельный телефон на сайте впервые появилась Индиго.

13
Фев
2020

«Каждый охотник желает знать»: теория цвета для веба и приложений

Рассказываем, как правильно подбирать и комбинировать цвета. Цветовой круг, модель HSL, цветовые схемы и психология, советы и правила. Показываем на примерах, как применять в CSS.

12
Фев
2020

Секреты правильной растяжки: адаптивные графики в вебе без клиентского JavaScript

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

Существует кучах библиотек для создания графиков и диаграмм в вебе. Можно даже имитировать отрисовку диаграмм «от руки». Обычно данные для графиков запрашиваются из сети или сама картинка рендерится на canvas – кажется, что без JavaScript не обойтись.

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

Ещё одна проблема – адаптивность. Графики необходимо перерисовывать при ресайзе, иначе они «поломаются». Это создаёт лишнюю работу и для браузера, и для программиста. Особенно если используются низкоуровневые библиотеки вроде D3.

Возьмём для примера SVG-графики из статьи New York Times и подумаем, можем ли построить их без JS.


Все методы, используемые в статье, собраны в экспериментальный компонент Pancake для фреймворка Svelte.

Проблема

Создать линейный график на SVG не так уж и сложно. Допустим, есть вот такие данные:

            const data = [
  { x: 0,  y: 0 },
  { x: 1,  y: 1 },
  { x: 2,  y: 4 },
  { x: 3,  y: 9 },
  { x: 4,  y: 16 },
  { x: 5,  y: 25 },
  { x: 6,  y: 36 },
  { x: 7,  y: 49 },
  { x: 8,  y: 64 },
  { x: 9,  y: 81 },
  { x: 10, y: 100 }
];
        

Требуется вывести результат в область размером 300×100 пикселей.

Чтобы получить координаты опорных точек, нужно умножить x на 30, а y вычесть из 100:

            <polyline points="
  0,0
  30,99
  60,96
  90,91
  120,84
  150,75
  180,64
  210,51
  240,36
  270,19
  300,0
"></polyline>
        

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

            function scale(domain, range) {
  const m = (range[1] - range[0]) / (domain[1] - domain[0]);
  return num => range[0] + m * (num - domain[0]);
}

const x = scale([0, Math.max(...data.map(d => d.x))], [0, 300]);
const y = scale([0, Math.max(...data.map(d => d.y))], [100, 0]);

const points = data.map(d => `${x(d.x)},${y(d.y)}`).join(' ');

const chart = `
<svg width="300" height="100">
  <polyline points="${points}"></polyline>
</svg>
`;
        

Добавив в svg пару осей и немного стилей, получаем график:


Живой пример

Всю логику можно разместить в NodeJS-скрипте, так что код будет целиком генерироваться на сервере. Таким образом, нам не требуется ни строчки клиентского JavaScript.

Однако этот чарт не адаптивен, размеры всегда составляют 300x100px. А это не то, чего мы ожидаем.

Решение. Часть 1

У SVG-элемента есть атрибут viewBox, задающий систему координат изображения, независящую от реальных размеров. По умолчанию соотношение сторон, заданное в viewBox сохраняется. Подавляется такое поведение с помощью preserveAspectRatio="none".

Добавим простую систему координат:

            <svg viewBox="0 0 100 100" preserveAspectRatio="none">
        

Что ж, теперь наш график действительно стал гибким…


Живой пример

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

Вторую проблему легко решить, используя малоизвестное CSS-свойство vector-effect: non-scaling-stroke для каждого элемента.


Живой пример

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

Решение. Часть 2

Оси графика необязательно рисовать прямо в SVG. Мы можем использовать обычные HTML-элементы и расположить их с помощью CSS. Чтобы синхронизировать масштабирование HTML- и SVG-слоев, применяем процентную координатную сетку.

            <!-- x axis -->
<div class="x axis" style="top: 100%; width: 100%; border-top: 1px solid black;">
  <span style="left: 0">0</span>
  <span style="left: 20%">2</span>
  <span style="left: 40%">4</span>
  <span style="left: 60%">6</span>
  <span style="left: 80%">8</span>
  <span style="left: 100%">10</span>
</div>

<!-- y axis -->
<div class="y axis" style="height: 100%; border-left: 1px solid black;">
  <span style="top: 100%">0</span>
  <span style="top: 50%">50</span>
  <span style="top: 0%">100</span>
</div>

<style>
  .axis {
    position: absolute;
  }

  .axis span {
    position: absolute;
    line-height: 1;
  }

  .x.axis span {
    top: 0.5em;
    transform: translate(-50%,0);
  }

  .y.axis span {
    left: -0.5em;
    transform: translate(-100%,-50%);
  }
</style>
        

График больше не ломается!


Живой пример

Ещё одно преимущество HTML-элементов – они автоматически привязываются к ближайшему пикселю. Так что не появляется неприятный эффект нечёткости, который встречается в SVG.

Готовое решение

Потребовалось много ручной работы. Чтобы этого избежать, воспользуемся готовым решением – компонентом Pancake для SvelteJS.

            <script>
  import * as Pancake from '@sveltejs/pancake';

  const points = [
    { x: 0,  y: 0 },
    { x: 1,  y: 1 },
    { x: 2,  y: 4 },
    { x: 3,  y: 9 },
    { x: 4,  y: 16 },
    { x: 5,  y: 25 },
    { x: 6,  y: 36 },
    { x: 7,  y: 49 },
    { x: 8,  y: 64 },
    { x: 9,  y: 81 },
    { x: 10, y: 100 }
  ];
</script>

<div class="chart">
  <Pancake.Chart x1={0} x2={10} y1={0} y2={100}>
    <Pancake.Box x2={10} y2={100}>
      <div class="axes"></div>
    </Pancake.Box>

    <Pancake.Grid vertical count={5} let:value>
      <span class="x label">{value}</span>
    </Pancake.Grid>

    <Pancake.Grid horizontal count={3} let:value>
      <span class="y label">{value}</span>
    </Pancake.Grid>

    <Pancake.Svg>
      <Pancake.SvgLine data={points} let:d>
        <path class="data" {d}/>
      </Pancake.SvgLine>
    </Pancake.Svg>
  </Pancake.Chart>
</div>

<style>
  .chart {
    height: 100%;
    padding: 3em 2em 2em 3em;
    box-sizing: border-box;
  }

  .axes {
    width: 100%;
    height: 100%;
    border-left: 1px solid black;
    border-bottom: 1px solid black;
  }

  .y.label {
    position: absolute;
    left: -2.5em;
    width: 2em;
    text-align: right;
    bottom: -0.5em;
  }

  .x.label {
    position: absolute;
    width: 4em;
    left: -2em;
    bottom: -22px;
    font-family: sans-serif;
    text-align: center;
  }

  path.data {
    stroke: red;
    stroke-linejoin: round;
    stroke-linecap: round;
    stroke-width: 2px;
    fill: none;
  }
</style>
        

Живой пример

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

Обратите внимание, Pancake не создаёт элементы span и path внутри SVG-изображения. Компоненты прежде всего логические – вы детально контролируете представление.

Усложняем задачу

Мы можем сделать гораздо больше, чем простейшие линейные графики:


Например, очень интересны диаграммы рассеивания (scatterplots).

Живой пример

Мы не можем использовать для них элементы circle, так как они могут некорректно масштабироваться. Компонент Pancake.Scatterplot генерирует path, состоящий из несвязанных дуг с нулевым радиусом. Размер окружностей определяется шириной контура (stroke width).

Благодаря тому, что библиотека оформлена в виде компонента Svelte, мы легко можем добавить графикам немного интерактива.

Живой пример

Взаимодействия также можно декларативно обрабатывать прямо внутри графика Pancake. Например, создадим дерево квадрантов (quadtree, во многом заимствовано из библиотеки D3), которое позволит находить ближайшую к курсору точку.

            <Pancake.SvgScatterplot data={points} let:d>
  <path class="data" {d}/>
</Pancake.SvgScatterplot>

<Pancake.Quadtree data={points} let:closest>
  {#if closest}
    <Pancake.SvgPoint x={closest.x} y={closest.y} let:d>
      <path class="highlight" {d}/>
    </Pancake.SvgPoint>
  {/if}
</Pancake.Quadtree>
        

В будущем в библиотеке появится поддержка canvas (2D и WebGL). Конечно, графики на canvas не смогут работать без JavaScript, но при работе с большими наборами данных SVG может не справляться.

Предостережения

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

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

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

***

Название Pancake (Блин) связано с тем, что фактически чарты строятся путём наложения слоёв друг на друга. Проект во многом вдохновлялся фреймворком Layer Cake и библиотекой D3.

А какие инструменты вы применяете для построения графиков?

06
Фев
2020

Нет времени объяснять! Пишем таймер обратного отсчёта на чистом CSS

Ради фана реализуем динамический виджет таймера на одних стилях. Для порядка приводим ещё и решение на JavaScript (но это не главное).

CSS – мощный инструмент современного разработчика. Он многое умеет, в нём есть переменные, функции, наследование и ещё много крутых штук. Но всё-таки это не язык программирования – у него совсем другая сфера ответственности. Тем интереснее использовать CSS для решения задач программирования 🙂 Именно этим сегодня и займёмся.

Дисклеймер! Многие вещи в принципе невозможно сделать на CSS. Ещё больше вещей делать на CSS нерационально. Мы занимаемся этим только из болезненного любопытства и стремления познать все скрытые возможности инструмента.

Условие задачи

Нужно сделать таймер обратного отсчёта. Предъявляемые требования:

  • Таймер должен выводить миллисекунды от 99 до 0.
  • Когда остаётся меньше 10 миллисекунд, нужно выводить только одну цифру (от 9 до 0) и центрировать её.
  • Бонус #1: Цвет шрифта и фона можно настраивать в процессе работы (без кусочка JS не обойтись).
  • Бонус #2. После остановки таймера его можно перезапустить.
  • Код должен работать и на ПК, и на мобильных устройствах.

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

Да, не очень элегантно. Но сработает, вот увидите.

Что нам потребуется?

  1. CSS трансформации
  2. CSS анимации
  3. Flexbox-модель
  4. CSS переменные
  5. Различные селекторы

Вот что получится в итоге:

Реализация на JavaScript

Сразу посмотрим, как это можно было сделать на JS.

Простой и понятный код, состоящий из пары функций. Для обновления таймера подписываемся на момент перерисовки браузера с помощью метода window.requestAnimationFrame().

            let end;
const now = Date.now;
const timer = document.getElementById("timer");
const duration = 9900;

function displayCountdown() {
  const count = parseInt((end - now()) / 100);
  timer.textContent =
    count > 0 ? (window.requestAnimationFrame(displayCountdown), count) : 0;
}

function start() {
  end = now() + duration;
  window.requestAnimationFrame(displayCountdown);
}
        

Всего пара блоков в HTML:

            <div class="timer-container">
  <p class="timer" id="timer">99</p>
</div>
        

И элементарные стили для выравнивания:

            .timer-container {
  display: flex;
  height: 100vh; 
}

.timer {
  margin: auto;
}
        

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

Общий подход

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

            <div class="timer">
  <div class="digit seconds">
    <span>9</span>
    <span>8</span>
    <span>7</span>
    <span>6</span>
    <span>5</span>
    <span>4</span>
    <span>3</span>
    <span>2</span>
    <span>1</span>
    <span>0</span> 
  </div><div class="digit milliseconds">
    <span>9</span>
    <span>8</span>
    <span>7</span>
    <span>6</span>
    <span>5</span>
    <span>4</span>
    <span>3</span>
    <span>2</span>
    <span>1</span> 
    <span>0</span>
  </div>
</div>
        

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

CSS трансформации

Большинство CSS-свойств плохо подходят для анимирования, так как их изменение вызывает перерисовку страницы. Но есть два «безопасных» свойства, которыми мы можем воспользоваться: transform и opacity.

Подробнее об анимации в вебе можно прочитать в замечательном руководстве High Performance Animations.

Для оживления таймера возьмём надёжное свойство translateY. Оно обеспечит перемещение блока только по y-оси.

            .selector {
  transform: translateY(0);
}
        

Можно воспользоваться и полным свойством translate, но помните, что его первый аргумент соответствует x-координате. Если хотите перемещать элемент только по вертикали, то передайте первым параметром 0.

            .selector {
  transform: translate(3em);
}

/* то же самое */
.selector {
  transform: translate(3em, 0);
}
        

Чтобы лучше понять функции трансформации, загляните в спецификацию CSS Transforms Module Level 1. Там всё разобрано на подробных примерах, так что вы разберётесь, даже если не очень любите математику.

CSS animations

Следующий шаг – анимировать применение трансформаций. Для этого мы используем CSS-анимации.

Самое важное правило, которое вы должны знать, – это @keyframes. Оно позволяет разбить анимацию на кадры и описать каждый из них в отдельности.

            @keyframes seconds {
  0% { transform: translateY(0) }
  10% { transform: translateY(-1em) }
  20% { transform: translateY(-2em) }
  30% { transform: translateY(-3em) }
  40% { transform: translateY(-4em) }
  50% { transform: translateY(-5em) }
  60% { transform: translateY(-6em) }
  70% { transform: translateY(-7em) }
  80% { transform: translateY(-8em) }
  90% { 
    transform: translateY(-10em);
    width: 0;
  }
  100% { 
    transform: translateY(-10em);
    width: 0;
  }
}

@keyframes milliseconds {
  0% {transform: translateY(0) }
  10% { transform: translateY(-1em) }
  20% { transform: translateY(-2em) }
  30% { transform: translateY(-3em) }
  40% { transform: translateY(-4em) }
  50% { transform: translateY(-5em) }
  60% { transform: translateY(-6em) }
  70% { transform: translateY(-7em) }
  80% { transform: translateY(-8em) }
  90% { transform: translateY(-9em) }
  100% { transform: translateY(-9em) }
}
        

Здесь мы создали две анимации – по одной для каждого блока с цифрами.

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

Чтобы применить анимации, используем краткий синтаксис свойства animation:

            .seconds {
  animation: seconds 10s 1 step-end forwards;
}

.milliseconds {
  animation: milliseconds 1s 10 step-end forwards;
}
        

Если вы зайдете в панель инструментов разработчика и откроете вкладку с вычисленными значениями (computed), то увидите, что вместо одного свойства animation к элементу применились сразу несколько:


animation-name

Имя анимации, использующееся для её идентификации. Для него можно использовать латинские буквы, цифры, нижнее подчёркивание и дефисы. Первой должна идти буква. В начале не могут стоять зарезервированные слова none, unset, initial или inherit, а также сочетание --. Регистр символов имеет значение.

animation-duration

Продолжительность одного цикла анимации. Для первой колонки цифр анимация будет длиться 10 секунд (10s). Вторая колонка двигается в 10 раз быстрее (1s).

animation-iteration-count

Количество циклов анимации, которое должно выполниться до ее остановки. Первую колонку нужно прокрутить лишь один раз – от 9 до 0. Вторую – целых 10 раз, по одному на каждое положение первой колонки.

animation-timing-function

Это свойство описывает прогресс анимации в течение одного цикла. Если вы знакомы с кривыми Безье, то можете контролировать его до мельчайших подробностей с помощью функции cubic-bezier(). Для простых смертных есть несколько готовых значений animation-timing-function, обозначенных ключевыми словами (ease, ease-in и т.д)

Нам же больше подойдёт значение step-end. Это то же самое, что и steps(1, jump-end).

Функция steps() разбивает анимацию на равные «шаги», то есть величина изменяется не плавно, а прерывисто. Первый аргумент – количество шагов, второй – момент, когда начинается анимация. Ключевое слово jump-end означает, что анимация запускается в конце, а не начале каждого шага.

Чтобы лучше разобраться в этой функции, обратитесь к статье Дэна Уилсона Jumps: The New Steps() in Web Animation.

animation-fill-mode

Состояние целевого объекта до и после завершения анимации. Нам требуется, чтобы колонки останавливались на последней цифре (последний ключевой кадр), поэтому используем значение forwards.

Когда анимация остановится, первая цифра будет скрыта, а вторая колонка замрёт на позиции -9em.

Ещё больше о CSS анимациях вы можете узнать в спецификации CSS Animations Level 1.

Flexbox

Цифру 0 в первом разряде мы уже скрыли с помощью инструкции @keyframes, осталось только выровнять таймер по центру страницы:

            .timer-container {
  display: flex;
  height: 100vh; 
}

.timer {
  overflow: hidden;
  margin: auto;
  height: 1em;
  width: 2ch;
  text-align: center;
}

.digit {
  display: inline-block;
}

.digit span {
  display: block;
  width: 100%;
  height: 1em;
}
        

Для вертикального выравнивания используем автоматический расчёт маргинов у потомка флекс-контейнера. Другими словами, назначаем display: flex родительскому блоку, и margin: auto дочернему.

Горизонтальное выравнивание достигается обычным text-align: center.

Больше информации о Flex-модели – в спецификации CSS Flexible Box Layout Module Level 1.

Бонус #1: Динамическое изменение цвета

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

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

Положим цвета в переменные:

            :root {
  --fontColour: #000000;
  --bgColour: #ffffff;
}
        

И используем их в таймере:

            .timer {
  background-color: var(--bgColour, white);
}

.digit {
  color: var(--fontColour, black);
}
        

Добавим на страницу два инпута для изменения цветовой схемы:

            <aside>
  <label>
    <span>Font colour:</span>
    <input id="fontColour" type="color" value="#000000" />
  </label>
  <label>
    <span>Background colour:</span>
    <input id="bgColour" type="color" value="#ffffff" />
  </label>
</aside>
        

Теперь придётся написать пару строк JS-кода, чтобы динамически изменять переменные цветов при изменении инпутов:

            let root = document.documentElement;
const fontColourInput = document.getElementById('fontColour');
const bgColorInput = document.getElementById('bgColour');

fontColourInput.addEventListener('input', updateFontColour, false);
bgColorInput.addEventListener('input', updateBgColour, false);

function updateFontColour(event) {
  root.style.setProperty('--fontColour', event.target.value);
}

function updateBgColour(event) {
  root.style.setProperty('--bgColour', event.target.value);
}
        

Да, не всё можно решить на чистом CSS.

Бонус #2: Перезапуск таймера

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

Возможно, вы подумали, что нам придётся снова смошенничать и добавить JavaScript? А вот и нет, мы справимся своими силами!

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

            .toggle span {
  font-size: 1.2em;
  padding: 0.5em;
  background-color: palegreen;
  cursor: pointer;
  border-radius: 4px;
}

input[type="checkbox"] {
  opacity: 0;
  position: absolute;
}

 input[type="checkbox"]:checked ~ aside .toggle span:first-of-type {
  display: none;
}

.toggle span:nth-of-type(2) {
  display: none;
}

input[type="checkbox"]:checked ~ aside .toggle span:nth-of-type(2) {
  display: inline;
}
        

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

            input[type="checkbox"]:checked ~ .timer .seconds {
  animation: seconds 10s 1 step-end forwards;
}

input[type="checkbox"]:checked ~ .timer .milliseconds {
  animation: milliseconds 1s 10 step-end forwards;
}
        

Важно, чтобы чекбокс находился на одном уровне с таймером и стоял в разметке перед ним. Это даст нам возможность воспользоваться селектором “сиблингов” (~). А лейбл для него может находиться где угодно.

***

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

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

Если вы любите CSS, вот ещё кое-что:

В комментариях делитесь самыми нестандартными решениями ваших задач.

20
Янв
2020

Графический приём, благодаря которому пользователи будут регистрироваться чаще

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

При разработке приложения мы прикладываем все усилия к со…

17
Янв
2020

Веб-скрапинг по расписанию с Django и Heroku

оздаём Django-приложение, ежедневно проверяющее доску объявлений о работе. Парсим в BeautifulSoup, сохраняем в PostgreSQL, развёртываем на сервере Heroku.

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

31
Окт
2019

WebMoney Domains

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

Запись WebMoney Domains впервые появилась Индиго.

13
Сен
2019

Поддержка сайта

С момента появления таких сервисов как narod.ru, Google Sites или Front Page, которые позволяли «склепать» самостоятельно простой сайт с использованием элементарных шаблонов, многие создавали себе домашние странички, где рассказывали о себе, о своих услугах, хобби, достижениях. Однако, для рекламы коммерческих услуг такое решение не подходило и стали обращаться к профессионалам, которые предлагали комплекс услуг по созданию сайта. Так было до тех… Читать далее »

Запись Поддержка сайта впервые появилась Индиго.

05
Сен
2019

Что такое IT-аутсорсинг и способы организации

За последние несколько лет IT-аутсорсинг прочно вошел в экономическую жизнь общества и взаимоотношения в бизнесе. Использование сторонних сервисов для решения задач в самом широком спектре информационных технологий можно назвать трендом нашего времени Выгоды и преимущества IT-аутсорсинга Более высокое качество услуг при относительно низкой стоимости. Возможность в одной фирме решить все необходимые задачи по созданию и обслуживанию IT-инфраструктуры. Абонентские услуги в… Читать далее »

Запись Что такое IT-аутсорсинг и способы организации впервые появилась Индиго.

30
Авг
2019

Список лучших CSS фреймворков для фронтенд-разработки

Список лучших CSS фреймворков для фронтенд-разработкиМечтаете быстро создавать прототипы приложений? Упростите себе задачу с помощью этих CSS фреймворков. И не придётся беспокоится о стилях. Зачем использовать CSS фреймворки при написании пользовательского CSS-кода? Время – ограниченный ресурс, поэтому тратьте его на создание функциональности. Улучшите ли производительность и эстетику, когда создадите CSS с нуля? Абсолютно! Но когда нет творческих способностей в области […]

Запись Список лучших CSS фреймворков для фронтенд-разработки впервые появилась Библиотека программиста.

26
Авг
2019

Мастер отзывчивого дизайна за 5 минут: 4 секретные техники

Современный веб-сайт должен хорошо выглядеть в любых условиях. Разбираемся в основах отзывчивого дизайна и учим страницу подстраиваться. Что такое отзывчивый дизайн?  Это способность вашего сайта подстраиваться под изменение внешних условий, например, ширины браузера, без потери визуальной привлекательности. Смотрите небольшой ликбез по отзывчивости и адаптивности в вебе. Зачем нужен отзывчивый дизайн?  Чтобы пользователи могли с одинаковым […]

Запись Мастер отзывчивого дизайна за 5 минут: 4 секретные техники впервые появилась Библиотека программиста.