欢迎关注我的公号『 前端我废了 』,查看更多文章!!!
我们从 vite 的官方文档中看到,可以使用 npm/yarn/pnpm create
命令来快速初始化一个基于 Vite 的项目;其实很多框架或库都会开发相应的脚手架工具,用于快速初始化项目,例如 create-vite、create-vue、create-react-app 等;这是如何实现的呢?本文将从头分析 create-vite
创建一个 Vite 项目流程的原理。
init
/ create
命令npm v6 版本给
init
命令添加了别名create
,俩命令一样的
npm init
命令除了可以用来创建 package.json 文件,还可以用来执行一个包的命令;它后面还可以接一个
参数。该命令格式:
npm init <initializer>
参数 initializer
是名为 create-
的 npm 包 ( 例如 create-vite
),执行 npm init
将会被转换为相应的 npm exec
操作,即会使用 npm exec
命令来运行 create-
包中对应命令 create-
(package.json 的 bin 字段指定),例如:
# 使用 create-vite 包的 create-vite 命令创建一个名为 my-vite-project 的项目
$ npm init vite my-vite-project
# 等同于
$ npm exec create-vite my-vite-project
npm create vite
发生了什么?当我们执行 npm create vite
时,会先补全包名为 create-vite
,转换为使用 npm exec
命令执行,即 npm exec create-vite
,接着执行包对应的 create-vite
命令(如果本地未安装 create-vite
包则先安装再执行);了解命令更多信息 init 、exec 。
create-vite 包源码在 vite 仓库 packages 文件夹下,从 create-vite 包的 package.json 文件的 bin
字段,可以看到配置了两个命令名 create-vite
、cva
以及对应的映射文件,也就是当我们执行命令时,会去执行对应的映射文件。cva
命令跟 create-vite
命令效果是一样的,我们也可以用 cva
命令来初始化 Vite 项目。
接下来,我们就来分析下 create-vite 的执行流程及源码。
create-vite
, vite
和一些 vite 内置插件;安装依赖;终端进到 packages/create-vite 目录下执行 pnpm i
,也可以直接在根目录使用 pnpm 过滤器选项 --filter
,pnpm -—filter create-vite i
;
cd packages/create-vite
,因为源码使用 ts 编写,所以我们需要一个可以运行 ts 的执行器,可以使用 tsx / esno;从 build.config.ts
文件(unbuild 打包工具的配置文件)可以看到入口文件为 src/index.ts
;所以我们在调试终端执行 npx tsx src/index.ts
就可以打断点调试啦。入口文件 src/index.ts
的主函数为 init 函数,init 函数的执行流程图如下:
流程图链接
根据上面的执行流程,我们详细看每个步骤的代码实现(init 函数里面);
执行命令时,如果命令行没指定项目名参数,则提示用户输入项目名称(默认值为 vite-project);有则跳过此步骤(例如 npm create vite my-vite-app ,指定项目名为 my-vite-app);
// 用于创建交互提示
import prompts from 'prompts'
// 用于设置输入输出颜色
import { reset, red, blue } from 'kolorist'
// 默认的项目名(目录名)
const defaultTargetDir = 'vite-project'
async function init() {
// 获取命令行项目名参数, 例如 npm create vite my-vite-project,则 argTargetDir 值为 my-vite-project
const argTargetDir = formatTargetDir(argv._[0])
// 目标目录,命令行未指定则使用默认值
let targetDir = argTargetDir || defaultTargetDir
// 创建交互提示
await prompts(
[
{
// 提示用户输入项目名,如果命令行有指定项目名,则 type 赋值为 null,就会跳过此步骤
type: argTargetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: (state) => {
targetDir = formatTargetDir(state.value) || defaultTargetDir
},
},
// 省略其他步骤代码...
]
)
}
// 格式化目录名,将结尾斜杠字符 / 去掉
function formatTargetDir(targetDir: string | undefined) {
return targetDir?.trim().replace(/\/+$/g, '')
}
相关包:
不存在则跳过此步骤,存在则提示用户存在相同目录名,并提示是否删除,用户确定删除则继续下一步,否则退出当前执行程序;
async function init() {
await prompts(
[
// ...
{
type: () =>
// 判断是否已存在目录,存在则提示是否删除,不存在则跳过此步骤
!fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm',
name: 'overwrite',
message: () =>
(targetDir === '.'
? 'Current directory'
: `Target directory "${targetDir}"`) +
` is not empty. Remove existing files and continue?`,
},
{
type: (_, { overwrite }: { overwrite?: boolean }) => {
// 上一步如果选择取消,则退出当前执行程序
if (overwrite === false) {
throw new Error(red('✖') + ' Operation cancelled')
}
return null
},
name: 'overwriteChecker',
},
// 省略其他步骤代码...
]
)
}
因为项目名后续需要作为包名(package.json 的 name 字段),所以会校验下,如果上一步输入的项目名作为包名不合法,则会自动转为和法治,并提示用户确认;
async function init() {
// 获取目录名
const getProjectName = () =>
targetDir === '.' ? path.basename(path.resolve()) : targetDir
await prompts(
[
// ...
{
// 如何不合法,则提示用户,否则跳过此步骤
type: () => (isValidPackageName(getProjectName()) ? null : 'text'),
name: 'packageName',
message: reset('Package name:'),
// 转为合法的包名作为初始值
initial: () => toValidPackageName(getProjectName()),
validate: (dir) =>
isValidPackageName(dir) || 'Invalid package.json name',
},
// 省略其他步骤代码...
]
)
}
// 校验包名是否合法
function isValidPackageName(projectName: string) {
return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(
projectName,
)
}
// 转为合法的包名
function toValidPackageName(projectName: string) {
return projectName
.trim()
.toLowerCase() // 转小写
.replace(/\s+/g, '-') // 空格转为连字符
.replace(/^[._]/, '') // . _ 字符转为空格
.replace(/[^a-z\d\-~]+/g, '-') // 非字母 a-z,非数字,非字符 - ~ 转为连字符
}
create-vite 还提供了一个命令行选项 --template
/ -t
,让用户指定使用的模板,如果指定了则判断模板是否存在,存在跳过此步骤,不存在或未指定模板则提示选择;
// 解析命令行参数
import minimist from 'minimist'
import { blue, yellow } from 'kolorist'
// 获取命令行选项值
const argv = minimist<{
t?: string
template?: string
}>(process.argv.slice(2), { string: ['_'] })
// 获取当前工作目录路径
const cwd = process.cwd()
// 所有内置模板
const FRAMEWORKS: Framework[] = [
{
name: 'vanilla',
display: 'Vanilla',
color: yellow,
variants: [
{
name: 'vanilla',
display: 'JavaScript',
color: yellow,
},
{
name: 'vanilla-ts',
display: 'TypeScript',
color: blue,
},
],
},
// 省略其他...
]
// 所有模板
const TEMPLATES = FRAMEWORKS.map(
(f) => (f.variants && f.variants.map((v) => v.name)) || [f.name],
).reduce((a, b) => a.concat(b), [])
async function init() {
// create-vite 提供了命令行选项 --template 或 -t 让用户指定模板
const argTemplate = argv.template || argv.t
await prompts(
[
// ...
{
// 若用户通过命令行选项指定了模板且存在此模板,则跳过此步骤,否则提供选择
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,
}
}),
},
// 省略其他步骤代码...
]
)
}
相关包:
例如前面选择 vue 框架,则会提供四种变体选择: JavaScript、TypeScript、使用 create-vue 自定义选择集成配置、Nuxt;
// 所有内置模板
const FRAMEWORKS: Framework[] = [
{
name: 'vue',
display: 'Vue',
color: green,
variants: [ // 所有变体选项
{
name: 'vue',
display: 'JavaScript',
color: yellow,
},
{
name: 'vue-ts',
display: 'TypeScript',
color: blue,
},
{
name: 'custom-create-vue',
display: 'Customize with create-vue ↗',
color: green,
customCommand: 'npm create vue@latest TARGET_DIR',
},
{
name: 'custom-nuxt',
display: 'Nuxt ↗',
color: lightGreen,
customCommand: 'npm exec nuxi init TARGET_DIR',
},
],
},
// 省略其他...
]
async function init() {
await prompts([
{
// 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,
}
}),
},
// 省略其他步骤代码...
])
}
如果第二步骤判断存在已有目录的话,则会递归删除已有目录,否则递归创建新目录(用户在第一步骤中也可以输入路径形式去创建项目目录,例如 a/b
);
import fs from 'node:fs'
// 当前目录路径
const cwd = process.cwd()
const defaultTargetDir = 'vite-project'
async function init() {
const argTargetDir = formatTargetDir(argv._[0])
let targetDir = argTargetDir || defaultTargetDir
// 省略其他代码...
// 终端交互用户的选择结果
const { overwrite } = result
// 合成绝对路径
const root = path.join(cwd, targetDir)
// 如果用户选择删除,则 overwrite 为 true
// PS: 其实这里我感觉没必要再做判断了,前面第 2 步骤如果选择取消删除的话,就会退出程序了
if (overwrite) {
// 删除已有目录
emptyDir(root)
} else if (!fs.existsSync(root)) {
// 递归创建新目录,因为用户输入的目录也可以是路径,例如 a/b/xxx
fs.mkdirSync(root, { recursive: true })
}
}
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 })
}
}
如果在第五步骤(选择变体)不存在自定义命令配置(customCommand),则跳过此步骤,否则去执行各个框架提供的初始化命令(例如前面框架选择 vue,变体选择 Customize with create-vue ↗, 则使用 create-vue 包的命令去初始化),执行完退出当前程序;
import spawn from 'cross-spawn'
// 默认目录名
const defaultTargetDir = 'vite-project'
async function init() {
const argTargetDir = formatTargetDir(argv._[0])
// 通过命令行选项 --template 或 -t 指定的模板
const argTemplate = argv.template || argv.t
// 目标目录
let targetDir = argTargetDir || defaultTargetDir
// 省略其他代码...
// 终端交互用户的选择结果
const { framework, packageName, variant } = result
// 确定模板
const template: string = variant || framework?.name || argTemplate
// 通过 npm 内置环境变量 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 { customCommand } =
FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}
// 如果存在自定义命令选项,执行该命令
if (customCommand) {
// 生成完整的命令,例如 npm create vite@latest vite-project
const fullCustomCommand = customCommand
// 替换 TARGET_DIR 字符串为目标目录
.replace('TARGET_DIR', targetDir)
// 替换为用户使用的包管理器
.replace(/^npm create/, `${pkgManager} create`)
// Yarn 1.x 版本不支持指定 @latest 标签,所以需去掉
.replace('@latest', () => (isYarn1 ? '' : '@latest'))
.replace(/^npm exec/, () => {
// 优先使用 `pnpm dlx` 或 `yarn dlx` 执行包命令
if (pkgManager === 'pnpm') {
return 'pnpm dlx'
}
if (pkgManager === 'yarn' && !isYarn1) {
return 'yarn dlx'
}
return 'npm exec'
})
// 拆分命令,例如 “npm create vite@latest vite-project” => ["npm", "create", "vite@latest", "vite-project"]
const [command, ...args] = fullCustomCommand.split(' ')
// 同步执行命令
const { status } = spawn.sync(command, args, {
stdio: 'inherit',
})
// 退出当前执行中的程序
process.exit(status ?? 0)
}
}
// 通过 npm ua 信息获取使用的包管理器及版本
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],
}
}
根据前面用户通过命令行指定或终端交互选择的模板,获取对应模板绝对路径,写入到用户的目录路径;修改 package.json 的 name 字段值为第一步骤输入的项目名;最后提示一些信息,结束流程 。
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import spawn from 'cross-spawn'
async function init() {
// 省略其他代码...
const root = path.join(cwd, targetDir)
// 解析生成选择模板的目录绝对路径
// fileURLToPath 将文件URL转为绝对路径
// 例如 'file:///Users/jizai/juejin/vite/packages/create-vite/src/index.ts'
// => '/Users/jizai/juejin/vite/packages/create-vite/src/index.ts'
const templateDir = path.resolve(
fileURLToPath(import.meta.url),
'../..',
`template-${template}`,
)
// 将用户选择的模板目录下的文件写入到目标路径下 targetPath
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'),
)
// 设置 name 字段为前面输入的项目名
pkg.name = packageName || getProjectName()
write('package.json', JSON.stringify(pkg, null, 2))
// 打印一些提示信息
console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
console.log(` cd ${path.relative(cwd, root)}`)
}
switch (pkgManager) {
case 'yarn':
console.log(' yarn')
console.log(' yarn dev')
break
default:
console.log(` ${pkgManager} install`)
console.log(` ${pkgManager} run dev`)
break
}
}
// 拷贝整个目录下的所有文件到目标路径
function copy(src: string, dest: string) {
// 获取文件信息
const stat = fs.statSync(src)
if (stat.isDirectory()) {
// 若为目录,则调用拷贝目录函数
copyDir(src, dest)
} else {
// 否则,拷贝文件
fs.copyFileSync(src, dest)
}
}
// 拷贝目录,首先会递归创建目录,然后将拷贝文件到对应目录下
function copyDir(srcDir: string, destDir: string) {
// destDir 递归创建目录
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)
}
}
相关包:
一句话总结 create-vite 原理:”通过创建交互式提示,根据交互结果获取模板,写入到指定目录”。通过深入学习 create-vite 包的原理及其源码,可以看到实现并没有想象的复杂,况且有丰富的包库可供使用,自己以后实现类似需求的时候可用来做下参考。