create-react-app做了什么

3,272 阅读5分钟

create-react-app(v3.7.2)可以很快很方便初始化一个react开发项目,这个东西到底是怎样运作的,做了哪些处理呢?今天揭开内部秘密。源码用到的一些有用的第三方库也列了出来,方便以后大家在自己的cli中使用。

初始化命令

// CRA的所有的命令如下
/**
npm package
commander: 命令基础工具
*/
const program = new commander.Command(packageJson.name)
  .version(packageJson.version)
  .arguments('<project-directory>')
  .usage(`${chalk.green('<project-directory>')} [options]`)
  .action(name => {
    projectName = name;
  })
  .option('--verbose', 'print additional logs')
  .option('--info', 'print environment debug info')
  .option(
    '--scripts-version <alternative-package>',
    'use a non-standard version of react-scripts'
  )
  .option(
    '--template <path-to-template>',
    'specify a template for the created project'
  )
  .option('--use-npm')
  .option('--use-pnp')
  // TODO: Remove this in next major release.
  .option(
    '--typescript',
    '(this option will be removed in favour of templates in the next major release of create-react-app)'
  )
  .allowUnknownOption()
  .on('--help', () => {
    console.log(`    Only ${chalk.green('<project-directory>')} is required.`);
    console.log();
    console.log(
      `    A custom ${chalk.cyan('--scripts-version')} can be one of:`
    );
    console.log(`      - a specific npm version: ${chalk.green('0.8.2')}`);
    console.log(`      - a specific npm tag: ${chalk.green('@next')}`);
    console.log(
      `      - a custom fork published on npm: ${chalk.green(
        'my-react-scripts'
      )}`
    );
    console.log(
      `      - a local path relative to the current working directory: ${chalk.green(
        'file:../my-react-scripts'
      )}`
    );
    console.log(
      `      - a .tgz archive: ${chalk.green(
        'https://mysite.com/my-react-scripts-0.8.2.tgz'
      )}`
    );
    console.log(
      `      - a .tar.gz archive: ${chalk.green(
        'https://mysite.com/my-react-scripts-0.8.2.tar.gz'
      )}`
    );
    console.log(
      `    It is not needed unless you specifically want to use a fork.`
    );
    console.log();
    console.log(`    A custom ${chalk.cyan('--template')} can be one of:`);
    console.log(
      `      - a custom fork published on npm: ${chalk.green(
        'cra-template-typescript'
      )}`
    );
    console.log(
      `      - a local path relative to the current working directory: ${chalk.green(
        'file:../my-custom-template'
      )}`
    );
    console.log(
      `      - a .tgz archive: ${chalk.green(
        'https://mysite.com/my-custom-template-0.8.2.tgz'
      )}`
    );
    console.log(
      `      - a .tar.gz archive: ${chalk.green(
        'https://mysite.com/my-custom-template-0.8.2.tar.gz'
      )}`
    );
    console.log();
    console.log(
      `    If you have any problems, do not hesitate to file an issue:`
    );
    console.log(
      `      ${chalk.cyan(
        'https://github.com/facebook/create-react-app/issues/new'
      )}`
    );
    console.log();
  })
  .parse(process.argv);

-V, --version版本号输出

  // 当前create-react-app版本号输出
 new commander.Command(packageJson.name)
  .version(packageJson.version) // 默认已经生成该命令选项

--verbose 展示详细的logs

--info 展示当前系统以及环境的一些信息

// 源码中,如果命令中有这个参数, 则会执行
/** 
npm package: 
envinfo: 快速获取当前各种软件环境的信息
*/
return envinfo
    .run(
      {
        System: ['OS', 'CPU'],
        Binaries: ['Node', 'npm', 'Yarn'],
        Browsers: ['Chrome', 'Edge', 'Internet Explorer', 'Firefox', 'Safari'],
        npmPackages: ['react', 'react-dom', 'react-scripts'],
        npmGlobalPackages: ['create-react-app'],
      },
      {
        duplicates: true,
        showNotFound: true,
      }
    )
    .then(console.log);

