构建工具篇 - react 的 yarn eject 构建命令都做了什么

5,159 阅读5分钟

前言

前段时间,一直在研究 react 技术栈,对于项目的构建方面,又有一定的特殊需求,通过 npx create-react-app [filename] 安装以后,发现没有 webpack 相关的配置的目录,在读了 react 官方文档后,发现通过 yarn eject 可以弹出相关的配置,进行自定义配置。

于是,我就想知道 eject 到底做了什么,发现里面涉及到很多的知识点,也有很多是我之前没有接触到的地方,自从看了 ejectbuild 的源码,我觉得,我们其实还可以做很多事。

初始化声明

其实,里面绝大部分内容都是基于 node 去实现的:

如果是 node 小白,可以学习到有关 node 的一些知识点;

如果是 node 大佬,也可以看看是否有可以学习的思想。

如果觉得哪里写的不对的,或者解释不够清晰,还请大佬们指出

订阅 promise 的 reject

process.on("unhandledRejection", err => {
  throw err;
});

在初始化执行 yarn reject 的时候,会先发布一个 unhandledRejection 的订阅,这个订阅是在如果在事件循环的一次轮询中,一个 Promiserejected,并且此 Promise 没有绑定错误处理器, unhandledRejection 事件会被触发。

这里直接 throw err 的目的,是为了在发生 rejected 的时候,直接崩溃,而不是忽略;

由于这里订阅了,将来一旦发生了 rejected ,就会直接退出 node 进程。

声明要使用的方法 (初始化)

const fs = require('fs-extra'); // node中fs的扩展,在支持fs所有api的基础上,还支持promise写法
const path = require('path'); // 用来获取目录模块
const execSync = require('child_process').execSync; // 执行同步命令
const chalk = require('react-dev-utils/chalk'); // 用来修改log字体颜色
const paths = require('../config/paths'); // 对于路径的处理
const createJestConfig = require('./utils/createJestConfig'); // 创建单元测试配置
const inquirer = require('react-dev-utils/inquirer'); // 常用交互式命令行用户界面的集合
const spawnSync = require('react-dev-utils/crossSpawn').sync; // 跨平台执行系统命令
const os = require('os'); // 用来操作系统的方法

const green = chalk.green; // 绿色
const cyan = chalk.cyan; // 青色

function getGitStatus() { // 获取git状态
  try {
    let stdout = execSync(`git status --porcelain`, {
      stdio: ['pipe', 'pipe', 'ignore'],
    }).toString();
    return stdout.trim();
  } catch (e) {
    return '';
  }
}

function tryGitAdd(appPath) { // 用来提交根目录下config和scripts文件夹下修改或者更新的内容,但是不包括删除部分
  try {
    spawnSync(
      'git',
      ['add', path.join(appPath, 'config'), path.join(appPath, 'scripts')],
      {
        stdio: 'inherit',
      }
    );

    return true;
  } catch (e) {
    return false;
  }
}
// 一段提示信息,说明一下不需要eject也支持ts、sass、css
console.log(
  chalk.cyan.bold(
    'NOTE: Create React App 2+ supports TypeScript, Sass, CSS Modules and more without ejecting: ' +
      'https://reactjs.org/blog/2018/10/01/create-react-app-v2.html'
  )
);

弹出 webpack 相关配置 (核心)

inquirer.prompt({
    type: "confirm",
    name: "shouldEject",
    message: "Are you sure you want to eject? This action is permanent.",
    default: false
  }).then(answer => {
    if (!answer.shouldEject) { // 选择 n
      console.log(cyan("Close one! Eject aborted."));
      return;
    }
    /*...*/
  })

这里需要手动输入 y 或者 n,通过开发者选择的状态,去执行对应的处理,效果如下:

shouldEject 属性,就是 name 属性的值,当开发者输入 y 时,shouldEjecttrue,如果输入 n 时,shouldEjectfalse

shouldEjectfalse 的时候,就代表开发者选择了不弹出 eject 相关配置

如果选择了 y ,就要执行下列步骤了

检查当前项目的文件状态

