"npm create vite" 是怎么实现初始化 Vite 项目?

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

前言

我们从 vite 的官方文档中看到,可以使用 npm/yarn/pnpm create 命令来初始化一个基于 Vite 的项目;其实很多框架或库都会开发相应的脚手架工具,用于快速初始化项目,例如 create-vite、create-vue、create-react-app 等;这是如何实现的呢?本文将从头分析 create-vite 创建一个 Vite 项目流程的原理。

Untitled.png

从创建项目说起

npm init / create 命令

npm v6 版本给 init 命令添加了别名 create,俩命令一样的

npm init 命令除了可以用来创建 package.json 文件,还可以用来执行一个包的命令;它后面还可以接一个 <initializer> 参数。该命令格式:

npm init <initializer>

参数 initializer 是名为 create-<initializer> 的 npm 包 ( 例如 create-vite ),执行 npm init <initializer> 将会被转换为相应的 npm exec 操作,即会使用 npm exec 命令来运行 create-<initializer> 包中对应命令 create-<initializer>(package.json 的 bin 字段指定),例如:

# 使用 create-vite 包的 create-vite 命令创建一个名为 my-vite-project 的项目
$ npm init vite my-vite-project
# 等同于
$ npm exec create-vite my-vite-project

执行 npm create vite 发生了什么?

当我们执行 npm create vite 时,会先补全包名为 create-vite;然后转换为 npm exec 命令执行,即 npm exec create-vite;接着执行包对应的 create-vite 命令(如果本地未安装 create-vite 包则先从远程拉取),了解更多 initexec

create-vite 原理

create-vite 包源码在 vite 仓库 packages 文件夹下,从 create-vite 包的 package.json 文件的 bin 字段,可以看到配置了两个命令名 create-vitecva 以及对应的映射文件;也就是当我们执行命令时,会去执行对应的映射文件。cva 命令跟 create-vite 命令效果是一样的,我们也可以用 cva 命令来初始化 Vite 项目。

Untitled 1.png

接下来,我们就来分析下 create-vite 的执行流程及源码。

调试准备

  1. 克隆 vite 仓库代码;可以看到 vite 仓库使用 pnpm 作为包管理器,使用 monorepo 方式管理项目,packages 文件夹里面包含 create-vite , vite 和一些 vite 内置插件;

    Untitled 2.png
  2. 安装依赖;终端进到 packages/create-vite 目录下执行 pnpm i ,也可以直接在根目录使用 pnpm 过滤器选项 --filterpnpm -—filter create-vite i ;

    Untitled 3.png
  3. 创建 JavaScript 调试终端;点击进入 VSCode 左侧面板中的运行调试菜单,点击 “JavaScript 调试终端”,就会创建出一个调试终端;

    Untitled 4.png
  4. 调试;进到 create-vite 包根目录,cd packages/create-vite ,因为源码使用 ts 编写,所以我们需要一个可以运行 ts 的执行器,可以使用 tsx / esno。从 build.config.ts 文件(unbuild 打包工具的配置文件)可以看到入口文件为 src/index.ts ,所以我们在调试终端执行 npx tsx src/index.ts 就可以打断点调试啦。

    Untitled 5.png

执行流程

Untitled 6.png

入口文件 src/index.ts 的主函数为 init 函数,init 函数的执行流程图如下: 流程图链接

yuque_diagram (1).jpg

源码分析

根据上面的执行流程,我们详细看每个步骤的代码实现(init 函数里面);

1. 判断是否提示用户输入项目名(目录路径)

Untitled 7.png

执行命令时,如果命令行未指定项目名参数,则提示用户输入项目名称(默认值为 vite-project),有指定则跳过此步骤(例如 npm create vite my-vite-app ,指定项目名为 my-vite-app);

