本文正在参加「金石计划」
前言
环境变量的静态替换指:使用编译工具对前端项目构建打包过程中将代码中的特定字符串标记进行替换。
不论使用的前端框架是 react、vue、angular 还是 svelte,在构建打包时绝大多数都会进行环境变量的替换,因此弄懂环境变量静态替换原理尤其重要。
所以,本文以当下热门的构建工具 vite
为例,来简单跟踪分析其环境变量替换的过程和原理。
vite 简介
vite
是什么就不多说,简言之:它是当前十分热门的前端构建工具,同时支持 vue、react 等多种框架的构建打包。
官网上其特性介绍如下:
其和传统的构建工具最大的不同是:
使用原生 ESM 文件,无需打包
ESM
是 ES6 Module
的简称,本地开发环境启动时入口的 index.html
页面使用直接加载经过 vite
转换之后的 ES6 模块。
而 webpack
系列构建工具在本地开发环境启动时,会将所有 JS 资源转换为 UMD 模块。
这一不同点也导致了 vite
在开发环境与生产环境下环境变量“替换”的原理不一样,下文【疑问解释】部分会讲到。
环境变量替换简述
前端项目中环境变量替换的过程可以描述为:
项目中一般会有 .env
.env.test
.env.prod
这样的配置文件,在不同的打包环境下构建工具会解析不同的配置文件,这些配置文件最后会将源码中的相应标记替换成配置文件的值。
vite
环境变量的用法和 webpack
中不太一样,官网描述如下:
不管是 vite
自带的还是用户通过 .env
配置文件指定的环境变量都是通过 import.meta.env.XXX
暴露出来。
替换效果展示
这里使用命令 npm create vite@latest
创建一个基于 vite
的示例项目,观察环境变量的替换效果。
源代码使用环境变量 import.meta.env.VITE_SOME_KEY
,通过 .env
文件配置其对应的值为字符串 123
。
开发环境
调试工具看到 import.meta.env
被替换成了一个对象,其中包含 .env
中定义的 VITE_SOME_KEY
。
同时,这段代码的打印逻辑也正确执行了。
生产环境
使用 npx vite build
构建项目,然后使用 npx vite preview
预览项目。
通过调试工具进行代码格式化,看到 import.meta.env.VITE_SOME_KEY
直接被替换成了字符串 123
。
毫无疑问,代码逻辑同样也正确执行了;但是,打包后代码中变量变量是直接被替换成了字符串,开发环境中却不是这样。
这个区别点先不展开讲,下文再继续说。
动态取值
上面源码里环境变量的写法是一种静态写法,下面看下其态动态取值写法。
vite
官网强调生产环境下环境变量动态取值写法是无效的。
动态取值写法近似如下:
const key = 'VITE_SOME_KEY'
console.log(import.meta.env[key])
修改源码环境变量为动态取值写法
开发环境使用动态取值写法,是生效的
生产环境环境变量替换为如下:
代码逻辑执行正确,说明生产环境下动态取值也有效的。
这里,动态取值与官方文档上不一致,至于为什么会生效后文再解答。
替换过程跟踪分析
下面重点跟踪分析 vite
构建打包过程中环境变量的替换原理,从打包命令 npx vite build
开始分析。
vite 解析配置文件路径
vite
打包命令第一步是解析相关配置文件。
读取vite.conig.js配置文件,若configFile或envFile设置为 false 则不解析环境变量。
根据构建模式 mode 标识解析项目根路径下的 env 配置文件路径。
按照以下顺序依次检查并解析配置文件。
例如:若执行默认的生产环境构建命令 npx vite build
,则 mode
为 production
,解析配置文件的顺序为 .env => .env.local => .env.production => .env.production.local
。
若以上配置文件中存在同名的 key,则按照上述顺序后者覆盖前者
.env.production.local
配置文件的优先级最高,VITE_SOME_KEY
的值为 '123456'
dotenv 解析配置内容
vite
根据 mode
解析到的配置文件路径进行文件读取得到文件字符串,然后将其交给 dotenv
进行配置解析。
上面环境变量配置转换为下方的配置对象
这里将文本以换行符 \n
进行分割,同时 =
作为对象 key
和 value
的分隔符,value
默认为空字符串,使用 #
注释的行会被忽略。
dotenv-expand 扩展配置语法
dotenv
将文本 FOO=bar
转换为对象 { FOO: 'bar' }
,这是一种最简单的转换语法,doctenv-expand
则对这个转换语法进行了扩展。
PASSWORD="s1mpl3"
DB_PASS=$PASSWORD
这种写法的字面意思应该是
let DB_PASS = PASSWORD = "s1mpl3"
得到的对象应该是
{
DB_PASS: 's1mpl3',
PASSWORD: 's1mpl3',
}
但是经过前面步骤解析后 DB_PASS
的值是 '$PASSWORD'
,于是需要使用 dotenv-expand
进一步转换配置对象。
转换后的配置对象为
vite 过滤配置对象
vite
会遍历配置对象将不以 prefix
开头的 key
移除掉;prefix
默认为:'VITE_'
,可以通过 vite.config.js
中的 envPrefix 配置项进行修改。
过滤后的配置对象为
vite 追加环境变量
1. 追加 process.env
环境变量
检查当前 process.env
中是否有以 prefix
开头的环境变量配置,有则将其追加到上一步的配置对象中。
如果执行的构建命令是 VITE_SOME_KEY=456 npx vite build
,则 process.env
上带有命令传入的环境变量 VITE_SOME_KEY
,其会被合并到配置对象上并覆盖已有的同名配置。
因此,命令行当中的传入的环境变量配置优先级最高。
2. 追加内建环境变量
接着追加 vite
内建环境变量
追加之后的结果如下:
definePlugin 配置项生成
这里的
definePlugin
是环境变量静态替换的核心rollup
插件,由vite
插件包提供的,并非@rollup/plugin-replace
插件;在vite
中该插件的名称叫作:vite:define
。
definePlugin 构建文本替换正则
将不同类型的环境变量合并
合并后的环境变量对象
根据上述环境变量对象生成用于源码字符串替换的正则表达式
该正则表达式可视化的结果
definePlugin 进行文本替换
经过上述过程,环境变量静态替换相关的准备工作已完成,将生成的配置项传递给 rollup
来编译源码。
根据 rollup
的插件运行规则,执行到 definePlugin
插件的 transform
阶段,开始使用上述正则表达式匹配源码进行文本替换。
替换之后的源码如下:
到这里,APP.vue
源码的所有环境变量标记都已经被替换,其他源代码依次按照 rollup
的文件遍历处理规则也会被 definePlugin
插件进行替换。
main.js
中的静态取值写法被替换为如下:
magic-string 进行字符串替换
对源代码进行字符串替换是替换的细节,但还是单独讲一下。definePlugin
内部使用了 magic-string
这个非常底层的工具库,@rollup/plugin-replace
中也用到了。
通过上一步的正则表达式去匹配源码,找到环境变量标记的在字符串中的索引位置,然后使用进行替换。
使用 magic-string
可以简化对字符串替换的操作,关键是方便生成 sourcemap
,它的使用示例如下:
import MagicString from 'magic-string';
import fs from 'fs'
const s = new MagicString('problems = 99');
s.overwrite(0, 8, 'answer');
s.toString(); // 'answer = 99'
s.overwrite(11, 13, '42'); // character indices always refer to the original string
s.toString(); // 'answer = 42'
s.prepend('var ').append(';'); // most methods are chainable
s.toString(); // 'var answer = 42;'
const map = s.generateMap({
source: 'source.js',
file: 'converted.js.map',
includeContent: true
}); // generates a v3 sourcemap
fs.writeFileSync('converted.js', s.toString());
fs.writeFileSync('converted.js.map', map.toString());
疑问解释
下面解答上文中没有解释的几个问题。
为何生产环境下动态取值也有效的?
在 definePlugin
初始化配置得到的环境变量配置对象 replacements
可以看到,import.meta.env
对应的值是一个 JSON 字符串。
在 definePlugin
用正则表达式替换源码过程中会将动态取值写法进行替换。
let key = 'VITE_SOME_KEY'
import.meta.env[key]
被替换为:
let key = 'VITE_SOME_KEY'
{"VITE_SOME_KEY":"12345","BASE_URL":"/","MODE":"production","DEV":false,"PROD":true,"SSR":false}[key]
所以,生产环境下动态取值写法有效。
vite 开发环境与生产环境下环境变态替换是否有区别?
肯定是有区别的。
开发环境下我们编写的源码、第三方 npm 包都会被编译成原生 ESM 模块,入口 html 页面通过 <script type="module" src="xxx"></script>
来加载执行这些编译好的 ESM 模块。
这种方式加载的 ESM 模块内部的存在一个全局的 import.meta
对象,vite
在转换代码为 ESM 模块时,会在文件头部定义环境变量配置,如下:
开发环境下源码中的环境变量标记未被替换,而生产环境下源码中的环境变量标记会按照上文分析的过程进行替换。
总结
vite
在生产环境下环境变量的静态替换过程可以概括为下图:
关键流程总结为:通过 vite
的项目配置解析出环境变量配置,然后借助 rollup
对源码进行替换,处理字符串替换的核心 rollup
插件是 vite
自己编写的 vite:define
插件。
对于 .env
文件内容的解析、字符串的替换 vite
没有重新造轮子,分别采用了 dotenv、doctenv-expand
和 magic-string
来实现。
出于篇幅的考虑,本文未对 vite
开发环境下环境变量的替换过程做详细跟踪分析,后续再专门出一篇文章进行讲解。