const gitStatus = getGitStatus();
if (gitStatus) {
  console.error(
    chalk.red(
      "This git repository has untracked files or uncommitted changes:"
    ) +
      "\n\n" +
      gitStatus
        .split("\n")
        .map(line => line.match(/ .*/g)[0].trim())
        .join("\n") +
      "\n\n" +
      chalk.red(
        "Remove untracked files, stash or commit any changes, and try again."
      )
  );
  process.exit(1);
}

这里会列出来当前 git 储存库有新的文件或者修改后未提交的文件存在,出现这种情况会直接中断当前的 node 进程,目的是为了防止要弹出的文件会和这些文件出现冲突或者覆盖的情况发生

所以安全起见,会希望开发者保证当前 git 储存库当前不存在新文件或者修改后的文件

检查要弹出的文件是否存在当前项目

console.log("Ejecting...");

const ownPath = paths.ownPath; //当前文件的父级目录
const appPath = paths.appPath; //当前目录

function verifyAbsent(file) {
  if (fs.existsSync(path.join(appPath, file))) {
    //检测文件是否存在
    console.error(
      `\`${file}\` already exists in your app folder. We cannot ` +
        "continue as you would lose all the changes in that file or directory. " +
        "Please move or delete it (maybe make a copy for backup) and run this " +
        "command again."
    );
    process.exit(1);
  }
}

const folders = ["config", "config/jest", "scripts"];

// 制作浅层文件路径
const files = folders.reduce((files, folder) => {
  return files.concat(
    fs
      .readdirSync(path.join(ownPath, folder))
      //node_modules/react-scripts/config
      .map(file => path.join(ownPath, folder, file))
      //node_modules/react-scripts/config/env.js
      .filter(file => fs.lstatSync(file).isFile())
    //检测当前目录属于文件类型的
    //config下面所有文件,config/jest下面所有文件,scripts下面所有文件(不包括utils)
  );
}, []);

// 检查所有文件是否存在
folders.forEach(verifyAbsent);
files.forEach(verifyAbsent);

由于后来要弹出这两个文件夹下面的文件,于是要去检查当前的项目当中,根目录是否存在这两个文件夹,并且确认是否存在相同的文件

如果存在,就会同上一样,希望移除或者删除文件,然后再次执行命令

在根目录创建文件夹

folders.forEach(folder => {
  fs.mkdirSync(path.join(appPath, folder));
});

在根目录下创建对应的文件夹

读取文件内容

files.forEach(file => {
  let content = fs.readFileSync(file, "utf8"); //读取文件内容

  // 跳过标记的文件
  if (content.match(/\/\/ @remove-file-on-eject/)) {
    return;
  }
  content =
    content
      .replace(
        /\/\/ @remove-on-eject-begin([\s\S]*?)\/\/ @remove-on-eject-end/gm,
        ""
      )
      .replace(
        /-- @remove-on-eject-begin([\s\S]*?)-- @remove-on-eject-end/gm,
        ""
      )
      .trim() + "\n";
  console.log(`  Adding ${cyan(file.replace(ownPath, ""))} to the project`);
  fs.writeFileSync(file.replace(ownPath, appPath), content);
});

读取所有文件的内容,如果有 //@remove-file-on-eject 的文件,就直接跳过,不进行创建写入

如果某个文件内存在 //@remove-on-eject-begin 开头, //@remove-on-eject-end 结尾的内容,就直接进行删除,写入剩下的内容

更新依赖

const ownPackage = require(path.join(ownPath, "package.json"));
const appPackage = require(path.join(appPath, "package.json"));

console.log(cyan("Updating the dependencies"));
const ownPackageName = ownPackage.name;
if (appPackage.devDependencies) {
  // 我们曾经把react脚本放在devDependencies中
  if (appPackage.devDependencies[ownPackageName]) {
    console.log(`  Removing ${cyan(ownPackageName)} from devDependencies`);
    delete appPackage.devDependencies[ownPackageName];
  }
}
appPackage.dependencies = appPackage.dependencies || {};
if (appPackage.dependencies[ownPackageName]) {
  console.log(`  Removing ${cyan(ownPackageName)} from dependencies`);
  delete appPackage.dependencies[ownPackageName];
}
Object.keys(ownPackage.dependencies).forEach(key => {
  // 由于某种原因,optionalDependencies在安装后以依赖关系结束
  if (ownPackage.optionalDependencies[key]) {
    return;
  }
  console.log(`  Adding ${cyan(key)} to dependencies`);
  appPackage.dependencies[key] = ownPackage.dependencies[key];
});
// 对deps进行排序
const unsortedDependencies = appPackage.dependencies;
appPackage.dependencies = {};
Object.keys(unsortedDependencies)
  .sort()
  .forEach(key => {
    appPackage.dependencies[key] = unsortedDependencies[key];
  });
