create-vite 代码解析&实现一个自己的脚手架

我正在参与掘金会员专属活动-源码共读第一期,点击参与

学习任务

  • 源码:create-vite
  • create-vite 不到400行代码;
  • 可以学会如何写一个脚手架等等;
  • 注意:如果克隆的最新的代码(最新的create-vite已升级为 ts),按照我文中的方式不能调试。推荐使用 npx esno src/index.ts调试源码。

目录结构

image.png

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
那么若pkgInfoname字段是pnpm,用户输入的TARGET_DIRvite-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对象供开发者进行模板选择,并且可以使用配置文件增加自定义配置:

image.png

image.png

image.png

目前可以支持vitereact官方的项目搭建,实际上可以成为一个脚手架模板的整合,其他开发者也可以用提pr的方式上传自己的模板到create-want-apptemplate目录下,遵守命名规范即可。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容