← Курс/Модули в JavaScript: CommonJS vs ES Modules#136 из 257+40 XP

Модули в JavaScript: CommonJS vs ES Modules

Краткий ответ

CommonJS (CJS) — система модулей Node.js: require() синхронный, exports — обычный объект, разрешение зависимостей в рантайме. ES Modules (ESM) — стандарт ECMAScript: import/export статические (анализируются до выполнения), поддерживают tree shaking, работают в браузере нативно, динамический import() для ленивой загрузки. Современные проекты используют ESM, Node.js поддерживает оба формата.

Полный разбор

CommonJS — синхронный, динамический

// math.js (CommonJS)
function add(a, b) { return a + b }
function multiply(a, b) { return a * b }

module.exports = { add, multiply }
// или по одному:
// module.exports.add = add
// exports.add = add  (shorthand)

// main.js (CommonJS)
const { add, multiply } = require('./math')
// или: const math = require('./math')

// Ключевые особенности CJS:
// 1. Синхронный: require() блокирует поток до загрузки модуля
// 2. Динамический: можно использовать внутри условий и функций
if (process.env.NODE_ENV === 'development') {
  const devTools = require('./dev-tools')  // динамически!
}

// 3. Кэширование: повторный require() возвращает тот же объект
const a = require('./math')
const b = require('./math')
console.log(a === b)  // true — один и тот же объект из кэша

// 4. Объект exports — живая ссылка на module.exports
// exports.foo = 'bar'  работает
// exports = { foo: 'bar' }  НЕ работает (переприсваивание ссылки)

ES Modules — статические, асинхронные

// math.js (ES Modules)
export function add(a, b) { return a + b }
export function multiply(a, b) { return a * b }
export const PI = 3.14159

// default export — один на файл
export default class Calculator {
  // ...
}

// main.js (ES Modules)
import Calculator, { add, multiply, PI } from './math.js'
// named imports + default import

// Реэкспорт (barrel file pattern):
// index.js
export { add, multiply } from './math.js'
export { default as Calculator } from './math.js'

// Переименование при импорте:
import { add as sum, multiply as mul } from './math.js'

// Импорт всего как namespace:
import * as MathUtils from './math.js'
MathUtils.add(1, 2)

Named exports vs Default export

// DEFAULT — один главный экспорт, импортируется с любым именем
// Когда: один класс, одна функция, одна константа — главное в файле
export default function fetchUser(id) { /* ... */ }
import fetchUser from './user'    // любое имя
import getUser  from './user'    // тоже работает!

// NAMED — несколько именованных экспортов, имена фиксированы
// Когда: набор утилит, константы, несколько функций
export const API_URL = 'https://api.example.com'
export function formatDate(date) { /* ... */ }
import { API_URL, formatDate } from './utils'  // имена точные

// ЛУЧШАЯ ПРАКТИКА: предпочитай named exports
// Они явные, лучше работают с tree shaking и IDE autocomplete

Циклические зависимости

// a.js
import { b } from './b.js'
export const a = 'A: ' + b  // b может быть undefined при старте!

// b.js
import { a } from './a.js'
export const b = 'B: ' + a  // a может быть undefined при старте!

// ESM: живые привязки (live bindings) — значения обновляются
// CJS: копии значений на момент require()
// Решение: рефакторить, чтобы избежать циклов, или использовать функции

Tree Shaking

// utils.js — 3 функции, но используем только одну
export function formatDate(d) { /* ... */ }
export function parseDate(s) { /* ... */ }
export function addDays(d, n) { /* ... */ }

// main.js
import { formatDate } from './utils.js'
// С ESM (статический анализ): бандлер увидит, что parseDate и addDays
// не используются, и исключит их из бандла (tree shaking)

// С CJS это НЕВОЗМОЖНО:
const utils = require('./utils')
// Бандлер не знает, какие свойства объекта будут использоваться в рантайме
utils.formatDate(new Date())  // весь utils.js попадает в бандл

Динамический import() — ленивая загрузка

// Статический import — загружается всегда при старте
import { heavyChart } from './chart.js'  // загружается сразу

// Динамический import() — Promise, загружается по требованию
async function showChart() {
  // Загружается только когда нужен (code splitting)
  const { heavyChart } = await import('./chart.js')
  heavyChart.render('#container')
}

// В React (lazy loading компонентов):
// const ChartPage = React.lazy(() => import('./ChartPage'))
// Webpack/Vite автоматически создадут отдельный чанк

// Условная загрузка (нельзя со статическим import):
async function loadPlugin(name) {
  const plugin = await import(`./plugins/${name}.js`)
  return plugin.default
}

Настройка в package.json

{
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.esm.js",
      "require": "./dist/index.cjs.js"
    }
  }
}

