Галоўная / Артыкулы / CommonJS проты ES Modules: Структурныя адміністрацыі, якія ствараюць проблемы з імпортом у Node

CommonJS проты ES Modules: Структурныя адміністрацыі, якія ствараюць проблемы з імпортом у Node

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

1364 слоў

Практычна кожны апошніцкі бяг пры работе з Node выкалываецца з аднаго факту, які рэдкая час выясняецца чыста. Паведамленні на кшталт скаргі пра тое, што require не існуе ў межах модуля, або синтаксічныя бягі пра вжыць import за межами модуля, або пакеты, якія ведаюць ся неадекватна залежна ад таго, як вы яго запрашваеце — усё гэта вказывае на адну і тую ж прычыну: CommonJS і ES Modules — гэта не проста два варыянты адной і той жа ідеі. Гэта два сапраўдна разныя системы. Адна з іх пабудавана на вызовах функцый, якія выконваюцца моментальна пасля ўзыву; іншая — на структуре, яюй двойчык можа пераглядзець до таго, калі ўсё насправдзе запускаецца. Практычна кожны проблема, з якой вы сталкнуліся пад час супрацоўкі гэтых двух систем, ёсць прымусовы наследкам гэтага розліку.

CommonJS: require — гэта проста вызов функцыі

Лёгкая ўпусціць з галавы, калі вы запісвалі require() тыячу разоў, што ў яго нема нічага чародзяйскага. Це звычная функцыя, а module.exports — звычны об’ект; якія бяруцца ад Node пад час выконання, а не ўбудованы ў саму мову.

// math.js
function add(a, b) { return a + b; }
module.exports = { add };

// app.js
const math = require("./math.js"); // a plain function call, evaluated when this line runs
console.log(math.add(2, 3));

Пакалі require паводзіцца як будзь-яка іншая функцыя, вы можете вызваць яе за певнымі умовамі: у блоку if, у структуре try/catch або з шляхам, вычысленым з мэнькі, — ўсё, што дозволяе стандартны вызов функцыі.

const driver = require(process.env.DB_DRIVER === "postgres" ? "./pg-driver" : "./sqlite-driver");

Гэтая гнучкасць справды корисная, і саме ўсё гэта ES Modules паціўпіліся пасвятаваць.

ES Modules: двыгун чытае структуру перш чым запускаць будзь-які код

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

// math.mjs
export function add(a, b) { return a + b; }

// app.mjs
import { add } from "./math.mjs"; // not evaluated like a function call
console.log(add(2, 3));
if (needsMath) {
  import { add } from "./math.mjs"; // SyntaxError, this is not allowed
}

Эта абсалютна не ўзначальная выбіркавасць. Яна існуе таму, што ES Modules задуманыя для статычнага аналізу: прытаманне жадной лініі вашага програмы на практыцы, двайчорка праходзіць праз кожны import і export у всім графе модуляў і стварае цэлышчы карточку таго, на шта залежыць калякое. Гэты статычны карточка і ўможлівяе tree-shaking — бандлер можа адзірнуць граф і безпечна адключыць код, які быў выэкспортаваны, але нігдзе не быў імпортаваны, таму што адносы залежнасцяў вядомы ўжо заздалегідь, а не як што-то, што стане відчутным толькі пад час выконання. CommonJS не можа дастаць такой жа абяцаці, таму што вызовы require() можаць быць умовнымі, вырачанымі або захаванымі ў логіцы, якая рашаецца толькі пад час выконання програмы — сама гэта гнучкасць робіць граф залежнасцяў CommonJS немагчымым дакладна ведаць.

Далей.

Стандартныя экспорты не маюць аднаковага значэння ў обох системах

Сюды самэўсёлкі і пачынаецца проблема сумеснай роботы. У CommonJS запіс module.exports = something проста заменяе тое, чым ёсць весь модуль — няма окрамленага панявання пра „стандартны экспорт“, які бы стаяў аддзельна ад усіх іншых экспортаў:

// legacy.js
module.exports = function greet(name) {
  return `Hello, ${name}`;
};

У ESM жа стандартны экспорт расследжваецца як чыста окрамленая, структурна аддзельная канцэпцыя:

// modern.mjs
export default function greet(name) {
  return `Hello, ${name}`;
}

Калі шар сумеснай роботы Node чытае файл CommonJS з коду ESM, ён берае весь значэнне module.exports і паказвае яго як стандартны экспорт. Гэта зазвычай ёсць разумная практыка, але гэта таксама стварае самэўсёлкі тыя тонкія неспараднасці, якія падводзяць людей:

import greet from "./legacy.js"; // works: greet is the whole module.exports value
import { greet } from "./legacy.js"; // fails silently or throws, depending on the module
// named destructuring assumes CommonJS explicitly attached named properties,
// which module.exports = function... never did

