Замена произвольных глобальных переменных

Во многих проектах требуется изменять значения глобальных идентификаторов ещё на этапе сборки. Наиболее распространённые сценарии:

  • переключение между режимами разработки и продакшена;
  • подстановка переменных окружения;
  • отключение отладочного кода;
  • выбор платформозависимой логики;
  • внедрение констант, известных заранее.

В Esbuild для подобных задач используется параметр define. Он позволяет заменять указанные идентификаторы на произвольные выражения во время компиляции.

В отличие от присваивания значений в рантайме, такая замена выполняется до запуска приложения, что даёт дополнительные возможности для оптимизации и удаления неиспользуемого кода.


Параметр define

Базовый пример:

await esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  outfile: 'dist/app.js',
  define: {
    DEBUG: 'false'
  }
});

Исходный код:

if (DEBUG) {
  console.log('Отладка включена');
}

После сборки:

if (false) {
  console.log('Отладка включена');
}

При включённой минификации Esbuild дополнительно удалит недостижимый код:

// код блока исчезнет полностью

Таким образом, механизм работает не как обычное присваивание значения переменной, а как текстовая подстановка корректного JavaScript-выражения с последующим анализом дерева программы.


Подстановка строковых значений

Особенность параметра define заключается в том, что значения передаются в виде строк, содержащих JavaScript-код.

Например:

define: {
  API_URL: '"https://api.example.com"'
}

Исходный код:

fetch(API_URL);

Результат:

fetch("https://api.example.com");

Если забыть внутренние кавычки:

define: {
  API_URL: 'https://api.example.com'
}

Esbuild воспримет содержимое как JavaScript-код и выдаст ошибку синтаксиса.

Правильный вариант всегда должен содержать строковый литерал:

define: {
  API_URL: '"https://api.example.com"'
}

Замена логических значений

Чаще всего define используется именно для логических флагов.

Конфигурация:

define: {
  IS_DEV: 'true',
  IS_PROD: 'false'
}

Код приложения:

if (IS_DEV) {
  console.log('Режим разработки');
}

if (IS_PROD) {
  enableAnalytics();
}

После сборки:

if (true) {
  console.log('Режим разработки');
}

if (false) {
  enableAnalytics();
}

После минификации:

console.log("Режим разработки");

Весь продакшен-код будет удалён.


Подстановка числовых констант

Define подходит не только для строк и булевых значений.

Конфигурация:

define: {
  VERSION_MAJOR: '2',
  VERSION_MINOR: '5'
}

Исходный код:

const version = `${VERSION_MAJOR}.${VERSION_MINOR}`;

После сборки:

const version = `${2}.${5}`;

Замена объекта process.env.NODE_ENV

Один из самых распространённых сценариев связан с переменной окружения Node.js.

Многие библиотеки проверяют:

process.env.NODE_ENV

Чтобы передать нужное значение:

define: {
  'process.env.NODE_ENV': '"production"'
}

Исходный код:

if (process.env.NODE_ENV !== 'production') {
  console.log('Отладочная информация');
}

После обработки:

if ("production" !== 'production') {
  console.log('Отладочная информация');
}

После оптимизации условие исчезнет.


Использование переменных окружения

Часто значения берутся непосредственно из окружения Node.js.

Пример:

const isProduction =
  process.env.NODE_ENV === 'production';

await esbuild.build({
  define: {
    __DEV__: String(!isProduction)
  }
});

Либо:

await esbuild.build({
  define: {
    API_URL: JSON.stringify(process.env.API_URL)
  }
});

Если переменная содержит:

https://api.company.com

то результатом станет:

define: {
  API_URL: '"https://api.company.com"'
}

Использование JSON.stringify() считается наиболее безопасным способом передачи строковых значений.


Подстановка сложных выражений

Define может содержать не только литералы.

Например:

define: {
  BUILD_DATE: 'Date.now()'
}

Код:

console.log(BUILD_DATE);

После замены:

console.log(Date.now());

Однако подобный подход используется редко. Обычно рекомендуется подставлять уже вычисленные константы.

Лучший вариант:

define: {
  BUILD_DATE: String(Date.now())
}

После сборки:

console.log(1717286400000);

Использование специальных префиксов

Для уменьшения вероятности конфликтов обычно применяются специальные имена:

define: {
  __DEV__: 'true',
  __TEST__: 'false',
  __BUILD_VERSION__: '"2.1.0"'
}

Пример:

if (__DEV__) {
  enableDebugTools();
}

Такой стиль широко распространён в экосистеме JavaScript и позволяет сразу отличать сборочные константы от обычных переменных.


Замена нескольких идентификаторов

В одном объекте define может находиться любое количество замен.

