yliu

时来天地皆同力,运去英雄不自由

vite 源码解析之 create-vite


home

如题这是一个系列文章不过更新起来可能很缓慢,从 vite 出来之际我就开始关注,目前 npm 的包下载量为 1,589,416+,可以看到已经非常稳定了。而且开发十分香,完全就是开箱即用,下面就来探讨一下 vite 是如何将项目创建到目录中的。

使用方式

目前比较火的管理代码形式为 monorepo,Vue3 和 vite 都采取了这种方式,打开 packages 可以很明显看到分布在 packages 下的各个包,这篇文章重点聊一聊 create-vite

打开 create-vite 目录可以看到下面结构

界面

其中以 template- 开头的文件为模板文件,而 __test__ 开头的是测试文件,updateVersions.ts 是更新相关 template 文件夹下的 package.json 文件让其与 vite 的版本号保持一致。

我们重点看 index.js 文件,这个文件负责具体的创建,不过在说源码之前先看下文档的使用形式

  • npm init vite@latest
  • npm init vite@latest my-vue-app --template vue

这里 init 其实是一个快捷指令,它相当于把 create-vite 简化成只需要 init create-后面部分即可,yarn 下的 create 也跟 npm init 类似。

如果直接使用第一种形式,vite 会询问一系列信息,例如包的名称、模板等,而使用第二种形式则可以省略询问信息。

vite 镜像网站

源码

vite-create 使用了三个模块,这里提前说下它们的作用是什么

minimist

minimist 的作用就是将命令行输入的信息解析出来,例如上面我们使用 npm init vite@latest my-vue-app --template vue 它会将其解析成下面内容

{
  _: ['my-vue-app'],
  template: 'vue'
}

prompts

prompts 则是与用户交互的一个包,它提供了 inputselect 等交互方式,更多内容可以查看文档了解。

kolorist

kolorist 它是一个 color 包,主要作用就是让 node 展示的文字更有趣,不再是默认的颜色。

流程

vite-create 将任务放到了 init 函数中,为了保持阅读体验这里直接在代码中进行讲解

