Vite是如何通过插件机制读取用户的环境变量Env文件

437 阅读6分钟

本文参加了由公众号@若川视野 发起的每周源码共读活动,       点击了解详情一起参与。

这是源码共读的第40期 | Vite 是如何解析用户配置的 .env 的

前言&咸鱼想法

间隔上一篇源码学习文章已经过去了三个月,历经了无底洞般加班的三个月,每天不停的搬砖。。改不合理的需求。。搬砖。。秃头了不少

学习目标

本篇笔记将:

  1. 学习cac的使用
  2. loadEnv函数学习
  3. Vite 读取Env变量流程

cac学习&准备

www.npmjs.com/package/cac

一个用于命令行的CLI包,特点是上手容易,且提示友好

基本使用

  1. option 用于定义可执行的选项
  2. command 用于定义可交互的命令
  3. action 接在option和command之后,执行对应的命令选项的动作
  4. help 提供帮助选项提示
  5. version 定义命令行版本

在命令中使用括号时,尖括号表示必需的命令参数,而方括号表示可选参数。

在选项中使用括号时,尖括号表示需要字符串/数字值,而方括号表示该值也可以为true

例子1 多参数命令

import { cac } from 'cac'
const cli = cac('Harexs')
cli.command('lint [...files]', 'Lint files').action((files, options) => {
    console.log(files, options)
})
cli.help() //提供帮助命令
cli.version('0.1.1') //定义版本号

const parsed = cli.parse() 

image.png

例子2 多选项命令

import { cac } from 'cac'
const cli = cac('Harexs')
cli.command('rm [dir]', 'Remove a dir')
    .option('-r, --recursive', 'Remove recursively')
    .option('--harexs-cli', 'Remove recursively')
    .action((dir, options) => {
        console.log(options)
        console.log('remove ' + dir + (options.recursive ? ' recursively' : ''))
    })
 cli.help() //提供帮助命令
cli.version('0.1.1') //定义版本号

const parsed = cli.parse() 

image.png

完整Demo

import { cac } from 'cac'

//初始化执行 用于定义整个函数的执行
const cli = cac('Harexs')

/**
    在命令中使用括号时,尖括号表示必需的命令参数,而方括号表示可选参数。
    在选项中使用括号时,尖括号表示需要字符串/数字值,而方括号表示该值也可以为true
*/

// //用于定义会被执行的--命令, 它会被赋值到 options参数中,--type的key就是type命名
cli.option('--type [type]', 'Choose a project type')
cli.option('--name <name>', 'Provide your name')

// action定义在对应命令被执行后做的事情  files是对应 lint 命令的 argvs, ooptions是其他参数
cli.command('lint [...files]', 'Lint files').action((files, options) => {
    console.log(files, options)
})

// //给命令 附加选项  命令执行时候只会抓取相关的选项 以及第一个 argvs
cli.command('rm [dir]', 'Remove a dir')
    .option('-r, --recursive', 'Remove recursively')
    .option('--harexs-cli', 'Remove recursively')
    .action((dir, options) => {
        console.log(options)
        console.log('remove ' + dir + (options.recursive ? ' recursively' : ''))
    })
// //展开语法 读取多个值
cli.command('build <entry> [...otherFile]', 'build your app')
    .option('--foo', 'foo options')
    .action((entry, otherFiles, options) => {
        console.log(entry, otherFiles, options)
    })
// //展开语法 默认 合并值操作
cli.command('[...files]', 'build files')
    .option('--mini', 'mini option')
    .action((files, option) => {
        console.log(files, option)
    })

//Vite 模板
cli
    .command('[root]', 'start dev server') // default command
    .alias('serve') // the command is called 'serve' in Vite's API
    .alias('dev') // alias to align with the script name
    .option('--host [host]', `[string] specify hostname`)
    .option('--port <port>', `[number] specify port`)
    .option('--https', `[boolean] use TLS + HTTP/2`)
    .option('--open [path]', `[boolean | string] open browser on startup`)
    .option('--cors', `[boolean] enable CORS`)
    .option('--strictPort', `[boolean] exit if specified port is already in use`)
    .option(
        '--force',
        `[boolean] force the optimizer to ignore the cache and re-bundle`,
    ).action((root, option) => {
        console.log(root, option)
    })


cli.help()
cli.version('0.1.1')

const parsed = cli.parse()

// console.log(JSON.stringify(parsed, null, 2)) 查看完整解析后的JSON对象

源码&流程

package.json

github1s.com/vitejs/vite…

命令执行入口

