我正在参与掘金会员专属活动-源码共读第一期,点击参与。
前言
相信不少朋友都使用过 Vite 的 npm create vite@latest 命令来初始化 Vue 项目或者React 项目,如果你对该命令的工作原理十分好奇,那巧了,本文将会揭开 npm create vite@latest 的神秘面纱,让你学会搭建属于自己的一个脚手架。
前置知识
在解析 npm create vite@latest 原理之前,我们需要先了解一下 npm create vite@latest 命令的实际运行命令是什么;cross-spawn 包的用法,minimist 包的用法,kolorist 包的用法,prompts 包的用法,这三个包都是 Vite 在执行命令时所用到的。
npm create 的实际命令
我们平时运行某个包常见的方式有两种:
- 在
package.json中的scripts字段编写好对应的脚本命令,通过npm run xxx的方式运行某个包 - 直接在终端输入
npx <包名>命令,即可运行某个包,比如:npx jest,就会去运行jest,跑整个项目的测试用例。
那 npm create vite@latest 又是怎么回事呢?
其实,它就相当于是 npx create-vite@lastest,lastest 是最新的版本号。
所以,本文要探究的就是 create-vite 包的原理,它位于 vite 项目中 packages 文件夹下。
那 npx create-vite@lastest 运行的又是哪个文件呢?我们找到 create-vite 文件夹下 package.json 文件,找到 bin 字段的值:
可以看到 npx create-vite@lastest 运行的是 index.js 文件。
cross-spawn
其实这个库在接下来要解析的主要源码中是用不到的,但 create-vite 既然在少数情况下用到了它,在这里就简单地介绍一下。
在 Node.js 中,可以使用 child_process 模块来创建子进程,child_process 模块下有一个名为 spawnSync (或 spawn)的函数,是用来同步(spawn 为异步)创建运行在系统上的命令,比如创建一个 npm install 的命令:
const spawnSync = require('child_process').spawnSync;
spawnSync('npm', ['install'], {
stdio: 'inherit'
});
// 相当于在终端执行 npm install 命令
这段代码在 Linux,macOS 系统上能够执行,但在 Windows 系统上执行会报错。
所以,cross-spawn 包就提供了关于 spawnSync (或 spawn) 函数的跨平台写法,不用开发人员处理跨平台的逻辑。其用法跟上面代码基本一样:
const spawn = require('cross-spawn');
spawn.sync('npm', ['install'], {
stdio: 'inherit'
});
Github 地址 — cross-spawn
minimist
minimist 是一个命令行参数解析包。在 Node.js 中,可以使用 process.argv 获取命令行的参数,比如在终端运行命令 node index.js create --template vue,并在 index.js 中打印 process.argv,你会在终端看到以下内容:
- 第一个元素是执行
node的地址 - 第二个元素是正在执行的
JavaScript文件的路径 - 其余元素将是任何其他命令行参数
通常情况下,开发人员想要拿到的是选项参数值 — --xxx 或 -xxx 对应的值,在这里就是获取 --template 对应的 vue 值。虽然现在可以通过 process.argv[4] 拿到,但实际情况是会存在一个到多个 --xxx 或 -xxx,并不好获取,需要进行逻辑处理。
而 minimist 包就是用来解决这个痛点的,我们在 index.js 中引入 minimist,并使用它:
import minimist from "minimist";
console.log(minimist(process.argv));
得到结果:
可以看到,minimist 将命令行参数解析成了对象,非选项参数放在了键 _ 的数组上,而选项参数则是键值对的形式展示,这样,我们就可以很方便地获取选项参数的值。
一般情况下,
process.argv的前两个元素基本上就是node的地址和正在执行的JavaScript文件的路径,不会动态变化了,在开发中,也基本不需要获取这两个值,所以可以获取第三个及后面的参数 —minimist(process.argv.slice(2))
Github 地址 — minimist
kolorist
颜色化标准的输入和输出,用法也很简单:
import {
blue,
cyan,
green,
lightGreen,
lightRed,
magenta,
red,
reset,
yellow,
} from "kolorist";
console.log("blue ->", blue("vite"));
console.log("cyan ->", cyan("vite"));
console.log("green ->", green("vite"));
console.log("lightGreen ->", lightGreen("vite"));
console.log("lightRed ->", lightRed("vite"));
console.log("magenta ->", magenta("vite"));
console.log("red ->", red("vite"));
console.log("reset ->", reset("vite"));
console.log("yellow ->", yellow("vite"));
结果如下:
值得注意的是,reset 代表重置为系统的默认颜色。
Github 地址 — kolorist
prompts
prompts 是一个交互命令行提示工具,当你在终端运行 npm create vite@latest 命令时,想必会经历这样的过程:
- 输入项目的名称
- 选择项目的框架
- 选择项目的编程语言
这一系列的交互行为就是通过 prompts 实现的。
它最基础的用法是单一提示:
const prompts = require('prompts');
(async () => {
const response = await prompts({
type: 'number',
name: 'age',
message: 'How old are you?'
});
console.log(response);
})();
type 表示该提示的类型,它常用的值有:number,text,confirm,select 等,可类比于 input 标签的类型,这些值也可以通过函数来动态返回。type 也可以是 null,如果是 null,则会跳过这个交互。
name 表示该提示的名称,作为键存储在 prompts 函数返回的结果中,值就是用户输入的内容。
message 表示该提示要向用户显示的消息。简单来说就是该提示的问句。
运行以上代码会得到如下结果:
当然了,强大的 prompts 也可以进行多个提示的交互方式:
import prompts from "prompts";
(async () => {
const response = await prompts([
{
type: "text",
name: "username",
message: "what's your name?",
},
{
type: "number",
name: "age",
message: "How old are you?",
},
]);
console.log(response);
})();
运行结果如下:
Github 地址 — prompts
源码准备
从 Vite 的 GitHub 仓库地址克隆到本地,从 packages 文件中可以看出,Vite 采用的是 Monorepo 多包管理方式。
因此,我们需要全局安装 pnpm,运行命令 npm install -g pnpm(如果已安装了 pnpm ,请省略这一步)。接着,通过 pnpm 安装依赖,运行命令 pnpm install。
而本文要分析的是 packages 文件夹下的 create-vite 包的源码,位于其 src/index.ts 文件中,所以需要安装 ts-node 命令行工具,运行命令 npm install -g ts-node,后续通过 ts-node 直接运行 index.ts 文件。
接下要进入调式模式,在 VSCode 终端选择 JavaScript Debug Terminal
接着输入命令 cd ./packages/create-vite/src,并找到源码文件所在的目录 — packages/create-vite/src,打开 index.ts,找到 init() 函数(因为它是整个程序的入口),在 init() 函数处打下一个断点,在 Debug 终端运行命令 ts-node --esm index.ts(相当于执行了 npm create vite@latest),VSCode 就进入了调试状态。
接下来,我们就可以开始源码解析啦!
源码解析
本文会通过调试的方式一步步解析源码,如果对调式方式不熟悉的话,可以先看这篇文章 — 新手向:前端程序员必学基本技能——调试JS代码。
我们先来看看这几个常量,变量和函数:
// 获取命令行参数
const argv = minimist<{
t?: string
template?: string
}>(process.argv.slice(2), { string: ['_'] })
// 将一个或多个 '/' 转换成空字符串
function formatTargetDir(targetDir: string | undefined) {
return targetDir?.trim().replace(/\/+$/g, '')
}
// 默认的项目名称
const defaultTargetDir = 'vite-project'
const argTargetDir = formatTargetDir(argv._[0])
const argTemplate = argv.template || argv.t
let targetDir = argTargetDir || defaultTargetDir
const getProjectName = () =>
targetDir === '.' ? path.basename(path.resolve()) : targetDir
let result: prompts.Answers<
'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'
>
如果初始化项目输入的命令是 npm create vite@latest my-vue-app --template vue,那么 argTargetDir 的值为 my-vue-app,代表项目的名称,argTemplate 的值为 vue,代表项目所用的框架(模板)。
很明显,当前我们并没有通过命令行的方式指定项目名称和项目的框架,因此这两个常量的值为 undefined。
我们连续按两下 F10,并将鼠标分别悬浮到 argTargetDir 和 argTemplate,可以清楚地看到值为 undefined。
代码执行到变量 targetDir 处,该变量代表一开始的项目名称,现在的初始化值为 vite-project。继续按下 F10。
代码执行到函数 getProjectName,其功能是获取项目名称。继续按下 F10。
代码执行到变量 result 处,该变量代表交互命令行提示工具 prompts 函数返回的结果,初始化值为 undefined。
搞清楚上面的几个常量,变量和函数的含义之后,我们先在代码 300 行处打断点。
接着再来分析分析,216 行到 293 行的代码做了什么事情:
result = await prompts(
[
// 输入项目名称
{
type: argTargetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: (state) => {
targetDir = formatTargetDir(state.value) || defaultTargetDir
},
},
// 选择框架
{
type:
argTemplate && TEMPLATES.includes(argTemplate) ? null : 'select',
name: 'framework',
message:
typeof argTemplate === 'string' && !TEMPLATES.includes(argTemplate)
? reset(
`"${argTemplate}" isn't a valid template. Please choose from below: `,
)
: reset('Select a framework:'),
initial: 0,
choices: FRAMEWORKS.map((framework) => {
const frameworkColor = framework.color
return {
title: frameworkColor(framework.display || framework.name),
value: framework,
}
}),
},
// 选择哪种编程语言(变体)
{
type: (framework: Framework) =>
framework && framework.variants ? 'select' : null,
name: 'variant',
message: reset('Select a variant:'),
choices: (framework: Framework) =>
framework.variants.map((variant) => {
const variantColor = variant.color
return {
title: variantColor(variant.display || variant.name),
value: variant.name,
}
}),
},
],
{
// 终止流程时,调用的回调函数
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled')
},
},
)
我把其中的 227 行到 253 行的代码去掉了,因为它们不会被执行到,简单说一下,这些代码主要的作用是针对输入的项目名称已经存在的情况下,进行重写项目名称的操作。
代码中涉及到的常量 FRAMEWORKS 和 TEMPLATES,分别表示框架的信息和获取所有框架的脚手架模板,它们在代码 41 行到 195 行处。
prompts 函数里面主要写了三个交互,分别是:
- 输入项目名称
{
type: argTargetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: (state) => {
targetDir = formatTargetDir(state.value) || defaultTargetDir
},
},
initial 表示该交互的初始值是 defaultTargetDir — vite-project,onState 函数是当该交互输入内容改变时的回调函数,通过 state.value 可以获取到输入的内容,最终赋值给变量 targetDir 。
- 选择项目的框架
{
type:
argTemplate && TEMPLATES.includes(argTemplate) ? null : 'select',
name: 'framework',
message:
typeof argTemplate === 'string' && !TEMPLATES.includes(argTemplate)
? reset(
`"${argTemplate}" isn't a valid template. Please choose from below: `,
)
: reset('Select a framework:'),
initial: 0,
choices: FRAMEWORKS.map((framework) => {
const frameworkColor = framework.color
return {
title: frameworkColor(framework.display || framework.name),
value: framework,
}
}),
},
如果是通过命令行参数指定的项目框架,并且指定的框架包括在 Vite 脚手架模板中,那么 type 值为 null,就会跳过这次交互。而经过上文对 argTemplate 的分析,现在 type 值为 select,表示选择框架,初始值为第一个框架。
- 选择项目的编程语言(变种)
{
type: (framework: Framework) =>
framework && framework.variants ? 'select' : null,
name: 'variant',
message: reset('Select a variant:'),
choices: (framework: Framework) =>
framework.variants.map((variant) => {
const variantColor = variant.color
return {
title: variantColor(variant.display || variant.name),
value: variant.name,
}
}),
},
type 和 choices 是一个函数,函数的参数是上一次交互的值,也就是选择的框架。
了解上述代码的基本逻辑之后,我们按下 F5,接着就会进行上述一系列的提示问答交互:
接下来,我们再看看 300 行到 308 行处代码做了什么:
const { framework, overwrite, packageName, variant } = result
const root = path.join(cwd, targetDir)
if (overwrite) {
emptyDir(root)
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root, { recursive: true })
}
通过 prompts 函数返回的结果获取到选择的框架(framework) 和选择的编程语言(variant),常量 root 表示所要创建项目的根目录,通过 fs.mkdirSync() 方法进行创建。
我们在代码 311 行处打上断点,按下 F5,让上述代码执行。你就会发现 src 文件夹下多了一个 vue-project 的文件夹。
再往代码 360 行处打上断点,分析代码 311 行到 360 行做了什么:
let template: string = variant || framework?.name || argTemplate
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
console.log(`\nScaffolding project in ${root}...`)
const templateDir = path.resolve(
fileURLToPath(import.meta.url),
'../..',
`template-${template}`,
)
上述代码是我将一些不运行的代码删除后的样子,来稍微解释一下这几个常量和变量的含义:
template表示框架模板,它是给后面用来获取框架模板文件夹用的。pkgInfo表示从 UA 中获取包管理工具的信息,在这里的值是undefined。pkgManager表示包管理工具,现最终值为npm。templateDir表示框架模板文件夹的路径,这个获取的是什么呢?细心的朋友可能已经发现了,在create-vite文件夹下有很多名字以template-xxx形式的文件夹,templateDir就是获取这些文件夹的路径。
而接下来要做的就只有一件事了,那就是把某个 template-xxx 文件夹的内容复制到刚刚我们自己输入的项目名称文件夹下,也就是把 template-vue-ts 文件夹的内容复制到 vue-project 文件夹。
我们直接看最后的代码 — 360 行到 405 行的代码,同样地,我也会把一些不执行的代码删除,以及附加一些相关的函数代码。为了看到效果,我们可以先在代码 374 行和382 行处分别打下断点。
// 文件夹复制
function copyDir(srcDir: string, destDir: string) {
fs.mkdirSync(destDir, { recursive: true })
for (const file of fs.readdirSync(srcDir)) {
const srcFile = path.resolve(srcDir, file)
const destFile = path.resolve(destDir, file)
copy(srcFile, destFile)
}
}
// 文件复制
function copy(src: string, dest: string) {
const stat = fs.statSync(src)
if (stat.isDirectory()) {
copyDir(src, dest)
} else {
fs.copyFileSync(src, dest)
}
}
// 向 vue-project 文件夹写入文件内容
const write = (file: string, content?: string) => {
const targetPath = path.join(root, renameFiles[file] ?? file)
if (content) {
fs.writeFileSync(targetPath, content)
} else {
copy(path.join(templateDir, file), targetPath)
}
}
const files = fs.readdirSync(templateDir)
// 不对 'package.json' 文件进行立马复制
for (const file of files.filter((f) => f !== 'package.json')) {
write(file)
}
// 读取模板中的 'package.json' 文件内容
const pkg = JSON.parse(
fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8'),
)
//修改 'package.json' 文件中 'name' 字段的值
pkg.name = packageName || getProjectName()
// 复制 'package.json' 文件
write('package.json', JSON.stringify(pkg, null, 2) + '\n')
// 后面代码就是控制 log 的输出内容
我们先从 369 行代码看起,通过 fs.readdirSync() 方法读取 template-vue-ts 文件夹的内容,并赋值给常量 files。template-vue-ts 文件夹的内容如下图所示:
再通过 for 循环调用 write 函数,它的功能是将 template-vue-ts 下的文件夹或文件(除了 package.json 文件)复制给 vue-project,或者是对 vue-project 文件夹写入文件内容。
write 函数内部调用了 copy 函数,它的功能就是实现文件的复制,如果传入的文件是一个文件夹,那么又会交给 copyDir 函数执行,进行文件夹的复制。copy 函数和 copyDir 函数之间存在相互调用的情况,从而达到递归复制文件的效果。
当我们这时按下 F5 之后,你会发现 vue-project 文件夹出现了很多文件。如下图:
眼尖的朋友应该发现了,缺少 package.json 文件,接下来的 374 行到 380 行代码就是向 vue-project 写入 package.json 文件内容。
为什么一开始不对 package.json 文件复制呢?因为需要修改 package.json 中 name 字段的值为项目名称,修改完后,再调用 write() 函数进行文件内容的写入。
我们再按下 F5,就会看到有 package.json 文件了。
至此,这就是 Vite 在初始化项目中最基本的流程了。
总结
npm create vite@latest实际上执行的是npx create-vite@lastest命令。create-vite源码依赖包有三个,命令行参数解析包 — minimist,终端颜色输入输出包 — kolorist,交互命令行提示工具 — prompts。create-vite主要做了三件事情,分别是:
- 创建项目目录
- 将
template-xxx的内容复制到项目目录下面 - 修改
template-xxx的package.json文件中name字段的值,最后将package.json文件写入项目目录。