Загрузка CommonJS модулей в браузер без изменения исходного кода

в 16:38, , рубрики: browser, CommonJS, javascript, module, require, Веб-разработка, метки: , , , ,

Загрузка CommonJS модулей в браузер без изменения исходного кода

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

Действительно, под мои требования замечательно подходил, например, RequireJS с его адаптером для Node.js, которые какое-то время с успехом удовлетворяли мои прихоти, пока меня опять не осенила гениальная мысль: «Почему я вынужден использовать кашу из двух совершенно различных форматов модулей в одном проекте? Нужно все унифицировать!».

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

Думаем

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

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

Мы же пойдем другим путем. Суть нашего метода состоит в том, что мы будем загружать каждый модуль в отдельный ифрейм, тем самым изолируя его от остальных модулей. В пространстве имен каждого такого ифрейма предварительно будут определены функция require и объекты exports и module.exports, как того требует спецификация CommonJS.

Данный способ загрузки скриптов, к сожалению, оказался не без недостатков. Первое, с чем я столкнулся, это неудобство работы с DOM родительского окна и прочими глобальными объектами. Для доступа к ним нужно использовать громоздкую конструкцию window.parent.window, которая, к тому же, будет излишней, если в дальнейшем мы захотим склеить наши модули для продакшена. Решением данной проблемы, в некотором роде, станет создние в каждом ифрейме объекта global, который будет являться ссылкой на window родительского окна. Посредством этого объекта мы сможем получать доступ из наших модулей к таким вещам, как непосредственно сам window, document, navigator, history и так далее, а также при необходимости использовать глобальные переменные.

Вторым, не столь очевидным на первый взгляд недостатком оказалась неидентичность глобальных функций-конструкторов (классов) Function, Date, String и т.д. в контекстах разных модулей. Это не позволит нам, например, проверить принадлежность объекта какому-либо встроенному классу, если он был создан в другом модуле.

var doSomething = require("./someModule").doSomething; // doSomething - это функция, определенная в модуле someModule
console.log(doSomething instanceof Function); // false, потому что Function текущего модуля и Function модуля someModule (которая является конструктором функции someFunction) - это разные объекты

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

console.log(typeof doSomething === "function"); // true

Еще одним нюансом, затрудняющим жизнь желающим загрузить свои CommonJS модули в браузер, является синхронная природа CommonJS'овской функции require. Наиболее распространенным способом решения этой проблемы является загрузка нужного модуля синхронным AJAX-запросом и последующий eval загруженного кода, либо создание анонимной функции с помощью new Function(). Нам этот способ не подходит, так как отладчик в этом случае перестанет указывать на строки кода в оригинальном файле. Мы опять пойдем другим путем, который позволит нам без проблем бегать дебаггером по нетронутому беспощадным евалом коду.

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

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

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

var a = someRandomValue();
require("./module" + a);

Тем не менее, чтобы решить эту проблему, можно воспользоваться для таких случаев обычной AJAX-загрузкой модуля и eval'ом со всеми вытекающими отсюда последствиями.

Еще есть проблемка, заключающаяся в том, что порядок исполнения кода в модулях будет отличаться от такового, например, в условиях Node.js. Рассмотрим два маленьких модуля:

// Модуль "a"
exports.result = doSomeExternalOperation();

// Модуль "b"
prepareDataForSomeExternalOperation();
var a = require("./a");

В Node.js, очевидно, вызов функции prepareDataForSomeExternalOperation произойдет раньше, чем вызов doSomeExternalOperation (но только в том случае, если до этого не было других вызовов require("./a")). В нашем же случае все будет наоборот, так как модуль a загрузится и выполнится раньше модуля b. С этим недостатком нам, к сожалению, тоже придется мириться. Но справедливости ради стоит сказать, что при правильном проектировании модулей таких ситуаций возникать не должно. Выполнять в основном коде модуля какие-то внешние действия (например в файловой системе или какой-нибудь базе данных), которые неявно могут повлиять на работу других модулей — нехорошо.

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

Но если вам вдруг нечем сейчас заняться, или просто любопытно — добро пожаловать в описание деталей моей реализации!

Кодим

Исходники лежат в свободном доступе на Github'е.

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

function Comeon(path) {
	var self = this;
	self.path = path;
	self.modules = {};
}

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

Comeon.prototype.require = function require(moduleRequest, callback) {
	var self = this;
	loadNextModule.bind(self)(enqueueModule.bind(self)(getModuleId("", moduleRequest)), callback);
}

Прежде, чем рассмотреть две основные и самые интересные функции enqueueModule и loadNextModule, рассмотрим несколько вспомогательных.

Функция searchRequires принимает параметром URL файла модуля, загружает его синхронным XHR-запросом, и ищет в нем вхождения вызовов функции require. Хочу обратить внимание, что мы не исполняем загруженный код, а всего лишь ищем зависимости модуля с помощью этой функции. Файл модуля во время этой загрузки закешируется браузером, что в дальнейшем нам пригодится при подключении этого модуля.

