- 本文参加了由公众号@若川视野发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共度的第37期。链接:传送门
- 源码vite/create-vite
本文将从下载源码开始一步一步解析vite中关于create-vite
的源码,了解在我们输入npm create vite
之后,vite做了哪些事情。熟悉之后还可以根据具体需求搭建我们自己的脚手架工具等等。
调试准备
克隆完代码之后,可以找到根目录下CONTRIBUTING.md
,这个文件中记录了如果想要调试代码该如何操作。如果没有安装pnpm
可以运行npm i -g pnpm
进行安装。
按照上面的说明运行之后我们进入packages/create-vite/package.json
文件
json
{
"name": "create-vite",
"version": "5.1.0",
"type": "module",
"license": "MIT",
"author": "Evan You",
"bin": {
"create-vite": "index.js",
"cva": "index.js"
},
//......
}
可以看到bin
命令中都指向了index.js
,所以接下来我们找到这个文件。index.js
文件中只写了import './dist/index.mjs'
,dist/index.mjs
是我们上一步pnpm run build
之后打包后的文件,无法看到具体的代码。这时候通过同级下的build.config.js
可以看到入口文件是src/index.ts
。 这个才是我们真正需要看的核心代码,因为是ts文件,所以我们在运行的时候需要使用npx tsx src/index.ts
或者npx esnosrc/index.ts
来调试。
源码分析
先来看一下index.ts
里面的内容,蓝色的是定义的变量名称,下面的是各种函数方法名称。
从中可以找到init()
方法,这个就是我们需要看的主方法,里面包含的正是cli
工具是如何帮你创建出一个基础项目目录的。接下来我们来逐步分析下里面的代码。
模块引用和基础变量的定义
typescript
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// 创建一个子进程并且可以在不同的操作系统中正确地执行命令
import spawn from 'cross-spawn'
// 解析命令行参数的模块
import minimist from 'minimist'
// 创建交互式输入提示的库
import prompts from 'prompts'
// 终端颜色输出的工具
import {
blue,
cyan,
green,
lightBlue,
lightGreen,
lightRed,
magenta,
red,
reset,
yellow,
} from 'kolorist'
// Avoids autoconversion to number of the project name by defining that the args
// non associated with an option ( _ ) needs to be parsed as a string. See #4606
const argv = minimist<{
t?: string
template?: string
}>(process.argv.slice(2), { string: ['_'] })
// 获取当前工作目录
const cwd = process.cwd();
// .... 一些自定义的变量,篇幅过长放到后面讲
const defaultTargetDir = 'vite-project';
async function init() {
/**
* formatTargetDir 方法作用是去除反斜杠 (init方法下定义)
* function formatTargetDir(targetDir: string | undefined) {return targetDir?.trim().replace(/\/+$/g, '')}
* 获取命令行的第一个参数并格式化
*/
const argTargetDir = formatTargetDir(argv._[0])
// 获取命令行中的模板参数 --template 或 -t
const argTemplate = argv.template || argv.t
// 获取项目名称
let targetDir = argTargetDir || defaultTargetDir
const getProjectName = () =>
targetDir === '.' ? path.basename(path.resolve()) : targetDir
//.... 接下文
}
确定项目名称,选择框架的实现
typescript
// ...接上文
let result: prompts.Answers<
'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'
>
try {
result = await prompts(
[
// 这里面是关于projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant 这几项配置的检查及提示
// projectName 项目名称,也是生成的文件夹名称
// overwrite 如果已存在同目录,是否覆盖原目录
// framework 框架名称
// variant 项目的变体, 在选择完框架之后,提供选择框架下的变体 详细内容可查看 FRAMEWORKS 变量
// ... 代码暂省略,具体可查源码
],
{
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled')
},
},
)
} catch (cancelled: any) {
console.log(cancelled.message)
return
}
// user choice associated with prompts
const { framework, overwrite, packageName, variant } = result
上面的代码中,实现的具体功能也就是下图。
根据项目名称创建项目文件的实现
typescript
// 解析当前目录的路径(当前路径/{targetDir})
const root = path.join(cwd, targetDir)
if (overwrite === 'yes') {
// 删除已存在的目录
emptyDir(root)
} else if (!fs.existsSync(root)) {
//创建以项目名为名的文件夹
fs.mkdirSync(root, { recursive: true })
}
根据选择的框架,生成模板文件的实现
typescript
// determine template
let template: string = variant || fra mework?.name || argTemplate
let isReactSwc = false
if (template.includes('-swc')) {
isReactSwc = true
template = template.replace('-swc', '')
}
// 使用 Node.js 的环境变量 npm_config_user_agent 来获取用户的用户代理,
// 并根据用户代理中的信息解析出使用的包管理器以及版本号,默认使用'npm'
/**
* 解析出使用的包管理器名称的方法
* function pkgFromUserAgent(userAgent: string | undefined) {
* if (!userAgent) return undefined
* const pkgSpec = userAgent.split(' ')[0]
* const pkgSpecArr = pkgSpec.split('/')
* return {
* name: pkgSpecArr[0],
* version: pkgSpecArr[1],
* }}
*/
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')
//判断选择的variants是否包含自定义命令
const { customCommand } =
FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}
if(customCommand) {
//....
// 如果有自定义命令,则使用对应的工具执行命令(npm | pnpm | yarn | bun)
// 若要查看是否包含自定义命令 可查看FRAMEWORKS变量
}
// 解析模板文件所在的目录路径 也就是同级目录下以template-开头的文件夹
const templateDir = path.resolve(
fileURLToPath(import.meta.url),
'../..',
`template-${template}`,
)
//定义一个write函数,将模板文件的内容写入目标文件
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)
}
}
//解析模板目录下的所有文件
// 遍历文件夹中的所有文件,并将除了package.json外的模板文件写入到目标文件夹中
const files = fs.readdirSync(templateDir)
for (const file of files.filter((f) => f !== 'package.json')) {
write(file)
}
// 读取package.json文件内容解析为json对象
// 修改name属性为用户输入的packageName, 这也是为什么需要把package.json单独处理的原因,保证里面的name值为用户输入的值。
const pkg = JSON.parse(
fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8'),
)
pkg.name = packageName || getProjectName()
write('package.json', JSON.stringify(pkg, null, 2) + '\n')
//...后面是一些关于执行完之后的提示信息,此处省略
总结
通过阅读源码,让我感觉很受用的地方有两个。
- 首先是通过
prompts
工具对命令行的交互,之前我对命令行交互的部分总觉得比较复杂,但通过了解这个工具,我发现实际上这些操作并不复杂。prompts
可以帮助我们轻松地实现与用户的交互,并根据用户的输入执行相应的操作。 - 其次是对于
fs
模块的使用。通过模板文件,我们可以方便地读取和写入文件,创建出我们需要的基础文件。我意识到,对文件的操作是一个 CLI 工具的核心。所以,了解并熟练运用fs
模块是很重要的。 整个文件阅读下来让我感受到了一个 CLI工具并没有想象中的那么复杂。总的来说,它的整体流程主要包括文件的读写和命令行交互。熟悉了fs
和path
模块的使用后,,那么自己动手实现一个CLI工具也并不是难事。