本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
这是源码共读的第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 并返回对象
主要函数
parse函数将.env文件读到内容 解析为对象格式返回config函数将读取返回的对象 写入到process.env中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`)
}
})
整体理清楚后比较简单:
parse函数通过fs模块将读到的env内容进行切割后,根据正则 返回一个新对象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的变量再注入到脚手架开发编译和打包环节中 实现了环境变量的使用!