学习create-vite,从零到一实现一个自己的 mini-create

0. 前言

本文参加了由公众号@若川视野 发起的每周源码共读活动点击了解详情一起参与。

本篇是源码共读第37期 | vite 3.0 都发布了,这次来手撕 create-vite 源码,点击了解本期详情

1. npm create vite 执行原理

我们通过 npm create vite 来创建一个 vite 的项目,那么这一行命令到底背后是如何执行的呢?

通过 npm 官方文档 我们可以知道,npm create vite 其中 createinit 的别名,也就是说等于执行了 npm init vite 。平常我们初始化一个 package.json 的时候,会用到 npm init,通过交互式生成一个 package.json 文件,后面跟了一个 vite 参数后,变成通过 npm-exec 去安装 create-vite 这个包,然后执行其 bin 对应的执行文件。

image.png

总结,需要两点

  • 包名以 create 开头
  • bin 执行脚本

2. 如何 debug

README.mdCONTRIBUTING.md,主要是 CONTRIBUTING.md 文件,README.md 是对工具的介绍,CONTRIBUTING.md 是给贡献者看的,所以就会有介绍环境如何搭建。

  1. Run pnpm i in Vite's root folder.
  2. Run pnpm run build in Vite's root folder.

在根目录执行以一下两步

  1. pnpm i 安装依赖包
  2. pnpm run build 打包

执行完以上两步之后,我们在 packages/create-vite/ 下就可以看到一个dist文件。

前面说到执行 npm create vite 其实是执行 create-vite 这个包的 bin 执行文件。

我们可以看一下 packages/create-vite/package.json

{
 "name": "create-vite",
  "version": "4.3.2",
  "type": "module",
  "license": "MIT",
  "author": "Evan You",
  "bin": {
    "create-vite": "index.js", // bin 执行文件
    "cva": "index.js"
  },
  ...
}

bin.create-vite 指向了 index.js

index.js

#!/usr/bin/env node

import './dist/index.mjs'

导入了 ./dist/index.mjs,是打包后的文件,被压缩过。

CONTRIBUTING.md 的Debugging下介绍了怎么做

create-vite 下执行 pnpm run dev

我们再来看 dist/index.mjs

import jiti from "file:///Users/liuyonggui/Documents/source/vite/node_modules/.pnpm/[email protected]/node_modules/jiti/lib/index.js";

/** @type {import("/Users/liuyonggui/Documents/source/vite/packages/create-vite/src/index")} */
const _module = jiti(null, { interopDefault: true, esmResolve: true })("/Users/liuyonggui/Documents/source/vite/packages/create-vite/src/index.ts");

export default _module;

通过 jiti 这个包,指向了 /src/index.ts 文件

jiti: Runtime Typescript and ESM support for Node.js

接下来就可以在 src/index.ts 的入口函数 init 函数打断点了。

然后运行在 create-vite 目录下执行 node index.js ,就可以进入断点了。

3.源码分析

3.1 用户交互前

源码

  ...
  const argTargetDir = formatTargetDir(argv._[0]);
  const argTemplate = argv.template || argv.t;

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

都是变量的定义,从变量名称可以容易的知道

  • argTargetDir: 命令行参数里的目标文件夹
  • argTemplate: 命令行参数里的模板
  • targetDir: 最终的目标文件夹
  • getProjectName: 获取项目名称的函数

argv 是怎么来的?用到了 minimist 这个包

const argv = minimist<{
  t?: string
  template?: string
}>(process.argv.slice(2), { string: ['_'] })

3.2 用户交互结果收集