--scripts-version 指定一个特定的react-scripts运行脚本

--template 指定项目的模板,可以指定一个自己的模板

--use-pnp 使用pnp --> pnp是什么

--typescript 使用ts开发,之后版本会移除这个选项

这个选项即将被弃用,可以使用--template typescript代替

if (useTypeScript) {
    console.log(
      chalk.yellow(
        'The --typescript option has been deprecated and will be removed in a future release.'
      )
    );
    console.log(
      chalk.yellow(
        `In future, please use ${chalk.cyan('--template typescript')}.`
      )
    );
    console.log();
    if (!template) {
      template = 'typescript';
    }
  }

开始创建项目

创建项目会调用createApp方法, node版本要求>=8.10.0, 低于这个版本会抛错

createApp

首先会先调用createApp方法

createApp(
  projectName, // 项目名称
  program.verbose, // --verbose
  program.scriptsVersion, // --scripts-version
  program.template, // --template
  program.useNpm, // --use-npm
  program.usePnp, // --use-pnp
  program.typescript // --typescript
);

创建package.json

const packageJson = {
    name: appName,
    version: '0.1.0',
    private: true,
};
fs.writeFileSync(
    path.join(root, 'package.json'),
    JSON.stringify(packageJson, null, 2) + os.EOL
);

如果有使用yarn, 会先将当前目录下的yarn.lock.cached文件拷贝到项目根目录下并重命名为yarn.lock

if (useYarn) {
    let yarnUsesDefaultRegistry = true;
    try {
      yarnUsesDefaultRegistry =
        execSync('yarnpkg config get registry')
          .toString()
          .trim() === 'https://registry.yarnpkg.com';
    } catch (e) {
      // ignore
    }
    if (yarnUsesDefaultRegistry) {
      fs.copySync(
        require.resolve('./yarn.lock.cached'),
        path.join(root, 'yarn.lock')
      );
    }
}

run

接着调用run,继续创建新项目

/**
npm 包
semver: 版本号校验以及比较等的工具库
*/

run(
    root,
    appName,
    version, // scriptsVersion
    verbose,
    originalDirectory,
    template,
    useYarn,
    usePnp
  );
  

处理react-scripts引用脚本和--template入参

// ...
let packageToInstall = 'react-scripts';
// ...
// 将所用到的依赖搜集
const allDependencies = ['react', 'react-dom', packageToInstall];
Promise.all([
    getInstallPackage(version, originalDirectory),
    getTemplateInstallPackage(template, originalDirectory),
])

调用getInstallPackage处理react-scripts的使用 --scripts-version选项的入参可以为多种:

  • 标准的版本号: '1.2.3','@x.x.x'之类的,指定使用react-scripts的版本 -> react-scripts@x.x.x
  • 指定本地自定义文件:'file:xxx/xxx',返回一个本地绝对路径
  • 其他路径(for tar.gz or alternative paths):可以是个线上npm包,直接返回这个路径 由于默认支持typescript模板,若指定scriptsVersionreact-scripts-ts,会有确认提示
/**
npm package
inquirer: 输入输出交互处理工具
*/
const scriptsToWarn = [
    {
      name: 'react-scripts-ts',
      message: chalk.yellow(
        `The react-scripts-ts package is deprecated. TypeScript is now supported natively in Create React App. You can use the ${chalk.green(
          '--template typescript'
        )} option instead when generating your app to include TypeScript support. Would you like to continue using react-scripts-ts?`
      ),
    },
  ];

  for (const script of scriptsToWarn) {
    if (packageToInstall.startsWith(script.name)) {
      return inquirer
        .prompt({
          type: 'confirm',
          name: 'useScript',
          message: script.message,
          default: false,
        })
        .then(answer => {
          if (!answer.useScript) {
            process.exit(0);
          }

          return packageToInstall;
        });
    }
  }

