Модульный javascript

По мотивам последнего MeetUp’а, прошедшего у нас в Максимастере, делюсь информацией на страницах своего блога. Уверен, что для многих из вас эта информация не станет новинкой, но нам она позволила несколько систематизировать понимание того, как развивался модульный подход к разработке на js, чем он стал сейчас и в каком направлении двигается. Благодаря этому мы стали лучше понимать, как с этим работать.

Дисклеймер

Инструменты разработчиков, все эти паттерны и best practices появляются тогда и только тогда, когда в них есть потребность. Не исключено, что у вас такой потребности сейчас может не быть и все у вас хорошо с организацией своего js кода. Не нужно лезть на рожон и применять все новомодные технологии тогда, когда это не оправдано. Нужно разумно подходить к выбору инструментов и использовать их только тогда, когда это необходимо. Этим я не стараюсь сказать, что все описанное ниже — не нужно. Просто надо понимать, в каких ситуациях будет разумно вместо привычного одного скрипта на jquery применять React/Angular, а когда нет.

В процессе подготовки доклада никто не пострадал (по крайней мере — замечено не было).

В чем проблема

Javascript на сегодняшний момент является самым популярным языком в мире и используется сейчас … да где он только не используется. Изначально js разрабатывался для того, чтобы привнести немного интерактива на веб-страницы (открывать алерты, задавать вопросы, ну может «снежок для сайта») и было это в далеких 90х. Однако веб-сайты слишком быстро стали очень популярным способом доставки приложений до конечного пользователя и заложенных возможностей очень быстро перестало хватать. Веб-приложения становились все более мощными, кода становилось все больше и больше. Его становилось настолько много, что хранить его весь в одном или даже нескольких файлах становилось все труднее. А работать с приложением, разбитым на сотни файлов становилось огромной проблемой, т.к. каждый файл потребовалось бы загружать через тег script, вставляемый на страницу. Это было серьезным ограничением по сравнению с любыми другими программными окружениями, где программа могла состоять из неограниченного количества файлов, а модульная архитектура была предоставлена «из коробки». Но чем больше тегов script, тем больше запросов придется сделать до того, как приложение начнет работать, а значит и времени до первого запуска потребуется больше.
Другим серьезным ограничением было то, что даже если на уровне языка ввести модульную систему, то она не сразу появится у пользователя приложения, т.к. это потребует серьезных трудозатрат как со стороны разработчика приложения, так и со стороны браузеров (чтобы доработать поддержку стандарта).

Однако потребность уже была. Разработчики софта не могли ждать, пока в javascript появится модульная система. Да и в тот момент не было вообще никаких предпосылок к тому, что она вообще появится.

Весь js код, который загружался в браузер, все объявленные переменные работали в глобальном контексте. Это могло привести к конфликтам имен переменных при разработке приложений.

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

Какие проблемы решает модульность

Чтобы осознать необходимость модульного подхода, нужно понимать, что высокая сложность в конечном итоге является неизбежной для серьезных проектов. Одной из задач, которую решает модульность, является возможность сокрыть эту сложность за простым (но что важно, хорошо продуманным!) интерфейсом, который этот модуль предоставляет. Разные модули должны общаться между собой, а также группироваться в другие модули только через этот интерфейс.

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

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

История развития модульного javascript

Мы уже знаем, что в современном виде в js уже есть сформированная система. Однако для того, чтобы понять, почему она сформировалась именно в таком виде, нужно обратиться к истории её становления.

Для наглядности я подготовил простейшее приложение, по исходному коду которого мы будем прослеживать эволюцию модульного подхода.

Приложение

Оно чрезвычайно простое. Вы стопицот раз видели, как на сайтах в момент подгрузки какого-то содержимого отображается «загрузчик» (loader, или throbber). Наше приложение — это как-раз вот такой загрузчик. Он должен появляться и исчезать в нужные нам моменты и это приложение должно предоставлять программный интерфейс управления собой. Для большего интереса я решил сделать так, чтобы троббер каждый раз выбирал свой внешний вид из 10ти заранее предустановленных вариантов.

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

Работающее приложение в самой его первой версии можно найти по ссылке. Исходный код доступен на github. Вся бизнес-логика приложения находится в скрипте app.js.

Итак, поехали.

Namespace паттерн (конец 90х)

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

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

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

Исходный код

Directly Defined Dependencies (1999-2000)

Первая серьезная ласточка в энтерпрайзе. Этот подход используется до сих пор в разных приложениях. По такому принципу работает google closure library, а также ранее работала библиотека dojo (автор которой претендует на изобретение данного способа). Кстати, Google до сих пор использует и поддерживает closure library, т.к. на основе него построены многие инструменты гугла в браузере — поиск, аналитика, почта, гуглодоки и прочие их сервисы.