async function init() {
  // 获取默认输入的文件夹名称,例如 npm init vite@latest my-vue-app --template vue 这个时候 targetDir 为 my-vue-app
  let targetDir = argv._[0];
  // 获取是否有指定的 template
  let template = argv.template || argv.t;
  // 默认创建项目名称
  const defaultProjectName = !targetDir ? 'vite-project' : targetDir;
  // prompts 返回的一系列结果
  let result = {};

  /*
   * prompts 如果 type 为 null 不会执行下去,并且 prompts 的 tasks 是按照顺序执行下去的
   */

  try {
    result = await prompts(
      [
        // 如果没有指定 targetDir 则需要用户手动输入
        {
          type: targetDir ? null : 'text',
          name: 'projectName',
          message: reset('Project name:'),
          initial: defaultProjectName,
          onState: (state) =>
            (targetDir = state.value.trim() || defaultProjectName),
        },
        // 如果目标目录存在,要求用户指定处理方式,是删除还是退出
        {
          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?`,
        },
        //如果上一步选择删除为 false 退出
        {
          type: (_, { overwrite } = {}) => {
            if (overwrite === false) {
              throw new Error(red('✖') + ' Operation cancelled');
            }
            return null;
          },
          name: 'overwriteChecker',
        },
        // 校验输入项目名称是否符合 npm 名称,如果不符合规则则不能通过
        {
          type: () => (isValidPackageName(targetDir) ? null : 'text'),
          name: 'packageName',
          message: reset('Package name:'),
          initial: () => toValidPackageName(targetDir),
          validate: (dir) =>
            isValidPackageName(dir) || 'Invalid package.json name',
        },
        // 用户如果直接传递的 template 不存在模板中让其重新选择
        {
          type: template && TEMPLATES.includes(template) ? null : 'select',
          name: 'framework',
          message:
            typeof template === 'string' && !TEMPLATES.includes(template)
              ? reset(
                  `"${template}" 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.name),
              value: framework,
            };
          }),
        },
        // 选择是 js 项目还是 ts 项目
        {
          type: (framework) =>
            framework && framework.variants ? 'select' : null,
          name: 'variant',
          message: reset('Select a variant:'),
          // @ts-ignore
          choices: (framework) =>
            framework.variants.map((variant) => {
              const variantColor = variant.color;
              return {
                title: variantColor(variant.name),
                value: variant.name,
              };
            }),
        },
      ],
      // 如果没有选择 crrl + c 退出
      {
        onCancel: () => {
          throw new Error(red('✖') + ' Operation cancelled');
        },
      }
    );
  } catch (cancelled) {
    console.log(cancelled.message);
    return;
  }

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

  const root = path.join(cwd, targetDir);
  // 上面提到了如果目录存在,则要求进行删除
  if (overwrite) {
    emptyDir(root);
  } else if (!fs.existsSync(root)) {
    // 如果不存在目录创建
    fs.mkdirSync(root);
  }

  // determine template
  template = variant || framework || template;

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

  // 当前模板文件所在路径
  const templateDir = path.join(__dirname, `template-${template}`);

  const write = (file, content) => {
    const targetPath = renameFiles[file]
      ? path.join(root, renameFiles[file])
      : path.join(root, file);
    if (content) {
      fs.writeFileSync(targetPath, content);
    } else {
      copy(path.join(templateDir, file), targetPath);
    }
  };

  /*
   * 写入文件,package.json 单独处理
   */
  const files = fs.readdirSync(templateDir);
  for (const file of files.filter((f) => f !== 'package.json')) {
    write(file);
  }

  const pkg = require(path.join(templateDir, `package.json`));

  pkg.name = packageName || targetDir;

  write('package.json', JSON.stringify(pkg, null, 2));

  // 这里是查看调用程序的是 yarn 还是 npm 或者 pnpm
  const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent);
  const pkgManager = pkgInfo ? pkgInfo.name : 'npm';

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

