3 года назад в версии 0.2.2 GPF-JS появился механизм полиморфной модульности, который имитирует реализацию NodeJS в RequireJS и CommonJS. Это было на удивление легко сделать на всех поддерживаемых хостах, поскольку библиотека предлагает базовые услуги. Этот API сочетает в себе множество технологий, вот контекст и подробности реализации.

Версия 0.2.2

Выпуск GPF-JS 0.2.2 вышел выпущенным и принес несколько улучшений:

  • Лучшее качество кода
  • Лучшая документация
  • Некоторые тесты были переписаны

Но самая захватывающая часть — это новое пространство имен gpf.require, которое предоставляет помощник модуляризации.

Чтобы дать немного контекста, статья начнется с объяснения того, как модульность помогает разработчикам создавать лучший код. Затем будет дан краткий обзор некоторых существующих модульных решений. Наконец, будет рассмотрена реализация и будущее gpf.require.

Один файл, чтобы управлять ими всеми

Один файл, чтобы собрать их всех и во тьме связать их…

…В Стране Мордора, где обитают Тени.

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

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

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

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

catName("Chloe");
function catName(name) {
  console.log("My cat's name is " + name);
}
// The result of the code above is: "My cat's name is Chloe"

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

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

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

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

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

Осталось два вопроса:

  • Каковы могут быть преимущества наличия одного исходного файла?
  • Каков максимальный размер файла?

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

Например:

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

В GPF-JS среднее количество строк на исходный файл чуть меньше 100. Но так было не всегда!

Разделяй и властвуй

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

Звучит просто, но это не так.

Когда дело доходит до решения о том, как разделить приложение и упорядочить исходные коды, должны существовать правила и дисциплина.

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

Организация файлов

В Интернете есть множество руководств и инструкций в зависимости от типа проекта или технологии.

Например:

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

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

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

  • Задокументируйте структуру и сделайте ее известной: попросите людей Прочитать сказочное руководство.
  • Имена папок используются для определения файлов, которые они содержат. Действительно, если папка называется контроллер, то более чем ожидаемо найти в ней только контроллеры. Точно так же для веб-приложения общедоступная папка обычно указывает на то, что ее содержимое открыто.
  • Квалификация папки может быть технической («источники», «тесты», «общедоступная») или функциональной («контроллеры», «представления», «диалоги», «поток»), но следует избегать смешивания на одном уровне. Для одного стека технологий, если диалоги реализованы с помощью контроллеров: имеет смысл видеть «диалоги» ниже «контроллеров», но их размещение в одной папке может привести к путанице.
  • Старайтесь придерживаться общепринятых (и понятных) названий: используйте «public» вместо «www», используйте «dist» или «release» вместо «shipping»…
  • Старайтесь избегать слишком общих названий: «папка» (правдивая история), «разное», «утилита», «помощники», «данные»…
  • Придерживайтесь одного языка (не смешивайте французский и английский)
  • Выберите и придерживайтесь формализма именования, например Camel Case.
  • Забудьте об ограничениях 8.3 или MAX_PATH, имена могут быть сколь угодно длинными

Уровень детализации

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

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

В случае сомнений просто свяжите:

Затем постарайтесь придерживаться SOLID принципов.

В частности, принцип единой ответственности должен определять выбор при создании нового файла.

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

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

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

Модули

Преимущества модулей многочисленны:

  • Тестируемость: иногда бывает сложно провести грань между модульным тестированием и интеграционным тестированием. Короче говоря, если для запуска теста требуется несколько модулей, это звучит как интеграция. С другой стороны, если модуль можно протестировать без каких-либо зависимостей или с очень небольшим количеством зависимостей (возможно, с помощью насмешек), это модульное тестирование. Таким образом, в идеале модуль можно легко изолировать для модульного тестирования.
  • Удобство сопровождения: помимо удобства тестирования размер файла должен быть относительно небольшим. Оба факта значительно повышают ремонтопригодность модуля. Это означает, что легче (с точки зрения сложности и усилий) улучшить или исправить модуль.
  • Повторное использование: сюда никто не указывает. Каждый разработчик начинает свою карьеру с изучения преимуществ копирования и вставки. Требуется время и опыт, чтобы понять, что дублирование кода — это зло. Модули являются бесспорной альтернативой копированию и вставке, поскольку они предназначены для повторного использования. Загрузка

