在创建 Vite 项目时,通常会使用npm create vite@latest
指令来进行初始化。然而,你可能只是简单地复制并执行该指令,而没有了解其背后的初始化模板过程。今天,我们来了解一下这个过程,相信看过之后你也可以编写一个指令来安装你的项目模板。
npm create
是什么
npm create
实际上就是我们常用的npm init
。在 npm 文档中并没有npm create
独立的相关文档,但你在npm init
文档中是能够找到的。
有点离谱,原来它还有这么多别名。所以,npm create vite@latest
和下面这些指令都是等效的:
npm init vite@latest
npm innit vite@latest
npx create-vite@latest
使用 npm create
实际上会转换成 npm exec
(npm x
),从而实现安装包和执行命令的功能,即:
npm create vite@latest -> npm exec create-vite@latest
不管这些指令怎么变,实际上都是执行create-vite包的指令。
create-vite
指令
接下来看看create-vite包是如何初始化项目的,可以在 Github 找到 Vite 的源码 https://github.com/vitejs/vite。
可以看到,目录下有一系列模板和一个指令入口文件,不难想到,执行指令时就是把这些模板拷贝到你的项目下的。
可以在package.json找到指令名称和文件入口。
"bin": {
"create-vite": "index.js",
"cva": "index.js"
}
然后来看看入口文件index.js。
#!/usr/bin/env node
import './dist/index.mjs'
可执行脚本通常需要在开头加上#!/usr/bin/env node
这个特殊注释,用于声明使用 Node 来执行。下一行是导入./dist/index.mjs
文件,它是打包后的文件,而源码则位于./src/index.ts
文件内。
init()
函数
整个操作的执行都在 init
函数中,在理解代码逻辑时,可以从 init
函数入手。为了方便说明,下面我会对部分代码进行简化处理。
// 获取目录名称参数
const argTargetDir = formatTargetDir(argv._[0])
// 获取模板参数
const argTemplate = argv.template || argv.t
// 默认目录名 vite-project
let targetDir = argTargetDir || defaultTargetDir
const getProjectName = () =>
targetDir === '.' ? path.basename(path.resolve()) : targetDir
let result // 交互命令选择结果
try {
result = await prompts(
// prompts 交互命令
)
} catch (cancelled: any) {
}
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
// ....
// 读取模板的文件,除package.json都写入项目目录
const files = fs.readdirSync(templateDir)
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'),
)
// 更改name
pkg.name = packageName || getProjectName()
// 写入package.json文件到项目
write('package.json', JSON.stringify(pkg, null, 2) + '\n')
上述内容大致介绍了整个模板创建的流程,具体的细节可以自行查看代码。接下来说下使用的包和命令交互流程。
minimist 包解析参数
minimist 包用于解析预设的参数,上面提到的目录名和模板等。
import minimist from 'minimist'
const argv = minimist(process.argv.slice(2), { string: ['_'] })
kolorist 包输出颜色
kolorist 包用于设置命令行输出不同颜色。
import { yellow } from 'kolorist'
// console.log(yellow('Vanilla'));
// 源码中FRAMEWORKS定义中添加color函数,后面交互定义时调用
const FRAMEWORKS = [
{
name: 'vanilla',
display: 'Vanilla',
color: yellow,
},
// ...
]
prompts 包定义交互
定义交互命令使用的 prompts 包,就是上面代码省略的部分。prompts 函数传入一个数组,包含整个流程的交互定义,这个包参数比较多,不详细说明。
import prompts from 'prompts'
result = await prompts([
{
type: argTargetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: (state) => {
targetDir = formatTargetDir(state.value) || defaultTargetDir
},
},
])
const { packageName } = result
console.log(packageName)
cross-spawn 包生成子进程
在我们选择模板时,有一个Others选项。选择完模板后,判断模板参数中是否包含customCommand
。如果包含,则根据当前使用的包管理器替换成对应指令,然后开启子进程执行。
const { customCommand } = FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}
if (customCommand) {
const fullCustomCommand = customCommand
.replace(/^npm create /, () => {
if (pkgManager === 'bun') {
return 'bun x create-'
}
return `${pkgManager} create `
})
.replace('@latest', () => (isYarn1 ? '' : '@latest'))
.replace(/^npm exec/, () => {
if (pkgManager === 'pnpm') {
return 'pnpm dlx'
}
if (pkgManager === 'yarn' && !isYarn1) {
return 'yarn dlx'
}
if (pkgManager === 'bun') {
return 'bun x'
}
return 'npm exec'
})
const [command, ...args] = fullCustomCommand.split(' ')
const replacedArgs = args.map((arg) => arg.replace('TARGET_DIR', targetDir))
const { status } = spawn.sync(command, replacedArgs, {
stdio: 'inherit',
})
process.exit(status ?? 0)
}
最后,利用上面提到的流程和 npm 包,你是否也能够写一个这样的指令?把一些公共模板整理一下,每次新建项目时就可以直接用指令生成了。