Если сильно упростить, то появляются 2 функции — provide и require (могут называться и по другому). Первая отвечает за регистрацию текущего файла как модуля, а вторая — за подключение зависимостей.

Весь код разбивается на множество маленьких файлов, которые содержат обособленные кусочки кода. Каждый такой кусочек, если его нужно подгружать как модуль, обозначается определенным именем с помощью функции provide. Имя обычно построено с помощью namespace паттерна и расположено в файловой структуре веб-сервера таким образом, чтобы неймспейс сопоставлялся с адресом файла. Например, если вы создаете модуль в неймспейсе application.loader.throbber, значит вы должны обеспечить его нахождение в файловой структуре по адресу /application/loader/throbber.js. Короче говоря — нужно определить правила и следовать им, например как это сейчас принято в php мире с PSR автозагрузкой.

А когда в коде встречается функция require с указанием имени какого-то из модулей, то производится подгрузка недостающего модуля. Имя модуля переводится в путь до файла, и модуль подгружается в приложение с помощью вставки тега script на страницу (если, конечно, он еще не был загружен ранее).

К минусам можно отнести тот факт, что чем меньше модули, тем больше придется сделать http запросов для старта приложения. Также приходилось следовать определенным правилам именования, и не всегда было понятно, где искать тот или иной модуль (зависело от разработанных правил поиска). Но тем не менее, благодаря стараниям Google, этот паттерн был существенно доработан и вполне жизнепособен на сегодняшний момент.

Исходный код

Module паттерн (2003)

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

Основой тут является самовызывающаяся лямбда-функция, результат работы которой присваивается какой-то переменной. И тут уже появляется довольно много свободы — можно применить этот паттерн совместно с namespace, и убить сразу двух зайцев. Можно расширить этот паттерн и добавлять новую функциональность объектам с помощью других модулей, внедряя зависимости через аргументы замыкания.

Исходный код

Определение зависимостей в комментариях и Внешнее определение зависимостей (2006-2007)

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

В качестве примера работы внедрения зависимостей через комментарии можно привести ныне живущий плагин для Gulprigger. Его задача — просто заменить комментарий в каком-то файле на содержимое этого файла. Для примера я добавил в файле app.js комментарии, которые ссылаются на другие файлы приложения. В момент сборки (запуск команды npm run gulp) выполняется задача для gulp, которая с помощью rigger собирает несколько файлов в один.

В качестве примера внешнего определения зависимостей можно привести тот же 1С-Битрикс. В нем есть специальный класс для работы с js модулями — \CJSCore. С помощью метода \CJSCore::RegisterExt() вы определяете набор модулей, с которыми может работать система, определяете зависимости между ними, а затем в нужном месте подключаете необходимый модуль с помощью \CJSCore::Init() и все собирается магическим образом в один бандл. Все это можно законфигурить в один файл, что-то типа deps.json, а потом парсить на бекенде и также скармливать в какой-то бандлер.

Исходный код

AMD (RequireJS) и Dependency Injection (AngularJS) (2009)

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

AMD чрезвычайно прост для понимания и своим распространением обязан библиотеке RequireJS. Нужно разбить код вашего приложения на автономные кусочки — js файлы, каждый из которых определяет собой обособленный модуль. C помощью функции define можно объявить любой кусочек кода модулем, а также объявить зависимости этого кусочка кода на другие модули.

В своем приложении я объявил два модуля — controller.js и throbber.js. В контроллере определена зависимость от троббера, т.к. контроллер сам по себе — довольно бессмысленный модуль.

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

Исходный код

В Angular первых версий была аналогичная система, но чуть более многословная. Однако построена она на немного другом принципе — Dependency Injection, который был описан Мартином Фаулером в далеком 2004м. Идея в том, что компонент не должен заниматься инициализацией собственных зависимостей, а должен принимать их извне. Но смысл тот же что и в AMD — в одном месте определяем модули (инициализируем их), а в другом месте пишем код, который от этих модулей зависит.

Исходный код

CommonJS (2009)

Данный подход появился благодаря Mozilla, а также благодаря тому, что js перебрался на сервер (как раз в этом году появился Node.js). В 2009 году сотрудником Mozilla был опубликован пост на тему того, чего очень не хватает серверному js, и предложил всем желающим присоединиться к работе над выдвинутыми предложениями. Этот проект был назван ServerJS (а в последствии был переименован в CommonJS). Самой горячо обсуждаемой стала спецификация CommonJS Modules, которая и описывала формат модулей, и которая впоследствии была реализована в Node.js.

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

Спецификация быстро прижилась, и по сей день является основной в Node.js сообществе, правда она сейчас вытесняется более современным подходом, о котором мы поговорим чуть позже.

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

Для того, чтобы подключить какой-то другой модуль, необходимо воспользоваться функцией require. Этой функции нужно передать путь до файла-модуля (вообще, тут возможны варианты, но в базовом виде — путь до файла). Функция подключит этот файл и вернет содержимое из module.exports.

