azproduction / lmd

LMD - JavaScript Module-Assembler for building better web applications :warning: Project is no longer supported :warning:
http://azproduction.ru/lmd/
MIT License
449 stars 27 forks source link

Add a way to change ThisBinding of 3-party module #105

Closed leonidborisenko closed 11 years ago

leonidborisenko commented 11 years ago

I've met a use-case for binding a module function (as made by LMD builder) to user-defined object.

Bacon.js adds asEventStream function to jQuery object, expecting it as a property of this. But I've made jQuery non-global by exposing it with "exports": "jQuery.noConflict(true)" in LMD configuration, so Bacon.js couldn't find it.

However, with little plugin for changing of ThisBinding and one-level indirection I now have non-global jQuery with asEventStream function.

Here is the plugin code:

/* Clone module into new module with changed value of "this" keyword,
 * consisting of other initialized modules.
 */
(function (sb) {
  sb.on('*:rewrite-shortcut', function (moduleName, module) {
    var pluginOptions = sb.options['shortcut-with-changed-this-binding'],
        shortcutOptions = pluginOptions && pluginOptions[moduleName],
        thisBinding,
        newModule;

    if (shortcutOptions === undefined) {
      return;
    }

    try {
      thisBinding = {};
      for (var name in shortcutOptions.bind) {
        thisBinding[name] = sb.require(shortcutOptions.bind[name]);
      }
      newModule = sb.modules[shortcutOptions.module].bind(thisBinding);
    } catch (error) {
      return;
    }

    return [moduleName, newModule];
  });
})(sandbox);

and corresponfing LMD configuration:

{
  "name": "Vendor libraries",
  "root": "../js",

  "shortcuts": true,
  "plugins": {
    "shortcut-with-changed-this-binding": {
      "path": "../.lmd/plugins/shortcut-with-changed-this-binding.js",
      "options": {
        "Bacon": {
          "module": "__Bacon__",
          "bind":   { "jQuery": "jQuery" }
        }
      }
    }
  },

  "modules": {
    "jQuery": {
      "path":    "jquery.js",
      "exports": "jQuery.noConflict(true)"
    },
    "__Bacon__": {
      "path":    "bacon.js",
      "exports": "this.Bacon"
    }
  }
}

I think it'd be useful to have this ability included in LMD, so it'd be possible to define modules like

"modules" {
  "Bacon": {
    "path": "bacon.js",
    "bind": {
      "jQuery": "require('jQuery')"
    },
    "exports": "this.Bacon"
  }
}

Note that syntax for value is a bit more explicit than in original plugin; it's intentional change.

It'd also be useful to be able to merge selected properties from window into binding object by providing whitelist (merge only listed properties) or blacklist (merge all properties except listed).

I'm not sure in my cross-browser JS skills, so I'm not providing PR, sorry for that.

azproduction commented 11 years ago

I guess I can eplain in russian.

Привет! Спасибо, что написал.

jQuery.noConflict(true) очень криточно?

Пока есть 2 решения это проблемы если jQuery будет глобальным и оба без дополнительных плагинов.

1) Вообще включить jQuery в состав сборки LMD можно, но лучше грузить его из какого-либо CDN. (jQuery в любом случае экспортирует себя в глобалы) Тогда jQuery будет в глобалах и при старте Bacon разрулит его автоматически (this.jQuery, this===window)

{
    "modules": {
        "bacon": {
            "path": "bacon.js",
            "require": "jQuery", // на всякий случай
            "exports": "this.Bacon"
        }
    }
}

2) Если очень хочется включить jQuery в сборку. Фактически jQuery является зависимостью Bacon и эта зависимость должна стартовать раньше Bacon.

{
    "modules": {
        "jQuery": {
            "path": "jquery.js",
            "exports": "jQuery"
        },

        "bacon": {
            "path": "bacon.js",
            // нужно обязательно добавить, чтобы jQuery проинициализировалась
            "require": "jQuery",
            "exports": "this.Bacon"
        }
    }
}

Спасибо за репорт!

Я добавлю возможноть сделать bind (изменить this) у 3-party модулей. Примерно так:

{
    "modules": {
        "bacon": {
            "path": "bacon.js",
            "bind": "jQuery", // "this": "jQuery" || "bind": {"jQuery": "jQuery"} || "this": {"jQuery": "jQuery"}
            "exports": "this.Bacon"
        }
    }
}
azproduction commented 11 years ago

Можно сделать временный костыль.

// myjQuery.js
var myjQuery = require('_jQuery').noConflict(true);

