Навигация без перезагрузки используя expressjs, jade и History.js

  • Tutorial
Мне ранее не доводилось использовать в своей работе такую возможность HTML5 как History API. И вот настал тот час, разобраться в этом и провести небольшой эксперимент. Результатом этого эксперимента я решил поделиться с Вами.

И так что мы хотим:
— Навигация по сайту с использованием history api
— Получения данных с сервера в виде json объекта с последующим рендером на клиенте
— При прямом переходе рендер должен происходить на сервере
— Что бы все было легко и просто

С кругом потребностей определились, теперь определимся с технологиями:
— На сервере будет трудиться expressjs под nodejs
— В качестве шаблонитизатора jade
— Для клиента History.js

Сервер

Для тех кто никогда не работал с nodejs для начала стоит ее установить. Как это сделать быстро под Ubuntu можно посмотреть тут. Создадим себе папку для проекта и перейдем в нее. Далее установим необходимые модули:
npm i express jade

И создадим две директории:
— view — тут будут лежать шаблоны
— public — тут будет статичный контент

Далее напишем сервер и остановимся лишь на основных моментах.
Первое чем я хотел себе облегчить жизнь, это не задумываться о том как пришел к нам запрос по ajax или нет. Для этого мы перехватим стандартный res.render
Код
app.all('*', function replaceRender(req, res, next) {
	var render = res.render,
		view = req.path.length > 1 ? req.path.substr(1).split('/'): [];
		
	res.render = function(v, o) {
		var data;
		
		res.render = render;
		
		//тут мы должны учесть что первым аргументом может придти
		//имя шаблона					
		if ('string' === typeof v) {
			if (/^\/.+/.test(v)) {
				view = v.substr(1).split('/');
			} else {
				view = view.concat(v.split('/'));
			}
			
			data = o;
		} else {
			data = v;
		}

		//в res.locals располагаются дополнительные данные для рендринга
		//Например такие как заголовок страницs (res.locals.title)		
		data = merge(data || {}, res.locals);
		
		if (req.xhr) {
			//Если это аякс то отправляем json
			res.json({ data: data, view: view.join('.') });
		} else {
			//Если это не аякс, то сохраняем текущее 
			//состояние (понадобиться для инициализации history api)
			data.state = JSON.stringify({ data: data, view: view.join('.') });
            //И добавляем префикс к шаблону. Далее я расскажу для чего он нужен.
			view[view.length - 1] = '_' + view[view.length - 1];
			//Собственно сам рендер
			res.render(view.join('/'), data);
		}
	};
	
	next();
});



res.render перегрузили, теперь мы можем спокойно вызывать в наших контроллерах res.render(data) или res.render('view name', data), и сервер сам либо отрендрит либо вернет json на клиента в зависимости от типа запроса.

Посмотрим на код еще раз, а я попробую объяснить зачем нужен префикс '_' к шаблонам в случае «рендринга на сервере».
Проблема заключается в следующем. В jade отсутствуют layout'ы, в место них используются блоки, блоки могут расширять, заменять или дополнять друг друга (все это хорошо описано в документации).

Рассмотрим пример.
Предположим у нас есть вот такая структура отображений:
вариант А
layout.jade
!!! 5
html
	head
		title Page title
	body
		#content
			block content


index.jade
extends layout

block content
	hello world


Если мы сейчас отрендрим index.jade то он отрендриться вместе с layout.jade. Это не доставляет проблем до тех пор пока мы не хотим экспортировать index.jade на клиента и рендрить его там, но уже без layout.jade. Поэтому я решил добавить еще один шаблон, который бы позволял это делать легко и просто.

вариант Б
layout.jade
!!! 5
html
	head
		title Page title
	body
		#content
			block content


_index.jade
extends layout

block content
	include index


index.jade
hello world


Теперь если мы хотим отрендрить блок с layout'ом, то мы рендрим файл _index.jade, если нам не нужен layout, то рендрим index.jade. Мне показался такой способ наиболее простым и понятным. Если придерживаться правила что только шаблоны с префиксом "_" расширяют layout.jade то можно безболезненно экспортировать все остальное на клиента. (Несомненно есть и другие способы сделать такое, можете рассказать о них в комментариях, будет интересно узнать)

