Noscript документация одной страницей

Build Status NPM version Dependency Status

Table of Contents

Философия работы noscript#

В первую очередь, noscript — фреймворк для построения одностраничного приложения со множеством виртуальных страниц. Другими словами, для легкого создания полноценного сайта, работающего исключительно в браузере. Исходя из этого, некоторые концепции отличаются от популярных mvc-фреймворков.

Рассмотрим, на примере приложения todo, как работает noscript. Приложение имеет 3 страницы: дело (todo), список дел (todo-list), список списков дел (todo-lists).

Определяющими понятиями для страницы являются ее название и параметры (например, id списка дел или дела). Эти параметры можно составлять вручную или преобразовывать из адреса страницы.

Двухсторонним преобразователем адреса страницы в параметры является ns.router. Подробнее про него можно прочитать тут.

Теперь надо описать из каких видов будет состоять каждая страница. Для этого служит модуль ns.layout. Он сопоставляет название страницы (по сути, название layout) с деревом видов, которые должны быть на странице.

ns.layout('todo-lists', {
  'app': {
    'todo-lists': {}
  }
});

Таким образом мы описали, что на странице есть вид app, внутри которого есть todo-lists. К виду todo-lists привязан модель todo-lists.

Клеем между видами и layout является ns.Update. Это «обновлятор» страницы. Он умеет ходить по дереву видов, запрашивать им нужные модели и перерисовывать страницу.

Теперь опишем вторую страницу.

ns.layout('todo-list', {
  'app': {
    'todo-list': {}
  }
});

К виду todo-list привязана модель todo-list. У модели есть параметр lid (list id). Но данный пример будет работать неправильно при переключении списка. Почему так происходит?

Во-первых, в отличие от многих фреймворков, в noscript вид жестко привязывает себя к своему экземпляру модели при инициализации.

Как это происходит? У модели todo-list есть параметр lid. При создании экземпляра ей генерируется ключ, который однозначно идентифицирует экземпляр. noscript делает так, что не может быть двух экземпляров с одним ключом. Ключ выглядит следующим образом model=todo-list&list-id=1.

Параметры модели (если не указано другое) копируются в параметры вида. Вид тоже генерирует себе ключ view=todo-list&list-id=1. Таким образом, чтобы показать список дел с list-id=2 noscript должен создать другой вид с другим ключом.

Во-вторых, виды жестко привязывают список вложенных видов с ключами. В этом «виноваты» и сам вид и ns.Update. Когда ns.Update проходится по дереву, то всем видам передается layout и каждый вид создает себе своих детей. Это операция происходит единственный раз во время инициализации вида. Получается, что, переключая параметры и не меняя название страницы, ns.Update ничего не сделает, потому что каждый вид останется на месте и не изменится.

А почему бы не пересчитывать детей для каждого вида? Тогда бы все уже работало! Это хорошая идея и ее можно сделать, но у нее есть один большой недостаток. Допустим был один ребенок, а стало два. Вид не может понять куда надо вставить новые виды. А с учетом того, что вид имеет собственную разметку и детей, он может располагаться где угодно, проблема становится нерешаемой.

Это и есть главные отличия noscript, ведь в большинстве других фреймворков вид всегда один и при изменении параметров у него заменятся модель, что приводит к перерисовке. А проблему излишних обновлений решают через data-binding или другие инструменты. В noscript же для переключения между двумя списками достаточно скрыть один вид и показать другой.

Но, чтобы произошла эта магия, надо немного изменить layout.

ns.layout('todo-list', {
  'app': {
    'todo-list-box@': {
      'todo-list': {}
    }
  }
});

Как видно, мы добавили еще один вид todo-list-box@, но на самом деле видом он не является и декларировать его не надо. @ в конце названия означает, что здесь будет box. Box - это специальный модуль noscript, который может скрывать и показывать нужные виды внутри себя. По сути, это точка изменения страницы.

Как он работает? Допустим у нас есть страница с названием todo-list и параметрами list-id=1.

todo-list-box создаст вид todo-list с ключом view=todo-list&list-id=1. При переходе на страницу с параметром list-id=1, box создаст вид с ключом view=todo-list&list-id=2. После этого он увидит, что у него уже есть видимый старый вид и скроет его. А вместо него покажет новый вид. У box может быть много внутренних видов и не обязательно, чтобы они все скрывались или показывались. Какие-то могут быть открыты постоянно и box будет сам следить за этим.

Теперь можно заметить, что переключение между списками дел работает, но переключится на список списков или дел нельзя. Чтобы это заработало нужно сам app тоже сделать box'ом. Получаются вот такие layout.

ns.layout('todo-lists', {
  'app@': {
    'todo-lists': {}
  }
});


ns.layout('todo-list', {
  'app@': {
    'todo-list': {}
  }
});


ns.layout('todo', {
  'app@': {
    'todo': {}
  }
});

Box app будет переключать нужные виды в зависимости от параметров и все будет работать правильно.

Ключевые сущности#

Страница#

Noscript служит для создания одностраничных приложений. Поэтому Страница в контексте noscript - это то же самое, что и приложение. В приложении может быть неограниченное количество логических страниц, но все они будут показываться в рамках одного физического html-документа. Он и есть Страница. В noscript страница представлена объектом ns.page.

Адрес страницы#

Основное состояние страницы определяется Адресом страницы (url). Он определяет то состояние, которое должно быть показано при загрузке/перезагрузке страницы. Основное состояние может определяться следующими атрибутами: - идентификатор логической страницы (раздел сайта) - идентификатор сущности, оторбражаемой в приложении (id фотки, id файла) - атрибут состояния интерфейса, который хочется иметь возможность задавать извне (идентификатор открытого диалога) Адрес страницы служит её внешним API.

Параметры страницы#

Параметры страницы (ns.page.params) - это параметры, получаемые из адреса страницы. ЧПУ преобразуется в объект, с которым в дальнейшем работают сущности приложения.

Маршрутизатор#

Для преобразования адреса в параметры используется маршрутизатор (ns.router). Кроме параметров маршрутизатор так же возвращает идентификатор раскладки страницы (ns.layout).

Модель#

Модель - это элемент данных. Все данные, которые говорит и показывает интерфейс, должны быть представлены моделями. Модель может быть как клиентским представлением данных на сервере, так и локальным элементом данных, относящихся только к интерфейсу (модели состояний интерфейса).

Для работы с данными сначала декларируются прототипы моделей (ns.Model.define). Затем создаются конкретные экземпляры модели. Уникальным идентификатором экземпляра модели является свойство key. Свойство id у экземпляра модели указывает на прототип модели.

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

Вид#

Вид - это элемент интерфейса, из видов состоит страница. Вид может отображать статическое содержимое, значения параметров страницы, или данные моделей. Вид состоит из декларации и шаблона. Декларация содержит - id вида, определяющий прототип - модели, от которых зависит вид - параметры страницы, от которых зависит вид.

Экземпляры видов идентифицируются атрибутом key. Ключ вида строится на основании параметров моделей, от которых зависит вид, и параметров, указанных в декларации вида.

Экземпляры видов создаются только автоматически в результате работы контроллера обновления (ns.Updater) Виды могут быть вложенны друг в друга. На странице всегда существует корневой вид, внутри которого находятся остальные виды приложения. Существуют специальные виды: бокс, асинхронный вид, вид-коллекция. Шаблон вида может иметь различную структуру в зависимости от используемого шаблонизатора. В комплекте с noscript идёт набор .yate шаблонов, задающих определённую структуру. Так же есть ряд нюансов, которые нужно учитывать при написании собственных шаблонов.

Раскладка страницы#

Раскладка страницы (layout) - это декларация, по которой в зависимости от параметров определяется структура видов. Приложение может иметь несколько раскладок. Раскладка выбирается по идентификатору раскладки, который возвращается маршрутизатором. Раскладка представляет собой древовидный json-объект. Каждый узел дерева соответствует виду. В ключе объекта - идентификатор прототипа вида. В значении объекта - вложенные виды. Единственный вложенный вид может быть задан строкой. Более сложная структура может быть задана объектом. Структура, зависящая от каких-то условий может быть задана функцией, которая возвращает одну из перечисленных структур. Чтобы определить вид без вложенностей, значение нужно установить в true.

Контроллер обновления#

Контроллер обновления (ns.Updater) - объект, реализующий логику построения и обновления страницы.

Инициализация приложения#

При использовании конфигурации по умолчанию вся инициализация сводится к вызову функции ns.init и запуску первого апдейта:

$(function() {
    ns.init();
    ns.page.go();
});

ns.init включает экшены, обрабатывает предварительно заданный роутинг и ищет в DOM ноду #app для использования ее в качестве контейнера для интерфейса. Вызов ns.page.go нужен для запуска первого глобального апдейта.

Конфигурация#

Базовый путь в URL#

До инициализации можно задать префиксный путь для всех ссылок. Это может пригодиться, когда ваше приложение находится не по корневому пути app.example.com, а, например, app.example.com/checkout:

ns.router.baseDir = '/checkout';

Заголовок страницы#

Noscript позволяет задавать заголовок страницы, зависящий от текущего URL, при переходах внутри приложения. Переопределите функцию ns.page.title:

ns.page.title = function(url) {
    if ('/settings' == url) {
        return 'App - Account Settings'
    }

    return 'App';
};

Примечание: При необходимости, для получения параметров страницы из полученного URL можно воспользоваться функцией ns.router:

ns.page.title = function(url) {
    var params = ns.router(url).params;
    // ...
};

URL запроса моделей#

По умолчанию фреймворк группирует запросы моделей, нужных для отрисовки интерфейса и запрашивает их по URL /models/ вне зависимости от ns.router.baseDir. Переопределите константу ns.request.URL для задания собственного пути:

ns.request.URL = '/models/v1/json/';

Дополнительные параметры при запросе моделей#

При необходимости пробросить дополнительные параметры при запросе моделей, добавьте их в объект ns.request.requestParams:

ns.request.requestParams.token = getAuthToken();
ns.request.requestParams.version = '0.1.1';

Это приведет к отправке запросов вида:

Request URL: http://example.com/models/?_m=todos

Query String Parameters:
  _m: todos

Form Data:
  category.0: home
  token: 6a5e516725c68c
  version: 0.1.1

Условная обработка ответа моделей#

Определение функции ns.request.canProcessResponse позволяет динамически заблокировать обработку ответа моделей, например, при несовпадении авторизации или рассинхронизации клиента с бекендом:

ns.request.canProcessResponse = function(response) {
    // На бекенде выехала новая версия, а текущий клиент засиделся.
    if (response.version != APP.version) {
        location.reload();
        return false;
    }

    return true;
};

Переопределение модуля Yate-шаблонов#

По умолчанию для генерации разметки из шаблонов используется модуль main, однако сохраняется возможность его динамического определения в зависимости от параметров страницы и текущего лейаута:

ns.Update.prototype.applyTemplate = function(tree, params, layout) {
    var module = 'main';

    if (params.context === 'setup') {
        module = 'setup';
    }

    return ns.renderString(tree, null, module);
};

ns.router#

Умеет: - получать из урла - id страницы (layout) и параметры params - генерировать url-ы по id страницы и параметрам params

API#

ns.router.baseDir: {string}#

