Минификация приложений ExtJS и Sencha Touch средствами ASP.NET

в 5:05, , рубрики: Библиотека ExtJS/Sencha

Если вы пишете веб-приложения на ExtJS в связке с ASP.NET MVC и хотите минифицировать исходные файлы, но по каким-то причинам вам не нравится использовать для этого стандартный SenchaCmd, добро пожаловать под кат. Для тех, у кого нет времени и уже хочется попробовать, в конце статьи есть ссылки на библиотеку, а пока попробуем разобраться, в чём проблема и написать такой минификатор самостоятельно.

Что будет в итоге

public class BundleConfig
{
    public static void RegisterBundles(BundleCollection bundles)
    {
        bundles.Add(
            new SenchaBundle("~/bundles/my-sencha-app")
            .IncludeDirectory("~/Scripts/my-sencha-app", "*.js", true)
        );
    }
}

Intro

Итак, вы разрабатываете с помощью библиотек ExtJS 4 или SenchaTouch 2, и ваши веб-приложения структурированы так, как это рекомендуют сами разработчики библиотеки. С ростом приложения количество исходников увеличивается, что наверняка приводит к задержке загрузки, ну или вы просто хотите скрыть свой красивый исходный код от чужих глаз.

Первое, что приходит в голову это использовать SenchaCmd — продукт, который рекомендует команда Sencha. Ему можно скормить файл index.html или URL приложения, он послушно возьмёт страницу и отследит, в каком порядке были загружены исходники, после чего отдаст минификатору, и на выходе вы получите что хотели.

В чём неудобство? Здесь мнения могут разниться, но IMHO для сжатия файлов SenchaCmd тяжеловат. В процессе участвуют Java-приложение, nodejs и phantomjs. В принципе, для таких редких операций как минификация перед загрузкой на сервер, может и сгодится, но есть ещё нюансы. Например, Index.cshtml ему не отдашь: участки с Razor-разметкой не поймёт. Можно дать URL приложения, но если у вас используется аутентификация до прохождения которой загружается не всё приложение, то в минифицированном файле тоже будут не все исходники. А в случае с Windows-аутентификацией вообще всё плохо.

Намного проще было бы сказать: «Вот тебе папка, сам разберись, что к чему и дай мне сжатый файл». На просторах интернета полным-полно минификаторов, но среди нет тех, кто мог бы установить зависимости между исходными файлами. Попробуем это исправить.

Приступим

В стеке ASP.NET уже есть инструмент для конкатенации и минификации — Bundles. Ему нужно только немного помочь — а именно, подсказать, в каком порядке склеивать исходники.

BundleConfig.cs
public class BundleConfig
{
    public static void RegisterBundles(BundleCollection bundles)
    {
        bundles.Add(
            new ScriptBundle("~/bundles/my-sencha-app")
            {
                Orderer = // ?
            }
            .IncludeDirectory("~/Scripts/my-sencha-app", "*.js", true);            
        );
    }
}

То, что нужно! Посмотрим на Orderer.

IBundleOrderer

public interface IBundleOrderer
{
    IEnumerable<BundleFile> OrderFiles(BundleContext context, IEnumerable<BundleFile> files);
}

На входе коллекция файлов, на выходе — тоже, только отсортированная. Давайте подумаем, как их упорядочить. В ExtJS есть несколько способов определить зависимости.

Явные:

  • Вручную в коде через Ext.require
  • Через конфигурационное свойство класса requires

Неявные (только конфигурационные свойства):

  • При наследовании — extend
  • При указании примесей — mixins
  • При указании модели хранилища — model
  • При указании представлений, моделей и хранилищ контроллера — views, models, stores
  • При указании контроллеров приложения — controllers
  • При автоматическом создании Viewport — autoCreateViewport: true

К первому случаю претензий иметь не будем — в коде значит в коде. Остальные вполне поддаются анализу.

Определимся со структурой программы. Для начала у нас есть JS-файл. Он может иметь несколько классов внутри, каждый из которых может иметь зависимости на другие классы:

SenchaFile.cs

public class SenchaFile
{
    /// <summary>
    /// Классы внутри файла
    /// </summary>
    public IEnumerable<SenchaClass> Classes { get; set; }

    /// <summary>
    /// Зависимости файла
    /// </summary>
    public virtual IEnumerable<SenchaFile> Dependencies { get; set; }
}

SenchaClass.cs

public class SenchaClass
{
    /// <summary>
    /// Имя класса
    /// </summary>
    public string ClassName { get; set; }

    /// <summary>
    /// Имена зависимостей
    /// </summary>
    public IEnumerable<string> DependencyClassNames { get; set; }
}

Теперь нужно как-то определить, какие классы описаны в файлах. Можно поискать регулярками, например, но я бы отложил этот скилл на потом. Тем более, что у нас есть JSParser из Microsoft.Ajax.Utilities. Он выдаёт содержимое JS-файла в виде дерева блоков, каждый из которых может быть например, вызовом функции, обращению к свойству и т.д. Поищем, где в файле создаются экземпляры приложения (Ext.application), определяются или переопределяются классы (Ext.define, Ext.override):

SenchaFile.cs