"bin": {
    "vite": "bin/vite.js"
}
if (profileIndex > 0) {
  process.argv.splice(profileIndex, 1)
  const next = process.argv[profileIndex]
  if (next && !next.startsWith('-')) {
    process.argv.splice(profileIndex, 1)
  }
  const inspector = await import('node:inspector').then((r) => r.default)
  const session = (global.__vite_profile_session = new inspector.Session())
  session.connect()
  session.post('Profiler.enable', () => {
    session.post('Profiler.start', start)
  })
} else {
  start()
}
//核心执行
function start() {
  return import('../dist/node/cli.js')
}

通过bin的文件指向,以及函数调用, 确定最终会去执行 node/cli这个文件,但我们源代码是没有这个文件夹的,它是在打包后才会产生的输出目录, 接下来我们要去 rollup中找到相关的配置

//rollup.config.ts
function createNodeConfig(isProduction: boolean) {
  return defineConfig({
    ...sharedNodeOptions,
    input: {
      index: path.resolve(__dirname, 'src/node/index.ts'),
      cli: path.resolve(__dirname, 'src/node/cli.ts'),
      constants: path.resolve(__dirname, 'src/node/constants.ts'),
    },
    output: {
      ...sharedNodeOptions.output,
      sourcemap: !isProduction,
    },
    external: [
      'fsevents',
      ...Object.keys(pkg.dependencies),
      ...(isProduction ? [] : Object.keys(pkg.devDependencies)),
    ],
    plugins: createNodePlugins(
      isProduction,
      !isProduction,
      // in production we use api-extractor for dts generation
      // in development we need to rely on the rollup ts plugin
      isProduction ? false : './dist/node',
    ),
  })
}

node/cli.ts

在这个文件中,你会发现就是cac这个包的使用和对应的函数执行

// dev
cli
  .command('[root]', 'start dev server') // default command
  .alias('serve') // the command is called 'serve' in Vite's API
  .alias('dev') // alias to align with the script name
  .option('--host [host]', `[string] specify hostname`)
  .option('--port <port>', `[number] specify port`)
  .option('--https', `[boolean] use TLS + HTTP/2`)
  .option('--open [path]', `[boolean | string] open browser on startup`)
  .option('--cors', `[boolean] enable CORS`)
  .option('--strictPort', `[boolean] exit if specified port is already in use`)
  .option(
    '--force',
    `[boolean] force the optimizer to ignore the cache and re-bundle`,
  )
  .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
    filterDuplicateOptions(options)
    // output structure is preserved even after bundling so require()
    // is ok here
    const { createServer } = await import('./server')
    try {
      const server = await createServer({
        root,
        base: options.base,
        mode: options.mode,
        configFile: options.config,
        logLevel: options.logLevel,
        clearScreen: options.clearScreen,
        optimizeDeps: { force: options.force },
        server: cleanOptions(options),
      })

      if (!server.httpServer) {
        throw new Error('HTTP server not available')
      }

      await server.listen()

      const info = server.config.logger.info

      const viteStartTime = global.__vite_start_time ?? false
      const startupDurationString = viteStartTime
        ? colors.dim(
            `ready in ${colors.reset(
              colors.bold(Math.ceil(performance.now() - viteStartTime)),
            )} ms`,
          )
        : ''

      info(
        `\n  ${colors.green(
          `${colors.bold('VITE')} v${VERSION}`,
        )}  ${startupDurationString}\n`,
        { clear: !server.config.logger.hasWarned },
      )

      server.printUrls()
      bindShortcuts(server, {
        print: true,
        customShortcuts: [
          profileSession && {
            key: 'p',
            description: 'start/stop the profiler',
            async action(server) {
              if (profileSession) {
                await stopProfiler(server.config.logger.info)
              } else {
                const inspector = await import('node:inspector').then(
                  (r) => r.default,
                )
                await new Promise<void>((res) => {
                  profileSession = new inspector.Session()
                  profileSession.connect()
                  profileSession.post('Profiler.enable', () => {
                    profileSession!.post('Profiler.start', () => {
                      server.config.logger.info('Profiler started')
                      res()
                    })
                  })
                })
              }
            },
          },
        ],
      })
    } catch (e) {
      const logger = createLogger(options.logLevel)
      logger.error(colors.red(`error when starting dev server:\n${e.stack}`), {
        error: e,
      })
      stopProfiler(logger.info)
      process.exit(1)
    }
  })

