Статьи

Модульный JavaScript в браузере с компилятором CommonJS

JavaScript был разработан как язык сценариев, который легко встраивать в большую хост-систему и предназначен для манипулирования объектами хост-системы. С развитием HTML5 ранее статичные веб-страницы превращались в сложные веб-приложения. Теперь мы ожидаем, что код JavaScript будет масштабируемым и модульным. Но как, если в JavaScript нет встроенных средств для объединения различных сценариев?

Конечно, вы можете вставить элемент сценария в DOM и, следовательно, динамически загружать внешние сценарии. Затем вам придется иметь дело с асинхронно загруженными сценариями и разрешить все необходимые зависимости перед запуском любого зависимого кода. На самом деле существует множество библиотек, которые могут сделать это за вас (http://wiki.commonjs.org/wiki/Implementations). Переходя к AMD, вы быстро поймете, что каждый модуль делает отдельный HTTP-запрос, который плохо влияет на производительность приложения. Ну, вы можете использовать такие инструменты, как r.jsобъединить модули в одну сборку. Тем не менее он приносит целую библиотеку в полученный код. Скомпилированные модули нам вряд ли понадобятся так много дополнительной сложности. После того, как компилятор заключает модули в уникальные области, нам нужна только функция для управления доступом к этим контекстам. Давайте возьмем формат модуля CommonJS. Мне очень нравится, как это реализовано в NodeJS — довольно лаконично, но невероятно эффективно. Он представляет объект модуля, представляющий модуль и доступный в каждой области видимости модуля. Таким образом, область видимости модуля может выглядеть так:

require.def( "module-id", function( require, exports, module ){
    // Module constructor
    var moduleObj = {};
    module.exports = moduleObj;
   return module;
});

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

/** Define scope for `require` */
var require = (function(){
  var /**
      * Store modules (types assigned to module.exports)
      * @type {module[]}
      */
      imports = [],
      /**
       * Store the code that constructs a module (and assigns to exports)
       * @type {*[]}
       */
      factories = [],
      /**
       * @type {module}
       */
      module = {},
      /**
       * Implement CommonJS `require`
       * @param {string} filename
       * @returns {*}
       */
      _require = function( filename ) {
 
        if ( typeof imports[ filename ] !== "undefined" ) {
          return imports[ filename ].exports;
        }
        module = {
          id: filename,
          filename: filename,
          parent: module,
          children: [],
          exports: {},
          loaded: false
        };
 
        // Called first time, so let's run code constructing (exporting) the module
        imports[ filename ] = factories[ filename ]( require, module.exports, module );
        imports[ filename ].loaded = true;
        if ( imports[ filename ].parent.children ) {
          imports[ filename ].parent.children.push( imports[ filename ] );
        }
        return imports[ filename ].exports;
      };
  /**
   * Register module
   * @param {string} filename
   * @param {function(module, *)} moduleFactory
   */
  _require.def = function( filename, moduleFactory ) {
    factories[ filename ] = moduleFactory;
  };
  return _require;
}());

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

Использование RequireJS Compiler

Я выпустил компилятор на GitHub https://github.com/dsheiko/cjsc. Это пакет NodeJS, который можно использовать так же просто, как и он:

./cjsc main-module.js build.js

Давайте напишем несколько модулей, чтобы увидеть, что он делает.

./main.js

onsole.log( "main.js running..." );
console.log( "Imported name in main.js is `%s`", require( "./lib/dep1" ).name );
console.log( "Getting imported object from the cache:" );
console.log( " imported name in main.js is still `%s`", require( "./lib/dep1" ).name );

./lib/dep1.js

console.log( "dep1.js running..." );
console.log( "Imported name in dep1.js is `%s`", require( "./dep2" ).name );
module.exports.name = "dep1";

./lib/dep2.js

console.log( "dep2.js running..." );
module.exports.name = "dep2";

После того, как мы скомпилируем модуль main.js и запустим производную сборку в браузере, мы получим следующий вывод:

main.js running...
dep1.js running...
dep2.js running...
Imported name in dep1.js is `dep2`
Imported name in main.js is `dep1`
Getting imported object from the cache:
 imported name in main.js is still `dep1`

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

Поддержка модулей RequireJS

Что если мы призовем модуль UMD? Давай попробуем:

./main.js

console.log( "%s is running...", module.id );
console.log( "%s imports %s", module.id, require( "./umd/module1.js" ).id );

./umd/module1.js

// UMD boilerplate according to https://github.com/umdjs/umd
if ( typeof module === "object" && typeof define !== "function" ) {
    /**
    * Override AMD `define` function for RequireJS
    * @param {function( function, Object, Object )} factory
    */
    var define = function ( factory ) {
        module.exports = factory( require, exports, module );
    };
}
 
define(function( require, exports, module ) {
    console.log( "%s is running...", module.id );
    return { id: module.id };
});

Результат сборки:

./umd.js is running...
./umd/module1.js is running...
./umd.js imports ./umd/module1.js

Все отлично.

Автоматизация процесса сборки

В среде разработки у нас может быть дурацкая конфигурация Grunt:

Gruntfile.js

module.exports = function(grunt) {
  grunt.loadNpmTasks('grunt-contrib-cjsc');
  grunt.loadNpmTasks('grunt-contrib-watch');
  grunt.initConfig({
     cjsc: {
      development: {
        options: {
       minify: true
        },
        files: {
          "js/build.js" : "./js/modules/main.js"
        }
      }
    },
    watch: {
        options: {
          livereload: false
        },
        build: {
            files: ['./js/modules/**/**/*.js'],
            tasks: ['cjsc']
        }
      }
  });
  grunt.registerTask( "default", [ "cjsc" ]);
};

И зависимости развития в package.json

"devDependencies": {
  //..
  "grunt-contrib-watch": "~0.4.4",
  "grunt-contrib-cjsc": "*"
}

Теперь мы можем заставить Grunt автоматически компилировать build.js при каждом изменении любого из модулей:

grunt watch

Итак, как вы видите, вы можете писать модули CommonJS и запускать их в браузере без дополнительной библиотеки. Если вы спросите меня о Browserify — это выглядит потрясающе. Честно говоря, я просто не знал об этом решении при создании компилятора RequireJS.