vite初始化项目: npm init vite@latest 后发生了什么?

510 阅读6分钟

一、先看一下create-vite包

vite仓库 地址:vitejs/vite: Next generation frontend tooling. It's fast! (github.com)

image.png

create-vite包位于vite仓库下的packages中

image.png

二、npm init vite@latest命令

通过查阅npm官方文档可见 image.png npm install后面还有一个参数<initializer>,可以用来安装一个新的或者已经存在的npm包。<initializer>对应的npm包的名字是create-<initializer>,这个npm包会被npm-exec执行,然后包中的package.jsonbin字段对应的脚本会执行。

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中定义了一个提示数组用于处理每一步用户的提示以及用户的输入,效果如下所示:

image.png

其参数具体为:

  • 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

image.png 可见直接跳到了选择框架的选项。

若没有在命令行中指定项目名称则提示用户输入,并显示一个默认值defaultTargetDir,当用户输入时使用formatTargetDir对输入的值进行处理作为文件夹名称,若用户没有输入具体的值则直接使用默认值。

image.png

已存在项目文件夹处理:

{
      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

image.png

覆写检查

   {
      type: (_, { overwrite }: { overwrite?: string }) => {
        if (overwrite === 'no') {
          throw new Error(red('✖') + ' Operation cancelled')
        }
        return null
      },
      name: 'overwriteChecker',
  },

取得上一步中用户输入的overwrite的值,如果上一步中选择了no,则直接退出提示过程并进行对应的提示

image.png

校验项目名是否能作为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',
},

其中getProjectNameisValidPackageNametoValidPackageName如下

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作为默认值。

image.png 使用validate对用户的输入进行校验,如果不符合要求则显示提示Invalid package.json name,直到正确输入。

image.png

选择框架模板

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'如果在命令行中通过命令直接指定了需要使用的框架模板则跳过这一步。

image.png

若没有直接指定则需要提示.默认选中第一项,遍历FRAMEWORKS中对应值作为选项。

image.png

选择框架下不同的选项


 {
      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,
          }
        }),
},

如果当前选择的框架下有不同的选项,如不同的语言版本需要让用户去选择

image.png

拿到用户所有输入和选择

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)

image.png

通过选择的模版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 包的目录结构、命令执行流程以及相关的代码处理过程。具体包括:

  1. 目录结构:介绍了 create-vite 包的目录结构,包括 index.js 等文件。
  2. npm init vite@latest 命令:说明了通过该命令可以初始化一个新的 Vite 项目,并介绍了命令执行的流程。
  3. src/index.ts 执行过程:介绍了 index.ts 中导入的依赖,以及通过 minimist 获取命令行输入等过程。
  4. 主要处理过程函数init :包括初始化项目文件夹名称、初始化选择的模版、配置重写、处理用户提示和输入等过程。
  5. 处理文件:介绍了如何处理用户选择的模板,并拷贝模板文件到目标路径。
  6. 输出提示:根据不同的包管理器,输出了不同的初始化和启动项目提示。