Про esModuleInterop и совместимость модулей ES6 и CommonJS

от автора

Всем привет, хабровчане! Я (не)начинающий разработчик с относительно небольшим стажем, который пытается углубить свои знания в любимой технологии. В работе и повседневной жизни очень часто приходится работать с языком TypeScript, который мне очень нравится, но к своему стыду, сам очень плохо знаком с настройкой его конфигурации, поэтому решил восполнить этот пробел, ведя собственный Today I Learned. Некоторые опции tsconfig являются очень простыми и понятными. Другие же заставляют знатно напрячься. И даже если поверхностное назначение какой-то настройки является понятным, все равно возникает желание разобраться с принципом ее действия, понять, на какие структурные аспекты проекта она влияет, а также узнать, а как вообще людям жилось до ее появления.

Как раз об одном из них и пойдет разговор в этой статье, а именно об esModuleInterop. Действие опции проверялось при попытке подружить CommonJS-модуль с ES-модульным проектом. Поверхностная гуглешка не дала исчерпывающий ответ на ряд моих вопросов, поэтому приходилось обращаться к спецификации ES6, документации tsconfig (упаси боже читать документацию (шутка)), в личные блоги авторитетных в сообществе дядек (https://2ality.com/2014/09/es6-modules-final.html) и к описаниям модульных систем. На основе найденной информации я составил небольшое резюме, с попыткой собрать материал во едно. Надеюсь, кому-то он покажется интересным. Приятного чтения!

Небольшая предыстория

В июне 2015 года на свет появилась спецификация EcmaScript2015, которая подарила разработчикам нативную модульную систему с приятным синтаксисом по работе с зависимостями. До этого момента использовались синтетические модульные системы, которые пытались ограничить область видимости языковых единиц, к примеру IIFE, CommonJS, AMD с ее реализацией RequireJS, UMD и другие (источник).

Но сегодняшний день большая часть npm-пакетов для NodeJS написано с использованием модульной системы CommonJS. Попытка подружить их приводит к ряду (на мой взгляд) не критичных проблем. Однако в транспилятор tsc заложен механизм адаптации CommonJS модулей. Но сначала рассмотрим, как работали импорты до появлениях этих инструментов в babel и tsc.

Мой tsconfig:

{   "target": "es2022",   "module": "commonjs",   "rootDir": "./src",   "outDir": "./build",   "moduleResolution": "node",   // "esModuleInterop": true,   // "allowJs": true,   "importHelpers": false,   "forceConsistentCasingInFileNames": true,   "strict": true,   "skipLibCheck": true }

Использование CommonJS-пакетов с отключенными опциями

На самом деле при отсутствии этих опций мы уже можем работать с commonjs модулем. Чтобы импортировать сущности из этого модуля, можно воспользоваться именованным импортом или импортом пространства имен (не путать с namespace(-ом)).

Именованный импорт

Рассмотрим следующий пример:

// commonjs.js module.exports = {     sum: function (a, b) {         return a + b;     },     aboba: function() {         return undefined     } };  // index.ts import { aboba, sum } from './commonjs.js';  console.log(sum(1, 2)); console.log(aboba);

В результате имеем следующий транспилированный код:

// index.js "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const commonjs_1 = require("./commonjs"); console.log((0, commonjs_1.sum)(1, 2)); console.log(commonjs_1.aboba);

Здесь можно увидеть, что tsc преобразовал код index.ts в commonjs-модуль. Это можно понять, взглянув на третью строчку, где транспилятор добавил признак «__esModule», чтобы пометить его как нативный es6 модуль (роль этого флага будет подробно рассматриваться в будущих примерах). В примере показано, что преобразованный index.js с помощью require загружает модуль commonjs.js, после чего идет обращение к импортированным sum и aboba.

Давайте еще остановимся на моменте вызова функции sum, которая почему-то оборачивается в круглые скобки, через запятую добавляется какой-то ноль, и только потом осуществляется сам вызов с переданными аргументами. На самом деле это прием, который использует транспилятор при импорте с помощью синтаксиса es6. Прием использует оператор «,», который возвращает последний элемент из этой последовательности. В данном случае будет возвращена ссылка на функцию sum. Дело в том, что при вызове sum напрямую через объект модуля, то контект this для этой функции будет определен как объект module.exports модуля commonjs.js. Извлекая sum из контекста модуля, транспилятор помещает ее в глобальный контекст, таким образом избегая нежелательных эффектов.

require-синтаксис

TypeScript предоставляет особый вид синтаксиса, который позволяет импортировать CommonJS и AMD модули подобно тому, как это делается в указанных модульных системах (ссылка на документацию https://www.typescriptlang.org/docs/handbook/2/modules.html).

// index.ts import module = require('./commonjs.js');  module.sum(1, 2);

Результат транспиляции:

"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const commonjsModule = require("./commonjs.js"); commonjsModule.sum(1, 2);

Результат очень похож на тот, что мы видели при использовании механизма именованного импорта, однако в этом случае функция sum вызывается в контексте модуля. Отсюда следует, что механизм менее безопасный, и в проектах использовать его не стоит. Однако он полностью реализует работу с нашим CommonJS модулем, позволяя как обращаться к функции, определенной по имени, так и к той, что могла бы экспортироваться анонимно через module.exports.

Еще один минус в пользу отказа от такого импорта — такой синтаксис импорта встроен в сам язык и не является частью стандарта es-модулей.

Импорт пространства имен

Следующий пример импорта:

import * as commonjsModule from './commonjs.js';  commonjsModule.sum(1, 2);

Результат транспиляции:

"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const commonjsModule = require("./commonjs.js"); commonjsModule.sum(1, 2);

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

Промежуточное резюме

Наличие различных модульных систем, которые имеют разную внутреннюю организацию порождает множество вариации синтаксиса импорта/экспорта для работы с уже существующими модульными системами. Другая проблема — проблема с определениями типов при импорте. Современные версии TS-могут гибко определять импортируемые типы, однако для того же пространства имен происходит путаница.

Рассмотрим следующий пример для импорта пространства имен:

// commonjs.js module.exports = function () { }  // index.ts import * as commonjsModule from './commonjs.js';  commonjsModule();

В данном примере среда распознает commonjsModule одновременно и как module и как функцию без аргументов. Возможно в более ранних версиях TS сделал бы более жесткое предупреждение. Вообще стандарт ES6 для модулей требует, чтобы импорт namespace(-а) представлял собой объект с набором свойств, который описывает интерфейс модуля. А это значит, что на уровне организации ES6 и CommonJS модулей имеется несовместимость, поскольку CommonJS не удовлетворяет одному из требований спецификации (спецификация, документация tsconfig).

Еще одна проблема взаимодействия ES6 и CommonJS модулей — разная реализация импорта по умолчанию. Синтаксис ES6 предоставляет возможность экспортировать сущность через конструкцию export default (одна сущность на весь модуль). Такая конструкция дает разработчикам некоторые приятные возможности:

  • разноcить сущности отдельно по собственным модулям (как, например, это реализовано в языке Java);

  • пользоваться удобной и простой конструкцией импорта сущности по умолчанию.

Кроме того, экспорт по умолчанию может присутствовать в модуле совместно с именованным экспортом.

Напротив, CommonJS модули не могут использовать именованный и дефолтный экспорт одновременно.

// Именованный module.exports = {     sum: (a, b) => a + b,     aboba: function() { } };  // Импорт по умолчанию module.exports = function () {  }

Отсюда вытекает еще одна задача на пути к достижению совместимости ES и CommonJS -модулей.

Использование транспилятора для адаптации CommonJS модулей к ES6

Проблему совместимости решают различные транспиляторы кода. Среди популярных иснтрументов можно выделить tsc и babel. Так как эта статья появилась в результате разбора опций tsconfig, то далее будет рассматриваться код, сгенерированный утилитой tsc.

В контексте TS проблема совместимости решается при помощи опции esModuleInterop, которая в процессе компиляции добавляет хэлперы в ES6-модули, которые осуществляют преобразование зависимостей (CommonJS-модулей) в совместимые модули. Эти функции имеют имена _importDefault и _importStar.

__importDefault

Рассмотрим следующий пример:

// commonjs.s module.exports = {     sum: (a, b) => a + b,     aboba: function() {} };  // index.ts import commonjsModule from './commonjs.js';  commonjsModule.sum(1, 2);

В результате транспиляции получаем:

"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) {     return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const commonjs_1 = __importDefault(require("./commonjs.js")); commonjs_1.default.aboba();

Как видно из примера загрузка CommonJS-модуля осуществляется при помощи require, вызов которой оборачивается в хэлпер. Хэлпер устроен достаточно просто. Он проверяет наличие признака __esModule. Если он выставлен, то переданный модуль является нативным ES6 модулем (как, например, index.ts, для которого транспилятор специально устанавливал этот флаг) и он возвращается как есть. Иначе импортированный объект оборачивается в default.

Кажется, что такая конструкция немного спорная, потому что в default помещается весь объект нашего модуля. Однако заранее узнать, что именно наодится в module.exports (объект со свойствами|константа|вызываемое выражение и другие) — невозможно. Подобное решение в целом удовлетворяет спецификации.

Примечание: В приведенном примере используется конструкция синтетического дефолтного импорта, которая доступна благодаря опции allowSyntheticDefaultImports, которая автоматически устанавливается в true, когда выставлен флаг esModuleInterop. В обычной ситуации TS-анализатор выкинул бы ошибку, что для модуля с отсутсвующим default-экспортом такая конструкция недопустима. Однако выставленный набор опции позволяет работать с commonjs модулем подобным образом.

__importStar

Рассмотрим следующий пример с тем же CommonJS модулем, но с импортом простнаства имен:

import * as commonjsModule from './commonjs.js';  commonjsModule.sum(1, 2); commonjsModule.aboba();

Результат:

"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {     if (k2 === undefined) k2 = k;     var desc = Object.getOwnPropertyDescriptor(m, k);     if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {       desc = { enumerable: true, get: function() { return m[k]; } };     }     Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) {     if (k2 === undefined) k2 = k;     o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {     Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) {     o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () {     var ownKeys = function(o) {         ownKeys = Object.getOwnPropertyNames || function (o) {             var ar = [];             for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;             return ar;         };         return ownKeys(o);     };     return function (mod) {         if (mod && mod.__esModule) return mod;         var result = {};         if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);         __setModuleDefault(result, mod);         return result;     }; })(); Object.defineProperty(exports, "__esModule", { value: true }); const commonjsModule = __importStar(require("./commonjs.js")); commonjsModule.sum(1, 2); commonjsModule.aboba();

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

Резюме

В итоге получаем, что опция esModuleInterop с allowSyntheticDefaultExport (включается автоматически с esModuleInterop) облегчет нам взаимодействие с другими модульными системами, позволяя нам как разработчикам писать более привычные импорты с использованием современного синтаксиса с корректной поддержкой типов.

Немного про importHelpers

Как вы могли заметить, реализация совместимости модулей создает достаточно много бойлерплейта, который увеличивает размер скомпилированного кода. Подобный код будет добавлен в каждый модуль, использующий импорт CommonJS. Чтобы решить эту проблему можно воспользоваться флагом importHelpers, который будет брать реализацию этих функций из библиотеки tslib (ее так же нужно будет добавить в ваш package.json)

P.S.

Спасибо всем, кто дочитал до конца! Буду рад комментариям и предложениям


ссылка на оригинал статьи https://habr.com/ru/articles/930182/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *