Работайте с объектами JavaScript как профессионал

Недавно я заканчивал свой первый пакет NPM, Vivisector. Это микробиблиотека Nodejs, которая предоставляет пользовательские «наблюдаемые» типы данных. Другими словами, он позволяет привязывать обработчики событий к определенным типам событий мутации для объектов, массивов и даже строк.

Излишне говорить, что из-за этого я довольно много работал с объектами. Манипуляции и выполнение операций с объектами могут быть пугающими, но если мы воспользуемся преимуществами многих превосходных Object.prototype методов, которые ECMAScript представил за эти годы, это будет очень просто.

Это не должен быть беспорядочный процесс; спецификация дает нам все необходимое для элегантного и эффективного управления объектами. Я надеюсь, что после прочтения этой статьи вы узнали о некоторых новых и творческих способах мышления об объектах, ключевом приспособлении для более глубокого понимания JavaScript.

Итак, давайте работать с объектами!

Получение длины объекта

Массивы хороши, потому что у них так много удобных свойств и методов для управления элементами в них.

Предположим, у нас есть объект:

const obj = {
    prop1: "Godel",
    prop2: "Escher",
    prop3: "Bach"
};

У нас здесь нет свойства length, как вы могли ожидать. Однако мы можем просто преобразовать obj в массив, и мы сделаем:

console.log(Object.keys(obj).length);
// 3

Фантастика. Давайте воспользуемся этим же способом мышления для…

Итерация по объекту

Давайте создадим новый объект:

const author = {
    name: "Yukio",
    surname: "Mishima",
};

Мы могли бы сделать это с помощью цикла for...in:

for (let prop in author) {
    console.log(prop, author[prop])
}
// name Yukio
// surname Mishima

Однако это может иметь неприятные побочные эффекты — for...in перебирает каждое свойство данного объекта, включая прототип этого объекта. Возможно, вы слышали об этом, но, вероятно, было бы полезно увидеть это в действии. Итак, давайте смоделируем, как это будет выглядеть, добавив реквизит к прототипу объекта:

Object.defineProperty(Object.prototype, "specialkey", {
    enumerable: true,
    value: function() {
        return "specialprop";
    }
});

Примечание: здесь мы используем Object.defineProperty() исключительно в демонстрационных целях; вам не следует расширять нативные прототипы таким образом.

Объект нередко имеет различные перечисляемые реквизиты. Так и бывает:

for (let prop in author) {
    console.log(prop, author[prop]);
}
// name Yukio
// surname Mishima
// specialkey [Function: value]

Если вы думаете «Но, подождите… вы установили enumerable на true. Просто измените его!» — вы правы. Мы можем изменить его. Однако мы не всегда имеем контроль над Объектом (и в силу этого над его свойствами).

Вместо этого мы можем добавить в наш цикл простое условие if:

for (let prop in author) {
    if (author.hasOwnProperty(prop)) {
        console.log(prop, author[prop]);
    }
}
// name Yukio
// surname Mishima

hasOwnProperty(), как следует из названия, возвращает логическое значение, указывающее, принадлежит ли указанное свойство данному объекту (в отличие от наследуемого). Но есть ли способ сделать это без условия if? Читать дальше...

Вы помните, что я упоминал о массивах, когда мы подходили к этой теме. На самом деле есть еще один способ перебрать объект — используя for...of. Для этого типа операции нам нужно будет сделать что-то похожее на то, что мы делали при получении длины объекта:

for (let prop of Object.keys(author)) {
    console.log(prop, author[prop]);
}
// name Yukio
// surname Mishima

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

Однако в реальном мире вы, вероятно, не будете просто записывать данные в консоль при переборе объекта. Вместо этого вам, вероятно, потребуется что-то сделать с реквизитом. Это вопрос личных предпочтений, но я думаю, что вышеупомянутый подход Object.keys() может стать действительно запутанным. Давайте посмотрим, что я имею в виду:

const authorCopy = {};
for (let prop of Object.keys(author)) {
    authorCopy[prop] = author[prop];
}
console.log(authorCopy);
// { name: 'Yukio', surname: 'Mishima' }

Глядя на этот код, я легко вспоминаю, как много раз в своей профессиональной деятельности мне приходилось делать это, но с объектом, который имеет вложенные свойства или над которым мне нужно выполнять сложные операции. На мой взгляд, проще, чище и читабельнее просто «деструктурировать» каждый ключ и значение:

const authorCopy = {};
Object.entries(author).forEach(([key, value]) => {
    // do work
    authorCopy[key] = value;
});

console.log(authorCopy);
// { name: 'Yukio', surname: 'Mishima' }

Если вы не знакомы, Object.entries() возвращает массив собственных enumerable[key, value] пар данного объекта.

Оценка свойств объекта

Что касается свойств объекта, давайте посмотрим, как мы можем проверить, существует ли данное свойство в объекте. Это может показаться тривиальным, и в некоторых случаях так оно и есть — как в этом случае:

const person = {
    name: "RuPaul",
    profession: "being awesome"
};

console.log("profession" in person);
// true

Однако вы помните подводную лодку, связанную с использованием оператора in. Вместо in следует использовать hasOwnProperty() (если только вы не пытаетесь пройти цепочку прототипов!). Не верите мне?

console.log("constructor" in person);
// true
console.log(person.hasOwnProperty("constructor"));
// false

Эта операция была для нас легкой, потому что свойство profession существует на «корневом» уровне объекта. Я также рискну предположить, что это так, потому что профессия РуПола действительно «быть крутым», но что-то подсказывает мне, что это чувство больше связано с моей склонностью к крутым людям, чем с JavaScript. Итак, двигаемся дальше...

У РуПола очень впечатляющая биография, или «жизненный путь». Я бы изобразил в производственной кодовой базе Объект person для такой звезды, как Ру, на самом деле выглядел бы примерно так:

const nestedPerson = {
    metadata: {
        id: 123456789,
        claims: {
            tokens: {
                current: "cnVwYXVsaXN0aGVncmVhdGVzdAo="
            }
        }
    },
    instanceData: {
        personal: {
            name: "RuPaul",
            surname: "Charles",
        },
        vocational: {
            profession: "being awesome",
            skills: {
                charisma: true,
                uniqueness: true,
                nerve: true,
                talent: true
            }
        }
    }
};

Этот объект, безусловно, имеет некоторые глубоко вложенные свойства. Оператор in не может найти вложенные реквизиты, даже если они существуют в целевом объекте:

console.log("profession" in nestedPerson);
// false

Итак, как нам теперь проверить реквизит profession? Мне кажется, здесь может пригодиться рекурсия...

Ходячие вложенные объекты (не зная, как они выглядят)

const walkNestedObject = (targetObject, root, ...args) => {
    if (targetObject === undefined) {
        return false;
    }
    if (args.length === 0 && targetObject.hasOwnProperty(root)) {
        return true;
    }
    return walkNestedObject(targetObject[root], ...args);
};

Эта функция принимает три параметра:

  1. целевой объект (в нашем случае nestedPerson),
  2. корневой ключ, от которого вы хотите пройти
  3. любое количество вложенных «ключей», обозначающих каждый путь, по которому вы хотите пройти, заканчивающийся целевой опорой

Мы можем использовать этот метод и передать связку ключей, надеясь, что найдем нашу опору. Это работает, когда мы знаем и можем предвидеть форму объекта заранее, и это не наше окончательное решение (сейчас я покажу вам, как это сделать программно!):

const propFound = walkNestedObject(nestedPerson, "instanceData", "vocational", "profession");

console.log(propFound);
// true

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

const walkRefined = (targetObject, ...args) => {
    return args.reduce((targetObject, root) => {
        return targetObject && targetObject[root];
    }, targetObject);
};

const propFound = walkRefined(nestedPerson,"instanceData", "vocational", "profession" );

console.log(propFound);
// being awesome

Это полезно и все такое, но мне это очень-очень-очень не нравится.

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

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

Итак, как же нам найти реквизит на Объекте… не зная, как этот Объект выглядит?

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

const isNested = x => Object(x) === x && !Array.isArray(x);
const formatter = (...args) => args.join(".");

const collateKeys = (obj, store=[], accumulator=[]) => 
    Object.keys(obj).reduce((accumulator, prop) => 
        isNested(obj[prop]) 
            ? [...accumulator, ...collateKeys(obj[prop], [...store, prop], accumulator)]
            : [...accumulator, formatter(...store, prop)]
    , []);

const keysList = collateKeys(nestedPerson);

console.log(keysList);
/* 
[
  'metadata.id',
  'metadata.claims.tokens.current',
  'instanceData.personal.name',
  'instanceData.personal.surname',
  'instanceData.vocational.profession',
  'instanceData.vocational.skills.charisma',
  'instanceData.vocational.skills.uniqueness',
  'instanceData.vocational.skills.nerve',
  'instanceData.vocational.skills.talent'
]
*/

