Введение
Всем привет! Не так давно я написал публикацию «Одностраничный магазин с корзиной на Phalcon + AngularJS + Zurb Foundation», которая имела неоднозначный эффект мягко говоря. А точнее получила много отрицательных комментариев, какие-то были объективные и конструктивные, какие-то нет, и они меня заставили задуматься, почему так произошло, ведь я хотел сделать полезный мануал, который пригодиться мне и другим, начинающим писать на AngularJS.
Исповедь
Да, мануал был полезен для меня, для меня старого, того, кому в 2009 году отказали в работе в местной веб-студии, и он по сей день ни разу ни работал в команде, ни разу не работал на наёмной работе, а полагался только на себя, и главным критерием эффективности реализации проектов был один — главное, что работает. Но это я — старый, после написания той статьи, и множества комментариев, я впервые решил попробовать сделать всё по правилам хорошего тона, хотя бы ради интереса.
Список литературы
Обычно список литературы приводится в конце, но с другой стороны, по ходу написания статьи, будут возникать вопросы, почему была выбрана именно такая реализация. И чтобы не оставлять вас без ответов на ваши вопросы, я буду в скобках [?] указывать на источник, откуда это было взято. Итак, вот и литература, на которую я опирался, исправляя свои ошибки.
- Комментарии к статье Одностраничный магазин с корзиной на Phalcon + AngularJS + Zurb Foundation
- Смелый стайлгайд по AngularJS для командной разработки [1/2]
- Смелый стайлгайд по AngularJS для командной разработки [2/2]
- angular js: ng-repeat no longer allowing duplicates
- Using Local Storage
- AngularJS $http not sending X-Requested-With header
- ngRoute preloader example
Работа над ошибками
По ходу реализации проекта одностраничного магазина по доставки еды были неумышленно допущены следующие недочёты:
- Выгрузка сразу всех товаров на одну страницу с помощью php
- Отсутствие единого стиля написания кода
- Использование логики в контроллерах AngularJS [1] by EugeneOZ
- Хранение корзины в объекте js, который после перезагрузки страницы сбрасывался [1] by hVostt
- Добавление в корзину через вставку инлайн PHP скриптов [1] от steppefox
- Отзывчивость интерфейса по ходу загрузки новых категорий
В этой статье я хочу поделиться как я искал решения, и что из этого получилось. А теперь пройдёмся по каждому пункту.
Выгрузка сразу всех товаров на одну страницу с помощью php
Товаров в магазине оказалось чуть больше, чем рассчитывалось, и учитывая наш камчатский интернет, мы посчитали:
100 товаров (изображений), каждая картинка 100-300 кб, в итоге только товары съедали драгоценный трафик наших клиентов в размере 10 мб, плюс почти 10 мб весил сам сайт (картинки, стили, скрипты). Сейчас же мне удалось оптимизировать всё, и сайт весит 3,9 мб во время первой загрузки, и не больше 1 мб в последующие, так как браузер кеширует всё что нужно.
Такая оптимизация была достигнута за счёт сжатия больших фоновых изображений без потери качества, а так же спрайтов для иконок и мелкой графики, так же были убраны блоки, которые не играли важной роли на странице (отзывы, партнёры, сертификаты, почему мы, открытая форма обратной связи заменена на jivosite). Всё таки скорость загрузки для сайта с едой, гораздо важнее наших лавр и наград, которые нафиг никому не нужны.
И конечно же оптимизация была за счёт отказа от рендеринга сразу всех товаров на страницу с помощью php, и заменена на ajax подгрузку json данных по клику на ссылку вида /#!/menu/7, да мне наконец довелось изучить как работает роутинг в angularjs, к стыду своему, до этого ни разу не работал с роутингом js.
И это оказалось не так сложно, как я ожидал, собственно у меня был всего один роут:
function config($routeProvider, $locationProvider) {
$routeProvider
.when('/menu/:id', {templateUrl: '/app/views/products.html', controller: ProductsController});
$locationProvider.hashPrefix('!');
$locationProvider.html5Mode(true);
}
angular.module('rollShop').config(config);
Он то и подгружал json данные с сервера и рендерил их в шаблоне:
<div class="large-12 columns" ng-view></div>
Вообще, я слышал что для индексации такого подхода сервер должен возвращать html вместо json, но тогда не понятно как избежать инлайн вставок PHP скриптов? Этот вопрос остался для меня загадкой. И этот подход породил ещё одну проблему, если пользователь перейдя по ссылке /#!/menu/7 нажмёт кнопку «Обновить страницу», то его глаза наполняться кровью от вида json данных он увидит вместо нормальной страницы просто json данные. И тут нам на подмогу приходит Phalcon PHP Framework, на самом деле можно было и без него, но так как я с ним работаю, то и делаю всё в его стиле. Чтобы исправить эту ошибку, я решил что буду отдавать данные json, только при ajax запросах, а при обычных запросах буду перенаправлять пользователя по ссылке /#!/menu/$id.
public function menuAction($id)
{
if($id != 'undefined')
{
if ($this->request->isAjax() == true) {
//Получаем основную категорию
$category = Category::findFirst($id);
//Проверяем есть ли подкатегории
$sub_category = Category::find("pid = '" . $id . "'");
if (count($sub_category) > 0) {
$products['category'] = $category->name;
foreach ($sub_category as $key => $val) {
$products['subcategory'][$key] = array(
'name' => $val->name,
'products' => Products::find("category = '" . $val->id . "'")->toArray()
);
}
} else {
$products = array(
'category' => $category->name,
'products' => Products::find("category = '" . $category->id . "'")->toArray()
);
}
$this->response->setContent(json_encode($products));
return $this->response->send();
} else {
$this->response->redirect('/#!/menu/' . $id);
return false;
}
}
else
{
return false;
}
}
Проверка на AJAX запрос в Phalcon, осуществляется с помощью условия:
if ($this->request->isAjax() == true) {
//This is Ajax
}
Но есть проблема, AngularJS по-умолчанию не отправляет специальный заголовок X-Requested-With серверу [6], а значит без помощи AngularJS, эта функция не будет работать, поэтому пришлось добавить одну строчку в конфиг ангуляра.
var app = angular.module('rollShop', ['ngRoute', 'mm.foundation'], function ($httpProvider) {
$httpProvider.defaults.headers.common["X-Requested-With"] = 'XMLHttpRequest';
}
Теперь всё работает, и пользователь никогда не увидит голый JSON.
Кстати, для индексации я подумал может сделать проверку на бота, и боту отдавать чистый html, без js кода, без кнопок, просто товары. Правда не знаю сработает это или правильно это, может кто подскажет в комментариях.
Добавление в корзину через вставку инлайн PHP скриптов
Благодаря исправлению предыдущего недочёта, автоматически был исправлен недочёт со вставкой инлайн PHP скриптов в функцию js. Давайте сравним как происходило добавление товара в корзину раньше, а как стало сейчас.
До (фу):
<div class="add-cart">
<input type="number" ng-model="num<?=$p['id']?> value="1" min="1" max="50">
<button type="button" ng-click="addCart(<?=$p['id']?>, num<?=$p['id']?>, '<?=$p['title']?>', <?=$p['price']?>)"></button>
</div>
$scope.addCart = function(id, num, title, price)
{
var nums = num || 1;
$scope.carts.push({
id : id,
num : nums,
title : title,
price : price
});
};
После:
<div class="add-cart">
<input type="number" ng-model="item.quality" min="1" max="50" placeholder="1">
<button type="button" ng-click="addCart(item)"></button>
</div>
$scope.addCart = function (item) {
CartsService.addCart(item);
};
Честно, мне такой подход гораздо больше нравится, хоть заказчик этого не оценит, и не заплатит больше, но как-то за себя стало гордо, и на душе приятно.
Отсутствие единого стиля написания кода
Пока исправлял предыдущие два недочёта, автоматически пришлось исправить ещё два — «использование логики в контроллерах», как видите теперь всё делает сервис, и «отсутствие единого стиля написания кода».
Это моя вечная проблема, но опять же если смотреть с точки зрения экономики, и подхода «главное — работает», то здесь всё достаточно эффективно, не заморачиваясь на стайлгайдах, я экономил кучу своего времени, а время, как известно — деньги. К слову бюджеты моих клиентов от 50 до 100 тыс. руб, и им всё равно как написан код. Но это всё оправдания, я ведь хочу исправить ситуацию, и сделать код красивым, читабельным и логичным, в чём мне помогли две статьи на Хабре [2] и [3].
В итоге у меня вышло 2 контроллера, 3 фабрики, 1 вид и один общий app.js. Выглядит это сейчас примерно так:
Собственно как и рекомендовалось делать в тех стайлгайдах. А теперь давайте посмотрим на контроллеры, и можете сравнить с прошлой статьей.
ProductsController.js
function ProductsController($scope, $routeParams, ProductsService, CartsService)
{
$scope.items = '';
ProductsService.getData($routeParams.id).success(function(data){
$scope.items = data;
});
$scope.addCart = function (item) {
CartsService.addCart(item);
};
}
angular.module('rollShop').controller('ProductsController', ProductsController);
CartsController.js
function CartController($scope, CartsService) {
$scope.carts = CartsService.getItemsCart();
$scope.total = function(){
return CartsService.summary($scope.delivery)
};
$scope.removeItem = function (carts, item) {
CartsService.removeItem(carts, item);
};
}
angular.module('rollShop').controller('CartController', CartController);
Всё круто, как мне кажется, даже самому больше нравится, чем то, что было раньше, точнее даже, что творилось раньше у меня в контроллерах.
Хранение корзины в объекте js, который после перезагрузки страницы сбрасывался
Следующий недочет, на который в первую очередь обратили внимание читатели, это хранение товаров корзины в объекте js. Почему я выбрал этот подход, я объяснил в прошлой статье, ведь магазин одностраничный. В любом случае, реализовать хранение в localStorage не составляет труда, да и думаю пригодиться в дальнейшем.
Я начал искать удобный модуль для AngularJS, который бы работал с localStorage, но в то же время он должен быть простым, и лёгким. Я нашёл несколько вариантов реализации, но они были сложные и большие на мой взгляд, ссылок сейчас к сожалению не помню, и потом наткнулся на тот вариант, что был как раз для меня [5].
angular.module('ionic.utils', [])
.factory('$localstorage', ['$window', function($window) {
return {
set: function(key, value) {
$window.localStorage[key] = value;
},
get: function(key, defaultValue) {
return $window.localStorage[key] || defaultValue;
},
setObject: function(key, value) {
$window.localStorage[key] = JSON.stringify(value);
},
getObject: function(key) {
return JSON.parse($window.localStorage[key] || '{}');
}
}
}]);
Правда использование его в таком виде не давало 100% исправную работу. При добавлении одного товара в корзину, всё было хорошо, но как только туда попадал второй другой товар или товар из другой категории, ng-repeat переставал выводить товары в корзине, а в консоли святилась ошибка:
Duplicates in a repeater are not allowed. Use 'track by' expression to specify unique keys.
Решение этой ошибки я нашёл в источнике [4]. Проблема была из-за добавления в массив $$hashKey, я так и не разобрался что это, откуда, и зачем, но мне нужно было избавиться от этого, чтобы всё работало, и конечный вариант фабрики работающей с localStorage выглядит так:
function LocalStorageFactory($window)
{
return {
set: function(key, value) {
$window.localStorage[key] = value;
},
get: function(key, defaultValue) {
return $window.localStorage[key] || defaultValue;
},
setObject: function(key, value) {
$window.localStorage[key] = JSON.stringify(value, function (key, val) {
if (key == '$$hashKey') {
return undefined;
}
return val;
});
},
getObject: function(key) {
return JSON.parse($window.localStorage[key] || '{}');
},
remove: function(key){
$window.localStorage.removeItem(key);
},
clear : function() {
$window.localStorage.clear();
}
}
}
angular.module('rollShop').factory('LocalStorageFactory', LocalStorageFactory);
Я думаю код небольшой, и найти 2 отличия будет не трудно, проверка на $$hashKey помогла мне исправить ошибка с дубликатами. Теперь сервис корзины работал исправно, и хорошо, запоминая позиции, пересчитывая сумму, и.т.д. Код привожу ниже:
function CartsService(LocalStorageFactory)
{
var CartsService = {};
var CartData;
if(LocalStorageFactory.getObject('carts').length > 0)
{
CartData = LocalStorageFactory.getObject('carts');
}
else
{
CartData = [];
}
CartsService.addCart = function (item)
{
CartData.push({
id: item.id,
num: item.quality || 1,
title: item.title,
price: item.price
});
CartsService.update();
};
CartsService.update = function()
{
if(LocalStorageFactory.getObject('carts').length > 0)
{
LocalStorageFactory.remove('carts');
}
LocalStorageFactory.setObject('carts',CartData);
};
CartsService.getItemsCart = function () {
return CartData;
};
CartsService.removeItem = function (items, id) {
items.splice(id, 1);
CartsService.update();
};
CartsService.summary = function(dispatch)
{
var total = 0;
var delivery = 0;
angular.forEach(CartsService.getItemsCart(), function (item) {
total += item.num * item.price;
});
//Тут был код высчитывающий стоимость доставки
return total + delivery;
};
return CartsService;
}
angular.module('rollShop').factory('CartsService', CartsService);
Не идеально, но всё же лучше, чем было.
Отзывчивость интерфейса по ходу загрузки новых категорий
JSON данные, приходящие с сервера весят не больше 10 кб, плюс время отдачи сервером, плюс время реакции js, в общем в случае великого камчатского интернета с огромнейшим пингом, есть смысл показать пользователю preloader при каждом запросе, да и это в любом случае хороший тон.
Можно конечно это отдать контроллеру, но хотелось более универсальное решение, и опять же при этом, простое и лёгкое. Сначала я думал использовать плагин nProgress lite для AngularJS, но всё же решил сделать ещё проще, и нашёл пример [7].
function run($rootScope, $timeout) {
$rootScope.layout = {};
$rootScope.layout.loading = false;
$rootScope.$on('$routeChangeStart', function () {
$timeout(function(){
$rootScope.layout.loading = true;
});
});
$rootScope.$on('$routeChangeSuccess', function () {
$timeout(function(){
$rootScope.layout.loading = false;
}, 500);
});
$rootScope.$on('$routeChangeError', function () {
$timeout(function(){
$rootScope.layout.loading = false;
}, 500);
});
}
angular.module('rollShop').run(run);
Теперь когда наступает одно из событий (думаю по названию события всё понятно), показывается / скрывается прелоадер.
<div class="large-12 columns" ng-hide="!layout.loading">
<!-- Можно текст, можно gif анимацию, можно на весь экран, в общем что хотите -->
</div>
Теперь клиент не будет кликать по 5 раз на категорию, не понимая грузиться она или нет.
Заключение
Я постарался исправить недочёты допущенные во время разработки, но основная цель была приучить себя к грамотному использованию технологий, даже не смотря на отсутствие команды. Честно признаюсь, я получил массу удовольствия, хоть и потратил не лишнее рабочее время, и не получил за это какой-либо компенсации. Для себя я заключил, что все последующие проекты я буду делать уже с более грамотным подходом, потому что такой подход заставляет ценить не только деньги полученные за проект, но и знания и опыт полученные во время работы над ним. Опыт и знания дают гордость за своё дело, повышают качество работы, любовь к тому что делаешь. А всё это не оставит тебя без денег в кармане.
Автор: chuikoffru