create-react-app教程-源码篇

之前在 create-react-app教程-使用篇 中介绍了create-react-app的基本使用,
为了便于理解一个脚手架脚本是如何运作的,现在来看一下 create-react-app v1.5.2 的源码

入口index.js

create-react-app 一般会作为全局命令,因为便于更新等原因,create-react-app 只会做初始化仓库 执行当前版本命令等操作。

找到 create-react-app 入口index文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
'use strict';
var chalk = require('chalk');
// 返回Node版本信息,如果有多个版本返回多个版本
var currentNodeVersion = process.versions.node;
var semver = currentNodeVersion.split('.');
var major = semver[0];// 取出第一个Node版本信息
//小于 4.x的提示并终止程序
if (major < 4) {
console.error(
chalk.red(
'You are running Node ' +
currentNodeVersion +
'.\n' +
'Create React App requires Node 4 or higher. \n' +
'Please update your version of Node.'
)
);
process.exit(1);
}
// 没有小于4就引入以下文件继续执行
require('./createReactApp');

可以看到 index 文件没有做什么,只是做为一个入口文件判断一下 node版本,小于 4.x的提示并终止程序, 如果正常则加载 ./createReactApp 这个文件,主要的逻辑在该文件实现。

createReactApp.js

虽然 createReactApp.js 有751行,但是里面有一大半是注释和错误友好信息。

除了声明的依赖。跟着执行顺序先看到的是第56行 program

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const program = new commander.Command(packageJson.name)
.version(packageJson.version)// create-react-app -v 时输出 ${packageJson.version}
.arguments('<project-directory>')// 这里用<> 包着project-directory 表示 project-directory为必填项
.usage(`${chalk.green('<project-directory>')} [options]`)// 用绿色字体输出 <project-directory>
.action(name => {
projectName = name;
})// 获取用户传入的第一个参数作为 projectName
.option('--verbose', 'print additional logs')
// option用于配置`create-react-app -[option]`的选项,
//比如这里如果用户参数带了 --verbose, 会自动设置program.verbose = true;
.option('--info', 'print environment debug info')
// info,用于打印出环境调试的版本信息
.option(
'--scripts-version <alternative-package>',
'use a non-standard version of react-scripts'
)
.option('--use-npm')// 默认使用`yarn`,指定使用`npm`
.allowUnknownOption()
.on('--help', () => {
//help 信息
})
.parse(process.argv);// 解析传入的参数

这里用到 commander 的依赖,这是 node.js 命令行接口的解决方案,正如我们所看到的 处理用户输入的参数,输出友好的提示信息等。

接着到了第109行:

1
2
3
4
5
6
7
8
9
10
11
12
13
//没有输入projectName的话,输出一些提示信息就终止程序
if (typeof projectName === 'undefined') {
if (program.info) {// 如果参数输入了 --info,就会进入这里
envinfo.print({// envinfo 是一个用来输出当前环境系统的而一些系统信息
packages: ['react', 'react-dom', 'react-scripts'],
noNativeIDE: true,
duplicates: true,
});
process.exit(0);
}
//略去部分log...
process.exit(1);
}

这里的 projectName 就是我们要创建的web应用名称,如果没有输入的话,输出一些提示信息就终止程序。

createApp 检测判断

然后到了第148行 执行createApp

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
createApp(
projectName,//项目名称
program.verbose, //是否暑促额外信息
program.scriptsVersion, //传入的脚本版本
program.useNpm, //是否使用npm
hiddenProgram.internalTestingTemplate //调试的模板路径,这个不管它,给开发人员调试用的……
);

function createApp(name, verbose, version, useNpm, template) {
const root = path.resolve(name);// 获取当前进程运行的位置,也就是文件目录的绝对路径
const appName = path.basename(root);// 返回root路径下最后一部分

checkAppName(appName);// 检查传入的项目名合法性
fs.ensureDirSync(name);//这里的 fs = require('fs-extra');
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)
);

const useYarn = useNpm ? false : shouldUseYarn();
const originalDirectory = process.cwd();
process.chdir(root);// 在这里就把进程目录修改为了我们创建的目录
// 如果是使用npm,检查npm是否能正常执行
if (!useYarn && !checkThatNpmCanReadCwd()) {
process.exit(1);
}
//这里的 semver = require('semver'); 做版本处理的
//如果node版本不符合要求就使用旧版本的 react-scripts
if (!semver.satisfies(process.version, '>=6.0.0')) {
//略去log信息...
// Fall back to latest supported react-scripts on Node 4
version = 'react-scripts@0.9.x';
}

// 如果npm版本小于3.x,使用旧版的 react-scripts
if (!useYarn) {
const npmInfo = checkNpmVersion();
if (!npmInfo.hasMinNpm) {
//略去log信息...
// Fall back to latest supported react-scripts for npm 3
version = 'react-scripts@0.9.x';
}
}
// 判断结束之后,执行 run 方法
run(root, appName, version, verbose, originalDirectory, template, useYarn);
}

可以了解到 createApp 主要做的事情就是做一些安全判断比如:检查项目名是否合法,检查新建的话是否安全,检查npm版本,处理react-script的版本兼容。然后看下在createApp中用到的 checkAppName

