项目中常用的环境变量.env文件原理是什么?通过源码阅读理解和实现!

946 阅读4分钟

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

这是源码共读的第22期 | 项目中常用的 .env 文件原理是什么?如何实现?

前言

通过阅读dotenv的实现,我们将理解 如Vue-Cli 脚手架中 对于环境变量的读取原理, 并加深对于Node中fs模块等API的使用记忆

环境仓库

# 推荐克隆川哥的项目,与文章同步
git clone https://github.com/lxchuan12/dotenv-analysis.git
# npm i -g yarn
cd dotenv-analysis/dotenv && yarn i
# VSCode 直接打开当前项目
# code .
# 例子都在 examples 这个文件夹中,可以启动服务本地查看调试
# 在 dotenv-analysis 目录下
node examples/index.js

# 或者克隆官方项目
git clone https://github.com/motdotla/dotenv.git
# npm i -g yarn
cd dotenv && yarn i
# VSCode 直接打开当前项目
# code .

入口

查看package.json知道主要文件为lib/main.js 进行分析流程

"main": "lib/main.js",
  "exports": {
    ".": "./lib/main.js",
    "./config": "./config.js",
    "./config.js": "./config.js",
    "./package.json": "./package.json"
  },

./config.js 调用后要执行自执行函数, 将env文件中的变量读取并返回对象

lib/env-options.js 用于读取process.env 下的对于dotnet的全局设置变量

lib/cli-options.js 用于读取传入的数组中 存在的 全局设置变量如: dotenv_config_path=xxx 的key-val 并返回对象

主要函数

  1. parse函数 将.env文件读到内容 解析为对象格式返回
  2. config函数 将读取返回的对象 写入到process.env中
  3. resolveHome函数 这个比较简单看是否需要从用户根目录取出env文件内容

resolveHome

function resolveHome(envPath) {
  return envPath[0] === '~' ? path.join(os.homedir(), envPath.slice(1)) : envPath
}

os模块和操作系统相关, homedir()将返回 用户的目录如我的就是:C:\Users\Administrator 将用来判断env文件路径是否需要从根目录读取

parse

function parse(src /*: string | Buffer */, options /*: ?DotenvParseOptions */) /*: DotenvParseOutput */ {
  const debug = Boolean(options && options.debug)
  const obj = {}

  src.toString().split(NEWLINES_MATCH).forEach(function (line, idx) {
    // matching "KEY' and 'VAL' in 'KEY=VAL'
    const keyValueArr = line.match(RE_INI_KEY_VAL)
    if (keyValueArr != null) {
      //这里不从0开始 是因为 match没用 g模式, 如果匹配到的话 下标0 是 要匹配的完整字符串本身,下标1开始才是符合匹配的字符
      const key = keyValueArr[1]
      // default undefined or missing values to empty string
      let val = (keyValueArr[2] || '')
      const end = val.length - 1
      //判断value是否有双引号或者单引号  将主要内容抓出来 去掉单双引号
      const isDoubleQuoted = val[0] === '"' && val[end] === '"'
      const isSingleQuoted = val[0] === "'" && val[end] === "'"

      // if single or double quoted, remove quotes
      if (isSingleQuoted || isDoubleQuoted) {
        val = val.substring(1, end)

        // if double quoted, expand newlines
        if (isDoubleQuoted) {
          val = val.replace(RE_NEWLINES, NEWLINE)
        }
      } else {
        // remove surrounding whitespace
        val = val.trim()
      }

      obj[key] = val
    } else if (debug) {
      log(`did not match key and value when parsing line ${idx + 1}: ${line}`)
    }
  })

  return obj
}
src.toString().split(NEWLINES_MATCH).forEach(function (line, idx){})

通过NEWLINES_MATCH 换行匹配切割每一行的数据为一个数组

const keyValueArr = line.match(RE_INI_KEY_VAL)

RE_INI_KEY_VAL 将匹配类似 xxx=xxx 的内容,所以这一行将会返回匹配结果的数组

const key = keyValueArr[1]
      // default undefined or missing values to empty string
      let val = (keyValueArr[2] || '')
      const end = val.length - 1
      //判断value是否有双引号或者单引号  将主要内容抓出来 去掉单双引号
      const isDoubleQuoted = val[0] === '"' && val[end] === '"'
      const isSingleQuoted = val[0] === "'" && val[end] === "'"

      // if single or double quoted, remove quotes
      if (isSingleQuoted || isDoubleQuoted) {
        val = val.substring(1, end)

        // if double quoted, expand newlines
        if (isDoubleQuoted) {
          val = val.replace(RE_NEWLINES, NEWLINE)
        }
      } else {
        // remove surrounding whitespace
        val = val.trim()
      }

