В статье ООП в JavaScript рассматривалось, каким образом можно создать литералы объектов и объекты с помощью функции-конструктора. Сейчас мы несколько углубимся в использование функции-конструктора для создания ряда однотипных объектов, а также посмотрим на использование свойства prototype для управления методами этих объектов.
Рассматривать функцию-конструктор будем на примере создания круга, причем мы реально отрисуем его на html-странице с помощью тега <svg>
. Давайте сначала просто посмотрим на код, позволяющий нам получить круг в теге <svg>
без всякого JavaScript.
1 2 3 | <svg width="600" height="350" id="start-svg"> <circle cx="100" cy="100" r="30" /> </svg> |
Вот, что получилось:
Мы получили черный круг внутри прямоугольной области шириной 600px и высотой 350px. Давайте добавим стилей, а также атрибуты fill
и stroke
для тега <circle>
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <style> svg { border: 1px dotted #ccc; display: block; margin: 20px auto; background-image: url(img/grid.png); } circle { stroke-width: 3px;} </style> <svg width="600" height="350" id="forDraw"> <circle cx="100" cy="100" r="30" fill="blue" stroke="#0fа" /> </svg> |
Наш круг остался в тех же координатах, но поменял цвет заливки и обводки. Плюс сам тег svg приобрел css-свойства, которые позволили увидеть его границы и координатную сетку для того, чтобы было удобно ориентироваться в пределах области, занимаемой тегом <svg>
. В коде нам также понадобится атрибут id, т.к. мы можем обратиться к любому элементу с таким атрибутом из JavaScript с помощью метода document.getElementById('some_id')
.
Это был тестовый пример, чтобы убедиться в том, что мы можем рисовать круги с помощью тегов. Для нашего кода следует оставить <svg>
абсолютно пустым, т.е. код в html будет таким:
1 2 3 | <svg width="600" height="350" id="forDraw"> </svg> <script src="circle.js"></script> |
Весь код мы разместим в файле circle.js, который нужно подключить после нашего тега <svg>
, чтобы иметь возможность рисовать в нем. Дальнейший код нужно писать именно в circle.js. Начать этот код необходимо с создания переменной forDraw
:
1 | let forDraw = document.getElementById('forDraw'); |
Создание функции-конструктора
Поскольку мы увидели, что для рисования круга необходимы такие атрибуты, как радиус, координаты x и y, а также цвет заливки и обводки, то именно эти параметры и станут атрибутами нашей функции-конструктора:
1 2 3 4 5 6 7 8 9 10 11 12 13 | function Circle(radius = 10, location, fill = 'black', stroke = 'transparent') { this.radius = radius; this.location = location || { x: 100, y: 100 }; this.fill = fill ; this.stroke = stroke; this.draw = function() { forDraw.innerHTML = `<circle cx="${this.location.x}" cy="${this.location.y}" r="${this.radius}" fill="${this.fill}" stroke="${this.stroke}" />`; } } let c1 = new Circle(); c1.draw(); |
В этой функции благодаря возможности задавать параметры по умолчанию мы сразу описываем, что наш круг может иметь радиус в 10px, черную заливку и прозрачный контур, а также расположение в координатах x=100px, y=100px
, т.е. за исключением размера радиуса, полностью повторять круг из первого примера. Именно такой круг мы рисуем после создания переменной c1
и вызова для нее метода c1.draw()
. Посмотрим на результат.
Действительно - у нас есть маленький черный круг в координатах x=100px, y=100px
.
Давайте добавим еще одну переменную с большим радиусом и другими цветами заливки и обводки.
1 2 | let c2 = new Circle(50, {x: 200, y:200}, 'yellow', 'red'); c2.draw(); |
Однако в области <svg>
мы увидим всего один круг - желтый с красным контуром, размещенный в координатах x=200px, y=200px
. Куда же делся наш первый черный круг?
На самом деле проблема в свойстве innerHTML
. Когда мы присваиваем ему строку с тегом <circle>
с атрибутами нового круга, все , что было перед этим внутри тега <svg>
заменяется на новый тег <circle>
. Причем он будет всегда в единственном экземпляре - том, для которого была последней вызвана функция draw()
. Для того чтобы изменить это состояние, необходимо не перезаписывать (заменять) содержимое <svg>
, а дописывать, используя оператор +=
для свойства innerHTML
. Наша функция draw() теперь будет выглядеть так:
1 2 3 4 | this.draw = function() { forDraw.innerHTML += `<circle cx="${this.location.x}" cy="${this.location.y}" r="${this.radius}" fill="${this.fill}" stroke="${this.stroke}" />`; } |
Посмотрим теперь на наши 2 круга:
Добавление метода translate()
Сейчас хотелось бы добавить метод перемещения круга, который представляет собой еще одну функцию для нашего объекта Circle. Назовем ее translate(). Она будет принимать 2 координаты - x и y, на которые мы будем смещать наши круги. Весь код теперь будет выглядеть так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | <svg width="600" height="350" id="forDraw"></svg> <script> let forDraw = document.getElementById('forDraw'); function Circle(radius = 10, location, fill = 'black', stroke = 'transparent') { this.radius = radius; this.location = location || { x: 100, y: 100 }; this.fill = fill; this.stroke = stroke; this.draw = function() { forDraw.innerHTML += `<circle cx="${this.location.x}" cy="${this.location.y}" r="${this.radius}" fill="${this.fill}" stroke="${this.stroke}" />`; } this.translate = function(x, y) { this.location.x += x; this.location.y += y; } } let c1 = new Circle(); c1.draw(); let c2 = new Circle(50, {x: 200, y:200}, 'yellow', 'red'); c2.draw(); c2.translate(50, 50); c2.draw(); </script> |
Учтите, что для того, чтобы посмотреть на смещение круга, его нужно перерисовать. Давайте посмотрим, как получилось сдвинуть жёлтый круг.
Все хорошо, но круг вместо того, чтобы переместиться на 50px по вертикали и горизонтали, просто перерисовался в этих координатах. И опять-таки это произошло за счет свойства innerHTML
, т.к. теперь мы дописываем при вызове метода draw()
новый тег <circle>
в наш <svg>
. Мы можем побороться с этими добавлениями, присвоив каждому нашему тегу <circle>
атрибут id, который будем получать, как случайное число в достаточно большом диапазоне. Затем в методе draw()
будем отслеживать есть ли элемент с таким id
и удалять его, если он есть, и только затем рисовать его в новых координатах.
Изменяем код еще раз:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <svg width="600" height="350" id="forDraw"></svg> <script> let forDraw = document.getElementById('forDraw'); function Circle(radius = 10, location, fill = 'black', stroke = 'transparent') { this.radius = radius; this.location = location || { x: 100, y: 100 }; this.fill = fill; this.stroke = stroke; this.id = Math.floor(Math.random() * 500); this.draw = function() { if (forDraw.getElementById(this.id)) forDraw.getElementById(this.id).remove(); forDraw.innerHTML += `<circle cx="${this.location.x}" cy="${this.location.y}" r="${this.radius}" fill="${this.fill}" stroke="${this.stroke}" id="${this.id}" />`; } this.translate = function(x, y) { this.location.x += x; this.location.y += y; } } var c1 = new Circle(); c1.draw(); var c2 = new Circle(50, {x: 200, y:200}, 'yellow', 'red'); c2.draw(); c2.translate(50, 50); c2.draw(); </script> |
Теперь мы можем увидеть, что круг действительно сместился.
Добавление геттера и сеттера
Добавим еще методы, которые получили название "геттеров" и "сеттеров". Это такие методы, которые позволяют получить (get) или назначить (set) какое-либо свойство объекта. В нашем примере этим свойством будет длина круга (circleLength). Т.е. пользователь получает или устанавливает именно свойство, а мы описываем метод, который позволяет это сделать. Записать это в функции-конструкторе можно только с помощью специального метода Object.defineProperty()
самого главного объекта в JavaScript - Object.
1 2 3 4 5 6 7 8 9 10 11 | function Circle(radius = 10, location, fill = 'black', stroke = 'transparent') { ... Object.defineProperty(this, "circleLength", { get: function() { return this.radius * 2 * Math.PI; }, set: function(value) { this.radius = Math.round(value / 2 / Math.PI); } }); } |
Троеточие в этом коде показывает, что мы дополнили геттером и сеттером тот код, который был ранее.
Используем назначение длины круга (окружности) для переменной c1
. Заодно поменяем для нее цвет заливки на полупрозрачный голубой и цвет контура. В консоль браузера выведем значение нового радиуса после назначения длины окружности.
1 2 3 4 5 | c1.fill = 'rgba(0, 216, 255, 0.87)'; c1.stroke = '#247f90' c1.circleLength = 200; c1.draw(); console.log(c1.radius); //32 |
Наш первый круг обновился, но остался в тех же координатах x=100px, y=100px
.
А теперь давайте выведем в консоль наши переменные.
1 2 | console.log('c1', c1) console.log('c2', c2); |
Вот, что можно увидеть в браузере Chrome:
Можно заметить, что все свои свойства и все свои функции каждая из наших переменных "носит с собой". А это значит, что при создании большого количества переменных с помощью нашей функции-конструктора мы можем использовать значительно большее количество памяти, чем это необходимо. Поэтому принято оставлять у объектов в конструкторе только свойства, а методы-функции, которые не всегда могут быть вызваны для каждого экземпляра объекта, принято выносить в прототип объекта.
Методы прототипа
Наш код сейчас изменится: в конструкторе останутся только определения свойств, а в прототип "переедут" 2 метода и геттер с сеттером. Код теперь будет таким:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | function Circle(radius = 10, location, fill = 'black', stroke = 'transparent') { this.radius = radius; this.location = location || { x: 100, y: 100}; this.fill = fill; this.stroke = stroke; this.id = Math.floor(Math.random() * 500); } Circle.prototype.draw = function() { if (forDraw5.getElementById(this.id)) forDraw5.getElementById(this.id).remove(); forDraw5.innerHTML += `<circle cx="${this.location.x}" cy="${this.location.y}" r="${this.radius}" fill="${this.fill}" stroke="${this.stroke}" id="${this.id}" />`; } Circle.prototype.translate = function(x, y) { this.location.x += x; this.location.y += y; } Object.defineProperty(Circle.prototype, "circleLength", { get: function() { return this.radius * 2 * Math.PI; }, set: function(value) { this.radius = Math.round(value / 2 / Math.PI); } }); var c1 = new Circle(); c1.draw(); var c2 = new Circle(50, {x: 200, y:200}, 'yellow', 'red'); c2.draw(); c2.translate(50, 50); c2.draw(); c1.fill = 'rgba(0, 216, 255, 0.87)'; c1.stroke = '#247f90' c1.circleLength = 200; c1.draw(); console.log('c1', c1) console.log('c2', c2); |
Посмотрим в консоль:
Визуально ничего не поменялось - те же круги, что и были у нас в первом варианте, но несут они несколько меньше информации, чем ранее, т.к. непосредственно в объекте хранится информация о его цвете заливки и контура, радиусе, расположении и id, а все методы - в его прототипе. "Достать" методы из прототипа можно при их вызове, но при этом количество памяти для объекта будет меньше, чем в первом варианте нашей функции-конструктора.
Наследование объектов
Предположим, мы хотим рисовать не только круги, но и квадраты. Принцип рисования квадратов в теге <svg>
несколько иной - они создаются с помощью тега <rect>
с одинаковыми значениями атрибутов width
и height
:
1 2 3 | <svg width="600" height="350"> <rect x="200" y="50" width="100" height="100" fill="red" stroke="black" /> </svg> |
Тем не менее, у кругов и квадратов много общего:
- радиус круга может заменить значение ширины и высоты квадрата
- координаты x и y нужны и кругу, и квадрату
- цвет заливки и обводки может быть назначен обеим фигурам
- атрибут id является универсальным, поэтому может быть использован для любого элемента
Поэтому мы используем функцию-конструктор Circle для создания квадрата. Для него также объявим функцию-конструктор Square:
1 2 3 4 5 6 7 8 9 10 11 12 | function Square (width, location, fill, stroke) { Circle.call(this, width, location, fill, stroke); //или второй вариант с помощью apply() //Circle.apply(this, arguments); } let sq1 = new Square(); let sq2 = new Square (50, {x: 350, y: 150}, 'pink', 'red'); console.log(sq1); console.log(sq2); sq1.draw(); sq2.draw(); |
Если посмотреть в консоль, то мы создали 2 объекта с парамерами, соответсвующими параметрам объекта-круга, т.е. такие свойства, как radius, location, fill, stroke и даже случайно формируемый id появились у наших переменных sq1
и sq2
, которые являются экземплярами объекта Square.
Однако метод draw()
вызвал ошибку. А это значит, что недостаточно только выполнить вызов функции-конструктора Circle методом call() (или методом apply(), передав аргуметы в виде массива). Необходимо также указать, что прототипом объекта Square является прототип объекта Circle. Иногда также стоит указать и функцию-конструктор:
1 2 3 4 5 6 | function Square (width, location, fill, stroke) { Circle.call(this, width, location, fill, stroke); //Circle.apply(this, arguments); } Square.prototype = new Circle(); Square.prototype.constructor = Circle; |
Теперь наш код выполнится, а в консоли мы увидим, что объект Square унаследовал методы draw()
и translate()
, а также геттер и сеттер для circleLength
:
Давайте теперь посмотрим, что же нарисовалось в теге <svg>
?
Переписываем методы прототипа
Не знаю, насколько вы удивлены, но у нас добавились ... еще 2 круга - маленький черный по центру голубого и розовый чуть выше желтого. На самом деле, удивляться-то нечему - метод draw()
наследуется от Circle.prototype и по-прежнему рисует в <svg>
круги. Поэтому перепишем метод draw()
для Square.prototype так, чтобы у нас рисовался квадрат и заодно применим принцип полиморфизма из ООП, переопределив метод родительского объекта Circle для Square:
1 2 3 4 5 | Square.prototype.draw = function() { if (forDraw.getElementById(this.id)) forDraw.getElementById(this.id).remove(); forDraw.innerHTML += `<rect x="${this.location.x}" y="${this.location.y}" width="${this.radius}" height="${this.radius}" fill="${this.fill}" stroke="${this.stroke}" id="${this.id}" />`; }; |
Смотрим на результат:
Наконец-то у нас получились квадраты.
Теперь было бы неплохо переопределить геттер и сеттер, т.к. периметр квадрата рассчитывается несколько проще, чем длина окружности.
1 2 3 4 5 6 7 8 | Object.defineProperty(Square.prototype, "perimeter", { get: function() { return this.radius * 4; }, set: function(value) { this.radius = Math.round(value / 4); } }); |
Последнее, что мы проверим - это работу метода translate()
и назначение свойства perimeter
:
1 2 3 4 5 | sq1.perimeter = 200; sq1.translate(100, -50); sq1.draw(); sq2.translate(100, 50); sq2.draw(); |
Результат изменения периметра можно увидеть по черному квадрату, а смещение назначено им обоим.
Для того чтобы не быть голословными, создадим теперь много кругов и квадратов на основе приведенного кода:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | function generateNum(m) { return Math.floor(Math.random() * m) + 1; } for(let i=0; i<40; i++){ let randomCircle = new Circle(generateNum(70), {x: generateNum(600), y:generateNum(300)}, 'rgba('+generateNum(254)+','+generateNum(254)+', '+generateNum(254)+', '+Math.random().toFixed(2)+')'); randomCircle.draw(); let randomSquare = new Square(generateNum(100), {x: generateNum(600), y:generateNum(300)}, 'rgba('+generateNum(254)+','+generateNum(254)+', '+generateNum(254)+', '+Math.random().toFixed(2)+')'); randomSquare.draw(); document.getElementById(randomCircle.id).setAttribute('onclick', 'this.remove()'); document.getElementById(randomSquare.id).setAttribute('onclick', 'this.remove()'); } |
Теперь посмотрим на то, что получилось со случайными размерами и цветами:
Обновите страницу несколько раз и получите разные наборы кругов и квадратов с разными цветами. При клике на круг/квадрат он удалится. Have fun!
Краткое резюме
Синтаксис функций-конструкторов предполагает создание на их основе целого ряда объектов-экземпляров с разными значениями свойств-переменных, но с едиными действиями в методах-функциях. Поэтому свойства определяются в конструкторе, а методы - в прототипе. Кстати, именно так реализованы встроенные объекты JavaScript - например, Array, String или Date. Принцип прототипного наследования экономит ресурсы, особенно, когда объектов много.
Данный синтаксис уже несколько устарел, т.к. в стандарте EcmaScript2015, больше известного, как ES6, были реализованы классы, синтаксис которых проще, наглядней и понятней + поддерживает их уже большая часть современных браузеров. Тем не менее есть масса примеров, где такой подход еще использовался и может использоваться дальше с целью поддержки более старых браузеров.
Ссылки по теме: