Какой подход лучше?

И свойство innerHTML, и метод appendChild() можно использовать для добавления нового содержимого на страницу.

Различия между innerHTML и appendChild()

Многословие

innerHTML предполагается присвоение HTML-строки, тогда как appendChild() принимает только Node объектов.

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

el.innerHTML='<div title="Some title">Some text</div>';

Напротив, appendChild() не принимает HTML и требует дополнительного кода для создания объектов Node.

const cell = document.createElement("div");
cell.title = "Some title";
cell.appendChild(document.createTextNode("Some text"));
el.appendChild(cell);

Однако, когда на страницу необходимо вставить элементы со многими атрибутами, такими как inputs, код становится более разборчивым и кратким, если используется appendChild(). Например, простую вспомогательную функцию input(props) можно использовать для простого создания входных данных любого типа:

function input({ list, ...props }) {
    const el = document.createElement("input");
    list && el.setAttribute('list', list);
    Object.assign(el, props);
    return el;
}

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

Для этого мне нужно составить простой код:

root.appendChild(input({
    value: 'MyValue',
    placeholder: 'MyPlaceholder',
    name: 'MyName',
    title: 'MyTitle',
    className: 'MyClass',
    list: 'browsers',
    type: 'search',
    pattern: '[a-z]+',
    required: true,
    autocomplete: 'off',
}));
root.appendChild(input({
    name: 'MyName',
    title: 'MyTitle',
    className: 'MyClass',
    type: 'checkbox',
    checked: true
}));

Вы можете увидеть эти входные данные на странице-образце https://innerhtmlvsappendchild.onrender.com/samples/input.html

Безопасность

Не универсально полезен, но реальное преимущество appendChild() перед innerHTML состоит в том, что appendChild() предотвращает непреднамеренное выполнение кода. Когда appendChild() используется для отображения текста, весь текст должен быть преобразован в текстовые узлы. Текст, отправленный пользователями, может также содержать некоторый код JavaScript. Любой код, включенный в текст, обязательно будет отображаться как безобидный текст при вставке на страницу в виде текстового узла. Напротив, когда innerHTML используется для отображения пользовательского ввода, код JavaScript, вставленный в назначенный текст, может быть выполнен.

Распространенные мифы о appendChild()

В Интернете можно встретить мнение, что appendChild() быстрее, чем innerHTML. Связанное с этим мнение состоит в том, что элементы HTML обрабатываются быстрее, если все они сначала добавляются к DocumentFragment, который затем вставляется в DOM одним appendChild() вызовом.

В этом посте я на всякий случай проверяю эти два популярных ложных убеждения. В частности, я проверяю, работает ли код на основе appendChild() быстрее, чем код на основе innerHTML. И имеет ли смысл использование DocumentFragment с appendChild() с точки зрения производительности.

Сравнительный анализ innerHTML и appendChild()

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

Тестовая таблица представляет собой div таблицу с 2000 строками, каждая из которых содержит 20 ячеек. Я использую div таблицу, потому что по сравнению со стандартными таблицами HTML, состоящими из элементов table, tr, td, div таблицам требуется› на 30% меньше времени для визуализации и, следовательно, они больше подходят для визуализации тысяч строк данных. Всего в тестовой таблице содержится 2000 * 20 + 2000 = 42000 divс. В ячейках указаны их координаты. Чтобы немного усложнить таблицу, координаты ячейки также назначаются атрибуту title ячейки divs.

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

  • присвоение всего HTML таблицы innerHTML
  • добавление строки divs одну за другой с appendChild()
  • завернуть всю строку div в DocumentFragment, а затем вставить ее с помощью appendChild()

Вы можете убедиться, что таблицы идентичны, щелкнув три варианта на образце веб-страницы https://innerhtmlvsappendchild.onrender.com/. Щелкнув по нему, вы также сможете оценить, сколько времени требуется браузеру для отображения тестовой таблицы.

Содержание таблицы

Идентичные таблицы, созданные с помощью трех разных подходов, отображают одни и те же данные, созданные модулем data.js:

// data.js
const COL_COUNT = 20, ROW_COUNT = 2000;
 
function generateData() {
    const rows = [];
    for (let r = 0; r < ROW_COUNT; r++) {
        const row = [];
        rows.push(row);
        for (let c = 0; c < COL_COUNT; c++)
            row.push(`${c},${r}`);
    }
    return rows;
}
export default generateData();

По сути, исходные данные представляют собой двумерный массив размером 2000 * 20, в котором каждый элемент содержит свои индексы.

Контейнер div

Все тестовые таблицы вставляются в один div с id корнем:

<div id="root"></div>

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

Вставка таблицы с innerHTML

Строка HTML, созданная из исходных данных, назначается root.innerHTML в render(data) функции модуля html.js:

// html.js
function row(vals) {
    return '<div class="row">'
        + vals.map(val => `<div title="${val}">${val}</div>`).join('')
        + '</div>';
}
function table(data) {
    return data.map(vals => row(vals)).join('');
}
export default function render(data) {
    root.innerHTML = table(data);
}

Постепенно вставляя строки таблицы с appendChild()

Код на основе appendChild() более подробный, чем приведенный выше код на основе innerHTML. Модуль nodes.js использует root.appendChild() в цикле для добавления одной строки за раз.

// nodes.js
export function  row(vals) {
    const rowDiv = document.createElement("div");
    rowDiv.className = 'row';
    vals.forEach(val => {
        const cell = document.createElement("div");
        cell.title = val;
        cell.appendChild(document.createTextNode(val));
        rowDiv.appendChild(cell);
    });
    return rowDiv;
}
function table(data) {
    return data.map(vals => row(vals));
}
 
