Всем привет! В данной статье я бы хотел рассказать о таком понятии как "ключи" в JavaScript фреймворках и библиотеках; зачем они используются и как помогают при работе с DOM.
Зачастую, на собеседованиях спрашивают про эту тему и нередко выходят ответы по типу: "чтобы предотвратить неконтролируемое поведение" или "их нужно указывать, т.к. это что-то на подобие уникальных идентификаторов" и др. Конечно, данные ответы с одной стороны - правильные, но главного они не отражают.
Я постараюсь внести небольшую ясность в эту тему показав то, как эта концепция работает на реальных примерах и какой код за ней стоит.
Год назад я уже делал статью на эту тему, но она была достаточно поверхностна, хотя отражала более ли менее суть. В этой же статье, я постараюсь раскрыть концепцию наиболее полным образом.
Все источники, описанные тут, я оставлю в конце статьи.
Базовые понятия
Перед рассмотрением темы "ключей" стоит определиться с некоторыми понятиями, такими как "цикл" и "статические || динамические данные". Они будут использоваться в дальнейшем при описании.
Цикл - это некий синтаксический приём, который используется в разных фреймворках и библиотеках для отображения зависимости DOM элементов от данных. Во Vue.js, допустим, для такой зависимости используется атрибут v-for
:
<template>
<tr
v-for="{ id, label } of rows"
:key="id"
:class="{ danger: id === selected }"
:data-label="label"
v-memo="[label, id === selected]"
>
<td class="col-md-1">{{ id }}</td>
<td class="col-md-4">
<a @click="select(id)">{{ label }}</a>
</td>
<td class="col-md-1">
<a @click="remove(id)">
<span class="glyphicon glyphicon-remove" aria-hidden="true"></span>
</a>
</td>
<td class="col-md-6"></td>
</tr>
</template>
Также, могут использоваться функции, как например это сделано в Cample
:
const eachComponent = each(
"table-rows",
({ importedData }) => importedData.rows,
`<tr key="{{row.id}}" class="{{stack.class}}">
<td class='col-md-1'>{{row.id}}</td>
<td class='col-md-4'><a ::click="{{setSelected()}}" class='lbl'>{{row.label}}</a></td>
<td class='col-md-1'><a ::click="{{importedData.delete(row.id)}}" class='remove'><span class='remove glyphicon glyphicon-remove' aria-hidden='true'></span></a></td>
<td class='col-md-6'></td>
</tr>`,
{
valueName: "row",
functionName: "updateTable",
stackName: "stack",
import: {
value: ["rows", "delete"],
exportId: "mainExport",
},
functions: {
setSelected: [
(setData, event, eachStack) => () => {
const { setStack, clearStack } = eachStack;
clearStack();
setStack(() => {
return { class: "danger" };
});
},
"updateTable",
],
},
}
);
Также может использоваться многое другое. Те же методы, применяемые к массиву, какие-то ключевые слова, которые определены только в синтаксических дополнениях к HTML и др.
Статические данные - это некая информация, которая не будет изменяться при работе с приложением. Такой, допустим, могут являться константы в файлах типа config.ts
, где будут располагаться строки классов, физические величины, массивы с картинками и пр. По текущим данным "цикл" условно "пройдёт" только один раз.
Динамические данные - это некая информация, которая как раз будет изменяться при работе с приложением. Это может быть как счётчик в таймере, где через каждую секунду будет обновляться значение, так и массив объектов, который приходит с api. Здесь "цикл" "пройдёт" сначала один раз, а затем "пройдёт" условно два раза, чтобы сравнить два состояния данных между собой (старое значение и новое).
Все эти понятия в дальнейшем будут использоваться при разборе алгоритма.
Понятие "ключ"
Ключом является уникальное значение, которое необходимо для привязки DOM узла к определённым данным. Привязка представляет собой процесс сохранения ссылочного узла, зависимого от исходного значения. Значением, обычно, является уникальный идентификатор, который идёт из базы данных. Но, также это может быть и любое другое значение, которое в приложении может использоваться только один раз. То-есть, рандомные значения могут не подойти, потому что шанс сгенерировать похожую строку или число ненулевой.
Зачастую, синтаксически ключ указывается в виде атрибута, в который мы передаём уникальное значение. Допустим, вот так:
<li key="{{id}}"></li>
Либо же, похожим образом в формате ключ:значение, как в объекте.
Отличие работы алгоритма с ключом и без него
Главной целью работы с "циклом" является показ DOM узлов, которые зависят от получаемых на вход данных. Допустим, перед нами стоит задача показать информацию о чём либо в таблице, где каждый объект - это строка. При чём, данная информация приходит с сервера и на клиенте мы должны реагировать на состояние запроса и соответствующим образом показывать UI компоненты. На вход, в таком случае, изначально приходит пустой массив, который будет заполнен или заменён на новый после того, как данные успешно придут с сервера, но пока, строк в таблице быть не должно.
Данную задачу проще всего показать в коде, где мы попробуем реализовать простенький case с данными на чистом js.
const data = [
{
id: 1,
label: "Текст 1",
},
{ id: 2, label: "Текст 2" },
{
id: 3,
label: "Текст 3",
},
];
const newData = [
{
id: 1,
label: "Текст 1",
},
{
id: 3,
label: "Текст 3",
},
];
const tbody = document.getElementById("tbody");
const btn = document.getElementById("btn");
const nodes = [];
const render = (oldData, data) => {
const oldDataLength = oldData.length;
const newDataLength = data.length;
if (oldDataLength && !newDataLength) {
tbody.textContent = "";
return;
}
if (!oldDataLength && newDataLength) {
for (let i = 0; i < newDataLength; i++) {
const item = data[i];
const { id, label } = item;
const tr = document.createElement("tr");
const td1 = document.createElement("td");
const td2 = document.createElement("td");
const td3 = document.createElement("td");
const input = document.createElement("input");
td1.textContent = id;
td2.textContent = label;
td3.append(input);
tr.append(td1);
tr.append(td2);
tr.append(td3);
// nodes.push({
// key: id,
// node: tr,
// });
nodes.push(tr);
tbody.append(tr);
}
}
if (oldDataLength && newDataLength) {
if (oldDataLength > newDataLength) {
const diffrence = oldDataLength - newDataLength;
for (let i = 0; i < diffrence; i++) {
const node = nodes[nodes.length - 1];
node.remove();
nodes.pop();
}
for (let i = 0; i < newDataLength; i++) {
const tds = nodes[i].querySelectorAll("td");
const itemData = data[i];
const { id, label } = itemData;
tds[0].textContent = id;
tds[1].textContent = label;
}
// for(let i = 0; i < oldDataLength; i++){
// const nodeObj = nodes[i];
// const isIncludes = data.some((e)=>e.id === nodeObj.key);
// if(isIncludes){
// ...
// }else{
// ...
// }
// }
}
}
};
const clickHandler = () => {
// Допустим, по клику отправляется запрос на сервер
// Можно сделать через Promise, но смысла в этом особо нету
render(data, newData); // старый массив с данными, новый массив с данными
};
render([], data); // изначальный массив, новый массив с данными
btn.addEventListener("click", clickHandler);
В данном случае, изначально с сервера бы приходило 3 объекта, которые имели свойство id
. Для них создаётся 3 строки и показываются на сайте:
Допустим, введём от 1 до 3 соответственно в каждый input:
В таком случае, в состоянии DOM у нас для input
хранится value
, который равен уникальному идентификатору соответственно. Если в реализации не используется концепция "ключей", то при удалении объекта с id
равным 2 получится следующий результат:
В данном случае, алгоритм будет сравнивать только по длине массивов. Если у нас было 3 элемента, а стало 2, то нужно удалить с конца 1 элемент. Если бы было так, что у нас было бы 3 элемента, а с сервера бы пришла 1000 элементов, то нужно было бы добавить ещё 997 элементов. Если длина старого массива равна длине нового, то тогда мы бы переиспользовали бы всё те же DOM узлы.
А, если, допустим, вместо привязки не только к DOM узлам, но и к данным мы бы протестировали код (при условии доработки алгоритма):
nodes.push({
key: id,
node: tr,
});
//nodes.push(tr);
то результатом было бы следующее:
В данном результате и заключается основное отличие ключевой реализации от неключевой. В одном случае, нам не важно, какой DOM узел используется для показа данных, а в другом, мы сохраняем тот узел, который соответствует определённому ключу. Таким образом, мы сохраняем не только состояние приложения, которое у нас, допустим, хранится в каком-нибудь state менеджере, но мы также сохраняем обработчики событий, значения в input
'ах, в общем, сохраняем state DOM узла. Это может быть очень полезно, если бы у нас была бы, допустим, заполняемая шкала для каждого пункта списка, и нам бы не пришлось заново её заполнять. Это что касается также Shadow DOM.
Тонкости алгоритма
У алгоритма так называемого "цикла" есть много ситуаций, которые нужно учесть. Все они происходят из особенностей работы с DOM узлами и массивами. В массиве, при сравнении элементов, мы можем проверить на то, добавлен ли элемент, удалён, либо же заменён на другой. Могут возникать ситуации, когда с сервера приходят данные, которые почти полностью не равны тем, которые были. Допустим, если взять реализацию уже с готовым алгоритмом, то на первой итерации сделаем 10 последовательных элементов, а на второй укажем, полностью хаотичный массив:
const oldData = [
{
id: 1,
label: "Текст 1",
},
{
id: 2,
label: "Текст 2",
},
{
id: 3,
label: "Текст 3",
},
{
id: 4,
label: "Текст 4",
},
{
id: 5,
label: "Текст 5",
},
{
id: 6,
label: "Текст 6",
},
{
id: 7,
label: "Текст 7",
},
{
id: 8,
label: "Текст 8",
},
{
id: 9,
label: "Текст 9",
},
{
id: 10,
label: "Текст 10",
},
];
const newData = [
{
id: 1,
label: "Текст 1",
},
{
id: 6,
label: "Текст 2",
},
{
id: 18,
label: "Текст 3",
},
{
id: 3,
label: "Текст 4",
},
{
id: 7,
label: "Текст 5",
},
{
id: 9,
label: "Текст 6",
},
{
id: 8,
label: "Текст 7",
},
{
id: 4,
label: "Текст 8",
},
{
id: 2,
label: "Текст 9",
},
{
id: 13,
label: "Текст 10",
},
];
В данном случае, алгоритм должен учитывать все тонкости, которые идут с данной реализацией. Результатом работы будет примерно следующее:
После установки новых данных:
Таким образом, если у вас будет желание создать подобный алгоритм, то стоит учитывать данные моменты.
Вывод
Ключевая реализация - это стандарт разработки современных пользовательских интерфейсов. Почти все JavaScript (и не только) фреймворки или библиотеки используют данную концепцию, ведь она позволяет сохранить состояние DOM узла, а это значит, что не придётся заново вводить те данные, которые были введены на клиенте. Многие события на сайте трудоёмко сделать контролируемыми (скролл, отправка формы, клик по элементу), поэтому "ключи", в данном случае, упрощают процесс создания и использования сайта.
Всем спасибо за прочтение данной статьи! На самом деле, вроде бы простая тема, но "под капотом" хранит в себе массу интересных тонкостей, которые стоит знать. Готовя материал, можно было ещё рассказать про алгоритм, но т.к. это уже математика, то, в целом, основной упор лучше было сделать на суть.
Источники
Код Cample - https://github.com/krausest/js-framework-benchmark/blob/master/frameworks/keyed/cample/src/main.js
Код Vue - https://github.com/krausest/js-framework-benchmark/blob/master/frameworks/keyed/vue/src/App.vue#L137
Старая статья - https://habr.com/ru/articles/751316
Видео - https://youtu.be/_R5JjRP9c5k