Vite 初始化项目都做了些什么?

779 阅读10分钟
vite

我正在参与掘金会员专属活动-源码共读第一期,点击参与

前言

相信不少朋友都使用过 Vitenpm create vite@latest 命令来初始化 Vue 项目或者React 项目,如果你对该命令的工作原理十分好奇,那巧了,本文将会揭开 npm create vite@latest 的神秘面纱,让你学会搭建属于自己的一个脚手架。

前置知识

在解析 npm create vite@latest 原理之前,我们需要先了解一下 npm create vite@latest 命令的实际运行命令是什么;cross-spawn 包的用法,minimist 包的用法,kolorist 包的用法,prompts 包的用法,这三个包都是 Vite 在执行命令时所用到的。

npm create 的实际命令

我们平时运行某个包常见的方式有两种:

  1. package.json 中的 scripts 字段编写好对应的脚本命令,通过 npm run xxx 的方式运行某个包
  2. 直接在终端输入 npx <包名> 命令,即可运行某个包,比如:npx jest,就会去运行 jest,跑整个项目的测试用例。

npm create vite@latest 又是怎么回事呢?

其实,它就相当于是 npx create-vite@lastestlastest 是最新的版本号。

所以,本文要探究的就是 create-vite 包的原理,它位于 vite 项目中 packages 文件夹下。

npx create-vite@lastest 运行的又是哪个文件呢?我们找到 create-vite 文件夹下 package.json 文件,找到 bin 字段的值:

npx create-vite@lastest

可以看到 npx create-vite@lastest 运行的是 index.js 文件。

cross-spawn

其实这个库在接下来要解析的主要源码中是用不到的,但 create-vite 既然在少数情况下用到了它,在这里就简单地介绍一下。

Node.js 中,可以使用 child_process 模块来创建子进程,child_process 模块下有一个名为 spawnSync (或 spawn)的函数,是用来同步(spawn 为异步)创建运行在系统上的命令,比如创建一个 npm install 的命令:

const spawnSync = require('child_process').spawnSync;
 
spawnSync('npm', ['install'], {
  stdio: 'inherit'
});
// 相当于在终端执行 npm install 命令

这段代码在 LinuxmacOS 系统上能够执行,但在 Windows 系统上执行会报错。

所以,cross-spawn 包就提供了关于 spawnSync (或 spawn) 函数的跨平台写法,不用开发人员处理跨平台的逻辑。其用法跟上面代码基本一样:

const spawn = require('cross-spawn');

spawn.sync('npm', ['install'], {
  stdio: 'inherit'
});

Github 地址 — cross-spawn

minimist

minimist 是一个命令行参数解析包。在 Node.js 中,可以使用 process.argv 获取命令行的参数,比如在终端运行命令 node index.js create --template vue,并在 index.js 中打印 process.argv,你会在终端看到以下内容:

process.argv
  1. 第一个元素是执行 node 的地址
  2. 第二个元素是正在执行的 JavaScript 文件的路径
  3. 其余元素将是任何其他命令行参数

通常情况下,开发人员想要拿到的是选项参数值 — --xxx-xxx 对应的值,在这里就是获取 --template 对应的 vue 值。虽然现在可以通过 process.argv[4] 拿到,但实际情况是会存在一个到多个 --xxx-xxx,并不好获取,需要进行逻辑处理。

minimist 包就是用来解决这个痛点的,我们在 index.js 中引入 minimist,并使用它:

import minimist from "minimist";

console.log(minimist(process.argv));

得到结果:

minimist

可以看到,minimist 将命令行参数解析成了对象,非选项参数放在了键 _ 的数组上,而选项参数则是键值对的形式展示,这样,我们就可以很方便地获取选项参数的值。

一般情况下,process.argv 的前两个元素基本上就是 node 的地址和正在执行的JavaScript 文件的路径,不会动态变化了,在开发中,也基本不需要获取这两个值,所以可以获取第三个及后面的参数 — minimist(process.argv.slice(2))

Github 地址 — minimist

kolorist

颜色化标准的输入和输出,用法也很简单:

import {
  blue,
  cyan,
  green,
  lightGreen,
  lightRed,
  magenta,
  red,
  reset,
  yellow,
} from "kolorist";

console.log("blue ->", blue("vite"));
console.log("cyan ->", cyan("vite"));
console.log("green ->", green("vite"));
console.log("lightGreen ->", lightGreen("vite"));
console.log("lightRed ->", lightRed("vite"));
console.log("magenta ->", magenta("vite"));
console.log("red ->", red("vite"));
console.log("reset ->", reset("vite"));
console.log("yellow ->", yellow("vite"));

结果如下:

kolorist

值得注意的是,reset 代表重置为系统的默认颜色。

Github 地址 — kolorist

prompts

prompts 是一个交互命令行提示工具,当你在终端运行 npm create vite@latest 命令时,想必会经历这样的过程:

kolorist
  1. 输入项目的名称
  2. 选择项目的框架
  3. 选择项目的编程语言

这一系列的交互行为就是通过 prompts 实现的。

它最基础的用法是单一提示:

const prompts = require('prompts');

