# 前言
在使用 React 的过程中,离不开使用 create-react-app 脚手架初始化项目。作为一个开发者,比较好奇这个脚手架到底搭建了怎样的一个开发环境,又是如何做到的。所以对其源码进行研究,了解下里面运行的机制。
# 开始
# 获取源码
当前 create-react-app 源码版本:v3.3.0
- 拉取源码:
git clone git@github.com:facebook/create-react-app.git
- 切换到指定版本:
git checkout v3.3.0
# 目录结构
我们先来看下整体的目录结构
.
├── CHANGELOG-0.x.md
├── CHANGELOG-1.x.md
├── CHANGELOG-2.x.md
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── azure-pipelines-test-job.yml
├── azure-pipelines.yml
├── docusaurus
│ ├── docs
│ └── website
├── lerna.json
├── netlify.toml
├── package.json
├── packages
│ ├── babel-plugin-named-asset-import
│ ├── babel-preset-react-app
│ ├── confusing-browser-globals
│ ├── cra-template
│ ├── cra-template-typescript
│ ├── create-react-app
│ ├── eslint-config-react-app
│ ├── react-app-polyfill
│ ├── react-dev-utils
│ ├── react-error-overlay
│ └── react-scripts
├── screencast-error.svg
├── screencast.svg
├── tasks
│ ├── compile-lockfile.js
│ ├── cra.js
│ ├── e2e-behavior.sh
│ ├── e2e-installs.sh
│ ├── e2e-kitchensink-eject.sh
│ ├── e2e-kitchensink.sh
│ ├── e2e-old-node.sh
│ ├── e2e-simple.sh
│ ├── e2e-typescript-unsupported-node.sh
│ ├── local-registry.sh
│ ├── local-test.sh
│ ├── publish.sh
│ ├── screencast-start.js
│ ├── screencast.js
│ ├── screencast.sh
│ └── verdaccio.yaml
└── test
├── README.md
├── fixtures
└── jest.config.js
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
打开 package.json 可以看到里面有比较多的命令,其中 start、build、test等关键命令都是指向 packages 目录,说明 packages 是关键目录,我们主要对它进行研究。
"scripts": {
"build": "cd packages/react-scripts && node bin/react-scripts.js build",
"changelog": "lerna-changelog",
"create-react-app": "node tasks/cra.js",
"e2e": "tasks/e2e-simple.sh",
"e2e:docker": "tasks/local-test.sh",
"postinstall": "cd packages/react-error-overlay/ && yarn build:prod",
"publish": "tasks/publish.sh",
"start": "cd packages/react-scripts && node bin/react-scripts.js start",
"screencast": "node ./tasks/screencast.js",
"screencast:error": "svg-term --cast jyu19xGl88FQ3poMY8Hbmfw8y --out screencast-error.svg --window --at 12000 --no-cursor",
"alex": "alex .",
"test": "cd packages/react-scripts && node bin/react-scripts.js test",
"format": "prettier --trailing-comma es5 --single-quote --write 'packages/*/*.js' 'packages/*/!(node_modules)/**/*.js'",
"compile:lockfile": "node tasks/compile-lockfile.js"
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
packages 目录下放着不同的模块,文件夹命名很规范,看名字就大概猜出功能划分,我们先来看 packages/create-react-app/package.json
{
"name": "create-react-app",
"version": "3.3.0",
...
"files": [
"index.js",
"createReactApp.js",
"yarn.lock.cached"
],
"bin": {
"create-react-app": "./index.js"
},
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
我们看到 bin
这个字段,bin
的功能是把命令对应到可执行文件,具体介绍可以看package document。
这里可以理解为,当装了 create-react-app
之后,跑 create-react-app my-app
npm 就会帮我们跑 packages/create-react-app/index.js my-app
,所以源码的入口文件就是 create-react-app/index.js
。
对于简单的源码可以直接阅读,如果是比较复杂的源码或者想看到执行到每一行代码时的变量是什么情况时,可以借助 vscode 的 debug 模式断点调试。这里使用的 vscode 版本 1.41.1。
# 设置断点调试
用 vscode 打开 create-react-app 项目,在 terminnal 终端项目根目录下先安装依赖 npm install
。点击 IDE 左边的 debug 菜单,设置好 index.js 的断点,点击 Debug with Node.js
就可以调试了。
下面我们跟着入口文件逐步分析源码:
# create-react-app/index.js
'use strict';
var currentNodeVersion = process.versions.node;
var semver = currentNodeVersion.split('.');
var major = semver[0];
if (major < 8) {
console.error(
'You are running Node ' +
currentNodeVersion +
'.\n' +
'Create React App requires Node 8 or higher. \n' +
'Please update your version of Node.'
);
process.exit(1);
}
require('./createReactApp');
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这个文件先对 Node.js 版本进行检查,这个容易理解,脚手架其实是基于 Node.js 通过各种 api 对文件进行的操作,对 Node.js 有最低的版本要求,如果小于这个最低要求就打印错误信息并退出,满足条件则执行 createReactApp.js。
process 是 node 环境中的进程,process.versions 会返回 Node 和其依赖的版本信息。
> console.log(process.versions)
{
node: '12.10.0',
v8: '7.6.303.29-node.16',
uv: '1.31.0',
zlib: '1.2.11',
brotli: '1.0.7',
ares: '1.15.0',
modules: '72',
nghttp2: '1.39.2',
napi: '4',
llhttp: '1.1.4',
http_parser: '2.8.0',
openssl: '1.1.1c',
cldr: '35.1',
icu: '64.2',
tz: '2019a',
unicode: '12.1'
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# create-react-app/createReactApp.js
# commander命令行处理程序
首先看下第一段代码
const program = new commander.Command(packageJson.name)
.version(packageJson.version) // create-react-app -v 时输出 ${packageJson.version}
.arguments('<project-directory>') // 必填项project-directory, 初始化时的项目路径
.usage(`${chalk.green('<project-directory>')} [options]`)
.action(name => {
// 获取用户传入的第一个参数做为 projectName
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()
...
...
})
.parse(process.argv);
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
这一段代码使用了 commander, 是一个 node.js 的命令行界面,基本功能看注释即可。先大概了解到这里定义了哪些参数,后面遇到的时候心里有底。
# 判断是否有传projectName
if (typeof projectName === 'undefined') {
console.error('Please specify the project directory:');
console.log(
` ${chalk.cyan(program.name())} ${chalk.green('<project-directory>')}`
);
console.log();
console.log('For example:');
console.log(` ${chalk.cyan(program.name())} ${chalk.green('my-react-app')}`);
console.log();
console.log(
`Run ${chalk.cyan(`${program.name()} --help`)} to see all options.`
);
process.exit(1);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# createApp
接着往下就是调用了 createApp,传入的参数是 项目名
, 是否输出额外信息
,传入的脚本版本
, 模板路径
, 是否使用npm
, 是否使用pnp
, 是否使用TypeScript
,除了项目名是必要的,其他都是可选的。
pnp: 全称Plug'n'Play, 是yarn为了解决现有的依赖管理方式效率低的解决方案,详情
function createApp(
name,
verbose,
version,
template,
useNpm,
usePnp,
useTypeScript
) {
...省略检查版本信息信息..
const root = path.resolve(name);
const appName = path.basename(root);
checkAppName(appName); // 检查传入的项目名是否合法
fs.ensureDirSync(name);
// 判断新建这个文件夹是否安全,不安全则直接退出
if (!isSafeToCreateProjectIn(root, name)) {
process.exit(1);
}
// 在新建的文件夹下写入 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
);
// 如果使用 npm, 检测npm是否在正确的目录下执行
const useYarn = useNpm ? false : shouldUseYarn();
const originalDirectory = process.cwd();
process.chdir(root);
if (!useYarn && !checkThatNpmCanReadCwd()) {
process.exit(1);
}
// ...使用yarn、pnp和typesript, 判断版本,输出一些提示信息
// 并采用旧版本的 react-scripts, 做一些兼容
// 判断结束,跑run方法,传入项目路径、项目名、reactScripts版本,是否输出额外信息,运行的路径,模板,是否使用yarn, 是否使用pnp
run(
root,
appName,
version,
verbose,
originalDirectory,
template,
useYarn,
usePnp
);
}
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
在 createApp 函数中,精简了一些输出信息,加了一些注释。这个函数主要做一些安全判断比如:检查项目名是否合法,检查新建的文件夹是否合法,检查 npm 版本,处理 react-scripts的版本兼容。检查完后调用 run 方法。
# run
到 run 这里就是真正的核心安装逻辑,开始安装依赖,拷贝模板等操作。
function run(...) {
Promise.all([
// 获取要安装的package, 默认情况下是 `react-scripts`
getInstallPackage(version, originalDirectory),
getTemplateInstallPackage(template, originalDirectory),
]).then(([packageToInstall, templateToInstall]) => {
// 需要安装所有的依赖, react,react-dom, react-scripts
const allDependencies = ['react', 'react-dom', packageToInstall];
Promise.all([
getPackageInfo(packageToInstall),
getPackageInfo(templateToInstall),
])
.then(([packageInfo, templateInfo]) =>
checkIfOnline(useYarn).then(isOnline => ({
isOnline,
packageInfo,
templateInfo,
}))
)
.then(({ isOnline, packageInfo, templateInfo }) => {
let packageVersion = semver.coerce(packageInfo.version);
// This environment variable can be removed post-release.
const templatesVersionMinimum = process.env.CRA_INTERNAL_TEST
? '3.2.0'
: '3.3.0';
// Assume compatibility if we can't test the version.
if (!semver.valid(packageVersion)) {
packageVersion = templatesVersionMinimum;
}
// Only support templates when used alongside new react-scripts versions.
const supportsTemplates = semver.gte(
packageVersion,
templatesVersionMinimum
);
if (supportsTemplates) {
allDependencies.push(templateToInstall);
} else if (template) {
console.log('');
console.log(
`The ${chalk.cyan(packageInfo.name)} version you're using ${
packageInfo.name === 'react-scripts' ? 'is not' : 'may not be'
} compatible with the ${chalk.cyan('--template')} option.`
);
console.log('');
}
...
});
}
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
run
做的事情主要有几个:先根据传入的版本 version 和原始目录 originalDirectory 去获取要安装的某个 package。
默认的 version 为空,获取到的 packageToInstall 的值为 react-scripts,然后将 packageToInstall 拼接到 allDependencies, 意为所有需要安装的依赖。react-scripts 是一系列的 webpack 配置和模板,属于另一个核心的大模块。 run 函数判断完条件之后就调用 install 方法,再由 install 方法去跑安装。
# 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);
// Explicitly set cwd() to work around issues like
// https://github.com/facebook/create-react-app/issues/3326.
// Unfortunately we can only do this for Yarn because npm support for
// equivalent --prefix flag doesn't help with this issue.
// This is why for npm, we run checkThatNpmCanReadCwd() early instead.
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();
});
});
}
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
install 代码里根据是否使用 yarn 选择 yarn 或者 npm 安装逻辑,处理方式都是同个逻辑,根据传入的 dependencies 去拼接需要安装的依赖,主要有 react、react-dom、react-scripts,再判断 verbose 和 isOnline 加一些命令行的参数,然后用 node 去跑命令,平台差异借助库 cross-spawn 去处理。
install 会返回一个 Promise, 在安装完之后会回到 run 函数继续走接下来的逻辑。
run(...) {
//...
return install(
root,
useYarn,
usePnp,
allDependencies,
verbose,
isOnline
).then(() => ({
packageInfo,
supportsTemplates,
templateInfo,
}));
})
.then(async ({ packageInfo, supportsTemplates, templateInfo }) => {
const packageName = packageInfo.name;
const templateName = supportsTemplates ? templateInfo.name : undefined;
checkNodeVersion(packageName);
setCaretRangeForRuntimeDeps(packageName);
const pnpPath = path.resolve(process.cwd(), '.pnp.js');
const nodeArgs = fs.existsSync(pnpPath) ? ['--require', pnpPath] : [];
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]));
`
);
if (version === 'react-scripts@0.9.x') {
console.log(
chalk.yellow(
`\nNote: the project was bootstrapped with an old unsupported version of tools.\n` +
`Please update to Node >=8.10 and npm >=5 to get supported tools in new projects.\n`
)
);
}
})
.catch(reason => {
console.log();
console.log('Aborting installation.');
if (reason.command) {
console.log(` ${chalk.cyan(reason.command)} has failed.`);
} else {
console.log(
chalk.red('Unexpected error. Please report it as a bug:')
);
console.log(reason);
}
console.log();
// On 'exit' we will delete these files from target directory.
const knownGeneratedFiles = [
'package.json',
'yarn.lock',
'node_modules',
];
const currentFiles = fs.readdirSync(path.join(root));
currentFiles.forEach(file => {
knownGeneratedFiles.forEach(fileToMatch => {
// This removes all knownGeneratedFiles.
if (file === fileToMatch) {
console.log(`Deleting generated file... ${chalk.cyan(file)}`);
fs.removeSync(path.join(root, file));
}
});
});
const remainingFiles = fs.readdirSync(path.join(root));
if (!remainingFiles.length) {
// Delete target folder if empty
console.log(
`Deleting ${chalk.cyan(`${appName}/`)} from ${chalk.cyan(
path.resolve(root, '..')
)}`
);
process.chdir(path.resolve(root, '..'));
fs.removeSync(path.join(root));
}
console.log('Done.');
process.exit(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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
install 把开发需要的依赖安装完之后,接下来就开始判断当前运行的 node 和 npm 是否符合已安装好的 react-scripts 里面的 package.json 要求的 node 版本。install 运行完之后,目标路径下就生成好了 package.json 和安装好了依赖,那么接下来就要生成 webpack 配置和一个简单的可启动demo。这一步主要是靠 scripts/init.js 脚本来完成。下一步我们来看 init.js 的处理逻辑。
# scripts/init.js
module.exports = function(
appPath,
appName,
verbose,
originalDirectory,
templateName
) {
const appPackage = require(path.join(appPath, 'package.json'));
const useYarn = fs.existsSync(path.join(appPath, 'yarn.lock'));
// Copy over some of the devDependencies
appPackage.dependencies = appPackage.dependencies || {};
// Setup the script rules
const templateScripts = templateJson.scripts || {};
appPackage.scripts = Object.assign(
{
start: 'react-scripts start',
build: 'react-scripts build',
test: 'react-scripts test',
eject: 'react-scripts eject',
},
templateScripts
);
fs.writeFileSync(
path.join(appPath, 'package.json'),
JSON.stringify(appPackage, null, 2) + os.EOL
);
// 改写README.md, 写入一些帮助信息
const readmeExists = fs.existsSync(path.join(appPath, 'README.md'));
if (readmeExists) {
fs.renameSync(
path.join(appPath, 'README.md'),
path.join(appPath, 'README.old.md')
);
}
// Copy the files for the user
// 把预设的模板拷贝到项目下,主要有 public, src/app.js, src/index.js等必要的文件
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;
}
// Rename gitignore after the fact to prevent npm from renaming it to .npmignore
// See: https://github.com/npm/npm/issues/1862
try {
fs.moveSync(
path.join(appPath, 'gitignore'),
path.join(appPath, '.gitignore'),
[]
);
} catch (err) {
// Append if there's already a `.gitignore` file there
if (err.code === 'EEXIST') {
const data = fs.readFileSync(path.join(appPath, 'gitignore'));
fs.appendFileSync(path.join(appPath, '.gitignore'), data);
fs.unlinkSync(path.join(appPath, 'gitignore'));
} else {
throw err;
}
}
// 再次进行命令行拼接,如果后面发现没有安装 react 和 react-dom, 重新安装一次
let command;
let remove;
let args;
if (useYarn) {
command = 'yarnpkg';
remove = 'remove';
args = ['add'];
} else {
command = 'npm';
remove = 'uninstall';
args = ['install', '--save', verbose && '--verbose'].filter(e => e);
}
// Install additional template dependencies, if present
const templateDependencies = templateJson.dependencies;
if (templateDependencies) {
args = args.concat(
Object.keys(templateDependencies).map(key => {
return `${key}@${templateDependencies[key]}`;
})
);
}
// Install react and react-dom for backward compatibility with old CRA cli
// which doesn't install react and react-dom along with react-scripts
if (!isReactInstalled(appPackage)) {
args = args.concat(['react', 'react-dom']);
}
// Install template dependencies, and react and react-dom if missing.
if ((!isReactInstalled(appPackage) || templateName) && args.length > 1) {
console.log();
console.log(`Installing template dependencies using ${command}...`);
const proc = spawn.sync(command, args, { stdio: 'inherit' });
if (proc.status !== 0) {
console.error(`\`${command} ${args.join(' ')}\` failed`);
return;
}
}
if (args.find(arg => arg.includes('typescript'))) {
console.log();
verifyTypeScriptSetup();
}
// Remove template
console.log(`Removing template package using ${command}...`);
console.log();
const proc = spawn.sync(command, [remove, templateName], {
stdio: 'inherit',
});
if (proc.status !== 0) {
console.error(`\`${command} ${args.join(' ')}\` failed`);
return;
}
if (tryGitInit(appPath)) {
console.log();
console.log('Initialized a git repository.');
}
// Display the most elegant way to cd.
// This needs to handle an undefined originalDirectory for
// backward compatibility with old global-cli's.
let cdpath;
if (originalDirectory && path.join(originalDirectory, appName) === appPath) {
cdpath = appName;
} else {
cdpath = appPath;
}
...
function isReactInstalled(appPackage) {
const dependencies = appPackage.dependencies || {};
return (
typeof dependencies.react !== 'undefined' &&
typeof dependencies['react-dom'] !== 'undefined'
);
}
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
init 文件处理的逻辑主要有:
- 修改 package.json,加入一些启动脚本,比如 start、build等命令
- 改写 README.md,写入一些帮助信息
- 把预设的模板拷贝到项目下
- 对就版本的 node 做一些兼容处理,在选择 react-scripts 时就根据 node 版本号去选择适合的版本
- 完成输出信息,如果失败,输入日志
由于篇幅关系,删减了部分代码,如果对原始代码有兴趣的可以去看 github 的源码。
# 总结
到这里create-react-app 项目的创建流程已经走完,总的来说,创建流程如下:
- 判断 node 版本,小于 8 则退出,否则执行 createReactApp.js 文件
- 判断用户命令行有没有输入 projectName,没有就提示并退出
- 根据传入的 projectName 创建目录,并创建 package.json
- 根据兼容安装适合的版本的 react-scripts, 然后使用 cross-spawn 去处理跨平台的命令行问题,安装 react, react-dom, react-scripts 依赖
- 安装完之后跑 init.js 修改 package.json 依赖,运行脚本,拷贝预设好的模板到目录执行目录下
- 处理完输出提示给用户