Переписываем Require.js с использованием Promise. Часть 1

в 7:55, , рубрики: amd, javascript, requirejs, tutorial, Веб-разработка, велосипеды на javascript, модули

Чтобы не иметь проблем с зависимостями и модулями при большом количестве браузерного javascript, обычно используют require.js. Также многие знают, что это всего лишь один из многих загрузчиков стандарта AMD, и у него есть альтернативы. Но мало кто знает, как они устроены внутри. На самом деле, написать подобный инструмент не сложно, и в этой статье мы шаг за шагом напишем свою версию загрузчика AMD. Заодно разберемся с Promise, который недавно появился в браузерах и поможет нам справиться с асинхронными операциями.

Основой require.js, является функция require(dependencies, callback). Первым аргументом передаем список модулей для загрузки, а вторым – функцию, которую вызовут по окончании загрузки, с модулями в аргументах. Используя Promise написать её совсем несложно:

function require(deps, factory) {
  return Promise.all(deps.map(function(dependency) {
    if(!modules[dependency]) {
      modules[dependency] = loadScript(dependency);
    }
    return modules[dependency];
  }).then(function(modules) {
    return factory.apply(null, modules);
  });
}

Конечно, это еще не всё, но основа есть. Поэтому продолжим.

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

Нашей первой функцией будет функция загрузки скрипта:

function loadScript(name) {
  return new Promise(function(resolve, reject) {
    var el = document.createElement("script");
    el.onload = resolve;
    el.onerror = reject;
    el.async = true;
    el.src = './' + name + '.js';
    document.getElementsByTagName('body')[0].appendChild(el);
  });
}

Загрузка происходит ассинхронно, поэтому будем возвращать объект Promise, по которому можно будет узнать об окончании.
Конструктор Promise принимает функцию, в которой будет происходить асинхронный процесс. В нее передаются два аргумента — resolve и reject. Это функции для сообщения результата. В случае успеха вызовем resolve, а при ошибке – reject.
Для подписки на результат у экземпляра Promise предусмотрен метод then. Но нам может понадобиться ожидать загрузки нескольких модулей. И для этого есть специальная функция-агрегатор Promise.all, которая соберет несколько promise в один, и его результатом в случае успеха будет массив результатов загрузки всех нужных модулей. С помощью этих двух несложных функций, уже можно получить минимально работающий прототип.

Репозиторий на github содержит тэги на ключевых шагах из этой статьи. В конце каждой главы есть ссылка на github, где можно посмотреть полную версию кода, написанную к данному шагу. Кроме того, в папке test лежат тесты, которые показывают, что наша функциональность работает как надо. К проекту подключен Travis-CI, который выполнил тесты для каждого шага.

Смотреть код этого шага на Github.

Объявление модулей

На самом деле мы так и не загрузили наши модули. Все дело в том, что когда мы добавляем скрипт на страницу, мы теряем над ним управление и ничего не узнаем о результате его работы. Поэтому, когда мы загружаем модуль A, нам могут подсунуть модуль B, а мы этого не заметим. Чтобы так не происходило, нужно дать модулям возможность представиться. Для этого в стандарте AMD предназначена функция define(). Например, регистрация модуля A выглядит так:

define('A', function() {
  return 'module A';
});

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

Для этого нам нужно при начале загрузки создать заготовку модуля, которая сможет превратиться в настоящий модуль после загрузки. Это можно сделать используя deferred-объекты. Они близки к Promise, но он прячет resolve и reject внутрь себя, а снаружи дает лишь возможность узнать результат. Deferred-объекты имеют методы resolve и reject, то есть, имея доступ к deferred, можно легко изменить его результат. Также deferred-объект имеет поле promise, в котором записан Promise, результат которого мы задаем. Deferred легко делаются из Promise по рецепту со Stackoverflow.

При загрузке модулей в require, мы создадим deferred-объект для каждого модуля и сохраним его в кэш (pendingModules).
define сможет его оттуда достать и вызвать resolve, чтобы отметить его как загруженный и сохранить его.

function define(name, factory) {
  var module = factory();
  if(pendingModules[name]) {
    pendingModules[name].resolve(module);
    delete pendingModules[name];
  } else {
    modules[name] = module;
  }
}

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

Функция loadScript теперь будет сохранять deferred-объекты в кеш и возвращать promise этого объекта, по которому функция require будет ждать загрузки модуля.

function loadScript(name) {
  var deferred = defer(),
    el = document.createElement("script");
  pendingModules[name] = deferred;
  el.onerror = deferred.reject;
  el.async = true;
  el.src = './' + name + '.js';
  document.getElementsByTagName('body')[0].appendChild(el);
  return deferred.promise;
}

Смотреть код этого шага на Github.

Зависимости в модулях, обнаружение циклов

Ура, теперь мы можем загружать модули. Но иногда модулю могут понадобиться другие модули для работы. Для этого в AMD
для функции define предусмотрен еще один аргумент — dependencies, который может быть между name и factory.
Когда у модуля есть зависимости, мы не можем просто так взять и вызвать factory, нужно сначала загрузить зависимости.
К счастью, для этого у нас уже есть функция require, здесь она придется как раз к месту. Там, где раньше был просто вызов factory() теперь будет require:

define(name, deps, factory) {
  ...
-  var module = factory();
+  var module = require(deps, factory);
  ...
}

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

Когда мы грузим зависимости модулей, нас подстерегает опасность. Например, модуль A зависит от B, а B зависит от A. Загрузим модуль A, он потребует модуль B. После загрузки B потребует A, и в результате они так и будут ждать друг друга до бесконечности. Ситуация может быть и хуже, если в цепочке не два модуля, а больше. Нужно уметь пресекать такие циклические зависимости. Для этого будем сохранять историю загрузок, чтобы показать предупреждение, когда заметим, что наша загрузка пошла по кругу. Мы использовали require() для загрузки зависимостей модуля, но этой функции фиксированный набор аргументов, прописанный в стандарте, его нужно соблюдать. Создадим свою внутреннюю функцию _require(deps, factory, path), которой мы сможем передать информацию об истории загрузки модуля, а в публичном API будем делать её вызов:

function require(deps, factory) {
  return _require(deps, factory, []);
}

Вначале наша история загрузки будет пуста, поэтому в качестве path передадим пустой массив. В _require() теперь будет прежняя логика загрузки, плюс отслеживание истории.

function loadScript(name, path) {
  var deferred = defer();
+  deferred.path = path.concat(name);
  ...
}
function _require(deps, factory, path) {
  return Promise.all(deps.map(function (dependency) {
+    if(path.indexOf(dependency) > -1) {
+      return Promise.reject(new Error('Circular dependency: '+path.concat(dependency).join(' -> ')));
+    }
  ...
}

Глобальный массив со списком всех модулей нам не подойдет, история загрузки у каждого модуля своя, будем сохранять ее в deferred-объект, загружаемого модуля, чтобы её потом можно было прочесть в define и передать в _require если понадобиться грузить еще модули. Замечу, что добавляем новый модуль в историю через .concat(), вместо .push(), потому что нам нужна независимая копия истории, чтобы не напортить историю другим модулям, которые грузились до нас. А еще вместо привычного throw new Error() мы возвращаем Promise.reject(). Это означает, что обещание не сбылось, и вызовется обработчик ошибок, так же как и происходит при ошибке во время загрузки скрипта, только в сообщении указана другая причина — цикл в зависимостях.

Смотреть код этого шага на Github.

Обработка ошибок

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

Дополнительный аргумент назовем errback, как и в оригинальном require.js

function _require(deps, factory, errback, path) {
  ...
  })).then(function (modules) {
    return factory.apply(null, modules);
+  }, function(reason) {
+    if(typeof errback === 'function') {
+      errback(reason);
+    } else {
+      console.error(reason);
+    }
+    return Promise.reject(reason);
  });
}

На случай если пользователь не заботится об ошибках, мы сделаем это сами, выведем сообщение в консоль. Также мы неспроста в обработчике ошибок возвращаем такое значение. Логика promise устроена так, что если мы передаем функцию на случай ошибки, то он считает, что мы в ней все исправим и можно продолжать работу, аналогично блоку try-catch.
Но для require.js потеря модуля фатальна, мы не сможем продолжить работу без всех модулей, передать ошибку дальше, используя Promise.reject.

Смотреть код этого шага на Github.

Анонимные модули

В стандарте AMD предусмотрена возможность определять модули без названия. В этом случае его имя определяется по тому скрипту, который сейчас загружается на страницу. Свойство document.currentScript поддерживается не всеми браузерами, поэтому нам придется определять текущий скрипт другим путем. Мы сделаем загрузку модулей последовательной, а значит мы будем ожидать в один момент времени только один модуль. С использованием Promise можно легко получить имплементацию FIFO-очереди:

var lastTask = Promise.resolve(),
    currentContext;
function invokeLater(context, fn) {
  lastTask = lastTask.then(function() {
      currentContext = context;
      return fn();
  }).then(function() {
    currentContext = null;
  });
}

Мы всегда храним promise от последней операции, следующая операция подпишется на её завершение и оставит новый promise.
Воспользуемся этой очередью для загрузки скриптов. Теперь у нас не список pendingModules, а один pendingModule, а остальные будут ждать.

function loadScript(name, path) {
  var deferred = defer();
  deferred.name = name;
  deferred.path = path.concat(name);
  invokeLater(deferred, function() {
    return new Promise(function(resolve, reject) {
      //прежний код загрузки скрипта
    });
  });
  return deferred.promise;
}

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

define(function() { return 'module-content'; });

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

Смотреть код этого шага на Github.

Отложенная инициализация

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

Заведем отдельный объект predefines, и будем в него сохранять модули, если их никто не просил.

function define(name, deps, factory) {
  ...
  } else {
-    modules[name] = _require(deps, factory, null, []);
+    predefines[name] = [deps, factory, null]; 
  }
}

А во время require будем сначала проверять predefines на интересующие нас модули

function _require(deps, factory, errback, path) {
  ...
+  var newPath = path.concat(dependency);
+  if(predefines[dependency]) {
+      modules[dependency] = _require.apply(null, predefines[dependency].concat([newPath]))
+  }
  else if (!modules[dependency]) {
      modules[dependency] = loadScript(dependency, newPath);
  }
  ...
}

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

define('A', ['B'], function() {});
define('B', function() {});
require(['A'], function() {});

Раньше, мы бы уже на первом шаге начали загружать откуда-то модуль 'B', и не нашли бы его. А теперь мы сможем дождаться, пока все модули объявятся сами, и уже потом вызывать require. Также теперь не имеет значения порядок их объявления.
Достаточно лишь того, чтобы точка входа (вызов require) шел последним.

Смотреть код этого шага на Github.

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

Автор: justboris

Источник

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


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