Ну вы бы посмотрели на это! Мы можем передать любой объект в collateKeys(), и он сделает именно это: сопоставит все ключи и вернет нам массив, содержащий своего рода карту нашего дерева объектов.

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

На каждой итерации мы проверяем, вложена ли опора; если это так, мы объединяем реквизит с «.» разделитель, чтобы обозначить его путь, рекурсию и повторить все сначала. Для этой реализации мы отформатировали выходные данные так, чтобы они четко указывали, где находится каждый ключ.

Это начинает затрагивать несколько парадигм функционального программирования, которые чрезвычайно эффективны. Мы могли бы изменить walkRefined() для обработки сгенерированных нами значений массива. Тогда мы могли бы передать collateKeys() в линию.

Но на данный момент простое наблюдение нашей String «профессии» в этом массиве информирует нас о том, что она действительно существует в объекте. Учитывая то, как данные структурированы в keysList, мы знаем, что нашим совпадением будет любой элемент, оканчивающийся реквизитом, который мы пытаемся найти. Таким образом:

console.log(keysList.filter(x => x.endsWith("profession")));
// [ 'instanceData.vocational.profession' ] 
// aka the precise location of our prop

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

Обновление. На рассмотрении находится предложение ECMAScript, которое направлено на реализацию необязательных цепочек и которое решило бы нашу проблему невероятно лаконичным способом. Читайте о tc39 здесь.

Преобразование объектов в массивы

Поскольку мы так много говорили о массивах, давайте посмотрим, как мы можем преобразовать объект в массив (при сохранении связи ключ/значение)…

Первый подход, который, вероятно, приходит на ум, — использовать forEach():

const music = {
    name: "La Monte Young",
    genre: "Minimalism"
};

const arr = [];

Object.keys(music).forEach(key => arr.push([ key, music[key] ]));

console.log(arr);
// [ [ 'name', 'La Monte Young' ], [ 'genre', 'Minimalism' ] ]

Ранее я заметил, что Object.keys() может немного запутаться при перехвате реквизита. Кроме того, есть гораздо более простой способ сделать это. Я про Object.entries(), разумеется:

const arr = Object.entries(music);

console.log(arr);
// [ [ 'name', 'La Monte Young' ], [ 'genre', 'Minimalism' ] ]

И это все, что нужно. Если мы хотим перехватить значения и выполнить какую-то работу перед их копированием в новый массив, может быть разумно повторно ввести forEach() в микс:

const arr = [];

Object.entries(music).forEach(([key, value]) => { 
    if (value === "Minimalism") {
        value = "Drone";
    }
    arr.push([key, value]);
});

console.log(arr);
// [ [ 'name', 'La Monte Young' ], [ 'genre', 'Drone' ] ]

Хм. Вы заметите, что в предыдущем примере мы объявляем переменную для нашего нового массива, а затем заполняем ее. Однако если вы знакомы с методом map(), то помните, что map() по умолчанию возвращает новый массив. Нам вообще не нужна упреждающая декларация:

const arr = Object.entries(music).map(([key, value]) => {
    if (value === "Minimalism") {
        value = "Drone";
    }
    return [key, value]; 
});

console.log(arr);
// [ [ 'name', 'La Monte Young' ], [ 'genre', 'Drone' ] ]

Здесь мы используем изученный прием деструктурирования и используем тот факт, что map() по умолчанию возвращает новый массив. Всего в нескольких строках кода мы можем сделать довольно много работы!

Создание массива ключей или значений объекта теперь кажется тривиальным:

const singer = {
    name: "Bjork",
    origin: "Iceland"
};

const keysArr = Object.keys(singer);
const valsArr = Object.values(singer);

console.log(keysArr);
// [ 'name', 'origin' ]

console.log(valsArr);
// [ 'Bjork', 'Iceland' ]

Фильтрация объектов

Говоря о преобразовании объектов в массивы, как мы можем отфильтровать объект?

const bestMovieIdea = {
    lead: "Shrek",
    supporting: "Adam Sandler",
    genres: ["horror", "russian", "uncanny valley"]
};

Думаю, из этого получился бы отличный фильм. Но чтобы продать эту идею руководителям кино, я просто расскажу им о роли Адама Сэндлера. Им не нужно знать, что Шрек будет нашим главным героем или что фильм будет на русском языке, пока после мы не получим финансирование.

