Create React App 源码揭秘

目录
背景
文章首发于@careteen/create-react-app,转载请注明来源即可。
Create React App是一个官方支持的创建React单页应用程序的脚手架。它提供了一个零配置的现代化配置设置。
平时工作中一部分项目使用的React,使用之余也需要了解其脚手架实现原理。
之前做的模板项目脚手架@careteen/cli,实现方式比较原始。后续准备通过lerna进行重构。下面先做一些前备知识了解。
monorepo管理
如果对monorepo和lerna已经比较了解,可以直接移步CreateReactApp架构Monorepo是管理项目代码的一个方式,指在一个项目仓库(repo)中管理多个模块/包(package)。不同于常见的每个模块都需要建一个repo。
babel的packages目录下存放了多个包。

monorepo优势
Monorepo最主要的好处是统一的工作流和代码共享。
比如我在看babel-cli的源码时,其中引用了其他库,如果不使用Monorepo管理方式,而是对@babel/core新建一个仓库,则需要打开另外一个仓库。如果直接在当前仓库中查看,甚至修改进行本地调试,那阅读别人代码会更加得心应手。
import { buildExternalHelpers } from "@babel/core";目前大多数开源库都使用Monorepo进行管理,如react、vue-next、create-react-app。
monorepo劣势
- 体积庞大。
babel仓库下存放了所有相关代码,clone到本地也需要耗费不少时间。 - 不适合用于公司项目。各个业务线仓库代码基本都是独立的,如果堆放到一起,理解和维护成本将会相当大。
Lerna
如果对monorepo和lerna已经比较了解,可以直接移步CreateReactApp架构Lerna是babel团队对Monorepo的最佳实践。是一个管理多个npm模块的工具,有优化维护多个包的工作流,解决多个包互相依赖,且发布需要手动维护多个包的问题。
前往lerna查看官方文档,下面做一个简易入门。
全局安装Lerna
$ npm i -g lerna初始化项目
$ mkdir lerna-example && cd $_
$ lerna init生成项目结构
|-- lerna.json
|-- package.json
`-- packages # 暂时为空文件夹packages.json文件中指定packages工作目录为packages/*下所有目录
{
"packages": [
"packages/*"
],
"version": "0.0.0"
}创建Package
# 一路回车即可
$ lerna create create-react-app
$ lerna create react-scripts
$ lerna create cra-template会在packages/目录下生成三个子项目

开启Workspace
默认是npm,每个子package都有自己的node_modules。
新增如下配置,开启workspace。目的是让顶层统一管理node_modules,子package不管理。
// package.json
{
"private": true,
"workspaces": [
"packages/*"
],
}// lerna.json
{
"useWorkspaces": true,
"npmClient": "yarn"
}Lerna Script
前往Lerna查看各个command的详细使用- lerna add
- lerna bootstrap
- lerna list
- lerna link
- lerna publish
lerna add
# 语法
$ lerna add <package>[@version] [--dev] [--exact] [--peer]# 示例
# 为所有子`package`都安装`chalk`
$ lerna add chalk
# 为`create-react-app`安装`commander`
$ lerna add commander --scope=create-react-app
# 如果安装失败,请检查拼写是否错误或者查看子包是否有命名空间
$ lerna list
# 由于我的包做了命名空间,所以需要加上前缀
$ lerna add commander --scope=@careteen/create-react-app如果想要在根目录为所有子包添加统一依赖,并只在根目录下package.josn,可以借助yarn
yarn add chalk --ignore-workspace-root-check还能在根目录为某个子package安装依赖
# 子包有命名空间需要加上
yarn workspace create-react-app add commanderlerna bootstrap
默认是npm i,指定使用yarn后,就等价于yarn install
lerna list
列出所有的包
$ lerna list打印结果
info cli using local version of lerna
lerna notice cli v3.22.1
@careteen/cra-template
@careteen/create-react-app
@careteen/react-scripts
lerna success found 3 packageslerna link
建立软链,等价于npm link
lerna publish
$ lerna publish # 发布自上次发布以来已经更改的包
$ lerna publish from-git # 显式发布在当前提交中标记的包
$ lerna publish from-package # 显式地发布注册表中没有最新版本的包第一次发布报错
- 原因
第一次leran publish发布时会报错lerna ERR! E402 You must sign up for private packages,原因可查看lerna #1821。
- 解决方案
以下操作需要保证将本地修改都git push,并且将npm registry设置为 https://registry.npmjs.org/ 且已经登录后。
- 由于
npm限制,需要先在package.json中做如下设置
"publishConfig": {
"access": "public"
},- 然后前往各个子包先通过
npm publish发布一次
$ cd packages/create-react-app && npm publish --access=public- 修改代码后下一次发布再使用
lerna publish,可得到如下日志
$ lerna publish
Patch (0.0.1) # 选择此项并回车
Minor (0.1.0)
Major (1.0.0)
Prepatch (0.0.1-alpha.0)
Preminor (0.1.0-alpha.0)
Premajor (1.0.0-alpha.0)
Custom Prerelease
Custom Version
? Select a new version (currently 0.0.0) Patch (0.0.1)
Changes:
- @careteen/cra-template: 0.0.1 => 0.0.1
- @careteen/create-react-app: 0.0.1 => 0.0.1
- @careteen/react-scripts: 0.0.1 => 0.0.1
? Are you sure you want to publish these packages? (ynH) # 输入y并回车
Successfully published: # 发布成功
- @careteen/cra-template@0.0.2
- @careteen/create-react-app@0.0.2
- @careteen/react-scripts@0.0.2
lerna success published 3 packages如果此过程又失败并报错lerna ERR! fatal: tag 'v0.0.1' already exists,对应issues可查看lerna #1894。需要先将本地和远程tag删除,再发布。
# 删除本地tag
git tag -d v0.0.1
# 删除远程tag
git push origin :refs/tags/v0.0.1
# 重新发布
lerna publishCreateReactApp架构

packages/create-react-app
准备工作
在项目根目录package.json文件新增如下配置
"scripts": {
"create": "node ./packages/create-react-app/index.js"
}然后在packages/create-react-app/package.json新增如下配置
"main": "./index.js",
"bin": {
"careteen-cra": "./index.js"
},新增packages/create-react-app/index.js文件
#!/user/bin/env node
const { init } = require('./createReactApp')
init()新增packages/create-react-app/createReactApp.js文件
const chalk = require('chalk')
const { Command } = require('commander')
const packageJson = require('./package.json')
const init = async () => {
let appName;
new Command(packageJson.name)
.version(packageJson.version)
.arguments('<project-directory>')
.usage(`${chalk.green('<project-directory>')} [options]`)
.action(projectName => {
appName = projectName
})
.parse(process.argv)
console.log(appName, process.argv)
}
module.exports = {
init,
}在项目根目录运行
# 查看包版本
npm run create -- --version
# 打印出`myProject`
npm run create -- myProject会打印myProject,`[ '/Users/apple/.nvm/versions/node/v14.8.0/bin/node', '/Users/apple/Desktop/create-react-app/packages/create-react-app/index.js', 'myProject']`
创建package.json
先添加依赖
# cross-spawn 跨平台开启子进程
# fs-extra fs增强版
yarn add cross-spawn fs-extra --ignore-workspace-root-check在当前工作环境创建myProject目录,然后创建package.json文件写入部分配置
const fse = require('fs-extra')
const init = async () => {
// ...
await createApp(appName)
}
const createApp = async (appName) => {
const root = path.resolve(appName)
fse.ensureDirSync(appName)
console.log(`Creating a new React app in ${chalk.green(root)}.`)
const packageJson = {
name: appName,
version: '0.1.0',
private: true,
}
fse.writeFileSync(
path.join(root, 'package.json'),
JSON.stringify(packageJson, null, 2)
)
const originalDirectory = process.cwd()
console.log('originalDirectory: ', originalDirectory)
console.log('root: ', root)
}安装依赖项
然后改变工作目录为新创建的myProject目录,确保后续为此目录安装依赖react, react-dom, react-scripts, cra-template
const createApp = async (appName) => {
// ...
process.chdir(root)
await run(root, appName, originalDirectory)
}
const run = async (root, appName, originalDirectory) => {
const scriptName = 'react-scripts'
const templateName = 'cra-template'
const allDependencies = ['react', 'react-dom', scriptName, templateName]
console.log(
`Installing ${chalk.cyan('react')}, ${chalk.cyan(
'react-dom'
)}, and ${chalk.cyan(scriptName)}${
` with ${chalk.cyan(templateName)}`
}...`
)
}此时我们还没有编写react-scripts, cra-template这两个包,先使用现有的。
后面实现后可改为@careteen/react-scripts, @careteen/cra-templatelerna add react-scripts cra-template --scope=@careteen/create-react-app借助cross-spawn开启子进程安装依赖
const run = async (root, appName, originalDirectory) => {
// ...
await install(root, allDependencies)
}
const install = async (root, allDependencies) => {
return new Promise((resolve) => {
const command = 'yarnpkg'
const args = ['add', '--exact', ...allDependencies, '--cwd', root]
const child = spawn(command, args, {
stdio: 'inherit',
})
child.on('close', resolve)
})
}拷贝模板
核心部分在于运行react-scripts/scripts/init.js做模板拷贝工作。
const run = async (root, appName, originalDirectory) => {
// ...
await install(root, allDependencies)
const data = [root, appName, true, originalDirectory, templateName]
const source = `
var init = require('react-scripts/scripts/init.js');
init.apply(null, JSON.parse(process.argv[1]));
`
await executeNodeScript(
{
cwd: process.cwd(),
},
data,
source,
)
console.log('Done.')
process.exit(0)
}
const executeNodeScript = async ({ cwd }, data, source) => {
return new Promise((resolve) => {
const child = spawn(
process.execPath,
['-e', source, '--', JSON.stringify(data)],
{
cwd,
stdio: 'inherit',
}
)
child.on('close', resolve)
})
}其中spawn(process.execPath, args, { cwd })类似于我们直接在terminal中直接使用node -e 'console.log(1 + 1)',可以直接运行js代码。
查看效果
运行下面脚本
npm run create -- myProject可以在当前项目根目录看到myProject的目录结构。
此时已经实现了create-react-app`package的核心功能。下面将进一步剖析cra-tempalte, react-scripts`。
packages/cra-tempalte
cra-tempalte可以从cra-tempalte拷贝,启动一个简易React单页应用。
对React原理感兴趣的可前往由浅入深React的Fiber架构查看。packages/cra-tempalte--typescript
同上,不是本文讨论重点。
packages/react-scripts
安装依赖
# `lerna`给子包装多个依赖时报警告`lerna WARN No packages found where webpack can be added.`
lerna add webpack webpack-dev-server babel-loader babel-preset-react-app html-webpack-plugin open --scope=@careteen/react-scripts
# 故使用`yarn`安装
yarn workspace @careteen/react-scripts add webpack webpack-dev-server babel-loader babel-preset-react-app html-webpack-plugin open为package.json配置
"bin": {
"careteen-react-scripts": "./bin/react-scripts.js"
},
"scripts": {
"start": "node ./bin/react-scripts.js start",
"build": "node ./bin/react-scripts.js build"
},创建bin/react-scripts.js文件
#!/usr/bin/env node
const spawn = require('cross-spawn')
const args = process.argv.slice(2)
const script = args[0]
spawn.sync(
process.execPath,
[require.resolve('../scripts/' + script)],
{ stdio: 'inherit' }
)react-scripts build
对webpack原理感兴趣的可前往@careteen/webpack查看简易实现。创建scripts/build.js文件,主要负责两件事
- 拷贝模板项目的
public目录下的所有静态资源到build目录下 - 配置为
production环境,使用webpack(config).run()编译打包
process.env.NODE_ENV = 'production'
const chalk = require('chalk')
const fs = require('fs-extra')
const webpack = require('webpack')
const configFactory = require('../config/webpack.config')
const paths = require('../config/paths')
const config = configFactory('production')
fs.emptyDirSync(paths.appBuild)
copyPublicFolder()
build()
function build() {
const compiler = webpack(config)
compiler.run((err, stats) => {
console.log(err)
console.log(chalk.green('Compiled successfully.\n'))
})
}
function copyPublicFolder() {
fs.copySync(paths.appPublic, paths.appBuild, {
filter: file => file !== paths.appHtml,
})
}配置config/webpack.config.js文件
const paths = require('./paths')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = function (webpackEnv) {
const isEnvDevelopment = webpackEnv === 'development'
const isEnvProduction = webpackEnv === 'production'
return {
mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development',
output: {
path: paths.appBuild
},
module: {
rules: [{
test: /\.(js|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
presets: [
[
require.resolve('babel-preset-react-app')
]
]
}
}, ]
},
plugins: [
new HtmlWebpackPlugin({
inject: true,
template: paths.appHtml
})
]
}
}配置config/paths.js文件
const path = require('path')
const appDirectory = process.cwd()
const resolveApp = relativePath => path.resolve(appDirectory, relativePath)
module.exports = {
appHtml: resolveApp('public/index.html'),
appIndexJs: resolveApp('src/index.js'),
appBuild: resolveApp('build'),
appPublic: resolveApp('public')
}npm run build后可查看build目录下会生成编译打包后的所有文件
react-scripts start
创建scripts/start.js文件,借助webpack功能启服务
process.env.NODE_ENV = 'development'
const configFactory = require('../config/webpack.config')
const createDevServerConfig = require('../config/webpackDevServer.config')
const WebpackDevServer = require('webpack-dev-server')
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000
const HOST = process.env.HOST || '0.0.0.0'
const config = configFactory('development')
const webpack = require('webpack')
const chalk = require('chalk')
const compiler = createCompiler({
config,
webpack
})
const serverConfig = createDevServerConfig()
const devServer = new WebpackDevServer(compiler, serverConfig)
devServer.listen(DEFAULT_PORT, HOST, err => {
if (err) {
return console.log(err)
}
console.log(chalk.cyan('Starting the development server...\n'))
})
function createCompiler({
config,
webpack
}) {
let compiler = webpack(config)
return compiler
}创建config\webpackDevServer.config.js文件提供本地服务设置
对webpack热更新原理感兴趣的可前往@careteen/webpack-hmr查看简易实现。module.exports = function () {
return {
hot: true
}
}npm run start后可在浏览器 http://localhost:8080/ 打开查看效果
react-scripts小结
上面两节实现没有源码考虑的那么完善。后面将针对源码中使用到的一些较为巧妙的第三方库和webpack-plugin做讲解。
packages/react-dev-utils
此子package下存放了许多webpack-plugin辅助于react-scripts/config/webpack.config.js文件。在文件中搜索plugins字段查看。
此文先列举一些我觉得好用的plugins
- PnpWebpackPlugin。提供一种更加高效的模块查找机制,试图取代
node_modules。 - ModuleScopePlugin。阻止用户从src/(或node_modules/)外部导入文件。
- InterpolateHtmlPlugin。使得
<link rel="icon" href="%PUBLIC_URL%/favicon.ico">中可以使用变量%PUBLIC_URL%。 - WatchMissingNodeModulesPlugin。使得安装了新的依赖不再需要重新启动项目也能正常运行。
return {
// ...
resolve: {
plugins: [
// 增加了对即插即用(Plug'n'Play)安装的支持,提高了安装速度,并增加了对遗忘依赖项等的保护。
PnpWebpackPlugin,
// 阻止用户从src/(或node_modules/)外部导入文件。
// 这经常会引起混乱,因为我们只使用babel处理src/中的文件。
// 为了解决这个问题,我们阻止你从src/导入文件——如果你愿意,
// 请将这些文件链接到node_modules/中,然后让模块解析开始。
// 确保源文件已经编译,因为它们不会以任何方式被处理。
new ModuleScopePlugin(paths.appSrc, [
paths.appPackageJson,
reactRefreshOverlayEntry,
]),
],
},
plugins: [
// ...
// 使一些环境变量在index.html中可用。
// public URL在index中以%PUBLIC_URL%的形式存在。html,例如:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// 除非你指定"homepage"否则它将是一个空字符串
// 在包中。在这种情况下,它将是该URL的路径名。
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// 如果你需要一个缺失的模块,然后用' npm install '来安装它,你仍然需要重启开发服务器,webpack才能发现它。这个插件使发现自动,所以你不必重新启动。
// 参见https://github.com/facebook/create-react-app/issues/186
isEnvDevelopment &&
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
]
}PnpWebpackPlugin
增加了对即插即用(Plug'n'Play)安装的支持,提高了安装速度,并增加了对遗忘依赖项等的保护。试图取代node_modules。先来了解下使用node_modules模式的机制
- 将依赖包的版本区间解析为某个具体的版本号
- 下载对应版本依赖的
tar报到本地离线镜像 - 将依赖从离线镜像解压到本地缓存
- 将依赖从缓存拷贝到当前目录的
node_modules目录
PnP工作原理是作为上述第四步骤的替代方案
PnP使用
示例存放在plugins-example/PnpWebpackPlugin
create-react-app已经集成了对PnP的支持。只需在创建项目时添加--use-pnp参数。
create-react-app myProject --use-pnp在已有项目中开启可使用yarn提供的--pnp
yarn --pnp
yarn add uuid与此同时会自动在package.json中配置开启pnp。而且不会生成node_modules目录,取而代替生成.pnp.js文件。
{
"installConfig": {
"pnp": true
}
}由于在开启了 PnP 的项目中不再有 node_modules 目录,所有的依赖引用都必须由 .pnp.js 中的 resolver 处理因此不论是执行 script 还是用 node 直接执行一个 JS 文件,都必须经由 Yarn 处理
{
// 还需配置使用脚本
"scripts": {
"build": "node uuid.js"
}
}运行脚本查看效果
yarn run build
# 或者使用node
yarn node uuid.js
ModuleScopePlugin
阻止用户从src/(或node_modules/)外部导入文件。这经常会引起混乱,因为我们只使用babel处理src/中的文件。为了解决这个问题,我们阻止你从src/导入文件——如果你愿意,请将这些文件链接到node_modules/中,然后让模块解析开始。确保源文件已经编译,因为它们不会以任何方式被处理。
通过create-react-app生成的项目内部引用不了除src外的目录,不然会报错which falls outside of the project src/ directory. Relative imports outside of src/ are not supported.
通常解决方案是借助react-app-rewired, customize-cra解决。
那接下来看看是如何实现这个功能。
示例存放在plugins-example/ModuleScopePlugin
实现步骤主要是
- 着手于resolver.hooks.file解析器读取文件
request时。 - 解析的文件路径如果包含
node_modules则放行。 - 解析的文件路径如果包含使用此插件的传参
appSrc则放行。 - 解析的文件路径和
src做path.relative,结果如果是以../开头,则认为在src路径之外,会抛错。
const chalk = require('chalk');
const path = require('path');
const os = require('os');
class ModuleScopePlugin {
constructor(appSrc, allowedFiles = []) {
this.appSrcs = Array.isArray(appSrc) ? appSrc : [appSrc];
this.allowedFiles = new Set(allowedFiles);
}
apply(resolver) {
const { appSrcs } = this;
resolver.hooks.file.tapAsync(
'ModuleScopePlugin',
(request, contextResolver, callback) => {
// Unknown issuer, probably webpack internals
if (!request.context.issuer) {
return callback();
}
if (
// If this resolves to a node_module, we don't care what happens next
request.descriptionFileRoot.indexOf('/node_modules/') !== -1 ||
request.descriptionFileRoot.indexOf('\\node_modules\\') !== -1 ||
// Make sure this request was manual
!request.__innerRequest_request
) {
return callback();
}
// Resolve the issuer from our appSrc and make sure it's one of our files
// Maybe an indexOf === 0 would be better?
if (
appSrcs.every(appSrc => {
const relative = path.relative(appSrc, request.context.issuer);
// If it's not in one of our app src or a subdirectory, not our request!
return relative.startsWith('../') || relative.startsWith('..\\');
})
) {
return callback();
}
const requestFullPath = path.resolve(
path.dirname(request.context.issuer),
request.__innerRequest_request
);
if (this.allowedFiles.has(requestFullPath)) {
return callback();
}
// Find path from src to the requested file
// Error if in a parent directory of all given appSrcs
if (
appSrcs.every(appSrc => {
const requestRelative = path.relative(appSrc, requestFullPath);
return (
requestRelative.startsWith('../') ||
requestRelative.startsWith('..\\')
);
})
) {
const scopeError = new Error(
`You attempted to import ${chalk.cyan(
request.__innerRequest_request
)} which falls outside of the project ${chalk.cyan(
'src/'
)} directory. ` +
`Relative imports outside of ${chalk.cyan(
'src/'
)} are not supported.` +
os.EOL +
`You can either move it inside ${chalk.cyan(
'src/'
)}, or add a symlink to it from project's ${chalk.cyan(
'node_modules/'
)}.`
);
Object.defineProperty(scopeError, '__module_scope_plugin', {
value: true,
writable: false,
enumerable: false,
});
callback(scopeError, request);
} else {
callback();
}
}
);
}
}InterpolateHtmlPlugin
使一些环境变量在index.html中可用。public URL在index中以%PUBLIC_URL%的形式存在。html,例如:<link rel="icon" href="%PUBLIC_URL%/favicon.ico">除非你指定"homepage"否则它将是一个空字符串在包中。在这种情况下,它将是该URL的路径名。
实现思路主要是对html-webpack-plugin/afterTemplateExecution模板执行后生成的html文件进行正则替换。
const escapeStringRegexp = require('escape-string-regexp');
class InterpolateHtmlPlugin {
constructor(htmlWebpackPlugin, replacements) {
this.htmlWebpackPlugin = htmlWebpackPlugin;
this.replacements = replacements;
}
apply(compiler) {
compiler.hooks.compilation.tap('InterpolateHtmlPlugin', compilation => {
this.htmlWebpackPlugin
.getHooks(compilation)
.afterTemplateExecution.tap('InterpolateHtmlPlugin', data => {
// Run HTML through a series of user-specified string replacements.
Object.keys(this.replacements).forEach(key => {
const value = this.replacements[key];
data.html = data.html.replace(
new RegExp('%' + escapeStringRegexp(key) + '%', 'g'),
value
);
});
});
});
}
}WatchMissingNodeModulesPlugin
如果你需要一个缺失的模块,然后用' npm install '来安装它,你仍然需要重启开发服务器,webpack才能发现它。这个插件使发现自动,所以你不必重新启动。参见https://github.com/facebook/c...
实现思路是在生成资源到 output 目录之前emit钩子中借助compilation的missingDependencies和contextDependencies.add两个字段对丢失的依赖重新安装。
class WatchMissingNodeModulesPlugin {
constructor(nodeModulesPath) {
this.nodeModulesPath = nodeModulesPath;
}
apply(compiler) {
compiler.hooks.emit.tap('WatchMissingNodeModulesPlugin', compilation => {
var missingDeps = Array.from(compilation.missingDependencies);
var nodeModulesPath = this.nodeModulesPath;
// If any missing files are expected to appear in node_modules...
if (missingDeps.some(file => file.includes(nodeModulesPath))) {
// ...tell webpack to watch node_modules recursively until they appear.
compilation.contextDependencies.add(nodeModulesPath);
}
});
}
}总结
使用多个仓库管理的优点
- 各模块管理自由度较高,可自行选择构建工具,依赖管理,单元测试等配套设施
- 各模块仓库体积一般不会太大
使用多个仓库管理的缺点
- 仓库分散不好找,当很多时,更加困难,分支管理混乱
- 版本更新繁琐,如果公共模块版本变化,需要对所有模块进行依赖的更新
CHANGELOG梳理异常折腾,无法很好的自动关联各个模块的变动联系,基本靠口口相传
使用monorepo管理的缺点
- 统一构建工具,对构建工具提出了更高要求,要能构建各种相关模块
- 仓库体积会变大
使用monorepo管理的优点
- 一个仓库维护多个模块,不用到处找仓库
- 方便版本管理和依赖管理,模块之间的引用、调试都非常方便,配合相应工具,可以一个命令搞定
- 方便统一生成
CHANGELOG,配合提交规范,可以在发布时自动生成CHANGELOG,借助Leran-changelog
转载自:https://segmentfault.com/a/1190000039060889