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

в 14:27, , рубрики: expressjs, history api, jade, javascript, node.js, nodejs, Веб-разработка, метки: , , ,

Мне ранее не доводилось использовать в своей работе такую возможность 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

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

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

Автор: zxcabs

Источник


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


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