Загрузка модулей

Все вместе

Способ загрузки модулей различен для каждого хоста, и некоторые примеры будут показаны в разделе «Существующие реализации». Именно здесь GPF-JS приносит пользу, предлагая единое решение для всех поддерживаемых хостов.

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

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

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

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

В результате на HTML-странице требуется большой список тегов скрипт, чтобы включить все источники.

Например, рассмотрим следующие модули: A, B, C, D, E и F.

  • A требует загрузки B и C
  • B требует загрузки D
  • E требует загрузки F
  • F можно загрузить отдельно

Полученные упорядоченные списки могут быть:

  • D, B, C, A, F, E
  • C, D, F, B, A, E
  • F, D, C, B, A, E
  • F, E, D, B, C, A

Преимущества:

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

Недостатки:

  • Каждый раз, когда создается новый источник, он должен быть добавлен в список в правильном порядке для загрузки
  • Чем больше файлов, тем больше список (и тем сложнее его поддерживать)
  • Когда список становится слишком большим, можно потерять представление о том, какие файлы действительно необходимы. В худшем случае некоторые из них могут быть загружены, даже если они больше не используются.
  • Любое изменение зависимостей подразумевает переупорядочивание списка

Это не самое предпочтительное решение, но именно так обрабатывается исходная версия GPF-JS. Ленивый я создал несколько инструментов для ведения этого списка на панели инструментов.

Ленивая загрузка

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

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

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

  • Загрузка приложения производится со списком загрузки: D, B, C, A
  • И, при необходимости, функция, реализованная E, загружается с помощью: F, E

Преимущества:

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

Недостатки:

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

Управление зависимостями

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

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

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

Вернемся к предыдущему примеру:

  • A содержит информацию о том, что B и C должны быть загружены
  • B содержит информацию о том, что D должен быть загружен
  • E содержит информацию о том, что F должен быть загружен Тогда:
  • Загрузка приложения выполняется путем загрузки A
  • И, когда требуется, функция, реализованная E, загружается с помощью E

Преимущества:

  • Не нужно поддерживать большие списки, каждый модуль объявляет свои зависимости отдельно
  • Лучшая видимость зависимостей модулей
  • Упрощенное повторное использование модулей
  • Новые файлы загружаются, если объявлены как зависимости
  • Загружаются только необходимые файлы

Недостатки:

  • Загрузка модуля более сложная: зависимости должны извлекаться и разрешаться рекурсивно.
  • Такой высокий уровень детализации увеличивает риск запутанных зависимостей и, в худшем случае, взаимоблокировок. Например, три модуля: A, B и C. Если A зависит от B, B зависит от C и C зависит от A, загрузить ни один из них невозможно.
  • Если один модуль является общей зависимостью для нескольких других модулей, его следует загрузить только один раз. В противном случае производительность загрузки будет снижена из-за многократной загрузки одного и того же модуля.

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

Интерфейс

Предполагая, что модули имеют информацию о своих зависимостях, как они получают доступ к предоставляемым функциям?

Глобальные переменные

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

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

// No dependencies
var ANY_CONSTANT = "value";
function myExportedFunction () {
  /* ...implementation... */
}

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

Главное преимущество — простота.

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

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

Это означает, что это дает меньше гибкости, когда дело доходит до обслуживания модуля.

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

// No dependencies
var
  ANY_CONSTANT = "value",
  myExportedFunction;
// IIFE
(function () {
  "use strict";
  // Begin of private scope
  // Any functions or variables declared here are 'private'
  // Reveal function
  myExportedFunction = function () {
    /* ...implementation... */
  }
  // End of private scope
}());

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

Интерфейс модуля

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

