Learn create-vite and realize your own mini-create from zero to one

0. Preface

This article participated in the weekly source code co-reading activity initiated by the public account @朝川视频 , click for details and participate together.

This article is the 37th issue of source code reading | vite 3.0 has been released, this time I will tear the source code of create-vite by hand, click to learn more about this issue

1. npm create vite execution principle

We use npm create viteto create a vite project, so how is this line of command executed behind the scenes?

From the npm official documentation, we can know npm create vitethat createis initan alias of , which means it is executed npm init vite. Usually when we initialize a package.json, we will use npm initit to generate a package.json file interactively, followed by a viteparameter, and then it will be used to npm-execinstall create-vitethe package through and then execute its bincorresponding execution file.

image.png

To sum up, two points

  • Package name createstarts with
  • Yes binExecute the script

2. How to debug

See README.mdand CONTRIBUTING.md, mainly CONTRIBUTING.mddocuments, README.mdwhich are introductions to tools and CONTRIBUTING.mdare for contributors, so there will be introductions on how to build the environment.

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

Execute the following two steps in the root directory

  1. pnpm iInstall dependencies
  2. pnpm run buildPack

执行完以上两步之后,我们在 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]]

In order to be more friendly, the name of package.json needs to be modified as well.

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

In this way, the name in the final generated package.json is my-app

Pasted image 20230709064257.png

4.4 Let the user choose a template in an interactive way

Here you need to use the prompts package,

pnpm i prompts

Then write the logic of the prompt, here you need to wait for the user's input to complete, so you need to change the init function into an asynchronous function

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

When type is null, there is no need to select, and the user's manual input has a higher priority.

At this point, the entire code implementation is complete. Attach a link to the source code

Last post

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

This will make it usable

pnpm create ab

image.png

5 summary

After knowing the idea and principle of the entire implementation, you will find create-vitethat the implementation principle of is actually very simple.

Used several third-party packages minimist, prompts.

mini-createPart of it has not realized the processing of package management, the part create-viteof it ts, and koloristthe function of adding color to options with , but the overall idea is there. create-xxxIn this way, you don’t have to panic if you want to realize your own experience in the future .

Finally, I would like to say that if I don’t usually write articles, I find that writing articles is more difficult than understanding the principles, so I still need to write more.

Guess you like

Origin juejin.im/post/7253389022939136061