Так что, вероятно, неплохо отфильтровать наш объект. Опять же, что мы делаем, когда хотим использовать удобный метод Array для нашего объекта?

const salesPitch = Object.keys(bestMovieIdea)
    .filter(key => key === "supporting");
console.log(salesPitch);
// [ 'supporting' ]

Хорошо… это хорошо, я полагаю. Нам удалось получить ключ, соответствующий «Адаму Сэндлеру». Но нам нужен отфильтрованный объект в конце всего этого, а не просто глупый ключ!

Давайте пересмотрим то, что мы сделали в прошлый раз, и соединим еще один метод после нашего вызова filter(). Здесь мы можем использовать map(), как и раньше:

const salesPitchObj = {};
Object.keys(bestMovieIdea)
    .filter(key => key === "supporting")
    .map(key => salesPitchObj["lead"] = bestMovieIdea[key]);

console.log(salesPitchObj);
// { lead: 'Adam Sandler' }

Здесь мы также изменили ключ на «ведущий», но могли просто оставить key, чтобы дословно скопировать реквизит. Руководители фильма обязательно дадут нам финансирование!

Это хорошо, но я только что показал вам, как отфильтровать объект для «Адама Сэндлера». Давайте подойдем к этому с более динамичным решением, написав метод с именем filterObj():

const filterObj = (target, prop) => {
    const clone = {};
    Object.keys(target)
        .filter(key => key === prop)
        .map(key => clone[key] = target[key]);
    return clone;
}

const salesPitch = filterObj(bestMovieIdea, "supporting");

console.log(salesPitch);
// { supporting: 'Adam Sandler' }

Это эксклюзивный фильтр — он возвращает объект, объединенный с нашим аргументом. В «правильном» фильтре мы бы установили выражение filter() на key !== prop. Мы также могли бы придумать что-то более близкое к функциональному программированию, простой обратный вызов для filter():

const flexibleFilter = (target, prop, fn) => {
    const clone = {};
    Object.keys(target)
        .filter(key => fn(key, prop))
        .map(key => clone[key] = target[key]);
    return clone;
};

const filterCallback = (key, prop) => key !== prop;

const salesPitch = flexibleFilter(bestMovieIdea, "supporting", filterCallback);

console.log(salesPitch);
// { lead: 'Shrek', genres: [ 'horror', 'russian', 'uncanny valley' ] }

Здесь все, что нам нужно сделать, это настроить filterCallback() на любое выражение, которое мы хотим использовать во внутреннем вызове filter(). В приведенном выше примере я установил обратный вызов для оценки всех ключей, которые не соответствуют заданному аргументу — таким образом, мой объект salesPitch является точной копией за исключением «поддерживающей» опоры. .

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

const bestMovieIdea = {
    lead: "Shrek",
    supporting: "Adam Sandler",
    genres: ["horror", "russian", "uncanny valley"],
    extras: "supporting"
};

Давайте отфильтруем любые упоминания о «поддержке», будь то ключ или значение. Мы также обновим метод фильтра. Не волнуйтесь; предыдущий обратный вызов все еще будет работать здесь:

const dynamicFilter = (target, prop, fn) => {
    const clone = {};
    Object.entries(target)
        .filter(([key, value]) => fn(key, prop, value))
        .map(([key, value]) => clone[key] = value);
    return clone;
};

А теперь о нашем новом обратном вызове:

const filterCallback = (key, prop, value) => key !== prop && value !== prop;
const clonedObj = dynamicFilter(bestMovieIdea, "supporting", filterCallback);

console.log(clonedObj);
// { lead: 'Shrek', genres: [ 'horror', 'russian', 'uncanny valley' ] }

Хороший! Все ключи и значения, соответствующие нашему заданному аргументу String, были исключены из нашего клонированного объекта. Опять же, вы, вероятно, можете себе представить, насколько мощным может быть этот подход — и мы еще даже не усовершенствовали его!

Копирование и объединение объектов

Обычный шаблон, который я вижу среди программистов-новичков, — это итерация при копировании или слиянии объектов. Как обычно, Object.prototype предоставляет нам метод, разработанный специально для таких операций. Нет необходимости в петлях.

Во-первых, давайте создадим три объекта:

const objOne = {
    name: "Archimedes"
};

const objTwo = {
    name: "Descartes"
};

const objThree = {
    book: "Tractatus"
};