调用getTemplateInstallPackage处理--template的使用

  • 指定模板为一个file:开头的本地文件
  • 不带协议的链接://或者tgz|tar.gz压缩包
  • 类似@xxx/xxx/xxxx或者@xxxx的指定路径或者模板名字
   const packageMatch = template.match(/^(@[^/]+\/)?(.+)$/);
   const scope = packageMatch[1] || '';
   const templateName = packageMatch[2];
    if (
       templateName === templateToInstall ||
       templateName.startsWith(`${templateToInstall}-`)
     ) {
       // Covers:
       // - cra-template
       // - @SCOPE/cra-template
       // - cra-template-NAME
       // - @SCOPE/cra-template-NAME
       templateToInstall = `${scope}${templateName}`;
     } else if (templateName.startsWith('@')) {
       // Covers using @SCOPE only
       templateToInstall = `${templateName}/${templateToInstall}`;
     } else {
       // Covers templates without the `cra-template` prefix:
       // - NAME
       // - @SCOPE/NAME
       templateToInstall = `${scope}${templateToInstall}-${templateName}`;
     }
     // cra-template: This is the official base template for Create React App.

最终处理成@xxx/cra-template或者@xxx/cra-template-xxxcra-template-xxxcra-template, 官方指定的两个模板为cra-template-typescriptcra-template。模板具体内容大家可以去 官方仓库 去查看,可以自己自定义或者魔改一些东西

接着获取--scripts-version--template处理后的安装包信息

/**
npm package
tem: 用于在node.js环境中创建临时文件和目录。
hyperquest: 将http请求转化为流(stream)输出
tar-pack: tar/gz的压缩或者解压缩
*/
Promise.all([
    getPackageInfo(packageToInstall),
    getPackageInfo(templateToInstall),
])