export default function render(data) {
    table(data).forEach(div => root.appendChild(div))
}

Вставка таблицы, завернутой в DocumentFragment с appendChild()

Модуль documentFragment.js повторно использует неуклюжую функцию row() из предыдущего модуля nodes.js. В отличие от предыдущего подхода, все строки div добавляются не к root, а к DocumentFragment, который позже добавляется к root. По сути, код содержит дополнительный бессмысленный шаг.

// documentFragment.js
import { row } from './nodes.js';
function rowsAsDocumentFragment(data) {
    const fragment = document.createDocumentFragment();
    data.forEach(vals => fragment.appendChild(row(vals)));
    return fragment;
}
 
export default function render(data) {
    root.appendChild(rowsAsDocumentFragment(data)); 
}

Сбор показателей производительности

При каждом из трех подходов отрисовка тестовой таблицы повторяется 10 раз:

// benchmarking.js
import documentFragment from './approaches/documentFragment.js';
import nodes from './approaches/nodes.js';
import html from './approaches/html.js';
import showResults from './results.js';
import clear from './clear.js';
import {  execute } from './shared.js';
export default function run() {
    const times =10;
    let p = Promise.resolve().then(clear); 
 
    for (let i = 0; i < times; i++) {
        p = p.then(() => execute('appendChild(DocumentFragment with all rows)', documentFragment)).then(clear);
    }
    for (let i = 0; i < times; i++) {
        p = p.then(() => execute('appendChild() for each row', nodes)).then(clear);
    }
    for (let i = 0; i < times; i++) {
        p = p.then(() => execute('innerHTML', html)).then(clear);
    }
    p.then(showResults);
}

Все измерения связаны с Promises. Последний метод в цепочке showResults() отображает собранные метрики.

Каждый раз перед вставкой таблицы root div очищается с помощью оператора root.innerHTML = '';, встроенного в функцию clear():

// clear.js
export default function clear() {
    return new Promise(resolve => {
        requestAnimationFrame(() => {
            root.innerHTML = '';
            setTimeout(resolve);
        });
    });
}

Функция возвращает Promise, поэтому ее можно связать со следующими функциями.

Метрики собираются функцией execute(label, render):

export function execute(label, render) {
    return new Promise(resolve => {
        try {
            gc(); // will not work without flags
        } catch (error) {
            console.error(error);
        }
    requestAnimationFrame(() => {
            const domLabel = label + "_INSERT";
            console.time(label);
            console.time(domLabel);
            const start = Date.now();
            render(data);
            const domDone = Date.now();
            console.timeEnd(domLabel);
            setTimeout(() => {
                console.timeEnd(label);
                addResult(label, start, domDone, Date.now());
                resolve();
            });
        });
    });
}

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

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

--js-flags="--expose-gc"

Таблица отображается в обратном вызове requestAnimationFrame(). requestAnimationFrame() гарантирует, что после того, как браузер выполнит свой обратный вызов, больше не будет выполняться код, пока браузер не закрасит изменения в DOM. Когда браузер возобновляет выполнение JavaScript, он берет первую задачу в очереди задач. Первым будет задача, запланированная с setTimeout(). Задача фиксирует три временных момента:

  • start - время до смены DOM
  • domDone - время после изменения DOM
  • Date.now() - время, когда браузер закончил рисование и возобновил выполнение кода JavaScript

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

  • domDone-start — время, необходимое для изменения модели DOM
  • Date.now()-domDone - время, необходимое для отрисовки изменений в DOM

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

Схема execute(label, render) на временной шкале Chrome DevTools

Чтобы проиллюстрировать принцип действия, execute(label, render) также вызывает console.time(label) и console.timeEnd(label). Промежутки времени между связанными вызовами отмечены на шкале времени производительности в Chrome DevTools. Я отмечаю два периода, которые я измеряю - время, необходимое для изменения модели DOM, и время, необходимое для отображения таблицы.

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

Обратите внимание на полосы в разделе консоли на временной шкале производительности. Каждая полоса отмечает промежуток времени между вызовами console.time() и console.timeEnd(). Полосы демонстрируют, что измерения в моем коде полностью перекрываются с событиями, показанными на временной шкале.

Сроки позволяют сразу сделать два вывода. Первое очевидное наблюдение заключается в том, что модификация модели DOM (представленная желтоватым прямоугольником в начале задачи) занимает не более 10% от общего времени, затрачиваемого на визуализацию таблицы. Неважно, какой способ быстрее, поскольку большую часть времени браузер тратит на рендеринг изменений DOM - пересчет стилей, макета и рисования.

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

Полученные результаты

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

Очевидно, что одни и те же изменения в DOM нужно рисовать в одно и то же время.

Заключение

Использование циклов с appendChild() кажется самым быстрым способом вставки нового HTML-содержимого. Но его преимущество перед innerHTML совершенно несущественно, если учесть время рендеринга - сравните 92–83 = 9 мс с 1500 мс. Так что можно сказать, что оба метода одинаково эффективны. При выборе метода следует учитывать другие их характеристики - appendChild() требует дополнительного кода, тогда как innerHTML может представлять угрозу безопасности, если он используется для отображения пользовательского ввода.

В любом случае, если вы ориентируетесь на современные браузеры и не хотите тратить время на ввод ненужного кода, вам следует забыть о appendChild(). Хотя innerHTML остается незаменимым, есть более удобные альтернативы многословному устаревшему методу appendChild().

Мой код можно скачать с https://github.com/marianc000/innerHTMLvsAppendChild

Вы также можете поиграть с образцом веб-страницы https://innerhtmlvsappendchild.onrender.com/