// build
cli
  .command('build [root]', 'build for production')
  .option('--target <target>', `[string] transpile target (default: 'modules')`)
  .option('--outDir <dir>', `[string] output directory (default: dist)`)
  .option(
    '--assetsDir <dir>',
    `[string] directory under outDir to place assets in (default: assets)`,
  )
  .option(
    '--assetsInlineLimit <number>',
    `[number] static asset base64 inline threshold in bytes (default: 4096)`,
  )
  .option(
    '--ssr [entry]',
    `[string] build specified entry for server-side rendering`,
  )
  .option(
    '--sourcemap [output]',
    `[boolean | "inline" | "hidden"] output source maps for build (default: false)`,
  )
  .option(
    '--minify [minifier]',
    `[boolean | "terser" | "esbuild"] enable/disable minification, ` +
      `or specify minifier to use (default: esbuild)`,
  )
  .option('--manifest [name]', `[boolean | string] emit build manifest json`)
  .option('--ssrManifest [name]', `[boolean | string] emit ssr manifest json`)
  .option(
    '--force',
    `[boolean] force the optimizer to ignore the cache and re-bundle (experimental)`,
  )
  .option(
    '--emptyOutDir',
    `[boolean] force empty outDir when it's outside of root`,
  )
  .option('-w, --watch', `[boolean] rebuilds when modules have changed on disk`)
  .action(async (root: string, options: BuildOptions & GlobalCLIOptions) => {
    filterDuplicateOptions(options)
    const { build } = await import('./build')
    const buildOptions: BuildOptions = cleanOptions(options)

    try {
      await build({
        root,
        base: options.base,
        mode: options.mode,
        configFile: options.config,
        logLevel: options.logLevel,
        clearScreen: options.clearScreen,
        optimizeDeps: { force: options.force },
        build: buildOptions,
      })
    } catch (e) {
      createLogger(options.logLevel).error(
        colors.red(`error during build:\n${e.stack}`),
        { error: e },
      )
      process.exit(1)
    } finally {
      stopProfiler((message) => createLogger(options.logLevel).info(message))
    }
  })

这两部分对应的就是 vite dev 和build的执行,接着我们去看 createServer函数的执行逻辑 github1s.com/vitejs/vite…

export async function _createServer(
  inlineConfig: InlineConfig = {},
  options: { ws: boolean },
): Promise<ViteDevServer> {
  const config = await resolveConfig(inlineConfig, 'serve')

resolveConfig函数内部会对 env变量进行读取解析部分 github1s.com/vitejs/vite…

  // load .env files
  const envDir = config.envDir
    ? normalizePath(path.resolve(resolvedRoot, config.envDir))
    : resolvedRoot
  const userEnv =
    inlineConfig.envFile !== false &&
    loadEnv(mode, envDir, resolveEnvPrefix(config))

loadEnv

loadEnv(环境变量,要解析的环境变量文件目录,自定义前缀)

github1s.com/vitejs/vite…

Object.fromEntries / Object.entries

将将可迭代的键值对列表转换为一个对象 / 将对象中的可枚举属性转换为数组键值对

const entries = new Map([
  ['foo', 'bar'],
  ['baz', 42]
]);
const obj = Object.fromEntries(entries); //{ foo: "bar", baz: 42 }
const object1 = {
  a: 'somestring',
  b: 42
};
console.log(Object.entries(object1))

for (const [key, value] of Object.entries(object1)) {
  console.log(`${key}: ${value}`);
}

loadEnv函数 会读取本地的环境变量文件, 通过 dotenv 提供的 parse解析内容

const env: Record<string, string> = {}
  const envFiles = [
    /** default file */ `.env`,
    /** local file */ `.env.local`,
    /** mode file */ `.env.${mode}`,
    /** mode local file */ `.env.${mode}.local`,
  ]

  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)))
    }),
  )

 expand({ parsed })

  // only keys that start with prefix are exposed to client
  for (const [key, value] of Object.entries(parsed)) {
    if (prefixes.some((prefix) => key.startsWith(prefix))) {
      env[key] = value
    }
  }
//将环境变量中 存在的指定前缀的变量也读取出来
  // check if there are actual env variables starting with VITE_*
  // these are typically provided inline and should be prioritized
  for (const key in process.env) {
    if (prefixes.some((prefix) => key.startsWith(prefix))) {
      env[key] = process.env[key] as string
    }
  }

  return env //返回解析后的对象

resolvedConfig

github1s.com/vitejs/vite…

 const resolvedConfig: ResolvedConfig = {
    configFile: configFile ? normalizePath(configFile) : undefined,
    configFileDependencies: configFileDependencies.map((name) =>
      normalizePath(path.resolve(name)),
    ),
    inlineConfig,
    root: resolvedRoot,
    base: resolvedBase.endsWith('/') ? resolvedBase : resolvedBase + '/',
    rawBase: resolvedBase,
    resolve: resolveOptions,
    publicDir: resolvedPublicDir,
    cacheDir,
    command,
    mode,
    ssr,
    isWorker: false,
    mainConfig: null,
    isProduction,
    plugins: userPlugins,
    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,
    },
    //...最终在resolvedConfig中 env属性存储并返回
    }

resolved / resolvePlugins

核心函数调用, resolvePlugins plugins内部会把我们的env对象注入到process.env中

 const resolved: ResolvedConfig = {
    ...config,
    ...resolvedConfig,
  }

  ;(resolved.plugins as Plugin[]) = await resolvePlugins(
    resolved,
    prePlugins,
    normalPlugins,
    postPlugins,
  )