// getPackageInfo是一个很有用的工具函数
// Extract package name from tarball url or path.
function getPackageInfo(installPackage) {
  if (installPackage.match(/^.+\.(tgz|tar\.gz)$/)) {
    return getTemporaryDirectory()
      .then(obj => {
        let stream;
        if (/^http/.test(installPackage)) {
          stream = hyperquest(installPackage);
        } else {
          stream = fs.createReadStream(installPackage);
        }
        return extractStream(stream, obj.tmpdir).then(() => obj);
      })
      .then(obj => {
        const { name, version } = require(path.join(
          obj.tmpdir,
          'package.json'
        ));
        obj.cleanup();
        return { name, version };
      })
      .catch(err => {
        // The package name could be with or without semver version, e.g. react-scripts-0.2.0-alpha.1.tgz
        // However, this function returns package name only without semver version.
        console.log(
          `Could not extract the package name from the archive: ${err.message}`
        );
        const assumedProjectName = installPackage.match(
          /^.+\/(.+?)(?:-\d+.+)?\.(tgz|tar\.gz)$/
        )[1];
        console.log(
          `Based on the filename, assuming it is "${chalk.cyan(
            assumedProjectName
          )}"`
        );
        return Promise.resolve({ name: assumedProjectName });
      });
  } else if (installPackage.startsWith('git+')) {
    // Pull package name out of git urls e.g:
    // git+https://github.com/mycompany/react-scripts.git
    // git+ssh://github.com/mycompany/react-scripts.git#v1.2.3
    return Promise.resolve({
      name: installPackage.match(/([^/]+)\.git(#.*)?$/)[1],
    });
  } else if (installPackage.match(/.+@/)) {
    // Do not match @scope/ when stripping off @version or @tag
    return Promise.resolve({
      name: installPackage.charAt(0) + installPackage.substr(1).split('@')[0],
      version: installPackage.split('@')[1],
    });
  } else if (installPackage.match(/^file:/)) {
    const installPackagePath = installPackage.match(/^file:(.*)?$/)[1];
    const { name, version } = require(path.join(
      installPackagePath,
      'package.json'
    ));
    return Promise.resolve({ name, version });
  }
  return Promise.resolve({ name: installPackage });
}

function extractStream(stream, dest) {
  return new Promise((resolve, reject) => {
    stream.pipe(
      unpack(dest, err => {
        if (err) {
          reject(err);
        } else {
          resolve(dest);
        }
      })
    );
  });
}

run方法主要的工作就是处理--scripts-version--template提供的包,搜集项目的依赖

install

run处理收集完依赖后会调用install方法

return install(
  root, // 项目的名称
  useYarn,
  usePnp,
  allDependencies,
  verbose,
  isOnline // 若使用yarn,dns.lookup检测registry.yarnpkg.com是否正常的结果
)
// install主要是处理安装前的一些命令参数处理以及上面搜集依赖的安装
function install(root, useYarn, usePnp, dependencies, verbose, isOnline) {
  return new Promise((resolve, reject) => {
    let command;
    let args;
    if (useYarn) {
      command = 'yarnpkg';
      args = ['add', '--exact'];
      if (!isOnline) {
        args.push('--offline');
      }
      if (usePnp) {
        args.push('--enable-pnp');
      }
      [].push.apply(args, dependencies);
      args.push('--cwd');
      args.push(root);

      if (!isOnline) {
        console.log(chalk.yellow('You appear to be offline.'));
        console.log(chalk.yellow('Falling back to the local Yarn cache.'));
        console.log();
      }
    } else {
      command = 'npm';
      args = [
        'install',
        '--save',
        '--save-exact',
        '--loglevel',
        'error',
      ].concat(dependencies);

      if (usePnp) {
        console.log(chalk.yellow("NPM doesn't support PnP."));
        console.log(chalk.yellow('Falling back to the regular installs.'));
        console.log();
      }
    }

    if (verbose) {
      args.push('--verbose');
    }

    const child = spawn(command, args, { stdio: 'inherit' });
    child.on('close', code => {
      if (code !== 0) {
        reject({
          command: `${command} ${args.join(' ')}`,
        });
        return;
      }
      resolve();
    });
  });
}

依赖安装后检查react-scripts执行包的版本与当前的node版本是否匹配,检查reactreact-dom是否正确安装,并在它们的版本号前面加^(上面安装命令带有exact选项,会精确安装依赖,版本号不带^),将依赖重新写入package.json

await executeNodeScript(
  {
    cwd: process.cwd(),
    args: nodeArgs,
  },
  [root, appName, verbose, originalDirectory, templateName],
  `
var init = require('${packageName}/scripts/init.js');
init.apply(null, JSON.parse(process.argv[1]));
`
);

function executeNodeScript({ cwd, args }, data, source) {
  return new Promise((resolve, reject) => {
    const child = spawn(
      process.execPath,
      [...args, '-e', source, '--', JSON.stringify(data)],
      { cwd, stdio: 'inherit' }
    );

    child.on('close', code => {
      if (code !== 0) {
        reject({
          command: `node ${args.join(' ')}`,
        });
        return;
      }
      resolve();
    });
  });
}

正确检查依赖后将执行提供的scripts脚本下的init初始化:

  1. package.json添加scripts/eslintConfig/browserslist等配置
  2. 如果存在README.md,将其重命名README.old.md
  3. 将模板拷贝到项目目录,根据是否使用yarn,将模板README.md的命令说明给为yarn
  4. 安装模板依赖,如果是ts项目则初始化相关配置(verifyTypeScriptSetup
  5. 移除node_modules的模板
  6. 初始化git相关

到此整个项目创建完毕

梳理

  1. 初始化脚手架各种命令
  2. 创建package.json
  3. 处理并验证命令参数,搜集依赖
  4. 安装前置依赖
  5. 处理package.jsonreact/react-dom依赖版本号,并校验node版本是否符合要求
  6. 验证所提供的react-scripts依赖,并通过子进程调用依赖下的react-scripts/scripts/init.js,进行项目模板初始化
  7. 安装模板依赖,用到ts则初始化其配置
  8. 验证代码仓库,做兼容处理;若没有添加仓库,初始化git

写在最后

create-react-app这个包的源码相对简单,但是非常细密精炼,整个流程非常清晰,绝对是一个cli的范本,感兴趣的小伙伴可以自己阅读。文正如果有不正确的地方欢迎指正批评!