27 мая 2012 в 21:14
Javascript: ООП, прототипы, замыкания, «класс» Timer.js
Здравствуйте программисты начинающие, законченные, а также все сочувствующие. Как известно, ничто не познается так хорошо, как на собственном опыте. Главное, чтобы опыт был полезный. И в продолжении этой простой мысли я хочу предложить заняться несколькими полезными делами сразу:
- Побеседовать на тему «ООП с человеческим лицом».
- Разобраться с прототипами в javascript, коротко и сердито!
- Вспомнить, что «замыкание» это не только ценный мех… удар током.
- Написать на javascript класс Timer — этакий планировщик событий для запуска анимаций, событий, любых функций.
- Весело провести время!
Предупреждение! Если вы не ждете от статьи ничего веселого… то ошибаетесь. Людям с пониженным чувством юмора читать… еще более рекомендуется! Ну-с, приступим…
javascript.ru/tutorial/object/inheritance#svoystvo-prototype-i-prototip
Она принимает в агрументах две функции-Конструктора — Потомка и Родителя, и делает то, что мы уже затронули:
- создает служебную функцию, для передачи прототипа
- записывает в ее prototype-свойство prototype функции Родителя
- передает свойству prototype Потомка промежуточный объект, новое звено цепочки, с ссылкой [[prototype]] на prototype Родителя
- записывает в constructor функцию Child( вместо исходного конструктора служебной F )
- записывает в свойство superclass ссылку на Родителя, на случай возможности обращения к его конструктору и другим исходным методам, если они будут переопределены в потомке.
Может возникнуть вопрос, зачем нужны первые три строки, почему бы сразу не сделать присвоение Child.prototype = Parent.prototype, безо всякого new F(), и дело с концом?!
Дело в том, что при таком присвоении не будет создано новое промежуточное звено в цепочке наследования! В Child.prototype запишется Parent.prototype, а не промежуточный объект -хранилище с дальнейшей ссылкой на Parent.prototype, и при попытке записать что либо в Child.prototype мы грубо ворвемся на территорию Parent, нарушая уважение к старшим и приемственность поколений. Вызывая конструктор new F(), мы создаем для Child свою собственную область хранения прототипных знаний, которые он сможет передать потомкам.
Добавлять отдельные свойства в прототип Конструктора можно еще и так:
Child.prototype.someProperty = "someProperty";
И кстати, не нужно пытаться обращаться к prototype как к свойству объекта — экземпляра класса.
У объекта нет свойства prototype, есть скрытая ссылка [[prototype]], а свойства prototype — нет! Его конечно можно создать, но толку от него в наследовании никакого. Толк есть только от свойства prototype функции-Конструктора, благодаря ее способности передавать указатель в ссылку [[prototype]] создаваемого объекта.
Вот и все, что касается прототипного наследования. Правда просто?
Но прототипное наследование не единственно возможная схема. Хочу упомянуть также и метод вызова конструктора суперкласса, т.е. класса родителя, не даром же мы позаботились о его записи в свойства прототипа ( см. function extend ).
В конструкторе Timer нашего забытого примера, мы присваиваем объекту некоторые свойства через this. Чтобы передать эти свойства последующим поколениям, надо в конструкторе потомка сделать вызов родительского конструктора в контексте потомка т.е.:
function TimerPlayer() {
TimerPlayer.superclass.constructor.apply( this, arguments );
}
Здесь важно помнить, что нельзя вызывать через this.superclass.constructor.apply, а именно через имя текущего конструктора( тут TimerPlayer), потому что иначе, если родительский конструктор тоже использует this, и вызывает this.superclass.constructor.apply(this, arguments), то это превратится в замкнутый вызов apply в контексте this как потомка, что вызовет ошибку.
Вызов родительского конструктора в контексте потомка создаст и присвоит потомку все его свойства и методы. Причем приватные свойства родителя, объявленные через var, а не через this, могут быть доступны только при наличии позволяющих их прочитать родительских публичных методов.
Именно этим путем мы и продолжаем строить наш Timer.
Часть 3. Javascript-класс Timer и его наследие.
Итак, у нас уже есть класс, что-то знающий, но ничего не умеющий, так что пора добавить ему умений! Чему мы желаем научить наш класс? Сделаем что-то вроде плеера:
• start
• pause
• stop
• rewind
• setToFrame
И некоторые менее важные методы. Представим, что мы их уже написали… Итак, вставляем дальше в функцию Timer:
this.start = function(){ /* старт */
if( busy ) return;
if( window.console ) console.log ('start: .currentTime='+currentTime+'; frame='+frame);
busy = true;
timer.call( this );
}
this.pause = function() { /* пауза */
if( window.console ) console.log ('pause: currentTime='+currentTime+'; frame='+frame);
clearInterval( this.intervalId );
busy = false;
}
this.stop = function() { /* стоп */
if( window.console ) console.log ('stop: currentTime='+currentTime+'; frame='+frame);
clearInterval( this.intervalId );
busy = false;
currentTime = 0;
frame = 1;
this.clearFrameLine();
}
/* highlighting - визуализация таймера */
this.clearFrameLine = function() { /* очистка линии кадров */
for(var i=1, str=''; i<this.stopFrame+1; i++)
if( elFr = document.getElementById( this.frameElementPrefixId+i ) ) removeClass( elFr, 'active');
}
this.setActiveFrameElement = function( frameNumber ){ /* подсветка активного кадра */
if( elFr = document.getElementById( this.frameElementPrefixId+frameNumber ) ) addClass(elFr, 'active');
}
this.toString = function() { /* строковое представление, например для alert(), использовал для отладки */
var str = '';
for(var option in this ) str+= option+': '+( (typeof this[option]=='function') ? 'function' : this[option] )+'\n';
return '{\n'+str+'}';
}
this.setTask = function( new_task ) { /* присвоение расписания действий, объекта со списком задач по кадрам */
task = new_task;
this.stopFrame = 0;
keyFrames.length = 0;
for(var frInd in task) {
if( (+this.stopFrame)< (+frInd) ) this.stopFrame=(+frInd);
keyFrames.push( +frInd );
}
}
this.getKeyFrames = function( ) { /* получить приватное свойство keyFrames */
return keyFrames;
}
this.getTask = function() { /* получить приватное свойство task */
return task;
}
this.setToFrame = function( toFrame ) { /* установка в позицию кадра */
if(toFrame>this.stopFrame) return;
frame=toFrame;
currentTime=(frame-1)*this.delay;
for(var frInd in task) {
if( (+frInd)>(+toFrame) ) break;
var taskList = task[ frInd ];
for(var i=0; i<taskList.length; i++ ){
var taskItem;
if( taskItem = taskList[i] )taskItem.run();
}
}
this.clearFrameLine();
this.setActiveFrameElement( toFrame );
}
this.rewind = function( amount ) { /* перемотка! а какже! у нас считай плеер получается :))))))))) */
if( amount<0 && this.intervalId ) amount--;/* поправка на работу setInterval */
var toFrame = frame+amount;
toFrame = Math.max( Math.min( toFrame, this.stopFrame), 1);
this.setToFrame(toFrame);
}
function timer(){ /* приватная функция, вызов setInterval который запускает задачи из списка */
var this_ = this; /* сохраняем ссылку на контекст нашего объекта в переменную */
this.intervalId = setInterval(
function() { /* функция которую вызывает setInterval через промежутки времени this.delay */
//console.log ('currentTime='+currentTime+'; frame='+frame+';'+task);
if( task[ frame ] ) { /* проверяем если ли задача для текущего кадра, если есть... */
var taskList = task[ frame ] /* ... забираем в задачу-массив в переменную */
for(var i=0; i<taskList.length; i++ ){ /* и перебираем элементы массива - сами объекты имеющие свойство-функцию run... */
var taskItem;
if( taskItem = taskList[i] ) taskItem.run(); /* ... которую мы и запускаем */
}
}
/* highlighting */
this_.setActiveFrameElement( frame ); /* подсветка кадра */
currentTime+=this_.delay; /* передвигаем значение текущего времени кадра */
frame++;
if( this_.stopFrame && frame>this_.stopFrame ) { /* если stopFrame не ноль и мы достигли его ... */
if( this_.loop ) this_.setToFrame( 1 ); /* если стоит свойство - цикличность, то переходим в начало, на первый кадр, и продожаем, */
else this_.stop(); /* а иначе стоп! */
}
},
this.delay
);
}
Все с конструктором покончили.
В целом, думаю, все понятно: нужен метод старт? Пишем публичный метод старт! Где…
this.start = function(){
if( busy ) return; /* выходим если уже стартовали, флаг стоит! */
/* это для отладки, вывод информации в консоль */
if( window.console ) console.log ('start: .currentTime='+currentTime+'; frame='+frame);
busy = true; /* ставим флаг, что стартуем */
timer.call( this ); /* вызываем приватный метод */
}
Саму функцию timer я подробно прокомментировал. В целом идея простая:
function timer(){
var this_ = this;
this.intervalId = setInterval(function() { /* тут все и делаем, используем this_ а не this! */ }, this.delay );
}
Сперва, сохраняем ссылку на контекст нашего объекта в переменную, т.к. внутри функции вызываемой в setInterval контекст будет потерян, а переменная останется в замыкании, т.е в локальной области видимости. Возможно для понимания, следует повторить(или узнать) про замыкания, а мы о них еще поговорим ниже… Далее присваиваем нашему объекту свойство intervalId, которое возвращается методом setInterval, этот идентификатор позволит нам останавливать выполнение setInterval при паузе или стоп, смотрите эти методы.
Отдельного разбора требует свойство task, ведь именно там мы в некоем виде храним задачи для выполнения. Структура его такая:
{
1:[
{ run: function(){} }
],
5:[
{},{},{} /*...*/
],
/*...*/
}
Объект массивов объектов. Ой, лучше бы не говорил, а то сам запутался…
Но все просто, в объекте task под нужным номером кадра содержится массив заданий-объектов со свойством run. Этому свойству надо присвоить функцию, которая и вызовется при нужном кадре. При необходимости каждому заданию-объекту, можно добавить еще свойство, на то он и объект.
Также, по надобности, можно в массив добавлять новый объект-задание, пользуясь стандартными методами массивов push, unshift, splice.
Ну и разумеется самому объекту task можно присваивать свойство по номеру нужного кадра!
Таким образом, заполняя task и присваивая нашему классу методом setTask, мы определяем, что и когда ему делать. Как это можно использовать? Выполнять различные динамические сценарии на сайте или на клиенте оffline, создавать анимацию, создавать «живые» учебные пособия или тесты завязанные на времени, напоминать о важных событиях(чайник вкипел!), доставать пользователей всплывающей рекламой( мерзкая и гадкая шутка!). Или просто выводить часики в углу страницы!
Более того, у нас уже организовался простейший интерфейс, некое маленькое API для управления нашим таймером и визуализацией, и сейчас мы его используем, построив панель управления на подобие плеера! Вставляем на страницу html код, любимый и родной:
<button onclick="timer.rewind(-50);">rewind -50</button>
<button onclick="timer.start();">start</button>
<button onclick="timer.pause();">pause</button>
<button onclick="timer.stop();">stop</button>
<button onclick="timer.rewind(+50);">rewind +50</button>
на событие onclick кнопочкам повешены методы объекта timer. Разумеется, перед вызовом которых объект следует не забыть создать. Помните как? — через функцию конструктор:
var timer = new Timer();
Однако, не плохо бы теперь и сценарий создать, чем управлять, а иначе — за что боролись?..
Попробуем создать простую анимацию, будем перемещать картинку по странице, ну своеобразный «hello world» в мире анимации. Перемещать будем картинку
<img id="ball" src="http://www.smayli.ru/data/smiles/transporta-854.gif" >
Напомню, что задача нашего таймера вызывать действие, какое нам угодно действие, при этом сам он за это действие не в ответе. Поэтому ЧТО именно делать — это наша задача, и мы ее сейчас решим, написав простенькую функцию перемещения элемента, которой передается сам элемент по ID и две его координаты:
function moveElem( elem, top, left ){
elem.style.cssText = 'position:relative;top:'+top+'px;left:'+left+'px';
}
Итак, теперь эту функцию нужно присвоить свойству run объекта-задания в массивах под номерами нужных кадров объекта task, следите за мыслью? Итак, создаем объект-сценарий, и первый его кадр определяем как массив, в этот кадр мы положим начальное положение элемента-картинки:
var frames = {};
/* начальное положение */
frames[1]=[];
frames[1].push(
{
run: function(){
moveElem( ball, 600, 600 );
}
}
);
Почему нельзя написать run: moveElem( ball, 600, 600 )? Это неправильно, потому что синтаксис…
moveElem();
… означает вызов функции, а нам ее не надо вызывать тут и сейчас, а надо поместить в тело свойства-функции run, которая вызов и сделает. А иначе мы бы в run запихали результат выполненной moveElem() — undefined, поскольку она ничего не возвращает, и картинку нашу почем зря дернули бы.
И вуаля! Первому кадру мы добавили действие, которое помещает нашу картинку(воздушный шарик) в некую нижнюю позицию страницы. Теперь, чтобы начать подниматься, нам нужно покадрово изменять это состояние, т.е. уменьшать координату top, ну и left — с поправкой на ветер. :) Для заполнения нужных кадров используем цикл. При желании, кстати, можно написать собственный метод класса Timer — который бы добавлял кадры, и действия, и распределял бы изменяющиеся параметры действий по кадрам… А пока, для примера, заполним циклом кадры со 2 по 600-й.
/* действие */
for(var i=2; i<601; i++) frames[i] = [
{
run: function(i){
return function(){
moveElem( ball, 600-i, 600-i );
}
}(i)
}
];
Здесь надо также обратить внимание на использование замыканий. Дело в том, что для передачи динамического i тут используется обертывание в функциональное выражение, которое вызывается на месте:
function(i){ /* сюда передался текущий i как аргумент функции */ }(i) ;
Если бы мы просто использовали:
run: function(){
moveElem( ball, 600-i, 600-i );
}
То при вызов moveElem() i бы бралась из глобальной области видимости, т.е. та, которая у нас объявлена и отработана в цикле for(var i=2; i<601; i++), т.е. вызывалась бы «отработанная» переменная i равная 600, а вовсе не динамическая i, которая должна постепенно нарастать, изменяя координаты взлетающего шарика.
Поэтому мы используем ЗАМЫКАНИЕ на javascript, которое определяет область видимости, т.е. при выполнении функции
function(i){ /* сюда передался текущий i как аргумент функции */ }(i) ;
… внутри него создалась своя переменная i как аргумент этой самой функции, а вызов на месте( повторите если забыли ), выполняет тело функции, где происходит возврат ( return ) уже нашей функции. И опять же:
не
return moveElem( ball, 600-i, 600-i );
а
return function(){
moveElem( ball, 600-i, 600-i );
}
потому что, в первом случае вернется не вызов функции, а результат вызова moveElem( ball, 600-i, 600-i ), которая выполнится тут же!
И вот уже у нас есть сценарий. Теперь можно его присвоить и запустить:
var timer = new Timer( );
timer.setTask( frames );
timer.start(); /* или кнопочкой start */
В целом, покадровое управление дает обширные возможности для создания интересных и сложных сценариев.
На демо странице denis-or-love.narod.ru/portf/timer/ я также реализовал пример покадровой линейки наподобие TimeLine в Adobe Flash :) — кнопочка drawFrameLine.
Надо заметить, что большое количество элементов на странице может сильно тормозить браузер при перерисовке их позиций, это хорошо заметно в ненаглядном IE, если нажать drawFrameLine с параметром 1 — каждый кадр.
При желании, можно написать целый интерфейс для создания сценариев, с расширяемыми возможностями и прочими приятностями. Тут уж как говорится, кто во что горазд…
А теперь, займемся
НАСЛЕДОВАНИЕМ!
Построив базовый класс Timer, мы расширим его, созданием более продвинутого класса TimerPlayer. Ограничим пример продвинутости простым примером, — наш дочерний класс, принимая навыки родительского, будет уметь создавать панель управления нашим таймером наподобие плеера. Для этого делаем три вещи:
- вызов родительского конструктора
- добавление новых методов
- передачу наследования через функцию extend
//Дочерний класс
function TimerPlayer( options ) {
// вызов родительского конструктора
TimerPlayer.superclass.constructor.apply(this, arguments);
// новый метод
this.drawPanel = function( panelId, objName ) {
var objName = objName || 'timer';
var template ='<button onclick="'+objName+'.rewind(-50);">rewind -50</button>'+
'<button onclick="'+objName+'.start();">start</button>'+
'<button onclick="'+objName+'.pause();">pause</button>'+
'<button onclick="'+objName+'.stop();">stop</button>'+
'<button onclick="'+objName+'.rewind(+50);">rewind +50</button>';
document.getElementById( panelId ).innerHTML = template;
}
}
//вызов extend
extend(TimerPlayer, Timer);
И хотя, здесь у нас не происходит прототипного наследования, extend нам нужна для выстраивания цепочки на будущее( а вдруг захотим добавить прототипных свойств родителю...), и для записи superclass и constructor. Новый метод drawPanel принимает строку id элемента внутри которого помещать кнопочки, и строку имя объекта для подстановки в шаблон HTML.
//используем дочерний класс
var timerPlayer = new TimerPlayer();
timerPlayer.setTask( frames2 );
timerPlayer.drawPanel( 'controlPanel', 'timerPlayer' );
Вот мы и закончили, а может только начали наш класс Timer и его потомка.
Я хочу поблагодарить devote за терпеливые консультации и содействие в написании статьи.
До новых встреч и приятного программирования.