Как настроить полифилл globalThis в универсальном JavaScript

Обложка поста

Предложенное свойство globalThis предполагает введение единого механизма доступа к глобальному значению this в любой среде JavaScript. Это похоже на обычный полифилл, но всё же немного отличается, и понять, что это на самом деле, довольно сложно.

В статье описаны трудности реализации правильного полифилла globalThis. Для него существуют следующие требования:

  • должен работать в любой среде JavaScript, включая браузеры, воркеры и расширения браузеров. А также в Node.js, Deno и standalone бинарных файлах на движке JavaScript;
  • должен поддерживать грязный и строгий (strict) режимы работы, а также модули JavaScript;
  • должен работать независимо от контекста, в котором запущен код (т. е. полифилл должен выдавать правильный результат, даже если его на этапе сборки обернут упаковщиком в строгий режим работы).

Обратите внимание, что в модулях JavaScript есть область-посредник между глобальной областью видимости и вашим кодом. Область видимости модуля скрывает значение this глобальной области видимости. Поэтому ключевое слово this, которое видно на верхнем уровне в модулях, на самом деле имеет свойство undefined.

TL;DR globalThis != глобальный объект, но globalThis == this из глобальной области видимости.

Альтернативы globalThis

Так сложилось, что для доступа к глобальному объекту в разных средах JavaScript требуется разный синтаксис. Например, в веб-среде можно использовать window, self или frames, при этом для веб-воркеров (и сервис-воркеров) работать будет только self.

globalThis === window;
// → true

globalThis === frames;
// → true

globalThis === self;
// → true

Node.js не работает со всем вышеперечисленным, в его случае нужно использовать global.

globalThis === global;
// → true

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

globalThis === this;
// → true

Проблема выше решается использованием Function(‘return this’)(), но в средах с отключенной функцией eval() вроде CSP нельзя использовать Function подобным образом.

globalThis === (function() {
	return this;
})();
// → true

globalThis === Function('return this')();
// → true

globalThis === (0, eval)('this');
// → true

Примечание setTimeout(‘globalThis = this’, 0) не следует использовать по тем же причинам, что и eval() и Function. Кроме того, функция setTimeout не является частью ECMAScript, а следовательно, не будет доступна во всех средах выполнения JavaScript. Вдобавок к этому, setTimeout асинхронна, и даже если бы она везде поддерживалась, использовать её в полифилле, от которого зависит другой код, было бы неразумно.

Примитивный полифилл

Похоже, вышеперечисленные методы можно было бы объединить в один полифилл, как например этот:

// Примитивный костыль для globalThis. Не используйте его!
const getGlobalThis = () => {
	if (typeof globalThis !== 'undefined') return globalThis;
	if (typeof self !== 'undefined') return self;
	if (typeof window !== 'undefined') return window;
	if (typeof global !== 'undefined') return global;
	if (typeof this !== 'undefined') return this;
	throw new Error('Unable to locate global `this`');
};
// Примечание: var используется вместо const, чтобы убедиться, что globalThis
// становится глобальной переменной (в отличие от переменной в
// лексической области верхнего уровня) при запуске кода в глобальной области видимости.
var globalThis = getGlobalThis();

Но, к сожалению, такой полифилл не будет работать в функциях во время исполнения кода в строгом режиме, в модулях JavaScript и в небраузерных средах (кроме поддерживающих GlobalThis).

Надёжный полифилл

А можно ли вообще написать надёжный полифилл globalThis? Возьмём в качестве примера среду, в которой:

  1. Нельзя полагаться на значение globalThis, window, self, global или this.
  2. Нельзя использовать конструктор Function или eval().
  3. Можно полагаться на целостность остальной встроенной функциональности JavaScript.

В таком случае есть решение, но оно не идеально.

Если установить значение функции на globalThis и вызвать его как метод, то можно получить доступ к this используя следующую функцию:

globalThis.foo = function() {
	return this;
};
var globalThisPolyfilled = globalThis.foo();

Как можно сделать что-то подобное, не полагаясь на globalThis или на специфичную связанную сущность, которая на него ссылается? Нельзя же просто сделать следующее:

function foo() {
	return this;
}
var globalThisPolyfilled = foo();

Функция foo() больше не является методом, а поэтому в строгом режиме или в модулях JavaScript у ключевого слова this будет значение undefined. Однако, это не относится к геттерам и сеттерам.

Object.defineProperty(globalThis, '__magic__', {
	get: function() {
		return this;
	},
	configurable: true // Благодаря этому впоследствии можно удалить геттер.
});
// Примечание: var используется вместо const, чтобы убедиться, что globalThis 
// становится глобальной переменной (в отличие от переменной в 
// лексической области верхнего уровня) при запуске кода в глобальной области видимости.
var globalThisPolyfilled = __magic__;
delete globalThis.__magic__;

Скрипт выше устанавливает геттер на полифилл globalThis, получает к нему доступ, чтобы в итоге ссылаться на globalThis, затем очищает полифилл, удаляя геттер. Таким образом у нас появляется доступ к globalThis при любых обстоятельствах, но этот метод опирается на глобальный this в первой строке (где написано globalThis). Можно ли как-то избавиться от этой зависимости? Как можно установить глобально доступный геттер без прямого доступа к globalThis?

Вместо установки геттера на globalThis, нужно установить его на то, что глобально наследует объект — Object.prototype.