Базовая часть урла (если приложение располагается не в корне сайта.

ns.router(url): { page:string, params:{object} }#

Выполняет роутинг: вычисляет по url какая это страница page (это id layout-а) и вытаскивает параметры из урла. Если в урле были GET параметры - они подклеиваются в итоговый набор params.

Когда выполняется роутинг выполняются: - (опционально) redirect-ы (получаем новый урл после redirect-а и ещё раз выполняем роутинг) - (опционально) rewrite (текущий урл заменяется на прописанный в rewrite-е, параметры подклеиваются в конце как GET параметры) - роутинг (ищем первый подходящий шаблон урла, подробнее см. ns.router.routes. Если не удалось заматчится - считаем, что получили страницу с not-found) - (опционально) rewrite параметров (при желании, меняем что-то в полученном объекте с параметрами params).

ns.router.url(url): { string }#

Генерация урла, когда урл известен и нужно только дописать базовую часть. Странный метод, лучше использовать ns.router.generateUrl

ns.router.generateUrl(id, params): {string}#

Генерация урла по id страницы (layout) и по набору параметров. Это операция, обратная той, которую делает ns.router. Умеет разворачивать rewrite-ы (после генерации урла проверяет, есть ли rewrite правила для полученного урла и выполняет их в обратную сторону). В случае неуспеха - кидает ошибку.

ns.router.routes: {object}#

Это объект, в котором нужно указать все урлы, rewrite-ы и redirect-ы. Кроме этого поддерживается rewrite параметров.

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

rewriteUrl - тут указаны урлы (не шаблоны урлов) и можно указать rewrite для конкретного урла на другой конкретный урл.

route - тут прописано соответствие шаблона урла - странице (layout-у). Матчинг урла выполняется сверху вниз. А значит у урла, который выше - больше приоритет. Отсюда правило - более общие шаблоны урлов указывать ниже. Матчинг выполняется до первого успешного сопоставления. Одной и тоже странице может соответствовать несколько шаблонов урлов.

rewriteParams - для страницы (layout-а) можно указать функцию, в которой произвольным образом поменять params.

ns.router.routes = {
    redirect: {
        '/': '/inbox',
        '/inbox/old/{int:int}': '/inbox',
        '/inbox/my': function() {
            return '/inbox';
        },
        '/inbox/my/{int:int}': function(params) {
            return '/inbox/' + params.int;
        }
    },
    rewriteUrl: {
        '/page1': '/page/1'
    },
    route: {
        '/inbox': 'messages',
        '/message/{mid:int}': 'message',
        '/page/prefix{page:int}': 'url-with-prefix',
        '/search/{request:any}': 'search'
    },
    rewriteParams: {
        'message': function(params) {
            return { id: params.mid };
        }
    }
};

ns.router.regexps: {object}#

Тут задаются типы параметров в виде регулярных выражения.

Начальный набор такой:

ns.router.regexps = {
    'id': '[A-Za-z_][A-Za-z0-9_-]*',
    'int': '[0-9]+'
};

Параметры#

Параметры в урле задаются в {}, к примеру, /message/{message-id}. Параметр может быть как между /-ами, так и в промежутках, к примеру, /archive/{year}-{month}-{day}.

Тип указывается после имени параметра и отделяется :: {page:int}. Если параметр указан без типа - ему присваивается тип id. Т.е. {message-id} соответствует {message-id:id}.

Параметр может быть опциональным. В этом случае, слеш перед ним тоже становится опциональным. Чтобы указать, что параметр опционален - нужно дописать = или =default после имени параметра (или типа, если он указан), примерно так: {page=}, {page=0}, {page:int=} или {page:int=0}. Если в исходном урле параметр не задан, но указано дефолтное значение - оно будет в итоговом наборе параметров страницы params.

Можно указать фильтр значения параметра. В этом случае параметр должен иметь строго указанное значение, только в этом случае урл будет заматчен. Чтобы указать фильтр нужно дописать ==filter после имени параметра (или типа, если он указан), примерно так: {color==green} или {color:colors==green}.

Можно указать либо дефолтное значение, либо фильтр.

ns.History#

В noscript для смены URL в адресной строке используется HTML5 History API, который не поддерживается в IE раньше 10.

Polyfill для IE#

В качестве полифилла можно использовать devote/HTML5-History-API. Скрипт предоставляет стандартизированное API и будет использовать смену хеш-фрагмента URL для навигации.

/notes/141 -> /#/notes/141

Кроме подключения самого скрипта на страницу нужно проделать небольшую работу:

  1. Организовать редирект до старта приложения:
// Тут может произойти смена URL и перезагрузка, поэтому какие-нибудь
// модели до редиректа запрашивать бессмысленно.
window.history.redirect();

ns.init();
  1. Переопределить вычисление текущего URL приложения:
var history = window.history;

if (history.emulate) {
    ns.page.getCurrentUrl = function() {
        return history.location.pathname + history.location.search;
    };
}

Раскладка страницы (ns.Layout)#

Раскладка служит для декларативного описания структуры видов на странице. При определении раскладки указывается её id и декларация.

    ns.layout.define('main', {
        app: {
            view1: true,
            view2: {
                view21: true
            }
        }
    });

В примере создаётся раскладка страницы, состоящей из четырёх видов. Корневой вид app содержит в себе view1 и view2, а view2 содержит в себе view21.

Каждый узел декларации соответствует виду. Ключ указывает на класс вида, в значении содержится декларация вложенных видов.

Способы описания структуры видов

    ns.layout.define('main', {
        app: {

            // Для описания вида без вложенностей значение устанавливается в true
            view1: true,

            // Для описания одного вложенного вида в значении указывается его класс
            view2: 'view21',

            // Для описания статической структуры видов используется объект
            view3: {
                view31: true,
                view32: 'view321'
            },
            // Для описания динамической структуры видов узел объяляется боксом,
            // а вложенность задаётся функцией.
            // В params приходят параметры страницы.
            // Функция может вернуть любой из выше перечисленных форматов декларации
            // видов, или falsy, чтобы отменить добавление вида в страницу.
            view4@: function(params) {
                if (params.value1) {
                    return null;
                }
                if (params.value2) {
                    return 'view41';
                }
                if (params.value3) {
                    return {
                        'view42': {
                            'view421': true;
                        }
                    };
                }
            }
        }
    });

Кроме описания структуры видов раскладка так же позволяет декларировать специальные атрибуты видов.

Бокс#

Бокс - это специальный вид-контейнер. Он не имеет собственного html-содержимого и представлен в DOM только одним узлом, содержащим непосредственно в себе все вложенности. Бокс позволяет решать следующие задачи.

Кеширование экземпляров вида

    ns.layout.define('main', {
        app: {
            box@: 'view1'
        }
    });

В примере box будет содержать один вложенный вид view1. Если view1 зависит от параметров, то при изменении параметров предыдущие html-узлы будут скрываться, но оставаться в DOM-дереве, а новые - добавляться в box и показываться. При возврате к одному из предыдущих наборов параметров будет показан ранее сгенерированный соответствующий ему html-узел.

Создание динамической раскладки

    ns.layout.define('main', {
        app: {
            view1: true,
            view2@: function(params) {
                if (params.value1) {
                    return null;
                }
                if (params.value2) {
                    return 'view41';
                }
                if (params.value3) {
                    return {
                        'view42': 'view421',
                        'view43': 'view431'
                    };
                }
            }
        }
    });

В примере вид в зависимости от параметров может отсутствовать, содержать единственный вид view41, или содержать view42 и view43, содержащие в свою очередь соответственно view421 и view431. Для создания такой структуры view2 обязательно должен быть боксом. Обычный view, содержащий вложенные виды, при их исчезновении после обновления может работать некорректно.

Асинхронные виды#

Асинхронный вид позволяет на время загрузки его моделей рисовать его в виде заглушки. Updater запросит модели асинхронных видов отдельными запросами и отрисует страницу до получения этих данных. Загрузив модели, Updater сделает повторный проход только по асинхронным видам и обновит их.

    ns.layout.define('main', {
        app: {
            viewLight: true,
            viewHard&: true
        }
    });
    ns.View.define('viewLight', {
        models: ['modelLight']
    })
    ns.View.define('viewHard', {
        models: ['modelHard']
    })
    match .view[id="viewLight"] ns-view {
        // основное html-содержимое вида viewLight
    }


    match .view[id="viewHard"] ns-view-async {
        // html-содержимое заглушки для вида viewHard
    }
    match .view[id="viewHard"] ns-view {
        // основное html-содержимое вида viewHard
    }

Предположим, что modelLight запросить легко, а запрос modelHard требует заметного времени. При обновлении Updater запросит модели отдельно: сначала modelLight, а затем modelHard. Получив данные для modelLight, Updater отрисует страницу. Вид viewHard при этом отрисуется в виде заглушки (с использованием шаблона ns-view-async). После получения данных для modelHard Updater сделает ещё один такт обновления и вместо заглушки отрисует основное содержимое viewHard.

Наследование#

Один layout может наследовать от другого. Наследование реализуется следующим образом

// объявляет общий layout для всех страниц
ns.layout.define('common', {
    'app': {
        // каждая страница в проекте состоит из шапки, левой колонки и правой колонки
        'header-box@': {},
        'left-box@': {},
        'right-box@': {},
    }
});

// страница 1
// обратите внимание, что header-box@ не доопределяелся и берется как есть из common
ns.layout.define('posts', {
    // чтобы заново не описывать структуру, путь до видов указывается через пробел
    'app left-box@': {
        'navigation': {}
    },
    'app right-box@': {
        'posts': {}
    }
// последним параметром для ns.layout.define указывает, что layout наследуется от common
}, 'common');

// страница 2
ns.layout.define('profile', {
    'app header-box@': {
        'user-header': {}
    },
    'app left-box@': {
        'navigation': {}
    },
    'app right-box@': {
        'profile': {}
    }
}, 'common');

Переходы по страницам (ns.page)#

ns.page - специальный модуль для перехода по страницам внутри ns-приложения.

ns.page.go - главный метод. Разроваричивает адрес через ns.router, выбирает layout, запускает ns.Update и производит необходимые операции по смене урла в адресной строке и обновления названия (document.title) страницы. Метод возвращает промис от ns.Update, но иногда может вернуть отклоненный промис со статусами: - block - переход был заблокирован через ns.page.block

ns.page.title - точка расширения приложения. Позволяет задавать заголовки страниц.

Также модуль предоставляет полезные данные: - ns.page.current - текущие параметры страницы - ns.page.current.page - название текущего layout - ns.page.current.params - текущие параметры - ns.page.currentUrl - адрес текущей страницы

ns.page.block#

Этот механизм позволяет блокировать переходы по страницам. Например, с помощью него можно блокировать уход с формы, если не были сохранены изменения.

ns.View.define('my-view', {
    events: {
        'ns-view-show': function() {
            // после показа вида, добавляем функцию блокировки
            ns.page.block.add( this.checkChanges.bind(this) );
        },
        'ns-view-hide': function() {
            // после скрытия вида, очищаем функции
            ns.page.block.clear();
        }
    },
    methods: {
        /**
         * @param {string} url ссылка, по которой выполняется переход
         */
        checkChanges: function(url) {
            if (this.hasUnsavedChanges()) {
                // здесь можно показать какое-то сообщение

                // функция блокировки должна вернуть false, если переход нельзя осуществить
                return false;
            }

            return true;
        }
    }
 });

ns.page.history#

Этот модуль хранит историю приложения. Он нужен, т.к. в History API нельзя получить произвольное состояние на N шагов назад.

Имеет два метода: - ns.page.history.back - переход "назад". Этот метод не аналогичен кнопке "Назад" в браузере. Так, при отсутствии истории, этот метод перейдет на дефолтную страницу приложения (ns.page.getDefaultUrl), а не выйдет из него. - ns.page.history.getPrevious(n) - возвращает урл N страниц назад. 0 - предыдующая страница.

ns.View#

Вид представляет собой элемент интерфейса. Он однозначно идентифицируется своим ключом, который строится во время инициализации исходя из параметров вида. Разный ключ всегда означает разный экземпляр вида.

Не стоит ожидать, что при изменении параметров будет перерисован тот же самый вид. Этого можно достичь, но в общем виде будет создан и отрисован новый экземпляр.

Ключ очень важен для работы ns.Box. ns.Box при каждом обновлении высчитывает ключи для видов, которые должен показать, скрывает все виды, у которых ключ не совпадает и показывает те, которые надо.

Декларация#

Определение нового вида происходит через статическую функцию ns.View.define

ns.View.define('viewName', viewDeclObject[, baseView])

Объект-декларация состоит из следующих свойств.

ctor#

ctor - это функция-конструтор. Обратите внимание, что он вызывается самым первым, до инициализации самого вида, т.о. в конструкторе еще не доступеы некоторые свойства.

Полностью готовый экземпляр бросает событие ns-view-init.

/**
 * @classdesc prj.vMyView
 * @augments ns.View
 */
ns.View.define('my-view', {
    /**
     * @constructs prj.vMyView
     */
    ctor: function() {
        this._state = 'initial';
        this.CONST = 100;
    }
});

events#

events - объект с декларацией подписок на события, как DOM, так и noscript.

Любая подписка имеет вид:

{
    "на что подписаться@когда": "обработчик"
}

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

События с суффиксом @show вешаются во время показа вида (событие ns-view-show) и снимаются во время скрытия (событие ns-view-hide). Аналогично, суффикс @init означает, что событие будет активировано на ns-view-htmlinit и деактивировано на ns-view-htmldestroy.

DOM-события#

DOM-события от события noscript различаются согласно массиву ns.V.DOM_EVENTS. Все, что не входит в этот массив, является "космическим" событием noscript.

DOM-события навешиваются через механизм делегирования.

Примеры деклараций:

{
    // событие click на корневой ноде вида
    "click": "onClick",

    // событие click на нодах к классом selector внутри вида
    "click .selector": "onSelectorClick",

    // событие click на нодах к классом selector внутри вида,
    // из-за @init обработчик навешивается на ns-view-htmlinit и снимается при ns-view-htmldestroy
    "click@init .selector": "onInitSelectorClick",

    // обработчик события scroll на window
    "scroll window": 'onScroll'
}

Правила для DOM-событий:

  1. Все (кроме scroll) события работают через механизм event delegation.
  2. По умолчанию все обработчики навешиваются на ns-view-show и снимаются на ns-view-hide
  3. Событие scroll не всплывает, поэтому навешивается напрямую на ноду, указанную в селекторе.
  4. В качестве селектора можно указать window или document, в этом случае событие будет навешано на соответствующий глобальный объект.

"Космические" события noscript#

Декларируются как и остальные события

{
    "my-custom-event": "onCustomEvent",
    "my-custom-init@init": "onCustomInit"
}

Если не указано когда вешать обработчик, то оно будет навешан при показе вида и снят при скрытии.

"Космические" события работают через единую шину ns.events

ns.events.trigger('my-custom-event');

Встроенные события#

Список событий:

  • ns-view-hide - вида сейчас виден на странице и будет скрыт. Нода вида находится в DOM.
  • ns-view-htmldestroy - вид сейчас виден на странице, но его нода будет заменена на новую. Нода вида находится в DOM.
  • ns-view-htmlinit - у вида появилась новая нода, при этом не гарантируется, что она находится в DOM.
  • ns-view-async - у async-view появилась заглушка. Это единственное событие, которое генерируется для заглушки async-view
  • ns-view-show - view был показан и теперь виден на странице. Нода вида находится в DOM.
  • ns-view-touch - view виден и был затронут в процессе обновления страницы. Нода вида находится в DOM.

Правила:

  1. События генерируются снизу вверх, т.е. сначала их получают дочерние вида, потом родительские.
  2. События генерируются пачками, т.е. сначала одно событие у всех view, потом другое событие у всех view.
  3. События генерируются в строго определенном порядке, указанном выше

Примеры последовательностей событий:

  • инициализация view: ns-view-htmlinit -> ns-view-show -> ns-view-touch
  • перерисовка страница, если view валиден: ns-view-touch
  • view был скрыт: ns-view-hide (без ns-view-touch)
  • view был показан: ns-view-show -> ns-view-touch
  • view был перерисован: ns-view-hide -> ns-view-htmldestroy -> ns-view-htmlinit -> ns-view-show -> ns-view-touch (ns-view-hide тут вызывается из тех соображений, что могут быть обработчики, которые вешаются на ns-view-show/ns-view-hide и при обновлении ноды, они должны быть переинициализированы)

methods#

methods - объект с методами вида. По сути является прототипом объекта.

/**
 * @classdesc prj.vMyView
 * @augments ns.View
 */
ns.View.define('my-view', {
    /** @lends prj.vMyView.prototype */
    methods: {
        BAR: 100
        foo: function(){}
    }
});

models#

models позволяет указать модели, от которых зависит вид. Зависимость означает, что

  1. параметры вида будут собраны на основе параметров связанных моделей
  2. в шаблонах вида будут доступны данные связанных моделей
  3. некоторые методы вида будут подписаны на события связанных моделей

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

  • ns-model-changed
  • ns-model-insert
  • ns-model-remove
  • ns-model-destroyed

и не подписывается на событие ns-model-touched.

Если обработчики явно не указаны, то в качестве обработчика стандартных событий устанавливается метод invalidate.

ns.View.define('super-view', {
  models: ['album', 'photo']
});

В приведённом примере вид будет инвалидироваться при любом стандартном событии модели.

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

ns.View.define('super-view', {
  models: {
    album: {
      'ns-model-boof': 'invalidate'
    }
  }
});

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

ns.View.define('super-view', {
  models: {
    album: {
      'ns-model-changed': 'keepValid'
    }
  }
});

В приведённом примере при наступлении события ns-model-changed вид будет оставаться валидным и не будет перерисован при последующих update'ах. При любом другом стандартном событии модели он будет проинвалидирован.

Для того, чтобы предотвратить инвалидацию вида по любому событию, keepValid нужно установить значением поля модели.

ns.View.define('super-view', {
  models: {album: 'keepValid'}
});

В приведённом примере события модели album не будут влиять на валидность вида.

'invalidate' и 'keepValid' - это имена реальных методов. Вместо них можно указать имя любого другого метода вида.

Если нужно в качестве обработчика события использовать произвольный метод, и при этом инвалидировать вид, достаточно внутри метода вызвать this.invalidate();.

Для краткости вместо методов invalidate и keepValid можно указывать их краткую форму: true и false соответственно. 2 варианта деклараций в следующем примере работают одинаково.

Пример использования произвольных обработчиков:

ns.View.define('supre-view', {
  models: {
    album: {
      'ns-model-changed': 'methodOfView'
    }
  },
  methods: {
    'methodOfView': function(){ }
  }
});
ns.View.define('supre-view', {
  models: {
    album: {
      'ns-model-changed': function() { }
    }
  }
});
ns.View.define('super-view', {
  models: {
    photo: 'invalidate',
    album: 'keepValid'
  }
});

ns.View.define('super-view', {
  models: {
    photo: true,
    album: false
  }
});

Для большей краткости зависимости от моделей можно указывать в виде массива. Это будет эквивалентно указанию в качестве обработчика их событий метода invalidate.

ns.View.define('super-view', {
  models: ['photo', 'album']
});

Параметры#

Параметры нужны для построения ключа.

По умолчанию, если params не указан, то параметры собираются из параметров всех моделей в порядке их объявления. Добавлять или удалять из собранных параметров моделей можно с помощью объектов params+ и params-

Если params явно заданы — нельзя использовать params+ / params-.

Если ключ view нельзя построить бросается исключение.

params+#

Добавляет в результирующий набор дополнительные параметры:

ns.View.define('super-view', {
  "models": [ 'album', 'photo' ],
  "params+": { page: 23 }
});

params-#

Удаляет из результирующего набора указанные параметры:

ns.View.define('super-view', {
  "models": [ 'album', 'photo' ],
  "params-": [ 'album-id' ]
});

params#

params может быть массивом объектов или функцией. Также можно указать объект - это короткая запись массива с одним элементом.

Каждый объект представляет собой группу параметров. Это позволяет строить ключ по-разному в зависимости от набора.

ns.View.define('super-view', {
  params: [
    { "context": "album", "album-id": null },
    { "context": null }
  ]
});

Как строится ключ:

  • каждое свойство объекта — это обязательный параметр
  • если значение свойства null — параметр обязателен, но значение его может быть любым
  • если значение свойства не null — это фильтр, параметр из урла должен иметь именно это значение
  • если есть все нужные параметры и выполняются все фильтры — ключ можно строить
  • иначе — пытаемся строить по следующей группе параметров

Т.о. при использовании params все параметры являются обязательными. Чтобы сделать их необязательными, используйте params+.

Если указана функция, то функция должна вернуть объект с параметрами, по которым будет построен ключ.

ns.View.define('view', {
  // ns.key - готовая функция для склеивания параметров в строку
  params: ns.key
})

paramsRewrite#

paramsRewrite - функция в декларации, изменяющая параметры после их создания стандартными способами. Она вызывается всегда и ее стоит использовать для динамического изменения параметров перед созданием вида.

ns.View.define('myView', {
    "models": [ 'album', 'photo' ],
    "paramsRewrite": function(params) {
        if (someConditions) {
            params.newParam = 1;
        }

        return params;
    }
});

Валидность#

Валидность view считается по двум факторам:

  • собственный статус ns.V.STATUS
  • статус привязанных моделей

При отрисовке вид запоминает все версии моделей и в дальшейшем сравнимает их. Если версия изменилась, то вид будет перерисован.

Также у вида есть собственный статус this.static, значением которого может быть тип ns.V.STATUS. Если статус не ns.V.STATUS.OK, то вид будет перерисован.

Инвалидировать вид можно методом this.invalidate().

Вид безусловно подписывается на все изменения моделей и автоматически инвалидирует себя при изменениях.

Взаимодействие#

В noscript нет какого-либо способа получить созданный экземпляр вида. Поэтому любое внешнее взаимодействие с ним осуществляется исключительно через механизм событий noscript

async#

Вид может быть "асинхронным". Такое поведение полезно, когда некоторые модели могут запрашиваться с сервера продолжительное время.

Схема работы:

  1. Если у вида есть все необходимые данные (все модели валидны) для отрисовки, то он отрисуется в общем потоке.
  2. Если модели не валидны, то сначала отрисуется заглушка - мода ns-view-async-content, где будут доступны все валидные на данный момент данные, и сделан запрос за остальными моделями. У вида будет вызвано событие ns-view-async.
  3. После получения данных вид будет перерисован с обычной модой ns-view-content и поведет себя как обычно

Шаблонизация ns.View в yate#

Каждый вид отрисовывается изолированно. Корнем является JSON-структура описанная ниже.

Исходя из сменя контекста для каждого вида, не стоит увлекаться созданием ключей и глобальных переменных в yate, т.к. они будут перевычислять для каждого вида. Более правильным способом является вынос таких данных в external-функции.

Создание View#

За создание DOM-обертки и содержимого отвечает мода ns-view. Ее стоит использовать только для управления местом отрисовки дочерних View.

Не стоит переопределять эту моду без крайней необходимости!

Атрибуты обертки View#

  • ns-view-add-attrs - с помощью этой моды можно дописать собственные атрибуты в DOM-обертку. Например,
match .my-view2 ns-view-add-attrs {
    @data-id = 'my-id'
}
  • ns-view-add-class - с помощью этой моды можно дописать собственные классы в DOM-обертку. Например,
match .my-view2 ns-view-add-class {
    // пробел в начале обязателен
    " my-class"
}
  • ns-build-view - с помощью этой моды можно поменять корневой узел у вьюхи. Например,
match .my-span-view ns-build-view {
    <span>
        apply . ns-build-view-content
    </span>
}

Содержимое View#

ns-view-content и ns-view-desc#

ns-view-content - самая главная мода, отвечает за содержимое view при нормальной отрисовке.

match .my-view1 ns-view-content {
    <div class="view1-content">
        // в этом месте отрисуются все дочерние view с помощью хелпера ns-view-desc
        apply . ns-view-desc
    </div>
}

Если надо расставить виды по разным местам:

match .my-view2 ns-view-content {
    <div class="view2-content">
        <div class="view2-content__child">
            // в этом месте отрисуются дочерний вид my-child1
            apply /.views.my-child1 ns-view
        </div>
        <div class="view2-content__child">
            // в этом месте отрисуются дочерний вид my-child2
            apply /.views.my-child2 ns-view
        </div>
    </div>
}

ns-view-async-content#

ns-view-async-content - мода отвечает за содержимое View в режиме async. В большинстве случаев тут стоит рисовать лоадер пока грузятся данные. В async-режиме у view не бывает дочерних элементов. Они появляются в нормальной отрисовке, когда используется ns-view-content

match .my-view1 ns-view-async-content {
    <div class="view1-content">
        <img src="loader.gif"/>
    </div>
}

ns-view-error-content#

ns-view-error-content - мода отвечается за состояние, когда часть моделей не удалось получить или для них вернулась ошибка.

Элементы ViewСollection#

В вопросе отрисовки коллеция не отличается от обычных View и рисуется теми же модами: ns-view-content и ns-view-async-content. Для управления местом вставки элементов коллекции есть мода ns-view-collection. Ее смысл в том, чтобы давать возможность ViewСollection иметь собственную обертку над элементами.

Если вы используете эту моду, то родительскую ноду для элементов коллекции надо пометить специальных классом ns-view-container-desc

match .my-view-collection ns-view-content {
    <div class="my-view-collection__wrapper">
        <div class="my-view-collection__text">My View Collection</div>
        <div class="my-view-collection__items ns-view-container-desc">
            // сюда будут отрисованы элементы коллекции
            // не забывайте добавлять класс "ns-view-container-desc" для родителя элементов коллекции
            apply . ns-view-collection
        </div>
    </div>
}

Помните, что элементы коллекции невозможно отрисовать модой ns-view-desc или через apply /.views.* ns-view. Элементы коллекции отрисует только мода ns-view-collection!

Yate-хелперы#

  • model('model-name') - хелпер для быстрого получения данных модели. Внутри использует ключи, поэтому быстрее, чем jpath /.models.modelName[ .status = 'ok'].modelName
  • modelError('model-name') - хелпер для получения ошибки модели. Внутри использует ключи, поэтому быстрее jpath /.models.modelName[ .status = 'error'].modelName
  • ns-url - external-функция для ns.router.url
  • ns-generate-url - external-функция для ns.router.generateUrl

Структура JSON для отрисовки#

{
    box: false,
    collection: false,
    key: 'view=app',
    models: {
        model1: {
            'model1': {},
            'status': 'ok'
        },
        model2: {
            'model2': {},
            'status': 'ok'
        },
        model3: {
            'model3': 'http_timeout',
            'status': 'error'
        }
    },
    params: {},
    state: 'ok',
    views: {}
}

Публичные свойства:

  • key: string. Ключ вида.
  • params: object. Собственные параметры вида.
  • views: object. Объект с дочерними видами, используется для дальнейшего наложения шаблонов через ns-view-content. Имеет следующий вид:
    {
     "views": {
         "view1Name": view1Tree
         "view2Name": view2Tree
     }
    }

Приватные свойства:

  • box: boolean. Флаг того, что это бокс.
  • collection: boolean. Флаг того, что это вид-коллекция.
  • models: object. Объект с данными моделей. Не предназначен для прямого использования. Для получения моделей всегда используйте yate-хелперы model() и modelError().
  • state: Текущее состояние вида. ok/error/loading/placeholder

Динамическое изменение детей у ns.View#

Иногда бывают ситуации, когда возможностей менять раскладку страницы через ns.Box недостаточно. Например, дети вида зависят от его состояния, моделей и того, что недоступно в layout. Для решения этой проблемы создан метод #patchLayout.

patchLayout для ns.View#

ns.layout.define('layout1', {
    'view-box@': {
        'view1': {
            'view2': {}
        }
    }
});

ns.layout.define('layout2', {
    'view-box@': {
        'view3': {
            'view4': {}
        }
    }
});

ns.View.define('view', {
    methods: {
        patchLayout: function(updateParams) {
            if (this.getModel('state').isGoodMoonPhase()) {
                return 'layout1';
            } else {
                return 'layout2';
            }
        }
    }
});

Описание и ограничение API #patchLayout:

  • метод вызывается только, если все модели валидны. Если это не так, то ns.Update запрашивает модели.
  • должен вернуть предопредленный layoutID, а не вид.
  • должен всегда возвращать layoutID, причем он должен начинаться с box. Это связано с проблемами из #533.

split.intoLayouts для ns.ViewCollection#

API схоже с ns.View#patchLyaout. По сути intoLayouts позволяет иметь неограниченно сложные элементы коллекции.

Вместо split.intoViews коллекции объявляет split.intoLayouts, где возвращает какую раскладку будет иметь каждый элемент коллекции.

ns.layout.define('layout1', {
    'view-box@': {
        'view1': {
            'view2': {}
        }
    }
});

ns.layout.define('layout2', {
    'view-box@': {
        'view3': {
            'view4': {}
        }
    }
});

ns.ViewCollection.define('view-collection', {
    split: {
        byModel: 'model-collection'
        patchLayout: function(modelItem, viewItemParams) {
            if (modelItem.isGoodMoonPhase()) {
                return 'layout1';
            } else {
                return 'layout2';
            }
        }
    }
});

Описание и ограничение API split.intoLayouts:

  • метод вызывается только, если все модели валидны. Если это не так, то ns.Update запрашивает модели.
  • поведение идентично split.intoViews.

ns.Model#

Модель представляет собой данные. Она однозначно идентифицируется своим ключом, который строится во время инициализации. Разный ключ всегда означает разный экземпляр модели.

Декларация#

Определение новой модели происходит через статическую функцию ns.Model.define

ns.Model.define('modelName', modelDeclObject[, baseModel])

Объект-декларация состоит из следующих свойств.

ctor#

ctor - это функция-конструтор. Обратите внимание, что он вызывается самым первым, до инициализации самой модели, т.о. в конструкторе еще не доступны некоторые свойства.

Полностью готовый экземпляр бросает событие ns-model-init.

/**
 * @classdesc prj.mMyModel
 * @augments ns.Model
 */
ns.Model.define('my-model', {
    /**
     * @constructs prj.mMyModel
     */
    ctor: function() {
        this._state = 'initial';
        this.CONST = 100;
    }
});

events#

events - объект с декларацией подписок на события noscript.

Любая подписка имеет вид:

{
    "на что подписаться": "обработчик"
}

Обработчиком может быть название метода из прототипа или функция.

Пример:

{
    "my-custom-event": "onCustomEvent"
}

methods#

methods - объект с методами. По сути является прототипом объекта.

/**
 * @classdesc prj.mMyModel
 * @augments ns.Model
 */
ns.Model.define('my-model', {
    /** @lends prj.mMyModel.prototype */
    methods: {
        BAR: 100
        foo: function(){}
    }
});

params#

Параметры нужны для как для построения ключа, так и для запроса моделей с сервера.

ns.Model.define('my-model', {
    params: {
        //  Любое значение, кроме null расценивается как дефолтное значение этого параметра.
        'author-login': null,
        'album-id': null,

        //  Этим двум параметрам заданы дефолтные значения.
        'page': 0,
        'pageSize': 20
    }
});

В запросе на сервер отправляются все параметры, которые не null.

Важно понимать, что HTTP - текстовый протокол, поэтому все значения отправятся как строки. Т.о. 0 станет "0", false - "false". А это значит, что параметры, которые в вашем приложении не обрабатываются как строки, надо приводить к правильному типу на сервере. Иначе, можно получить такую ошибку

// отправили параметры как
// ?flag=false
//

if (params.flag) {
   // эта ветка выполнится, потому что params.flag === "false"
}

paramsRewrite#

paramsRewrite - функция в декларации, изменяющая параметры после их создания стандартными способами. Она вызывается всегда и ее стоит использовать для динамического изменения параметров перед созданием модели.

ns.Model.define('myModel', {
    "params": {
        "p1": null,
        "p2": null,
        "p3": null,
        "p4": null
    },
    "paramsRewrite": function(params) {
        // бизнес-логика приложения предполагает, что p1 и p3 вместе быть не могут
        if (params.p1 && params.p3) {
            delete params.p3;
        }
        return params;
    }
});

Получение экземпляра модели#

  • ns.Model.get('modelName', params) - строит ключ из params и возвращает соответствующую модель. Если такого экземпляра нет, то он будет создан.
  • ns.Model.getValid('modelName', params) - тоже самое что и ns.Model.get. Только экземпляр еще проверяется на валидность. Если валидный экземпляр не найден, то возвращается null.

Запрос данных модели с сервера#

Данные модели: - могут прийти с севера (модель можно явно запросить с помощью метода ns.request() или неявно, создав и выполнив ns.Update на странице) - могут быть установлены вручную (см. Работа с данными)

В случае запроса модели с сервера модель перезапрашивается если: - она не валидна (isValid() возвращает false) - её можно запросить ещё раз (canRequest() возвращает true)

Локальные модели#

Локальная модель - модель, которая никогда не запрашивается на сервере. Данные такой модели устанавливают вручную. Чтобы модель стала локальной нужно переопределить метод canRequest(), к примеру, так:

ns.Model.define('local-model', {
    methods: {
        canRequest: function() {
            return false;
        }
    }
});

Работа с данными#

Методы для получения данных:

  • #getData() - возвращает весь объект данных модели. Этот метод можно переопределять для доп. обработки данных. Например, для коллекции этот метод собирает актуальные данных из всех элементов.
  • #get(jpath) - выбирает данные по jpath и приводит результат к упрощенному виду. Результат приведения зависит как от самих данных, так и от jpath. Поэтому при изменениях формат результата может меняться.
    {
     "foo": "1",
     "bar": [
         { "id": 1 }
     ]
    }
    this.get('.foo') -> "1"
    this.get('.bar.id') -> ["1"]
  • #select(jpath) - выбирает данные по jpath. В отличии от #get, не занимается приведением и всегда возвращает массив результатов выборки, т.о. формат результат остается стабильным при изменениях.
{
    "foo": "1",
    "bar": [
        { "id": 1 }
    ]
}
this.get('.foo') -> ["1"]
this.get('.bar.id') -> ["1"]

Методы для изменения данных:

  • #set(jpath, value) - изменяет данные по jpath. Поддерживаются только несложные jpath.
this.set('.foo', 2);
  • #setData(data) - устаналивает полностью новые данные. В частности, этот метод вызывается при получении данных с сервера.

Пре- и постобработка данных#

extractData#

Метод извлекает данные из ответа сервера. По умолчанию берется поле data из ответа. Если метод не возвращает данные, то считается, что модель загружена с ошибкой.

ns.Model.define('my-model', {
    methods: {
        extractData: function(serverResponse) {
            if (serverResponse) {
                return serverResponse.result;
            }
        }
    }
});

extractError#

Метода извлекает данные об ошибке сервера. По умолчанию берется поле error из ответа.

Метод вызывается, когда #extractData() не вернул данные.

ns.Model.define('my-model', {
    methods: {
        extractError: function(serverResponse) {
            if (serverResponse) {
                return serverResponse.error;
            }
        }
    }
});

hasDataChanged#

Этот метод может контроллировать изменились ли данные на самом деле, чтобы не вызывать лишних события и перерисовок. Аргументом метода являются новые данные, а старые можно получить способами описанными выше, например #getData. Должен вернуть boolean.

ns.Model.define('my-model', {
    methods: {
        hasDataChanged: function(newData) {
            var oldData = this.getData();
            // изменяем данные, только если изменилось поле id
            return oldData.id !== newData.id
        }
    }
});

preprocessData#

Этот метод позволяет обработать полученные данные. Аргументом метода являются новые данные, должен вернуть обработанные данные.

ns.Model.define('my-model', {
    methods: {
        preprocessData: function(newData) {
            // например, проверяем формат поля в данных
            newData.field = Array.isArray(newData.field) ? newData.field : [];
            return newData;
        }
    }
});

События#

  • ns-model-changed - модель изменилась. В аргументах приходит jpath, по которому было сделано изменение. Если он пустой, то изменилась вся модель (обычно методом #setData())
  • ns-model-changed<.jpath> - изменились данные по указанному jpath. В аргументах приходит jpath, по которому было сделано изменение. События кидаются иерархично, т.о. для .for.bar будет три события: ns-model-changed.foo.bar, ns-model-changed.foo, ns-model-changed
  • ns-model-before-destroyed - модель будет инвалидирована и уничтожена.
  • ns-model-destroyed - модель была инвалидированна и уничтожена.
  • ns-model-init - модель создана и проинициализованна
  • ns-model-touched - у модели изменилась версия. Такое событие будет как результатом изменения данных через #set или #setData, так и прямым вызовом метода #touch()

Запрос#

По умолчанию все модели запрашиваются по урлу ns.request.URL. Если запрашиваются несколько моделей, то они группируются в один запрос.

Это поведение можно изменить с помощью метода request() у модели, который должен вернуть Vow.Promise. В этом случае, вся логика запроса находится в этом методе, в том числе модель сама должна вызвать методы setData() или setError(). Такие запросы не группируются, но подчиняются общим правилам ns.request, т.е. будут работать перезапросы и пока не завершится первый запрос, нельзя сделать дублирующий.

ns.Model.define('model', {
    methods: {
        request: function() {
            return ns.http('https://api.twitter.com', {}, {type: 'GET'})
                .then(function(data) {
                    this.setData(data);
                }, function(error) {
                    this.setError(error);
                }, this);
        }
    }
});

ns.Update#

Флаг исполнения execFlag#

ns.Update можно запустить с одним из следующих флагов: - GLOBAL: глобальное обновление (обычно всей страницы). С таким флагом ns.Update запускается из ns.page.go. Такое обновление может прервать только другой с флагом GLOBAL. В один момент времени в приложении может работать единственное GLOBAL-обновление, иначе состояние страницы может стать непредсказуемым. GLOBAL-обновление прерываем все ASYNC-обновления. - ASYNC: обновление для асинхронных вид. Таких обновлений может быть несколько и они будут работать одновременно. ASYNC-обновление может запустить только, если в текущий момент нет GLOBAL-обновление. - PARALLEL: параллельное обновление. Выполняется в любом случае, его никто не может прервать.

Логика построения и обновления страницы#

Отрисовка или обновление страницы запускается методом ns.page.go. На вход он принимает адрес страницы, возвращает promise, который разрешается после завершения обновления страницы.

ns.page.go работает в несколько этапов.

  • Адрес страницы с помощью маршрутизатора преобразуется в параметры страницы и id раскладки.

  • По id инстанциируется раскладка

  • В зависимости от значения второго аргумента ns.page.go добавляется или заменяется запись в истории.

  • На основе раскладки и параметров страницы создаётся экземпляр контроллера обновления и запускается. Метод start возвращает promise, который возвращается из метода ns.page.go.

    • Контроллер обновления рекурсивным проходом по раскладке страницы собирает виды, требующие обновления, и делит их на синхронные и асинхронные.

    • На основании видов получает 2 группы моделей, требующие запроса с сервера.

    • Запрашивает модели для синхронных видов.

      • Получив модели для синхронных видов с сервера, контроллер обновления рекурсивным проходом по синхронным видам строит из раскладки страницы и моделей дерево страницы для наложения шаблона.

      • Накладывает шаблон, получает html-узлы обновлённых видов. Асинхронные виды рендерятся в виде заглушек.

      • Рекурсивным проходом по видам контроллер обновления раскладывает по ним html-узлы и собирает события видов, чтобы запустить их в нужный момент.

      • Триггерит события видов.

      • Разрешает promise, выданный при старте.

    • Параллельно с запросом моделей для синхронных видов запрашиваются модели для асинхронных видов.

      • Получив модели для асинхронных видов с сервера, контроллер обновления рекурсивным проходом по асинхронным видам строит из раскладки страницы и моделей дерево страницы для наложения шаблона.

      • Накладывает шаблон, получает html-узлы обновлённых асинхронных видов.

      • Рекурсивным проходом по асинхронным видам контроллер обновления раскладывает по ним новые html-узлы взамен заглушек и собирает события видов, чтобы запустить их в нужный момент.

      • Триггерит события видов.

ns.ViewCollection#

ViewCollection - это коллеция ns.View, привязанная к ns.ModelCollection. При изменении коллекции позволяет перерисовывать только изменившиеся элементы.

По сути, образуется следующая зависимость один-к-одному:

ViewCollection      ->  ModelCollection
    view-item-1     ->      model-item-1
    view-item-2     ->      model-item-2
                    ...
    view-item-N     ->      model-item-N

ns.ViewCollection может зависит только от одной ns.ModelCollection.

ns.ViewCollection может содержать внутренние виды и иметь собственную html-разметку.

Декларация#

ns.ViewCollection.define('my-view-collection', {
    models: [ 'my-model-collection' ],
    split: {
        byModel: 'my-model-collection',
        intoViews: 'my-view-collection-item'
    }
});

Опция split.intoViews определяет из каких ns.View состоит коллекция.

Опция split.byModel определяет по какой модели коллекции строить виды.

Опция models, как и в ns.View определяет зависимость от моделей и подписки на их события. По умолчанию ViewCollection делает следующие подписки:

  • обработчиком собственных событий ns-model-changed и ns-model-destroyed любых моделей устанавливается invalidate. Эти события наступают при изменении данных, по которым рисуется собственная html-разметка viewCollection'а, поэтому вид по умолчанию становится невалидным, чтобы перерисоваться.
  • обработчиком ns-model-insert и ns-model-remove модели-коллекции устанавливается keepValid. Эти события наступают при изменении состава модели-коллекции, по которой рисуются вложенные виды viewCollection'а. Собственная html-разметка при этом не затрагивается, поэтому вид по умолчанию остаётся валидным. События моделей, вложенных в коллекцию игнорируются и подписаться на них через декларацию нельзя.

Декларация элемента ns.ViewCollection выглядит так:

ns.View.define('my-view-collection-item', {
    models: [ 'my-model-collection-item' ]
});

Элемент коллекции ведет себя как обычный ns.View и ничего не знает про коллекцию.

Элементы коллекции помещаются в узел-контейнер, размеченный классом ns-view-container-desc. Узел-контейнер обязательно должен быть указан. Вне этого контейнера можно делать собcтвенную html-разметку.

Фильтрация и разнородная коллекция#

Если для split.intoViews указать функцию вместо строки, то это даст как возможность фильтровать виды, так и составлять коллекцию из разных элементов. Функция должна возвращать ID вида или false.

ns.View.define('my-view-collection', {
    models: [ 'my-model-collection' ],
    split: {
        byModel: 'my-model-collection',
        intoViews: function(model) {
            // эти элементы мы фильтруем и не включаем в коллекцию
            if (model.get('.type') === 3) {
                return false;
            }

            if (model.get('.type') === 1) {
                // этот элемент станет видом 'view-item-type-1'
                return 'view-item-type-1';
            }

            // этот элемент станет видом 'view-item'
            return 'view-item';
        }
    }
});

ns.ModelCollection#

ModelCollection - это коллеция (по сути, массив) ns.Model.

Может иметь собственные данные. Данные коллекции непосредственно не хранит, а собирает динамически из актуальных ns.Model.

Коллеция может содержать разные модели.

ns.ModelCollection наследуется от ns.Model и добавляет к ней некоторые методы:

  • #clear() - очищает коллекцию
  • #insert(models[, index = last]) - добавляет models в коллекцию на позицию index.
  • #remove(models) - удаляет models из коллекции.

При добавлении элементов бросает событие ns-model-insert со списком новых моделей.

При удалении элементов бросает событие ns-model-remove со списком удаленных моделей.

Если мы делаем setData() у коллекции, то коллекция при необходимости бросает события ns-model-remove, ns-model-insert. В случае, если в setData() передали текущий список моделей, то события бросаться не будут.

Декларация#

Декларация отличается наличием поля split

ns.Model.define('my-model-collection', {
    split: {
        items: '.message',
        params: {
            'mid': '.mid'
        },
        model_id: 'message'
    }
});

split.items - jpath до элементов коллекции. После получения данных коллекции выберет элементы по этому jpath и сделает из каждого элемента модель. Это и будет коллекция. split.model_id - название модели, из которых будет состоять коллекции split.params - параметры для элементов коллекции

Если модель наполняется вручную, то split можно не указывать, а указать флаг isCollection === true.

Для таких колекций так же можно указать jpath, по которому будет лежать коллекция - jpathItems (по умолчанию, .items).

ns.Model.define('my-model-collection', {
    isCollection: true,
    jpathItems: '.files'
});

ns.Model.define('my-model-item', {
    params: {
        id: null
    }
});

var collection = ns.Model.get('my-model-collection');
var collectionItem1 = ns.Model.get('my-model-item', {id : 1}).setData({'foo': 'bar'});
var collectionItem2 = ns.Model.get('my-model-item', {id : 2}).setData({'foo': 'baz'});

// добавляем элементы в коллекцию
collection.insert(collectionItem1);
collection.insert(collectionItem2);

// т.к. указан jpathItems, то данные коллекции будут выглядет вот так
{
    "files": [
        {
            "foo": "bar"
        },
        {
            "foo": "baz"
        }
    ]
}

Фильтрация и разнородная коллекция#

Если для split.model_id указать функцию вместо строки, то это даст как возможность фильтровать модели, так и составлять коллекцию из разных элементов. Функция должна возвращать ID модели или false.

ns.Model.define('my-model-collection', {
    split: {
        items: '.message',
        params: {
            'mid': '.mid'
        },
        model_id: function(modelItemData) {
            // эти элементы мы фильтруем и не включаем в коллекцию
            if (modelItemData.type === 3) {
                return false;
            }

            if (modelItemData.type === 1) {
                // эти элементы станут экземпляром модели 'model-item-type-1'
                return 'model-item-type-1';
            }

            // эти элементы станут экземпляром модели 'model-item'
            return 'model-item';
        }
    }
});

Шаблон: модель состояние#

Состояние интерфейса в noscript можно хранить одним из двух основных способов: - в url, который затем преобразуется в параметры - в данных модели.

Первый способ является основным и базовым. С его помощью формируется множество адресов сервиса, т.е. его внешний api.

Этот способ обладает следующими особенностями: - он физически способен вместить очень ограниченное число атрибутов состояния - при перезагрузке страницы он остаётся неизменным - каждый атрибут, добавленный в url фактически добавляется во внешний api web-сервиса, что не всегда хорошо.

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

Для хранения перечисленных и других подобных атрибутов состояния рекомендуется использовать паттерн модель состояния.

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

Если вдруг появилось желание сохранить атрибуты, из моделей состояния, между перезагрузками страницы, никто не мешает сохранить их в любое хранилище (localStorage, сервер) и считывать при загрузке страницы.

В id модели рекомендуется использовать слово state, чтобы явно указать, что эта модель представляет не сущность всего сервиса, а специфическую сущность интерфейса - "состояние компонента".

Пример. Модель состояния элемента списка


    ns.Model.define('letters', {
        split: {
            model_id: 'letter',
            items: 'letter',
            params: {
                id: '.id'
            }
        }
    });

    ns.Model.define('letter', {
        params: {
            id: null
        }
    });

    ns.Model.define('stateLetter', {
        events: {
            // записываем данные в модель при создании
            'ns-model-init': function() {
                this.setData({selected: false});
            }
        },
        methods: {
            toggleSelected: function() {
                if (this.get('.selected')) {
                    this.set('.selected', false);
                } else {
                    this.set('.selected', true);
                }
            }
        }
        params: {
            id: null
        }
    });

Определена модель-коллекция letters, которая при загрузке автоматически порождает какое-то количество моделей letter. Опраделена модель stateLetter (состояние письма), которая зависит от тех же параметров, что и letter.

Модель stateLetter инициализирует свои данные при создании.

    ns.ViewCollection.define('letters', {
        split: {
            byModel: 'letters',
            intoViews: 'letter'
        },
        models: ['letters']
    });

    ns.View.define('letter', {
        models: {
            'letter': true,
            'stateLetter': 'keepValid'
        },
        events: {
            'click .js-select-letter': 'toggleSelected'
        },
        methods: {
            toggleSelected: function() {
                this.getModel('stateLetter').toggleSelected();
            }
        }
    });

Определён вид-коллекция letters, который по модели letters создаёт внутри себя виды letter. Каждый вид letter зависит от моделей letter и stateLetter.

При наступлении события click на dom-элементе .js-select-letter срабатывает метод toggleSelected, который изменяет модель состояния. Если dom-элемент .js-select-letter - checkbox, то при клике перерисовывать вид letter уже не нужно. Поэтому в зависимости вида letter от модели stateLetter указан метод keepValid, предотвращающий его перерисовку.

Данная конструкция позволяет хранить состояние выделенности неограниченного количества элементов списка между запусками ns.Update. При этом атрибут, относящийся только к списку писем хранится в отдельной модели. Этот атрибут никак не будет влиять на другие виды, зависящие от модели letter.

Шаблон: модель с дозагрузкой данных#

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

Пример задачи 1: список писем. Серверный метод letters отдаёт список кратких описаний писем, а метод letter - подробные данные одного письма.

Пример задачи 2: сбор профиля пользователя из аккаунтов нескольких соц. сетей. Для каждой соц. сети есть отдельный серверный метод получения из неё профиля пользователя.

В подобных ситуациях важно помнить, что тот факт, что модели и источники данных - "это всё про данные", абсолютно не означает, что каждому источнику должна обязательно соответствовать модель. Модель должна соответствовать сущности, независимо от способа получения данных. Создание отдельной модели оправдано только тогда, когда осознанно создаётся новая сущность системы.

Для того, чтобы в ns совместить в одной модели несколько источников, нужно просто проинициализировать её из первого источника, а затем дополнить данными из второго источника с помощью методов set и setData. При этом, на модели сработают нужные события и виды, зависящие от неё обновятся в момент, предусмотренный используемой схемой обновления.

Пример из жизни: раскрытие подробностей письма в списке

// Модель-коллекция писем
// Запрашивается с сервера и автоматом разделяется
// на отдельные экземпляры модели `letter`.
// В списке приходят краткие представления данных писем
ns.Model.define('letters', {
    split: {
        model_id: 'letter',
        params: {
            id: '.id'
        }
    }
});

// Модель отдельного письма
// Может быть проинициализирована одним из двух способов:
//    1. При автоматическом разделении данных модели `letters` в `letter` будет краткое
//     представление письма.
//    2. При ручном вызове метода `fetch` произойдёт запрос к серверному методу `letter`,
//     в ответ на который ns автоматом подставит в `letter` новые данные - полное
//     представление письма.
ns.Model.define('letter', {
    params: {
        id: '.id'
    },
    methods: {
        fetch: function() {
            ns.request.models([this]);
        }
    }
});

ns.View.define('letters', {
    models: ['letters'],
    split: {
        byModel: 'letters'
        intoViews: 'letter'
    }
});

ns.View.define('letter', {
    models: ['letter'],
    events: {
        'ns-view-htmlinit': 'onhtmlinit',
        'click .js-expand-letter': 'onExpand'
    },
    methods: {
        onhtmlinit: function() {
            this.getModel('letter').on('ns-model-changed',
                ns.page.go.bind(ns.page, null)
            );
        },
        onExpand: function() {
            this.getModel('letter').fetch();
        }
    }
});

ns.layout.define('main', {
    app: {
        letters: true
    }
});

При начальной отрисовке страницы будет запрошена модель letters. Получив данные, она разделит их на отдельные экземпляры модели letter. На странице появится вид letters, содержащий отдельные виды letter.

При клике на элемент вида letter с классом .js-expand-letter у модели letter будет вызван метод fetch, который сходит на сервер за данными из источника letter и положит их в соответствующий экземпляр модели letter. После этого на модели letter произойдёт событие ns-model-changed, которое заставит страницу перерисоваться.

При повторной отрисовке вида letter в данных его модели уже будет полное представление данных письма, что позволит в шаблоне отрисовать его раскрытым.

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

Эта схема хороша тем, что все данные об экземпляре сущности сервиса (о письме) хранятся в единой модели. Это избавляет от необходимости поддержки и синхронизации избыточного количества сущностей, т.е. - логика работы с данными письма собрана в единой модели - данные одного экземпляра сущности хранятся в едином экземпляре модели. При удалении и модификации экземпляра на клиенте нужно думать только об одном экземпляре - логика отрисовки экземпляра собрана в едином виде