Будущее загрузки CSS

Перевод статьи The future of loading CSS с сайта jakearchibald.com, опубликовано на css-live.ru, автор — Джейк Арчибальд.

Опубликовано 11 февраля 2016 и чуть было не затерялось в тени каких-то волнующихся гравитационных штук. Ну спасибо, Эйнштейн.

Chrome собирается изменить поведение <link rel="stylesheet">, что будет заметно, если эта конструкция окажется внутри <body>. Из описания в почтовой рассылке разработчиков Blink не очень понятно, чем это грозит и что это дает, так что я решил пояснить это здесь.

Обычная загрузка CSS на сегодня

<head>
 <link rel="stylesheet" href="/all-of-my-styles.css">
</head>
<body>
 …содержание…
</body>

CSS блокирует отрисовку, заставляя пользователя смотреть на белый экран до полной загрузки all-of-my-styles.css.

Обычно принято объединять весь CSS сайта в один-два ресурса, что значит, что пользователь скачивает много правил, которые не применяются к текущей странице. Это потому, что сайт включает в себя разные типы страниц со множеством «компонентов», а отдача CSS на уровне отдельных компонентов в HTTP/1 ухудшает быстродействие.

Это не проблема в случае SPDY и HTTP/2, где можно передавать много небольших ресурсов с минимальными издержками, и кешировать их независимо.

<head>
 <link rel="stylesheet" href="/site-header.css">
 <link rel="stylesheet" href="/article.css">
 <link rel="stylesheet" href="/comment.css">
 <link rel="stylesheet" href="/about-me.css">
 <link rel="stylesheet" href="/site-footer.css">
</head>
<body>
 …содержание…
</body>

Это решает вопрос избыточности, но для этого вам нужно уже при выводе <head> знать, что будет на странице, что может мешать потоковой отдаче. Кроме того, браузеру всё еще приходится загружать весь CSS до того, как он может что-либо отобразить. Медленная загрузка /site-footer.css задержит отрисовку всего.

Посмотреть пример

Современный подход к загрузке CSS

<head>
 <script>
    // https://github.com/filamentgroup/loadCSS
    !function(e){"use strict"
    var n=function(n,t,o){function i(e){return f.body?e():void setTimeout(function(){i(e)})}var d,r,a,l,f=e.document,s=f.createElement("link"),u=o||"all"
    return t?d=t:(r=(f.body||f.getElementsByTagName("head")[0]).childNodes,d=r[r.length-1]),a=f.styleSheets,s.rel="stylesheet",s.href=n,s.media="only x",i(function(){d.parentNode.insertBefore(s,t?d:d.nextSibling)}),l=function(e){for(var n=s.href,t=a.length;t--;)if(a[t].href===n)return e()
    setTimeout(function(){l(e)})},s.addEventListener&&s.addEventListener("load",function(){this.media=u}),s.onloadcssdefined=l,l(function(){s.media!==u&&(s.media=u)}),s}
    "undefined"!=typeof exports?exports.loadCSS=n:e.loadCSS=n}("undefined"!=typeof global?global:this)
 </script>
 <style>
    /* Стили для «шапки» страницы, плюс: */
    .main-article,
    .comments,
    .about-me,
    footer {
     display: none;
    }
 </style>
 <script>
    loadCSS("/the-rest-of-the-styles.css");
 </script>
</head>
<body>
</body>

В этом коде мы заготовили немножко встроенных стилей, которые дают нам быструю начальную отрисовку и скрывают те элементы, для которых у нас еще нет стилей, а остальной CSS мы потом подгружаем асинхронно с помощью JavaScript. Этот остальной CSS переопределит display: none для .main-article и т.п..

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

Посмотреть пример.

Более приближенный к жизни пример — моя оффлайновая вики, где это сработало на ура:

wpt.205296fac4a9
На 3G первая отрисовка на 0,6 секунды быстрее. Полные результаты до и после.

Но есть и пара недостатков:

Нужна (небольшая) JavaScript-библиотека

К сожалению, это из-за реализации в WebKit. Как только <link rel="stylesheet"> добавляется на страницу, WebKit блокирует отрисовку до загрузки файла стилей, даже если его добавили скриптом.

В Firefox и IE/Edge, файлы стилей, добавленные скриптом, грузятся полностью асинхронно. Стабильная версия Chrome пока еще ведет себя как WebKit, но в Canary мы перешли на поведение Firefox/Edge.

Вы ограничены двумя фазами загрузки

В предыдущем примере встроенный CSS скрывает содержимое, у которого еще нет стилей, с помощью display:none, затем асинхронно загружаемый CSS открывает его. Если развить эту идею до двух и более CSS-файлов, они могут загружаться не по порядку, вызывая «перескоки» содержимого по ходу загрузки:

Посмотреть пример.

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

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

Способ проще и лучше

<head>
</head>
<body>
 <!-- Отдаем ресурс без запроса с помощью HTTP/2, или встраиваем его — смотря что быстрее -->
 <link rel="stylesheet" href="/site-header.css">
 <header>…</header>
 
 <link rel="stylesheet" href="/article.css">
 <main>…</main>
 
 <link rel="stylesheet" href="/comment.css">
 <section class="comments">…</section>
 
 <link rel="stylesheet" href="/about-me.css">
 <section class="about-me">…</section>
 
 <link rel="stylesheet" href="/site-footer.css">
 <footer>…</footer>
</body>

План в том, чтобы для каждого <link rel="stylesheet"> блокировать отрисовку контента, пока стили грузятся, но разрешить отрисовку контента перед ним. Стили грузятся параллельно, но применяются последовательно. Благодаря этому <link rel="stylesheet"> начинает вести себя как <script src="…"></script>.

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

  • «Шапка»: отобразилась
  • Статья: отобразилась
  • Комментарии: не отобразились, CSS перед ними еще не загрузился (/comment.css)
  • Раздел «обо мне»: не отобразился, CSS перед ним еще не загрузился (/comment.css)
  • «Подвал»: не отобразился, CSS перед ним еще не загрузился (/comment.css), даже несмотря на то, что его собственный CSS уже загружен

Это дает нам последовательную отрисовку страницы. Вам не нужно решать, что считать «первым экраном», просто подключайте CSS компонента страницы до первого экземпляра этого компонента. Это полностью совместимо с потоковой загрузкой, поскольку не нужно выводить <link> до того самого момента, как он понадобится.

Нужно быть внимательными при использовании систем раскладки, где она определяется содержимым (напр. таблиц и флексбоксов), чтобы избежать «скачков» контента по ходу загрузки. Это не новая проблема, но с постепенной отрисовкой натыкаться на нее придется чаще. Можно исправить поведение флексбоксов хаками, но CSS-гриды — гораздо лучшая система для раскладки всей страницы (флексбоксы при этом великолепны для небольших компонентов).

Изменения в Chrome

Спецификация HTML не описывает, как CSS должен блокировать отрисовку страницы, и вообще не одобряет <link rel="stylesheet"> внутри <body>, но все браузеры это поддерживают. Разумеется, каждый из них обходится с <link> в <body> по-своему:

  • Chrome & Safari: прекращают отрисовку, как только обнаружат <link rel="stylesheet">, и не возобновляют ее, пока все обнаруженные стили не загрузятся. Это часто приводит к тому, что не отображается содержимое до того <link>, который блокировал отрисовку.
  • Firefox: <link rel="stylesheet"> в <head> блокирует загрузку, пока все обнаруженные стили не загрузятся. <link rel="stylesheet"> в <body> не блокирует отрисовку, если только ее уже не блокировал CSS-файл в <head>. Из-за этого может происходить «мелькание неоформленного содержимого» (англ. flash of unstyled content — FOUC).
  • IE/Edge: блокирует парсер, пока стили не загрузятся, но позволяет отрисоваться контенту до <link>.

Нам в Chrome нравится поведение IE/Edge, так что мы решили подстроиться под него. Это делает возможной такую постепенную отрисовку CSS, как описано выше. Мы работаем над тем, чтобы это вошло в спецификацию, начиная с того, чтобы <link> мог находиться в <body>.

Нынешнее поведение Chrome/Safari обратно совместимо, они лишь блокируют отрисовку дольше, чем нужно. С поведением Firefox всё чуточку сложнее, но есть обходной путь…

Фикс для Фокса

Поскольку Firefox не всегда блокирует отрисовку в случае <link> в <body>, нужно немного повозиться с ним, чтобы избежать мелькания «голого» контента. К счастью, это очень легко, поскольку <script> блокирует парсинг, но также дожидается загрузки стилей:

<link rel="stylesheet" href="/article.css"><script> </script>
<main>…</main>

Чтобы это сработало, элемент <script> должен не быть пустым, пробела в нем вполне достаточно.

Посмотрите на это в действии!

Посмотреть пример.

Firefox и Edge с IE покажут вам чудесную постепенную отрисовку, тогда как Chrome и Safari будут показывать белый экран до полной загрузки всего CSS. Нынешнее поведение Chrome и Safari не хуже, чем при размещении всех стилей в <head>, так что можно начать применять этот метод уже сейчас. В ближайшие месяцы Chrome перейдет на подход Edge, и быструю отрисовку увидит больше пользователей.

Так что вот вам гораздо более простой способ грузить только нужный CSS и заодно получить более быструю отрисовку. Пользуйтесь на здоровье!

Спасибо @lukedjn, Полу Льюису и Доменику Дениколе за поправки. Особое спасибо Борису Збарски, который помог мне разобраться с поведением Firefox. Пол также первым придумал каламбур «Фикс для Фокса», но я надеюсь, что досюда вы не дочитаете, так что все лавры достанутся мне.

P.S. Это тоже может быть интересно:

9 Комментарии

  1. Алексей

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

  2. Максим Усачев

    Кстати, у Джейка вышло продолжение этой статьи, и так случилось, что его мы тоже перевели

  3. Георгий

    https://github.com/agragregra/start_html
    Полезный исходник, оптимизированно все на 100%, + есть сборки под все предпроцессоры

  4. Алексей

    привоодит
    Спеификация HTML
    Комментарий можете не публиковать, ввиду его ситуационной ценности :-)
    А статья интересная, спасибо за перевод!

    1. SelenIT (Автор записи)

      Спасибо! Исправлено.

  5. Redisko

    Посмотрел код loadCSS. Не очень понравилась идея писать подключение css в скриптах через функцию. Да и сам он неоправданно большой. Имхо, своё значение атрибута «rel» у тега «link» — лучше.

    <!-- HTML -->
    <link rel="async-css" href="/style-a.css" media="print" />
    <link rel="async-css" href="/style-b.css" />
    <link rel="async-css" href="/style-c.css" />
    // JS
    !function () {
      function _ready (callback) {
        if (document.body) return callback();
        setTimeout(_ready.bind(null, callback));
      }
    
      _ready(function (event) {
        [].forEach.call(document.querySelectorAll('link[rel="async-css"]'), function (lnk) {
          var media = lnk.media;
          // предотвратить блокирование рендеринга страницы (идея из loadCSS)
          lnk.media = 'only nothing';
          lnk.rel = 'stylesheet';
          lnk.addEventListener('load', function () {
            lnk.setAttribute('media', media || 'screen');
          })
        })
      });
    }();

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

    !function () {
      function _ready (callback) {
        if (document.body) return callback();
        setTimeout(_ready.bind(null, callback));
      }
      var groups = {}, loaded = {};
      _ready(function (event) {
        [].forEach.call(document.querySelectorAll('link[rel="async-css"]'), function (lnk) {
          var media = lnk.media, name = lnk.getAttribute('group') || '*';
          if (!groups[name]) {
            groups[name] = [];
            loaded[name] = 0;
          }
          groups[name].push(lnk);
          lnk.media = 'only nothing';
          lnk.rel = 'stylesheet';
          lnk.addEventListener('load', function () {
            if (++loaded[name] >= groups[name].length) {
              console.log(groups, loaded);
              groups[name].forEach(function (el) {
                el.setAttribute('media', media || 'screen');
              });
            }
          })
        })
      });
    }();
    

    Код особо не тестировал, это больше демонстрация подхода к решению, общей идеи.

  6. Redisko

    Черт, так не честно, думал что <code> сделают код преформатированным. И вот как теперь поправить этот ужас?

    1. SelenIT (Автор записи)

      Да, есть у вордпресса неудобство с вводом больших кусков кода. Вроде поправили!

  7. Иван

    Мне кажется, что корень проблемы в том, что форму считают важнее содержания. Если стили перестанут весить больше контента, то костыли не потребуются.

Оставить комментарий

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

Можно использовать следующие HTML-теги и атрибуты: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

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