define: {
  __DEV__: 'false',
  __API_URL__: '"https://api.example.com"',
  __VERSION__: '"3.4.1"',
  __FEATURE_CHAT__: 'true',
  __FEATURE_PAYMENTS__: 'false'
}

Использование:

console.log(__VERSION__);

if (__FEATURE_CHAT__) {
  startChat();
}

if (__FEATURE_PAYMENTS__) {
  initPayments();
}

Во время сборки все идентификаторы будут заменены соответствующими значениями.


Удаление отладочного кода

Define особенно полезен совместно с tree shaking.

Исходный код:

function debugLog(message) {
  console.log(message);
}

if (__DEV__) {
  debugLog('Инициализация');
}

Конфигурация:

define: {
  __DEV__: 'false'
}

После преобразования:

if (false) {
  debugLog('Инициализация');
}

После оптимизации:

// блок удалён

Если функция больше нигде не используется, Esbuild может удалить и её.


Управление функциональностью через feature flags

Feature flags позволяют включать и отключать функциональность на этапе сборки.

Конфигурация:

define: {
  FEATURE_NEW_UI: 'true',
  FEATURE_BETA_API: 'false'
}

Код:

if (FEATURE_NEW_UI) {
  renderNewInterface();
}

if (FEATURE_BETA_API) {
  useBetaApi();
}

После минификации останется только реально используемый функционал.

Подобный подход часто применяется в крупных приложениях для формирования разных вариантов сборки.


Различные конфигурации для разных окружений

Development

await esbuild.build({
  define: {
    __DEV__: 'true',
    __API_URL__: '"http://localhost:3000"'
  }
});

Production

await esbuild.build({
  define: {
    __DEV__: 'false',
    __API_URL__: '"https://api.company.com"'
  }
});

Исходный код остаётся одинаковым:

if (__DEV__) {
  console.log('Запуск');
}

fetch(__API_URL__);

Различия появляются исключительно на этапе сборки.


Использование через CLI

Параметр доступен не только через JavaScript API.

Пример:

esbuild src/index.js \
  --bundle \
  --define:DEBUG=false \
  --outfile=dist/app.js

Для строк:

esbuild src/index.js \
  --define:API_URL=\"https://api.example.com\"

Либо:

esbuild src/index.js \
  --define:process.env.NODE_ENV=\"production\"

Использование с TypeScript

TypeScript не знает о существовании переменных, определённых через define.

Код:

if (__DEV__) {
  console.log('debug');
}

Компилятор выдаст ошибку:

Cannot find name '__DEV__'

Необходимо объявить глобальную константу.

Файл:

declare const __DEV__: boolean;

Либо:

declare const __VERSION__: string;

После этого TypeScript сможет корректно выполнять проверку типов.


Ограничения define

Заменяются только идентификаторы

Работает:

define: {
  DEBUG: 'true'
}

Не работает:

const DEBUG = false;
console.log(DEBUG);

Локальная переменная перекрывает глобальный идентификатор.


Нельзя заменять произвольные фрагменты текста

Define не является системой поиска и замены строк.

Например:

define: {
  hello: '"world"'
}

Не приведёт к изменению строки:

console.log("hello");

Заменяются только элементы синтаксического дерева JavaScript.


Следует избегать конфликтов имён

Плохой вариант:

define: {
  VERSION: '"1.0.0"'
}

Если в проекте появится переменная:

const VERSION = 'test';

возникнет неоднозначность.

Гораздо безопаснее:

define: {
  __APP_VERSION__: '"1.0.0"'
}

Отличие define от обычных переменных окружения

Переменная окружения:

process.env.API_URL

определяется во время выполнения программы.

Define:

__API_URL__

заменяется во время сборки.

Сравнение:

Характеристика process.env define
Доступна во время выполнения Да Нет
Заменяется при сборке Нет Да
Позволяет удалять код Нет Да
Участвует в tree shaking Нет Да
Подходит для feature flags Частично Да

Практический пример конфигурации

import * as esbuild from 'esbuild';

const production =
  process.env.NODE_ENV === 'production';

await esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  minify: production,
  outfile: 'dist/app.js',
  define: {
    __DEV__: String(!production),
    __VERSION__: JSON.stringify('2.4.0'),
    __API_URL__: JSON.stringify(
      production
        ? 'https://api.company.com'
        : 'http://localhost:3000'
    ),
    'process.env.NODE_ENV': JSON.stringify(
      production
        ? 'production'
        : 'development'
    )
  }
});

Использование в приложении:

console.log('Версия:', __VERSION__);

if (__DEV__) {
  console.log('Режим разработки');
}

fetch(`${__API_URL__}/users`);

В результате каждая сборка получает собственный набор констант, а Esbuild может удалить весь код, который никогда не будет выполнен в выбранном окружении.