// 用于创建交互提示
import prompts from 'prompts'
// 用于设置输入输出颜色
import { reset, red, blue } from 'kolorist'// 默认的项目名(目录名)
const defaultTargetDir = 'vite-project'async function init() {
  // 获取命令行项目名参数, 例如 npm create vite my-vite-project,则 argTargetDir 值为 my-vite-project
  const argTargetDir = formatTargetDir(argv._[0])
  // 目标目录,命令行未指定则使用默认值
  let targetDir = argTargetDir || defaultTargetDir
  // 创建交互提示
  await prompts(
      [
        {
          // 提示用户输入项目名,如果命令行有指定项目名,则 type 赋值为 null,就会跳过此步骤
          type: argTargetDir ? null : 'text',
          name: 'projectName',
          message: reset('Project name:'),
          initial: defaultTargetDir,
          onState: (state) => {
            targetDir = formatTargetDir(state.value) || defaultTargetDir
          },
        },
        // 省略其他步骤代码...
      ]
    )
}
// 格式化目录名,将结尾斜杠字符 / 去掉
function formatTargetDir(targetDir: string | undefined) {
  return targetDir?.trim().replace(//+$/g, '')
}

相关包:

  • prompts 用于创建轻量的、漂亮的、友好的终端交互提示;
  • kolorist 用于设置输入输出颜色

2. 判断当前路径下是否存在相同目录名

Untitled 8.png 不存在则跳过此步骤,存在则提示用户存在相同目录名,并提示是否删除,用户确定删除则继续下一步,否则退出当前执行程序;

async function init() {
  await prompts(
      [
        // ...
        {
          type: () =>
          // 判断是否已存在目录,存在则提示是否删除,不存在则跳过此步骤
            !fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm',
          name: 'overwrite',
          message: () =>
            (targetDir === '.'
              ? 'Current directory'
              : `Target directory "${targetDir}"`) +
            ` is not empty. Remove existing files and continue?`,
         },
        {
          type: (_, { overwrite }: { overwrite?: boolean }) => {
            // 上一步如果选择取消,则退出当前执行程序
            if (overwrite === false) {
              throw new Error(red('✖') + ' Operation cancelled')
            }
            return null
          },
          name: 'overwriteChecker',
        },
        // 省略其他步骤代码...
      ]
    )
}

3. 判断项目名是否为合法包名

Untitled 9.png

因为项目名后续需要作为包名(package.json 的 name 字段),所以会校验下,如果前面输入的项目名作为包名不合法,则会自动转为合法值,并提示用户确认;

async function init() {
  // 获取目录名
  const getProjectName = () =>
     targetDir === '.' ? path.basename(path.resolve()) : targetDir
​
  await prompts(
      [
        // ...
        {
          // 如何不合法,则提示用户,否则跳过此步骤
          type: () => (isValidPackageName(getProjectName()) ? null : 'text'),
          name: 'packageName',
          message: reset('Package name:'),
          // 转为合法的包名作为初始值
          initial: () => toValidPackageName(getProjectName()),
          validate: (dir) =>
            isValidPackageName(dir) || 'Invalid package.json name',
        },
        // 省略其他步骤代码...
      ]
  )
}
​
// 校验包名是否合法
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, '-') // 非字母 a-z,非数字,非字符 - ~ 转为连字符
}

4. 判断是否提示用户选择框架

Untitled 10.png

create-vite 还提供了一个命令行选项 --template / -t ,让用户指定使用的模板,如果指定了则判断模板是否存在,存在跳过此步骤,不存在或未指定模板则提示选择框架;