(async () => {
  const response = await prompts({
    type: 'number',
    name: 'age',
    message: 'How old are you?'
  });

  console.log(response);
})();

type 表示该提示的类型,它常用的值有:numbertextconfirmselect 等,可类比于 input 标签的类型,这些值也可以通过函数来动态返回。type 也可以是 null,如果是 null,则会跳过这个交互。

name 表示该提示的名称,作为键存储在 prompts 函数返回的结果中,值就是用户输入的内容。

message 表示该提示要向用户显示的消息。简单来说就是该提示的问句。

运行以上代码会得到如下结果:

prompts

当然了,强大的 prompts 也可以进行多个提示的交互方式:

import prompts from "prompts";

(async () => {
  const response = await prompts([
    {
      type: "text",
      name: "username",
      message: "what's your name?",
    },
    {
      type: "number",
      name: "age",
      message: "How old are you?",
    },
  ]);

  console.log(response);
})();

运行结果如下:

prompts

Github 地址 — prompts

源码准备

Vite 的 GitHub 仓库地址克隆到本地,从 packages 文件中可以看出,Vite 采用的是 Monorepo 多包管理方式。

1685245014496.png

因此,我们需要全局安装 pnpm,运行命令 npm install -g pnpm(如果已安装了 pnpm ,请省略这一步)。接着,通过 pnpm 安装依赖,运行命令 pnpm install

而本文要分析的是 packages 文件夹下的 create-vite 包的源码,位于其 src/index.ts 文件中,所以需要安装 ts-node 命令行工具,运行命令 npm install -g ts-node,后续通过 ts-node 直接运行 index.ts 文件。

接下要进入调式模式,在 VSCode 终端选择 JavaScript Debug Terminal

VS Code

接着输入命令 cd ./packages/create-vite/src,并找到源码文件所在的目录 — packages/create-vite/src,打开 index.ts,找到 init() 函数(因为它是整个程序的入口),在 init() 函数处打下一个断点,在 Debug 终端运行命令 ts-node --esm index.ts(相当于执行了 npm create vite@latest),VSCode 就进入了调试状态。

VSCode Debug

接下来,我们就可以开始源码解析啦!

源码解析

本文会通过调试的方式一步步解析源码,如果对调式方式不熟悉的话,可以先看这篇文章 — 新手向:前端程序员必学基本技能——调试JS代码

我们先来看看这几个常量,变量和函数:

// 获取命令行参数
const argv = minimist<{
  t?: string
  template?: string
}>(process.argv.slice(2), { string: ['_'] })

// 将一个或多个 '/' 转换成空字符串
function formatTargetDir(targetDir: string | undefined) {
  return targetDir?.trim().replace(/\/+$/g, '')
}

// 默认的项目名称
const defaultTargetDir = 'vite-project'

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

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

let result: prompts.Answers<
  'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'
>

如果初始化项目输入的命令是 npm create vite@latest my-vue-app --template vue,那么 argTargetDir 的值为 my-vue-app,代表项目的名称,argTemplate 的值为 vue,代表项目所用的框架(模板)。

很明显,当前我们并没有通过命令行的方式指定项目名称和项目的框架,因此这两个常量的值为 undefined

我们连续按两下 F10,并将鼠标分别悬浮到 argTargetDirargTemplate,可以清楚地看到值为 undefined

代码执行到变量 targetDir 处,该变量代表一开始的项目名称,现在的初始化值为 vite-project。继续按下 F10

代码执行到函数 getProjectName,其功能是获取项目名称。继续按下 F10

代码执行到变量 result 处,该变量代表交互命令行提示工具 prompts 函数返回的结果,初始化值为 undefined

搞清楚上面的几个常量,变量和函数的含义之后,我们先在代码 300 行处打断点。

VSCode Debug

接着再来分析分析,216 行到 293 行的代码做了什么事情:

result = await prompts(
  [
    // 输入项目名称
    {
      type: argTargetDir ? null : 'text',
      name: 'projectName',
      message: reset('Project name:'),
      initial: defaultTargetDir,
      onState: (state) => {
        targetDir = formatTargetDir(state.value) || defaultTargetDir
      },
    },
    // 选择框架
    {
      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')
    },
  },
)

我把其中的 227 行到 253 行的代码去掉了,因为它们不会被执行到,简单说一下,这些代码主要的作用是针对输入的项目名称已经存在的情况下,进行重写项目名称的操作。

代码中涉及到的常量 FRAMEWORKSTEMPLATES,分别表示框架的信息和获取所有框架的脚手架模板,它们在代码 41 行到 195 行处。

VSCode Debug

prompts 函数里面主要写了三个交互,分别是:

  1. 输入项目名称
{
  type: argTargetDir ? null : 'text',
  name: 'projectName',
  message: reset('Project name:'),
  initial: defaultTargetDir,
  onState: (state) => {
    targetDir = formatTargetDir(state.value) || defaultTargetDir
  },
},

initial 表示该交互的初始值是 defaultTargetDirvite-projectonState 函数是当该交互输入内容改变时的回调函数,通过 state.value 可以获取到输入的内容,最终赋值给变量 targetDir

  1. 选择项目的框架
{
  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,
    }
  }),
},

如果是通过命令行参数指定的项目框架,并且指定的框架包括在 Vite 脚手架模板中,那么 type 值为 null,就会跳过这次交互。而经过上文对 argTemplate 的分析,现在 type 值为 select,表示选择框架,初始值为第一个框架。

  1. 选择项目的编程语言(变种)
{
  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,
      }
    }),
},

typechoices 是一个函数,函数的参数是上一次交互的值,也就是选择的框架。

了解上述代码的基本逻辑之后,我们按下 F5,接着就会进行上述一系列的提示问答交互:

VSCode Debug

接下来,我们再看看 300 行到 308 行处代码做了什么:

const { framework, overwrite, packageName, variant } = result

const root = path.join(cwd, targetDir)

if (overwrite) {
  emptyDir(root)
} else if (!fs.existsSync(root)) {
  fs.mkdirSync(root, { recursive: true })
}

通过 prompts 函数返回的结果获取到选择的框架(framework) 和选择的编程语言(variant),常量 root 表示所要创建项目的根目录,通过 fs.mkdirSync() 方法进行创建。

我们在代码 311 行处打上断点,按下 F5,让上述代码执行。你就会发现 src 文件夹下多了一个 vue-project 的文件夹。

VSCode Debug

再往代码 360 行处打上断点,分析代码 311 行到 360 行做了什么:

let template: string = variant || framework?.name || argTemplate

const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'

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

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

上述代码是我将一些不运行的代码删除后的样子,来稍微解释一下这几个常量和变量的含义:

  1. template 表示框架模板,它是给后面用来获取框架模板文件夹用的。
  2. pkgInfo 表示从 UA 中获取包管理工具的信息,在这里的值是 undefined
  3. pkgManager 表示包管理工具,现最终值为 npm
  4. templateDir 表示框架模板文件夹的路径,这个获取的是什么呢?细心的朋友可能已经发现了,在 create-vite 文件夹下有很多名字以 template-xxx 形式的文件夹,templateDir 就是获取这些文件夹的路径。

而接下来要做的就只有一件事了,那就是把某个 template-xxx 文件夹的内容复制到刚刚我们自己输入的项目名称文件夹下,也就是把 template-vue-ts 文件夹的内容复制到 vue-project 文件夹。

我们直接看最后的代码 — 360 行到 405 行的代码,同样地,我也会把一些不执行的代码删除,以及附加一些相关的函数代码。为了看到效果,我们可以先在代码 374 行和382 行处分别打下断点。

// 文件夹复制
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 copy(src: string, dest: string) {
  const stat = fs.statSync(src)
  if (stat.isDirectory()) {
    copyDir(src, dest)
  } else {
    fs.copyFileSync(src, dest)
  }
}
// 向 vue-project 文件夹写入文件内容
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' 文件进行立马复制
for (const file of files.filter((f) => f !== 'package.json')) {
  write(file)
}
// 读取模板中的 'package.json' 文件内容
const pkg = JSON.parse(
  fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8'),
)
//修改 'package.json' 文件中 'name' 字段的值
pkg.name = packageName || getProjectName()
// 复制 'package.json' 文件
write('package.json', JSON.stringify(pkg, null, 2) + '\n')

// 后面代码就是控制 log 的输出内容

我们先从 369 行代码看起,通过 fs.readdirSync() 方法读取 template-vue-ts 文件夹的内容,并赋值给常量 filestemplate-vue-ts 文件夹的内容如下图所示:

VSCode Debug

再通过 for 循环调用 write 函数,它的功能是将 template-vue-ts 下的文件夹或文件(除了 package.json 文件)复制给 vue-project,或者是对 vue-project 文件夹写入文件内容。

write 函数内部调用了 copy 函数,它的功能就是实现文件的复制,如果传入的文件是一个文件夹,那么又会交给 copyDir 函数执行,进行文件夹的复制。copy 函数和 copyDir 函数之间存在相互调用的情况,从而达到递归复制文件的效果。

当我们这时按下 F5 之后,你会发现 vue-project 文件夹出现了很多文件。如下图:

VSCode Debug

眼尖的朋友应该发现了,缺少 package.json 文件,接下来的 374 行到 380 行代码就是向 vue-project 写入 package.json 文件内容。

为什么一开始不对 package.json 文件复制呢?因为需要修改 package.jsonname 字段的值为项目名称,修改完后,再调用 write() 函数进行文件内容的写入。

我们再按下 F5,就会看到有 package.json 文件了。

至此,这就是 Vite 在初始化项目中最基本的流程了。

总结

  1. npm create vite@latest 实际上执行的是 npx create-vite@lastest 命令。
  2. create-vite 源码依赖包有三个,命令行参数解析包 — minimist终端颜色输入输出包 — kolorist交互命令行提示工具 — prompts
  3. create-vite 主要做了三件事情,分别是:
  • 创建项目目录
  • template-xxx 的内容复制到项目目录下面
  • 修改 template-xxxpackage.json 文件中 name 字段的值,最后将 package.json 文件写入项目目录。