Всем привет, хабровчане! Я (не)начинающий разработчик с относительно небольшим стажем, который пытается углубить свои знания в любимой технологии. В работе и повседневной жизни очень часто приходится работать с языком 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/
Добавить комментарий