// 解析命令行参数
import minimist from 'minimist'
import { blue, yellow } from 'kolorist'// 获取命令行选项值
const argv = minimist<{
  t?: string
  template?: string
}>(process.argv.slice(2), { string: ['_'] })
// 获取当前工作目录路径
const cwd = process.cwd()
// 所有内置模板
const FRAMEWORKS: Framework[] = [
  {
    name: 'vanilla',
    display: 'Vanilla',
    color: yellow,
    variants: [
      {
        name: 'vanilla',
        display: 'JavaScript',
        color: yellow,
      },
      {
        name: 'vanilla-ts',
        display: 'TypeScript',
        color: blue,
      },
    ],
  },
  // 省略其他...
]
// 所有模板
const TEMPLATES = FRAMEWORKS.map(
  (f) => (f.variants && f.variants.map((v) => v.name)) || [f.name],
).reduce((a, b) => a.concat(b), [])
​
async function init() {
  // create-vite 提供了命令行选项 --template 或 -t 让用户指定模板
  const argTemplate = argv.template || argv.tawait prompts(
      [
        // ...
        {
          // 若用户通过命令行选项指定了模板且存在此模板,则跳过此步骤,否则提供选择
          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,
            }
          }),
          },
        // 省略其他步骤代码...
      ]
  )
}

相关包:

5. 提示用户选择变体

Untitled 11.png

假设上一步选择框架 vue ,则会提供四种变体选项: JavaScript、TypeScript、使用 create-vue 自定义选择集成配置、Nuxt;

// 所有内置模板
const FRAMEWORKS: Framework[] = [
  {
    name: 'vue',
    display: 'Vue',
    color: green,
    variants: [ // 所有变体选项
      {
        name: 'vue',
        display: 'JavaScript',
        color: yellow,
      },
      {
        name: 'vue-ts',
        display: 'TypeScript',
        color: blue,
      },
      {
        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',
      },
    ],
  },
  // 省略其他...
]
​
async function init() {
  await prompts([
    {
      // 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,
          }
        }),
     },
    // 省略其他步骤代码...
  ])
​
}

6. 删除已有目录或创建新目录

如果第二步中判断存在已有目录的话,则会递归删除已有目录,否则递归创建新目录(用户在第一步中也可以输入路径形式去创建项目目录,例如 a/b);

import fs from 'node:fs'
// 当前目录路径
const cwd = process.cwd()
const defaultTargetDir = 'vite-project'async function init() {
  const argTargetDir = formatTargetDir(argv._[0])
  let targetDir = argTargetDir || defaultTargetDir
  
  // 省略其他代码...// 终端交互用户的选择结果
  const { overwrite } = result
  // 合成绝对路径
  const root = path.join(cwd, targetDir)
  // 如果用户选择删除,则 overwrite 为 true
  // PS: 其实这里我感觉没必要再做判断了,前面第 2 步骤如果选择取消删除的话,就会退出程序了
  if (overwrite) {
    // 删除已有目录
    emptyDir(root)
  } else if (!fs.existsSync(root)) {
    // 递归创建新目录,因为用户输入的目录也可以是路径,例如 a/b/xxx
    fs.mkdirSync(root, { recursive: true })
  }
}
​
function emptyDir(dir: string) {
  if (!fs.existsSync(dir)) {
    return
  }
  for (const file of fs.readdirSync(dir)) {
    // 不删除 .git 目录
    if (file === '.git') {
      continue
    }
    // 同步递归强制删除文件
    fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
  }
}
​

7. 判断变体选项是否有初始化命令

Untitled 12.png

如果在第五步(提示用户选择变体)选择的变体不存在自定义命令配置(customCommand),则跳过此步骤,否则去执行各个框架提供的初始化命令(例如框架选择 vue,变体选择 Customize with create-vue ↗, 则内部会使用 create-vue 包的命令去初始化),执行完退出当前程序;

import spawn from 'cross-spawn'
// 默认目录名
const defaultTargetDir = 'vite-project'