Следующий момент на котором я остановлюсь, это экспорт шаблонов на клиента. Для этого напишем функцию которая будет на вход получать путь к шаблону относительно viewdir, а на выход будет возвращать скомпилированную функцию приведенную к строке.
Код
function loadTemplate(viewpath) {
	var fpath = app.get('views') + viewpath,
		str = fs.readFileSync(fpath, 'utf8');
	
	viewOptions.filename = fpath;
	viewOptions.client = true;
	
	return jade.compile(str, viewOptions).toString();	
}


Теперь напишем контроллер который будет собирать javascript файл с шаблонами.
Код
(Прошу не обращать внимание на то что все руками, это всего лишь эксперимент, в реальном проекте так конечно же делать не стоит)
app.get('/templates', function(req, res) {
	
	var str = 'var views = { '
			+	'"index": (function(){ return ' + loadTemplate('/index.jade')  + ' }()),'
			+	'"users.index": (function(){ return ' + loadTemplate('/users/index.jade')  + ' }()),'
			+	'"users.profile": (function(){ return ' + loadTemplate('/users/profile.jade')  + ' }()),'
			+	'"errors.error": (function(){ return ' + loadTemplate('/errors/error.jade')  + ' }()),'
			+	'"errors.notfound": (function(){ return ' + loadTemplate('/errors/notfound.jade')  + ' }())'
			+ '};'

	res.set({ 'Content-type': 'text/javascript' }).send(str);
});


Теперь когда клиент запросит /template, в ответ он получит такой объект:
var view = {
		'имя шаблона': <функция>
	};

И на клиенте что бы отрендрить нужный шаблон, достаточно будет вызвать view['имя шаблона'](data);

Закончим рассматривать серверную часть, т.к. все остальное особо к делу не относится и на прямую не связано с нашей задачей. Тем более код можно посмотреть тут.

Клиент

Так как мы экспортируем на клиента уже скомпилированные шаблоны, нам нет нужды подключать сам шаблонитизатор, достаточно подключить его runtime и не забываем подгружать наши шаблоны, подключив их как обычный javascript файл.

Следующая библиотека из списка это History.js, название которой говорит само за себя. Я выбрал версию только для html5 браузеров, это все современные браузеры, хотя библиотека может работать в старых браузерах через url hash.

Осталось совсем немного клиентского кода.
Первое напишем функцию render(). Она достаточно простая и выполняет рендер заданного шаблона в блок content.
var render = (function () {
	return function (view, data) {
		$('#content').html(views[view](data));
	}
}());


Теперь код инициализирующий работу с History.js
Код
$(function () {
	var initState;
	
	if (History.enabled) {
		$('a').live('click', function () {
			var el = $(this),
				href = el.attr('href');
			
			$.get(href, function(result) {
				History.pushState(result, result.data.title, href);
			}, 'json');
			
			return false;
		});
	
	
		History.Adapter.bind(window,'statechange', function() {
			var state = History.getState(),
				obj = state.data;
			render(obj.view, obj.data);
		});
		
		//init
		initState = $('body').data('init');
		History.replaceState(initState, initState.data.title, location.pathname + location.search);
	}
});


Код достаточно простой. Первое что мы делаем, это смотрим поддерживает ли браузер history api. Если нет, то ничего не меняем и клиент работает по старинке.
А если поддерживает, мы перехватываем все клики по a, посылаем аякс запрос на сервер.

Не забываем навесить обработчик события «statechange», в этот момент нам нужно перерисовывать наш content блок, и добавить инициализацию начального состояния, я решил хранить его в теге body, атрибут data-init, сюда пишутся начальные значения при рендере на сервере.
Строчка data.state = JSON.stringify({ data: data, view: view.join('.') }); в функции replaceRender

Вот собственно и все.

Рабочий пример тут (Если умрет, значит хаброэффект его накрыл :))
Код можно посмотреть тут
Share post

Comments 22

    +7
    Даешь больше инфы по node.js!
      0
      Как правильнее закэшировать скомпилированные шаблоны, чтобы лишний раз не нагружать сервер?
        0
        Как правильно я Вам не скажу. Но я перекладываю это но голову сервера занимающегося отдачей статики (у меня nginx), при запуске сервера, просто компилируем все шаблоны как статичные js файлы, и выкладываем в public, можно еще следить не меняются ли шаблоны и в случае изменения перегенерировать файлы.
          +2
          Насколько кошерно хранить их в памяти самого Node.js?
            0
            Все зависит от условий. Если шаблонов не много, то нет ничего плохого в хранении в памяти nodejs, только не забываем выставлять заголовки, что бы клиент лишний раз не бегал.
            0
            оба читаем матчасть en.wikipedia.org/wiki/HTTP_ETag
              0
              Тут имелось в виду, как закэшировать шаблоны внутри приложения. ETag вас не спасет в случае множества клиентов заходящих впервые.
              Про заголовки я намекнул комментарием выше.
                +2
                а зачем кешировать шаблоны внутри приложения? скомпилируйте их раз и отдавайте скомпилированные, файловая система сама разберется что ей чаще нужно и в свой кеш это вытянет, вы даже не заметите, что не с диска читаете :)
                • UFO just landed and posted this here
          0
          Вобщем вся статья — про то как настроить шаблонизатор, про навигацию — 10 строк кода и ни слова :) удовлетворительно

          Посмотрите на Бекбон, вам понравится, backbonejs.org/#Router
            0
            А что вы еще хотели бы узнать? С backbone знаком, но он мне не подходит, для большинства задач knockout подходит много лучше, и кода меньше.
              0
              да про навигацию много не нарассказываешь :) раз уж вы пользуетесь knockout сдлайте хотя бы обзорное сравнение knockout vs angular.js
            –1
            Что только люди не придумают, чтоб не использовать backbone или spine…
              0
              Зачем вам библиотека History если вы можете использовать пару строчек кода который добавить в историю новый адрес.
              try {
              	history.pushState({foo:'bar'}, 'Test', url);
              } catch(ex) {
              	location.hash = '#' + url;
              }
              

              И повесить обработчик на события beforeunload и unload
                0
                Зачем людям jquery?
                Наверное потому что библиотека будет работать везде одинаково в независимости от браузера, скрывая в своих внутренностях все баги и грабли разных браузеров.
                0
                Спасибо за статью про интересные технологии! Совсем как-то некрасиво сделана у вас отдача темплейтов клиенту. Понятно, что это девелопмент-версия и компиляция+конкатенация всего каждый раз не страшна при разработке, но для того, чтоб добавить один шаблон, придется каждый раз дописывать эту функцию. Гораздо удобнее было бы сделать отдачу jade с помощью middleware, это будет по-коннектовско-экспрессовски — можно просто настроить отдачу какого-то фолдера через jade.compile (можно оборачивать отдельные файлы в АМД), впрочем, я уверен, это можно спокойно найти готовое на гитхабе.
                  0
                  Вообще можно безболезненно экспортировать все шаблоны без префикса. По хорошему при запуске сервера, можно обходить все рекурсивно, компилировать в 1 js файл и выкладывать его в статик. Руками конечно делать не стоит, только как то трудно написать просто и понятный для всех пример и что бы все автоматически, кода тогда будет много, а основная идея где то потеряется.
                    0
                    Согласен )
                    С выкладыванием всего в один файл неплохо справляется browserify
                • UFO just landed and posted this here
                    0
                    Спасибо за позитив :D Намек понял.
                    0
                    Буду благодарен если кто-то напишет как поэтапно с нуля можно написать простенький сайт на jade. Ajax и прочие плюшки не обязательно, просто самые основы.
                      0
                      А чего вы конкретно хотите в этом примере? Если совсем просто, то достаточно примера

                    Only users with full accounts can post comments. Log in, please.