【源码学习】第⑦期|想开发脚手架却无从下手?跟create-vite get脚手架的正确打开方式

153 阅读5分钟

前言

    不是吧,不是吧,vite在7月份都发布3.0了,你却还跟我这个大怨种一样停留在只会enter create-vite新建项目😭?更别提要开发自己的脚手架了,要知道知其然才能更好的知其所以然,今天就来一起捋捋create-vite的源码,掌握一下它的原理吧!

任务清单

这一part虽然很鸡肋,但是通关式的学习很有成就感哇~

  • 下载调试create-vite源码
  • 分析源码
  • 总结归纳

学前准备

2.1 npm create
    老规矩,学习源码前要养成使用说明 README 的好习惯,浅看一下用法: image.png     用我蹩脚的英文翻译一下就是vite对node.js环境的要求是14.18+, 16+,有些甚至要求更高一点,然后用法是npm create vite,emmm...,npm init、npm install见惯不怪了,npm create好像有那么点陌生,那就在npm Docs上查查资料,原来npm create是npm init的另一种叫法,涨芝士了~

image.png     顺带记一下怎么查看最新版本的

npm dist-tag ls create-vite
// latest: 3.0.2

2.2 克隆库

// 以下两种方式都可以
// ①官网是ts版本的
git clone https://github.com/vitejs/vite.git
cd vite/packages/create-vite
// 全局安装pnpm npm i pnpm -g
pnpm install

// ②习惯用js的也可以直接克隆若川大佬的库
git clone https://github.com/lxchuan12/vite-analysis.git 
cd vite-analysis/vite2
# npm i -g pnpm pnpm install 
# 在这个 index.js 文件中断点 
# 在命令行终端调试 
node vite2/packages/create-vite/index.js

    vite的CONTRIBUTING.md 还详细写了debug方法,可以说很贴心了

  • 开启 script 调试 image.png
  • 开启调试后执行 pnpm run dev 生成 dist/index.mjs image.png 2.3 调试ts
  • 由于3.0.2是用ts写的,顺带记录一下如何用vscode调试typescript,先是安装了一下扩展@category:debuggers TypeScript,不过我通过launch.json调试是一直不生效的,有好的解决方案的也欢迎多多指教哇~

image.png

  • 下面推荐生效的调试方式
//  ① 安装ts-node typescript
pnpm i ts-node typescript -D
node --loader ts-node/esm ./src/index.ts
// ② 通过esno执行ts
npx esno src/index.ts
  • ts-node调试截图 f28f7453824e180954b5750fbcccadf.jpg
  • esno 运行截图

image.png

源码分析

3.1 引入依赖

// 文件模块
import fs from 'node:fs'
// 路径模块
import path from 'node:path'
// url模块 fileURLToPath函数将文件URL解码为路径字符串,并确保在将给定的文件URL转换为路径时正确地附加/调整了URL控制字符(/,%) 
import { fileURLToPath } from 'node:url'
// 根据node.js的子进程(child_process)模块下的spawn函数封装,可以在调用 spawn 函数时,自动根据当前的运行平台,来决定是否生成一个 shell 来执行所给的命令
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'

3.2 定义关键参数

// 命令行选项对象
const argv = minimist<{
  t?: string
  template?: string
}>(process.argv.slice(2), { string: ['_'] })
// node.js 进程执行时的文件夹地址
const cwd = process.cwd()
// 颜色函数类型
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'
      }
    ]
  }
]
// 模板名字数组
const TEMPLATES = FRAMEWORKS.map(
  (f) => (f.variants && f.variants.map((v) => v.name)) || [f.name]
).reduce((a, b) => a.concat(b), [])
// 重命名文件
const renameFiles: Record<string, string | undefined> = {
  _gitignore: '.gitignore'
}
// 默认目标路径
const defaultTargetDir = 'vite-project'

4 init函数拆分

  • 4.1 输出目标路径
  const argTargetDir = formatTargetDir(argv._[0])
  const argTemplate = argv.template || argv.t

  let targetDir = argTargetDir || defaultTargetDir
  const getProjectName = () =>
  targetDir === '.' ? path.basename(path.resolve()) : targetDir
  

formatTargetDir函数替代 /'',trim()去空格,

function formatTargetDir(targetDir: string | undefined) {
  return targetDir?.trim().replace(/\/+$/g, '')
}

  • 4.2 询问项目名\是否重写路径\包名\框架\变体等
    framework:框架, overwrite:是否重写目录, packageName:包名, variant:变体,如vue - vue-ts
  let result: prompts.Answers<
    'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'
  >

  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
  }

  // user choice associated with prompts
  const { framework, overwrite, packageName, variant } = result

  • 4.3 重写目录/创建空目录实现
 const root = path.join(cwd, targetDir)

  if (overwrite) {
  // 目录存在则递归删除
    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
    }
    // recursive是否递归
    fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
  }
}

  • 4.5 获取模板路径信息,若有自定义命令则批处理执行
 // determine template
  const template: string = variant || framework || argTemplate
// 获取用户包管理器信息
  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) {
  // 若有定义命令则根据包信息替换变体的执行命令,这里用spawn批处理执行命令
    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'
    })
    process.exit(status ?? 0)
  }

  console.log(`\nScaffolding project in ${root}...`)

  const templateDir = path.resolve(
    fileURLToPath(import.meta.url),
    '../..',
    `template-${template}`
  )

  • 4.6 写入函数
    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)
    }
  }

copy&&copyDir函数

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) {
  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.7 根据模板路径的文件依次写入目标文件
  const files = fs.readdirSync(templateDir)
  for (const file of files.filter((f) => f !== 'package.json')) {
    write(file)
  }

  const pkg = JSON.parse(
    fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8')
  )

  pkg.name = packageName || getProjectName()
  // package.json单独处理
  write('package.json', JSON.stringify(pkg, null, 2))

  • 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()
}

运行结果截图

image.png

总结

    今天运行调试了create-vite3.0.2的源码,分析了脚手架的大体实现思路,感谢若川大佬的指导才第一次完整地实践了调试源码中的ts代码,边调试边分析才能更好地掌握其原理,言归正传,create-vite的主要实现还是依靠node.js的文件模块达到写入文件的目的,3.0.2较3.0.0版本增加了cross-spawn批处理命令外还改用了ts书写,不止create-vite其实大多数插件也越来越偏向于使用ts代替js,这也启发我们在日常项目中也可以用ts规范书写,当然前端想要开发脚手架学好node.js还是yyds

参考文献