Примитивные объекты в JavaScript: Когда их использовать (часть 2)
В первой части цикла Кирилл рассказал о том, как заставить обычные объекты JavaScript вести себя как примитивные значения. Теперь давайте внимательно посмотрим на полезность примитивных объектов и выясним, как уменьшение их возможностей может быть полезно для вашего проекта.
Написание программ на JavaScript на первых порах вполне доступно. Язык прощает ошибки, и вы привыкаете к его возможностям. Со временем и при работе над сложными проектами вы начинаете ценить такие вещи, как контроль и точность в процессе разработки.
Ещё одна вещь, которую вы можете начать ценить, — это предсказуемость, но в JavaScript она гораздо менее гарантирована. Если примитивные значения достаточно предсказуемы, то объекты — нет. Когда вы получаете на вход объект, вам необходимо проверить все:
- Является ли он объектом?
- Есть ли у него искомое свойство?
- Если свойство
undefined
, то это его значение или само свойство отсутствует?
Вполне понятно, если такой уровень неопределённости вызывает у вас лёгкую паранойю в том смысле, что вы начинаете сомневаться во всех своих решениях. Как следствие, ваш код становится защитным. Вы все чаще задумываетесь о том, справились ли вы со всеми ошибочными случаями или нет (скорее всего, нет). И в итоге ваша программа представляет собой набор проверок, а не приносит реальную пользу проекту.
Сделав объекты примитивными, многие потенциальные точки отказа переносятся в одно место — туда, где объекты инициализируются. Если вы можете убедиться, что ваши объекты инициализируются с определённым набором свойств и эти свойства имеют определённые значения, вам не нужно проверять наличие свойств в других местах программы. При необходимости можно гарантировать, что undefined
является значением.
Рассмотрим один из способов создания примитивных объектов. Это не единственный и даже не самый интересный способ. Скорее, его цель — показать, что работа с объектами, доступными только для чтения, необязательно должна быть громоздкой и сложной.
Примечание: Рекомендую также ознакомиться с [первой частью цикла]](/articles/javascript/discovering-primitive-objects-javascript-part1/), в которой я рассмотрел некоторые аспекты JavaScript, позволяющие приблизить объекты к примитивным значениям, что в свою очередь позволяет нам пользоваться общими возможностями языка, которые обычно не ассоциируются с объектами, такими как сравнение и арифметические операторы.
Массовое создание примитивных объектов
Самый простой, самый примитивный (каламбур) способ создания примитивного объекта заключается в следующем:
const my_object = Object.freeze({});
Эта единственная строка приводит к созданию объекта, который может представлять что угодно. Например, можно реализовать интерфейс с вкладками, используя пустой объект для каждой вкладки.
import React, { useState } from "react";
const summary_tab = Object.freeze({});
const details_tab = Object.freeze({});
function TabbedContainer({ summary_children, details_children }) {
const [ active, setActive ] = useState(summary_tab);
return (
<div className="tabbed-container">
<div className="tabs">
<label
className={active === summary_tab ? "active" : ""}
onClick={() => {
setActive(summary_tab);
}}
>
Summary
</label>
<label
className={active === details_tab ? "active": ""}
onClick={() => {
setActive(details_tab);
}}
>
Details
</label>
</div>
<div className="tabbed-content">
{active === summary_tab && summary_children}
{active === details_tab && details_children}
</div>
</div>
);
}
export default TabbedContainer;
Если вы похожи на меня, то этот элемент tabs
просто кричит, что его нужно переделать. Если присмотреться, то можно заметить, что элементы вкладок похожи друг на друга и нуждаются в двух вещах, таких как ссылка на объект и строка label
. Давайте включим свойство label
в объекты tabs
, а сами объекты перенесём в массив. И поскольку мы не планируем каким-либо образом изменять tabs
, сделаем этот массив доступным только для чтения.
const tab_kinds = Object.freeze([
Object.freeze({ label: "Summary" }),
Object.freeze({ label: "Details" })
]);
Это делает то, что нам нужно, но является многословным. Подход, который мы сейчас рассмотрим, часто используется для скрытия повторяющихся операций, чтобы свести код только к данным. Таким образом, становится более очевидным, когда данные некорректны. Кроме того, мы хотим, чтобы объекты (включая массив) freeze
по умолчанию, а не для того, чтобы не забыть их ввести. По той же причине тот факт, что нам приходится каждый раз указывать имя свойства, оставляет возможность для ошибок, например, опечаток.
Чтобы легко и последовательно инициализировать массивы примитивных объектов, я использую функцию populate
. На самом деле у меня нет одной функции, которая бы выполняла эту работу. Обычно каждый раз я создаю одну, исходя из того, что мне нужно в данный момент. В случае данной статьи это одна из самых простых функций. Вот как мы это сделаем:
function populate(...names) {
return function(...elements) {
return Object.freeze(
elements.map(function (values) {
return Object.freeze(names.reduce(
function (result, name, index) {
result[name] = values[index];
return result;
},
Object.create(null)
));
})
);
};
}
Если этот вариант кажется тяжёлым, то вот более удобный для чтения:
function populate(...names) {
return function(...elements) {
const objects = [];
elements.forEach(function (values) {
const object = Object.create(null);
names.forEach(function (name, index) {
object[name] = values[index];
});
objects.push(Object.freeze(object));
});
return Object.freeze(objects);
};
}
Имея под рукой подобную функцию, мы можем создать тот же массив объектов с вкладками следующим образом:
const tab_kinds = populate(
"label"
)(
[ "Summary" ],
[ "Details" ]
);
Каждый массив во втором вызове представляет собой значения результирующих объектов. Теперь предположим, мы хотим добавить дополнительные свойства. Для этого нужно добавить новое имя в первый вызов и значение в каждый массив во втором вызове.
const tab_kinds = populate(
"label",
"color",
"icon"
)(
[ "Summary", colors.midnight_pink, "💡" ],
[ "Details", colors.navi_white, "🔬" ]
);
Если использовать немного пробелов, то можно сделать его похожим на таблицу. Таким образом, гораздо легче заметить ошибку в огромных определениях.
Вы, наверное, заметили, что populate
возвращает другую функцию. Есть несколько причин для того, чтобы сохранить её в двух вызовах функций. Во-первых, мне нравится, что два последовательных вызова создают пустую строку, разделяющую ключи и значения. Во-вторых, мне нравится иметь возможность создавать подобные генераторы для похожих объектов. Например, нам нужно создать эти объекты метки для разных компонентов и хранить их в разных массивах.
Вернёмся к примеру и посмотрим, что мы получили с помощью функции populate
:
import React, { useState } from "react";
import populate_label from "./populate_label";
const tabs = populate_label(
[ "Summary" ],
[ "Details" ]
);
const [ summary_tab, details_tab ] = tabs;
function TabbedContainer({ summary_children, details_children }) {
const [ active, setActive ] = useState(summary_tab);
return (
<div className="tabbed-container">
<div className="tabs">
{tabs.map((tab) => (
<label
key={tab.label}
className={tab === active ? "active" : ""}
onClick={() => {
setActive(tab);
}}
>
{tab.label}
</label>
)}
</div>
<div className="tabbed-content">
{summary_tab === active && summary_children}
{details_tab === active && details_children}
</div>
</div>
);
}
export default TabbedContainer;
Использование функций типа populate
менее громоздко для создания таких объектов и просмотра того, как выглядят данные.
Проверьте эту радиокнопку
Одна из альтернатив вышеописанному подходу, с которой я сталкивался, заключается в том, чтобы сохранять состояние active
— выбрана вкладка или нет — в качестве свойства объекта tabs
:
const tabs = [
{
label: "Summary",
selected: true
},
{
label: "Details",
selected: false
},
];
Таким образом, мы заменяем tab === active
на tab.selected
. Это может показаться улучшением, но посмотрите, как нам пришлось бы менять выбранную вкладку:
function select_tab(tab, tabs) {
tabs.forEach((tab) => tab.selected = false);
tab.selected = true;
}
Поскольку это логика для радиокнопки, одновременно может быть выбран только один элемент. Поэтому, прежде чем установить элемент выбранным, необходимо убедиться, что все остальные элементы не выбраны. Да, глупо так поступать с массивом, состоящим всего из двух элементов, но в реальном мире существует множество более длинных списков, чем в данном примере.
При использовании примитивного объекта нам необходима единственная переменная, представляющая состояние выбора. Я предлагаю установить переменную на одном из элементов, чтобы сделать его выбранным в данный момент, или установить её в значение undefined
, если ваша реализация не допускает выбора.
Для элементов с несколькими вариантами выбора, таких как чекбоксы, подход практически такой же. Мы заменяем переменную выбора на массив. Каждый раз, когда элемент выбран, мы помещаем его в этот массив, или, в случае Redux, создаём новый массив с этим элементом. Чтобы снять выборку, мы либо разделяем её, либо отфильтровываем элемент.
let selected = []; // Ничего не выбрано.
// Выбрать.
selected = selected.concat([ to_be_selected ]);
// Снять выделение.
selected = selected.filter((element) => element !== to_be_unselected);
// Проверяем, выбран ли элемент.
selected.includes(element);
И снова все просто и лаконично. Вам не нужно запоминать, называется ли свойство selected
или active
; для определения этого используется сам объект. Когда ваша программа усложнится, эти строки будут наименее подвержены рефакторингу.
В конце концов, не элемент списка должен решать, выбран он или нет. Он не должен хранить эту информацию в своём состоянии. Например, что делать, если он одновременно выбран и не выбран в нескольких списках?
Альтернатива строкам
И последнее, на чем я хотел бы остановиться, — это пример использования строк, с которым я часто сталкиваюсь.
Текст — это хороший компромисс для обеспечения совместимости. Вы определяете что-то как строку и мгновенно получаете представление контекста. Это похоже на мгновенный прилив энергии от употребления сахара. Как и в случае с сахаром, в лучшем случае вы ничего не получите в долгосрочной перспективе. Однако это не приносит удовлетворения, и вы неизбежно проголодаетесь снова.
Проблема со строками заключается в том, что они предназначены для людей. Для нас естественно различать вещи, давая им имя. Но программа не понимает смысла этих имён.
Ваша программа знает только, равны ли две строки или нет. И даже в этом случае информация о том, равны или неравны строки, необязательно даёт представление о том, содержит ли какая-либо из этих строк опечатку.
Объекты предоставляют больше возможностей увидеть, что что-то не так, ещё до запуска программы. Поскольку для примитивных объектов нельзя использовать литералы, необходимо откуда-то получить ссылку. Например, если это переменная и вы допустите опечатку, то получите ошибку ссылки. Существуют средства, позволяющие выявить такую ошибку до сохранения файла.
Если вы будете получать объекты из массива или другого объекта, то JavaScript не выдаст ошибку, если свойство или индекс не существует. Вы получите undefined, и это то, что можно проверить. У вас есть единственная вещь, которую нужно проверить. В случае со строками возможны сюрпризы, которых хотелось бы избежать, например, когда они пусты.
Ещё одно использование строк, которого я стараюсь избегать, — это проверка того, получили ли мы нужный нам объект. Обычно это делается путём хранения строки в свойстве с именем id
. Допустим, у нас есть переменная. Для того чтобы проверить, хранится ли в ней нужный нам объект, нам может понадобиться проверить, совпадает ли строка в свойстве id
с той, которую мы ожидаем получить. Для этого сначала нужно проверить, содержит ли переменная объект. Если переменная содержит объект, но у него отсутствует свойство id
, то мы получим undefined
, и все будет в порядке. Однако если у нас есть одно из нижних значений в этой переменной, то мы не можем напрямую запросить это свойство. Вместо этого нам нужно сделать что-то, чтобы убедиться в том, что в эту точку попадают только объекты, либо выполнить обе проверки на месте.
const myID = "О, это так уникально";
function magnification(value) {
if (value && typeof value === "object" && value.id === myID) {
// делаем магию
}
}
Вот как мы можем сделать то же самое с примитивными объектами:
import data from "./файл, в котором хранятся данные";
function magnification(value) {
if (value === data.myObject) {
// делаем магию
}
}
Преимущество строк в том, что они представляют собой единое целое, которое можно использовать для внутренней идентификации, и сразу же распознаются в логах. Конечно, они просты в использовании, но по мере увеличения сложности проекта они не станут вашим другом.
Я считаю, что не стоит полагаться на строки для чего-либо, кроме вывода пользователю. Недостаточная совместимость строк в примитивных объектах могла бы быть решена постепенно и без необходимости менять способ обработки основных операций, таких как сравнение.
Подведение итогов
Работа непосредственно с объектами освобождает нас от подводных камней
, которые возникают при использовании других методов. Наш код становится проще, поскольку мы пишем то, что должна делать ваша программа. При организации кода с помощью примитивных объектов мы меньше подвержены влиянию динамической природы JavaScript и некоторого его багажа. Примитивные объекты дают нам больше гарантий и большую степень предсказуемости.