Идея данной статьи родилась после тяжелого рабочего дня при 30 градусах в офисе и тяжких раздумий и холиваров на тему: «А как должно строиться современное веб-приложение?»
И тут мне пришла в голову мысль изложить свой процесс работы над задачей на Хабре. И сам разберусь до мелочей, и внесу вклад в знания сообщества.
О чем пойдет речь в данной статье? Я напишу (не)большое приложение на TypeScript, которое будет реализовывать модульную архитектуру, асинхронную загрузку модулей, абстрактную событийную модель и обновление состояния модулей по наступлению определенных событий. Эта статья будет выступать как бы дневником и журналом моих действий и размышлений. Моя личная цель — создать некоторый рабочий прототип, опыт создания которого я потом мог бы использовать в рамках реального проекта. Код будет писаться максимально аккуратно и близко к требованиям реальной разработки. Пояснения будут даваться так, будто это потом будут читать работающий под моим руководством джуниоры, которые вообще до этого никогда такие системы не писали.
Статья будет разбита на куски, которые я буду выкладывать на суд общественности по мере готовности. Первая часть посвящена общей постановке задачи, модулям и их асинхронной загрузке.
Итак, дав себе и сообществу эти обещания, включив AC/DC и собравшись с мыслями я приступаю.
Используемое ПО и прочие замечания
В рамках данной статьи я буду использовать в качестве рабочего инструмента Visual Studio Express 2012 for Web со всеми последними обновлениями. Причина — это единственный IDE с адекватной поддержкой TypeScript на сегодня.
По поводу самого TypeScript. Трехмесячный опыт использования TS 0.8.3 показывает, что TS это реально работающий инструмент для создания действительно больших приложений для веб, насчитывающих десятки тысяч строк кода. Статический анализ кода реально уменьшает количество ошибок на порядок. Также с нами почти полноценный классический ООП, реальная модульность на уровне языка, позволяющая прозрачно интегрироваться с Require.js и Node.js, IntelliSense в Visual Studio да и вообще идеология явно отдающая C#, что крайне мне близко. Прозрачная трансформация TS в JS позволяет элементарно отлаживать код даже без помощи sourcemaps, хотя и они присутствуют. Для написания статьи я буду использовать последнюю версию — 0.9 с generics и прочими плюшками.
Require.js будет использоваться для реализации асинхронной загрузки. Node.js будет использоваться для имитации серверной части.
В качестве «стандартной библиотеки» в проект будут подключены jQuery 1.10 (Совместимость с IE8 нам нужна) и Underscore.js. Основой для интерфейса для нас послужит Backbone.js. Заголовочный d.ts файлы используем из стандартной поставки TS (jQuery) и из проекта DefinitelyTyped — github.com/borisyankov/DefinitelyTyped (Require.js, Underscore, Backbone, Node.js).
Для воспроизведения AC/DC используется WinAmp.
Чуть-чуть о TypeScript
Программа на TS состоит из набора *.ts и *.d.ts файлов. Их можно представить как некоторые аналоги *.cpp и *.h файлов из С++. Т.е. первые содержат реальный код, вторые описывают интерфейсы, которые предоставляет одноименный файл реализации. Для разработки чисто на TS заголовочный d.ts файлы не нужны. Также d.ts ни в чего не компилируется и не используется в рантайме. Но d.ts файлы незаменимы для описания существующего JS кода. Т.к. TS полностью преобразуется в JS, он может полноценно использовать любой JS код, но компилятору необходимо знать о тех типах, переменных и функциях, которые используются в JS. D.ts файлы как раз служат целям этого описания. Подробнее останавливаться не буду, все есть в спецификации TS.
В общем случае при компиляции example.ts могут быть созданы следующие файлы:
- example.d.ts — заголовочный файл для использования в других проектах
- example.js — JS для использования в рантайме
- example.map.js — sourcemap для отладки
Структура проекта
Создадим проект TypeScript в Visual Studio:
Все исходники я буду публиковать на CodePlex: tsasyncmodulesexampleapp.codeplex.com/.
Добавляем код по-умолчанию, создаем 2-й проект — Server, подключаем все необходимые исходные файлы библиотек и получаем следующую структуру:
Практика показывает, что d.ts файлы для общих библиотек лучше выносить на один уровень с проектами, т.к. это потом страшно пригодится при написании тестов, но об этом в другой раз, а также, если несколько проектов используют одни и те же библиотеки.
Здесь следует чуть-чуть поговорить о том, как собирает проект TypeScript в Visual Studio и без него. В первом случае студия все делает за разработчика, передавая компилятору файлы по одному, если на них нет ссылок. Т.е. все ts файлы всегда будут скомпилированы. Если мы будем собирать проект из командной строки, то необходимо, чтобы сборка начиналась с файла имеющего ссылки на все остальные файлы. Обычно я создаю в корне проекта файл Build.d.ts, содержащий ////>, т.е. ссылки на все файлы проекта, которые необходимо собрать и передаю его компилятору через консоль, т.к. этот путь позволяет куда более гибко управлять настройками компилятора TS, нежели текущий плагин в студии, что нам обязательно потребуется в дальнейшем.
Node.exe и полный дистрибутив TS добавлены для того, чтобы не завязываться на студию и прочее установленное ПО при ознакомлении с проектом. Cmd файлы для удобного запуска и интеграции со студией я напишу позже.
Описание учебной задачи
В качестве учебного примера я буду писать простой клиент для системы личных сообщений на сайте, состоящей из нескольких независимых интерфейсных компонентов, некоторого промежуточного слоя для загрузки данных на сервер и фэйкового сервера для имитации бурной деятельности. Общение с сервером будет происходить через restful сервисы, качественное написание которых не является основной задачей. Достаточно, чтобы они просто работали. В системе будет 2 экрана — краткий список из последних 3-х сообщений и экран полноценного клиента. Также будет некоторое меню, которое позволит переключаться между ними. Авторизацию и т.п. в данной статье я рассматривать не буду.
Экраны представляют собой полностью автономные модули, которые не знают друг о друге, но знают о слое доступа к данным. Все экраны и объекты доступа к данным обертываются в отдельные модули, загружаются асинхронно и общаются между собой путем публикации и подписки на события через некоторый менеджер событий, т.е. согласно паттерну publisher/subscriber.
Настройка модульной загрузки
Все очень просто. В файл App.ts проекта Client, который мы получили по-умолчанию добавляем следующий код:
export class App
{
public static Main(): void
{
alert('Main');
}
}
Создаем файл RequireJSConfig.ts в корне проекта:
/// <reference path="../Lib/require.d.ts" />
/// <reference path="App.ts" />
require(["App"], function(App: any)
{
App.App.Main();
});
Default.htm:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>TSAsyncModulesExampleApp</title>
<link rel="stylesheet" href="app.css" type="text/css" />
<script src="js/jquery-1.10.1.min.js"></script>
<script src="js/underscore-min.js"></script>
<script src="js/backbone.js"></script>
<script src="js/require.js" data-main="RequireJSConfig"></script>
</head>
<body>
</body>
</html>
Запускаем приложение:
Поздравляю, мы получили приложение с асинхронно загружаемыми модулями.
Остановимся на том, что мы сделали поподробнее.
Во-первых, стоит остановиться на том, как компилируются файлы в TS. Если файл содержит директиву export, то это означает, что он однозначно будет скомпилирован в модуль CommonJS или AMD в зависимости от настроек компилятора. По-умолчанию, в VS компиляция идет в формате AMD, что нас более чем устраивает в контексте использования Require.js. TS полностью избавляет нас от необходимости писать «жуткий» ручной код обертки AMD модулей и сам заботится о корректной установке зависимостей. Именно такое поведение мы наблюдаем для App.ts:
define(["require", "exports"], function(require, exports) {
var App = (function () {
function App() {
}
App.Main = function () {
alert('Main');
};
return App;
})();
exports.App = App;
});
//@ sourceMappingURL=App.js.map
RequireJSConfig.ts не содержит директив export и компилируется в обычный «плоский» JS:
/// <reference path="../Lib/require.d.ts" />
/// <reference path="App.ts" />
require(["App"], function (App) {
App.App.Main();
});
//@ sourceMappingURL=RequireJSConfig.js.map
Комментарии остаются в коде исключительно в целях отладки, т.к. у меня стоит Debug режим сборки приложения. В Release конфигурации все будет очищено от комментариев.
Но, вернемся к нашим бар… модулям. Что же произошло:
- Загрузился default.htm
- Загрузились css и статически заданные js файлы, которые нет никакого, на мой субъективный взгляд, смысла грузить асинхронно, т.к. они нужны почти всем модулям, которые мы будем создавать.
- Среди js в п.2 загрузился require.js
- Require.js прочитал значение аттрибута data-main=«RequireJSConfig» и загрузил соответствующий JS файл, который трактуется как стартовый.
- В RequireJSConfig мы первый и последний раз используем метод require в глобальном контексте. Далее все вызовы модулей должны происходить из других модулей.
- В функции require мы говорим, что после загрузки модуля App (первый параметр), необходимо вызвать callback, куда передать загруженный модуль в виде одноименной переменной. Тут мы идем на сделку с совестью и не типизируем ее в TS, т.к. в данном месте происходит конфликт между идеологией разбиения на модули в TS и конкретной реализаций Require.js, как менеджера асинхронной загрузки скриптов. Подробнее чуть ниже.
- Require.js загружает модуль App. Об соглашениях об именовании модулей детально можно прочитать в документации к Require.js. Если кратко, то указываем путь от корня, который мы можем указать отличным от корня сайта, который мы используем по-умолчанию, опуская расширение файла. Далее RequireJS загружает каждый зависимый файл как тэг script, используя head.appendChild(). Загрузка требуемого модуля происходит последней, т.е. после зависимостей, что означает, что мы всегда можем быть уверены в том, что все зависимости всегда загружены. Require.js и TS работают в данном вопросе полностью согласованно. Синтаксис и процесс компиляции TS специально адаптированы для данного сценария.
- Callback, переданный в метод require, вызывает статический метод Main, класса App, модуля App.
- Вызывается alert('Main');
Параметр callback'а метода require не типизируется из-за того, что данный тип не известен в данном контексте компилятору TS. Чтобы его типизировать, необходимо использовать конструкцию TS следующего вида:
import App = require('App');
Эта конструкция может быть использована только в контексте модуля для загрузки других модулей и приводит к формированию соответствующих зависимостей в обертке AMD/CommonJS модуля, которого у нас тут нет, т.к. у нас обычный плоский код, без оберток, т.е. получаем противоречие. Иными словами, TS полностью согласован с Require.js в области генерации кода модулей, но никак не поддерживает сценарий загрузки первого модуля. Поэтому в данной ситуации единственное, что нам остается — откатиться к использованию старого доброго подхода в стиле ванильного JS.
Загрузка дополнительных модулей
Итак, первый модуль мы загрузили, метод Main вызвали, приложение начало работать. Согласно нашему учебному ТЗ, мы должны уметь грузить произвольное количество модулей. Но перед тем, как приступить к загрузке, надо понять, что такое модуль в TS:
- Во-первых, это автономная часть приложения, кусок кода реализующий паттерн модуля, экспортирующий функции и переменные наружу. При этом вся реализация скрыта от внешнего мира. Фактически, это инкапсуляция в чистом виде.
- Во-вторых, модуль после загрузки присваивается переменной. Т.е. мы можем работать с ним, как с объектом. По-сути, модуль и есть объект в терминах JS.
- В-третьих, модуль это файл.
- В-четвертых, модуль это пространство имен для TS. Могут быть неэкспортируемые модули, объявленные внутри других модулей, реализующие пространства имен в чистом виде. Т.е. модули могут иметь неограниченное количество уровней вложенности, но только модуль верхнего уровня может быть преобразован в AMD/CommonJS модуль и приведен к автономному файлу.
Фактически, модуль это все и сразу. Использование модулей так же неоднозначно. Единственное, что можно сказать точно — модули разбивают приложение на автономные, повторно используемые компоненты, что полностью отвечает нашим целям.
Создадим новый файл Framework/Events.ts, который в дальнейшем будем использовать в качестве стартовой точки для реализации нашей абстрактной событийной модели:
export interface IEventPublisher
{
On();
Off();
Trigger();
}
export class EventPublisherBase implements IEventPublisher
{
On() { }
Off() { }
Trigger() { }
constructor()
{
alert('EventPublisher');
}
}
Пока все методы являются просто заглушками. Нам важен сам принцип работы.
Изменим App.ts:
import Events = require('Framework/Events');
export class App
{
public static Main(): void
{
var e = new Events.EventPublisherBase();
}
}
У нас появилась директива загрузки модуля:
import Events = require('Framework/Events');
Путь указывается от корня. Как было написано выше, модуль присваивается переменной. Далее мы создаем новый экземпляр класса EventPublisherBase, из пространства имен Events, в конструкторе которого у нас объявлен alert:
При этом переменная Events строго типизирована. Компилятор строго отслеживает типы загружаемых модулей. Следует отметить, что компилятору не нужна деректива reference для контроля типов в модулях, но она необходима ему для корректной компиляции. Т.е. компилятор не сможет отследить зависимости без нее. Сейчас за компилятор работу делает VS, подставляя ему имена файлов в явном виде. Решить это можно создав уже упоминавшийся файл Build.d.ts запуская компиляцию относительно него:
/// <reference path="Framework/Events.ts" />
/// <reference path="App.ts" />
/// <reference path="RequireJSConfig.ts" />
Пример cmd файла(Build-Client.cmd), который можно использовать для компиляции, приложен в корне решения.
Собственно, в части асинхронной загрузки это все. Все очень просто и просто работает.
Надеюсь мой графоманский опус будет интересен сообществу. В следующей части статьи я планирую рассмотреть вопросы построения абстрактной событийной модели и причин ее использования в асинхронных веб приложениях.
Всем спасибо за то, что вы дочитали до конца!
Автор: Keeperovod