Теперь давайте объединим их. Прежде чем мы используем… спойлеры… Object.assign(), я хотел бы показать вам альтернативный метод, о котором вы, возможно, не слышали, который называется «Распределение объектов»:

const collatedObj = { ...objOne, ...objTwo, ...objThree };

console.log(collatedObj);
// { name: 'Descartes', book: 'Tractatus' }

Ну, Декарт не писал Трактат, но это и есть желаемый результат!

Важно отметить, что порядок, в котором вы передаете объекты (используя спред или Object.assign()), имеет значение. Это поясняется тем фактом, что name опорой collatedObj является «Декарт», а не «Архимед». Было бы наоборот, если бы мы прошли objTwo раньше objOne.

Теперь давайте использовать Object.assign(). Та же идея здесь.

const collatedObj = Object.assign(objOne, objTwo, objThree);

Регистрация collatedObj сейчас приведет к тому же объекту, что и раньше.

Интересный пример использования Object.assign(), с которым я столкнулся, — это отделение объекта от его исходной ссылки. Например, я немного поработал с Proxy API (скоро выйдет статья о Proxy) и обнаружил, что мутировал исходный объект, который я проксировал.

Вот, посмотри. Это имеет смысл, когда вы думаете о ссылке по ссылке и по значению:

const objOne = {
    name: "Archimedes"
};

const ref = objOne;

ref.name = "Hegel";

console.log(objOne);
// { name: 'Hegel' }

Ожидаемо, но тем не менее неприятно. Кроме того, не совсем лучшая практика.

Чтобы избежать этого, мы можем просто создать копию исходного объекта, передав пустой объект в качестве первого аргумента Object.assign():

const objOne = {
    name: "Archimedes"
};

const decoupledObj = Object.assign({}, objOne);

decoupledObj.name = "Spinoza";

console.log(objOne);
// { name: 'Archimedes' }

Это также имеет смысл; мы скопировали значения, а не фактическую ссылку на место, где objOne находится в памяти.

Запретить новые реквизиты

Часто полезно иметь возможность управлять нашими объектами. Давайте посмотрим, как мы можем предотвратить добавление новых свойств к объекту:

const artist = {
    name: "Henry Darger",
    period: "Outsider Art",
};

Object.preventExtensions(artist);

artist.films = ["In the Realms of the Unreal"];

console.log(artist);
// { name: 'Henry Darger', period: 'Outsider Art' }

console.log(Object.isExtensible(artist));
//false

Мы видим, что добавление новых свойств к Объекту больше невозможно.

Однако регистрация Object.getOwnPropertyDescriptors(artist) говорит нам, что writable, enumerable и configurable установлены на true. Это правильно — мы по-прежнему можем установить любые реквизиты, существовавшие в объекте на момент вызова preventExtensions(), мы просто не можем установить новые свойства.

Отсюда и название «запретить расширения».

Запретить добавление/удаление реквизита

Если мы хотим еще больше заблокировать наш объект, мы можем предотвратить добавление и удаление свойств с помощью Object.seal():

const bands = {
    0: "Stereolab",
    1: "Can",
    2: "Swans"
};

Object.seal(bands);

bands[3] = "Black Flag";

delete bands[2];

console.log(bands);
// { '0': 'Stereolab', '1': 'Can', '2': 'Swans' }

Быстрая проверка с нашим другом Object.getOwnPropertyDescriptors() покажет нам, что configurable было установлено на false. Обратите внимание, что добавление здесь означает новые реквизиты. Как и в последнем примере, мы все еще можем установить существующие реквизиты.

Полностью заблокировать объект

Иногда необходимо полностью заблокировать объект. Возможно, мы не хотим, чтобы его реквизиты изменялись или настраивались каким-либо образом, что может легко произойти в результате неожиданного побочного эффекта. Давайте предотвратим подобное с Object.freeze():

const dance = {
    0: "Merce Cunningham",
    1: "Martha Graham",
    2: "Moses Pendleton"
}

Object.freeze(dance);

delete dance[1];
dance[0] = "Anna Halprin";
dance[3] = "Ryan Heffington";

console.log(dance);
// {'0': 'Merce Cunningham', '1': 'Martha Graham', '2': 'Moses Pendleton'}

И это только верхушка айсберга в том, что касается Объектов.

Если вам понравилась эта статья, не забудьте подписаться на меня и заглянуть — в следующей части этой серии я расскажу о массивах!

Первоначально опубликовано на https://goldmund.sh.