源码

  try {
    result = await prompts(
      [
        {
          type: argTargetDir ? null : 'text',
          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?`,
        },
        {
          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()),
          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:'),
          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,
              }
            }),
        },
      ],
      {
        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

这里用到了 prompts 这个包,是用来做命令行交互,收集用户选择信息的。 我们可以看最后收集到的是 framework, overwrite, packageName, variant 这个几个值。

  • overwrite:确定要不要覆盖项目最后生成的目录
  • packageName: package.json 文件中的 name
  • framework和variant:通过这两个值确定最后的模板

3.3 目标文件夹处理


  const root = path.join(cwd, targetDir) // 目标文件夹根目录

  if (overwrite) { 
    emptyDir(root)  // 清空目录
  } else if (!fs.existsSync(root)) { 
    fs.mkdirSync(root, { recursive: true }) // 不存在时,创建一个目录,recursive 为 true,表示如果文件夹不存在时,递归创建,比如/a/b  a不存在会创建一个a文件夹,a文件夹下创建b文件夹 
  }

...
function emptyDir(dir: string) {
  if (!fs.existsSync(dir)) { // 不存在,则直接返回
    return
  }
  // 遍历文件夹下的每个文件、文件夹
  for (const file of fs.readdirSync(dir)) { 
    if (file === '.git') { // 保留 .git 文件
      continue
    }
    fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }) // 递归删除
  }
}

3.4 确定模板

源码

  let template: string = variant || framework?.name || argTemplate
  let isReactSwc = false
  if (template.includes('-swc')) {
    isReactSwc = true
    template = template.replace('-swc', '')
  }

// 通过process.env.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) { // 主要是根据不同的包管理器,有部分模板需要执行特殊的命令
    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)
  }

3.5 下载模板

源码

// 确定模板的地址
 const templateDir = path.resolve(
    fileURLToPath(import.meta.url),
    '../..',
    `template-${template}`,
  )  

 // 文件写入函数
  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)
  // 遍历模板文件,进行写入
  for (const file of files.filter((f) => f !== 'package.json')) {
    write(file)
  }

// 获取 packageName,写入修改name后的内容
  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')

//  如果有swc的,替换一下插件
  if (isReactSwc) {
    setupReactSwc(root, template.endsWith('-ts'))
  }

上面用到的方法


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

function setupReactSwc(root: string, isTs: boolean) {
// 安装包替换
  editFile(path.resolve(root, 'package.json'), (content) => {
    return content.replace(
      /"@vitejs\/plugin-react": ".+?"/,
      `"@vitejs/plugin-react-swc": "^3.3.2"`,
    )
  })
// 插件替换
  editFile(
    path.resolve(root, `vite.config.${isTs ? 'ts' : 'js'}`),
    (content) => {
      return content.replace('@vitejs/plugin-react', '@vitejs/plugin-react-swc')
    },
  )
}

function editFile(file: string, callback: (content: string) => string) {
  const content = fs.readFileSync(file, 'utf-8')
  fs.writeFileSync(file, callback(content), 'utf-8')
}

3.6 最后输出一下打印信息

源码

const cdProjectName = path.relative(cwd, root)
  console.log(`\nDone. Now run:\n`)
  if (root !== cwd) {
    console.log(
      `  cd ${
        cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName
      }`,
    )
  }
  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()

4. 自己实现一个mini版本的 create-cli

通过上面的源码分析,我们知道要实现这样一个create-cli,最主要的步骤为

  1. 解析命令行参数 (minimist)
  2. 用户选择(可选) (prompts)
  3. 找到对应的模板拷贝到指定的目录(fs-extra)

4.1 创建文件目录

创建一个文件夹,执行 pnpm init,修改package.json中的 namecreate-ab

{
  "name": "create-ab",   // 包名
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "bin": {
    "create-ab": "./index.js"   // bin执行文件
  },
  ...
}

然后创建一个 index.js

#!/usr/bin/env node

console.log('this is a mini create cli');

最终目录

.
├── README.md  # 写一些这个包的描述
├── index.js
└── package.json

这样一个最简陋的架子就搭建好了,可以发布个npm包试试效果。

# 因为 默认的仓库源用的是淘宝源,所以加个发布的仓库为官方源
npm publish --registry=https://registry.npmjs.org   

发布成功后,就可以执行 pnpm create ab

Pasted image 20230709061320.png

4.2 收集命令行参数

主要要收集2个信息,一个是目标目录,一个是选择的模板,我们暂时都通过命令行参数中获取。 这里使用跟 create-vite 一样的包,minimist

先安装依赖包

pnpm i minimist

编写 index.js 文件

#!/usr/bin/env node

const minimist = require('minimist');

const argv = minimist(process.argv.slice(2));

// 入口函数
function init() {
  console.log(argv);
}

// 执行
init();

最终执行一下, 我们用 template 表示选择的模板的名字,name 表示项目的名字

node index.js --template a --name my-app

Pasted image 20230709062422.png

![[Pasted image 20230709062422.png]]

4.3 开始下载模板

拿到模板的名字和项目的名字,我们就可以开始下载模板了。 现在项目中创建两个示例模板

.
├── README.md
├── index.js
├── package.json
├── template-a
│   ├── index.js
│   ├── package.json
│   └── public
│       └── config.js
└── template-b
    ├── index.js
    ├── package.json
    └── public
        └── config.js

模板里面有一级目录,也有二级目录

拷贝文件,在 create-vite 中是自己实现的,我们这里可以借助 第三方包 fs-extra ,它是对 fs 模块的扩展,比如我们这里要用到的递归拷贝整个文件夹,原生的fs模块是没有的,只能拷贝文件。

安装依赖包

pnpm i fs-extra

编写逻辑

#!/usr/bin/env node

const minimist = require('minimist');
const fse = require('fs-extra');
const path = require('path');

const argv = minimist(process.argv.slice(2));

function init() {
  const { template, name } = argv;

  const targetDir = path.resolve(process.cwd(), name); // 目标文件夹,就是当前目录 + 项目的名字
  const templateDir = path.resolve(__dirname, `template-${template}`); // 模板所在的路径

  fse.copySync(templateDir, targetDir);  // 拷贝整个模板目录
}

init();

这样再执行上面的命令

node index.js --template a --name my-app

就可以看到当前目录下多了一个 my-app ![[Pasted image 20230709064257.png]]

为了更友好一些,需要将 package.json 的name也要修改一下。

  let pkg = JSON.parse(fse.readFileSync(path.resolve(targetDir, 'package.json'), 'utf-8'));
  pkg.name = name;
  fse.writeFileSync(path.resolve(targetDir, 'package.json'), JSON.stringify(pkg, null, 2));

这样最终生成的 package.json 中的 name 就是 my-app

Pasted image 20230709064257.png

4.4 用交互式的方式,让用户选择模板

这里需要用到 prompts 这个包,

pnpm i prompts

然后编写提示的逻辑,这里需要等待用户的输入完成,所以需要将init函数变为异步函数

async function init() {
  const { template, name } = argv;

  const result = await prompts([
    {
      type: name ? null : 'text',
      name: 'name',
      message: 'please input your project name',
    },
    {
      type: template ? null : 'select',
      name: 'template',
      message: 'please select a template',
      choices: [
        {
          title: 'templateA',
          value: 'a',
        },
        {
          title: 'templateB',
          value: 'b',
        },
      ],
    },
  ]);

  const templateName = template || result.template;
  const projectName = name || result.name;
  
  const targetDir = path.resolve(process.cwd(), projectName);
  const templateDir = path.resolve(__dirname, `template-${templateName}`);
  fse.copySync(templateDir, targetDir);

  let pkg = JSON.parse(fse.readFileSync(path.resolve(targetDir, 'package.json'), 'utf-8'));
  pkg.name = projectName;
  fse.writeFileSync(path.resolve(targetDir, 'package.json'), JSON.stringify(pkg, null, 2));
  
}

type为null时,则不用进行选择,用户手动输入的优先级更高。

至此,整个代码实现就完成了。附上一下 源码链接

最后发布一下

npm publish --registry=https://registry.npmjs.org

这样就要可以使用了

pnpm create ab

image.png

5 总结

知道了整个实现的思路和原理之后,就会发现 create-vite 的实现原理其实很简单。

用到了几个第三方包 minimistprompts

mini-create 部分没有实现对包管理的处理、 create-vitets 部分,以及用 kolorist 对选项加色等功能,但整体的思路是有了。这样以后自己要实现一个自己的 create-xxx 就不慌了。

最后说一下,平常不写文章,发现写文章比理解原理还难,还是要多写才行。

猜你喜欢

转载自juejin.im/post/7253389022939136061