Объекты JavaScript, когда их использовать (часть 2)

Объекты JavaScript, когда их использовать (часть 2)

Содержание
  1. Создание примитивных объектов в массовом порядке
  2. Использование примитивных объектов упрощает написание логики пользовательского интерфейса.
  3. Альтернатива струнам
  4. Подведение итогов

В первой части цикла Кирилл рассказал о том, как заставить обычные объекты JavaScript вести себя как примитивные значения. Теперь давайте внимательно рассмотрим полезность примитивных объектов и выясним, как уменьшение их возможностей может быть полезно для вашего проекта.

Писать программы на JavaScript можно с самого начала. Язык снисходителен, и вы привыкаете к его возможностям. Со временем и опытом работы над сложными проектами вы начинаете ценить такие вещи, как контроль и точность в процессе разработки.

Еще одна вещь, которую вы, возможно, начнете ценить, - это предсказуемость, но в JavaScript она гораздо менее гарантирована. Если примитивные значения достаточно предсказуемы, то объекты - нет. Когда вы получаете объект на вход, вам нужно проверить все:

Объект ли это? Есть ли у него искомое свойство? Когда свойство содержит undefined, это его значение или само свойство отсутствует?

Вполне понятно, если такой уровень неопределенности оставляет вас слегка параноиком, поскольку вы начинаете сомневаться во всех своих решениях. Как следствие, ваш код становится защитным. Вы больше думаете о том, справились ли вы со всеми ошибочными случаями или нет (скорее всего, нет). И в итоге ваша программа представляет собой набор проверок, а не приносит реальную пользу проекту.

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

Давайте рассмотрим один из способов создания примитивных объектов. Это не единственный и даже не самый интересный способ. Скорее, его цель - показать, что работа с объектами, доступными только для чтения, не обязательно должна быть громоздкой или сложной.

Примечание: я также рекомендую вам ознакомиться с первой частью цикла, где я рассказал о некоторых аспектах 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 в объекты tabs и перенесем сами объекты в массив. И поскольку мы не планируем каким-либо образом изменять tabs, давайте также сделаем этот массив доступным только для чтения.

const tab_kinds = Object.freeze([
	Object.freeze({ label: 'Summary' }),
	Object.freeze({ label: 'Details' }),
]);

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

Чтобы легко и последовательно инициализировать массивы примитивных объектов, я использую функцию 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, менее громоздко для создания этих объектов и просмотра того, как выглядят данные.

Одна из альтернатив вышеописанному подходу, с которой я сталкивался, заключается в том, чтобы сохранять состояние активности - выбрана вкладка или нет - в качестве свойства объекта 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 = [];

// Select.
selected = selected.concat([to_be_selected]);

// Unselect.
selected = selected.filter((element) => element !== to_be_unselected);

// Check if an element is selected.
selected.includes(element);

И снова все просто и лаконично. Вам не нужно помнить, называется ли свойство selected или active; вы используете сам объект для определения этого. Когда ваша программа станет сложнее, эти строки будут наименее подвержены рефакторингу.

В конце концов, элемент списка не должен решать, выбран он или нет. Он не должен хранить эту информацию в своем состоянии. Например, что делать, если он одновременно выбран и не выбран в нескольких списках?

Альтернатива струнам

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

Текст - это хороший компромисс для совместимости. Вы определяете что-то как строку и мгновенно получаете представление контекста. Это как получить мгновенный прилив энергии, съев сахар. Как и в случае с сахаром, в лучшем случае вы не получите ничего в долгосрочной перспективе. Тем не менее, это не приносит удовлетворения, и вы неизбежно проголодаетесь снова.

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

Ваша программа знает только, равны ли две строки или нет. И даже в этом случае информация о том, равны или неравны строки, не обязательно дает представление о том, содержит ли какая-либо из этих строк опечатку.

Объекты дают больше возможностей понять, что что-то не так, еще до запуска программы. Поскольку для примитивных объектов нельзя использовать литералы, вам придется откуда-то получать ссылку. Например, если это переменная и вы допустили опечатку, то получите ошибку ссылки. Существуют инструменты, которые могут отловить такую ошибку до сохранения файла.

Если бы вы получали объекты из массива или другого объекта, то JavaScript не выдал бы вам ошибку, когда свойство или индекс не существует. Вы получите undefined, и это то, что вы можете проверить. У вас есть одна вещь, которую нужно проверить. В случае со строками есть сюрпризы, которых лучше избегать, например, когда они пусты.

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

const myID = "Oh, it's so unique";

function magnification(value) {
	if (value && typeof value === 'object' && value.id === myID) {
		// do magic
	}
}

Вот как мы можем сделать то же самое с примитивными объектами:

import data from './the file where data is stored';

function magnification(value) {
	if (value === data.myObject) {
		// do magic
	}
}

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

Я считаю, что не стоит полагаться на строки для чего-либо, кроме вывода пользователю. Недостаточная совместимость строк в примитивных объектах может быть решена постепенно и без необходимости менять способ обработки базовых операций, например сравнения.

Подведение итогов

Работа напрямую с объектами избавляет нас от подводных камней, которые возникают при использовании других методов. Наш код становится проще, потому что мы пишем то, что должна делать ваша программа. Организуя код с помощью примитивных объектов, мы в меньшей степени подвержены влиянию динамической природы JavaScript и некоторого его багажа. Примитивные объекты дают нам больше гарантий и большую степень предсказуемости.