Babel macro:`runCodeForEnvVar()` - run code conditionally on env var.
1. The need to run code conditionally based on env var
When we build a web app, we often feel the need to do something conditionally based on environment, for example:
- for local dev env : enable some hacky login
- for staging env: enable some debug menu, catch special errors
- for prod: donāt want any of above
Thus the goal is to run some code under certain condition but strip it totally otherwise.
There is already a convention to use NODE_ENV
to determine
a development build or production build, but since NODE_ENV
targets the mode node.js is running,
not exactly the product environment, we could try something like this.
js
if (process.env.TARGET === 'local') {// do something only in local}if (process.env.TARGET === 'staging') {// do something only on staging}
js
if (process.env.TARGET === 'local') {// do something only in local}if (process.env.TARGET === 'staging') {// do something only on staging}
But this doesnāt scale because it is hard to manage what are enabled under each product
env. What if we want to enable something both for local
and staging
?
A better way might be explicit feature flags.
js
if (process.env.ENABLE_DEBUG_MENU) {// enable debug}if (process.env.ENABLE_EXTRA_LOGGING) {// enable extra logging}
js
if (process.env.ENABLE_DEBUG_MENU) {// enable debug}if (process.env.ENABLE_EXTRA_LOGGING) {// enable extra logging}
Then we can just set up the flags in build command.
shell
ENABLE_DEBUG_MENU=true ENABLE_EXTRA_LOGGING=true npm run build
shell
ENABLE_DEBUG_MENU=true ENABLE_EXTRA_LOGGING=true npm run build
2. Demo with Vite
For above code to work, we need to make sure that env vars are injected. Weāll run some code with Vite to demo the approaches.
2.1 default approach with process.env
First letās just try process.env
in an if
.
console.log(process.env.VITE_ENABLE_DEBUG);if (process.env.VITE_ENABLE_DEBUG === 'true') {console.log('ENABLE_DEBUG');}
console.log(process.env.VITE_ENABLE_DEBUG);if (process.env.VITE_ENABLE_DEBUG === 'true') {console.log('ENABLE_DEBUG');}
{"scripts": {"build:staging": "VITE_ENABLE_DEBUG=true vite build",only set up the flag under
build:staging
"build:prod": "vite build"}}
{"scripts": {"build:staging": "VITE_ENABLE_DEBUG=true vite build",only set up the flag under
build:staging
"build:prod": "vite build"}}
Now letās run npm run build:staging
, here is what we get in the built assets.
js
var i={};console.log(i.VITE_ENABLE_DEBUG);i.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");
js
var i={};console.log(i.VITE_ENABLE_DEBUG);i.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");
We can see that process.env
is replaced with {}
and the value 'true'
is actually not injected.
For npm run build:prod
we actually get the same stuff.
js
var i={};console.log(i.VITE_ENABLE_DEBUG);i.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");
js
var i={};console.log(i.VITE_ENABLE_DEBUG);i.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");
The problem is that for the bundler, it actually doesnāt know what is process.env.VITE_ENABLE_DEBUG
.
2.2 Global constant replacement by esbuild defines
In order to let Vite know the value of the env var, we need to define global constant replacement. We can change the vite config into below.
import { defineConfig } from 'vite';export default defineConfig({define: {'process.env.VITE_ENABLE_DEBUG': JSON.stringify(process.env.VITE_ENABLE_DEBUG),},});
import { defineConfig } from 'vite';export default defineConfig({define: {'process.env.VITE_ENABLE_DEBUG': JSON.stringify(process.env.VITE_ENABLE_DEBUG),},});
Now Vite knows the value, here is what we get from npm run build:staging
.
js
console.log("true");console.log("ENABLE_DEBUG");
js
console.log("true");console.log("ENABLE_DEBUG");
For npm run build:prod
, we can see that the if
clause gets stripped.
console.log(void 0)
void 0
is undefined
console.log(void 0)
void 0
is undefined
So this result is what we want, it keeps the true branch and strips the false branch completely.
2.3 Global constant replacement by import.meta.env
It could be a bit verbose to manually define each env var in vite config,
vite actually exposes env vars in import.meta.env
if they are prefixed with VITE_
. Letās give it a try.
console.log(import.meta.env.VITE_ENABLE_DEBUG);if (import.meta.env.VITE_ENABLE_DEBUG === 'true') {console.log('ENABLE_DEBUG');}
console.log(import.meta.env.VITE_ENABLE_DEBUG);if (import.meta.env.VITE_ENABLE_DEBUG === 'true') {console.log('ENABLE_DEBUG');}
For npm run build:staging
, we get what we want and it is the same as previous
approach.
js
console.log("true");console.log("ENABLE_DEBUG");
js
console.log("true");console.log("ENABLE_DEBUG");
For npm run build:prod
, it is quite different.
var s={BASE_URL:"/",MODE:"production",DEV:!1,PROD:!0,SSR:!1};import.meta.env is inlined
console.log(s.VITE_ENABLE_DEBUG);s.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");
var s={BASE_URL:"/",MODE:"production",DEV:!1,PROD:!0,SSR:!1};import.meta.env is inlined
console.log(s.VITE_ENABLE_DEBUG);s.VITE_ENABLE_DEBUG==="true"&&console.log("ENABLE_DEBUG");
We can see that it injects built-in env vars and also keep the runtime code.
Though s.VITE_ENABLE_DEBUG
is undefined
so the false branch wonāt get run,
but it is not cool to have debug code sneak into production build. So this
is not cool.
Actually we can improve this by explicitly set VITE_ENABLE_DEBUG
to "false"
.
Below is what we get from VITE_ENABLE_DEBUG=false vite build
, we can see
that the false branch is stripped.
js
console.log("false");
js
console.log("false");
This is not very straightforward, Iād expect it to work without explicitly setting the flag. To truly understand why it behaves like this, weāll need to dive into the internals of Vite.
3. How Vite eliminates dead code and injects env vars.
What vite does is actually injecting the env vars by defining the replacement in the background for us. The replacement itself is provided by esbuild - a way to replace global identifiers with constant expressions.
3.1 Dead if
branch is eliminated by esbuildās minify
Built assets are optimized by esbuildās minify,
in which dead code are eliminated. In our case there is dead if
branch if we set up
the flag with values other than "true"
, if ("false" === "true"){...}
.
The elimination code of if
branches could be found
here.
It is written in go, Iāve never used it but we still could understand what is going on.
case *js_ast.SIf:This means we try to optimize every
if
clauses.Test = p.visitExpr(s.Test)if p.options.minifySyntax {s.Test = p.astHelpers.SimplifyBooleanExpr(s.Test)This helper will simplify
"false" === "true"
intofalse
Source code could be found here
}// Fold constantsboolean, _, ok := js_ast.ToBooleanWithSideEffects(s.Test.Data)// Mark the control flow as dead if the branch is never takenThe comment here says itself
if ok && !boolean {old := p.isControlFlowDeadp.isControlFlowDead = trues.Yes = p.visitSingleStmt(s.Yes, stmtsNormal)p.isControlFlowDead = old} else {s.Yes = p.visitSingleStmt(s.Yes, stmtsNormal)}...if p.options.minifySyntax {return p.mangleIf(stmts, stmt.Loc, s)}If
minify
is enabled, then the actual stripping happens inmangleIf()
case *js_ast.SIf:This means we try to optimize every
if
clauses.Test = p.visitExpr(s.Test)if p.options.minifySyntax {s.Test = p.astHelpers.SimplifyBooleanExpr(s.Test)This helper will simplify
"false" === "true"
intofalse
Source code could be found here
}// Fold constantsboolean, _, ok := js_ast.ToBooleanWithSideEffects(s.Test.Data)// Mark the control flow as dead if the branch is never takenThe comment here says itself
if ok && !boolean {old := p.isControlFlowDeadp.isControlFlowDead = trues.Yes = p.visitSingleStmt(s.Yes, stmtsNormal)p.isControlFlowDead = old} else {s.Yes = p.visitSingleStmt(s.Yes, stmtsNormal)}...if p.options.minifySyntax {return p.mangleIf(stmts, stmt.Loc, s)}If
minify
is enabled, then the actual stripping happens inmangleIf()
mangleIf()
is the actual optimization.
func (p *parser) mangleIf(stmts []js_ast.Stmt, loc logger.Loc, s *js_ast.SIf) []js_ast.Stmt {// Constant folding using the test expressionif boolean, sideEffects, ok := js_ast.ToBooleanWithSideEffects(s.Test.Data); ok {if boolean {// The test is truthyif s.NoOrNil.Data == nil || !shouldKeepStmtInDeadControlFlow(s.NoOrNil) {// We can drop the "no" branchif sideEffects == js_ast.CouldHaveSideEffects {// Keep the condition if it could have side effects (but is still known to be truthy)if test := p.astHelpers.SimplifyUnusedExpr(s.Test, p.options.unsupportedJSFeatures); test.Data != nil {stmts = append(stmts, js_ast.Stmt{Loc: s.Test.Loc, Data: &js_ast.SExpr{Value: test}})}}return appendIfOrLabelBodyPreservingScope(stmts, s.Yes)If the condition is true, then we keep the true branch(Yes)
} else {}} else {// The test is falsyif !shouldKeepStmtInDeadControlFlow(s.Yes) {// We can drop the "yes" branchif sideEffects == js_ast.CouldHaveSideEffects {// Keep the condition if it could have side effects (but is still known to be falsy)if test := p.astHelpers.SimplifyUnusedExpr(s.Test, p.options.unsupportedJSFeatures); test.Data != nil {stmts = append(stmts, js_ast.Stmt{Loc: s.Test.Loc, Data: &js_ast.SExpr{Value: test}})}}if s.NoOrNil.Data == nil {return stmts}return appendIfOrLabelBodyPreservingScope(stmts, s.NoOrNil)If the condition is false, then we keep the false branch(NoOrNil)
} else {// We have to keep the "yes" branch}}}...}
func (p *parser) mangleIf(stmts []js_ast.Stmt, loc logger.Loc, s *js_ast.SIf) []js_ast.Stmt {// Constant folding using the test expressionif boolean, sideEffects, ok := js_ast.ToBooleanWithSideEffects(s.Test.Data); ok {if boolean {// The test is truthyif s.NoOrNil.Data == nil || !shouldKeepStmtInDeadControlFlow(s.NoOrNil) {// We can drop the "no" branchif sideEffects == js_ast.CouldHaveSideEffects {// Keep the condition if it could have side effects (but is still known to be truthy)if test := p.astHelpers.SimplifyUnusedExpr(s.Test, p.options.unsupportedJSFeatures); test.Data != nil {stmts = append(stmts, js_ast.Stmt{Loc: s.Test.Loc, Data: &js_ast.SExpr{Value: test}})}}return appendIfOrLabelBodyPreservingScope(stmts, s.Yes)If the condition is true, then we keep the true branch(Yes)
} else {}} else {// The test is falsyif !shouldKeepStmtInDeadControlFlow(s.Yes) {// We can drop the "yes" branchif sideEffects == js_ast.CouldHaveSideEffects {// Keep the condition if it could have side effects (but is still known to be falsy)if test := p.astHelpers.SimplifyUnusedExpr(s.Test, p.options.unsupportedJSFeatures); test.Data != nil {stmts = append(stmts, js_ast.Stmt{Loc: s.Test.Loc, Data: &js_ast.SExpr{Value: test}})}}if s.NoOrNil.Data == nil {return stmts}return appendIfOrLabelBodyPreservingScope(stmts, s.NoOrNil)If the condition is false, then we keep the false branch(NoOrNil)
} else {// We have to keep the "yes" branch}}}...}
Now we know how Vite(esbuild under the hood) eliminates the dead if branch.
3.2 How import.meta.env
is injected in Vite.
First of all, loadEnv()
collects all env vars from env files or commands.
export function loadEnv(mode: string,envDir: string,prefixes: string | string[] = 'VITE_',): Record<string, string> {prefixes = arraify(prefixes)const env: Record<string, string> = {}const envFiles = getEnvFilesForMode(mode)const parsed = Object.fromEntries(envFiles.flatMap((file) => {const filePath = path.join(envDir, file)if (!tryStatSync(filePath)?.isFile()) return []return Object.entries(parse(fs.readFileSync(filePath)))}),)// test NODE_ENV override before expand as otherwise process.env.NODE_ENV// would override thisif (parsed.NODE_ENV && process.env.VITE_USER_NODE_ENV === undefined) {process.env.VITE_USER_NODE_ENV = parsed.NODE_ENV}// support BROWSER and BROWSER_ARGS env variablesif (parsed.BROWSER && process.env.BROWSER === undefined) {process.env.BROWSER = parsed.BROWSER}if (parsed.BROWSER_ARGS && process.env.BROWSER_ARGS === undefined) {process.env.BROWSER_ARGS = parsed.BROWSER_ARGS}// let environment variables use each other// `expand` patched in patches/[email protected]expand({ parsed })// only keys that start with prefix are exposed to clientfor (const [key, value] of Object.entries(parsed)) {if (prefixes.some((prefix) => key.startsWith(prefix))) {env[key] = value}}Filter out the env vars starting with "VITE_" from env files
// check if there are actual env variables starting with VITE_*// these are typically provided inline and should be prioritizedfor (const key in process.env) {if (prefixes.some((prefix) => key.startsWith(prefix))) {env[key] = process.env[key] as string}}Filter out the env vars starting with "VITE_" from commands
Which are what we used in previous demos
return env}
export function loadEnv(mode: string,envDir: string,prefixes: string | string[] = 'VITE_',): Record<string, string> {prefixes = arraify(prefixes)const env: Record<string, string> = {}const envFiles = getEnvFilesForMode(mode)const parsed = Object.fromEntries(envFiles.flatMap((file) => {const filePath = path.join(envDir, file)if (!tryStatSync(filePath)?.isFile()) return []return Object.entries(parse(fs.readFileSync(filePath)))}),)// test NODE_ENV override before expand as otherwise process.env.NODE_ENV// would override thisif (parsed.NODE_ENV && process.env.VITE_USER_NODE_ENV === undefined) {process.env.VITE_USER_NODE_ENV = parsed.NODE_ENV}// support BROWSER and BROWSER_ARGS env variablesif (parsed.BROWSER && process.env.BROWSER === undefined) {process.env.BROWSER = parsed.BROWSER}if (parsed.BROWSER_ARGS && process.env.BROWSER_ARGS === undefined) {process.env.BROWSER_ARGS = parsed.BROWSER_ARGS}// let environment variables use each other// `expand` patched in patches/[email protected]expand({ parsed })// only keys that start with prefix are exposed to clientfor (const [key, value] of Object.entries(parsed)) {if (prefixes.some((prefix) => key.startsWith(prefix))) {env[key] = value}}Filter out the env vars starting with "VITE_" from env files
// check if there are actual env variables starting with VITE_*// these are typically provided inline and should be prioritizedfor (const key in process.env) {if (prefixes.some((prefix) => key.startsWith(prefix))) {env[key] = process.env[key] as string}}Filter out the env vars starting with "VITE_" from commands
Which are what we used in previous demos
return env}
Then the filtered env vars are returned as the resolved config(source).
const userEnv =inlineConfig.envFile !== false &&loadEnv(mode, envDir, resolveEnvPrefix(config))resolved = {configFile: configFile ? normalizePath(configFile) : undefined,configFileDependencies: configFileDependencies.map((name) =>normalizePath(path.resolve(name)),),inlineConfig,root: resolvedRoot,base: withTrailingSlash(resolvedBase),rawBase: resolvedBase,resolve: resolveOptions,publicDir: resolvedPublicDir,cacheDir,command,mode,ssr,isWorker: false,mainConfig: null,isProduction,plugins: userPlugins,css: resolveCSSOptions(config.css),esbuild:config.esbuild === false? false: {jsxDev: !isProduction,...config.esbuild,},server,build: resolvedBuildOptions,preview: resolvePreviewOptions(config.preview, server),envDir,env: {...userEnv,BASE_URL,MODE: mode,DEV: !isProduction,PROD: isProduction,},BASE_URL, MODE,DEV,PROD are built-in env vars, they are injected no matter what
assetsInclude(file: string) {return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file)},logger,packageCache,createResolver,optimizeDeps: {disabled: 'build',...optimizeDeps,esbuildOptions: {preserveSymlinks: resolveOptions.preserveSymlinks,...optimizeDeps.esbuildOptions,},},worker: resolvedWorkerOptions,appType: config.appType ?? 'spa',experimental: {importGlobRestoreExtension: false,hmrPartialAccept: false,...config.experimental,},getSortedPlugins: undefined!,getSortedPluginHooks: undefined!,}resolved = {...config,...resolved,}
const userEnv =inlineConfig.envFile !== false &&loadEnv(mode, envDir, resolveEnvPrefix(config))resolved = {configFile: configFile ? normalizePath(configFile) : undefined,configFileDependencies: configFileDependencies.map((name) =>normalizePath(path.resolve(name)),),inlineConfig,root: resolvedRoot,base: withTrailingSlash(resolvedBase),rawBase: resolvedBase,resolve: resolveOptions,publicDir: resolvedPublicDir,cacheDir,command,mode,ssr,isWorker: false,mainConfig: null,isProduction,plugins: userPlugins,css: resolveCSSOptions(config.css),esbuild:config.esbuild === false? false: {jsxDev: !isProduction,...config.esbuild,},server,build: resolvedBuildOptions,preview: resolvePreviewOptions(config.preview, server),envDir,env: {...userEnv,BASE_URL,MODE: mode,DEV: !isProduction,PROD: isProduction,},BASE_URL, MODE,DEV,PROD are built-in env vars, they are injected no matter what
assetsInclude(file: string) {return DEFAULT_ASSETS_RE.test(file) || assetsFilter(file)},logger,packageCache,createResolver,optimizeDeps: {disabled: 'build',...optimizeDeps,esbuildOptions: {preserveSymlinks: resolveOptions.preserveSymlinks,...optimizeDeps.esbuildOptions,},},worker: resolvedWorkerOptions,appType: config.appType ?? 'spa',experimental: {importGlobRestoreExtension: false,hmrPartialAccept: false,...config.experimental,},getSortedPlugins: undefined!,getSortedPluginHooks: undefined!,}resolved = {...config,...resolved,}
The final step is to expose these env vars through esbuild defines, and this is done by the Viteās internal define plugin.
import { transform } from 'esbuild'import type { ResolvedConfig } from '../config'import type { Plugin } from '../plugin'import { escapeRegex, getHash } from '../utils'import { isCSSRequest } from './css'import { isHTMLRequest } from './html'const nonJsRe = /\.json(?:$|\?)/const isNonJsRequest = (request: string): boolean => nonJsRe.test(request)export function definePlugin(config: ResolvedConfig): Plugin {const isBuild = config.command === 'build'const isBuildLib = isBuild && config.build.lib// ignore replace process.env in lib buildconst processEnv: Record<string, string> = {}if (!isBuildLib) {const nodeEnv = process.env.NODE_ENV || config.modeObject.assign(processEnv, {'process.env': `{}`,'global.process.env': `{}`,'globalThis.process.env': `{}`,'process.env.NODE_ENV': JSON.stringify(nodeEnv),'global.process.env.NODE_ENV': JSON.stringify(nodeEnv),'globalThis.process.env.NODE_ENV': JSON.stringify(nodeEnv),})}// during dev, import.meta properties are handled by importAnalysis plugin.const importMetaKeys: Record<string, string> = {}const importMetaEnvKeys: Record<string, string> = {}const importMetaFallbackKeys: Record<string, string> = {}if (isBuild) {importMetaKeys['undefined'] = `undefined`for (const key in config.env) {const val = JSON.stringify(config.env[key])importMetaKeys[`import.meta.env.${key}`] = valimportMetaEnvKeys[key] = val}Notice that only the explicit defined env vars are collected
// these will be set to a proper value in `generatePattern`importMetaKeys['import.meta.env.SSR'] = `undefined`importMetaFallbackKeys['import.meta.env'] = `undefined`}const userDefine: Record<string, string> = {}const userDefineEnv: Record<string, any> = {}for (const key in config.define) {This means the manual defines like what we did in 2.2
userDefine[key] = handleDefineValue(config.define[key])// make sure `import.meta.env` object has user define propertiesif (isBuild && key.startsWith('import.meta.env.')) {userDefineEnv[key.slice(16)] = config.define[key]}}function generatePattern(ssr: boolean) {const replaceProcessEnv = !ssr || config.ssr?.target === 'webworker'const define: Record<string, string> = {...(replaceProcessEnv ? processEnv : {}),...importMetaKeys,...userDefine,...importMetaFallbackKeys,}This is the final define entries
// Additional define fixes based on `ssr` valueif ('import.meta.env.SSR' in define) {define['import.meta.env.SSR'] = ssr + ''}if ('import.meta.env' in define) {define['import.meta.env'] = serializeDefine({...importMetaEnvKeys,SSR: ssr + '',...userDefineEnv,})}// Create regex pattern as a fast check before running esbuildconst patternKeys = Object.keys(userDefine)if (replaceProcessEnv && Object.keys(processEnv).length) {patternKeys.push('process.env')}if (Object.keys(importMetaKeys).length) {patternKeys.push('import.meta.env', 'undefined')}const pattern = patternKeys.length? new RegExp(patternKeys.map(escapeRegex).join('|')): nullreturn [define, pattern] as const}const defaultPattern = generatePattern(false)const ssrPattern = generatePattern(true)return {name: 'vite:define',async transform(code, id, options) {const ssr = options?.ssr === trueif (!ssr && !isBuild) {// for dev we inject actual global defines in the vite client to// avoid the transform cost. see the `clientInjection` and// `importAnalysis` plugin.return}if (// exclude html, css and static assets for performanceisHTMLRequest(id) ||isCSSRequest(id) ||isNonJsRequest(id) ||config.assetsInclude(id)) {return}const [define, pattern] = ssr ? ssrPattern : defaultPatternif (!pattern) return// Check if our code needs any replacements before running esbuildpattern.lastIndex = 0if (!pattern.test(code)) returnreturn await replaceDefine(code, id, define, config)},}}export async function replaceDefine(code: string,id: string,define: Record<string, string>,config: ResolvedConfig,): Promise<{ code: string; map: string | null }> {// Because esbuild only allows JSON-serializable values, and `import.meta.env`// may contain values with raw identifiers, making it non-JSON-serializable,// we replace it with a temporary marker and then replace it back after to// workaround it. This means that esbuild is unable to optimize the `import.meta.env`// access, but that's a tradeoff for now.const replacementMarkers: Record<string, string> = {}const env = define['import.meta.env']if (env && !canJsonParse(env)) {const marker = `_${getHash(env, env.length - 2)}_`replacementMarkers[marker] = envdefine = { ...define, 'import.meta.env': marker }}const esbuildOptions = config.esbuild || {}const result = await transform(code, {loader: 'js',charset: esbuildOptions.charset ?? 'utf8',platform: 'neutral',define,The define entries are used in esbuild transform
sourcefile: id,sourcemap: config.command === 'build' ? !!config.build.sourcemap : true,})for (const marker in replacementMarkers) {result.code = result.code.replaceAll(marker, replacementMarkers[marker])}return {code: result.code,-----------------After code transform,
import.meta.env.XXX
is replaced with actual values/expressionsmap: result.map || null,}}
import { transform } from 'esbuild'import type { ResolvedConfig } from '../config'import type { Plugin } from '../plugin'import { escapeRegex, getHash } from '../utils'import { isCSSRequest } from './css'import { isHTMLRequest } from './html'const nonJsRe = /\.json(?:$|\?)/const isNonJsRequest = (request: string): boolean => nonJsRe.test(request)export function definePlugin(config: ResolvedConfig): Plugin {const isBuild = config.command === 'build'const isBuildLib = isBuild && config.build.lib// ignore replace process.env in lib buildconst processEnv: Record<string, string> = {}if (!isBuildLib) {const nodeEnv = process.env.NODE_ENV || config.modeObject.assign(processEnv, {'process.env': `{}`,'global.process.env': `{}`,'globalThis.process.env': `{}`,'process.env.NODE_ENV': JSON.stringify(nodeEnv),'global.process.env.NODE_ENV': JSON.stringify(nodeEnv),'globalThis.process.env.NODE_ENV': JSON.stringify(nodeEnv),})}// during dev, import.meta properties are handled by importAnalysis plugin.const importMetaKeys: Record<string, string> = {}const importMetaEnvKeys: Record<string, string> = {}const importMetaFallbackKeys: Record<string, string> = {}if (isBuild) {importMetaKeys['undefined'] = `undefined`for (const key in config.env) {const val = JSON.stringify(config.env[key])importMetaKeys[`import.meta.env.${key}`] = valimportMetaEnvKeys[key] = val}Notice that only the explicit defined env vars are collected
// these will be set to a proper value in `generatePattern`importMetaKeys['import.meta.env.SSR'] = `undefined`importMetaFallbackKeys['import.meta.env'] = `undefined`}const userDefine: Record<string, string> = {}const userDefineEnv: Record<string, any> = {}for (const key in config.define) {This means the manual defines like what we did in 2.2
userDefine[key] = handleDefineValue(config.define[key])// make sure `import.meta.env` object has user define propertiesif (isBuild && key.startsWith('import.meta.env.')) {userDefineEnv[key.slice(16)] = config.define[key]}}function generatePattern(ssr: boolean) {const replaceProcessEnv = !ssr || config.ssr?.target === 'webworker'const define: Record<string, string> = {...(replaceProcessEnv ? processEnv : {}),...importMetaKeys,...userDefine,...importMetaFallbackKeys,}This is the final define entries
// Additional define fixes based on `ssr` valueif ('import.meta.env.SSR' in define) {define['import.meta.env.SSR'] = ssr + ''}if ('import.meta.env' in define) {define['import.meta.env'] = serializeDefine({...importMetaEnvKeys,SSR: ssr + '',...userDefineEnv,})}// Create regex pattern as a fast check before running esbuildconst patternKeys = Object.keys(userDefine)if (replaceProcessEnv && Object.keys(processEnv).length) {patternKeys.push('process.env')}if (Object.keys(importMetaKeys).length) {patternKeys.push('import.meta.env', 'undefined')}const pattern = patternKeys.length? new RegExp(patternKeys.map(escapeRegex).join('|')): nullreturn [define, pattern] as const}const defaultPattern = generatePattern(false)const ssrPattern = generatePattern(true)return {name: 'vite:define',async transform(code, id, options) {const ssr = options?.ssr === trueif (!ssr && !isBuild) {// for dev we inject actual global defines in the vite client to// avoid the transform cost. see the `clientInjection` and// `importAnalysis` plugin.return}if (// exclude html, css and static assets for performanceisHTMLRequest(id) ||isCSSRequest(id) ||isNonJsRequest(id) ||config.assetsInclude(id)) {return}const [define, pattern] = ssr ? ssrPattern : defaultPatternif (!pattern) return// Check if our code needs any replacements before running esbuildpattern.lastIndex = 0if (!pattern.test(code)) returnreturn await replaceDefine(code, id, define, config)},}}export async function replaceDefine(code: string,id: string,define: Record<string, string>,config: ResolvedConfig,): Promise<{ code: string; map: string | null }> {// Because esbuild only allows JSON-serializable values, and `import.meta.env`// may contain values with raw identifiers, making it non-JSON-serializable,// we replace it with a temporary marker and then replace it back after to// workaround it. This means that esbuild is unable to optimize the `import.meta.env`// access, but that's a tradeoff for now.const replacementMarkers: Record<string, string> = {}const env = define['import.meta.env']if (env && !canJsonParse(env)) {const marker = `_${getHash(env, env.length - 2)}_`replacementMarkers[marker] = envdefine = { ...define, 'import.meta.env': marker }}const esbuildOptions = config.esbuild || {}const result = await transform(code, {loader: 'js',charset: esbuildOptions.charset ?? 'utf8',platform: 'neutral',define,The define entries are used in esbuild transform
sourcefile: id,sourcemap: config.command === 'build' ? !!config.build.sourcemap : true,})for (const marker in replacementMarkers) {result.code = result.code.replaceAll(marker, replacementMarkers[marker])}return {code: result.code,-----------------After code transform,
import.meta.env.XXX
is replaced with actual values/expressionsmap: result.map || null,}}
Now we know why the dead code is still kept in prod build when the env var is not set -
because only explicit defined env vars are injected through esbuild defines. And also
this explains why we can be sure to get what we want by VITE_ENABLE_DEBUG=false vite build
.
But still it doesnāt feel straightforward, because the dead code actually wonāt cause runtime issues, people might just forget setting the env vars and think everything is fine but actually it is not.
4. A possibly better approach with babel macro
With all above explanations, what I want to demonstrate is that above approaches heavily rely on bundler settings and it is quite a lot to understand what really is going on and get it right. What if we can eliminate the code even before bundler kicks in? Then we could no longer worry about the bundler internals!
Ok here comes the true hero of this post - babel macro. We actually have another approach - eliminate dead code earlier by AST manipulating.
Since vite 4, both swc and babel are supported for React project. You should choose what suits you, in this post Iām introducing the power of macros so will use the babel plugin to demonstrate.
The first step is to enable macros in babel config.
js
import { defineConfig } from 'vite';import react from '@vitejs/plugin-react';export default defineConfig({plugins: [react({babel: {plugins: ['macros'],},}),],});
js
import { defineConfig } from 'vite';import react from '@vitejs/plugin-react';export default defineConfig({plugins: [react({babel: {plugins: ['macros'],},}),],});
Second step is to use the actual macro, Iāve published the macro to npm - jser.macro.
js
import runCodeForEnvVar from 'jser.macro/dist/runCodeForEnvVar.macro.cjs';console.log('runCodeForEnvVar()');runCodeForEnvVar('VITE_ENABLE_DEBUG', () => {console.log('ENABLE_DEBUG');});
js
import runCodeForEnvVar from 'jser.macro/dist/runCodeForEnvVar.macro.cjs';console.log('runCodeForEnvVar()');runCodeForEnvVar('VITE_ENABLE_DEBUG', () => {console.log('ENABLE_DEBUG');});
It might look a bit more complex, but actually it is straightforward because
we have the freedom to change the function name, we can make it explicit with
runCodeForEnvVarOrStripIt()
.
What it does is the same as the function name - run the code if process.env.VITE_ENABLE_DEBUG
is "true"
or just strip the code otherwise.
Now if we npm run build:staging
, we see the if
branch getting bundled.
js
console.log("runCodeForEnvVar()");console.log("ENABLE_DEBUG");
js
console.log("runCodeForEnvVar()");console.log("ENABLE_DEBUG");
Well it actually should be console.log("runCodeForEnvVar()");(() => {console.log("ENABLE_DEBUG");})();
but vite does further optimization and removes the call expression.
For npm run build:prod
, we see the dead branch getting eliminated even if
we donāt set the env var explicitly.
js
console.log("runCodeForEnvVar()");
js
console.log("runCodeForEnvVar()");
Fantastic, right? We no longer need to worry about bundler setting since the code is stripped at a very early stage before bundlers handle it.
You can find the code of the macro on github, here let me just briefly explain it.
import { MacroParams, createMacro, MacroError } from "babel-plugin-macros";import * as t from "@babel/types";function macro({ references }: MacroParams) {const { default: runCodeForEnvVar } = references;runCodeForEnvVar.forEach((path) => {const call = path.parent;if (t.isCallExpression(call)) {runCodeForEnvVar() must be used as a function call
const [firstArg, secondArg] = call.arguments;if (!t.isStringLiteral(firstArg)) {throw new MacroError("1st argument of runCodeForEnvVar() must be a string literal");}if (!(t.isArrowFunctionExpression(secondArg) ||t.isFunctionExpression(secondArg))) {throw new MacroError("2nd argument of runCodeForEnvVar() must be a function expression.");}Validations of the arguments
// now check if the env var matchesconst isValid = process.env[firstArg.value] === "true";if (isValid) {path.parentPath?.replaceWith(t.callExpression(secondArg, []));} else {path.parentPath?.remove();}This is a bit similar to the esbuild minify logic
If truthy, keep the callback, strip everything otherwise
} else {throw new MacroError("runCodeForEnvVar should be called as runCodeForEnvVar(envVarName, function)");}});}export default createMacro(macro);
import { MacroParams, createMacro, MacroError } from "babel-plugin-macros";import * as t from "@babel/types";function macro({ references }: MacroParams) {const { default: runCodeForEnvVar } = references;runCodeForEnvVar.forEach((path) => {const call = path.parent;if (t.isCallExpression(call)) {runCodeForEnvVar() must be used as a function call
const [firstArg, secondArg] = call.arguments;if (!t.isStringLiteral(firstArg)) {throw new MacroError("1st argument of runCodeForEnvVar() must be a string literal");}if (!(t.isArrowFunctionExpression(secondArg) ||t.isFunctionExpression(secondArg))) {throw new MacroError("2nd argument of runCodeForEnvVar() must be a function expression.");}Validations of the arguments
// now check if the env var matchesconst isValid = process.env[firstArg.value] === "true";if (isValid) {path.parentPath?.replaceWith(t.callExpression(secondArg, []));} else {path.parentPath?.remove();}This is a bit similar to the esbuild minify logic
If truthy, keep the callback, strip everything otherwise
} else {throw new MacroError("runCodeForEnvVar should be called as runCodeForEnvVar(envVarName, function)");}});}export default createMacro(macro);
5. Summary
Phew, this is a lot but quite interesting journey, right?
Weāve not only learned how a babel macro helps with our need of conditionally logic based on environment, but also touched a bit on vite/esbuildās internals.
This idea of manipulating AST tree on the fly is quite powerful, it could help simplify our code or build pipeline. Search keywords:babel-plugin-macros on npm to get some inspiration!