November 18

style.setProperty vs setAttribute

На днях столкнулся с интересным вопросом. Что быстрее element.style.setProperty(свойство, значение) или element.setAttribute('style', 'свойство: значение')? На первый взгляд ответ кажется очевидным. Логика говорит нам, что setProperty должен устанавливать значение сразу в CSSOM, тогда как setAttribute выставляет сначала атрибут style и уже потом значение атрибута будет разобрано в CSSOM. Таким образом, setProperty должен быть быстрее. Но действительно ли всё так однозначно? Давайте разбираться.

Начнем с того, что немного освежим мат. часть. Мы знаем, что стили описываются с помощью языка CSS. Получив строковое описание стилей на языке CSS, браузер разбирает его и составляет объект CSSOM. Интерфейс этого объекта представлен спецификацией https://www.w3.org/TR/cssom-1. Он следует принципам каскадности и наследования, изложенным в https://www.w3.org/TR/css-cascade-4.

Из выше указанных спецификаций мы знаем, что основной единицей CSS является "свойство". Свойству присваивается значение, характерное конкретно этому свойству. Если значение не задано явным образом, оно наследуется от выше стоящего стиля или, если нет вышестоящего, будет установлено initial value.

Набор свойств для элемента собирается в правила CSSRule. Правила бывают разных типов. Наиболее популярный тип - CSSStyleRule, определяющий свойства элемента. Такое правило начинается с указания одного из валидных селекторов и последующих фигурных скобок с набором свойств и значений <selector>: { ... } Имеются и другие типы правил, например CSSFontFaceRule, описывающий параметры подключаемого шрифта @font-face { ... }, CSSMediaRule - @media { ... } и др. Полный список в спецификации https://www.w3.org/TR/cssom-1/#css-rules.

Правила собираются в так называемый CSSStyleSheet, который фактически является абстрактным представлением тэга <style>. В свою очередь, style sheets собираются в коллекцию и привязываются к документу.

Все эти уровни абстракции и являются моделью CSS Object Model (CSSOM). И хотя спецификация описывает в основном только синтаксис модели, на практике в неё же входит и непосредственная реализация CSS API браузера. Такие функции, как computed style, методы кодировки цвета, математические операции и многое другое - тоже часть CSSOM.

Установка свойства в Blink

С теорией разобрались, теперь давайте немного заглянем под капот браузера. А точнее в движок рендеринга Blink, используемый браузерами Chromium, Opera, WebView и др. Blink является частью репозитория Сhromium и не поставляется самостоятельно. Поэтому эксперименты будем проводить именно в Chromium (версия 132.0.6812.1 на момент написания статьи).

style.setProperty

Начнем с метода style.setProperty.

element.style.setProperty("background-color", "red");

Чтобы понять, что происходит под капотом Blink, стоит заглянуть внутрь самого Blink.

/third_party/blink/renderer/core/css/abstract_property_set_css_style_declaration.cc#130

void AbstractPropertySetCSSStyleDeclaration::setProperty(
    const ExecutionContext* execution_context,
    const String& property_name,
    const String& value,
    const String& priority,
    ExceptionState& exception_state) {
  CSSPropertyID property_id =
      UnresolvedCSSPropertyID(execution_context, property_name);
  if (!IsValidCSSPropertyID(property_id) || !IsPropertyValid(property_id)) {
    return;
  }
  
  bool important = EqualIgnoringASCIICase(priority, "important");
  if (!important && !priority.empty()) {
    return;
  }
  
  const SecureContextMode mode = execution_context
                                     ? execution_context->GetSecureContextMode()
                                     : SecureContextMode::kInsecureContext;
  SetPropertyInternal(property_id, property_name, value, important, mode,
                      exception_state);
}

Первым делом проходит базовая проверка переданного свойства на валидность. Далее проверяется приоритет стиля и вызывается внутренний метод SetPropertyInternal.

/third_party/blink/renderer/core/css/abstract_property_set_css_style_declaration.cc#236

