我正在参与掘金会员专属活动-源码共读第一期,点击参与
学习任务
- 源码:create-vite
- create-vite 不到400行代码;
- 可以学会如何写一个脚手架等等;
- 注意:如果克隆的最新的代码(最新的create-vite已升级为 ts),按照我文中的方式不能调试。推荐使用 npx esno src/index.ts调试源码。
目录结构
create-vite的目录结构非常简单,根目录有一个index.js
,用来指明脚本地址,src为项目目录,其余template-xxx
为模板,用于创建模板。
依赖包说明
create-vite共使用了四个第三方包:
cross-spawn
:运行批处理脚本
minimist
:解析命令行参数
prompts
:命令行交互式引导提示
kolorist
:多彩命令输出
具体使用细节不再详述。
源码解析
获取命令参数
考虑用户首先使用npx create-vite xxx -t xxx
创建项目,所以需要获取:
- 项目目录
- 模板名
这里使用minimist
进行命令解析
const argv = minimist<{
t?: string
template?: string
}>(process.argv.slice(2), { string: ['_'] })
const cwd = process.cwd()
create-vite
的命令为npm create vite@latest my-vue-app --template vue
argv
最终为一个对象:
{
_: [ 'projectName' ],
t: templateName
}
其中_
是一个数组,为用户输入的未带前缀属性的值,若运行上述命令,这里数组第一项就是my-vue-app
。
需要解释的是minimist
第二个参数,这个在代码中做了注释,目的是为了将数字类型的项目名转换为字符串类型,见#4606 · vitejs/vite (github.com)
定义模板
既然是脚手架工具,必然少不了模板,vite是这样定义模板类型的:
type Framework = {
name: string
display: string
color: ColorFunc
variants: FrameworkVariant[]
}
type FrameworkVariant = {
name: string
display: string
color: ColorFunc
customCommand?: string
}
一般来说,Framework
为某个框架,variants
是框架下的不同脚手架实现,如js
,ts
版本,下面是一个示例:
const FRAMEWORKS = [{
name: 'vue',
display: 'Vue',
color: green,
variants: [
{
name: 'vue',
display: 'JavaScript',
color: yellow
},
...
]
}]
其中各字段用处为:
name
:模板名,用于vite
找到最终模板
display
:用于命令行展示的名称
color
:命令行展示时的文字颜色
customCommand
:需要执行的命令
除了定义外,我们需要遍历这个Frameworks,将所有的模板名遍历出来,后面会用到:
const TEMPLATES = FRAMEWORKS.map(
(f) => (f.variants && f.variants.map((v) => v.name)) || [f.name]
).reduce((a, b) => a.concat(b), [])
这里也可以用flatMap
实现
const TEMPLATES = FRAMEWORKS.flatMap(
(f) => (f.variants && f.variants.map((v) => v.name)) || [f.name]
);
用户交互
作为一个脚手架工具,免不了与用户进行交互,这里vite
使用的是prompts
工具,他是一款轻量级,美观且用户友好的交互式提示库。prompts
接收一个数组对象,返回一个promise
包裹的最终结果。下面来看一个create vite
都有哪些命令交互:
projectName
结合上文内容我们知道,用户在初始输入时可以输入projectName
,不过也需要考虑用户没有输入的情况,这里会判断argTargetDir
字段是否存在,不存在说明用户还没有输入,则提示用户输入,并提供一个默认的项目名称:
{
// 路径
type: argTargetDir ? null : "text",
name: "projectName",
message: reset("project name"),
initial: defaultTargetDir,
onState(state) {
targetDir = formatTargetDir(state.value) || defaultTargetDir;
},
},
overwrite
若用户提供的项目名称在当前路径下已存在且不为空,则需要提醒用户是否继续,如果用户选择否,则退出命令。
{
// 若文件夹已存在或不为空
type: () =>
!fs.existsSync(targetDir) || isEmptyDir(targetDir)
? null
: "confirm",
name: "overwrite",
message: `${
targetDir === "." ? "Current Dir" : "target directory" + targetDir
} is not empty. Remove existing files and continue?`,
},
{
type: (_, { overwrite }: { overwrite?: boolean }) => {
console.log(overwrite);
if (overwrite === false) {
// 用户选择 X ,抛出错误退出。
throw new Error(red("✖") + " Operation cancelled");
}
return null;
},
name: "overwriteChecker",
},
packageName
由于package.json
的name字段有命名要求:
The
name
field contains your package’s name, and must be lowercase and one word, and may contain hyphens and underscores.
所以需要对于不符合条件的projectName,我们需要提示用户重新输入一个项目名称:
{
type: () => (isValidPackageName(getProjectName()) ? null : 'text'),
name: 'packageName',
message: reset('Package name:'),
initial: () => toValidPackageName(getProjectName()),
validate: (dir) =>
isValidPackageName(dir) || 'Invalid package.json name'
},
framework
最后一步就是选择模板了,如果用户在初始化输入中输入了正确的模板名,则当前步骤则会跳过,直接进入到模板生成的步骤。否则会提示用户进行模板选择:
{
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
}
})
},
{
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
}
})
}
],
模板构建
当用户交互完成后,脚手架工具的输入阶段就算告一段落了,目前拿到的字段有:targetDir
,template
, framework
, overwrite
, packageName
, variant
,接下来就要根据这些参数进行模板的构建工作,具体的步骤如下,大家可以依照代码进行对应:
- 创建文件夹 | 清空文件夹
const root = path.join(cwd, targetDir)
if (overwrite) {
emptyDir(root)
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root, { recursive: true })
}
- 获取项目模板
由于使用了多个输入方式进行模板选择(初始输入,命令行选择),故需要最终确认模板名是什么:
const template: string = variant || framework?.name || argTemplate
- 获取用户包管理器名称、版本
由于接下来可能会执行一些命令,所以需要提前获取下用户环境下的包管理器,这里使用的是process.env.npm_config_user_agent
,这个是node的环境变量,可以直接获取到当前的包管理器类型及node版本号:
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
pkgFromUserAgent
最终返回的是一个含有name和version的对象,即包管理器名称和版本号。
- 判断模板是否存在
customCommand
,如果存在则运行命令,并退出
这里主要就是根据前面获取的包管理器类型对customCommand
进行修改,以适应用户的环境。
如customCommand
命令为npm create vue@latest TARGET_DIR
,
那么若pkgInfo
的name
字段是pnpm
,用户输入的TARGET_DIR
为vite-app
,则修改后的命令为:pnpm create vue@latest vite-app
。
命令执行完毕后,这里分支的任务就完成了,所以调用process.exit(status ?? 0)
返回执行结果并退出。
-
项目生成
如果不存在
customCommand
命令,则会进行项目生成,主要步骤如下,这里不再进行代码分析:-
根据模板名找到模板路径,将模板文件迁移到
projectName
所在的文件夹 -
更新模板
package.json
中的name
字段为packageName||projectName
-
引导用户进入项目目录、安装依赖、启动项目。
-
总结
create-vite的源码相对来说还是比较简单易读的,我也在阅读的过程中学到的不少知识,除此之外,我还在create-vite的基础上进行修改,改造了一款kakachake/create-want脚手架,欢迎大家使用npx create-want-app
进行试用,它可以检测template
文件夹下的模板,自动生成上述提到的Framework
对象供开发者进行模板选择,并且可以使用配置文件增加自定义配置:
目前可以支持vite
、react官方
的项目搭建,实际上可以成为一个脚手架模板的整合,其他开发者也可以用提pr的方式上传自己的模板到create-want-app
的template
目录下,遵守命名规范即可。
暂无评论内容