// Подсовываем для Bacon
window.jQuery = myjQuery;

module.exports = myjQuery;
{
    "modules": {
        "jQuery": "myjQuery.js",

        "_jQuery": {
            "path": "jquery.js",
            "exports": "jQuery"
        },

        "bacon": {
            "path": "bacon.js",
            "require": "jQuery",
            // Возвращаем старый jQuery
            "exports": "this.jQuery = require('_jQuery'), this.Bacon" // '_jQuery' - старый закэшированный jQuery
        }
    }
}
azproduction commented 11 years ago

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

function (require, module, exports) { // added by wrapper
var deps = require('deps');           // added by wrapper
return (function () {                 // added by wrapper

// ТУТ СТОРОННИЙ КОД

return this.Bacon;                    // added by wrapper
}).call({
    "jQuery": require("jQuery")       // added by wrapper
});
}                                     // added by wrapper
leonidborisenko commented 11 years ago

jQuery.noconflict() не критично. Ход с подменой this я предпринял, руководствуясь не прагматизмом, а эстетизмом. Раз уж модули явно запрашивают зависимости, то можно задаться целью исключить или отменить утекание объектов в глобальное пространство имён. Чтобы "всё было красиво". Ну, вот и пришлось подступаться к возникшей проблеме с занятой позиции.

Брать jQuery из CDN я не могу, потому что моё приложение предполагает оффлайновую установку и рассчитано на работу в изолированной среде, где доступ к интернету не гарантирован. require('jQuery') в зависимостях у модуля bacon я пробовал, и это не сработало, потому что оригинальный код в bacon.js уже завёрнут в функцию, вызывающуюся с помощью .call(this). "Временный костыль" -- хитро завёрнутая идея: я честно пытался разобраться, как это отменит "утекание" jQuery в window и не смог; потом попробовал эту конструкцию в работе и не увидел нужного результата.

Идея с явным .call мне очень нравится, голосую за неё.

azproduction commented 11 years ago

Проблема jQuery в том, что оно по своей природе утекает в глобалы - явно пишет window.jQuery = jQuery

leonidborisenko commented 11 years ago

Это да. В случае с jQuery/lodash/Backbone предотвратить утекание невозможно. Но отменить-то его можно с помощью noConflict() в "exports"-параметре в конфигурации LMD. После чего прочие модули не увидят эти библиотеки в window. "Идеально" уже не получается, но подобное компромиссное решение -- отмена утекания в самом модуле сразу же после выполнения оригинального кода -- меня устраивает.

azproduction commented 11 years ago

Накидал пример https://gist.github.com/azproduction/4771673 Gist не умеет папки поэтому lmd.json закинул в корень. С реальными файлами так же должно работать.

azproduction commented 11 years ago

Обновил пример - зачистил jQuery

leonidborisenko commented 11 years ago

Теперь понял, спасибо. myjQuery.js, по-моему, можно переписать:

var jQuery = require('_jQuery');
require('Bacon');
module.exports = jQuery.noConflict(true);

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

{
    <...>
    "modules": {
        <...>
        "Bacon": {
          "path": "bacon.js",
          "require": "jQuery",
          "exports": "Bacon"
        }
    }
}

Но жизнеспособность данного способа нивелируется тем, что Bacon.js выглядит как-то так:

(function(){
  (this.jQuery || this.Zepto).fn.asEventStream = function () {
  };
  Bacon = this.Bacon = {};
}).call(this);

и у него нет метода, подобного noConflict(), так что он неизбежно утекает в глобальное пространство имён (если не переопределять this).

azproduction commented 11 years ago

Ясно. В ближайшее время я добавлю возможность влиять на this. Как будет -- напишу.

azproduction commented 11 years ago

Обновляйтесь lmd@1.10.4

leonidborisenko commented 11 years ago

Спасибо. Для моего случая достаточно.

Только, предполагаю, код в модуле, полагающийся на this === window, может использовать свойства window через this: например, this.document или this.history или this.location. При переопределении this подобный код поломается. Из-за этого было бы удобнее писать произвольный код в значениях объекта:

"bind": {
  "MyDate": "require('Date')",
  "document": "window.document"
}

Возможно, я недопонял, и возможность добавлять свойства из window в пользовательский ThisBinding есть и в текущем варианте?

azproduction commented 11 years ago

Алгоритм require такой, что если он не найдет переменную в "modules", то полезет искать в "global" (window).

Для вашего случая достаточно сделать так:

{
  "MyDate": "Date",
  "document": "document"
}
leonidborisenko commented 11 years ago

Отлично, спасибо за пояснение.