December 07, 2019

create-react-app使用工具与过程分析

最近在弄一个项目的模版,之前是以fork的方式新建;这种方式不太友好,所以想着参考cra,用cli的方式创建模版;也趁这个机会了解cra创建项目的过程。如果想了解概要过程,直接拉到页面底部即可。

工具概览

先大概了解cra所用到的工具,在入口文件可以看到,下面写一些简单工具的简单描述与使用目的,对所使用工具熟悉,看源码起来会比较有帮助。熟悉的话可以跳过...

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// chalk 是一个美化终端输出文字,通常可以更改文字的颜色与背景色
const chalk = require('chalk');
// commander 是一个对终端的参数输入进行处理工具,让输入参数更容易处理
const commander = require('commander');
// 原生 dns 模块,emm,作用就是对域名相关dns解析吧
const dns = require('dns');
// eninfo 获取系统的信息,设备信息,浏览器,node版本等;在debug的时候用到
const envinfo = require('envinfo');
// execSync 调用子进程的一个方法
const execSync = require('child_process').execSync;
// fs-extra 增强型 fs,提供一些更友好调用方法
const fs = require('fs-extra');
// hyperquest 将http请求应答过程变成stream形式返回
const hyperquest = require('hyperquest');
// inquirer 用于在终端与用户交互的输入工具,例如,提问,y/N等
const inquirer = require('inquirer');
// 原生 os 模块,用于获取不同系统的结束标识:os.EOL
const os = require('os');
// 原生 path 模块,用于路径拼接等处理
const path = require('path');
// semver 用于对版本的大小判断, 通常基于 x.y.z
const semver = require('semver');
// cross-spawn 与child_process的spawn类似,增强型,兼容多系统
const spawn = require('cross-spawn');
// tmp 获取系统的临时文件夹等 https://github.com/raszi/node-tmp
const tmp = require('tmp');
// tar-pack 解压tar压缩包
const unpack = require('tar-pack').unpack;
// 原生 url 模块,对url进行处理,返回对象形式等
const url = require('url');
// validateProjectName 判断 npm 包名称是否合法
const validateProjectName = require('validate-npm-package-name');

创建流程

输入处理

1
2
3
4
const program = new commander.Command(packageJson.name)
.arguments('<project-directory>')
.option('--typescript')
// ...

可以看到处理输入的文件夹名称与部分配置等;通过commander的处理转换成对象形式,更容易操作

createApp函数

当判断输入已经没问题之后,就执行createApp函数,

  1. 函数执行首先是判断node版本,对于低版本进行提醒或退出,这个取决于是否用到typescript;这里判断node版本信息通过semver处理,需要node版本>=8.10.0

  2. 检查node版本之后,就会检测输入的文件夹名称是否负责npm包名称的规范,检测的方法为:checkAppName,其中利用到的工具库是validateProjectName

  3. 接着处理目标文件夹,fs.ensureDirSync(name),这里的fs是指fs-extra模块;fs.ensureDirSync方法的作用是:如果目标文件夹不存在,则创建对应的文件夹

  4. 那如果目标文件夹存在怎么办?cra会对目标文件夹的文件进行判断,如果目标文件夹的文件不影响新建项目,则还是可以继续进行;cra会维护一些文件的白名单还有部分规则,具体可以看isSafeToCreateProjectIn函数

  5. 文件夹准备完成之后,就会往目标文件夹写入一个简单的package.json文件

1
2
3
4
5
{
    name: appName, // appName 就是目标文件夹的名称
    version: '0.1.0',
    private: true,
}

