create vite 如何初始化项目,源码分析!

890 阅读4分钟

1 学习目标

  分析 create-vite 源码

2 初始化项目

当使用npm create vite@latest初始化 vite 项目,其中 create 是 init 的别名、vite@latest 代表的意思是 vite 的最新版本,等于npx create-vite@latest

npx 是 npm 5.2 之后的一个命令,主要用于解决调用项目内部安装的模块
上面执行的命令,npx 会到 node_modules/.binPATH 中检查 create-vite 是否存在
如果不存在,则会临时下载 create-vite,使用后删除

3 克隆调试项目

vite GitHub 地址,根据项目内的 README.md 和 CONTRIBUTING.md 文档安装调试

git clone https://github.com/vitejs/vite.git
cd vite
// 根据文档说明执行安装
// "preinstall": "npx only-allow pnpm",只允许使用 pnpm
pnpm i
// 当前 vite 版本为 3.1,从 3.1 版本开始,vite 使用 typescript
// 调试需使用 esno 库 (执行 ts 文件)
npx esno packages/create-vite/src/index.ts

image.png

控制台完整输出 image.png

4 主流程分析

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// 用于开启多进程 https://www.npmjs.com/package/cross-spawn
import spawn from 'cross-spawn'
// 解析命令行参数 https://www.npmjs.com/package/minimist
import minimist from 'minimist'
// 提示选择 https://www.npmjs.com/package/prompts
import prompts from 'prompts'
// 终端色彩库 https://www.npmjs.com/package/kolorist
import {
  blue,
  cyan,
  green,
  lightGreen,
  lightRed,
  magenta,
  red,
  reset,
  yellow
} from 'kolorist'
// node 执行目录
const cwd = process.cwd()
/*
解析 npx create-vite@latest projectName --template/--t vue
agrv = {
  _: ['projectName']
  template: 'vue'
}
*/
const argv = minimist<{
  t?: string
  template?: string
}>(process.argv.slice(2), { string: ['_'] })
// 入口函数
async function init() {
...
}
init().catch((e) => {
  console.error(e)
})

4.1 获取命令行上指定的项目名称

// 默认输出的项目文件夹名称
const defaultTargetDir = 'vite-project'
// 获取命令行指定的项目文件夹名称
const argTargetDir = formatTargetDir(argv._[0])
// 目标文件夹名称
let targetDir = argTargetDir || defaultTargetDir
// 获取项目名称,如果指定的项目名称为点,则取当前工作目录名称
const getProjectName = () =>
  targetDir === '.' ? path.basename(path.resolve()) : targetDir

4.1.1 formatTargetDir 函数

// 格式化为有效的文件夹名称,替换反斜杠为空字符
function formatTargetDir(targetDir: string | undefined) {
  return targetDir?.trim().replace(/\/+$/g, '')
}

4.2 获取命令行上指定的框架模板

// 类型定义
type ColorFunc = (str: string | number) => string
type Framework = {
  name: string
  display: string
  color: ColorFunc
  variants: FrameworkVariant[]
}
type FrameworkVariant = {
  name: string
  display: string
  color: ColorFunc
  customCommand?: string
}
// 模板框架以及框架变体
const FRAMEWORKS: Framework[] = [
  {
    name: 'vanilla',
    display: 'Vanilla',
    color: yellow,
    variants: [
      {
        name: 'vanilla',
        display: 'JavaScript',
        color: yellow
      },
      {
        name: 'vanilla-ts',
        display: 'TypeScript',
        color: blue
      }
    ]
  },
  {
    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'
      }
    ]
  },
  {
    name: 'react',
    display: 'React',
    color: cyan,
    variants: [
      {
        name: 'react',
        display: 'JavaScript',
        color: yellow
      },
      {
        name: 'react-ts',
        display: 'TypeScript',
        color: blue
      }
    ]
  },
  {
    name: 'preact',
    display: 'Preact',
    color: magenta,
    variants: [
      {
        name: 'preact',
        display: 'JavaScript',
        color: yellow
      },
      {
        name: 'preact-ts',
        display: 'TypeScript',
        color: blue
      }
    ]
  },
  {
    name: 'lit',
    display: 'Lit',
    color: lightRed,
    variants: [
      {
        name: 'lit',
        display: 'JavaScript',
        color: yellow
      },
      {
        name: 'lit-ts',
        display: 'TypeScript',
        color: blue
      }
    ]
  },
  {
    name: 'svelte',
    display: 'Svelte',
    color: red,
    variants: [
      {
        name: 'svelte',
        display: 'JavaScript',
        color: yellow
      },
      {
        name: 'svelte-ts',
        display: 'TypeScript',
        color: blue
      },
      {
        name: 'custom-svelte-kit',
        display: 'SvelteKit ↗',
        color: red,
        customCommand: 'npm create svelte@latest TARGET_DIR'
      }
    ]
  },
  {
    name: 'others',
    display: 'Others',
    color: reset,
    variants: [
      {
        name: 'create-vite-extra',
        display: 'create-vite-extra ↗',
        color: reset,
        customCommand: 'npm create vite-extra@latest TARGET_DIR'
      }
    ]
  }
]
// 取出全部框架变体,方便匹配命令行中指定的框架
const TEMPLATES = FRAMEWORKS.map(
  (f) => (f.variants && f.variants.map((v) => v.name)) || [f.name]
).reduce((a, b) => a.concat(b), [])


