create-vite 代码解析&实现一个自己的脚手架
学习任务
源码: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
目录下,遵守命名规范即可。
作者:叁十四城
链接:https://juejin.cn/post/7170528293345558558