一、先看一下create-vite包
vite仓库 地址:vitejs/vite: Next generation frontend tooling. It's fast! (github.com)
create-vite包位于vite仓库下的packages中
二、npm init vite@latest命令
通过查阅npm官方文档可见
npm install后面还有一个参数
<initializer>
,可以用来安装一个新的或者已经存在的npm包。<initializer>
对应的npm包的名字是create-<initializer>
,这个npm包会被npm-exec
执行,然后包中的package.json
中bin
字段对应的脚本会执行。
create-vite包的package.json中bin如下:
{
"bin": {
"create-vite": "index.js",
"cva": "index.js"
},
}
可见此时会执行index.js,实际上就是打包之后的src/index.ts
二、index.ts文件执行过程
1.index.ts中导入的依赖
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'
node:fs
:node中用于读写文件的模块node:path
:node中用于处理和转换路径的模块cross-spawn
:用于跨平台地执行子进程,保证子进程在不同系统上都可运行minimist
:用于解析命令行参数prompts
:用于在命令行界面进行交互式的用户输入kolorist
:用于在命令行中打印带有颜色的文本
2.minimist获取命令行输入
const argv = minimist<{
t?: string
template?: string
}>(process.argv.slice(2), { string: ['_'] })
process.argv.slice(2)
: 去除命令行中的node
和路径{ string: ['_'] }
: 匹配非选项参数放入_
数组中
假设在命令行中输入了如下的值:
node index.js -t example --template demo arg1 arg2
则得到的argv
为
{
"t": "example",
"template": "demo",
"_": ["arg1", "arg2"]
}
3.cwd:获取进程的运行目录:
const cwd = process.cwd()
4.FRAMEWORKS数组:定义可选框架模板
const FRAMEWORKS: Framework[] = [
{
name: 'vanilla',
display: 'Vanilla',
color: yellow,
variants: [
{
name: 'vanilla-ts',
display: 'TypeScript',
color: blue,
},
{
name: 'vanilla',
display: 'JavaScript',
color: yellow,
},
],
},
{
name: 'vue',
display: 'Vue',
color: green,
variants: [
{
name: 'vue-ts',
display: 'TypeScript',
color: blue,
},
{
name: 'vue',
display: 'JavaScript',
color: yellow,
},
{
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',
},
],
},
// ... 省略部分代码
]
4.主要处理过程函数init
初始化项目文件夹名称
const defaultTargetDir = 'vite-project'
async function init() {
const argTargetDir = formatTargetDir(argv._[0])
let targetDir = argTargetDir || defaultTargetDir
//...省略代码
function formatTargetDir(targetDir: string | undefined) {
return targetDir?.trim().replace(/\/+$/g, '')
}
}
这里主要是用于处理如下直接输入命令的情况
npm init vite@latest my-vue-app -- --template vue
argv._[0]
会取到项目名称即my-vue-app
,使用formatTargetDir
函数去除其中的空字符串等,确保能够作为一个合法的文件夹名称。如果没有配置名称则将名称配置为默认的vite-project
初始化选择的模版
const argTemplate = argv.template || argv.t
匹配template
或者t
的参数:
命令如下时:
npm init vite@latest my-vue-app -- --template vue
取到的argv.template
就是vue
配置重写
prompts.override({
overwrite: argv.overwrite,
})
当命令中有overwrite
参数时,后续需要输入overwrite时会默认使用用户在命令行中设置的值,不会再重新提示用户输入。
处理用户提示和输入
try {
result = await prompts(
[
{
type: argTargetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: (state) => {
targetDir = formatTargetDir(state.value) || defaultTargetDir
},
},
// ... 省略部分代码
],
{
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled')
},
},
)
} catch (cancelled: any) {
console.log(cancelled.message)
return
}
prompts中定义了一个提示数组用于处理每一步用户的提示以及用户的输入,效果如下所示:
其参数具体为:
type
:提示用户输入的类型,为null时直接跳过该提示;name
:当前提示选项中用户输入值在结果对象中的键;message
:提示信息;initial
:当前提示的默认值;choices
:配置问题的可选项;onState
:用户输入时的回调函数,第一个参数为用户当前输入值的对象,第二个参数为结果对象。
用户输入各个步骤具体处理过程
项目名
{
type: argTargetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: (state) => {
targetDir = formatTargetDir(state.value) || defaultTargetDir
},
},
当用户已经在命令行中指定了项目名称即argTargetDir
的值存在,直接跳过该步骤,如下:
npm init vite@latest my-vue-app
可见直接跳到了选择框架的选项。
若没有在命令行中指定项目名称则提示用户输入,并显示一个默认值defaultTargetDir
,当用户输入时使用formatTargetDir
对输入的值进行处理作为文件夹名称,若用户没有输入具体的值则直接使用默认值。
已存在项目文件夹处理:
{
type: () =>
!fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'select',
name: 'overwrite',
message: () =>
(targetDir === '.'
? 'Current directory'
: `Target directory "${targetDir}"`) +
` is not empty. Please choose how to proceed:`,
initial: 0,
choices: [
{
title: 'Remove existing files and continue',
value: 'yes',
},
{
title: 'Cancel operation',
value: 'no',
},
{
title: 'Ignore files and continue',
value: 'ignore',
},
],
},
判断上一步中指定的文件夹是否存在或者为空
!fs.existsSync(targetDir) || isEmpty(targetDir) ?
如果不是说明项目文件需要覆盖,根据targetDir
的值给用户做相应的提示
,并且给用户提供数个选择放入choices
中
覆写检查
{
type: (_, { overwrite }: { overwrite?: string }) => {
if (overwrite === 'no') {
throw new Error(red('✖') + ' Operation cancelled')
}
return null
},
name: 'overwriteChecker',
},
取得上一步中用户输入的overwrite
的值,如果上一步中选择了no
,则直接退出提示过程并进行对应的提示
校验项目名是否能作为package.json中的name
{
type: () => (isValidPackageName(getProjectName()) ? null : 'text'),
name: 'packageName',
message: reset('Package name:'),
initial: () => toValidPackageName(getProjectName()),
validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name',
},
其中getProjectName
、isValidPackageName
和 toValidPackageName
如下
const getProjectName = () =>
targetDir === '.' ? path.basename(path.resolve()) : targetDir
//...省略代码
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, '-')
}
首先使用getProjectName
获取得到项目名称,使用isValidPackageName
通过正则匹配项目名称是否符合package.json
中对于名称的要求,如果不符合要求则使用toValidPackageName
将其转换成合法的packageName
作为默认值。
使用
validate
对用户的输入进行校验,如果不符合要求则显示提示Invalid package.json name,直到正确输入。
选择框架模板
const argTemplate = argv.template || argv.t
{
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,
}
}),
},
argTemplate && TEMPLATES.includes(argTemplate) ? null : 'select'
如果在命令行中通过命令直接指定了需要使用的框架模板则跳过这一步。
若没有直接指定则需要提示.默认选中第一项,遍历FRAMEWORKS
中对应值作为选项。
选择框架下不同的选项
{
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,
}
}),
},
如果当前选择的框架下有不同的选项,如不同的语言版本需要让用户去选择
拿到用户所有输入和选择
const { framework, overwrite, packageName, variant } = result
处理文件
//通过将当前工作目录和目标目录拼接在一起,生成一个完整的路径
const root = path.join(cwd, targetDir)
if (overwrite === 'yes') {
emptyDir(root)
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root, { recursive: true })
}
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 })
}
}
fs.existsSync
:用于检查目录是否存在;fs.mkdirSync
:、用于创建目录;fs.readdirSync
:读取目录内容,返回一个文件和子目录名的数组;fs.rmSync
:recursive: true
递归删除,即如果是目录,会删除目录及其内容;force: true
强制删除,即使文件或目录有只读属性也会被删除;
如果用户选择了覆盖,则需要先调用emptyDir
清空项目文件夹下除了.git
外的所有文件。如果用户没有选择覆盖并且root
路径没有目录则创建一个目录。
let template: string = variant || framework?.name || argTemplate
let isReactSwc = false
if (template.includes('-swc')) {
isReactSwc = true
template = template.replace('-swc', '')
}
获得用户选择的模板,并对特殊情况进行处理
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')
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],
}
}
通过node的process.env.npm_config_user_agent
环境变量读取到用户代理信息,使用pkgFromUserAgent
对信息进行处理得到用户正在使用的包管理器,判断是不是1.x版本的yarn
通过扁平化数组FRAMEWORKS
,寻找用户所选择的模板,通过解构赋值拿到variants
中的customCommand
,
if (customCommand) {
const fullCustomCommand = customCommand
.replace(/^npm create /, () => {
// `bun create` uses it's own set of templates,
// the closest alternative is using `bun x` directly on the package
if (pkgManager === 'bun') {
return 'bun x create-'
}
return `${pkgManager} create `
})
// Only Yarn 1.x doesn't support `@version` in the `create` command
.replace('@latest', () => (isYarn1 ? '' : '@latest'))
.replace(/^npm exec/, () => {
// Prefer `pnpm dlx`, `yarn dlx`, or `bun x`
if (pkgManager === 'pnpm') {
return 'pnpm dlx'
}
if (pkgManager === 'yarn' && !isYarn1) {
return 'yarn dlx'
}
if (pkgManager === 'bun') {
return 'bun x'
}
// Use `npm exec` in all other cases,
// including Yarn 1.x and other custom npm clients.
return 'npm exec'
})
const [command, ...args] = fullCustomCommand.split(' ')
// we replace TARGET_DIR here because targetDir may include a space
const replacedArgs = args.map((arg) => arg.replace('TARGET_DIR', targetDir))
const { status } = spawn.sync(command, replacedArgs, {
stdio: 'inherit',
})
process.exit(status ?? 0)
}
通过对customCommand
处理使其能够适应不同的包管理器。
const templateDir = path.resolve(
fileURLToPath(import.meta.url),
'../..',
`template-${template}`,
)
const files = fs.readdirSync(templateDir)
通过选择的模版template
,将当前模块的路径向上两级目录,并拼接上 template-
和模板名称,最终解析出模板目录的绝对路径。使用fs.readdirSync
读取路径对应的文件。
for (const file of files.filter((f) => f !== 'package.json')) {
write(file)
}
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)
}
}
function copy(src: string, dest: string) {
const stat = fs.statSync(src)
if (stat.isDirectory()) {
copyDir(src, dest)
} else {
fs.copyFileSync(src, dest)
}
}
const renameFiles: Record<string, string | undefined> = {
_gitignore: '.gitignore',
}
遍历读取到的模板文件,除了package.json
以外的文件使用write
函数去处理,通过path.root
拼接生成目标路径,文件名如果是_gitignore
的话需要重命名成.gitignore
,然后调用copy
函数将模板文件复制到目标路径中。
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')
取得模板文件夹中的package.json
文件,拷贝给pkg
修改其中的name
字段,调用write
函数,生成一个package.json
文件并将pkg
中的内容写入
if (root !== cwd) {
console.log(
` cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`,
)
}
如果root
路径不是当前路径则输入一个cd命令方便用户切换目录
switch (pkgManager) {
case 'yarn':
console.log(' yarn')
console.log(' yarn dev')
break
default:
console.log(` ${pkgManager} install`)
console.log(` ${pkgManager} run dev`)
break
}
针对不同的包管理器输出不同的初始化和启动命令
总结
文章主要介绍了 create-vite
包的目录结构、命令执行流程以及相关的代码处理过程。具体包括:
- 目录结构:介绍了
create-vite
包的目录结构,包括index.js
等文件。 - npm init vite@latest 命令:说明了通过该命令可以初始化一个新的 Vite 项目,并介绍了命令执行的流程。
- src/index.ts 执行过程:介绍了 index.ts 中导入的依赖,以及通过 minimist 获取命令行输入等过程。
- 主要处理过程函数init :包括初始化项目文件夹名称、初始化选择的模版、配置重写、处理用户提示和输入等过程。
- 处理文件:介绍了如何处理用户选择的模板,并拷贝模板文件到目标路径。
- 输出提示:根据不同的包管理器,输出了不同的初始化和启动项目提示。