В этот момент есть несколько возможностей:

  • Изменение кода для поддержки обоих форматов в одной функции не вариант: ради удобства сопровождения должна быть функция-оболочка для определения формата и последующего переключения на правильную реализацию.
  • Переименуйте методы сохранения и загрузки, указав номер версии (например, saveV1, loadV1, saveV2 и loadV2). Затем код (производственный и тестовый) необходимо изменить, чтобы использовать правильный метод в зависимости от формата, который необходимо сериализовать.
  • Создайте пространство имен для каждой версии и установите методы внутри соответствующего пространства имен: app.io.v1.save, app.io.v1.load, app.io.v2.save и app. io.v2.загрузить. Опять же, код должен быть адаптирован, но при условии, что пространство имен может быть параметром (благодаря JavaScript, где пространства имен являются объектами), это снижает стоимость.
  • Определите интерфейс и создайте один модуль для каждой версии, предоставляющий этот интерфейс. Это почти как с пространством имен, но доступ к нужному объекту зависит не от глобального именования, а скорее от загрузки правильного модуля.

Например :

var
  currentVersion = 2,
  io = loadModule("app/io/v" + currentVersion);
// io exposes save and load methods
function save (fileName) {
  // Always save with the lastest version
  return io.save(fileName);
}
function load (fileName) {
  var version = detect(fileName);
  if (version !== currentVersion) {
    return loadModule("app/io/v" + version).load(fileName);
  }
  return io.load(fileName);
}

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

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

Загрузчик модулей

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

  • Приложение состоит из модулей, которые представляют собой меньшие части, инкапсулирующие отдельные функции.
  • Чтобы упростить загрузку приложения, модули объявляют свой список зависимостей.
  • Загрузка модуля дает доступ к данному набору API, также известному как интерфейс.

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

Насмешка

Еще одно тонкое преимущество модульности связано с модульным тестированием.

Возвращаясь к примеру модуля B, требующего загрузки модуля D: что, если модуль D нельзя загрузить или использовать в тестовой среде? Для этого модуля могут потребоваться критически важные ресурсы. Например, он может получить доступ к базе данных и изменить ее содержимое: никто не хочет, чтобы тесты искажали реальные данные.

Означает ли это, что модуль B нельзя протестировать, потому что модуль D не подлежит тестированию?

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

Существующие реализации

Браузер включает

Самый простой способ включить исходный код JavaScript в HTML-страницу — добавить тег script.

Несмотря на то, что загрузка является асинхронной и если атрибуты async или defer не указаны, скрипты оцениваются в том же порядке, что и объявлен на странице.

По умолчанию область оценки является глобальной. Следовательно, объявление любой переменной доступно глобально, как если бы оно было членом объекта окна.

Как объяснялось ранее, можно создать частную область с помощью IIFE. Предоставить интерфейс модуля можно с помощью назначения новых членов объекту окна или с помощью this в IIFE.

Вот небольшая вариация, чтобы сделать экспортируемый API более явным:

(function (exports) {
  "use strict";
  // Begin of private scope
  // Any functions or variables declared here are 'private'
  // Reveal function
  exports.myExportedFunction = function () {
    /* ...implementation... */
  }
  // End of private scope
}(this));

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

ТребоватьJS

Библиотека RequireJS изначально создавалась для браузеров, но работает и с другими хостами (Rhino).

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

define([
  "dependency"
], function (dependency) {
  "use strict";
  // Begin of private scope
  // Any functions or variables declared here are 'private'
  // Reveal function
  return {
    myExportedFunction: function () {
      /* ...implementation... */
    }
  };
  // End of private scope
});

В статье Понимание RequireJS для эффективной загрузки модулей JavaScript описаны все шаги для начала работы с библиотекой.

NodeJs

NodeJS предлагает помощник модульности, изначально вдохновленный CommonJS.

Загрузить модуль так же просто, как вызвать функцию require: загрузка выполняется синхронно. Точно так же модуль может загружать свои зависимости, используя require.

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

Но обычный способ предоставить доступ к API модуля — назначить членов объекту module.exports (можно также использовать экспорт ярлыков).

Типичный модуль будет выглядеть так:

"use strict";
// Begin of private scope
// Any functions or variables declared here are 'private'
var dependency = require("dependency");
// Reveal function
module.exports = {
  myExportedFunction = function () {
    /* ...implementation... */
  }
};
// End of private scope

На самом деле есть два типа модулей.

Модули NPM