Гэта адна неяснасць — чы гэты модуль, які вы імпортуеце, ўсё цэлае ядро модуля чы толькі адна з його частака пад апошнім іменем — стварае вялікую частку багоў типу „чаму гэта не адзначана“, калі база коду сумешчае старыя пакеты CommonJS з новым кодам, які працуе на прынцыпах ESM.

Ціркулярныя імпорты рашаюцца разна, і гэта дзейсна мае значэнне

Калі два модулі імпортуюць адзін другога, гэта вяліка сложнасць у будзь-яй системе модуляў, але CommonJS і ESM падходзяць да гэтай слабасі разна, таму код з аднаковым выглядам можа выканацься неяксна залежна ад таго, у якай системе ён працуе.

// a.js (CommonJS)
const b = require("./b.js");
console.log("b's value:", b.value);
module.exports = { value: "from a" };

// b.js (CommonJS)
const a = require("./a.js");
console.log("a's value:", a.value); // undefined — a hasn't finished exporting yet
module.exports = { value: "from b" };

CommonJS рашае гэта, вяртаючы тое, што ў момент запиту є module.exports модуля, які трэбаецца цыклічна, нават якщо гэты модуль ўсё ўжо не завершыў сваю експаніяцію. Самэльга a.value становіць undefined, калі яго чытаюць знутры b.jsa.js ўжо не дасягнуў рэгістрацыі module.exports на момент, калі b.js запытаўся пра яго.

ESM выкорыстоўвае іншы падход за дапамогай так званых жывых прыяўленняў: канкрэтныя аб’екты, якія застаюцца на звязку з модулем, які іх выкарыстоўвае, і автаматычна апдэйтуюцца пасля таго, як гэты модуль завершыў свою обработку, замест таго, каб была створана фіксаваная копія на момент імпорту. Гэта значыць, што певныя цыркулярныя структуры працуюць правільна пад ESM, тады калі пад CommonJS яны проста вяртаюць значэнне undefined. Але гэта не ператварае цыркулярныя імпорты на хорашую практіку ні ў одной з систем — гэта проста зміняе спосаб, якім праказуецца адказка, а не ліквідоўвае яго.

Практычная пастка: сумешанне іх у аднам проекте

У павсякдзеннай працы проблема не ў концэптуальных аспектах — яна выражаецца у конкрэтных, павторюваных групах адказак:

SyntaxError: Cannot use import statement outside a module
ReferenceError: require is not defined in ES module scope
ReferenceError: exports is not defined

Этыя проблемы выступаюць тады, калі Node павінен з’ясаваць, у якам формате модуля напісаны даны файл, і для гэтага він выкарыстоўвае калькі сигналаў: чы рашчытанні файла заканчваецца на .mjs, чы на .cjs, а якщо ніяк з гэтага не выйшло, то што заданае найбліжэйшым package.json у поле "type". Калі фактычная сынтаксіс файла не паспявае да таго, як Node хочаць яго адразумець, ствараюцца самэ гэтыя прычыны. Пакет, выданы толькі у формате ESM, проста не можа быць запрашаны за дапамою require() з коду CommonJS. Ён можа быць выкарыстоўаны толькі за дапамою асінхроннага import() — які, на вядменнае ад статычнага ключавага слова import, працуе як справжнія вызов функцыі і можа быць выкарыстоўаны будзь-дзе, уключаючы умовныя выразы — або пасля полной міграціі коду, які яго выкарыстоўвае, у формат ESM.

// this works from CommonJS, because import() is a dynamic function call, not a static declaration
async function loadEsmOnlyPackage() {
  const mod = await import("esm-only-package");
  return mod.default;
}

Едын факт, які лежыць у основе всьага

Кожны окремы момент троблів — такі як вымога, каб import знаходзілася на верхнім рэвэле, можлівасць выкарыстоўвання тэхнікы tree-shaking у адной системе, але не ў іншай, несувярэнныя стандартныя экспорты і рознае паведанне цыркулярных імпортаў — маюць адну спальную прычыну: CommonJS стварае свой граф модуляў дынамічна, пад час выканання програмы, тады як ESM стварае свой граф статычна, ўсё раней, чым будзе выкананы хоць які код. Ні адны з эых падходаў не ёсць недакладнасцю ў дызайне другога; оба адпавядаюць на тая ж запытанне — „як файлы залежаць адзін ад другога?“ — але з сапраўдна разнымі абмежэннямі і сапраўдна разнымі гарантіямі. Троблі, якія вы відчуваеце пад час сумешання ўжытку эых систем, не ёсць наследкам несправнасці Node. Це два внутршна сувярэнныя системы, якія змушаны весты абмен інфармацыяй на межы ўсходжэння.

Спадневана літаратура