void AbstractPropertySetCSSStyleDeclaration::SetPropertyInternal(
    CSSPropertyID unresolved_property,
    const String& custom_property_name,
    StringView value,
    bool important,
    SecureContextMode secure_context_mode,
    ExceptionState&) {
  StyleAttributeMutationScope mutation_scope(this);
  WillMutate();
  
  MutableCSSPropertyValueSet::SetResult result;
  if (unresolved_property == CSSPropertyID::kVariable) {
    AtomicString atomic_name(custom_property_name);
    
    bool is_animation_tainted = IsKeyframeStyle();
    result = PropertySet().ParseAndSetCustomProperty(
        atomic_name, value, important, secure_context_mode, ContextStyleSheet(),
        is_animation_tainted);
  } else {
    result = PropertySet().ParseAndSetProperty(unresolved_property, value,
                                               important, secure_context_mode,
                                               ContextStyleSheet());
  }
  
  if (result == MutableCSSPropertyValueSet::kParseError ||
      result == MutableCSSPropertyValueSet::kUnchanged) {
    DidMutate(kNoChanges);
    return;
  }
  
  CSSPropertyID property_id = ResolveCSSPropertyID(unresolved_property);
  
  if (result == MutableCSSPropertyValueSet::kModifiedExisting &&
      CSSProperty::Get(property_id).SupportsIncrementalStyle()) {
    DidMutate(kIndependentPropertyChanged);
  } else {
    DidMutate(kPropertyChanged);
  }
  
  mutation_scope.EnqueueMutationRecord();
}

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

setAttribute

Теперь взглянем на setAttribute.

element.setAttribute("style", "background-color: red");

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

/third_party/blink/renderer/core/dom/attr.cc#73

void Attr::setValue(const AtomicString& value,
                    ExceptionState& exception_state) {
  // Element::setAttribute will remove the attribute if value is null.
  DCHECK(!value.IsNull());
  if (element_) {
    element_->SetAttributeWithValidation(GetQualifiedName(), value,
                                         exception_state);
  } else {
    standalone_value_or_attached_local_name_ = value;
  }
}

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

element.style = "background-color: red";

Что приведет к вызову метода SetAttributeWithValidation, о котором поговорим чуть ниже.

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

/third_party/blink/renderer/core/dom/element.h#282

void setAttribute(const QualifiedName& name, const AtomicString& value) {
  SetAttributeWithoutValidation(name, value);
}

Который обращается уже к другому методу SetAttributeWithoutValidation.

Итак, что же это за методы SetAttributeWithoutValidation и SetAttributeWithValidation?

/third_party/blink/renderer/core/dom/element.cc#10314

void Element::SetAttributeWithoutValidation(const QualifiedName& name,
                                            const AtomicString& value) {
  SynchronizeAttribute(name);
  SetAttributeInternal(FindAttributeIndex(name), name, value,
                       AttributeModificationReason::kDirectly);
}

void Element::SetAttributeWithValidation(const QualifiedName& name,
                                         const AtomicString& value,
                                         ExceptionState& exception_state) {
  SynchronizeAttribute(name);
  
  AtomicString trusted_value(TrustedTypesCheckFor(
      ExpectedTrustedTypeForAttribute(name), value, GetExecutionContext(),
      "Element", "setAttribute", exception_state));
  if (exception_state.HadException()) {
    return;
  }
  
  SetAttributeInternal(FindAttributeIndex(name), name, trusted_value,
                       AttributeModificationReason::kDirectly);
}

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

element.style = "invalid-prop: red"
// <div style></div>

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

Однако, нам ничто не помешает сделать вот так.

element.setAttribute("style", "invalid-prop: red")
// <div style="invalid-prop: red"></div>

В этом случае style будет установлен вне зависимости от того, что мы указали в качестве значения.

Выглядит довольно странно. Почему бы не валидировать значения атрибутов всегда? Ответ довольно простой. Метод setAttribute, это своего рода backdor для атрибутов. Он призван оперировать не только встроенными атрибутами, но и произвольными, такими как например, data-*.

element.setAttribute("data-ship-id", "324");
element.setAttribute("data-weapons", "laserI laserII");
element.setAttribute("data-shields", "72%");
element.setAttribute("data-x", "414354");
element.setAttribute("data-y", "85160");
element.setAttribute("data-z", "31940");
element.setAttribute("onclick", "spaceships[this.dataset.shipId].blasted()");

setAttribute работает на уровне DOM элемента и его задача просто присвоить значение атрибуту без каких либо валидаций. Подробный алгоритм описан в DOM стандарте.

Что быстрее?

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

Испытание 1. Одно свойство

Чтобы определиться, что же все таки быстрее, проведем эксперимент. Для этого нам понадобится HTML-страница с тестовым элементом и парой кнопок.

<div id="test-element"></div>
<button onclick="handleSetProperty()">style.setProperty</button>
<button onclick="handleSetAttribute()">setAttribute</button>

И не сложный JS скрипт.

const N = 100;

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function createElement() {
  const el = document.getElementById("test-element");
  
  const newEl = document.createElement("div");
  newEl.setAttribute("id", "test-element");
  newEl.setAttribute("width", 100);
  newEl.setAttribute("height", 100);
  
  el.replaceWith(newEl);
  
  return newEl;
}

async function handleSetProperty() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.style.setProperty("background-color", "red");
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

async function handleSetAttribute() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.setAttribute("style", "background-color: red");
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

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

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

avgSetProperty     0.07400000274181366
avgSetAttribute    0.08999999761581420

В данном эксперименте уверенно лидирует style.setProperty. Однако, мы пытались устанавливать только одно единственное CSS-свойство.

Испытание 2. Два свойства

Для объективности, повторим эксперимент сразу с двумя свойствами. Для этого немного изменим тестовые функции.

async function handleSetProperty() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.style.setProperty("background-color", "red");
    el.style.setProperty("border", "1px solid blue");
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

async function handleSetAttribute() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.setAttribute("style", "background-color: red; border: 1px solid blue;");
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

Запустив циклы еще раз, получаем следующий результат.

avgSetProperty     0.10900000214576722
avgSetAttribute    0.10399999618530273

Картина изменилась. Средняя скорость обоих вариантов приблизительно сравнялась. "setAttribute" оказался даже немного быстрее, но эту разницу можно списать на погрешность. И причина тому вовсе даже не валидация, здесь она пока еще не значительна. В данном случае в игру вступает еще один процесс - мутация объекта CSSStyleDeclaration. Этот интерфейс описывает структуру набор стилей, такого как например, тело тэга <style>. Такой же объект хранит и стиль отдельно взятого элемента. Я уже приводил листинг метода установки значения свойства в CSSStyleDeclaration. Давайте взглянем на него еще раз.

/third_party/blink/renderer/core/css/abstract_property_set_css_style_declaration.cc#236

void AbstractPropertySetCSSStyleDeclaration::SetPropertyInternal(
    CSSPropertyID unresolved_property,
    const String& custom_property_name,
    StringView value,
    bool important,
    SecureContextMode secure_context_mode,
    ExceptionState&) {
  StyleAttributeMutationScope mutation_scope(this);
  WillMutate();

  MutableCSSPropertyValueSet::SetResult result;
  if (unresolved_property == CSSPropertyID::kVariable) {
    AtomicString atomic_name(custom_property_name);

    bool is_animation_tainted = IsKeyframeStyle();
    result = PropertySet().ParseAndSetCustomProperty(
        atomic_name, value, important, secure_context_mode, ContextStyleSheet(),
        is_animation_tainted);
  } else {
    result = PropertySet().ParseAndSetProperty(unresolved_property, value,
                                               important, secure_context_mode,
                                               ContextStyleSheet());
  }

  if (result == MutableCSSPropertyValueSet::kParseError ||
      result == MutableCSSPropertyValueSet::kUnchanged) {
    DidMutate(kNoChanges);
    return;
  }

  CSSPropertyID property_id = ResolveCSSPropertyID(unresolved_property);

  if (result == MutableCSSPropertyValueSet::kModifiedExisting &&
      CSSProperty::Get(property_id).SupportsIncrementalStyle()) {
    DidMutate(kIndependentPropertyChanged);
  } else {
    DidMutate(kPropertyChanged);
  }

  mutation_scope.EnqueueMutationRecord();
}

Первым делом объект блокируется. Далее парсится и сохраняется значение свойства. После чего блокировка снимается.

А вот так выглядит установка стиля целиком, через текстовую строку.

/third_party/blink/renderer/core/css/abstract_property_set_css_style_declaration.cc#54

void AbstractPropertySetCSSStyleDeclaration::setCSSText(
    const ExecutionContext* execution_context,
    const String& text,
    ExceptionState&) {
  StyleAttributeMutationScope mutation_scope(this);
  WillMutate();

  const SecureContextMode mode = execution_context
                                     ? execution_context->GetSecureContextMode()
                                     : SecureContextMode::kInsecureContext;
  PropertySet().ParseDeclarationList(text, mode, ContextStyleSheet());

  DidMutate(kPropertyChanged);

  mutation_scope.EnqueueMutationRecord();
}