В репозитории NPM хранится огромная коллекция модулей, которые можно загружать и обновлять с помощью командной строки NPM (устанавливается вместе с NodeJS). Эти модули обычно определяются как зависимости проекта через файл package.json или могут быть установлены глобально.

При загрузке этих модулей на них ссылаются по абсолютному имени.

Например, модуль NPM GPF-JS после установки может быть загружен:

var gpf = require("gpf-js");

Вы можете поэкспериментировать сами.

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

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

var CONST = require("./const.js");

ФантомJS

PhantomJS предлагает подход, аналогичный NodeJS, когда дело доходит до загрузки модулей: функция require. Однако это пользовательская и ограниченная реализация. Подробности смотрите в следующей цепочке вопросов.

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

Бандлеры

Сборщик JavaScript — это инструмент, предназначенный для объединения приложения, состоящего из модулей (и ресурсов), в один самодостаточный и простой для загрузки файл.

Хорошие части:

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

Плохие части:

  • Сгенерированный код обычно обфусцирован, отладка может быть сложной.

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

  • Файл приложения должен быть сгенерирован перед запуском приложения. В зависимости от количества файлов это может занять некоторое время

Просматривать

Browserify — это сборщик, понимающий синтаксис требований NodeJS и способный собирать все зависимости JavaScript для создания одного файла, загружаемого в браузере (отсюда и название).

Веб-пакет

WebPack похож на браузер на стероидах, поскольку он делает больше, чем просто собирает и упаковывает зависимости JavaScript. Он также может встраивать файлы ресурсов, такие как изображения, звуки…

Проект бубу-таймер основан на WebPack.

Транспиляторы

  • Большинство транспилеров также являются сборщиками:
  • Вавилон, который понимает ES6
  • CoffeeScript, своего рода промежуточный язык, скомпилированный в JavaScript
  • Typescript, который можно чрезвычайно резюмировать с помощью надмножества JavaScript с проверкой типов.

Если кто-то планирует использовать тот или иной, следующие статьи могут помочь:

Хост сценариев Windows

Командные строки Microsoft cscript и wscript предлагают простой и немного устаревший, но все же интересный хост сценариев, позволяющий использовать объекты ActiveX.

Он может запускать любой файл JavaScript (расширение .js) при условии, что система правильно настроена или путем настройки движка с помощью выбора движка /E. В GPF-JS тестирование wscript так настроено.

Загрузка вручную

Этот хост не предоставляет никаких стандартных помощников модульности для файлов JavaScript. Один распространенный обходной путь состоит в загрузке и оценке файла с помощью объекта FileSystem и eval (что зло):

var
  fso = new ActiveXObject("Scripting.FileSystemObject"),
  srcFile = OpenTextFile("dependency.js",1),
  srcContent = srcFile.ReadAll();
srcFile.Close();
eval(srcContent);

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

eval((new ActiveXObject("Scripting.FileSystemObject")).OpenTextFile("dependency.js",1).ReadAll());

Файл сценария Windows

Microsoft разработала формат Windows Scripting File, который позволяет:

  • Смешение языков, например, VBA с JavaScript (но нас интересует только JavaScript, не так ли?)
  • Ссылки на компонент или внешние файлы

Это показано в руководстве Загрузка библиотеки GPF.

носорог

Почти как скриптовые хосты Microsoft, Rhino (Java’s JavaScript host) не предоставляет никакого помощника по модульности. Однако оболочка предлагает метод загрузки, который загружает и оценивает файлы JavaScript. В этом контексте также загрузка является синхронной, а область действия является глобальной.

Собственный импорт Javascript

Я почти уверен, что некоторые из вас ожидали, что эта часть появится в разделе NodeJs. Однако, поскольку он стандартизирован, я предпочел изолировать его.

В этой статье невозможно рассказать о модульности JavaScript, не упомянув функции импорта последней версии EcmaScript.

Статический импорт

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

Например :

import { myExportedFunction } from "dependency";

Если файл dependency.js содержит:

"use strict";
// Begin of private scope
// Any functions or variables declared here are 'private'
// Reveal function
export function myExportedFunction () {
  /* ...implementation... */
}
// End of private scope

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