Object.defineProperty(Object.prototype, '__magic__', {
	get: function() {
		return this;
	},
	configurable: true // Это позволит  позже избавиться от геттера.
});
// Примечание: var используется вместо const, чтобы убедиться, что globalThis;
// становится глобальной переменной (в отличие от переменной в 
// лексической области верхнего уровня).
var globalThis = __magic__;
delete Object.prototype.__magic__;

Примечание В спецификации ECMAScript не указано, что глобальное this наследует именно Object.prototype — только указано, что это строго должен быть объект. Функция Object.create(null) создаёт объект, который не наследуется от Object.prototype. Движок JavaScript мог бы использовать такой объект как глобальное this, не нарушая требования спецификации, но в этом случае фрагмент кода выше всё равно не сработал бы. Однако, в современных движках разработчики, похоже, согласны с тем, что глобальное this должно включать Object.prototype в своей цепочке прототипов.

Во избежание проблем с Object.prototype в современных средах JavaScript, где полифилл globalThis уже доступен, изменим его следующим образом

(function() {
if (typeof globalThis === 'object') return;
Object.defineProperty(Object.prototype, '__magic__', {
get: function() {
return this;
},
configurable: true // Это позволит избавиться от геттера позже.
});
__magic__.globalThis = __magic__;
delete Object.prototype.__magic__;
}());
// В коде теперь может использоваться globalThis.
console.log(globalThis);

Или можно использовать __defineGetter__ :

(function() {
	if (typeof globalThis === 'object') return;
	Object.prototype.__defineGetter__('__magic__', function() {
		return this;
	});
	__magic__.globalThis = __magic__;
	delete Object.prototype.__magic__;
}());

// В коде теперь можно использовать globalThis.
console.log(globalThis);

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

Не стоит играться с встроенными прототипами вообще — это объясняется в JavaScript Engine Fundamentals: optimizing prototypes.

С другой стороны, единственный способ сломать этот полифилл — изменить object, Object.defineProperty (или Object.prototype._ _defineGetter) перед его запуском.

Тестирование полифилла

Этот полифилл — интересный пример универсального JavaScript: чистый и независимый код, который не полагается на какие-либо встроенные компоненты той или иной среды выполнения, поэтому работает везде, где работает ECMAScript. Итак, одна цель достигнута, теперь посмотрим, как он будет работать.

Обратите внимание на пример HTML-страницы для полифилла, который журналирует globalThis, используя классический скрипт globalThis вместе с globalThis.mjs (с одинаковым исходным кодом). Этот пример может быть использован для проверки работы полифилла в браузерах. globalThis нативно поддерживается в Chrome 71, Firefox 65, Safari 12.1 (+iOS Safari 12.2). Чтобы протестировать нужные части полифилла, откройте демо-страницу в старых версиях браузеров.

Примечание Полифилл не поддерживается в Internet Explorer 10 и старше. В этих браузерах строка __magic__.globalThis = __magic__ по каким-то причинам не делает globalThis доступным глобально, несмотря на то, что __magic__ служит рабочей ссылкой на глобальное this. В итоге выясняется, что __magic__ !== window, хотя оба относятся к [object Window], что как бы намекает, что браузеры могут запутаться в определении глобального объекта и глобального this. Внесение правок в полифилл для отката к одной из альтернатив позволяет ему работать в IE 10 и 9. Для поддержки в IE 8 нужно обернуть Object.defineProperty в try-catch, подобным образом откатившись в блок catch (это также поможет избежать проблемы в IE 7 с глобальным кодом, который не наследуется от Object.Prototype). Попробуйте поиграть с демо-версией, поддерживающей старые версии IE.

Для тестирования полифилла в Node.js и отдельных движках JavaScript скачайте те же самые файлы с расширением .js/.mjs.

# Загрузите полифилл + демо-код как модуль.
curl https://mathiasbynens.be/demo/globalthis.mjs > globalthis.mjs
# Создайте копию (или символьную ссылку) файла, который должен будет использоваться как обычный скрипт.
ln -s globalthis.mjs globalthis.js

Теперь можно тестировать в Node.js.

$ node --experimental-modules --no-warnings globalthis.mjs
Testing the polyfill in a module
[object global]

$ node globalthis.js
Testing the polyfill in a classic script
[object global]

Для тестирования полифилла в отдельной оболочке JavaScript-движка используйте jsvu для установки любого желаемого движка, а затем запустите скрипты напрямую. Например, протестируем в V8, v7.0 (без поддержки globalThis) и v7.1 (с поддержкой globalThis):

$ jsvu v8@7.0 # Install the `v8-7.0.276` binary.

$ v8-7.0.276 globalthis.mjs
Testing the polyfill in a module
[object global]

$ v8-7.0.276 globalthis.js
Testing the polyfill in a classic script
[object global]

$ jsvu v8@7.1 # Install the `v8-7.1.302` binary.

$ v8-7.1.302 globalthis.js
Testing the polyfill in a classic script
[object global]

$ v8-7.1.302 globalthis.mjs
Testing the polyfill in a module
[object global]

Таким же образом можно тестировать JavaScriptCore, SpiderMonkey, Chakra и другие JavaScript-движки. Ниже приведён пример использования JavaScriptCore:

$ jsvu # Install the `javascriptcore` binary.

$ javascriptcore globalthis.mjs
Testing the polyfill in a module
[object global]

$ javascriptcore globalthis.js
Testing the polyfill in a classic script
[object global]

Заключение

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

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

Перевод статьи: «A horrifying globalThis polyfill in universal JavaScript»

Как Яндекс использует ваши данные и машинное обучение для персонализации сервисов — читать и смотреть YaC 2019.