Vite 源码包之 create-vite
项目地址:https://github.com/WalkAlone0325/cli
create-vite
创建模版过程解析
理解和思路
我们使用 vite
创建项目 的时候使用 pnpm create vite
来进行初始化,并经历如下流程:
pnpm create vite
- 输入项目名称:
vue-project
- 选择框架:
Vue
- 选择变体(语言、自定义的模版等):
TypeScript
- 在对应的目标文件夹下生成
脚手架项目模版
- 提示完成,并给出对应的提示命令
核心包
prompts
(实现命令行交互式界面)minimist
(命令行参数解析工具)kolorist
(颜色工具)unbuild
(打包工具)esno
(基于esbuild
的TS/ESNext
的Node.js
运行时,直接运行.ts文件
)
实现
基础配置
ts
// 获取命令行的解析参数
const argv = minimist<{
t?: string // --t
template?: string // --template
}>(process.argv.slice(2), { string: ['_'] })
// 获取当前路径
const cwd = process.cwd()
type ColorFunc = (str: string | number) => string
type Framework = {
name: string
display: string
color: ColorFunc
variants: FrameworkVariant[]
}
type FrameworkVariant = {
name: string
display: string
color: ColorFunc
customCommand?: string
}
// 模版
const FRAMEWORKS: Framework[] = [
{
name: 'vue',
display: 'Vue',
color: green,
variants: [
{
name: 'vue',
display: 'JavaScript',
color: yellow
},
{
name: 'vue-ts',
display: 'TypeScript',
color: blue
}
]
}
]
// ['vue', 'vue-ts'] 获取对应的模版名称,方便查找相应模版
const TEMPLATES = FRAMEWORKS.map(
(f) => (f.variants && f.variants.map((v) => v.name)) || [f.name]
).reduce((a, b) => a.concat(b), [])
// 对平台不同,解析不一样的 . 行为处理
const renameFiles: Record<string, string | undefined> = {
_gitignore: '.gitignore'
}
// 定义 默认的目标文件夹名称
const defaultTargetDir = 'mine-cli-project'
核心方法
- 定义交互执行命令:
输入项目名称
=>选择框架
=>选择模版
- 自动写入模版
- 写入完成提示信息
ts
async function init() {
const argTargetDir = formatTargetDir(argv._[0])
// --template / --t xxx => xxx
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'
>
try {
// 实现交互
}
catch (cancelled: any) {
console.log(cancelled.message)
return
}
}
// 运行和抛出错误信息
init().catch(e => {
console.error(e)
})
实现交互
ts
result = await prompts(
[
{
type: argTargetDir ? null : 'text',
name: 'projectName',
// message: reset('Project name:'),
message: reset('请输入项目名称:'),
initial: defaultTargetDir,
// 对输入的项目名称进行规范
onState: (state) => {
targetDir = formatTargetDir(state.value) || defaultTargetDir
}
},
// 判断输入,为 . ,判断是否为空路径,是否覆盖,否则直接取输入为项目名称
{
type: () =>
!fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm',
name: 'overwrite',
message: () =>
(targetDir === '.'
? 'Current directory'
: // : `Target directory "${targetDir}"`) +
`目标文件夹 "${targetDir}"`) +
// ` is not empty. Remove existing files and continue?`
` 不是空文件夹。请选择覆盖或退出。`
},
// 如果选择不覆盖,直接退出界面,取消操作
{
type: (_, { overwrite }: { overwrite: boolean }) => {
if (overwrite === false) {
// 不覆盖直接 取消操作
// throw new Error(red('✖') + 'Operation cancelled')
throw new Error(red('✖') + '取消操作')
}
return null
},
name: 'overwriteChecker'
},
// 检测包名称
{
type: () => (isVaildPackageName(getProjectName()) ? null : 'text'),
name: 'packageName',
message: reset('Package name:'),
inactive: () => toValidPackageName(getProjectName()),
// 检测是否为有效的包名称
validate: (dir) =>
isVaildPackageName(dir) || 'Invalid package.json name'
},
// 第一层 选择的框架,如果是选择的框架模版,直接跳过,否则是 select,继续选择 框架模版
{
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: `
`"${argTemplate}" 不是一个有效的模版,请选择如下: `
)
: // : reset('Select a framework:'),
reset('请选择一个框架:'),
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'),
message: reset('请选择具体模版:'),
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')
throw new Error(red('✖') + '取消操作')
}
}
)
写入模版
- 拿到命令交互后的结果信息
- 根据
overwrite
属性清空
或创建
目标文件夹 - 从结果信息里确认对应的模版,并找到模版所在的文件夹
- 开始写入模版
- 递归写入文件和创建文件夹
- 单独处理
package.json
,转为json
格式,修改name
、version
等相关信息 - 获取环境使用的包管理工具
- 写入完成,根据包管理器提示信息
ts
// 拿到命令交互后返回的结果信息
const { framework, overwrite, packageName, variant } = result
// 根路径
const root = path.join(cwd, targetDir)
// 清空或者创建文件夹
if (overwrite) {
// 强制清空文件夹
emptyDir(root)
} else if (!fs.existsSync(root)) {
// recursive 递归的创建文件夹
fs.mkdirSync(root, { recursive: true })
}
// 确定模版
const template: string = variant || framework || argTemplate
// 获取包管理信息
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
// 获取包管理器名称
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
// 脚手架项目在 root 写入模版开始
console.log(`\n正在写入模版到 ${root} \n请稍等...`)
// 找到放置模版文件夹的路径
const templateDir = path.resolve(
fileURLToPath(import.meta.url),
'../../templates',
`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)
}
}
// 写入文件 此处过滤掉 package.json 是做单独处理:修改name等
const files = fs.readdirSync(templateDir)
for (const file of files.filter((f) => f !== 'package.json')) {
write(file)
}
// 将 package.json 的内容转成 json
const pkg = JSON.parse(
fs.readFileSync(path.join(templateDir, 'package.json'), 'utf-8')
)
// 更改 package.json 的 name
pkg.name = packageName || getProjectName()
// 将 package.json 的 name 改为 项目名称
write('package.json', JSON.stringify(pkg, null, 2))
// 写入完成后的提示信息
console.log(`\n写入完成. 请运行如下命令:\n`)
if (root !== cwd) {
console.log(lightGreen(` cd ${path.relative(cwd, root)}`))
}
switch (pkgManager) {
case 'yarn':
console.log(lightGreen(' yarn'))
console.log(lightGreen(' yarn dev'))
break
default:
console.log(lightGreen(` ${pkgManager} install`))
console.log(lightGreen(` ${pkgManager} run dev`))
break
}
console.log()
工具函数
ts
// 规范处理文件夹名称
function formatTargetDir(targetDir: string | undefined) {
return targetDir?.trim().replace(/\/+$/g, '')
}
// 复制模版
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) {
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)
}
}
// 是否为合规发 packageName
function isVaildPackageName(projectName: string) {
return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(
projectName
)
}
// 转换为合规的 packageName
function toValidPackageName(projectName: string) {
return projectName
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]/, '')
.replace(/[^a-z0-9-~]+/g, '-')
}
// 判断是否是空文件夹
function isEmpty(path: string) {
const files = fs.readdirSync(path)
return files.length === 0 || (files.length === 1 && files[0] === '.git')
}
// 强制删除成为空文件夹
function emptyDir(dir: string) {
if (!fs.existsSync(dir)) {
return
}
for (const file of fs.readdirSync(dir)) {
if (file === '.git') {
continue
}
fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
}
}
// 获取使用的包管理工具和版本号
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]
}
}