resolvePlugins

return [
    ...(isDepsOptimizerEnabled(config, false) ||
    isDepsOptimizerEnabled(config, true)
      ? [
          isBuild
            ? optimizedDepsBuildPlugin(config)
            : optimizedDepsPlugin(config),
        ]
      : []),
    isWatch ? ensureWatchPlugin() : null,
    isBuild ? metadataPlugin() : null,
    watchPackageDataPlugin(config.packageCache),
    preAliasPlugin(config),
    aliasPlugin({ entries: config.resolve.alias }),
    ...prePlugins,
    modulePreload === true ||
    (typeof modulePreload === 'object' && modulePreload.polyfill)
      ? modulePreloadPolyfillPlugin(config)
      : null,
    resolvePlugin({
      ...config.resolve,
      root: config.root,
      isProduction: config.isProduction,
      isBuild,
      packageCache: config.packageCache,
      ssrConfig: config.ssr,
      asSrc: true,
      getDepsOptimizer: (ssr: boolean) => getDepsOptimizer(config, ssr),
      shouldExternalize:
        isBuild && config.build.ssr && config.ssr?.format !== 'cjs'
          ? (id) => shouldExternalizeForSSR(id, config)
          : undefined,
    }),
    htmlInlineProxyPlugin(config),
    cssPlugin(config),
    config.esbuild !== false ? esbuildPlugin(config) : null,
    jsonPlugin(
      {
        namedExports: true,
        ...config.json,
      },
      isBuild,
    ),
    wasmHelperPlugin(config),
    webWorkerPlugin(config),
    assetPlugin(config),
    ...normalPlugins,
    wasmFallbackPlugin(),
    definePlugin(config),
    cssPostPlugin(config),
    isBuild && buildHtmlPlugin(config),
    workerImportMetaUrlPlugin(config),
    assetImportMetaUrlPlugin(config),
    ...buildPlugins.pre,
    dynamicImportVarsPlugin(config),
    importGlobPlugin(config),
    ...postPlugins,
    ...buildPlugins.post,
    // internal server-only plugins are always applied after everything else
    ...(isBuild
      ? []
      : [clientInjectionsPlugin(config), importAnalysisPlugin(config)]),
  ].filter(Boolean) as Plugin[]

definePlugin(config) 会在 Build模式下 进行环境变量注入

github1s.com/vitejs/vite…

if (isBuild) {
    // set here to allow override with config.define
    importMetaKeys['import.meta.hot'] = `undefined`
    for (const key in config.env) {
      importMetaKeys[`import.meta.env.${key}`] = JSON.stringify(config.env[key])
    }
    Object.assign(importMetaFallbackKeys, {
      'import.meta.env.': `({}).`,
      'import.meta.env': JSON.stringify({
        ...config.env,
        SSR: '__vite__ssr__',
        ...userDefineEnv,
      }).replace(
        /"__vite__define__(.+?)"([,}])/g,
        (_, val, suffix) => `${val.replace(/(^\\")|(\\"$)/g, '"')}${suffix}`,
      ),
    })
  }

而如果处于Dev 命令执行的模式下,define.ts插件不会执行 env环境变量的注入,整个代码最终会走到最后一行的[clientInjectionsPlugin(config), importAnalysisPlugin(config)], 核心就是importAnalysisPlugin插件

github1s.com/vitejs/vite…

最终你可以在函数内部看到此处的 环境变量注入

if (hasEnv) {
        // inject import.meta.env
        str().prepend(getEnv(ssr))
      }

总结

推荐一个做笔记很方便的Vscode插件 Bookmarks , 在关键位置可以进行标记,方便回来阅读。

  1. bin/vite.js 可得知 命令执行的入口在 /src/node/cli.ts
  2. 不管是执行vite build 还是vite dev ,在 build/_createServer 函数中都会去执行 config.ts 中的resolveConfig 函数
  3. resolveConfig 内部会调用 lodeEnv 函数解析命令执行目录下的 .env.xxx文件解析符合条件的环境变量对象, 并执行到resolvePlugins函数中,通过插件机制注入环境变量
  4. /src/node/plugins/index.ts resolvePlugins 函数中,会通过 definePlugin 注入执行build模式下的环境变量,通过importAnalysisPlugin 注入dev 模式下的环境变量

一开始读还是很懵逼的,因为除了cli部分,后续函数的调用太多,甚至一度以为环境变量注入都是在 define 这个plugin完成的, 实际当你执行的是默认的 vite命令 即 vite dev模式, define内的判断会让环境变量注入不会执行。开发模式下最终的代码注入是在 resolvePlugins => importAnalysisPlugin