var requirePattern = /(?:^|s|=|;)require(("|')([w-/.]*)1)/g;
function searchRequires(url) {
	var requires = [];
	var xhr = new XMLHttpRequest();
	xhr.open("GET", url, false);
	xhr.onreadystatechange = function () {
		if ((xhr.readyState === 4) && (xhr.status === 200)) {
			var match;
			while ((match = requirePattern.exec(xhr.responseText)) !== null) {
				requires.push(match[1]);
			}
		}
	};
	xhr.send();
	return requires;
}

Функции getModuleId и getModuleContext предназначены для получения идентификатора и пути к модулю соответственно.

function getModuleId(moduleContext, moduleRequest) {
	var moduleId = [];
	(/^..?//.test(moduleRequest) ? (moduleContext + moduleRequest) : moduleRequest).replace(/.(?:js|node)$/, "").split("/").forEach(function (value) {
		if (value === ".") {
		} else if (value === "..") {
			moduleId.pop();
		} else if (/[w-.]+/.test(value)) {
			moduleId.push(value);
		}
	});
	return moduleId.join("/");
}

function getModuleContext(moduleId) {
	return moduleId.slice(0, moduleId.lastIndexOf("/") + 1);
}

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

function require(moduleContext, moduleRequest) {
	var self = this;
	var moduleId = getModuleId(moduleContext, moduleRequest);
	if (self.modules[moduleId] && self.modules[moduleId].exports) {
		return self.modules[moduleId].exports;
	} else {
		throw Error("Module not found.");
	}
}

Ну и, наконец, рассмотрим две функции, выполняющие всю основную работу.

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

function enqueueModule(moduleId) {
	var self = this;
	var moduleQueue = [];
	if (!self.modules[moduleId]) {
		self.modules[moduleId] = {
			url: self.path + moduleId + ".js?ts=" + (new Date()).valueOf()
		};
		moduleQueue.push(moduleId);
		searchRequires(self.modules[moduleId].url).forEach(function (value) {
			Array.prototype.push.apply(moduleQueue, enqueueModule.bind(self)(getModuleId(getModuleContext(moduleId), value)));
		});
	}
	return moduleQueue;
}

Функция loadNextModule проходит по возвращенной функцией enqueueModule очереди и по порядку загружает в браузер наши модули (файлы браузер при этом будет брать из своего кеша, так как мы их уже загружали для поиска зависимостей). Для подключения каждого модуля, как мы договорились выше, используется отдельный ифрейм, в котором мы создаем переменные global, exports и module.exports, а также функцию require. Каждый следующий ифрейм загружается только после полной загрузки предыдущего скрипта. Когда очередь загрузки подходит к концу, мы вызываем переданную в самом начале функцию обратного вызова, если таковая имеется, и передаем в нее экспорт последнего модуля.

function loadNextModule(moduleQueue, callback) {
	var self = this;
	if (moduleQueue.length) {
		var iframe = document.createElement("iframe");
		iframe.src = "about:blank";
		iframe.style.display = "none";
		iframe.onload = function () {
			var moduleId = moduleQueue.pop();
			var iframeWindow = this.contentWindow;
			var iframeDocument = this.contentDocument;
			iframeWindow.global = window;
			iframeWindow.require = require.bind(self, getModuleContext(moduleId));
			iframeWindow.module = {
				exports: {}
			}
			iframeWindow.exports = iframeWindow.module.exports;
			var script = iframeDocument.createElement("script");
			script.src = self.modules[moduleId].url;
			script.onload = function () {
				self.modules[moduleId].exports = iframeWindow.module.exports;
				if (moduleQueue.length) {
					loadNextModule.bind(self)(moduleQueue, callback);
				} else if (typeof callback === "function") {
					callback(self.modules[moduleId].exports);
				}
			};
			iframeDocument.head.appendChild(script);
		};
		document.body.appendChild(iframe);
	} else if (typeof callback === "function") {
		callback();
	}
}

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

var script = Array.prototype.slice.call(document.getElementsByTagName("script"), -1)[0];
var main = script.getAttribute("data-main");
if (main) {
	window.addEventListener("load", function () {
		var comeon = new Comeon(script.getAttribute("data-path") || "/");
		comeon.require(main);
	});
}

Вот и все. Теперь мы можем использовать написанные в формате CommonJS модули на стороне браузера и дебажить их в свое удовольствие. Для этого нам нужно всего лишь подключить comeon.js с указанием пути к скриптам и имени главного модуля в data-атрибутах:

<script src="http://rawgithub.com/avgaltsev/comeon/master/comeon.js" data-path="scripts/" data-main="main"></script>

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

<script src="http://rawgithub.com/avgaltsev/comeon/master/comeon.js"></script>
<script>
	window.onload = function () {
		var comeon = new Comeon("scripts/");
		// Точка входа
		comeon.require("main");
		// Другая точка входа
		comeon.require("another_main", function (exports) {
			console.log(exports);
		});
	};
</script>

Автор: avgaltsev

Источник



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