4.3 提示选择框架、项目名称等

let result: prompts.Answers<
    'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'
  >
// 获取命令行中指定的框架模板
const argTemplate = argv.template || argv.t

try {
  // 执行 prompts
  result = await prompts(
    [
      {
        // 显示的提示类型,如果值为 null 则跳过
        type: argTargetDir ? null : 'text',
        // 保存 key 值
        name: 'projectName',
        // 显示值
        message: reset('Project name:'),
        // 默认提示值
        initial: defaultTargetDir,
        // 提示状态值更改时触发
        onState: (state) => {
          // 格式化为有效项目名称,为空则取默认名称
          targetDir = formatTargetDir(state.value) || defaultTargetDir
        }
      },
      {
        // 项目文件夹已存在,提示是否覆盖,文件夹不存在,则跳过
        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?`
      },
      {
        // overwrite 选择为 N 时,抛出 message,选择为 Y 时,跳过
        type: (_, { overwrite }: { overwrite?: boolean }) => {
          if (overwrite === false) {
            throw new Error(red('✖') + ' Operation cancelled')
          }
          return null
        },
        name: 'overwriteChecker'
      },
      {
        // 判断指定项目名称是否有效,有效则跳过,无效则重新输入
        type: () => (isValidPackageName(getProjectName()) ? null : 'text'),
        name: 'packageName',
        message: reset('Package name:'),
        // 提示默认文件夹名称
        initial: () => toValidPackageName(getProjectName()),
        // 实时检查用户输入的值,应返回 true, 如果返回 false 则显示 'Invalid ...'
        validate: (dir) =>
          isValidPackageName(dir) || 'Invalid package.json name'
      },
      {
        // 用户是否指定框架模板
        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:'),
        choices: FRAMEWORKS.map((framework) => {
          const frameworkColor = framework.color
          return {
            title: frameworkColor(framework.display || framework.name),
            value: framework
          }
        })
      },
      {
        // 提示选择框架变体 framework.variants
        type: (framework: Framework) => {
          return 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
            }
          })
      }
    ],
    {
      onCancel: () => {
        throw new Error(red('✖') + ' Operation cancelled')
      }
    }
  )
} catch (cancelled: any) {
  console.log(cancelled.message)
  return
}
// user choice associated with prompts
// framework:框架名称 overwrite:是否覆盖已有目录 
// packageName:指定的项目名称 variant:选择的框架变体
const { framework, overwrite, packageName, variant } = result

4.3.1 toValidPackageName 函数

// 转换为有效的包名称
function toValidPackageName(projectName: string) {
  return projectName
    .trim()
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/^[._]/, '')
    .replace(/[^a-z0-9-~]+/g, '-')
}

4.3.2 isValidPackageName 函数

// 有效包名判断
function isValidPackageName(projectName: string) {
  return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(
    projectName
  )
}

4.3.3 isEmpty 函数

// 是否为空文件夹
function isEmpty(path: string) {
  const files = fs.readdirSync(path)
  return files.length === 0 || (files.length === 1 && files[0] === '.git')
}

4.4 覆盖已有目录/创建指定项目名称目录

// 指定目录创建位置
const root = path.join(cwd, targetDir)
if (overwrite) {
  // 选择 overwrite yes,删除除了.git以外文件,
  emptyDir(root)
} else if (!fs.existsSync(root)) {
  // 不存在文件夹,进行创建
  fs.mkdirSync(root, { recursive: true })
}

4.4.1 emptyDir 函数

// 删除文件夹内容
function emptyDir(dir: string) {
  if (!fs.existsSync(dir)) {
    return
  }
  for (const file of fs.readdirSync(dir)) {
    if (file === '.git') {
      continue
    }
    // recursive true 递归删除,force true 忽略路径不存在异常
    fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
  }
}

4.5 若用户选择自定义创建vue模板,执行命令并退出程序

// determine template
const template: string = variant || framework?.name || argTemplate
// 获取当前用户包管理器
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')
//  - ?? 空值合并运算符,当表达式为 null 或 undefined 时,为变量设置一个默认值
// 用户选择 customCommand 自定义创建 vue 命令
const { customCommand } =
  FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}
// 执行自定义创建命令流程
if (customCommand) {
  // 替换 npm create vue@latest TARGET_DIR
  const fullCustomCommand = customCommand
    .replace('TARGET_DIR', targetDir)
    .replace(/^npm create/, `${pkgManager} create`)
    // Only Yarn 1.x doesn't support `@version` in the `create` command
    .replace('@latest', () => (isYarn1 ? '' : '@latest'))
    .replace(/^npm exec/, () => {
      // Prefer `pnpm dlx` or `yarn dlx`
      if (pkgManager === 'pnpm') {
        return 'pnpm dlx'
      }
      if (pkgManager === 'yarn' && !isYarn1) {
        return 'yarn dlx'
      }
      // Use `npm exec` in all other cases,
      // including Yarn 1.x and other custom npm clients.
      return 'npm exec'
    })
  const [command, ...args] = fullCustomCommand.split(' ')
  // 执行命令
  const { status } = spawn.sync(command, args, {
    stdio: 'inherit'
  })
  console.log(status, status ?? 0)
  // 退出进程,0 正常,1 取消操作/异常
  process.exit(status ?? 0)
}
console.log(`\nScaffolding project in ${root}...`)

4.5.1 pkgFromUserAgent 函数

// 获取包管理器名称和版本
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]
  }
}

4.6 获取框架模板

// 理解 path.resolve
// 从后向前,若字符以 / 开头,不会拼接到前面的路径
// 若以 ../ 开头,拼接前面的路径,且不含最后一节路径
// 若连续出现多个 ../../.. 或者 ../.. 则忽略前方 .. 个路径名进行拼接
// 若以 ./ 开头或者没有符号 则拼接前面路径
// fileURLToPath: file:///xx/index.ts To /xx/index.ts
const templateDir = path.resolve(
  fileURLToPath(import.meta.url),
  '../..',
  `template-${template}`
)

可选择的框架模板

4.7 写入文件

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 是因为需要修改 package name 后写入
for (const file of files.filter((f) => f !== 'package.json')) {
  write(file)
}
// 模板框架 package.json 文件转为 JSON 对象
const pkg = JSON.parse(
  fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8')
)
// pkg name 为指定项目名称
pkg.name = packageName || getProjectName()
// 往指定项目中写入 package.json
write('package.json', JSON.stringify(pkg, null, 2))

4.7.1 copy 函数

// 复制文件和用copyDir复制文件夹
function copy(src: string, dest: string) {
  // 获取文件信息状态实例
  const stat = fs.statSync(src)
  if (stat.isDirectory()) {
    copyDir(src, dest)
  } else {
    fs.copyFileSync(src, dest)
  }
}

4.7.2 copyDir 函数

// 复制文件夹
function copyDir(srcDir: string, destDir: string) {
  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)
  }
}

4.8 安装完成后,打印提示信息

// 执行完成,提示可执行命令
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
}
console.log()

5 总结

解读完 create-vite 源码,可以从中学到很多优秀的思想,不到 500 多行的源代码,非常轻便