В первой части мы перебрали все основные проблемы перехода на новую версию, а в этой мы коснёмся того, ради чего мы это делали.
Как говорилось ранее, главной причиной перехода может служить существенное увеличение в скорости работы приложения: в 4.3 раза более быстрые манипуляции с DOM и в 3.5 раза более быстрые циклы $digest
(по сравнению с 1.2), как заявили Джеф Кросс и Бриан Форд на конференции ngEurope.
Однако скорость эта по большей части приобретается не за счёт внутренних оптимизаций и магии, а за счёт предоставления инструментов, которые позволяют писать код более эффективно.
Давайте же рассмотрим эти инструменты!
Debug Info
Не новость, что ангуляр тратит значительную часть ресурсов на информацию, облегчающую дебаг, такую как добавление классов
элементам DOM (например, классов ng-binding
и ng-isolated-scope
) или прикрепление к ним различных методов для доступа к scope
(например, .scope()
и .isolateScope()
).
Всё это полезно и необходимо для работы таких инструментов, как Protractor и Batarang, однако нужны ли эти данные на продакшене?
Начиная с версии 1.3, debug info можно отключить:
app.config(['$compileProvider', function ($compileProvider) { $compileProvider.debugInfoEnabled(false); }]);
Но что делать, если нам необходимо продебажить продакшн, а дебаг отключён?
Здесь нас спасёт метод .reloadWithDebugInfo()
объекта angular
, а поскольку объект angular
глобальный, то мы можем выполнить этот код просто из консоли:
angular.reloadWithDebugInfo();
$applyAsync
С версией 1.3 пришёл сервис $applyAsync
, во многом схожий по своей механике с уже существующим сервисом $evalAsync
:
Упрощённо говоря, он добавляет выражение в очередь, затем ожидает (выставляет setTimeout(…, 0), в современных браузерах это около 10 милисекунд), и если за прошедшее время в очередь не добавлено ещё одно выражение – запускает
$rootScope.$digest()
.
Это позволяет запускать множество параллельных выражений, влияющих на DOM, будучи уверенным в том, что они запустятся в один цикл $digest и не беспокоясь о слишком частом вызове $apply.
В чём отличие от $evalAsync?
Главное отличие в том, что $applyAsync
сам выполняет всю очередь выражений в начале цикла $digest
, перед dirty checking, что позволяет выполнить его только один раз, в то время как очередь $evalAsync
выполняется во время грязной проверки (если точнее, то в самом начале цикла грязной проверки), и любое добавленное в очередь выражение (вне $watch) запустит цикл $digest ещё раз, что приведёт к повторному выполнению выражения в этом же $digest
чуть позже.
Однако истинную пользу от использования этого инструмента можно увидеть во внутренних сервисах ангуляра, например, в $httpProvider
.
$http
Главное отличие сервиса $http
от иных способов совершить XHR запрос – это вызов $apply
по его завершению.
Проблема заключается в множестве паралельных запросов, каждый из которых вызывает по своему завершению $apply
, что приводит к тормозам.
Проблема решена с приходом $applyAsync
в версии 1.3:
app.config(function ($httpProvider) { $httpProvider.useApplyAsync(true); });
Данный код включает использование очереди $applyAsync
внутри $httpProvider
.
Это позволяет запускать $apply лишь один раз, когда все промисы одновременных запросов будут resolved
, что даёт значительный прирост производительности.
Bind Once
Одна из главных проблем с производительностью в AngularJS заключается в огромном количестве $watch
‘еров из-за того, что ко всем выражениям применяется двустороннее связывание, но не все данные этого требуют.
Для статических данных достаточно одностороннего связывания, без навешивания вотчера, и раньше это решалось кастомными директивами, но всё изменилось в версии 1.3.
Начиная с версии 1.3, доступен новый синтаксис в виде ::
в начале выражения.
Любое выражение, начинающиеся с ::
, будет воспринято как одностороннее связывание и перестанет отслеживаться (unwatch), как только данные в выражении станут стабильными и пройдёт первый цикл $digest
.
Например:
{{:: foo }} <button ng-bind=":: foo"></button> <ul> <li ng-repeat=":: foo in bar"></li> </ul> <custom-directive two-way-bind-property=":: foo"><custom-directive>
Стабильность данных:
Данные считаются нестабильными до тех пор, пока они равны undefined
. Любые другие данные, будь то NaN
, false
, ''
, []
или null
, считаются стабильными и приведут к unwatch
выражения.
Это необходимо для статичных данных, которые недоступны во время первого цикла $digest.
Например, если данные приходят с сервера, то можно просто держать в переменной значение undefined
во время ожидания запроса. Всё это время вотчер этого выражения будет жить и только после установки данных, отличных от undefined
, отдаст концы.
Относитесь к выражениям с ::
как к константе: как только она установлена, её уже невозможно изменить.
Обновить необновляемое:
Допустим, у нас есть выражение, которое не обновляется в 99 случаев из 100 (т.е. нам нафиг не впал на неё вотчер), но иногда это требуется. Как быть? Я задался вопросом, можно ли силой обновить bind-once выражение.
Нет, нельзя 🙂 Однако можно написать свою директиву-атрибут, которая заставит сделать re-compile всю директиву в ответ на некие события. Пример доступен здесь.
На этом часть о производительности заканчивается, и можно поговорить о просто приятных плюшках:
ngModel Options
Версия 1.3 подарила в дополнение к ng-model
вспомогательную директиву ng-model-options
, которая отвечает за то, когда модель будет обновлена.
Время обновления зависит от двух факторов:
1) updateOn
– специальные события (events), по которым происходит обновление модели, например, это может быть blur
, click
или какой-то кастомный ивент. По умолчанию всегда стоит default
, означающий, что каждый контрол будет использовать своё собственное событие. Если вы хотите расширить стандартное событие, добавив своё, не забудьте добавить в список default
, например: {event: "default customEvent"}
.
2) debounce
– задержка при обновлении модели в ожидании новых данных (по умолчанию 0, т.е. мгновенно). Если указать инпуту {debounce: 300}
и ввести 3 символа с промежутком менее 300 миллисекунд, ваша модель (а значит и различные модификаторы/валидаторы) обновится лишь один раз. Кроме того, debounce
можно комбинировать с событиями, указывая свою задержку для каждого из них, например: {event: "default customEvent", debounce: {default: 0, customEvent: 400}}
.
Это позволяет нам избавиться от множества велосипедов (прощайте, setTimeout/clearTimeout) и существенно повышает производительность (ведь мы избавляемся от бесполезного перезапуска $digest
, а соответственно и всех $watchers
), а также уменьшает количество ложных срабатываний для асинхронной валидации (впрочем, $http
сервис достаточно умный, чтобы не спамить запросами, а подождать стабильных данных).
Но есть ещё три полезные опции
Флаг allowInvalid позволит установить $modelValue
, даже если значение является невалидным для него (по умолчанию пока значение является невалидным, в модель записывается undefined
, что не позволяет, например, узнать промежуточное значение)
Флаг setterGetter позволит установить в качестве ngModel
свою собственную функцию, своеобразного посредника между ngModel.modelValue
и ngModel.viewValue
выполняющего роль сеттера и геттера. Живой пример на plunker.
timezone позволяет установить часовой пояс для контролов, связанных со временем (date
или time
), например '+0430'
будет означать ‘4 часа 30 минут GTM’. По умолчанию берётся часовой пояс браузера.
Игнорируя updateOn и debounce
Иногда при ручной записи модели необходимо проигнорировать установленную в ивентах задержку и выполнить обновление мгновенно. Для этого существует метод ngModelCtrl.$commitViewValue()
.
Отмена изменения
Если необходимо отменить все изменения и висящие в процессе debounce
, существует метод $rollbackViewValue()
(бывший $cancelUpdate()
), который подгоняет вьюху к актуальному состоянию модели.
В качестве примера использования такой возможности официальная документация предлагает инпат, изменения в котором можно откатить по нажатию ESC.
Валидация
Одно из главных улучшений в плане удобства работы коснулось валидации форм.
Ранее приходилось реализовывать механизм валидации через ndModel.$formatters
и ndModel.$parsers
, влияя на результат валидации напрямую, через ndModel.$setValidity()
, а реализация асинхронных проверок доставляла отдельную радость.
Нововведение отразилось и на производительности:
Ведь ранее валидирующие функции запускались при каждом обновлении в DOM ($parsers
) или модели ($formatters
), часто влияя на значения друг друга, тем самым перезапуская цикл проверок заново.
В новой версии валидация запускается, только если изменена модель и только если в этой модели нет ошибки ({parse: true
). Влияние на саму модель или представление контрола внутри валидатора тоже исключается, что положительно влияет на скорость работы приложения.
Нет, их не удалили, это другие инструменты для других вещей, и они по-прежнему необходимы.
И $formatters
, и $parsers
– это массивы, содержащие функциии-обработчики, которые принимают значение и передают его по цепочке следующей функции-обработчику. Каждое звено цепочки может модифицировать значение перед тем, как передать его далее.
$formatters
Каждый раз, когда изменяется модель, $formatters
перебирает обработчики в массиве в обратном порядке, от конца к началу. Последнее переданное значение отвечает за то, как будет представлена модель в DOM. Иными словами, $formatters
отвечает за то, как будет конвертирован ngModelCtrl.$modelValue
в ngModelCtrl.$viewValue
.
$parsers
Каждый раз, когда контрол читает значение из DOM, $parsers
перебирает массив обработчиков в нормальном порядке, от начала к концу, передавая значение по цепочке. Последнее переданное значение отвечает за то, как будет представлено значение в модели. Иными словами, $parsers
отвечает за то, как будет конвертирован ngModelCtrl.$viewValue
в ngModelCtrl.$modelValue
.
Где используется?
Прежде всего, их использует в своей работе сам ангуляр. Например, если вы создадите контрол с валидацией на минимальную длину, а затем проверите массив $formatters
, то заметите, что тот не пуст, а уже содержит одну функцию-обраточик, конвертирующую значение из DOM в строку.
Пример выше – это использование обработчиков для предварительной обработки (sanitize) значения, если мы хотим быть уверены, что оно попадёт в модель только в строго заданном виде.
Другие два популярных способа использования это двусторонняя фильтрация значений (например, когда пользователь вводит в инпут "10, 000", а в модели хранится "10000" и наоборот) и создание масок (например, когда пользователь должен заполнить маску телефона "+7 (000) 000-00-00", а в модели мы будем хранить "70000000000").
Как видно из примеров – инструмент незаменимый.
Исчерпывающую информацию о старых методах валидации можно получить по данным статьям на Хабре:
Дело в том, что возращение undefined
из ndModel.$parsers
теперь приводит к выставлению ngModelCtrl.$modelValue
в undefined
и добавлению в ndModel.$errors
значения {parse: false}
, т.е. инвалидации поля.
В этом случае валидаторы ($validators
и $asyncValidators
) даже не начинают своей работы.
Это поведение можно отключить в ngModelOptions
, выставив флаг allowInvalid
в true
.
Начиная с версии 1.3 у нас появились инструменты для удобной синхронной и асинхронной проверки.
Также стоит упомянуть, что раз работа новых валидаторов завязана на обновлении модели, то она зависит и от ngModelOptions
, о которых говорилось выше.
Синхронная
Для синхронной валидации новая версия предлагает нам коллекцию ndModel.$validators
, расширяя её функцией-валидатором.
Функция-валидатор должна возращать true
или false
, для валидных и невалидных значений соответственно.
Пример:
ngModel.$validators.integer = function(modelValue, viewValue) { // Определяем, что пустая модель является валидной if (ctrl.$isEmpty(modelValue)) { return true; } if (INTEGER_REGEXP.test(viewValue)) { return true; // Поле валидно } return false; // Поле не валидно };
Асинхронная
Для асинхронной валидации используется коллекция ngModelCtrl.$asyncValidators
, с той же логикой расширяя её функцией-валидатором.
Основные отличия работы асинхронной версии:
- Асинхронная валидация запускается только если все синхронные валидации оказались валидными
- Функция-валидатор должна возвращать только промис, осуществляя его
resolve()
илиreject()
, для валидных и невалидных значений соответственно.
Промисы в AngularJS порождаются специальным сервисом $q
, а также сервисами вроде $timeout
и $http
, которые в своей основе тоже используют $q
.
В коллекции хранится не более одного промиса возращённого одним валидатором. Это значит, что если вызвать валидацию несколько раз, то будет учитываться только промис из последнего вызова валидатора, независимо от результата работы предыдущих промисов.
С момента передачи функцией-валидатором промиса, и до его разрешения (resolve()
или reject()
) поле ngModelCtrl.$pending
хранит имя валидатора, а ngModelCtrl.$valid
и ngModelCtrl.$invalid
равны undefined
Будьте осторожны с данной особенностью: до тех пор пока идёт валидация форма будет являться не валидной, при этом вы не увидите никаких ошибок в FormCtrl.$errors
, но можете увидеть "ожидающие" валидаторы в FormCtrl.$pending
.
ngModelCtrl.$asyncValidators.username = function(modelValue, viewValue) { // Определяем, что пустая модель является валидной if (ctrl.$isEmpty(modelValue)) { return $q.when(); } var def = $q.defer(); // Эмулируем асинхронный запрос $timeout(function() { if (usernames.indexOf(modelValue) === -1) { def.resolve(); // Имя доступно, поле валидно } else { def.reject(); // Имя занято, поле не валидно } }, 2000); return def.promise; }; ngModelCtrl.$asyncValidators.uniqueUsername = function(modelValue, viewValue) { var value = modelValue || viewValue; // Проверяем, существует ли имя пользователя return $http.get('/api/users/' + value). then(function resolved() { // Пользователь найден, значит имя занято, а поле не валидно return $q.reject('exists'); }, function rejected() { // Пользователь не найден, имя доступно, а поле валидно return true; }); };
Стоит аккуратно использовать асинхронную валидацию с полями, которые используют модификаторы значения (через $formatters
и $parsers
): это может вызывать множественные срабатывания, а также ложную валидацию или инвалидацию поля.
var pendingPromise; ngModelCtrl.$asyncValidators.checkPhoneUnique = function (modelValue) { if (pendingPromise) { return pendingPromise; } var deferred = $q.defer(); if (modelValue) { pendingPromise = deferred.promise; $http.post('/запрос', {value: modelValue}) .success(function (response) { if (response.Result === 'Хитрое условие с сервера') { deferred.resolve(); } else { deferred.reject(); } }).error(function () { deferred.reject(); }).finally(function () { pendingPromise = null; }); } else { deferred.resolve(); } return deferred.promise; };
ngMessages
Модуль ngMessages
призван облегчить показ сообщений на странице.
Не смотря на то, что этот модуль можно удобно использовать для любых сообщений на странице, обычно его используют для показа ошибок в формах. Чтобы понять, какие проблемы решает этот инструмент, давайте посмотрим, какую боль доставлял старый способ отображения ошибок, и сравним его с новым.
Для примера сравнения будем использовать данную форму добавления комментария:
<form name="commentForm"> <ul class="warnings" ng-if="commentForm.$error && commentForm.$dirty"> ... </ul> <label>Имя:</label> <input type="text" name="username" ng-model="comment.username" required> <label>Комментарий:</label> <textarea name="message" ng-model="comment.message" minlength="5" maxlength="500"></textarea> </form>
Оставим за бортом реализацию непосредственно валидации и рассмотрим только отображение сообщений о необходимых условиях:
- Поле "Имя" не должно быть пустым
- Поле "Сообщение" не должно быть менее 5 символов
- Поле "Сообщение" не должно быть более 500 символов
Ошибки будем отображать в блоке.errors
.
Старые методы показа ошибок
Теперь представим, как нам отобразить ошибки для этих полей:
<form name="commentForm"> <ul class="warnings" ng-if="commentForm.$error && commentForm.$dirty"> <span ng-if="commentForm.message.$error.minlength"> Сообщение не может быть менее 5 символов </span> <span ng-if="commentForm.username.$error.maxlength"> Сообщение не может быть более 500 символов </span> <span ng-if="commentForm.username.$error.required"> Это обязательное поле </span> </ul> <label>Имя:</label> ... <label>Комментарий:</label> ... </form>
Что ж, пока всё выглядит не так уж и плохо, да?
Но почему сообщения о никнейме и комментарии показываются все разом? Давайте исправим это, добавив последовательное отображение:
<form name="commentForm"> <ul class="warnings" ng-if="commentForm.$error && commentForm.$dirty"> <span ng-if="commentForm.message.$error.minlength && commentForm.username.$valid"> Сообщение не может быть менее 5 символов </span> ... </ul> <label>Имя:</label> ... <label>Комментарий:</label> ... </form>
Вы всё ещё думаете: «это не так плохо»? Представьте, что полей на странице не 2, а 20, и у каждого минимум 5 сообщений. В подобном стиле наша страница быстро превратиться в мусорку из условий.
Конечно, есть лучшие практики для реализации данной задачи, специальные директивы и костыли, расширяющие поведение FormController
(например, в нашем проекте все контролы расширялись свойством showError
, которое хранило текущую ошибку), но все они проигрывают в удобстве методам, о которых мы будем говорить далее.
Новые методы показа ошибок
ngMesssages
поставляется отдельным модулем, и перед тем как начать работать с ним, нам надо его подключить. Скачайте или установите модуль через пакетный менеджер и подключите к проекту:
<script src="path/to/angular-messages.js"></script>
И добавим в зависимости:
angular.module('myApp', ['ngMessages']);
Базовая работа с данным модулем сводится к работе с двумя директивами:
ng-messages
– контейнер, содержащий наши сообщенияng-message
– непосредственно сообщение
ng-messages
принимает в качестве аргумента коллекцию, по ключам которой будут сверяться и показываться уже ng-message
, которые принимают в качестве аргумента для сравнения строку или выражение (начиная с 1.4).
Давайте повторим пример всё той же формы добавления комментария, но уже с помощью ngMessages
:
<div ng-messages="commentForm.message.$error" class="warnings"> <p ng-message="minlength"> Сообщение не может быть менее 5 символов </p> <p ng-message="maxlength"> Сообщение не может быть более 500 символов </p> <p ng-message="required"> Это обязательное поле </p> </div>
ngMessages
можно использовать и в качестве элемента:
<ng-messages for="commentForm.message.$error" class="warnings"> <ng-message when="minlength"> Сообщение не может быть менее 5 символов </ng> <ng-message when="maxlength"> Сообщение не может быть более 500 символов </ng> <ng-message when="required"> Это обязательное поле </ng> </ng-messages>
И так, сходу ngMessages
решили для нас две проблемы:
- Лапша из условий превратилась в удобочитаемый
switch
подобный список - Проблема с выводом сообщений по одному решилась сама собой
Кроме того, решается и проблема приоритезации вывода сообщений. Здесь всё просто: сообщения показываются согласно их расположению в DOM.
Вывод множественных сообщений можно включить добавлением атрибута ng-messages-multiple
к директиве ng-messages
:
<ng-messages ng-messages-multiple for="commentForm.message.$error"> ... </ng-messages>
Повторное использование сообщений – это ещё одна важная особенность ngMessages
, которая позволяет подключать наши сообщения, подобно шаблонам, в любом необходимом месте:
Версия 1.3 использует ng-messages-include
в качестве дополнительного атрибута ng-messages
, в то время как в более поздних версиях ng-messages-include
это самодостаточная дочерняя директива наряду с ng-message
:
1.3
<ng-messages ng-messages-include="length-message" for="commentForm.message.$error"> </ng-messages>
1.4+
<ng-messages for="commentForm.message.$error"> <ng-messages-include="length-message"></ng-messages-include> </ng-messages>
Это позволяет нам вынести часто повторяющиеся сообщения (например, сообщения о длине поля) в отдельный шаблон, который мы будем включать в нужных местах без необходимости копипасты:
<script type="script/ng-template" id="length-message"> <ng-message when="minlength"> Значение поля слишком короткое </ng-message> </script> ... <ng-messages for="commentForm.message.$error"> <ng-messages-include="length-message"></ng-messages-include> </ng-messages> <ng-messages for="anotherForm.someField.$error"> <ng-messages-include="length-message"></ng-messages-include> </ng-messages>
Пример с длиной может казаться натянутым, но хэй, таких сообщений может быть десятки и даже сотни. В таком случае хорошей практикой в данном случае было бы вынесение всех использованных на проекте сообщений в одно место для дальнейшего их повторного использования.
Мы не ограничены статическим вызовом шаблонов, и с версии 1.4 нам доступны динамические сообщения, которые возможно создавать через директиву ng-message-exp
:
// error = {type: required, message: 'Поле не должно быть пустым'}; <ng-messages for="commentForm.message.$error"> <ng-message-exp="error.type"> {{ error.message }} </ng-message-exp> </ng-messages>
ng-message-exp
, в отличие от ng-message
, принимает в качестве аргумента выражение (expression
). Это позволяет нам, например, показывать сообщения, которые генерирует нам сервер в AJAX запросе.
Динамический вывод сообщений – это мощнейший инструмент. Ведь он позволяет нам генерировать любые типы ошибок с любым содержанием текста без привязки к статическому представлению или привязанной к ng-messages
коллекции!
Что мы имеем в итоге:
- Сформированный список без лапши из условий
- Приоритезированный вывод сообщений по одному прямо из коробки
- Повторное использование сообщений на проекте
- Удобный инструмент создания динамических сообщений
- Срочную необходимость бросать всё и переносить свой старый механизм показа ошибок на
ng-messages
Контроллеры
bindToController
Каждый, кто любит использовать controller as
синтаксис, знает боль использования его для директив с изолированным scope
.
Проблема заключается в двустороннем связывании свойства, указанного через this.something
внутри контроллера. Любые изменения значения извне ни к чему не приведут.
Например:
app.directive('someDirective', function () { return { scope: { name: '=' }, controller: function () { this.name = 'Foo' }, controllerAs: 'ctrl' ... }; });
Любые изменения name
из контроллеров выше ни к чему не приведут.
Это можно решить через жопу вотчер:
$scope.$watch('name', function (newValue) { this.name = newValue; }.bind(this));
Но это неудобно, костыльно, и что делать, если в скоупе не одно свойство, а пятьдесят?
Решение пришло с версией 1.3:
Встречайте свойство bindToController
.
app.directive('someDirective', function () { return { scope: { name: '=' }, controller: function () { this.name = 'Foo' }, bindToController: true, ... }; });
Теперь ctrl.name
связано с $scope.name
и будет изменяться вместе с ним.
С версией 1.4 был добавлен ещё более удобный синтаксис:
app.directive('someDirective', function () { return { scope: true, bindToController: { name: '=' }, controller: function () { this.name = 'Foo' }, ... }; });
Теперь можно передавать объект определения scope
прямо в bindToController
.
Всё, что передано в bindToController
, будет привязано к контроллеру, а всё, что передано в scope
, будет привязано к scope
соответственно.
При этом указывать что-то для scope
вовсе не обязательно, хватит true
. В этом случае всё указанное в bindToController
привяжется и для scope
.
Фильтры
{{ expression | filter }}
Динамические фильтры
Здесь речь пойдёт о фильтрах, преимущественно завязанных на данных, приходящих «извне» и не зависящих от выражения (expression
), к которому привязан фильтр (filter
). Это может быть, например, фильтр, который использует внутри себя некий сервис.
В версии до 1.3 фильтры были довольно глупыми: навешивая на выражение вотчер, они постоянно заново вычисляли результат этого фильтра. Это одна из причин, почему не стоит использовать множество фильтров в одном месте, нагружая лишней работой цикл $digest
, и одна из причин, почему новые фильтры работают быстрее. В новой версии фильтры ведут себя куда умнее и не вычисляют значение фильтра до тех пор, пока не изменится выражение, к которому привязан данный фильтр.
Однако возникает проблема: как обновить значение фильтра, если оно зависит не только от выражения, но и от каких-то внешних факторов? Иными словами, как вернуть фильтру своё старое, «глупое» поведение, когда нам это нужно?
Для этого в 1.3 были введены понятия статичного (stateless
) и динамического (stateful
) фильтров. По умолчанию фильтр ведёт себя как stateless
. Сменить его поведение можно, выставив нашему фильтру флаг $stateful
в true
.
Пример:
angular.module('myApp', []) .filter('customFilter', ['someService', function (someService) { function customFilter(input) { // манипуляция данными сторонним сервисом someService input += someService.getData(); return input; } customFilter.$stateful = true; return customFilter; }]);
Breaking change:
Внимательный читатель мог заметить, что такое изменение по сути может сломать поведение старых динамических фильтров. К сожалению, я забыл описать эту особенность в первой части, но такую возможность стоит учесть.
Фильтр dateFilter
В данный фильтр теперь добавлена поддержка недель как формата weeks
Заключение
На этом всё. Если я забыл какие-то важные особенности новых версий или знаете ресурсы с более полным описанием оных, поправляйте меня в комментариях.
От себя добавлю данный блог, с достаточно полным описанием всех основных фич новых версий ангуляра.
За то, что наконец сделали markdown. Изначально статья была написана именно в этой разметке и провалялась почти пол года из-за моей лени и не желания переводить всё в html.
А это котик, для тех, кто дочитал до конца 🙂
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
ссылка на оригинал статьи https://habrahabr.ru/post/281721/
Добавить комментарий