Статьи

Шаблоны проектирования JavaScript — Шаблон модуля

 Как я уже упоминал несколько дней назад, я сейчас нахожусь в процессе чтения книги по шаблонам JavaScript от Addy Osmani . (Примечание: в моей предыдущей записи в блоге я ссылался на физическую копию на Amazon. Ссылка здесь на бесплатную онлайн-версию. Я думаю, что его книгу стоит покупать лично, но вы можете попробовать, прежде чем купить!) Первый шаблон, описанный в книга, о которой я собираюсь поговорить сегодня, — это шаблон модуля.

Прежде чем я начну, я хочу быть ясным. Я пишу это как средство, чтобы помочь мне укрепить мое понимание. Я не эксперт. Я учусь. Я ожидаю, что сделаю ошибки, и я ожидаю, что мои читатели назовут меня на это. Если в конце концов мы все узнаем что-то вместе, то я думаю, что этот процесс стоит того!

Адди описывает шаблон модуля следующим образом:


Шаблон Module был первоначально определен как способ обеспечить как частную, так и публичную инкапсуляцию для классов в традиционной разработке программного обеспечения.

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

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

<html>
<head>
<title></title>
<script>
//stuff here
</script>
</head>

<body>
<!--awesome layout here -->
</body>
</html>

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

<html>
<head>
<title></title>
<script>
function doSomething() {

}
</script>
</head>
 
<body>
<!--awesome layout here -->
</body>
</html>

И затем постепенно добавляя все больше и больше …

<html>
<head>
<title></title>
<script>
function doSomething() {
 
}

function doSomethingElse() {

}

function heyINeedThisToo() {

}
</script>
</head>
 
<body>
<!--awesome layout here -->
</body>
</html>

В конце концов, объем кода JavaScript, который у вас есть, может достигнуть точки, когда вы поймете, что вам, вероятно, следует хранить его в своем собственном файле. Но перемещение всего этого кода в отдельный файл не обязательно поможет. Через некоторое время вы начинаете испытывать трудности с поиском нужных функций. У вас может быть код, связанный с функцией X, рядом с функцией Y, за которой следует еще кое-что, связанное с функцией X. Возможно, вы создадите несколько файлов JavaScript. Один для X, другой для Y. Но, как намекает Addy выше, вы рискуете перезаписать имена функций друг на друга.

Рассмотрим веб-приложение, которое взаимодействует как с Facebook, так и с Twitter. Вы пишете для Twitter функцию getLatest, которая извлекает последние твиты о Пэрис Хилтон. (И почему бы вам не сделать это?) Затем вы пишете код, чтобы получать последние обновления статуса от ваших друзей в Facebook. Как мы должны это назвать? Ах да — получи последний!

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

Я создал простое веб-приложение, которое позволяет пользователям вести дневник. На данный момент функциональность проста: вы можете просматривать свои прошлые записи, писать новые и просматривать определенные записи. Это веб-приложение будет использовать WebSQL, который не работает в Firefox или IE! Это намеренно, и я планирую рассмотреть это в более поздней записи в блоге. Вы можете продемонстрировать это (снова, пожалуйста, используйте Chrome, и снова имейте в виду, что есть причина, по которой я не работаю с несколькими браузерами), перейдя сюда:

http://www.raymondcamden.com/demos/2013/mar/22/v1/

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

var db;
var mainView;

$(document).ready(function() {
  //create a new instance of our Diary and listen for it to complete it's setup
	setupDiary(startApp);
});

function dbErrorHandler(e) {
	console.log('DB Error');
	console.dir(e);
}

function setupDiary(callback) {

	//First, setup the database
	db = window.openDatabase("diary", 1, "diary", 1000000);
	db.transaction(initDB, dbErrorHandler, callback);

}

function initDB(t) {
	t.executeSql('create table if not exists diary(id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT, image TEXT, published DATE)');
}

/*
Main application handler. At this point my database is setup and I can start listening for events.
*/

function startApp() {
	console.log('startApp');

	mainView = $("#mainView");

	//Load the main view
	pageLoad("main.html");
	
	//Always listen for home click
	$(document).on("touchend", ".homeButton", function(e) {
		e.preventDefault();
		pageLoad("main.html");
	});


}

function pageLoad(u) {
	console.log("load "+u);
	//convert url params into an ob
	var data = {};
	if(u.indexOf("?") >= 0) {
		var qs = u.split("?")[1];
		var parts = qs.split("&");
		for(var i=0, len=parts.length; i<len; i++) {
			var bits = parts[i].split("=");
			data[bits[0]] = bits[1];
		};
	}
	$.get(u,function(res,code) {
		mainView.html(res);
		
		var evt = document.createEvent('CustomEvent');
		evt.initCustomEvent("pageload",true,true,data);
		var page = $("div", mainView);
		page[0].dispatchEvent(evt);
		
	});
}

//Utility to convert record sets into array of obs
function fixResults(res) {
	var result = [];
	for(var i=0, len=res.rows.length; i<len; i++) {
		var row = res.rows.item(i);
		result.push(row);
	}
	return result;
}

function getDiaryEntries(start,callback) {
	console.log('Running getEntries');
	if(arguments.length === 1) callback = arguments[0];

	db.transaction(
		function(t) {
			t.executeSql('select id, title, body, image, published from diary order by published desc',[],
				function(t,results) {
					callback(fixResults(results));
				},dbErrorHandler);
		}, dbErrorHandler);

}

$(document).on("pageload", "#mainPage", function(e) {
	getDiaryEntries(function(data) {
		console.log('getEntries');
		var s = "";
		for(var i=0, len=data.length; i<len; i++) {
			s += "<div data-id='"+data[i].id+"'>" + data[i].title + "</div>";
		}
		$("#entryList").html(s);

		//Listen for add clicks
		$("#addEntryBtn").on("touchend", function(e) {
			e.preventDefault();
			pageLoad("add.html");
		});

		//Listen for entry clicks
		$("#entryList div").on("touchend", function(e) { 
			e.preventDefault();
			var id = $(this).data("id");
			pageLoad("entry.html?id="+id);
		});

	});

});

function fixResult(res) {
	if(res.rows.length) {
		return res.rows.item(0);
	} else return {};
}

function getEntry(id, callback) {

	db.transaction(
		function(t) {
			t.executeSql('select id, title, body, image, published from diary where id = ?', [id],
				function(t, results) {
					callback(fixResult(results));
				}, dbErrorHandler);
			}, dbErrorHandler);

}

$(document).on("pageload", "#entryPage", function(e) {

	getEntry(Number(e.detail.id), function(ob) {
		var content = "<h2>" + ob.title + "</h2>";
		content += "Written "+dtFormat(ob.published) + "<br/><br/>";
		content += ob.body;
		$("#entryDisplay").html(content);
	});
});

function saveEntry(data, callback) {
	db.transaction(
		function(t) {
			t.executeSql('insert into diary(title,body,published) values(?,?,?)', [data.title, data.body, new Date().getTime()],
			function() { 
				callback();
			}, dbErrorHandler);
		}, dbErrorHandler);
}

$(document).on("pageload", "#addPage", function(e) {

	$("#addEntrySubmit").on("touchstart", function(e) {
		e.preventDefault();
		//grab the values
		var title = $("#entryTitle").val();
		var body = $("#entryBody").val();
		//store!
		saveEntry({title:title,body:body}, function() {
			pageLoad("main.html");
		});
		
	});
});


function dtFormat(input) {
   if(!input) return "";
	input = new Date(input);
	var res = (input.getMonth()+1) + "/" + input.getDate() + "/" + input.getFullYear() + " ";
	var hour = input.getHours()+1;
	var ampm = "AM";
	if(hour === 12) ampm = "PM";
	if(hour > 12){
		hour-=12;
		ampm = "PM";
	}
	var minute = input.getMinutes()+1;
	if(minute < 10) minute = "0" + minute;
	res += hour + ":" + minute + " " + ampm;
	return res;
}

То, что вы видите, это то, как я обычно пишу код. Мое приложение начинается с ожидания загрузки DOM. Затем необходимо настроить базу данных. Далее необходимо перечислить их. И так далее. Если вы прочтете файл JavaScript «вниз», вы увидите функции, связанные с манипулированием DOM, функции, обрабатывающие мою «одностраничную архитектуру», и функции, выполняющие дерьмо в базе данных. Когда я работал над первой страницей, я сосредоточился на списках. Когда я работал над формой добавления, я работал над поддержкой записи данных. По сути, когда я перешел в приложение, я просто переключал функции одну за другой.

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

Базовая структура кода с использованием шаблона Module может быть определена следующим образом:

var someModule = (function() {
 
 
 
}());

Поднимите руку, если этот синтаксис смущает. Я знаю, что сделал. Теперь это имеет смысл для меня. Но долгое время это было просто … странно. У меня был умственный блок, принимающий форму этого кода в течение длительного времени. Мне не стыдно в этом признаться.

Для меня это помогло, если я немного отступил и посмотрел на это так:

var someModule = (
 

 
);

Хорошо, это имеет смысл. Я в основном говорю, что переменная someModule равна тому, что происходит в моих скобках. Хорошо, так что, черт возьми, там происходит …

function() {



}()

Итак, мы создаем функцию и запускаем ее. Немедленно. Итак, что бы функция не возвращала — это то, что будет передано someModule. Здесь вещи становятся интересными. Давайте поместим некоторый код в этот модуль.

var someModule = (function() {

  //Credit Addy Osmani: http://addyosmani.com/resources/essentialjsdesignpatterns/book/#modulepatternjavascript
  var counter = 0;

  return {

    incrementCounter: function () {
      return counter++;
    },

    resetCounter: function () {
      console.log( "counter value prior to reset: " + counter );
      counter = 0;
    }
  };
 
 
}());

Код для этого модуля взят из примера Addy. Переменная counter является частной переменной. Зачем? Потому что это на самом деле не возвращается из функции. Вместо этого мы возвращаем объектный литерал, который содержит две функции. Эти функции имеют доступ к счетчику переменных, потому что при создании они находились в одной области видимости. (Примечание. Последнее предложение может не точно описывать ситуацию.) Результатом этой функции является литерал объекта. Мой модуль содержит две функции и личную переменную данных.

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

Итак, как я уже сказал, я хотел убрать аспекты кода из моего кода. Я создал новый файл diary.js и создал этот модуль.

var diaryModule = (function() {
  
	var db;
	
	function initDB(t) {
		t.executeSql('create table if not exists diary(id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT, image TEXT, published DATE)');
	}
	
	function dbErrorHandler(e) {
		console.log('DB Error');
		console.dir(e);
	}
	
	//Utility to convert record sets into array of obs
	function fixResults(res) {
		var result = [];
		for(var i=0, len=res.rows.length; i<len; i++) {
			var row = res.rows.item(i);
			result.push(row);
		}
		return result;
	}
	
	//I'm a lot like fixResults, but I'm only used in the context of expecting one row, so I return an ob, not an array
	function fixResult(res) {
		if(res.rows.length) {
			return res.rows.item(0);
		} else return {};
	}

	return {
	
		setup:function(callback) {
			db = window.openDatabase("diary", 1, "diary", 1000000);
			db.transaction(initDB, dbErrorHandler, callback);
		},
		getEntries:function(start,callback) {
			console.log('Running getEntries');
			if(arguments.length === 1) callback = arguments[0];
			
			db.transaction(
				function(t) {
					t.executeSql('select id, title, body, image, published from diary order by published desc',[],
						function(t,results) {
							callback(fixResults(results));
						},dbErrorHandler);
				}, dbErrorHandler);
		
		},
		getEntry:function(id, callback) {
			db.transaction(
				function(t) {
					t.executeSql('select id, title, body, image, published from diary where id = ?', [id],
						function(t, results) {
							callback(fixResult(results));
						}, dbErrorHandler);
					}, dbErrorHandler);
		
		},
		saveEntry:function(data, callback) {
			db.transaction(
				function(t) {
					t.executeSql('insert into diary(title,body,published) values(?,?,?)', [data.title, data.body, new Date().getTime()],
					function() { 
						callback();
					}, dbErrorHandler);
				}, dbErrorHandler);
		}


		
	}
	
}());

Теперь у меня есть «пакет», который может использовать мой основной файл JavaScript. Давайте посмотрим на это.

var mainView;

$(document).ready(function() {

  //create a new instance of our Diary and listen for it to complete it's setup
	diaryModule.setup(startApp);
});

/*
Main application handler. At this point my database is setup and I can start listening for events.
*/

function startApp() {
	console.log('startApp');

	mainView = $("#mainView");

	//Load the main view
	pageLoad("main.html");
	
	//Always listen for home click
	$(document).on("touchend", ".homeButton", function(e) {
		e.preventDefault();
		pageLoad("main.html");
	});


}

function pageLoad(u) {
	console.log("load "+u);
	//convert url params into an ob
	var data = {};
	if(u.indexOf("?") >= 0) {
		var qs = u.split("?")[1];
		var parts = qs.split("&");
		for(var i=0, len=parts.length; i<len; i++) {
			var bits = parts[i].split("=");
			data[bits[0]] = bits[1];
		};
	}
	$.get(u,function(res,code) {
		mainView.html(res);
		
		var evt = document.createEvent('CustomEvent');
		evt.initCustomEvent("pageload",true,true,data);
		var page = $("div", mainView);
		page[0].dispatchEvent(evt);
		
	});
}

$(document).on("pageload", "#mainPage", function(e) {
	diaryModule.getEntries(function(data) {
		console.log('getEntries');
		var s = "";
		for(var i=0, len=data.length; i<len; i++) {
			s += "<div data-id='"+data[i].id+"'>" + data[i].title + "</div>";
		}
		$("#entryList").html(s);

		//Listen for add clicks
		$("#addEntryBtn").on("touchend", function(e) {
			e.preventDefault();
			pageLoad("add.html");
		});

		//Listen for entry clicks
		$("#entryList div").on("touchend", function(e) { 
			e.preventDefault();
			var id = $(this).data("id");
			pageLoad("entry.html?id="+id);
		});

	});

});

$(document).on("pageload", "#entryPage", function(e) {

	diaryModule.getEntry(Number(e.detail.id), function(ob) {
		var content = "<h2>" + ob.title + "</h2>";
		content += "Written "+dtFormat(ob.published) + "<br/><br/>";
		content += ob.body;
		$("#entryDisplay").html(content);
	});
});

$(document).on("pageload", "#addPage", function(e) {

	$("#addEntrySubmit").on("touchstart", function(e) {
		e.preventDefault();
		//grab the values
		var title = $("#entryTitle").val();
		var body = $("#entryBody").val();
		//store!
		diaryModule.saveEntry({title:title,body:body}, function() {
			pageLoad("main.html");
		});
		
	});
});


function dtFormat(input) {
    if(!input) return "";
	input = new Date(input);
    var res = (input.getMonth()+1) + "/" + input.getDate() + "/" + input.getFullYear() + " ";
    var hour = input.getHours()+1;
    var ampm = "AM";
	if(hour === 12) ampm = "PM";
    if(hour > 12){
        hour-=12;
        ampm = "PM";
    }
    var minute = input.getMinutes()+1;
    if(minute < 10) minute = "0" + minute;
    res += hour + ":" + minute + " " + ampm;
    return res;
}

Вы можете видеть, что все функции базы данных теперь ушли. Теперь я получаю к ним доступ через diaryModule. Я считаю, что я вырезал около 60 или около того строк кода, примерно треть, но то, что осталось, более сфокусировано. (Я мог бы также взять функции, используемые для поддержки моей одностраничной архитектуры.)

Вы можете запустить эту версию здесь: http://www.raymondcamden.com/demos/2013/mar/22/v2

Итак, вопросы? Мнения? Rants? ?