Исходный код

UMD (2011)

В этот момент было два противоборствующих лагеря в модульном мире — это AMD и CommonJS. У каждого из них были свои достоинства и недостатки. У AMD это был довольно низкий порог вхождения, было очень просто начать работать с модульным кодом в браузере. Но при этом невозможно было без модификации использовать модули, написанные с AMD подходом на сервере. Тогда как на стороне сервера было разработано уже довольно много утилит, которые хотелось бы использовать в браузере вместе с AMD, но это было невозможно. Тогда и придумали Universal Module Definition. Идея проста — нужно просто проверить в каком окружении мы работаем и применить подход для экспорта интерфейса модуля соответствующий окружению.

Вариантов реализации UMD было множество. Можно рассмотреть один из них, который представляет собой адаптер для Node.js модулей. Тут хорошо видно, что весь модуль помещается в тело самовызывающейся функции. В качестве аргумента ей передается функция define, работа которой определяется в зависимости от окружения. А дальше мы пишем типичный для AMD код с define.

ECMAScript 2015 Modules (2015)

В 2015 году был выпущен стандарт, работа над которым велась с 2010 года. Одной из возможностей, описанных в стандарте, являлась нативная модульная система. Совершенно очевидно, что ключевое влияние на нее оказал стандарт CommonJS, как наиболее популярный и удобный в тот момент.

Если проводить аналогию, то объект module.exports заменяется на инструкцию export. а функция require заменяется на инструцию import. На деле, их возможности куда шире, чем у CommonJS, поэтому стоит полностью познакомиться с их синтаксисом и возможными вариантами применения.

Исходный код

Модульное мышление

Многие из нас начинали с jquery. Это во-истину, великая библиотека, которая сделала разработку на javascript гораздо проще и удобнее. Надо понимать, что библиотека предоставляет набор инструментов, который призван помочь разработчикам в решении повседневных проблем — манипулирование DOM-деревом, обработка событий, анимации, работа с ajax и т.д. Мы настолько привыкли к этому инструменту, что используем его везде.

Javascript же — это мультипарадигмальный язык программирования, и он предоставляет несколько подходов к реализации приложений. Можно использовать и объектно-ориентированный подход, и функциональный (который ныне является наиболее популярным в этом языке). Кроме того в языке присутствует событийно-ориентированная модель, без которой сложно представить разработку какого-либо пользовательского интерфейса. Никуда не денешься и от процедурного стиля.

Так уж сложилось, что в сознании разработчиков, часто использующих jquery в своих проектах (особенно у тех, кто начинал с jquery), прочно засел единый принцип написания приложений:

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

Но данная схема, несмотря на то, что она имеет множество плюсов (простота понимания, наглядность, низкий порог вхождения) имеет и ряд значительных недостатков:

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

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

Только не надо думать, что jquery плох. И с его помощью можно писать отлично структурированный, модульный код, но это требует немного бОльших усилий, чем просто лапша, к которой мы так привыкли.

Webpack и компонентный подход к разработке

Фронтенд сейчас развивается стремительными темпами. Каждый день появляются все новые и новые инструменты. На сегодняшний момент Webpack является наиболее популярным инструментом для module-bundling’а (сборки модулей).

Webpack привнес в разработку новую идеологию — модулем может быть не только js, модулем может быть вообще любой ресурс. Стили, верстка, картинки, видео — все это может быть модулем, если надо. Какая-нибудь ссылка, внешний ресурс, бинарный файл на вашем компьютере — тоже может быть модулем, если захотеть. Да что цифра … стул, на котором вы сидите, тоже может быть модулем в webpack (правда придется написать соответствующий loader), нет ничего невозможного.

Если переписывать наше приложение в компонентном виде, то можно выделить снова два компонента — controller и throbber. Каждый из этих компонентов будет включать в себя все, что необходимо для работы каждого из них — верстку, стили, js, картинки.

Весь код, необходимый для работы компонента throbber, располагается в /components/throbber/index.js, а по соседству лежат его картинки и стили. Аналогичная ситуация с компонентом controller.

Весь исходный код приложения

Стоит отметить, что тут представлен лишь пример очень малого количества возможностей webpack’а. На деле он может гораздо больше, и файлы организовывать таким способом совсем не обязательно.

Определенно, webpack несет за собой некоторый оверхед, который может только помешать. Но если у вас есть сложности с организацией кода на js, то стоит раз изучить этот инструмент, и его применение не будет вызывать труда.

Что дальше?

Ну конечно же, стоит обратиться к чужому опыту. Если вы хотите грамотно организовывать свой код, то имеет смысл посмотреть на то, как это делают профессионалы. Попробуйте react, vue.js, angular. Это такие фреймворки/библиотеки, попробовав которые хотя бы раз уже не хочется возвращаться к тому подходу, которому научил jquery.

Использованные материалы