本文是Vite 原理揭秘 的第二篇,该系列的其它文章如下:
前言
在上一篇文章Vite 的安装和启动中我们用 npm run dev 来启动 Vite 程序,并且通过分析 npm run dev 命令的原理,了解到当你在命令行中输入 npm run dev 后,经过一系列的步骤,最终会通过 Node.js 来执行 vite_project/node_modules/vite/bin/index.js 这个 js 文件,这个文件正是 Vite 的入口文件。下面是这个文件内容的主要部分:
// ...
function start() {
// ...
return import('../dist/node/cli.js')
}
if (profileIndex > 0) {
// ...
} else {
start()
}
可以看到这个这个文件的主要逻辑就是动态 import 了 ../dist/node/cli.js 这个文件。
../dist/node/cli.js是打包后的路径,对应源码中 vite/packages/vite/src/node/cli.js 文件。这个文件的主要作用就是解析命令行中,用户输入的命令行参数,并且创建 httpServer 对象。本文将详细介绍 Vite 是如何解析命令行参数的。
nodejs 命令行
运行 Node.js 程序的常见方式是在终端中使用全局可用的 node 命令,并传入你想要执行的文件名。
新建一个文件夹 node_project,进入文件夹新建文件index.js:
console.log("Hello world")
然后打开命令行输入 node ./index.js 就可以执行这个文件了。现在我希望在运行 index.js 时,向这个程序传入一些参数来控制程序的逻辑,这可以通过 Node.js 命令行参数来完成。
命令行参数是指当你在命令行中运行 Node.js 程序时,在 node 命令和文件名之后额外输入的参数,这些参数可以在程序里通过 process.argv 这个数组来获取。
在index.js中再加入一段逻辑:
console.log("Hello world")
console.log(progress.argv) // 打印
将命令修改为 node ./index.js Hello World,Hello World 就是命令行参数。运行 index.js,命令行中会打印如下信息:
[
'/node', // Node.js 可执行文件的路径(安装 Node.js 的路径)
'/node_project/index.js', // 当前执行的 JS 文件路径
'Hello', // 第一个自定义参数
'World' // 第二个自定义参数
]
通过 progress.argv 拿到命令行参数后,就可以实现对程序中代码逻辑的控制了。
Vite 的命令行选项
Vite 提供了丰富的命令行选项来供用户自定义配置。比如你可以通过在 vite 命令后面添加 --port 8080 来修改 vite 服务的端口号。修改一下 vite_project/package.json 的 scripts 字段上的 dev 属性:
{
"scripts": {
"dev": "vite --port 8080"
}
}
--port 对应选项名,8080 对应选项的值,再次运行npm run dev,vite服务的端口就变成了8080。
完整的可配置项可参考官网命令行接口。
为什么添加 --port 8080 后就可以将 vite 服务的端口号修改为 8080? Vite是怎么获取到这个配置的? --port 8080是如何传递给 Node.js 程序的?
还记得上一篇文章中 vite-project/node_modules/bin/ 下的三个可执行文件吗?这些可执行文件会通过 Node.js 来执行 node_modules/vite/bin/vite.js从而启动 vite 服务。我们主要看下 vite 和 vite.cmd 这两个可执行文件。
vite 可执行文件
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@" # "$@" 表示 "--port 8080"
else
exec node "$basedir/../vite/bin/vite.js" "$@" # "$@" 表示 "--port 8080"
fi
vite.cmd 可执行文件
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\vite\bin\vite.js" %* # %* 表示 "--port 8080"
) ELSE (
@SETLOCAL
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\vite\bin\vite.js" %* # %* 表示 "--port 8080"
)
注意 vite 可执行文件中的 $@ 符号,他表示 sh 脚本接收到的所有参数(跟在程序后面的额外信息)。而 vite.cmd 可执行文件中 %* 表示 cmd 脚本接收到的所有参数。所以 $@ 和%*对应的是 vite --port 8080 的 --port 8080。在用 Nodejs 执行 /vite/bin/vite.js 文件时,又被传给了 nodejs 程序。
下图展示了整个过程的对应关系:
这样就可以在 nodejs 程序获取用户在命令行中传入的参数了。
Vite 解析命令行选项
前面在解释 Node.js 命令行选项时说到,可以通过 progress.argv 来获取命令行的参数。那 Vite 也是用这个属性来获取的吗?答案就在 vite/packages/vite/src/node/cli.js 这个文件中。文件的主要逻辑如下:
import { cac } from 'cac'
// ...
const cli = cac('vite')
// ...
// 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`, { type: [convertHost] })
.option('--port <port>', `[number] specify port`)
.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) => {
// ...
const { createServer } = await import('./server')
try {
const server = await createServer({...
await server.listen()
// ...
} catch (e) {
// ...
}
})
cli.parse()
可以看到 vite 并没有直接使用 progress.argv 来获取命令行的参数,而是使用了一个叫 cac的外部包。第十一行(源码中并非第十一行)代码 .option('--port <port>', `[number] specify port`) 正是用来获取端口号配置的。
为什么 vite 没有直接使用 progress.argv,而是使用了一个外部的包呢?
核心原因是 process.argv 只是原始数据,拿到数据需要手动进行解析,比如你需要先找到 --port这个选项,再找到接在他后面的值,如果参数很多,解析起来会很麻烦,容易出错,也没有统一的规范。而 cac 提供了解析、验证、帮助提示等一整套命令行框架能力。
认识 cac 包
首先要解释四个概念,程序,命令,选项,选项值,vite的命令行由这几个部分组成。对应关系如下图所示:
cac 可以帮助你解析命令行中的参数,创建命令行界面。它轻量,易用且功能强大。
最常用的两个方法是 command和option。
command方法用于定义一个命令,比如 dev, build,preview 和 optimize。它的调用语法是:
cli.command(name, description, config?)
option 方法用于定义命令的选项,用于控制命令执行的行为,是命令的“修饰语”,比如 --port,--host 等。它的调用语法是:
cli.option(name, description, config?)
看下 vite/packages/vite/src/node/cli.js文件的源码:
// 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('--port <port>', `[number] specify port`)
// ...
.action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
// ...
const { createServer } = await import('./server')
try {
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
configLoader: options.configLoader,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
server: cleanGlobalCLIOptions(options),
forceOptimizeDeps: options.force,
})
// ...
await server.listen()
}
}
// ...
cli.parse()
首先定义了一个默认命令,也就是在不指定命令时,就会走这个逻辑。
接着定义了默认命令的两个别名 serve 和 dev,意思就是如果命令行中的内容是 vite,vite serve 或者 vite dev 是等效的。
然后定义了一个选项 --port。
最后调用 action 方法,这个方法接受一个回调函数,用户可以在这个回调函数中拿到命令行解析的结果。
在这个回调函数中动态import了./server,其实就是 vite/packages/vite/src/node/server/index.ts这个文件。接着调用这个文件中的 createServer 方法,并将命令行解析的结果作为参数传给这个函数。
最后一步必须调用 parse 方法,这样才能正常解析。
结束
本篇文章主要分析了 Vite 是如何解析命令行中的选项的,下一篇将分析 createHttpServer 这个函数。