Клонирование объектов и функции конструкторы

Итак, пришло время разобраться: как создать клон объекта – точную его копию в памяти компьютера? Предположим, у нас имеется некий объект book:

let book = {
    title: "название",
    author: "автор",
    nPages: 0,
    price: 0,
};

Для его клонирования, создадим пустой объект и скопируем в него все примитивные свойства другого объекта:

let lib = {};
 
for (let key in book) {
    lib[key] = book[key];
    console.log(key + ": " + lib[key]);
}

Но здесь есть одна проблема: так можно скопировать только примитивные данные. А что если внутри объекта book будет еще один объект? Например, так:

let book = {
    title: "название",
    author: "автор",
    nPages: 0,
    price: 0,
    size: {height: 100, width: 50}
};

тут свойство size указывает на объект. Тогда при его копировании мы скопируем ссылку на него, а не сам объект. Чтобы исправить это, мы должны в цикле клонирования делать проверку: является ли значение book[key] объектом, и если это так – копируем и его структуру тоже. Это называется «глубокое клонирование». Я приведу здесь лишь пример реализации глубокого копирования. На практике его использовать нельзя, так как он не учитывает всех разновидностей полей объекта и в ряде случаев будет давать неверные результаты:

function cloneDeepObj(dest, obj) {
    for (let key in obj) {
       if((typeof obj[key]) == "object") {
           dest[key] = cloneDeepObj({}, obj[key]);
       }
       else {
           dest[key] = obj[key];
           //console.log(key + ": " + dest[key]);
       }
    }
    return dest;
}
 
let lib = cloneDeepObj({}, book);
book.size.height = 0;
console.log(lib);

Я реализовал все через функцию, которая вызывает саму себя, если встречается свойство, содержащее объект. Здесь это ключевой момент. Когда встречается объект, то запускается такой же алгоритм для создания его клона, затем, результат возвращается и присваивается свойству dest[key]. Так реализуется копирование вложенных объектов. Причем глубина вложенности может быть любой (но, конечно, в разумных пределах).

Далее, мы вызываем эту функцию. В качестве первого аргумента передаем пустой объект, а вторым – клонируемый объект. Результат клонирования сохраняется по ссылке в переменной lib. Затем, для проверки, мы меняем значение вложенного объекта book, выводим в консоль объект lib и видим, что изменения в book его не коснулись, то есть, все было скопировано корректно.

Существует стандартный алгоритм глубокого клонирования, Structured cloning algorithm (http://w3c.github.io/html/infrastructure.html#safe-passing-of-structured-data). Он решает описанную выше задачу, а также более сложные задачи. Чтобы не изобретать велосипед, мы можем использовать реализацию этого алгоритма из JavaScript-библиотеки lodash (https://lodash.com), в частности, метод _.cloneDeep(obj).

Функция-конструктор для объектов

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

Что такое функция-конструктор? Это обычная функция, но ее имя должно начинаться с заглавной буквы. Например:

function Book() {}
function Car() {}

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

Запишем функцию-конструктор Book в виде:

function Book(title, author) {
    this.title = title;
    this.author = author;
    this.price = 10;
}

Теперь можем создать объект с помощью оператора new:

let book = new Book("Муму", "Тургенев");

и вывести объект в консоль:

console.log( book );

Когда функция вызывается как new Book(…), происходит буквально следующее:

  1. Создаётся новый пустой объект, на который указывает this.
  2. Выполняется тело функции, добавляя в объект новые свойства.
  3. Возвращается значение this.

Обратите внимание, что пункт 1 и 3 мы нигде явно не прописывали, движок JavaScript добавляет эти конструкции автоматически при вызове функции через new. В результате мы получаем такое выполнение:

function Book(title, author) {
    // this = {};  (неявно)
 
    this.title = title;
    this.author = author;
    this.price = 10;
 
    // return this;  (неявно)
}

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

let book2 = new Book("Онегин", "Пушкин");

и теперь мы имеем два разных объекта:

console.log( book );
console.log( book2 );

Как видите, так создавать новые объекты бывает гораздо удобнее, чем каждый раз прописывать их через литерал {}. Это и является основной целью конструкторов – удобное повторное создание однотипных объектов.

В JavaScript допускается использовать анонимные функции в качестве конструкторов, например, так:

let car = new function() {
    this.model = "reno";
    this.go = function() {
       console.log("машина едет");
    }
}

И далее, уже работать с объектом:

car.go();
console.log( car );

Конечно такой конструктор вызывается единожды в том месте, где прописан, так как анонимная функция не может быть вызвана где-либо еще. Тогда зачем это нужно? Не проще ли в этом случае создать объект через литерал {}? Иногда нет. Например, создавая сложный объект, мы можем формировать его структуру по некому алгоритму, на основе локальных или даже глобальных переменных. В этом случае прописать логику внутри функции оказывается гораздо удобнее.

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

Давайте вернемся к функции Book и заметим такую особенность: она может быть вызвана и без оператора new, вот так:

let book = Book();

и тогда мы получим ошибку выполнения, так как ссылка this становится равной undefined. Поправим код внутри функции так, чтобы он корректно работал в обоих случаях. Для этого мы будем использовать свойство target оператора new, который равен undefined, если функция вызвана без new. Получим:

function Book(title, author) {
    if(new.target == undefined) //если вызвали без new
       return new Book(title, author); //добавим new автоматически
 
    this.title = title;
    this.author = author;
    this.price = 10;
}

Теперь можно вызывать функцию как с new, так и без new – результат будет тот же. Часто такую подстановку делают в библиотечных конструкторах, чтобы облегчить использование таких функций. Но делать это в своем собственном скрипте не рекомендуется, так как отсутствие new может ввести разработчика в заблуждение. С оператором new мы точно знаем, что в итоге будет создан новый объект.

Наконец, в конструкторе мы можем явно прописать оператор return, к ошибке это не приведет. Но есть такие особенности:

  • При вызове return с объектом, будет возвращён объект, а не this.
  • При вызове return с примитивным значением, примитивное значение будет отброшено.

Например, в этом конструкторе будет создан и возвращен объект, указанный в операторе return:

function User() {
    this.name = "user1";
    return {name: "user2"};
}
 
console.log( new User().name );

А если записать так:

function User() {
    this.name = "user1";
    return;
}

То будет создан объект с именем «user1». Но, обычно, return все-таки опускают и не записывают.

Видео по теме