然后判断使用哪一个依赖管理器,默认是yarn,也可以指定npmpnpm;依赖的npm版本需要大于5,yarn版本需要大于1.12.0;当处理完管理器之后;如果确定使用yarn且没有更改yarn的仓库地址(默认是:https://registry.yarnpkg.com);则会拷贝yarn.lock.cache当作yarn.lock,用于保证安装依赖是正确的;如果不是指定yarn或者指定了别的仓库地址,则按最新版本安装。

这里简单说一下pnpmnpmyarn会熟悉一点,但是这个pnpm用得比较少。这个管理器号称速度比yarnnpm都要快3倍(2017年数据),而且省空间;因为yarn不同项目如果依赖相同版本的npm包,如果本地已安装,是通过复制文件到不同的项目中去;而pnpm是通过硬链接代替复制。具体更详细可以看这篇文章why-should-we-use-pnpm?

准备的工具和文件夹都ok了,就开始安装依赖~

run函数

run函数最开始是获取react-scriptscra-template的安装路径与对应版本。源码在这需要注意的是,cra-template是从v3.3.0版本开始才增加,之前的版本中,cra-template的内容也是在react-scripts中。

为什么要获取安装的路径呢?因为这两个安装包的安装路径,cra支持多种方式:

  1. 默认从包管理仓库下载,大部分使用用户的选择
  2. 通过本地路径下载(file://),猜测是为了更方便的debug过程
  3. 指定压缩包下载(tar.gz)文件,本地或http链接。

获取完依赖包的信息之后,就开始下载

1
2
3
4
react
react-dom
react-scripts
cra-template

如果指定是typescript的环境,则还会增加相应的包:

1
2
3
4
5
@types/node
@types/react
@types/react-dom
@types/jest
typescript

install函数

当确认好使用的包管理工具,依赖的包版本与地址信息之后;进入install方法后,还需要对当前网络环境进行判断;因为使用yarn是支持离线下载的;这个判断就使用到dns模块,对registry.yarnpkg.com域名进行解析,若解析成功则为在线,反之则是离线。

一切就绪就开始进行下载,执行下载的命令需要对上述工具与信息拼接,然后使用spawn方法调用起子进程,让子进程去执行我们的安装命令,例如我们平常的安装命令yarn add lodash。到这里会有一个疑问,看到文件顶部引入的execSyncspawn都是子进程的执行方法,这两个方法会有什么区别?

这两个方法最主要的两个区别是:

  1. spawn()返回的是一个流streamstream会触发dataend等事件,通过触发事件返回数据;文章中称之为"异步的异步"
  2. exec()返回是一个buffer,也就是对执行命令的输出一次性返回,这个buffer默认是200k;如果输出超过这个值,就会报错。文章中称之为"同步的异步"

所以通常对于输出数据比较大的选用spawn 输出数据比较简单的,选用exec。更详细可以看这篇文章,difference-between-spawn-and-exec

回到install函数,spawn执行安装,当安装完毕后,通过close事件确认是否安装成功:

1
2
3
4
5
6
7
8
9
10
11
// https://github.com/facebook/create-react-app/blob/9a817dd0d780ec401afb1f99dbc0f3bdbcd51683/packages/create-react-app/createReactApp.js#L402
const child = spawn(command, args, { stdio: 'inherit' });
child.on('close', code => {
  if (code !== 0) {
    reject({
      command: `${command} ${args.join(' ')}`,
    });
    return;
  }
  resolve();
});

安装完毕之后,会回到原来的run函数,接着还有对环境进行检查:checkNodeVersion,检查当前node的环境版本是否符合react-scripts最低的node版本要求;

环境检查完毕后,对所依赖的包react-scripts的版本修正:setCaretRangeForRuntimeDeps,例如下载react-scripts的时候指定v3.3.0版本,则在新建的项目中的package.jsondependencies修正为:^3.3.0

检查都通过之后,准备对模版文件进行处理;因为安装的cra-template已经包含了我们需要的源文件,是直接拷贝到目标文件就可以了?这个时候执行另外一段命令,地址

1
2
3
4
5
6
7
8
9
10
11
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]));
`
);

这段命令是执行react-scripts/scripts/init.js的方法。后续的操作就交给init方法处理,这个暂时先放一下,后面再展开;再看一下如果该段代码执行出错,或者在install过程中出错,就会跳到最后的catch方法:这个方法主要是对已生成的文件进行删除,错误代码处理过程:源码

1
2
3
package.json
yarn.lock
node_modules

如果在目标文件夹已生成上面的文件列表,则会对这些文件移除;若移除后文件夹为空,则会对文件夹也删除。

react-scripts/script/init.js

到这一步的时候,新建的项目中主动安装的依赖有:react,react-dom,react-scripts,cra-template 这个方法主要是对cra-template的项目的模版文件进行处理,安装一些缺失的额外依赖与更改新建项目的package.json进行优化处理。

  1. 更新package.json内容,源码地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// appPackage为package.json的内容,初始化为,后续安装依赖会有改变
// {
//   name: appName,
//   version: '0.1.0',
//   private: true,
// }
// 设置npm script命令
appPackage.scripts = Object.assign(
  {
    start: 'react-scripts start',
    build: 'react-scripts build',
    test: 'react-scripts test',
    eject: 'react-scripts eject',
  },
  templateScripts
);
appPackage.eslintConfig = {
  extends: 'react-app',
};
appPackage.browserslist = defaultBrowsers;
// ...
// 再次更新写入文件
fs.writeFileSync(
  path.join(appPath, 'package.json'),
  JSON.stringify(appPackage, null, 2) + os.EOL
);
  1. 复制cra-template/template文件到新建项目目录:
1
2
3
4
5
6
7
8
9
10
// Copy the files for the user
const templateDir = path.join(templatePath, 'template');
if (fs.existsSync(templateDir)) {
  fs.copySync(templateDir, appPath);
} else {
  console.error(
    `Could not locate supplied template: ${chalk.green(templateDir)}`
  );
  return;
}
  1. 安装额外依赖cra-template/template.json

template.json内容为:

1
2
3
4
5
6
7
{
  "dependencies": {
    "@testing-library/react": "^9.3.2",
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/user-event": "^7.1.2"
  }
}

由于这些依赖没有在react-scripts中,因为需要在这里需要再次安装该部分依赖。

  1. 移除cra-template

前面可以知道我们安装的时候会包含这个cra-template的依赖,而这个依赖的作用是提供模版文件,现在已经复制到目标文件夹了,因此可以执行命令删除:

1
$ yarn remove cra-template
  1. 初始化为git项目 主动把项目执行git init初始化新建项目文件夹为git仓库,并把初始化的文件加入到commitInitial commit from Create React App

至此整个流程已经安装完毕

小结

安装的过程梳理为以下几点:

  1. 对输入的命令处理,配置新建项目的类型,例如使用typescript
  2. 检查环境是否符合要求
  3. 新建目标文件夹与package.json
  4. 安装react,react-dom,react-scripts,cra-template
  5. 拷贝cra-template到目标文件夹,更改package.json内容
  6. 安装cra-template指定额外依赖
  7. 初始化git仓库并提交commit

上述过程实现起来并不困难,但是需要兼顾到非常多的环境问题与版本处理等比较零碎的边界值等。需要对文件系统的操作了解很多,对于优化用户提示与用户交互所用到工具也要较为熟悉。整体流程需要十分严谨,才能在各种环境中处理正常。

react-scripts是项目的核心处理,包括npm script命令,还有项目的依赖。还有一些该项目的其他问题要处理,例如:

  1. cra-template项目如何维护?
  2. yarn.lock.cached如何更新处理?
  3. 发布项目流程如何处理项目之间的依赖关系等

这些问题解决方式都可以从create-react-app根目录的tasks文件夹与根目录的package.json中找到。