Связанные уроки курса

  • Модули — базовый разбор модульной системы JS
  • Динамические импорты — детальный разбор import() и code splitting
  • Как отвечать на собеседовании

    Начни с главного отличия: «CJS — синхронный, динамический, runtime-разрешение; ESM — статический, async, compile-time анализ, что и делает tree shaking возможным». Объясни практические последствия: tree shaking уменьшает размер бандла, динамический import() позволяет code splitting. Упомяни live bindings в ESM vs. копии значений в CJS.

    Красные флаги ответа

  • «require — это просто как import» — они фундаментально различаются: синхронность, время разрешения, live bindings
  • Незнание про tree shaking — почему ESM лучше для бандлеров, важная тема для frontend-разработчика
  • Путаница между default и named exports — частая ошибка при работе с библиотеками, ведёт к runtime ошибкам
  • Примеры

    Демонстрация обеих модульных систем, динамический import, паттерн barrel-файла

    // ===== ЭМУЛЯЦИЯ CommonJS В СРЕДЕ ВЫПОЛНЕНИЯ =====
    console.log('=== CommonJS паттерны ===')
    
    // Эмулируем module.exports / require для демонстрации
    function createCJSModule(factory) {
      const module = { exports: {} }
      const exports = module.exports
      factory(module, exports)
      return module.exports
    }
    
    // math.cjs.js — CommonJS стиль
    const mathCJS = createCJSModule((module, exports) => {
      function add(a, b) { return a + b }
      function multiply(a, b) { return a * b }
      const PI = 3.14159
    
      // Два способа экспорта в CJS:
      exports.add = add          // через exports shorthand
      exports.multiply = multiply
      module.exports.PI = PI     // через module.exports
    })
    
    console.log('add(2, 3):', mathCJS.add(2, 3))        // 5
    console.log('multiply(4, 5):', mathCJS.multiply(4, 5)) // 20
    console.log('PI:', mathCJS.PI)                       // 3.14159
    
    // Деструктурирование при импорте (CJS стиль)
    const { add, multiply, PI } = mathCJS
    console.log('Деструктурирование:', add(1, 2), multiply(3, 4), PI)
    
    // ===== ES MODULES ПАТТЕРНЫ (статические) =====
    console.log('\n=== ES Modules паттерны ===')
    
    // Named exports — несколько утилит
    // В реальном ESM файле: export function formatDate(d) { ... }
    const dateUtils = (() => {
      function formatDate(date) {
        const d = new Date(date)
        return d.toLocaleDateString('ru-RU')
      }
    
      function formatTime(date) {
        const d = new Date(date)
        return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
      }
    
      function isWeekend(date) {
        const day = new Date(date).getDay()
        return day === 0 || day === 6
      }
    
      // export { formatDate, formatTime, isWeekend }
      return { formatDate, formatTime, isWeekend }
    })()
    
    const now = new Date('2024-03-15T14:30:00')
    console.log('formatDate:', dateUtils.formatDate(now))
    console.log('formatTime:', dateUtils.formatTime(now))
    console.log('isWeekend(пятница):', dateUtils.isWeekend(now))
    
    // Default export — один главный класс/функция
    // В реальном ESM: export default class EventBus { ... }
    class EventBus {
      constructor() { this.listeners = new Map() }
      on(event, fn)  { /* ... */ }
      emit(event, data) { /* ... */ }
    }
    // import EventBus from './event-bus.js'
    // import Bus from './event-bus.js'  — любое имя для default
    
    // ===== ДИНАМИЧЕСКИЙ IMPORT (ЭМУЛЯЦИЯ) =====
    console.log('\n=== Динамический import() — ленивая загрузка ===')
    
    // Реальный динамический import():
    // const module = await import('./heavy-module.js')
    // Эмулируем через Promise для демонстрации
    
    function mockDynamicImport(moduleName) {
      return new Promise((resolve) => {
        setTimeout(() => {
          // Имитируем загрузку чанка
          const modules = {
            'chart': {
              default: { render: (el) => `Chart rendered in ${el}` }
            },
            'pdf-generator': {
              generatePDF: (data) => `PDF generated with ${JSON.stringify(data)}`
            }
          }
          resolve(modules[moduleName] || {})
        }, 10)
      })
    }
    
    // Code splitting: загружаем только когда нужно
    async function showChart(elementId) {
      console.log('Загружаем чарт-библиотеку...')
      const { default: chart } = await mockDynamicImport('chart')
      console.log(chart.render(elementId))
    }
    
    async function exportToPDF(data) {
      console.log('Загружаем PDF генератор...')
      const { generatePDF } = await mockDynamicImport('pdf-generator')
      console.log(generatePDF(data))
    }
    
    showChart('#dashboard-chart')
    exportToPDF({ title: 'Отчёт', rows: 150 })
    
    // ===== BARREL FILE PATTERN =====
    console.log('\n=== Barrel file паттерн ===')
    
    // components/index.js (barrel):
    // export { Button } from './Button.js'
    // export { Input }  from './Input.js'
    // export { Modal }  from './Modal.js'
    
    // Использование:
    // import { Button, Modal } from './components'
    // Вместо:
    // import { Button } from './components/Button'
    // import { Modal }  from './components/Modal'
    
    // Эмуляция для демонстрации
    const Button = { render: () => '<button>' }
    const Input  = { render: () => '<input>' }
    const Modal  = { render: () => '<div class=modal>' }
    
    const components = { Button, Input, Modal }
    const { Button: Btn, Modal: Mdl } = components
    console.log('Button:', Btn.render())
    console.log('Modal:', Mdl.render())
    
    // ===== CJS vs ESM: TREE SHAKING =====
    console.log('\n=== Tree Shaking: почему ESM лучше ===')
    
    const bigLibrary = {
      // Только formatDate используется в коде
      formatDate: (d) => new Date(d).toLocaleDateString('ru-RU'),
    
      // Эти функции НИКОГДА не используются в приложении:
      generateReport: () => { /* 50KB кода */ },
      exportToExcel:  () => { /* 100KB кода */ },
      renderPDF:      () => { /* 200KB кода */ },
    }
    
    // CJS: const { formatDate } = require('big-library')
    // Бандлер включает ВСЁ в бандл (не знает что нужно в рантайме)
    
    // ESM: import { formatDate } from 'big-library'
    // Бандлер ЗНАЕТ что нужно только formatDate → 350KB не войдут в бандл!
    
    console.log('Tree shaking исключит неиспользуемый код из бандла')
    console.log('Это возможно ТОЛЬКО с ESM (статический анализ импортов)')