create-react-app做了什么
前言
在最开始学习react 开发的时候,我们使用 npx create-react-app my-app 命令即可创建一个react 项目,现在我们来回头看看,这一行代码具体做了什么,是如何创建一个配置好的 react 的项目的。
仓库介绍
克隆 CRA 项目后,其目录如下:
创建react-app
官方推荐创建 react 项目的两种方式:
- 全局安装
npm i -g create-react-appcreate-react-app my-app
- 使用
npx create-react-app my-app
npx 先会去检查本地有无 create-react-app没有就先下载,然后在创建
特点
CRA脚手架: 用来帮助程序员快速创建一个基于react库的模板项目,包含了所有需要的配置(语法检查、jsx编译、devServer…)。- 下载好了
所有相关的依赖,可以直接运行一个简单效果。 - 项目的
整体技术架构为:react+webpack+es6+eslint。 - 使用脚手架开发的项目的特点:
模块化,组件化,工程化。
配置断点调试
vscode 调试
vscode 的调试非常简单,点击这个小甲虫图标,点击设置 然后直接修改 "program"的值,修改完点击左上角的绿色箭头就可以跑起来了,如果要在某一处断点,比如 create-react-app/index.js line39 断点,直接在行号的左边点一下鼠标就可以了
launch.json 配置
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "start debug cra",
"program": "${workspaceFolder}/packages/create-react-app/index.js"
}
]
}
node 调试
如果平时没有用vscode开发或者习惯chrome-devtool的,可以直接用node命令跑,然后在chrome里面调试。首先保证node的版本的 6 以上,然后在项目根目录下运行 node --inspect-brk packages/create-react-app/index.js在chrome地址栏输入 chrome://inspect/#devices 然后就可以看到我们要调试的脚本了,关于node chrome-devtool 调试详细可以看这里 传送门
断点调试源码阅读

