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

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

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

Немного утилитарных функций

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

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

function toUrl(name, appendJS) {
    return './' + name + (appendJS ? '.js' : '');
}

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

require.toUrl = function(name) {
    return toUrl(name, false);
};

А мы выставим true при вызове из функции loadScript:

el.src = toUrl(name, true);

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

specified – проверка, есть ли указанный модуль в списке загружаемых или загруженных

require.specified = function(name) {
    return modules.hasOwnProperty(name);
};

undef – удаляет определение модуля из списка.

require.undef = function(name) {
    delete modules[name];
};

onError – стандартный обработчик ошибок. Если вы не хотите видеть сообщение с ошибкой в консоли, вам нужно
переопределить его. Вынесем нашу обработку ошибок в эту функцию:

if(typeof errback === 'function') {
    errback(reason);
}
+require.onError(reason);
-console.error(reason);

Осталась одна очень важная функция – require.config. Но она настолько значительная, что ей мы займемся позже.

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

Настройки загрузки

Не всегда стандартное поведение устроит всех. Require.js может настраиваться через функцию require.config(). Настало
время и нам поддержать её. У нас будет переменная config, в которую мы сможем записывать свои опции и использовать их
в работе. Функция будет принимать новые опции и добавлять их к существующим, например, используя библиотеку deepmerge,
найденную на просторах npm.

var config = {};
require.config = function(options) {
  config = deepMerge(config, options);
}

Когда у нас есть механизм сохранения настроек, надо разобраться, какие опции нам нужно поддержать.

baseUrl – базовый путь до модулей, путь до всех модулей начинается с него. По умолчанию равен адресу текущей
страницы, то есть его значение ./. Добавим его поддержку в функцию require.toUrl().

toUrl = function(name, appendJS) {
-    return './' + name + (appendJS ? '.js' : '');
+    return config.baseUrl + name + (appendJS ? '.js' : '');
}

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

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

paths – пути для тех файлов, которые находятся отдельно от остальных модулей. Обычно это популярные библиотеки,
например jQuery, которые есть на CDN, поэтому было бы неплохо объявить, что модуль jquery должен грузиться по url вида
//code.jquery.com/jquery-2.1.3.min.js. Поддержим наличие модуля в paths в нашем коде.

if(config.paths[name]) {
  name = config.paths[name];
}
if(!/^([w+.-]+:)?/.test(name)) {
  name = config.baseUrl + name;  
}
if(config.urlArgs) {
    name += '?'+config.urlArgs;
}

Составление пути до модуля стало сложнее.

  • Возьмем его из paths, если там есть.
  • Добавим baseUrl, если наш путь не абсолютный
  • Добавим GET-параметры, если требуется

Точно так же работает require, поэтому такие настройки дадут такой же результат и как и там.

bundles – недавнее нововведение, но полезное. Мы можем создать файл с несколькими модулями (bundle), он будет
загружаться, когда хотя бы один из указанных модулей будет вызван. Чтобы поддержать эту опцию, мы должны поискать модуль
в одной из пачек, а если найдем, то загрузим этот файл вместо исходного.

