create-react-app 怎么这么 Diao!
前言
前段时间自己做了一个前端脚手架工具 Zeus,通过 cli 命令去搭建前端框架,目前支持了 web, node 工具类,chrome 扩展插件等模板。
其实原理都是一样的,根据不同的配置去 down 不同的 template,以及执行相应的 script。在做的过程中发现模板的搭建还真不是一个容易的活,踩过了各种坑,但同时对 webpack, rollup 这些工具也有了深入的了解。所以就在想 create-react-app 是如何实现类似功能的。
这个系列打算从 create-react-app 着手去分析它的源码。希望自己能够坚持下去~
目录结构
一级目录结构
create-react-app 使用 Lerna 管理前端 packages
值得关注的代码在 packages 这个目录下面
我们来看看 packages 里面都有什么
packages 目录结构
这里有我们熟悉的 create-react-app 目录,其余的包也挺重要,比如 react-scripts 但是我们先找到入口再来看其他文件夹的作用吧。
我们要找的入口就在 create-react-app 里面
create-react-app 目录结构
create-react-app 源码解读
create-react-app 用法
在看源码之前,我们要先知道如何使用 creat-react-app 这样能够帮助我们快速理解各部分代码的作用。
可以看到用法就是 create-react-app <project-directory> [options]
非常的简单易用,而且各参数的作用简单易懂,一看就知道怎么使用,命名非常的好。
create-rect-app/index.js
这是入口文件非常简明,就是判断当前 node 版本,如果主版本低于 8 就输出一段错误提示信息,并终止进程。并返回 1 作为退出码。(Linux 系统中只有 0 是成功码,其余的退出码都表示进程没有按预期执行成功)
如果主版本适配,那么就执行 creatReactApp 里的代码
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');
create-rect-app/createReactApp.js
这里的代码就有点多了,不能把全部都给粘上来。我们一步步分析。
const chalk = require('chalk');
const commander = require('commander');
const dns = require('dns');
const envinfo = require('envinfo');
const execSync = require('child_process').execSync;
const fs = require('fs-extra');
const hyperquest = require('hyperquest');
const inquirer = require('inquirer');
const os = require('os');
const path = require('path');
const semver = require('semver');
const spawn = require('cross-spawn');
const tmp = require('tmp');
const unpack = require('tar-pack').unpack;
const url = require('url');
const validateProjectName = require('validate-npm-package-name');
const packageJson = require('./package.json');
这里我们忽略头上的包的引用,直接看代码
使用 commander 做命令行工具
// 这段代码只是对该工具可以用哪些方法做一个声明和初始化
let projectName;
const program = new commander.Command(packageJson.name)
// 版本号
.version(packageJson.version)
.arguments('<project-directory>')
// 申明使用方法
.usage(`${chalk.green('<project-directory>')} [options]`)
.action(name => {
// 这里的 name 就是使用 create-react-app 传入的 <project-directory>
projectName = name;
})
// 申明 option 信息
.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('--use-npm')
.option('--use-pnp')
.option('--typescript')
.allowUnknownOption()
// 帮助信息
.on('--help', () => {/* 省略... */})
.parse(process.argv);
// 如果使用了 --info 参数就输出系统信息且不创建项目并返回
if (program.info) {
console.log(chalk.bold('\nEnvironment Info:'));
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);
}
// 如果没哟输入项目名称就提示如何使用并退出(退出码为 1)
if (typeof projectName === 'undefined') {
/* 省略... */
process.exit(1);
}
// 这申明了一个内部程序,当 --scripts-version 选项使用时才出发相应的操作
// 可以下载自定义的模板文件
const hiddenProgram = new commander.Command()
.option(
'--internal-testing-template <path-to-template>',
'(internal usage only, DO NOT RELY ON THIS) ' +
'use a non-standard application template'
)
.parse(process.argv);
// 这里才是 creat-react-app 处理完输入参数后执行的函数,可以看到它吧相关参数信息都传到这个函数里的
createApp(
projectName,
program.verbose,
program.scriptsVersion,
program.useNpm,
program.usePnp,
program.typescript,
hiddenProgram.internalTestingTemplate
);
那么下面我们看看 creatApp 这个函数里面做了什么吧
function createApp(
// 项目名称
name,
// 是否打印附加 log 信息
verbose,
// script 版本
version,
// 是否使用 npm
useNpm,
// 是否使用 Yarn Plug'n'Play
usePnp,
// 是否使用 ts
useTypescript,
// 模板
template
) {
const root = path.resolve(name);
const appName = path.basename(root);
// 检测名称是否合法
checkAppName(appName);
// 具体实现 -> node-fs-extra/lib/mkdirs/mkdirs-sync.js
// 总之就是如果该目录不存在就创建一个
fs.ensureDirSync(name);
// 判断该目录下是否已有一些冲突文件,如果有就认为覆盖现有文件有风险,就退出
if (!isSafeToCreateProjectIn(root, name)) {
process.exit(1);
}
console.log(`Creating a new React app in ${chalk.green(root)}.`);
console.log();
const packageJson = {
name: appName,
version: '0.1.0',
private: true,
};
// 写入 package.json
fs.writeFileSync(
path.join(root, 'package.json'),
JSON.stringify(packageJson, null, 2) + os.EOL
);
// useYarn 和 useNpm 不传的话就优先使用 yarn
const useYarn = useNpm ? false : shouldUseYarn();
const originalDirectory = process.cwd();
process.chdir(root);
// 如果不适用 yarn 且 npm 无法读取 cwd 就退出
if (!useYarn && !checkThatNpmCanReadCwd()) {
process.exit(1);
}
// 判断当前 node 版本 如果低于 8.10.0 就使用 0.9.x 版本的 react-script
if (!semver.satisfies(process.version, '>=8.10.0')) {
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 8.10 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';
}
if (!useYarn) {
const npmInfo = checkNpmVersion();
// 当前 npm 版本低于 5.0.0
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 5 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();
// yarn 版本低于 1.12.0
if (!yarnInfo.hasMinYarnPnp) {
if (yarnInfo.yarnVersion) {
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 (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 ? 原来这才是真正处理操作的地方?
// createApp 只是处理一些 npm yarn 版本等操作?让我们接着看下去这个 run 是什么
run(
root,
appName,
version,
verbose,
originalDirectory,
template,
useYarn,
usePnp,
useTypescript
);
}
下面就是 run 函数啦
function run(
root,
appName,
version,
verbose,
originalDirectory,
template,
useYarn,
usePnp,
useTypescript
) {
getInstallPackage(version, originalDirectory).then(packageToInstall => {
/* balabala 省略 */
});
}
啊,什么。run 里面就先执行了一个 getInstallPackage
函数,该函数 resolve 后再执行相应的内容。
那就继续 go! 先看看 getInstallPackage
再回头看看 balabala 里的内容吧
/*
* 这里两个参数 version 表示的是 script 版本,可能是自带的 react-script 也可能是自定义的 git 地址等
* originalDirectory 表示的是 proces.cwd() 即当前命令执行的目录
*/
function getInstallPackage(version, originalDirectory) {
let packageToInstall = 'react-scripts';
// 处理要安装的包是哪一种
const validSemver = semver.valid(version);
if (validSemver) {
packageToInstall += `@${validSemver}`;
} else if (version) {
if (version[0] === '@' && version.indexOf('/') === -1) {
packageToInstall += version;
} else if (version.match(/^file:/)) {
packageToInstall = `file:${path.resolve(
originalDirectory,
version.match(/^file:(.*)?$/)[1]
)}`;
} else {
// for tar.gz or alternative paths
packageToInstall = version;
}
}
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 --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) {
// 如果以 react-scripts-ts 开头的包就告诉用户 create-react-app 已经支持加 --typescript 参数就可以用
// 问是不是还要继续使用自定义的 react-scripts-ts 包如果反馈是是就继续下载,如果反馈是否程序正常结束退出不做任何工作。
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;
});
}
}
// 这里把要安装的包给 resolve 出去
return Promise.resolve(packageToInstall);
}
ok, 看到这里大概知道了 getInstallPackage 这个函数就是根据参数将要下载的包名称返回而已。
那继续看看 run 函数 getInstallPackage 的 then 里面是什么操作吧。
function run(
root,
appName,
version,
verbose,
originalDirectory,
template,
useYarn,
usePnp,
useTypescript
) {
getInstallPackage(version, originalDirectory).then(packageToInstall => {
// 这里包含所有的依赖项
const allDependencies = ['react', 'react-dom', packageToInstall];
// 如果用了 ts 要额外装一些包
if (useTypescript) {
// 看到这里,哈哈哈哈,原来不管哪里的程序员都爱记 TODO
// 可以看到未来 create-react-app 会根据 node 和 jest 去安装对应的 types
allDependencies.push(
// TODO: get user's node version instead of installing latest
'@types/node',
'@types/react',
'@types/react-dom',
// TODO: get version of Jest being used instead of installing latest
'@types/jest',
'typescript'
);
}
console.log('Installing packages. This might take a couple of minutes.');
/*
* getPackageName 就是根据 packageToInstall 判断是那种类型的包
* tgz|tar.gz 的压缩包
* git 仓库
* @version or @tag 类型的
* file: 本地文件名类型的
*/
getPackageName(packageToInstall)
.then(packageName =>
// 检查是否在线
checkIfOnline(useYarn).then(isOnline => ({
isOnline: isOnline,
packageName: packageName,
}))
)
.then(info => {
const isOnline = info.isOnline;
const packageName = info.packageName;
console.log(
`Installing ${chalk.cyan('react')}, ${chalk.cyan(
'react-dom'
)}, and ${chalk.cyan(packageName)}...`
);
console.log();
// 开始安装对应的包
return install(
root,
useYarn,
usePnp,
allDependencies,
verbose,
isOnline
).then(() => packageName);
})
.then(async packageName => {
// 检查当前 node 版本是否符合下载的包的 package.json 中要求的 node 版本
checkNodeVersion(packageName);
// 设置 react react-dom 版本为带 ^ 的版本号
setCaretRangeForRuntimeDeps(packageName);
const pnpPath = path.resolve(process.cwd(), '.pnp.js');
const nodeArgs = fs.existsSync(pnpPath) ? ['--require', pnpPath] : [];
// 执行下载好的文件内的 init 脚本
await executeNodeScript(
{
cwd: process.cwd(),
args: nodeArgs,
},
[root, appName, verbose, originalDirectory, template],
`
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.
// exit 前删除目标文件夹内的文件列表
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);
});
});
}
嗯,看到这里发现自己写的 Zeus 项目和 create-react-app 的思路是非常的像的。
creat-react-app 和 Zeus 都是分为两个部分,一个是命令部分,一个是模板 template 部分。然后通过命令去交互式询问并下载相应的 template。
只不过 Zeus 不支持自定义的模板,但优势就是比 creat-react-app 有更加友好的交互方式,遇到无法处理的情况会询问用户来选择如何处理。(比如目标目录已经存在 creat-react-app 会自动退出,而 Zeus 会询问用户是覆盖还是主动修改名称或者取消)这带给了用户更好的体验,还是会在未来写脚本的时候带来麻烦?这就看具体的使用场景了。至少目前使用 Zeus 下来还是不错的。
这个系列下一篇会分析 react-script 这个目录下的文件。这里面包含了模板文件,同时也让我们看看 create-react-app 是如何写 webpack 的吧。
这里是我不知道的知识点
拓展 Yarn Plug’n’Play