这一部分将匹配到的每一项内容 去除可能存在的 单双引号以及去空, 最后将val写入到obj[key]

config

function config(options /*: ?DotenvConfigOptions */) /*: DotenvConfigOutput */ {
  let dotenvPath = path.resolve(process.cwd(), '.env')
  let encoding /*: string */ = 'utf8'
  let debug = false

  //判断options参数是否有手动指定
  if (options) {
    if (options.path != null) {
      //resolveHome主要判断路径开头是否为~ 是的话 则去 用户的根目录取env文件
      dotenvPath = resolveHome(options.path)
    }
    if (options.encoding != null) {
      encoding = options.encoding
    }
    if (options.debug != null) {
      debug = true
    }
  }

  try {
    // specifying an encoding returns a string instead of a buffer
    const parsed = parse(fs.readFileSync(dotenvPath, { encoding }), { debug })

    Object.keys(parsed).forEach(function (key) {
      //判断一下process.env 环境变量 是否 有这个key 了 没有才加上去
      if (!Object.prototype.hasOwnProperty.call(process.env, key)) {
        process.env[key] = parsed[key]
      } else if (debug) {
        log(`"${key}" is already defined in \`process.env\` and will not be overwritten`)
      }
    })

    return { parsed }
  } catch (e) {
    return { error: e }
  }
}

使用parse函数读取目录下的.env文件内容,返回一个对象,并通过Object.keys方法 循环判断这个变量是否已存在,不存在则挂载到process.env中

 const parsed = parse(fs.readFileSync(dotenvPath, { encoding }), { debug })

    Object.keys(parsed).forEach(function (key) {
      //判断一下process.env 环境变量 是否 有这个key 了 没有才加上去
      if (!Object.prototype.hasOwnProperty.call(process.env, key)) {
        process.env[key] = parsed[key]
      } else if (debug) {
        log(`"${key}" is already defined in \`process.env\` and will not be overwritten`)
      }
    })

整体理清楚后比较简单:

  1. parse函数 通过fs模块将读到的env内容进行切割后,根据正则 返回一个新对象
  2. config函数 将返回的对象与 process.env 中的变量比对,不存在的变量则挂载上去

可以照着这个思路 手动实现一个版本

import path from 'node:path'
import fs from 'node:fs'

const NEW_LINE = /\r\n|\n/g
const KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*/
const CWD = process.cwd()
/**
 * content  env内容  切割处理
 */
function parse(content) {
    let obj = {}
    content.split(NEW_LINE).forEach((line, index) => {
        let keyValItem = line.match(KEY_VAL)
        if (keyValItem && keyValItem.length > 1) {

            let key = keyValItem[1]
            let val = keyValItem[2] || ''
            const end = val.length - 1
            //处理单双引号问题
            const isDoubleQuoted = val[0] === "'" && val[end] === "'"
            const isSingleQuoted = val[0] === '"' && val[end] === '"'
            if (isDoubleQuoted || isSingleQuoted) {
                val = val.substring(1, end)
            } else {
                val = val.trim()
            }

            obj[key] = val
        } else {
            console.log(`did not match key and value when parsing line ${index + 1}`)
        }
    })
    return obj
}

/**
 *  将通过parse函数读取返回的对象 和 process env进行对比 并挂载
 */
function config(strPath = '.env') {
    let envPath = path.resolve(CWD, strPath)

    //得到对象
    const obj = parse(fs.readFileSync(envPath, { encoding: 'utf-8' }))

    Object.keys(obj).forEach((key, index) => {
        if (!(Object.prototype.hasOwnProperty.call(process.env, key))) {
            process.env[key] = obj[key]
        } else {
            console.log(`"${key}" is already defined in \`process.env\` and will not be overwritten`)
        }
    })
}

config()
console.log(process.env.VUE_APP_NAME) //GU1ST

//VUE_APP_NAME=GU1ST

总结

阅读这一期源码主要内容都相对简单很多,新认识OS模块的使用还翻了下文档加深了印象, 可以说是 边学边用!

明白了其中的细节,就触类旁通了一些东西,比如 Vite 或者 Webpack的脚手架, 将dotEnv挂载到Process.env的变量再注入到脚手架开发编译和打包环节中 实现了环境变量的使用