JavaScript: исследование объектов

в 8:53, , рубрики: javascript, Блог компании RUVDS.com, разработка, Разработка веб-сайтов

Материал, перевод которого мы сегодня публикуем, посвящён исследованию объектов — одной из ключевых сущностей JavaScript. Он рассчитан, преимущественно, на начинающих разработчиков, которые хотят упорядочить свои знания об объектах.

JavaScript: исследование объектов - 1

Объекты в JavaScript представляют собой динамические коллекции свойств, которые, кроме того, содержат «скрытое» свойство, представляющее собой прототип объекта. Свойства объектов характеризуются ключами и значениями. Начнём разговор о JS-объектах с ключей.

Ключи свойств объектов

Ключ свойства объекта представляет собой уникальную строку. Для доступа к свойствам можно использовать два способа: обращение к ним через точку и указание ключа объекта в квадратных скобках. При обращении к свойствам через точку ключ должен представлять собой действительный JavaScript-идентификатор. Рассмотрим пример:

let obj = {
  message : "A message"
}
obj.message //"A message"
obj["message"] //"A message"

При попытке обращения к несуществующему свойству объекта сообщения об ошибке не появится, но возвращено будет значение undefined:

obj.otherProperty //undefined

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

let french = {};
french["merci beaucoup"] = "thank you very much";

french["merci beaucoup"]; //"thank you very much"

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

et obj = {};
//Number
obj[1] = "Number 1";
obj[1] === obj["1"]; //true
//Object
let number1 = {
  toString : function() { return "1"; }
}
obj[number1] === obj["1"]; //true

В этом примере в качестве ключа используется объект number1. Он, при попытке доступа к свойству, преобразуется к строке 1, а результат этого преобразования используется как ключ.

Значения свойств объектов

Свойства объекта могут быть примитивными значениями, объектами или функциями.

▍Объект как значение свойства объекта

Объекты можно помещать в другие объекты. Рассмотрим пример:

let book = {
  title : "The Good Parts",
  author : {
    firstName : "Douglas",
    lastName : "Crockford"
  }
}
book.author.firstName; //"Douglas"

Подобный подход можно использовать для создания пространств имён:

let app = {};
app.authorService = { getAuthors : function() {} };
app.bookService = { getBooks : function() {} };

▍Функция как значение свойства объекта

Когда в качестве значения свойства объекта используется функция, она обычно становится методом объекта. Внутри метода, для обращения к текущему объекту, используется ключевое слово this.

У этого ключевого слова, однако, могут быть разные значения, что зависит от того, как именно была вызвана функция. Здесь можно почитать о ситуациях, в которых this теряет контекст.

Динамическая природа объектов

Объекты в JavaScript, по своей природе, являются динамическими сущностями. Добавлять в них свойства можно в любое время, то же самое касается и удаления свойств:

let obj = {};
obj.message = "This is a message"; //добавление нового свойства
obj.otherMessage = "A new message"; // добавление нового свойства
delete obj.otherMessage; //удаление свойства

Объекты как ассоциативные массивы

Объекты можно рассматривать как ассоциативные массивы. Ключи ассоциативного массива представляют собой имена свойств объекта. Для того чтобы получить доступ к ключу, все свойства просматривать не нужно, то есть операция доступа к ключу ассоциативного массива, основанного на объекте, выполняется за время O(1).

Прототипы объектов

У объектов есть «скрытая» ссылка, __proto__, указывающая на объект-прототип, от которого объект наследует свойства.

Например, объект, созданный с помощью объектного литерала, имеет ссылку на Object.prototype:

var obj = {};
obj.__proto__ === Object.prototype; //true

▍Пустые объекты

Как мы только что видели, «пустой» объект, {}, на самом деле, не такой уж и пустой, так как он содержит ссылку на Object.prototype. Для того чтобы создать по-настоящему пустой объект, нужно воспользоваться следующей конструкцией:

Object.create(null)

Благодаря этому будет создан объект без прототипа. Такие объекты обычно используют для создания ассоциативных массивов.

▍Цепочка прототипов

У объектов-прототипов могут быть собственные прототипы. Если попытаться обратиться к свойству объекта, которого в нём нет, JavaScript попытается найти это свойство в прототипе этого объекта, а если и там нужного свойства не окажется, будет сделана попытка найти его в прототипе прототипа. Это будет продолжаться до тех пор, пока нужное свойство не будет найдено, или до тех пор, пока не будет достигнут конец цепочки прототипов.

Значения примитивных типов и объектные обёртки

JavaScript позволяет работать со значениями примитивных типов как с объектами, в том смысле, что язык позволяет обращаться к их свойствам и методам.

(1.23).toFixed(1); //"1.2"
"text".toUpperCase(); //"TEXT"
true.toString(); //"true"

При этом, конечно, значения примитивных типов объектами не являются.

Для организации доступа к «свойствам» значений примитивных типов JavaScript, при необходимости, создаёт объекты-обёртки, которые, после того, как они оказываются ненужными, уничтожаются. Процесс создания и уничтожения объектов-обёрток оптимизируется JS-движком.

