Это перевод статьи Николаса Закаса, создателя ESLint и популярного автора, о дефолтном экспорте в JavaScript. Повествование ведётся от лица автора оригинальной публикации.
В январе 2019 года я написал в «Твиттере» о планах отказаться от экспорта по умолчанию в модулях JavaScript. В ответ получил много неожиданных сообщений. Вот текст поста:
В 2019 году планирую отказаться от экспорта по умолчанию в модулях CommonJS/ES6. При импорте дефолтных экспортов шанс ошибиться составляет 50 на 50. Что я импортирую? Это класс? Это функция?
Написал этот пост, когда понял, что львиная доля проблем с модулями в JavaScript может быть связана с экспортом по умолчанию. Не имеет значения, идёт ли речь о модулях JavaScript (или ECMAScript, как многие любят говорить) или CommonJS. Я всякий раз спотыкаюсь при импорте дефолтных экспортов. В ответ на твит получил много вопросов, многие из которых касались того, как именно я пришёл к решению отказаться от экспорта по умолчанию. Эта статья — попытка ответить на все вопросы.
Как и все остальные мои твиты, твит об отказе от дефолтных экспортов был скорее снепшотом или кратким выражением моего мнения, а не публикацией мнения целиком. Нужно прояснить несколько моментов, которые смутили подписчиков в твиттере.
Надеюсь, эти уточнения снимут большую часть вопросов, которые возникли у моих подписчиков.
Насколько я знаю, экспорт по умолчанию впервые стал популярным в CommonJS, где модуль можно экспортировать так:
class LinkedList {}
module.exports = LinkedList;
Здесь экспортируется класс LinkedList, но при этом не определяется имя класса для пользователей модуля. Если имя файла — linked-list.js, можно импортировать этот дефолтный экспорт в другой модуль CommonJS так:
const LinkedList = require("./linked-list");
Функция require() возвращает значение, которое я только что назвал LinkedList, чтобы оно соответствовало содержимому файла linked-list.js. Но я также мог назвать его foo или Mountain или любым другим именем.
Популярность экспорта по умолчанию в CommonJS привела к тому, что модули JavaScript разрабатывались с учётом необходимости поддержки этого паттерна:
В ES6 предпочтительно использовать экспорт по умолчанию. Он позволяет использовать приятный синтаксис при импорте экспортированных по умолчанию модулей.
В JavaScript можно экспортировать по умолчанию так:
export default class LinkedList {}
Импорт выглядит так:
import LinkedList from "./linked-list.js";
Опять же, LinkedList в данном случае — произвольное имя. С таким же успехом можно использовать Dog или symphony.
Кроме экспорта по умолчанию, CommonJS и JavaScript поддерживают именованный экспорт. Именованный экспорт позволяет передавать имена функций, классов, переменных в файл, где они будут использоваться.
В CommonJS именованный экспорт выполняется путём присоединения соответствующего имени к объекту exports. Пример ниже.
exports.LinkedList = class LinkedList {};
Импорт выглядит так:
const LinkedList = require("./linked-list").LinkedList;
Опять же, константу можно было назвать как угодно, но я решил, что имя должно совпадать с экспортированным — LinkedList.
В JavaScript именованный экспорт выглядит так:
export class LinkedList {}
Импорт будет таким:
import { LinkedList } from "./linked-list.js";
В этом примере LinkedList — не случайный идентификатор. Он должен совпадать с именованным экспортом LinkedList. Это единственное важное отличие JavaScript от CommonJS в контексте вопросов, которые рассматриваются в этой статье.
Промежуточный итог: в CommonJS и JavaScript поддерживается как экспорт по умолчанию, так и именованный экспорт.
Прежде чем идти дальше, стоит поделиться личными предпочтениями при работе с кодом. Это общие принципы, которых я придерживаюсь, когда пишу код на любом языке программирования:
Важный момент: для меня важен фокус на скорости разработки. Поскольку здоровье с годами ухудшается, энергии на работу с кодом остаётся всё меньше. Всё, что помогает сократить время на написания кода без ущерба для результата, я приветствую.
С учётом сказанного выше, я сформулировал основные проблемы, с которыми сталкиваюсь при экспорте по умолчанию, и которые можно решить с помощь именованного экспорта.
Как сказано в моём твите, при использовании экспорта по умолчанию сложно разобраться, что именно я импортирую. Если вы работаете с модулем или файлом, который не знаете, сложно понять, что он возвращает. Пример:
const list = require("./list");
В контексте этого примера попробуйте понять, что такое list. Вряд ли это примитивное значение, но это может быть функция, класс или другой тип объекта. Как я могу узнать это наверняка? Понадобится дополнительное исследование или прогулка по неизвестной тропинке. В этом случае могут понадобиться такие действия:
В любом случае, придётся запомнить дополнительную информацию, чтобы не возвращаться к изучению файла или чтению документации, когда понадобится новый импорт из list.js. При импорте экспортированных по умолчанию сущностей увеличивается когнитивная перегрузка, так как вам приходится либо запоминать дополнительную информацию, либо дополнительно изучать что-то. Это неоптимальная стратегия.
Кто-то скажет, что эту проблему решают IDE, так как они достаточно «умные» для того, чтобы подсказывать, что именно вы импортируете. Я поддерживаю использование IDE, так как они действительно помогают разработчикам. Но требовать от IDE эффективного использования функций языка — проблематичный подход.
Использование именованного экспорта требует, чтобы в файлах-пользователях как минимум определялось имя сущности, которая импортируется. Преимущество в том, что я могу легко найти все случаи использования LinkedList в коде и понять, что это один и тот же LinkedList. В отличие от экспорта по умолчанию, где не надо держать в уме имена, при именованном экспорте разработчик прилагает дополнительные когнитивные усилия. Вы должны выработать правильный подход к именованию, а также должны убедиться, что все разработчики, которые работают над приложением, используют одни и те же имена. Конечно, можно разрешить разработчикам использовать разные имена, но это ещё сильнее усилит когнитивную перегрузку.
Импорт именованного экспорта означает как минимум указание канонического имени сущности везде, где она используется. Даже если вы решите переименовать импорт, это решение будет явным, и его невозможно выполнить без указания канонического имени. Пример в CommonJS:
const MyList = require("./list").LinkedList;
Пример в JavaScript:
import { LinkedList as MyList } from "./list.js";
В каждом из этих примеров вы явно указываете, что LinkedList будет использоваться как MyList.
Когда в коде принято согласованное именование, вы можете:
Возможно ли это при использовании экспорта по умолчанию и именовании по принципу ad hoc? Догадываюсь, что возможно. Но также думаю, что это будет намного сложнее, к тому же, увеличится вероятность ошибок.
Ad hoc — латинское выражение, которое применяется для обозначения исключительных случаев, специальных процедур, которые не укладываются в общую практику.
Именованный экспорт в модулях JavaScript имеет важное преимущество перед экспортом по умолчанию. Если вы попробуете импортировать несуществующую сущность, при использовании именованного экспорта будет выброшена ошибка. Посмотрите на этот код:
import { LinkedList } from "./list.js";
Если в list.js нету LinkedList, будет выброшена ошибка. Более того, IDE и ESLint определяют недостающие ссылки ещё до выполнения кода.
Если вернуться к IDE, можно напомнить, что WebStorm может помогать разработчику определять импорты. Если вы введёте идентификатор, который не определён в файле, WebStorm автоматически начнёт искать этот идентификатор в именованных экспортах в модулях вашего проекта. В этот момент он может выполнить любое из указанных действий:
Как видите, WebStorm действительно помогает вам с именованным импортом. Для Visual Studio Code есть плагин с аналогичной функциональностью. А при использовании экспорта по умолчанию эта функциональность не работает, так как канонического имени для сущности, которую вы импортируете, не существует.
Я сталкивался с несколькими проблемами, которые снижали продуктивность, когда пользовался экспортом по умолчанию. Хотя среди этих проблем нет непреодолимых, использование именованного экспорта и импорта всё-таки больше подходит мне. Моя продуктивность растёт, когда я делаю вещи явно и использую автоматические инструменты. Именованный экспорт помогает мне, поэтому я буду использовать его в обозримой перспективе. Конечно, я не могу влиять на то, какой экспорт используют разработчики внешних модулей, которые я использую. Но я точно контролирую свои модули, и в них я буду использовать именованный экспорт.
Ещё раз напоминаю, что всё вышесказанное — моё личное мнение, и вы можете считать его недостаточно убедительным. Эта статья написана не для того, чтобы убедить кого-то отказаться от экспорта по умолчанию. Это всего лишь объяснение моего решения не использовать экспорт по умолчанию в модулях, которые я пишу.
Адаптированный перевод статьи Why I've stopped exporting defaults from my JavaScript modules by Nicholas C. Zakas. Мнение администрации Хекслета может не совпадать с мнением автора оригинальной публикации.