上面的流程还是很清晰的概括下来就是

  • 要求用户输入创建所需要的项目名称(如果用户指定跳过
  • 如果项目存在,则询问是否删除
  • 校验输入的名称是否符合 npm.name 的要求
  • 如果用户指定 template 则进行校验,指定 template 如果不存在重新要求选择
  • 拉取指定模板仓库,将其 copy 到目标文件夹下
  • 修改 package.json 文件输入
  • 输出完成信息,结束

之前讲解 init 函数为了阅读省略了一些前置定义的变量,这里放出来。

const cwd = process.cwd();

const FRAMEWORKS = [
  {
    name: 'vanilla',
    color: yellow,
    variants: [
      {
        name: 'vanilla',
        display: 'JavaScript',
        color: yellow,
      },
      {
        name: 'vanilla-ts',
        display: 'TypeScript',
        color: blue,
      },
    ],
  },
  {
    name: 'vue',
    color: green,
    variants: [
      {
        name: 'vue',
        display: 'JavaScript',
        color: yellow,
      },
      {
        name: 'vue-ts',
        display: 'TypeScript',
        color: blue,
      },
    ],
  },
  {
    name: 'react',
    color: cyan,
    variants: [
      {
        name: 'react',
        display: 'JavaScript',
        color: yellow,
      },
      {
        name: 'react-ts',
        display: 'TypeScript',
        color: blue,
      },
    ],
  },
  {
    name: 'preact',
    color: magenta,
    variants: [
      {
        name: 'preact',
        display: 'JavaScript',
        color: yellow,
      },
      {
        name: 'preact-ts',
        display: 'TypeScript',
        color: blue,
      },
    ],
  },
  {
    name: 'lit',
    color: lightRed,
    variants: [
      {
        name: 'lit',
        display: 'JavaScript',
        color: yellow,
      },
      {
        name: 'lit-ts',
        display: 'TypeScript',
        color: blue,
      },
    ],
  },
  {
    name: 'svelte',
    color: red,
    variants: [
      {
        name: 'svelte',
        display: 'JavaScript',
        color: yellow,
      },
      {
        name: 'svelte-ts',
        display: 'TypeScript',
        color: blue,
      },
    ],
  },
];

const TEMPLATES = FRAMEWORKS.map(
  (f) => (f.variants && f.variants.map((v) => v.name)) || [f.name]
).reduce((a, b) => a.concat(b), []);

const renameFiles = {
  _gitignore: '.gitignore',
};

FRAMEWORKS 定义了模板的信息,而 TEMPLATES 简单来说就是将 TEMPLATES.name 下的信息返回, 配合 prompts 做校验和重新选择使用,它的值如下

[
  'vanilla',
  'vanilla-ts',
  'vue',
  'vue-ts',
  'react',
  'react-ts',
  'preact',
  'preact-ts',
  'lit',
  'lit-ts',
  'svelte',
  'svelte-ts',
];

renameFiles 则是重命名文件,将一些特殊的文件重新命名输出。

当然 vite-create 也用了 fspath 的一些方法,这里选取重点的几个函数讲解,剩余的几个函数可以自行去源码查看 create-vite/index.js

emptyDir

function emptyDir(dir) {
  if (!fs.existsSync(dir)) {
    return;
  }
  for (const file of fs.readdirSync(dir)) {
    const abs = path.resolve(dir, file);
    // baseline is Node 12 so can't use rmSync :(
    if (fs.lstatSync(abs).isDirectory()) {
      emptyDir(abs);
      fs.rmdirSync(abs);
    } else {
      fs.unlinkSync(abs);
    }
  }
}

这个方法是删除文件夹,node 的删除文件夹必须保证文件夹内没有文件,所以需要递归一层层的删除。

isEmpty

function isEmpty(path) {
  return fs.readdirSync(path).length === 0;
}

这个方法比较简单,判断目标文件夹的文件数量,如果不存在表示为空。

copy

function copy(src, dest) {
  const stat = fs.statSync(src);
  if (stat.isDirectory()) {
    copyDir(src, dest);
  } else {
    fs.copyFileSync(src, dest);
  }
}

配合 copyDir 方法,来完成整体 copy 目录的操作

copyDir

function copyDir(srcDir, destDir) {
  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);
  }
}

将 src 目录下的内容 copy 到 dest 下,这里首先创建 destDir 目录,之后调用 copy 方法,而 copy 方法只会拷贝文件如果是文件夹继续调用 copyDir

__test__

上面已经把 create-vite 创建的流程讲了一遍,不过在软件开发中单元测试也是一个大头,所以这里看下 vite-create 是怎么写的单元测试。

cli.spec.ts 文件中,vite 引用了两个库

  • execa:封装 child_process 使用起来更加方便;
  • fs-extra:封装的 fs 库,提供了更高级的用法,例如 copy、remove 等;

在 jest 运行之前 cli.spce.ts 定义了一些变量

// ..返回到 index.js,package.json 存在的这个目录
const CLI_PATH = join(__dirname, '..');
// 项目名称
const projectName = 'test-app';
// 生成的目录路径
const genPath = join(__dirname, projectName);

/*
 * 封装的 run 方法,node CLI_PATH 会默认执行 CLI_PATH 下 index.js 文件
 */
const run = (
  args: string[],
  options: SyncOptions<string> = {}
): ExecaSyncReturnValue<string> => {
  return commandSync(`node ${CLI_PATH} ${args.join(' ')}`, options);
};

/*
 * 写入 package.json 文件,为了方便测试后续的 package.json 信息
 */
// Helper to create a non-empty directory
const createNonEmptyDir = () => {
  // Create the temporary directory
  mkdirpSync(genPath);

  // Create a package.json file
  const pkgJson = join(genPath, 'package.json');
  writeFileSync(pkgJson, '{ "foo": "bar" }');
};