checkAppName 检查项目名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function checkAppName(appName) {
//这里 validateProjectName = require('validate-npm-package-name');
//可以用来判断当前的项目名是否符合npm规范 比如不能大写等
const validationResult = validateProjectName(appName);
// 判断是否符合npm规范如果不符合,输出提示并结束任务
if (!validationResult.validForNewPackages) {
//略去log信息...
process.exit(1);
}

const dependencies = ['react', 'react-dom', 'react-scripts'].sort();
// 判断是否重名,如果重名则输出提示并结束任务
if (dependencies.indexOf(appName) >= 0) {
//略去log信息...
process.exit(1);
}
}

run 安装依赖拷贝模版

在 createApp 方法体内调用了run方法,run方法体内完成主要的安装依赖 拷贝模板等功能。

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
function run(root,appName,version,verbose,originalDirectory,template,useYarn) {
// 这里获取要安装的package,
// getInstallPackage 默认情况下packageToInstall是 `react-scripts`。
// 也可能是根据去本地拿到对应的package
// react-scripts是一系列的webpack配置与模版
const packageToInstall = getInstallPackage(version, originalDirectory);
// 需要安装所有的依赖
const allDependencies = ['react', 'react-dom', packageToInstall];

// getPackageName 获取依赖包原始名称并返回
getPackageName(packageToInstall)
.then(packageName =>
// 如果是yarn,判断是否在线模式(对应的就是离线模式),处理完判断就返回给下一个then处理
checkIfOnline(useYarn).then(isOnline => ({
isOnline: isOnline,
packageName: packageName,
}))
)
.then(info => {
const isOnline = info.isOnline;
const packageName = info.packageName;
//略去log信息...
//传参数给install 负责安装 allDependencies
return install(root, useYarn, allDependencies, verbose, isOnline).then(
() => packageName
);
})
.then(packageName => {
//检查当前环境运行的node版本是否符合要求
checkNodeVersion(packageName);
//修改react, react-dom的版本信息,将准确版本信息改为高于等于版本
// 例如 15.0.0 => ^15.0.0
setCaretRangeForRuntimeDeps(packageName);
// `react-scripts`脚本的目录
const scriptsPath = path.resolve(
process.cwd(),
'node_modules',
packageName,
'scripts',
'init.js'
);
const init = require(scriptsPath);
//调用安装了的 react-scripts/script/init 去拷贝模版
init(root, appName, verbose, originalDirectory, template);
//略去log信息...
})

.catch(reason => {
// 出错的话,把安装了的文件全删了 并输出一些日志信息等
// 错误处理 略
process.exit(1);
});
}

可以猜到其中最重要的逻辑是 install 安装依赖和 init 拷贝模板。

install 安装依赖

install 方法体中是根据参数拼装命令行,然后用node去跑安装脚本 ,执行完成后返回一个 Promise

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
function install(root, useYarn, dependencies, verbose, isOnline) {
return new Promise((resolve, reject) => {
let command;
let args;
// 参数拼装命令行,
// 例如 使用yarn : `yarn add react react-dom`
// 或 使用npm : `npm install react react-dom --save`
if (useYarn) {
command = 'yarnpkg';
args = ['add', '--exact'];
if (!isOnline) {
args.push('--offline');
}
[].push.apply(args, dependencies);
args.push('--cwd');
args.push(root);
//略去log信息...
} else {
command = 'npm';
args = [
'install',
'--save',
'--save-exact',
'--loglevel',
'error',
].concat(dependencies);
}

if (verbose) {
args.push('--verbose');
}
//然后用node去跑安装脚本
//这里 spawn = require('cross-spawn'); 出来处理平台差异
const child = spawn(command, args, { stdio: 'inherit' });
child.on('close', code => {
if (code !== 0) {
reject({
command: `${command} ${args.join(' ')}`,
});
return;
}
resolve();
});
});
}

init 拷贝模板

init 方法默认是在 【当前web项目路径】/node_modules/react-scripts/script/init.js 中 :

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
module.exports = function(
appPath,
appName,
verbose,
originalDirectory,
template
) {
const ownPath = path.dirname(
require.resolve(path.join(__dirname, '..', 'package.json'))
);
const appPackage = require(path.join(appPath, 'package.json'));
const useYarn = fs.existsSync(path.join(appPath, 'yarn.lock'));
appPackage.dependencies = appPackage.dependencies || {};

const useTypeScript = appPackage.dependencies['typescript'] != null;

// 设置package.json 中 scripts/eslint/browserslist 信息
appPackage.scripts = {
start: 'react-scripts start',
build: 'react-scripts build',
test: 'react-scripts test',
eject: 'react-scripts eject',
};
appPackage.eslintConfig = {
extends: 'react-app',
};
appPackage.browserslist = defaultBrowsers;

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

//把预设的模版拷贝到项目下
// 可以在 react-scripts/template 看到这些文件 public目录 src目录 gitignore README.md
const templatePath = template
? path.resolve(originalDirectory, template)
: path.join(ownPath, useTypeScript ? 'template-typescript' : 'template');
if (fs.existsSync(templatePath)) {
fs.copySync(templatePath, appPath);
} else {
console.error(
`Could not locate supplied template: ${chalk.green(templatePath)}`
);
return;
}

// 如果发现没有安装react和react-dom,重新安装一次 代码略

// Install additional template dependencies, if present
//略去log信息...
};

简化一下逻辑这里的主要内容就是 修改package.json信息和拷贝模板文件

~END~