console.log();

console.log(cyan("Updating the scripts"));
delete appPackage.scripts["eject"];
Object.keys(appPackage.scripts).forEach(key => {
  Object.keys(ownPackage.bin).forEach(binKey => {
    const regex = new RegExp(binKey + " (\\w+)", "g");
    if (!regex.test(appPackage.scripts[key])) {
      return;
    }
    appPackage.scripts[key] = appPackage.scripts[key].replace(
      regex,
      "node scripts/$1.js"
    );
    console.log(
      `  Replacing ${cyan(`"${binKey} ${key}"`)} with ${cyan(
        `"node scripts/${key}.js"`
      )}`
    );
  });
});

console.log();
console.log(cyan("Configuring package.json"));
// 添加 jest 配置
console.log(`  Adding ${cyan("Jest")} configuration`);
appPackage.jest = jestConfig;

// 添加 babel 配置
console.log(`  Adding ${cyan("Babel")} preset`);
appPackage.babel = {
  presets: ["react-app"]
};

// 添加 eslint 配置
console.log(`  Adding ${cyan("ESLint")} configuration`);
appPackage.eslintConfig = {
  extends: "react-app"
};

fs.writeFileSync(
  path.join(appPath, "package.json"),
  JSON.stringify(appPackage, null, 2) + os.EOL
); //写入json,2用来做格式化缩进

这里代码量看起来比较多,但是没有很复杂的知识点,所以就不做详细介绍了,大家看一下就了解了

处理弹出以后的事(扫尾)

到这里,其实弹出相对应文件的工作已经完成了,只是在这里需要在弹出以后把和项目已经无关的资源进行清理即可

从声明文件删除 react-scripts 相关

if (fs.existsSync(paths.appTypeDeclarations)) {
  try {
    // 阅读应用声明文件
    let content = fs.readFileSync(
      paths.appTypeDeclarations,
      "utf8"
    );
    const ownContent =
      fs
        .readFileSync(paths.ownTypeDeclarations, "utf8")
        .trim() + os.EOL;

    // 删除 react-scripts 引用,因为它们正在获取项目中类型的副本
    content =
      content
        // 删除 react-scripts 类型
        .replace(
          /^\s*\/\/\/\s*<reference\s+types.+?"react-scripts".*\/>.*(?:\n|$)/gm,
          ""
        )
        .trim() + os.EOL;

    fs.writeFileSync(
      paths.appTypeDeclarations,
      (ownContent + os.EOL + content).trim() + os.EOL
    );
  }
}

从根目录的 node_modules 删除 react-scripts 相关

if (ownPath.indexOf(appPath) === 0) {
  try {
    // 从app node_modules中删除react-scripts和react-scripts二进制文件
    Object.keys(ownPackage.bin).forEach(binKey => {
      fs.removeSync(path.join(appPath, "node_modules", ".bin", binKey));
    });
    fs.removeSync(ownPath);
  } 
}

结束语

有关 eject 相关的代码,到这里就讲解的差不多了,其实呢,代码量看起来挺大,但是仔细看的话,也不是很复杂,只是里面掺杂了有关 node 相关的知识点,这样对纯前端同学来说不是很友好

但是只要去查询对应的 api 就会发现其实实现的并不难,只是对于一些实现这种做法的思想,是值得我们去学习的

看懂了这篇文章,了解了 react 是如何隐藏 webpack 相关配置的,又是如何弹出的,会对未来我们自己去写一个相同作用的 npm 包,是很有利的

希望这篇文章可以帮到大家,另外多点赞,谢谢啦