+var bundle = Object.keys(config.bundles).filter(function(bundle) {
+    return config.bundles[bundle].indexOf(name) > 0;
+});
+if(bundle) {
+    return require.toUrl(bundle);
+}
if(config.paths[name]) {
...

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

shim – прокладка для работы с кодом, не поддерживающим AMD. Каждое поле shim содержит объект с описанием, как
загрузить указанный модуль. Например:

require.config({
  shim: {
    'jquery.scroll': {
        deps: ['jquery'], // зависимости библиотеки
        exports: 'jQuery.fn.scroll', // глобальный объект с API библиотеки
        init: function() {}, // код инициализации библиотеки, если нужен
        exportsFn: function() {} // функция экспорта, используется вместо пары init и exports
    }
  }
});

Когда мы грузим модуль, для которого определен shim, мы должны подготовить заранее его зависимости и не ждать от него
вызова define. Вместо привычного механизма здесь вступит в дело функция loadByShim()

function loadByShim(name, path) {
    var shim = config.shim[name];
    return _require(shim.deps || [], function() {
        return loadScript(name).then(shim.exportsFn || function() {
            return (shim.init && shim.init()) || getGlobal(shim.exports)
        });
    }, null, path)
}

В случае, если у модуля есть зависимости, загрузим их, а потом подключим сам модуль. Загрузка значения из него идет или
через функцию init, а, если её нет или она ничего не вернула, то воспользуемся значением exports. Там не имя
переменной, а путь до неё, разделенный точками. Функция getGlobal довольно стандартный подход, его можно найти,
например, в этом ответе со stackoverlow.

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

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

Специальные модули

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

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

module – информация о текущем модуле. Самое главное здесь – это возможность узнать свои настройки. Require.js умеет сохранять настройки для модулей в среди своих опций:

require.config({
  config: {
    'my-module': {
      beAwesome: true
    }
  }
})

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

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

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

Итак, теперь у нас будет новый место для поиска модулей – переменная locals:

+var currentModule = path.slice(-1)[0];
...
+if(locals[dependency]) {
+   return locals[dependency](currentModule);
+}
if(predefines[dependency]) {

Мы извлечем имя модуля из истории загрузки, чтобы по имени создать правильные локальные модули. Результат поиска для locals
не будет сохраняться, потому что у они каждого свои. Проще всего устроен модуль require – это будет копия глобальной функции.

locals.require = function() {
  return require;
}

module должна собрать информацию о текущем модуле и передать ему.

locals.module = module: function(moduleName) {
  var module = modules[moduleName];
  if(!module) {
    throw new Error('Module "module" should be required only by modules')
  }
  return (module.module = module.module || {
    id: moduleName,
    config: function () {
      return config.config[moduleName] || {};
    }
  });
};

exports добавляет к определению модуля возвращаемое значение:

locals.exports = function(moduleName) {
    return (locals.module(moduleName).exports = {});
}

Если мы захотим заменить exports целиком (например, вернуть функцию вместо объекта), то мы можем это сделать через
используя module:

module.exports = result;

По-другому перезаписать exports не выйдет, потому что агрументы функций в js нельзя перезаписать полностью, только
частично изменить.

Осталось научиться забирать себе результат из exports, если он там есть. Добавим проверку, что в него что-то написали:

})).then(function (modules) {
-  return factory.apply(null, modules); 
+  var result = factory.apply(null, modules);
+  return modules[currentModule].module && modules[currentModule].module.exports || result;
}, function(reason) {

Теперь модули в нашей системе смогут узнать о себе немного больше и возвращать значения как им удобно.

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

Поддержка плагинов

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

require(['text!template.html'], function(template) {
  //do with template anything what you want
});

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

if(dependency.indexOf('!') > -1) {
    modules[dependency] = loadWithPlugin(dependency, newPath);
}

Функция loadWithPlugin() для начала разберет зависимость, чтобы отделить плагин от модуля:

var index = dependency.indexOf('!'),
  plugin = dependency.substr(0, index);
  dependency = dependency.substr(index+1);

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

return _require([plugin], function(plugin) {
  //...
});

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

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

define('text', function() {
  return {
    load: function(name, require, onLoad, config) {
      //plugin logic
    }
  }
})

Функция load получает при вызвове определенные аргументы:

  • name – имя модуля, уже без названия плагина в префиксе.
  • require – специальная версия функции require для использования внутри плагина. У этой функции есть те же методы, что
    и глобальной, которые мы рассматривали в разделе про утилитарные функции.
  • onLoad – функция, которую следует вызвать по окончании загрузки модуля и передать его самого как аргумент. Для сообщения
    об ошибке вызывается метод onLoad.error. Для особых случаев есть метод onLoad.fromText, который исполнит переданную ему строку как javascript. Этот метод используется в плагинах-процессорах, для загрузки CoffeeScript, например.
  • config – объект с нашими настройками. Плагин может захотеть их прочесть, чтобы понять, как себя вести.

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

return _require([plugin], function(plugin) {
  return new Promise(function(resolve, reject) {
    resolve.error = reject;
    resolve.fromText = function(name, text) {};
    plugin.load(dependency, require, resolve, config);
  });
});

В случае успеха, плагин вызовет resolve() у нашего promise и наполнит его правильным значением. Для остальных возможностей
нужно расширить функцию resolve дополнительными свойствами. Плагин сможет вызвать reject в случае ошибки, обратившись
к свойству error, как и предусмотрено в API require.js. Метод fromText предусмотрен на случай, если плагин
захочет вернуть не значение модуля напрямую, а исполнив строку как javascript-код. Так, например, поступает плагин для CoffeeScript, который загружает coffee-файлы преобразовывает их в js на лету и отдает результат на исполнениение.

resolve.fromText = function(name, text) {
  if(!text) {
    text = name;
  }
  var previousModule = pendingModule;
  pendingModule = {name: dependency, resolve: resolve, reject: reject, path: path};
  (new Function(text))();
  pendingModule = previousModule;
};

Функция fromText имеет две формы вызова. Раньше передавалось имя модуля и код, а теперь предлагается передавать только
код. Первый метод хоть и объявлен устаревшим, но используется даже в официальных плагинах, поэтому надо поддерживать его.
Для исполнения кода воспользуемся конструктором Funсtion(), но перед этим нужно настроить переменную pendingModule.
В ней у нас хранится модуль, ожидающий своей загрузки. Во время исполнения, скорее всего, произойдет вызов define, который
начнет искать модуль и выбросит ошибку, если модуля не найдет. Подготовим модуль перед вызовом и аккуратно вернем на место
то, что там было до него. В это время мог грузиться другой модуль без плагина, и не стоит ему ломать загрузку.

Поддержка плагинов успешно протестирована на require-text и require-cs.

Но есть одно замечание по поводу работы с require-coffee. Плагин знает слишком много о внутренней структуре оригинального
require и делает странные действия при загрузке.

load.fromText(name, text);               
parentRequire([name], function (value) {
  load(value);
});

Сначала вызывается fromText, чтобы исполнить код модуля и вызвать в нем define(), затем происходит require(), чтобы
вызвать resolve еще раз. Однако promise так устроен, что сохраняет результат только первого вызова и игнорирует дальнейшие
вызовы обеих своих функций – resolve и reject.

Оставлен pull-request с удалением нежелательного метода, а пока его не
приняли, стоит научиться игнорировать такие вызовы на своей стороне. Для этого добавим флаг resolved и будем однажды ставить
его в true и подавлять вызовы функции после этого.

После этого хака работа CoffeeScript плагина будет налажена.

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

Релизимся

Библиотека выглядит готовой к использованию. Большая часть требований к AMD-загрузчику выполнена. Осталось сделать удобным
подключение её для пользователей. Не все браузеры поддерживают Promise, которым мы активно пользуемся. А значит, нужно взять
его на борт своей библиотеки и использовать в случае, если в браузере его нет. Будем использовать этот
es6-promise-polyfill, потому что он небольшой и не содержит ничего сверх стандарта, а также не требует дополнительной
сборки для работы в браузере, в отличии от, например, этой реализации,
которую еще придется подготовить.

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

  • проверим свой код через gulp-jshint
  • соберем наш код вместе с Promise с помощью gulp-include. Include позволит нам не просто склеить файлы, но еще и обернуть
    их в замыкание, чтобы наши приватные функции не светились снаружи. Получится как-то так:

    (function(global) {
      //= include ../bower_components/es6-promise-polyfill/promise.js
      //= include deepmerge.js
      //= include core.js
    
      global.define = define;
      global.require = require;
      global.requirejs = require;
    })(window);
    
  • пропустим код через gulp-uglify для сжатия. Кстати, полный упакованный размер библиотеки 5.8 Кб, без Promise остается
    3 Kb. Require.js версии 2.16 весит 16.5 Кб.
  • собранный код протестируем с помощью karma. Мы это делали и раньше, надо лишь интегрировать в сборку. Karma не требует
    плагинов для сборки, интегрируется с gulp самостоятельно. Еще добавим PhantomJS, чтобы тестировать работу с polyfill, так
    как Firefox уже поддерживает его у себя.

Осталось библиотеку назвать и написать красочный readme. Итак, встречайте – require-mini!

Минималистичная версия require.js с поддержкой только современных браузеров (IE9+). За счет отсутствия хаков имеет меньший
размер и более понятный код. Установка

bower install require-mini --save

Первый релиз готов!

Что дальше?

На самом деле развитие не окончено. Require.js имеет еще много особенностей, некоторые из них могут быть полезны.
Каждая ссылка – issue в проекте на Github.

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

Спасибо за внимание!

Автор: justboris

Источник


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


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