С другой стороны, Rhino и WScript его не поддерживают. PhantomJS может поддерживать его в версии 2.5… или нет.

Динамический импорт

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

Есть несколько предложений по реализации:

И недавно это стало реальностью в Chrome и Safari.

Реализация GPF-JS

API

Новое пространство имен gpf.require предоставляет помощник модульности, точкой входа которого является gpf.require.define.

Основные характеристики:

  • Он работает одинаково на всех хостах.
  • Загрузка асинхронная
  • Область модуля является приватной (из-за шаблона, использующего фабричные функции).
  • Предоставление доступа к API осуществляется путем возврата объекта.

Но API также предлагает:

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

При написании модуля JavaScript поддерживается несколько синтаксисов:

  • Способ «GPF»:
gpf.require.define({
  name1: "dependency1.js",
  name2: "dependency2.json",
  // ...
  nameN: "dependencyN.js"
}, function (require) {
  "use strict";
  // Private scope
  require.name1.api1();
  // Implementation
  // Interface
  return {
    api1: function (/*...*/) {
      /*...*/
    },
    // ...
    apiN: function (/*...*/) {
      /*...*/
    }
  };
});
  • Используя формат AMD:
define([
  "dependency1.js",
  "dependency2.json",
  /*...*/,
  "dependencyN.js"
], function (name1, name2, /*...*/ nameN) {
  "use strict";
  // Private scope
  name1.api1();
  // Implementation
  // Interface
  return {
    api1: function (/*...*/) {
      /*...*/
    },
    // ...
    apiN: function (/*...*/) {
      /*...*/
    }
  };
});
  • Используя формат CommonJS:
"use strict";
// Private scope
var
  name1 = require("dependency1.js"),
  name2 = require("dependency2.json"),
  // ...
  nameN = require("dependencyN.js");
name1.api1();
// Implementation
// Interface
module.exports = {
  api1: function (/*...*/) {
    /*...*/
  },
  // ...
  apiN: function (/*...*/) {
    /*...*/
  }
};

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

Поддержка этих форматов была реализована по двум причинам: было интересно обрабатывать их все, но это также обеспечивает взаимодействие между хостами. Действительно, некоторые модули NodeJS достаточно просты, чтобы их можно было повторно использовать в Rhino, WScript или браузерах без дополнительных затрат.

Детали реализации

Реализация состоит из нескольких файлов:

  • src/require.js: содержит объявление пространства имен, основные точки входа, а также обработку кеша.
  • src/require/load.js: управляет загрузкой ресурсов
  • src/require/json.js: обрабатывает ресурсы JSON.
  • src/require/javascript.js: обрабатывает ресурсы JavaScript.
  • src/require/wrap.js: содержит специальный механизм, необходимый для синхронизации загрузки последующих модулей JavaScript (детали будут позже)

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

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

Простой пример

Рассмотрим следующую пару файлов (в одной папке):

  • образец1.js
gpf.require.define({
  dep1: "dependency1.json"
}, function (require) {
  alert(dep1.value);
});
  • зависимость1.json
{
    "value": "Hello World!"
}

При запуске библиотеки метод gpf.require.define использует внутреннюю функцию.

Действительно, три основные точки входа (define, resolve и configure) внутренне привязаны к объекту скрытого контекста, хранящему текущую конфигурацию.

При вызове gpf.require.define реализация начинается с перечисления зависимостей и построения массива промисов. Каждое обещание в конечном итоге будет разрешено для содержания соответствующего ресурса:

  • Открытый API для модуля JavaScript
  • Объект JavaScript для файла JSON

Следовательно, эта функция:

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

В приведенном выше примере массив будет содержать только одно обещание, соответствующее ресурсу «dependency1.json».

Когда все обещания разрешены, если второй параметр gpf.require.define является функцией (также называемой фабричной функцией), она выполняется. >» со словарем, индексирующим все зависимости по имени.

Формат AMD передает все зависимости в виде отдельных параметров. Как следствие, фабричная функция часто обходит максимальное количество параметров, указанное в линтере. У словаря есть два преимущества: ему нужен только один параметр, а API остается открытым для будущих дополнительных параметров.

