-
本文参加了由 公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
-
这是源码共读的第37期,链接:传送门。
-
撰写日期 2023-07-10,源码 create-vite v4.3.2
vite 源码库下载、依赖安装
如果使用 Windows Git-Bash
安装也许你会遇到以下问题:
shell
node-pre-gyp info it worked if it ends with ok
│ node-pre-gyp info using node-pre-gyp@1.0.10
│ node-pre-gyp info using node@16.13.0 | win32 | x64
│ node-pre-gyp info check checked for "C:\xxx\vite\node_modules\.pnpm\bcrypt@5.1.0\node_modules\bcrypt\lib\binding\napi-v3\bcrypt_lib.node" (not found)
│ node-pre-gyp http GET https://github.com/kelektiv/node.bcrypt.js/releases/download/v5.1.0/bcrypt_lib-v5.1.0-napi-v3-win32-x64-unknown.tar.gz
│ node-pre-gyp ERR! install request to https://github.com/kelektiv/node.bcrypt.js/releases/download/v5.1.0/bcrypt_lib-v5.1.0-napi-v3-win32-x64-unknown.tar.gz failed, reason: read ECONNRESET
│ node-pre-gyp WARN Pre-built binaries not installable for bcrypt@5.1.0 and node@16.13.0 (node-v93 ABI, unknown) (falling back to source compile with node-gyp)
│ node-pre-gyp WARN Hit error request to https://github.com/kelektiv/node.bcrypt.js/releases/download/v5.1.0/bcrypt_lib-v5.1.0-napi-v3-win32-x64-unknown.tar.gz failed, reason: read ECONNRESET
│ node-pre-gyp ERR! build error
│ node-pre-gyp ERR! stack Error: Failed to execute 'node-gyp.cmd clean' (Error: spawn node-gyp.cmd ENOENT)
│ node-pre-gyp ERR! stack at ChildProcess.<anonymous> (C:\DevCode\Github源码\vite\node_modules\.pnpm\@mapbox+node-pre-gyp@1.0.10\node_modules\@mapbox\node-pre-gyp\lib\util\compile.js:83:23)
│ node-pre-gyp ERR! stack at ChildProcess.emit (node:events:390:28)
│ node-pre-gyp ERR! stack at Process.ChildProcess._handle.onexit (node:internal/child_process:288:12)
│ node-pre-gyp ERR! stack at onErrorNT (node:internal/child_process:477:16)
│ node-pre-gyp ERR! stack at processTicksAndRejections (node:internal/process/task_queues:83:21)
│ node-pre-gyp ERR! System Windows_NT 10.0.19045
│ node-pre-gyp ERR! command
这是 bcrypt
依赖平台兼容性导致的,具体原因请看 Github,我的解决办法也很简单,换 Powershell 开启管理员模式安装,work it !
查看 packages/create-vite 源码库 README
根据 README 文档,我们可以看到 cva (即 create-vite)的使用方法如下:
shell
# with NPM
npm create vite@latest
# with Yarn
yarn create vite
# with PNPM
pnpm create vite
指定模板、和生成的目标文件夹
shell
npm create vite@latest my-vue-app --template vue
# npm 7+, extra double-dash is needed:
npm create vite@latest my-vue-app -- --template vue
# yarn
yarn create vite my-vue-app --template vue
# pnpm
pnpm create vite my-vue-app --template vue
查看 package.json
json
{
"bin": {
"create-vite": "index.js",
"cva": "index.js"
},
// others...
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
// others...
"devDependencies": {
"@types/minimist": "^1.2.2",
"@types/prompts": "^2.4.4",
"cross-spawn": "^7.0.3", // 跨平台的 node.js spawn/spawnSync 方案库
"kolorist": "^1.8.0", // 轻量的终端着色方案库
"minimist": "^1.2.8", // 轻量、强大的终端参数解析库
"prompts": "^2.4.2" // 轻量、强大、开发友好的终端交互库,多用于 cli 构建
},
}
查看目录,可以很容易得知,入口文件是 index.ts
,运行时方便调试 typescript
的源码可以使用 esno
;打开 vscode 的 JavaScript Debug Terminal 面板,提前打好断点,输入以下命令
shell
# 需先进入到 vite/packages/create-vite 文件目录
npx esno src/index.ts
# 或这样,esno 的别名 "tsx"
npx tsx src/index.ts
调试示例
我们在 index.ts 文件 init
方法是核心方法里,打下断点,输入上一步的命令,就会发现执行后会在对应位置停住
纵览主文件大纲
前面是变量,后面是那部分从命名上看,都是各种处理文件的方法、以及 cva 的交互主入口 init 方法;
接下来逐一深入源码查看一番
- init 方法主入口
ts
// 为了方便理解,适当有删减
async function init () {
// 从 terminal 输入得到生成的目标文件夹
const argTargetDir = formatTargetDir(argv._[0])
// 从 terminal 输入得到需要生成的模板类型
// 支持 vanilla/vue/react/preact/lit/svelte 极其他变体
const argTemplate = argv.template || argv.t
/**
* projectName 是你需要生成的目标文件夹名
* overwrite 判断是否覆盖已存在的同名生成目标件文件夹
* framework 选择生成的库模板,如 vanila/vue/react 等
* variant 选择需要生成的其他变体变体,如 vue-ts/react-ts 等
*/
let result: prompts.Answers<'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'>
try {
result = await prompts(
[
// prompts 的流程步骤省略,具体看源码
// 核心就是通过与用户交互得到 'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant' 这几个值
],
{
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
const root = path.join(cwd, targetDir)
if (overwrite) {
// 选择了覆盖同名生成目标文件夹,则清空删除原来文件目录
emptyDir(root)
} else if (!fs.existsSync(root)) {
// 反之创建目录
fs.mkdirSync(root, { recursive: true })
}
// 用户交互选择生成的模板
let template: string = variant || framework?.name || argTemplate
// node.js 包管理工具信息(即npm/yarn/pnpm)
// 可以从 process.env.npm_config_user_agent 获取
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')
const templateDir = path.resolve(
fileURLToPath(import.meta.url),
'../..',
`template-${template}`,
)
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)
for (const file of files.filter((f) => f !== 'package.json')) {
// 从模板目录,逐个遍历文件,然后写入目标目录
write(file)
}
// more...
}
主流程就是如上注释解析那样,下面深入 cva d的文件操作系列方法
- formatTargetDir
ts
// 对目标目录名进行初步格式化
function formatTargetDir(targetDir: string | undefined) {
// 去掉首尾空格、末尾的 '/' 字符
return targetDir?.trim().replace(/\/+$/g, '')
}
- copy
ts
function copy(src: string, dest: string) {
const stat = fs.statSync(src)
if (stat.isDirectory()) {
// 文件夹使用 copyDir
copyDir(src, dest)
} else {
// 文件使用 fs.copyFileSync
fs.copyFileSync(src, dest)
}
}
- copyDir
ts
// 从下面源码看到,copyDir 操作也是去遍历目录,进行文件的 copy
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)
}
}
- emptyDir
ts
function emptyDir(dir: string) {
if (!fs.existsSync(dir)) {
return
}
for (const file of fs.readdirSync(dir)) {
// 跳过 .git 文件夹
if (file === '.git') {
continue
}
// 清空删除文件夹
fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
}
}
- isEmpty
ts
function isEmpty(path: string) {
// 判断文件夹是否为空,.git 目录特殊处理
const files = fs.readdirSync(path)
return files.length === 0 || (files.length === 1 && files[0] === '.git')
}
- editFile
ts
// 这个方法是修改文件内容,重新回写到文件内;用于 --template=react-swc 的特殊处理
// 实际上 --template=react-swc 从 react-ts 模板变体而来,只是修改某些 package.json 依赖
function editFile(file: string, callback: (content: string) => string) {
const content = fs.readFileSync(file, 'utf-8')
fs.writeFileSync(file, callback(content), 'utf-8')
}
- pkgFromUserAgent
ts
// 这个方法用来解析获取,当前使用的包管理工具和版本号,如 NPM6/NPM7,Yarn1/Yarn2 等
// 实际上就是解析上文提到的 process.env.npm_config_user_agent 环境变量
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],
}
}
- isValidPackageName
ts
// 判断生成目标文件名是否有效
function isValidPackageName(projectName: string) {
return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(
projectName,
)
}
- toValidPackageName
ts
// 对目标目录名进行有效化转换
function toValidPackageName(projectName: string) {
return projectName
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]/, '')
.replace(/[^a-z\d\-~]+/g, '-')
}
调试 unit-test
如果想调试单元测试,可以打开 JavaScript Debug Terminal ,输入以下命令:打开 JavaScript Debug Terminal,输入以下命令:
bash
./node_modules/.bin/vitest run packages/create-vite/
总结
通过对 cva 源码的调试阅读,发现原来自己实现一个简易的 cli 工具也不是很难,借助社区强大的轻量工具(如:prompts,kolorist...),剩下就是对 node.js 文件系统的各种操作了,这些需要扎实的 node.js-fs 基本功,文件操作由自己实现可以轻量可控制,但需要多注意跨平台的兼容性就是了!