脚本执行
/**
* File: packages/tasks/cra.js
*/
// Now run the CRA command
const craScriptPath = path.join(packagesDir, 'create-react-app', 'index.js');
// const cp = require('child_process');
cp.execSync(
`node ${craScriptPath} ${args.join(' ')} --scripts-version="${scriptsPath}"`,
{
cwd: rootDir,
stdio: 'inherit',
}
);
这里会拿到 create-react-app/index.js 中代码,然后通过 node 的 child_process 子进程 去执行这个文件的代码
File:packages/create-react-app/index.js
这个文件十分简单,只是做为一个入口文件判断一下 node版本,小于 4.x的提示并终止程序, 如果正常则加载
./createReactApp 这个文件,主要的逻辑在该文件实现。
File:packages/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('')} [options]`) // 用绿色字体输出
.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') // 后面会用到这个参数,用于打印出环境调试的版本信息
.option(
'--scripts-version ',
'use a non-standard version of react-scripts'
)
.option('--use-npm')
.allowUnknownOption()
// on('option', cb) 输入 create-react-app --help 自动执行后面的操作输出帮助
.on('--help', () => {
console.log(` Only ${chalk.green('')} 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 custom fork published on npm: ${chalk.green(
'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(
` If you have any problems, do not hesitate to file an issue:`
);
console.log(
` ${chalk.cyan(
'https://github.com/facebookincubator/create-react-app/issues/new'
)}`
);
console.log();
})
.parse(process.argv); // 解析传入的参数 可以不用理会
这里用到了commander.js依赖, 去npm官网查一下这个依赖的作用,简单了解下,自己开发脚手架时知道有这么个东西就行。
判断用户是否设置projectName
这里会判断这个必传字段-projectNam,如果没有的话,直接就终止进程了。
这里在vscode直接断点调试时,因为在启动调试服务的时候没有给脚本传入参数作为 projectName,这里我们添加下,不然直接终止了。
createApp
然后就进入到了 createApp 方法中了,可以看到函数接受的对应的参数,这里我们一步一步看看这个函数到底做了是什么
function createApp(name, verbose, version, template, useYarn, usePnp) {
const unsupportedNodeVersion = !semver.satisfies(
// Coerce strings with metadata (i.e. `15.0.0-nightly`).
semver.coerce(process.version),
'>=14'
);
if (unsupportedNodeVersion) {
console.log(
chalk.yellow(
`You are using Node ${process.version} so the project will be bootstrapped with an old unsupported version of tools.\n\n` +
`Please update to Node 14 or higher for a better, fully supported experience.\n`
)
);
// Fall back to latest supported react-scripts on Node 4
version = 'react-scripts@0.9.x';
}
const root = path.resolve(name);
const appName = path.basename(root);
checkAppName(appName); // 检查项目名称是否合法
fs.ensureDirSync(name);
// 判断新建这个文件夹是否是安全的 不安全直接退出
if (!isSafeToCreateProjectIn(root, name)) {
process.exit(1);
}
console.log();
console.log(`Creating a new React app in ${chalk.green(root)}.`);
console.log();
// 新建的文件夹下面写入一个 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
);
const originalDirectory = process.cwd();
process.chdir(root);
// 如果使用npm,判断能否在当前文件下正确运行
if (!useYarn && !checkThatNpmCanReadCwd()) {
process.exit(1);
}
if (!useYarn) {
const npmInfo = checkNpmVersion();
if (!npmInfo.hasMinNpm) {
if (npmInfo.npmVersion) {
console.log(
chalk.yellow(
`You are using npm ${npmInfo.npmVersion} so the project will be bootstrapped with an old unsupported version of tools.\n\n` +
`Please update to npm 6 or higher for a better, fully supported experience.\n`
)
);
}
// Fall back to latest supported react-scripts for npm 3
version = 'react-scripts@0.9.x';
}
} else if (usePnp) {
const yarnInfo = checkYarnVersion();
if (yarnInfo.yarnVersion) {
if (!yarnInfo.hasMinYarnPnp) {
console.log(
chalk.yellow(
`You are using Yarn ${yarnInfo.yarnVersion} together with the --use-pnp flag, but Plug'n'Play is only supported starting from the 1.12 release.\n\n` +
`Please update to Yarn 1.12 or higher for a better, fully supported experience.\n`
)
);
// 1.11 had an issue with webpack-dev-middleware, so better not use PnP with it (never reached stable, but still)
usePnp = false;
}
if (!yarnInfo.hasMaxYarnPnp) {
console.log(
chalk.yellow(
'The --use-pnp flag is no longer necessary with yarn 2 and will be deprecated and removed in a future release.\n'
)
);
// 2 supports PnP by default and breaks when trying to use the flag
usePnp = false;
}
}
}
// 判断结束之后,跑run 方法
// 传入 项目路径,项目名, reactScripts版本, 是否输入额外信息, 运行的路径, 模板(开发调试用的), 是否使用yarn
run(root,appName,version,verbose,originalDirectory,template,useYarn,usePnp);
}
run
前面的步骤都是一些前置判断,到这里 run 方法里面才开始真正的逻辑,依赖安装、模版拷贝等等
function run(...){
// 这里获取安装的依赖包,默认是 react-script,也可以根据用户传入的
const packageToInstall = getInstallPackage(version, originalDirectory);
// 需要安装的所有依赖
const allDependencies = ['react', 'react-dom', packageToInstall];
...
}
run 做的事情主要有:根据传入的 version 和 originalDorectory 来获取需要安装的依赖;默认 version 为空,则获取的是 react-scripts ,合并到 allDependencies 依赖数组中;其实 react-scripts 中是一系列的 webpack 配置,可以在 packages/react-scripts/ 路径下查看完整的配置代码。
function run(...) {
...
// 获取包名,支持 taz|tar格式、git仓库、版本号、文件路径等等
getPackageName(packageToInstall)
.then(packageName =>
// 如果是yarn,判断是否在线模式(对应的就是离线模式),处理完判断就返回给下一个then处理
checkIfOnline(useYarn).then(isOnline => ({
isOnline: isOnline,
packageName: packageName,
}))
)
.then(info => {
const isOnline = info.isOnline;
const packageName = info.packageName;
/** 开始核心的安装部分 传入`安装路径`,`是否使用yarn`,`所有依赖`,`是否输出额外信息`,`在线状态` **/
/** 这里主要的操作是 根据传入的参数,开始跑 npm || yarn 安装react react-dom等依赖 **/
/** 这里如果网络不好,可能会挂 **/
return install(root, useYarn, allDependencies, verbose, isOnline).then(
() => packageName
);
})
...
}
install
function install(root, useYarn, dependencies, verbose, isOnline) {
// 主要根据参数拼装命令行,然后用node去跑安装脚本 如 `npm install react react-dom --save` 或者 `yarn add react react-dom`
return new Promise((resolve, reject) => {
let command;
let args;
// 开始拼装 yarn 命令行
if (useYarn) {
command = 'yarnpkg';
args = ['add', '--exact']; // 使用确切版本模式
// 判断是否是离线状态 加个状态
if (!isOnline) {
args.push('--offline');
}
[].push.apply(args, dependencies);
// 将cwd设置为我们要安装的目录路径
args.push('--cwd');
args.push(root);
// 如果是离线的话输出一些提示信息
} else {
// npm 安装模式,与yarn同理
command = 'npm';
args = [
'install',
'--save',
'--save-exact',
'--loglevel',
'error',
].concat(dependencies);
}
// 如果有传verbose, 则加该参数 输出额外的信息
if (verbose) {
args.push('--verbose');
}
// 用 cross-spawn 跨平台执行命令行
const child = spawn(command, args, { stdio: 'inherit' });
// 关闭的处理
child.on('close', code => {
if (code !== 0) {
return reject({ command: `${command} ${args.join(' ')}`, });
}
resolve();
});
});
}
顺着断点从run跑到install方法,能看到代码里根据是否使用yarn分成两种处理方法。if (useYarn) { yarn 安装逻辑 } else { npm 安装逻辑 }处理方法都是同个逻辑,根据传入的 dependencies 去拼接需要安装的依赖,主要有 react,react-dom,react-script 。再判断verbose和isOnline 加一些命令行的参数。 最后再用node跑命令,平台差异的话是借助cross-spawn去处理的,这里不再赘述。 具体逻辑见上面代码,去掉不重要的信息输出,代码还是比较易懂。
在install会返回一个Promise在安装完之后,断点又回到我们的run函数继续走接下来的逻辑。
function run() {
...
getPackageName()
.then(()=> {
return install(root, useYarn, allDependencies, verbose, isOnline).then(
() => packageName
);
})
...
}
既然我们的install已经把开发需要的依赖安装完了,接下来我们可以开判断当前运行的node是否符合我们已经安装的react-scripts里面的packages.json要求的node版本。 这句话有点绕,简单来说就是判断当前运行的node版本是否react-scripts这个依赖所需。
然后就把开始修改package.json我们已经安装的依赖(react, react-dom, react-scripts)版本从原本的精确版本eg(16.0.0)修改为高于等于版本eg(^16.0.0)。 这些处理做完之后,我们的目录是长这样子的,里面除了安装的依赖和package.json外没有任何东西。所以接下来的操作是生成一些webpack的配置和一个简单的可启动demo。
那么他是怎么快速生成这些东西的呢? 还记得一开始说了有一个 隐藏的命令行参数 --internal-testing-template 用来给开发者调试用的吗,所以其实create-react-app生成这些的方法就是直接把某一个路径的模板拷贝到对应的地方。很简单粗暴
run(...) {
...
getPackageName(packageToInstall)
.then(...)
.then(info => install(...).then(()=> packageName))
/** install 安装完之后的逻辑 **/
/** 从这里开始拷贝模板逻辑 **/
.then(packageName => {
// 安装完 react, react-dom, react-scripts 之后检查当前环境运行的node版本是否符合要求
checkNodeVersion(packageName);
// 该项package.json里react, react-dom的版本范围,eg: 16.0.0 => ^16.0.0
setCaretRangeForRuntimeDeps(packageName);
// 加载script脚本,并执行init方法
const scriptsPath = path.resolve(
process.cwd(),
'node_modules',
packageName,
'scripts',
'init.js'
);
const init = require(scriptsPath);
// init 方法主要执行的操作是
// 写入package.json 一些脚本。eg: script: {start: 'react-scripts start'}
// 改写README.MD
// 把预设的模版拷贝到项目下
// 输出成功与后续操作的信息
init(root, appName, verbose, originalDirectory, template);
if (version === 'react-scripts@0.9.x') {
// 如果是旧版本的 react-scripts 输出提示
}
})
.catch(reason => {
// 出错的话,把安装了的文件全删了 并输出一些日志信息等
});
}
这里安装完依赖之后,执行checkNodeVersion判断node版本是否与依赖相符。
之后拼接路径去跑目录/node_modules/react-scripts/scripts/init.js,传参让他去做一些初始化的事情。
然后对出错情况做一些相应的处理
File:packages/react-scripts/scripts/init.js
module.exports = function(
appPath,
appName,
verbose,
originalDirectory,
template
) {
const ownPackageName = require(path.join(__dirname, '..', 'package.json'))
.name;
const ownPath = path.join(appPath, 'node_modules', ownPackageName);
const appPackage = require(path.join(appPath, 'package.json'));
const useYarn = fs.existsSync(path.join(appPath, 'yarn.lock'));
// 1. 把启动脚本写入目标 package.json
appPackage.scripts = {
start: 'react-scripts start',
build: 'react-scripts build',
test: 'react-scripts test --env=jsdom',
eject: 'react-scripts eject',
};
fs.writeFileSync(
path.join(appPath, 'package.json'),
JSON.stringify(appPackage, null, 2)
);
// 2. 改写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')
);
}
// 3. 把预设的模版拷贝到项目下,主要有 public, src/[APP.css, APP.js, index.js,....], .gitignore
const templatePath = template
? path.resolve(originalDirectory, template)
: path.join(ownPath, 'template');
if (fs.existsSync(templatePath)) {
fs.copySync(templatePath, appPath);
} else {
return;
}
fs.move(
path.join(appPath, 'gitignore'),
path.join(appPath, '.gitignore'),
[],
err => { /* 错误处理 */ }
);
// 这里再次进行命令行的拼接,如果后面发现没有安装react和react-dom,重新安装一次
let command;
let args;
if (useYarn) {
command = 'yarnpkg';
args = ['add'];
} else {
command = 'npm';
args = ['install', '--save', verbose && '--verbose'].filter(e => e);
}
args.push('react', 'react-dom');
const templateDependenciesPath = path.join(
appPath,
'.template.dependencies.json'
);
if (fs.existsSync(templateDependenciesPath)) {
const templateDependencies = require(templateDependenciesPath).dependencies;
args = args.concat(
Object.keys(templateDependencies).map(key => {
return `${key}@${templateDependencies[key]}`;
})
);
fs.unlinkSync(templateDependenciesPath);
}
if (!isReactInstalled(appPackage) || template) {
const proc = spawn.sync(command, args, { stdio: 'inherit' });
if (proc.status !== 0) {
console.error(``${command} ${args.join(' ')}` failed`);
return;
}
}
// 5. 输出成功的日志
};
init文件又是一个大头,处理的逻辑主要有
- 修改package.json,写入一些启动脚本,比如
script: {start: 'react-scripts start'},用来启动开发项目 - 改写README.MD,把一些帮助信息写进去
- 把预设的模版拷贝到项目下,主要有
public,src/[APP.css, APP.js, index.js,....],.gitignore - 对旧版的node做一些兼容的处理,这里补一句,在选择 react-scripts 时就有根据node版本去判断选择比较老的 @0.9.x 版。
- 如果完成输出对应的信息,如果失败,做一些输出日志等操作。
总结
到这里 create-react-app 项目构建的部分大流程已经走完了,我们来回顾一下:
- 判断node版本如果小于4就退出,否则执行
createReactApp.js文件 createReactApp.js先做一些命令行的处理响应处理,然后判断是否有传入projectName没有就提示并退出- 根据传入的
projectName创建目录,并创建package.json。 - 判断是否有特殊要求指定安装某个版本的
react-scripts,然后用cross-spawn去处理跨平台的命令行问题,用yarn或npm安装react,react-dom,react-scripts。 - 安装完之后跑
react-scripts/script/init.js修改package.json的依赖版本,运行脚本,并拷贝对应的模板到目录里。 - 处理完这些之后,输出提示给用户。