Объектные обёртки есть у значений числового, строкового и логического типов. Объекты соответствующих типов представлены функциями-конструкторами Number, String, и Boolean.

Встроенные прототипы

Объекты-числа наследуют свойства и методы от прототипа Number.prototype, который является наследником Object.prototype:

var no = 1;
no.__proto__ === Number.prototype; //true
no.__proto__.__proto__ === Object.prototype; //true

Прототипом объектов-строк является String.prototype. Прототипом объектов-логических значений является Boolean.prototype. Прототипом массивов (которые тоже являются объектами), является Array.prototype.

Функции в JavaScript тоже являются объектами, имеющими прототип Function.prototype. У функций есть методы наподобие bind(), apply() и call().

Все объекты, функции, и объекты, представляющие значения примитивных типов (за исключением значений null и undefined) наследуют свойства и методы от Object.prototype. Это ведёт к тому, что, например, у всех них есть метод toString().

Расширение встроенных объектов с помощью полифиллов

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

▍Использование полифиллов

Например, существует полифилл для метода Object.assign(). Он позволяет добавить в Object новую функцию в том случае, если она в нём недоступна.

То же самое относится и к полифиллу Array.from(), который, в том случае, если в объекте Array нет метода from(), оснащает его этим методом.

▍Полифиллы и прототипы

С помощью полифиллов новые методы можно добавлять к прототипам объектов. Например, полифилл для String.prototype.trim() позволяет оснастить все строковые объекты методом trim():

let text = "   A text  ";
text.trim(); //"A text"

Полифилл для Array.prototype.find() позволяет оснастить все массивы методом find(). Похожим образом работает и полифилл для Array.prototype.findIndex():

let arr = ["A", "B", "C", "D", "E"];
arr.indexOf("C"); //2

Одиночное наследование

Команда Object.create() позволяет создавать новые объекты с заданным объектом-прототипом. Эта команда используется в JavaScript для реализации механизма одиночного наследования. Рассмотрим пример:

let bookPrototype = {
  getFullTitle : function(){
    return this.title + " by " + this.author;
  }
}
let book = Object.create(bookPrototype);
book.title = "JavaScript: The Good Parts";
book.author = "Douglas Crockford";
book.getFullTitle();//JavaScript: The Good Parts by Douglas Crockford

Множественное наследование

Команда Object.assign() копирует свойства из одного или большего количества объектов в целевой объект. Её можно использовать для реализации схемы множественного наследования. Вот пример:

let authorDataService = { getAuthors : function() {} };
let bookDataService = { getBooks : function() {} };
let userDataService = { getUsers : function() {} };
let dataService = Object.assign({},
 authorDataService,
 bookDataService,
 userDataService
);
dataService.getAuthors();
dataService.getBooks();
dataService.getUsers();

Иммутабельные объекты

Команда Object.freeze() позволяет «заморозить» объект. В такой объект нельзя добавлять новые свойства. Свойства нельзя удалять, нельзя и изменять их значения. Благодаря использованию этой команды объект становится неизменяемым или иммутабельным:

"use strict";
let book = Object.freeze({
  title : "Functional-Light JavaScript",
  author : "Kyle Simpson"
});
book.title = "Other title";//Ошибка: Cannot assign to read only property 'title'

Команда Object.freeze() выполняет так называемое «неглубокое замораживание» объектов. Это означает, что объекты, вложенные в «замороженный» объект, можно изменять. Для того чтобы осуществить «глубокую заморозку» объекта, нужно рекурсивно «заморозить» все его свойства.

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

Для создания клонов (копий) объектов можно использовать команду Object.assign():

let book = Object.freeze({
  title : "JavaScript Allongé",
  author : "Reginald Braithwaite"
});
let clone = Object.assign({}, book);

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

Объектный литерал

Объектные литералы дают разработчику простой и понятный способ создания объектов:

let timer = {
  fn : null,
  start : function(callback) { this.fn = callback; },
  stop : function() {},
}

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

timer.fn;//null 
timer.start = function() { console.log("New implementation"); }

Метод Object.create()

Решить две вышеозначенные проблемы можно благодаря совместному использованию методов Object.create() и Object.freeze().

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

let timerPrototype = Object.freeze({
  start : function() {},
  stop : function() {}
});
let timer = Object.create(timerPrototype);
timer.__proto__ === timerPrototype; //true

Если прототип защищён от изменений, объект, являющийся его наследником, не сможет изменять свойства, определённые в прототипе. Теперь методы start() и stop() переопределить нельзя:

"use strict";
timer.start = function() { console.log("New implementation"); } //Ошибка: Cannot assign to read only property 'start' of object

Конструкцию Object.create(timerPrototype) можно использовать для создания множества объектов с одним и тем же прототипом.

Функция-конструктор

В JavaScript существуют так называемые функции-конструкторы, представляющие собой «синтаксический сахар» для выполнения вышеописанных действий по созданию новых объектов. Рассмотрим пример:

function Timer(callback){
  this.fn = callback;
}
Timer.prototype = {
  start : function() {},
  stop : function() {}
}
function getTodos() {}
let timer = new Timer(getTodos);

В качестве конструктора можно использовать любую функцию. Конструктор вызывают с использованием ключевого слова new. Объект, созданный с помощью функции-конструктора с именем FunctionConstructor, получит прототип FunctionConstructor.prototype:

let timer = new Timer();
timer.__proto__ === Timer.prototype;

Тут, для предотвращения изменения прототипа, опять же, можно прототип «заморозить»:

Timer.prototype = Object.freeze({
  start : function() {},
  stop : function() {}
});

▍Ключевое слово new

Когда выполняется команда вида new Timer(), производятся те же действия, которые выполняет представленная ниже функция newTimer():

function newTimer(){
  let newObj = Object.create(Timer.prototype);
  let returnObj = Timer.call(newObj, arguments);
  if(returnObj) return returnObj;
    
  return newObj;
}

Здесь создаётся новый объект, прототипом которого является Timer.prototype. Затем вызывается функция Timer, устанавливающая поля для нового объекта.

Ключевое слово class

В ECMAScript 2015 появился новый способ выполнения вышеописанных действий, представляющий собой очередную порцию «синтаксического сахара». Речь идёт о ключевом слове class и о соответствующих конструкциях, связанных с ним. Рассмотрим пример:

class Timer{
  constructor(callback){
    this.fn = callback;
  }
  
  start() {}
  stop() {}  
}
Object.freeze(Timer.prototype);

Объект, созданный с использованием ключевого слова class на основе класса с именем ClassName, будет иметь прототип ClassName.prototype. При создании объекта на основе класса нужно использовать ключевое слово new:

let timer= new Timer();
timer.__proto__ === Timer.prototype;

Использование классов не делает прототипы неизменными. Их, если это нужно, придётся «замораживать» так же, как мы это уже делали:

Object.freeze(Timer.prototype);

Наследование, основанное на прототипах

В JavaScript объекты наследуют свойства и методы от других объектов. Функции-конструкторы и классы — это «синтаксический сахар» для создания объектов-прототипов, содержащих все необходимые методы. С их использованием создают новые объекты являющиеся наследниками прототипа, свойства которого, специфичные для конкретного экземпляра, устанавливают с помощью функции-конструктора или с помощью механизмов класса.

Хорошо было бы, если бы функции-конструкторы и классы могли бы автоматически делать прототипы неизменными.

Сильной стороной прототипного наследования является экономия памяти. Дело в том, что прототип создаётся лишь один раз, после чего им пользуются все объекты, созданные на его основе.

▍Проблема отсутствия встроенных механизмов инкапсуляции

В шаблоне прототипного наследования не используется разделение свойств объектов на приватные и общедоступные. Все свойства объектов являются общедоступными.

Например, команда Object.keys() возвращает массив, содержащий все ключи свойств объекта. Его можно использовать для перебора всех свойств объекта:

function logProperty(name){
  console.log(name); //имя свойства
  console.log(obj[name]); //значение свойства
}
Object.keys(obj).forEach(logProperty);

Существует один паттерн, имитирующий приватные свойства, полагающийся на то, что разработчики не будут обращаться к тем свойствам, имена которых начинаются с символа подчёркивания (_):

class Timer{
  constructor(callback){
    this._fn = callback;
    this._timerId = 0;
  }
}

Фабричные функции

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

function TodoStore(callback){
    let fn = callback;
    
    function start() {},
    function stop() {}
    
    return Object.freeze({
       start,
       stop
    });
}

Здесь переменная fn является приватной. Общедоступными являются лишь методы start() и stop(). Эти методы нельзя модифицировать извне. Здесь не используется ключевое слово this, поэтому при использовании данного метода создания объектов проблема потеря контекста this оказывается неактуальной.

В команде return используется объектный литерал, содержащий лишь функции. Более того, эти функции объявлены в замыкании, они совместно пользуются общим состоянием. Для «заморозки» общедоступного API объекта используется уже известная вам команда Object.freeze().

Здесь мы, в примерах, использовали объект Timer. В этом материале можно найти его полную реализацию.

Итоги

В JavaScript значения примитивных типов, обычные объекты и функции воспринимаются как объекты. Объекты имеют динамическую природу, их можно использовать как ассоциативные массивы. Объекты являются наследниками других объектов. Функции-конструкторы и классы — это «синтаксический сахар», они позволяют создавать объекты, основанные на прототипах. Для организации одиночного наследования можно использовать метод Object.create(), для организации множественного наследования — метод Object.assign(). Для создания инкапсулированных объектов можно использовать фабричные функции.

Уважаемые читатели! Если вы пришли в JavaScript из других языков, просим рассказать нам о том, что вам нравится или не нравится в JS-объектах, в сравнении с реализацией объектов в уже известных вам языках.

JavaScript: исследование объектов - 2

Автор: ru_vds

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js