async function init() {
  const argTargetDir = formatTargetDir(argv._[0])
  // 通过命令行选项 --template 或 -t 指定的模板
  const argTemplate = argv.template || argv.t
  // 目标目录
  let targetDir = argTargetDir || defaultTargetDir
  // 省略其他代码...
  // 终端交互用户的选择结果
  const { framework, packageName, variant } = result
  // 确定模板
  const template: string = variant || framework?.name || argTemplate
  // 通过 npm 内置环境变量 npm_config_user_agent 解析获取包管理器信息
  const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
  const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
  const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')
  // 获取选择的模板变体选项中的自定义命令选项
  const { customCommand } =
    FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}
  // 如果存在自定义命令选项,执行该命令
  if (customCommand) {
    // 生成完整的命令,例如 npm create vite@latest vite-project
    const fullCustomCommand = customCommand
      // 替换 TARGET_DIR 字符串为目标目录
      .replace('TARGET_DIR', targetDir)
      // 替换为用户使用的包管理器
      .replace(/^npm create/, `${pkgManager} create`)
      // Yarn 1.x 版本不支持指定 @latest 标签,所以需去掉
      .replace('@latest', () => (isYarn1 ? '' : '@latest'))
      .replace(/^npm exec/, () => {
        // 优先使用 `pnpm dlx` 或 `yarn dlx` 执行包命令
        if (pkgManager === 'pnpm') {
          return 'pnpm dlx'
        }
        if (pkgManager === 'yarn' && !isYarn1) {
          return 'yarn dlx'
        }
        return 'npm exec'
      })
    // 拆分命令,例如 “npm create vite@latest vite-project” => ["npm", "create", "vite@latest", "vite-project"]
    const [command, ...args] = fullCustomCommand.split(' ')
    // 同步执行命令
    const { status } = spawn.sync(command, args, {
      stdio: 'inherit',
    })
    // 退出当前执行中的程序
    process.exit(status ?? 0)
  }
}
// 通过 npm ua 信息获取使用的包管理器及版本
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],
  }
}

8. 读取模板写入到目标目录

image.png

根据前面用户通过命令行指定或终端交互选择的模板,解析获取对应模板绝对路径,写入到用户的目录路径;修改 package.json 的 name 字段值为第一步骤输入的项目名;最后提示一些信息,结束流程 🎉。

Untitled 13.png
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import spawn from 'cross-spawn'

async function init() {
  // 省略其他代码...
  const root = path.join(cwd, targetDir)
	
  // 解析生成选择模板的目录绝对路径
  // fileURLToPath 将文件URL转为绝对路径
  // 例如 'file:///Users/jizai/juejin/vite/packages/create-vite/src/index.ts'
  // => '/Users/jizai/juejin/vite/packages/create-vite/src/index.ts'
  const templateDir = path.resolve(
    fileURLToPath(import.meta.url),
    '../..',
    `template-${template}`,
  )
  // 将用户选择的模板目录下的文件写入到目标路径下 targetPath
  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)
    }
  }
  // 根据模板目录路径读取模板目录
  const files = fs.readdirSync(templateDir)
  // 遍历模板目录下的文件,写入到目标路径,过滤掉 package.json 文件
  for (const file of files.filter((f) => f !== 'package.json')) {
    write(file)
  }
  // 获取 package.json 文件内容,转为对象
  const pkg = JSON.parse(
    fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8'),
  )
  // 设置 name 字段为前面输入的项目名
  pkg.name = packageName || getProjectName()
  write('package.json', JSON.stringify(pkg, null, 2))
	
  // 打印一些提示信息
  console.log(`\nDone. Now run:\n`)
  if (root !== cwd) {
    console.log(`  cd ${path.relative(cwd, root)}`)
  }
  switch (pkgManager) {
    case 'yarn':
      console.log('  yarn')
      console.log('  yarn dev')
      break
    default:
      console.log(`  ${pkgManager} install`)
      console.log(`  ${pkgManager} run dev`)
      break
  }
}
// 拷贝整个目录下的所有文件到目标路径
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) {
  // destDir 递归创建目录
  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)
  }
}

相关包:

  • cross-spawn node 子进程的 spawn 方法的跨平台封装

总结

一句话总结 create-vite 原理:”通过创建交互式提示,根据交互结果获取相应模板,写入到指定目录”。类似的 create-xxx 原理应该都大差不差吧。通过深入学习 create-vite 包的原理及其源码,可以看到实现并没有想象的复杂,况且有丰富的包库可供使用,自己以后实现类似需求的时候可用来做下参考。