Тот же самый объект так же блокируется. Дальше текстовая строка парсится целиком и разбирается на свойства. Значения сохраняются и объект разблокируется.

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

Не могу не отметить один момент, касающийся оптимизации парсинга стилей.

/third_party/blink/renderer/core/css/parser/css_parser_impl.cc#255

 static ImmutableCSSPropertyValueSet* CreateCSSPropertyValueSet(
    HeapVector<CSSPropertyValue, 64>& parsed_properties,
    CSSParserMode mode,
    const Document* document) {
  if (mode != kHTMLQuirksMode &&
      (parsed_properties.size() < 2 ||
       (parsed_properties.size() == 2 &&
        parsed_properties[0].Id() != parsed_properties[1].Id()))) {
    // Fast path for the situations where we can trivially detect that there can
    // be no collision between properties, and don't need to reorder, make
    // bitsets, or similar.
    ImmutableCSSPropertyValueSet* result =
        ImmutableCSSPropertyValueSet::Create(parsed_properties, mode);
    parsed_properties.clear();
    return result;
  }
  
  ...
}

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

Испытание 3. Множество свойств

Проведем еще одно испытание. На этот раз возьмем свойств побольше. Скажем, семь. Почему именно семь? Это число выбрано неспроста. Но об этом чуть ниже.

async function handleSetProperty() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.style.setProperty("background-color", "red");
    el.style.setProperty("border", "1px solid blue");
    el.style.setProperty("position", "relative");
    el.style.setProperty("display", "flex");
    el.style.setProperty("align-items", "center");
    el.style.setProperty("text-align", "center");
    el.style.setProperty("text-transform", "uppercase");
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

async function handleSetAttribute() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.setAttribute(
      "style",
      "background-color: red; border: 1px solid blue; position: relative; display: flex; align-items: center; text-align: center; text-transform: uppercase;",
    );
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

Результаты этого испытания вполне логичны.

avgSetProperty     0.17899999499320984
avgSetAttribute    0.12500000596046448

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

Испытание 4. Множество числовых значений

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

К сожалению, таких свойств не много. Большинство привычных нам свойств, которые могут принимать числа, умеют работать, например, с разными единицами измерения, делать разные математические операции и т.д. Что заставляет в любом случае конвертировать их числовое значение во что-то иное. Поэтому разработчики ограничили набор возможных свойств с числовыми значениями. На данный момент таких свойств всего семь: opacity, fill-opacity, flood-opacity, stop-opacity, stroke-opacity, shape-image-threshold и -webkit-box-flex.

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

async function handleSetProperty() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.style.setProperty("opacity", 0.5);
    el.style.setProperty("fill-opacity", 0.5);
    el.style.setProperty("flood-opacity", 0.5);
    el.style.setProperty("stop-opacity", 0.5);
    el.style.setProperty("stroke-opacity", 0.5);
    el.style.setProperty("shape-image-threshold", 0.5);
    el.style.setProperty("-webkit-box-flex", 1);

    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

async function handleSetAttribute() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.setAttribute(
      "style",
      "opacity: 0.5; fill-opacity: 0.5; flood-opacity: 0.5; stop-opacity: 0.5; stroke-opacity: 0.5; shape-image-threshold: 0.5; -webkit-box-flex: 1;",
    );
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

И теперь результат.

avgSetProperty     0.17099999666213989
avgSetAttribute    0.09600000023841858

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

Вывод

Подытожим всё выше сказанное.

  1. style.setProperty показывает себя быстрее, в случае установки одного единственного свойства. Если устанавливается одновременно 2 и более свойств, быстрее будет setAttribute.
  2. setAttribute не валидирует значения. Это может быть как плюсом, так и минусом. Если нам важно, чтобы выставлялись только корректные значения, можно воспользоваться сеттером element.style = "<стиль>". Однако, если нам требуется установить на элементе произвольный атрибут или заведомо не корректное значение, без setAttribute не обойтись.
  3. Существует семь свойств, которым разрешено принимать числовые значения из JS-скрипта. Установка значений этих свойств будет немного быстрее за счет исключения операции конвертации строки в число.


Мои телеграмм-каналы:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

English version: https://blog.frontend-almanac.com/style-setproperty-vs-setattribute