public class SenchaFile
{
    // ..
    /// <summary>
    /// Получить классы, описанные в файле
    /// </summary>
    protected virtual IEnumerable<SenchaClass> GetClasses()
    {
        var extApps = this.RootBlock.OfType<CallNode>()
            .Where(cn => cn.Children.Any())
            .Where(cn => cn.Children.First().Context.Code == "Ext.application")
            .Select(cn => cn.Arguments.OfType<ObjectLiteral>().First())
            .Select(arg => new SenchaClass(arg) { IsApplication = true });

        var extDefines = this.RootBlock.OfType<CallNode>()
            .Where(cn => cn.Arguments.OfType<ConstantWrapper>().Any())
            .Where(cn => cn.Arguments.OfType<ObjectLiteral>().Any())
            .Where(cn =>
            {
                var code = cn.Children.First().Context.Code;
                return code == "Ext.define" || code == "Ext.override";
            })
            .Select(cn =>
            {
                var className = cn.Arguments.OfType<ConstantWrapper>().First().Value.ToString();
                var config = cn.Arguments.OfType<ObjectLiteral>().First();
                return new SenchaClass(config) { ClassName = className };
            });           

        foreach (var cls in extApps.Union(extDefines))
        {
            yield return cls;
        }
    }
}

Следующим шагом необходимо определить зависимости каждого класса. Для этого возьмём тот же JSParser и пройдёмся по всем случаям определения зависимостей (явным и неявным), описанным выше. Приводить код не буду, чтобы не загружать статью, но суть та же: перебираем дерево блоков в поисках нужных свойств и выбираем имена используемых классов.

Теперь у нас в наличии список файлов, у каждого файла найдены описанные в нём классы, а у каждого класса — его зависимости. И нужно как-то расставить их в порядке очереди. Для этого существует так называемая топологическая сортировка. Алгоритм несложный и для интересующихся есть онлайн-демка:

Минификация приложений ExtJS и Sencha Touch средствами ASP.NET

SenchaOrderer.cs

public class SenchaOrderer
{
    /// <summary>
    /// Рекурсивная функция топологической сортировки
    /// </summary>
    /// <param name="node">Узел, с которого начинать</param>
    /// <param name="resolved">Лист файлов в порядке очереди</param>
    protected virtual void DependencyResolve<TNode>(TNode node, IList<TNode> resolved)
        where TNode: SenchaFile
    {
        // При входе в узел помечаем его серым
        node.Color = SenchaFile.SortColor.Gray;

        // Идём по его зависимостям
        foreach (TNode dependency in node.Dependencies)
        {
            // Если мы в этом узле не были (он белый), заходим вглубь
            if (dependency.Color == SenchaFile.SortColor.White)
            {
                DependencyResolve(dependency, resolved);
            }
            // А если были (серый), то всё плохо: есть циклическая зависимость
            else if (dependency.Color == SenchaFile.SortColor.Gray)
            {
                throw new InvalidOperationException(String.Format(
                    "Circular dependency detected: '{0}' -> '{1}'", 
                    node.FullName ?? String.Empty, 
                    dependency.FullName ?? String.Empty)
                );
            }
        }

        // Но лучше, чтобы циклов не было... 
        // При выходе из узла добавляем его в очередь, метим чёрным и больше не возвращаемся.
        node.Color = SenchaFile.SortColor.Black;
        resolved.Add(node);
    }

    /// <summary>
    /// Отсортировать файлы используя топологическую сортировку
    /// </summary>
    /// <param name="files">Файлы для сортировки</param>
    /// <returns>Отсортированная коллекция SenchaFileInfo</returns>
    public virtual IEnumerable<TNode> OrderFiles<TNode>(IEnumerable<TNode> files)
        where TNode: SenchaFile
    {
        var filelist = files.ToList();

        // Коллекции файлов с неразрешёнными и разрешёнными зависимостями
        IList<TNode> unresolved = filelist;
        IList<TNode> resolved = new List<TNode>();

        TNode startNode = unresolved
            .Where(ef => ef.Color == SenchaFile.SortColor.White)
            .FirstOrDefault();

        while (startNode != null)
        {
            DependencyResolve(startNode, resolved);
            startNode = unresolved
                .Where(ef => ef.Color == SenchaFile.SortColor.White)
                .FirstOrDefault();
        }

        return resolved;
    }
}

Вот как бы и всё. Ещё пара служебных файлов и можно пользоваться:

BundleConfig.cs

public class BundleConfig
{
    public static void RegisterBundles(BundleCollection bundles)
    {
        bundles.Add(
            new SenchaBundle("~/bundles/my-sencha-app")
            .IncludeDirectory("~/Scripts/my-sencha-app", "*.js", true)
        );
    }
}

Index.cshtml

...
<script src="@Url.Content("~/bundles/my-sencha-app")" type="text/javascript"></script>

Итого

В чём плюсы такого решения? Я думаю, очевидно: использовать стандартную функциональность, предусмотренную фреймворком ASP.NET. В чём минусы? Они тоже есть:

  • Старт веб-приложения несколько задерживается, пока минифицируются файлы.
  • Алгоритм чувствителен к написанию кода, например, autoCreateViewport: true он поймёт, а autoCreateViewport: !0 — уже нет (без допиливания).
  • Приложение ExtJS или SenchaTouch необходимо создавать строго через вызов Ext.application.

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

Попробовать

  1. NuGet. Пакет SenchaMinify.
  2. Проект на GitHub с демками.

На гитхабе также включён проект самостоятельного exe-файла для командной строки (SenchaMinify.Cmd). Так что желающие могут использовать свои любимые средства автоматизации.

Буду рад конструктиву, идеям или пулл-реквестам.

Автор: alexstz

Источник


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


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