// Vue 3 starter template
const templateFiles = readdirSync(join(CLI_PATH, 'template-vue'))
  // _gitignore is renamed to .gitignore
  .map((filePath) => (filePath === '_gitignore' ? '.gitignore' : filePath))
  .sort();

// 运行之前执行步骤,只会执行一次
beforeAll(() => remove(genPath));
// 每次运行之后执行步骤
afterEach(() => remove(genPath));

ok,上面就把一些关键的点说了,我们来逐条分析测试用例

prompts for the project name if none supplied

test('prompts for the project name if none supplied', () => {
  const { stdout, exitCode } = run([]);
  expect(stdout).toContain('Project name:');
});

toContain 作用简单来说就是匹配数组有没有当前值信息,这条 test 是为了验证如果没有输入项目名称是否出现 prompts 交互信息

这里可以出现交互得益于使用的 execa 库,这个是它的功能之一

prompts for the framework if none supplied

test('prompts for the framework if none supplied', () => {
  const { stdout } = run([projectName]);
  expect(stdout).toContain('Select a framework:');
});

按照 init 函数的分析,输入名称并且项目没有重复的,且用户也没有指定 template 就需要选择框架了,这条 jest 就是为了测试 prompts 顺序。

prompts for the framework on not supplying a value for --template

test('prompts for the framework on not supplying a value for --template', () => {
  const { stdout } = run([projectName, '--template']);
  expect(stdout).toContain('Select a framework:');
});

继续测试 framework 情况。如果指定 --template 但是没有指定 vue、react, minimist 会将其解析成 true,当然也不符合情况。

prompts for the framework on supplying an invalid template

test('prompts for the framework on supplying an invalid template', () => {
  const { stdout } = run([projectName, '--template', 'unknown']);
  expect(stdout).toContain(
    `"unknown" isn't a valid template. Please choose from below:`
  );
});

指定错误的 template 会出现错误提示,校验 init 函数的验证。

asks to overwrite non-empty target directory

test('asks to overwrite non-empty target directory', () => {
  createNonEmptyDir();
  const { stdout } = run([projectName], { cwd: __dirname });
  expect(stdout).toContain(`Target directory "${projectName}" is not empty.`);
});

测试项目目录如果存在情况,createNonEmptyDir 这个方法前面有讲到,确保目录一定存在并且写入一些 package.json 信息。

asks to overwrite non-empty current directory

test('asks to overwrite non-empty current directory', () => {
  createNonEmptyDir();
  const { stdout } = run(['.'], { cwd: genPath, input: 'test-app\n' });
  expect(stdout).toContain(`Current directory is not empty.`);
});

测试输入项目名称不能为 .. 会返回当前 process.cwd() 目录,按照 init 函数的流程,如果目录存在会提示是否删除,如果执行了删除就是一个重大 bug 了。

successfully scaffolds a project based on vue starter template

test('successfully scaffolds a project based on vue starter template', () => {
  const { stdout } = run([projectName, '--template', 'vue'], {
    cwd: __dirname,
  });
  const generatedFiles = readdirSync(genPath).sort();

  // Assertions
  expect(stdout).toContain(`Scaffolding project in ${genPath}`);
  expect(templateFiles).toEqual(generatedFiles);
});

这里测试了两条

  • 走完了所有流程;
  • 确定输出的文件信息和 templateFiles 是一样的;

works with the -t alias

test('works with the -t alias', () => {
  const { stdout } = run([projectName, '-t', 'vue'], {
    cwd: __dirname,
  });
  const generatedFiles = readdirSync(genPath).sort();

  // Assertions
  expect(stdout).toContain(`Scaffolding project in ${genPath}`);
  expect(templateFiles).toEqual(generatedFiles);
});

这里主要是测试简写语法是否可以识别。

最后

如果文章有错别字或者不对的地方欢迎指出。