Загрузка ресурсов

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

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

В приведенном выше примере файл JSON анализируется, и обещание преобразуется в результирующий объект.

Обратите внимание, как упрощается обработка ошибок, если этот код инкапсулирован в промисы.

Последующие зависимости

Теперь давайте рассмотрим следующие файлы (в одной базовой папке):

  • образец2.js
gpf.require.define({
  dep2: "sub/dependency2.js"
}, function (require) {
  alert(dep2.value);
});
  • суб/зависимость2.js
gpf.require.define({
  dep2: "dependency2.json"
}, function (require) {
  return {
    value: dep2.value;
  };
});
  • суб/зависимость2.json
{
  "value": "Hello World!"
}

Последний пример иллюстрирует несколько проблем:

  • Загрузка модулей JavaScript
  • Управление относительным путем
  • Последующие зависимости должны быть загружены до разрешения промиса, связанного с модулем.

Рассмотрим первый пункт: как API загружает модули JavaScript?

Как объяснялось ранее, расширение ресурса используется для определения применяемого обработчика контента. В этом случае выбирается и выполняется процессор JavaScript.

Чтобы определить, какой синтаксис используется в модуле, алгоритм v0.2.2 ищет ключевое слово require CommonJS. Действительно, модули CommonJS являются синхронными, поэтому зависимости должны быть предварительно загружены перед оценкой модуля.

В приведенном выше примере ключевое слово require не найдено, поэтому применяется универсальный процессор JavaScript.

Перед оценкой загруженного модуля JavaScript содержимое заворачивается в новую функцию с двумя параметрами:

function (gpf, define) {
gpf.require.define({
  dep2: "dependency2.json"
}, function (require) {
  return {
    value: dep2.value;
  };
});
}

Этот механизм имеет ряд преимуществ:

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

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

  • Он позволяет определять (или переопределять) и внедрять «глобальные» символы.

В нашем случае определяются два из них:

Этот последний пункт важен для решения двух оставшихся вопросов:

  • Как библиотека обрабатывает относительный путь?
  • Как он узнает, когда будут загружены последующие зависимости?

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

Так называемая оболочка создается для:

Как следствие, API может вернуть промис, который либо немедленно разрешается, либо тот, который соответствует внутреннему использованию gpf.require.define, гарантируя, что модуль правильно загружен.

Будущее gpf.require

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

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

Пользовательский процессор ресурсов

Сейчас обрабатываются только файлы json и js. Все остальное считается текстовыми ресурсами (т.е. загруженный контент возвращается без какой-либо дополнительной обработки).

Предложение возможности сопоставить расширение с настраиваемым обработчиком ресурсов (например, yml или jsx) значительно повысило бы ценность библиотеки.

Обработка перекрестных ссылок

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

Например, модуль A зависит от модуля B, B зависит от C, C зависит от D, а D зависит от A!

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

Также может быть реализована концепция слабых ссылок: это зависимости, которые разрешаются позже, чем сам модуль.

Например, (и это всего лишь идея) зависимость помечена как weak=true. Это означает, что выполнять заводскую функцию не требуется. Чтобы узнать, когда зависимость доступна (и получить к ней доступ), член больше не является результатом ресурса, а скорее обещанием, разрешенным к результату ресурса после его загрузки и обработки.

gpf.require.define({
  dependency: {
    path: "dependency.js",
    weak: true
  }
}, function (require) {
    return {
      exposedApi: function () {
        return require.dependency
          .then(function (resolvedDependency) {
            return resolvedDependency.api();
          };
    }
  }
});

Объект модуля

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

Вывод

Если вы пришли к такому выводу: поздравляем и благодарим за терпение!

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

Я настоятельно рекомендую это упражнение всем, кто занимается разработкой кода: напишите статью о том, как это работает и почему это работает именно так.

Было важно проиллюстрировать, как модульность помогает создавать более качественные приложения, прежде чем углубляться в предложение API. Точно так же, поскольку библиотека GPF-JS совместима с несколькими хостами, было важно выделить существующие решения, поскольку они меня вдохновили.

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

Первоначально опубликовано на http://gpf-js.blogspot.com.