- 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第22期,链接:# 项目中常用的 .env 文件原理是什么?如何实现?。
说明
我们平时开发,经常在根目录通过增加.env类型的文件,在文件中以键值对的形式配置环境变量,最后配置的环境变量会绑定到process.env上,那么究竟是如何实现的呢?
其实像vue、react等框架脚手架都内置了dotenv插件,这个插件会帮助我们读取.env文件,并解析插入到process.env,那么今天我们就来阅读下该插件的源码,看看是如何实现的。
一、猜想和目标
1、猜想
在阅读这块源码之前,我觉得读者有必要自己思考一番,如果是我们自己设计,那么从逻辑上应该怎么实现?
- 首先,一切基于已经存在
.env文件,我们配置好键值对(当然其实不一定是.env文件,.env文件只是作为默认读取文件,当我们没有指定文件时); - 然后,使用
node的io流api读取该文件,并使用js把每一对键值对解析成数组,每个元素再根据正则匹配或者split对=号拆分,得到完整的多个键值对; - 最后,把键值对挂载到
process.env上,当然整个过程中必然会有一些边界或者防重复等判断,但是不管怎样,整体逻辑已经很清晰了;
2.目标
既然从逻辑上已经分析出来了,那么定个小目标,我们自己简单的重写一下dotenv的实现。
二、资源准备
# 我这边依旧直接用川哥准备的资源了
git clone https://github.com/lxchuan12/dotenv-analysis.git
cd dotenv-analysis/dotenv && npm i
# 或者大家可以也可以克隆官方的
git clone https://github.com/motdotla/dotenv.git
关于如何调试,此处不再赘述,前面几期都有具体描述,有不懂的可以看笔者前面几期中关于断点调试的内容,也可以直接看川哥的文章:新手向:前端程序员必学基本技能——调试JS代码。
lib中的main.js就是核心文件。
笔者是创建了
ali-test文件夹并在内部引用main.js的config函数进行的断点调试,读者可自行选择自己进入调试入口的方式。
三、源码分析
1、模块导入
下面导入了一些模块,并定义了部分常量和方法。
const fs = require('fs')
const path = require('path')
const os = require('os')
function log (message /*: string */) {
console.log(`[dotenv][DEBUG] ${message}`)
}
// 以下正则主要就是为了读取.env文件时针对字符进行截取解析
const NEWLINE = '\n'
const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/
const RE_NEWLINES = /\\n/g
const NEWLINES_MATCH = /\r\n|\n|\r/
// 处理指定的envPath,有时候获取的绝对路径不是以盘符展示,而是~开头的地址,需转换成实际盘符
function resolveHome (envPath) {
return envPath[0] === '~' ? path.join(os.homedir(), envPath.slice(1)) : envPath
}
2、parse方法
parse方法是用来处理等会在config方法中解析.env文件的键值对,最终获取到对象形式。这里先分析下parse内部的逻辑,等下在config方法中会执行。
/**
* src 是.env文件内容,纯文本如,NODE_ENV=example\nSAMPLE_KEY=defined\nERR_API_KEY=qwerty12345
* 注意读取的.env文本是有\n换行符或者其他空字符串的
* options 里面存放debug信息,是否需要提示信息
*/
function parse (src, options) {
const debug = Boolean(options && options.debug)
const obj = {}
// convert Buffers before splitting into lines and processing
src.toString().split(NEWLINES_MATCH).forEach(function (line, idx) {
// matching "KEY' and 'VAL' in 'KEY=VAL'
const keyValueArr = line.match(RE_INI_KEY_VAL)
// matched?
if (keyValueArr != null) {
const key = keyValueArr[1]
// default undefined or missing values to empty string
let val = (keyValueArr[2] || '')
const end = val.length - 1
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
}
- debug参数是用户自定义插入键值对到环境变量时失败的情况,是否输出提醒的日志信息;
- 对.env文本内容使用split根据空格/换行符等拆分成数组,遍历数组,根据match匹配到正则中设置的分组,匹配到时match返回值是数组,第一个元素是匹配的内容,第二个元素及之后为设置的分组内容,如有不了解的可自行了解match方法,此处不再赘述;
- 未匹配上match时,根据用户是否需要debug提示,若需要则输出日志;
- 匹配到时,获取匹配到的数组,拿到第二个元素key,和第三个元素val;
- 此处还进行了一次去除单引号和双引号的,防止用户在.env文件写入键值对时加了引号,如NODE_ENV="example";
- 去除val首尾空格,并把键值对插入到obj对象,统一收集,并返回;
3、config方法
// Populates process.env from .env file
/*
* options 用户传递对象参数,可有path(.env文件绝对路径)和debug(是否需要日志输出)属性
*/
function config (options) {
// 设置默认文件地址(如果用户指定了path,则下面会覆盖该值)
let dotenvPath = path.resolve(process.cwd(), '.env')
let encoding = 'utf8'
let debug = false
if (options) {
if (options.path != null) {
// 前面说了,处理~路径成盘符绝对路径
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
// 使用fs.readFileSync同步解析对应的文件,并传递给parse方法(前面讲了),获取到所有键值对的对象
const parsed = parse(fs.readFileSync(dotenvPath, { encoding }), { debug })
// 遍历对象,插入到process.env作为环境变量,注意之前有添加过则不会覆盖,根据需求绝对是否日志提示用户
Object.keys(parsed).forEach(function (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 }
}
}
以上就是dotenv的实现过程,其实整体来说还是逻辑比较清晰,设计的结构也不复杂,想必大家读到这里,应该心理都有个清晰的认知了。
读者也可以尝试借鉴dotenv的设计思路去实现自己的env模式。
四、自己实现
笔者基于上面dotenv实现思路,仿照简单的实现了自己的dotenv,里面剔除了一些判断逻辑,没过多考虑边界情况,读者有兴趣可查阅coderali的dotenv。
五、总结和收获
1、总结
很多工具平时我们都是直接用,其实有时候还是有必要去了解其实现原理的,这不但有助于加深对于一些工具的认知,了解原理,也在不断地加强自己对于工具的实现思路,为以后自己制作自己的工具打下基础。
2、收获
- 理解dotenv的实现逻辑;
- 加强对于正则的理解;
- 自己实现了简单的dotenv;