「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」
前言
ceate-react-app
(下面简称cra)相信使用 React
技术栈的小伙伴都很熟悉,无论是从刚开始学习 React
,亦或者已经轻车熟路的,可以说大部分都以其作为起手式,搭建地基。然后使用 react-app-rewired
对 webpack
的配置进行复写。本文就从源码角度分析这两个库是怎么运行的,这些背后的原理是什么。毕竟,只有知己知彼,才能百战百胜鸭!看完本文你可以了解到:
create-react-app
帮我们做了什么以及为什么开箱即可用react-app-rewired
是怎么做到覆盖配置的
注:本文篇幅不多,只涉及主要的流程,并没有对细节部分深究,请谅解!
看 create-react-app 做了什么
准备
准备什么? 我们分析源码,当然是准备好源码啦。大家可以从 github
上自行下载源码,但我这边推荐一种更快更好的方法,那就是 github1s
,墙裂推荐
前菜
先来看下使用 cra
时,一般都是怎么初始化的,看官方文档
npx create-react-app my-app
关于 npx
和 npm
区别:npx是一个npm包运行器,典型用途可临时下载和运行包或者试用,在没有 npx
之前使用 cra
初始化项目是以下命令:
npm install -g create-react-app
create-react-app my-app
npx
的出现避免了需要全局安装 create-react-app
,可以下载到临时目录然后删除,具体可以看 关于npx的教程
正餐
打开上面的 cra
源码链接,很多新手同学可能会一脸怀疑人生,不知道该从哪看起。别急,跟着我的脚步一步步往下。可以看到,我们安装完 cra
之后,初始化项目的命令是 create-react-app my-app
。那么我们先去找到 package.json
(里头存放着可执行的脚本命令和一些项目的相关信息,特别是 bin
,不是很明白的同学可以自行搜索相关资料了解)。虽然没找到相关命令,但我们找到了:
红框部分 workspaces
,顺着指引我们去找到文件夹 packages
,打开此文件夹,看到核心 create-react-app
文件夹。还是老规矩,看 package.json
,找到:
"bin": {
"create-react-app": "./index.js"
}
因此 create-react-app myapp
其实就是执行该目录下的 index.js
文件,接下来我们找到 index.js
,内容不多:
// packages/create-react-app/index.js
// .......省略
const { init } = require('./createReactApp');
init();
继续查看 createReactApp.js
文件,内容稍微比较多,这里只截取比较核心的几个函数
// packages/create-react-app/createReactApp.js
function init() {
// 初始化 projectName 就是我们初始化项目的时候输入的项目名词
const program = new commander.Command(packageJson.name)
.version(packageJson.version)
.arguments('<project-directory>')
.usage(`${chalk.green('<project-directory>')} [options]`)
.action(name => {
projectName = name;
})
// ...此处省略一大段
else {
createApp(
projectName,
program.verbose,
program.scriptsVersion,
program.template,
program.useNpm,
program.usePnp
);
}
init
方法里面的内容虽多但比较常规,基本都是交互提示,比如比较 create-react-app
版本号是不是最新的,以及一些帮助提示文案等,最后调用 createApp
方法
// packages/create-react-app/createReactApp.js
function createApp(name, verbose, version, template, useNpm, usePnp) {
.......
const root = path.resolve(name); // 根据项目名称获取项目路径
const appName = path.basename(root);
.......
// 在项目下写入 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
);
}
.......
run(
root,
appName,
version,
verbose,
originalDirectory,
template,
useYarn,
usePnp
);
createApp
函数做的事请也不多,还是各种检查 Node
的版本, npm
的版本,是否使用 yarn
等。最后调用 run
方法
// packages/create-react-app/createReactApp.js
function run(
root,
appName,
version,
verbose,
originalDirectory,
template,
useYarn,
usePnp
) {
// promise.all中两个请求
// getInstallPackage: 是请求获取 react-scrips的
// getTemplateInstallPackage:是获取请求 cra-template 模板文件的
// 具体可在该文件上下文中找到
Promise.all([
getInstallPackage(version, originalDirectory),
getTemplateInstallPackage(template, originalDirectory),
]).then(([packageToInstall, templateToInstall]) => {
const allDependencies = ['react', 'react-dom', packageToInstall]; // 准备好要下载的依赖列表
console.log('Installing packages. This might take a couple of minutes.');
// 获取依赖包中的信息
// 这里着重注意下 packageInfo 就是 react-scripts 的信息 待会会用到
Promise.all([
getPackageInfo(packageToInstall),
getPackageInfo(templateToInstall),
])
.then(([packageInfo, templateInfo]) =>
......
// 调用 install 方法,比较简单,就是检查网络状态等,正常的话直接执行脚本 npm install 安装 allDependencies 里的依赖包
return install(
root,
useYarn,
usePnp,
allDependencies,
verbose,
isOnline
).then(() => ({
packageInfo,
supportsTemplates,
templateInfo,
}));
......
// 解析并执行 node 脚本
// 脚本很简单 引入 packageInfo.name 即我们上面提到的 react-scripts 中的 init 文件
// 并调用执行
const packageName = packageInfo.name;
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]));
`
);
可以知道,run
方法主要的一些工作是收集需要安装的依赖包如:react-dom
、react
、reacr-scripts
以及 cra-template
,在这个过程中还是会做一些判断,比如当前的网络状态,版本号的对比等,最后执行一个 node
命令。具体 executeNodeScript
这个方法是库里面自己封装的一个方法
function executeNodeScript({ cwd, args }, data, source) {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[...args, '-e', source, '--', JSON.stringify(data)],
{ cwd, stdio: 'inherit' }
);
......
});
}
我们将命令进行合并其实就是下面这条:
node -e `要执行的js脚本` -- `数据`
其实就是引入 react-scripts
里 init.js
的方法并将后面跟着的 data
参数传过去。
接下来我们找到这个 init.js
方法
// react-scripts/scrips/init.js
module.exports = function (
appPath,
appName,
verbose,
originalDirectory,
templateName
) {
// 获取项目下 package.json 的文件内容
const appPackage = require(path.join(appPath, 'package.json'));
const templatePath = path.dirname(
require.resolve(`${templateName}/package.json`, { paths: [appPath] })
);
......
// 把启动脚本写进 json 里
appPackage.scripts = Object.assign(
{
start: 'react-scripts start',
build: 'react-scripts build',
test: 'react-scripts test',
eject: 'react-scripts eject',
},
templateScripts
);
......
// Copy the files for the user
// 把 cra-template 下的模板文件全部拷贝到项目中去
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;
}
到这里基本就是最后一步了,主要是对项目的初始化,包括 package.json
、gitignore
、README
等,然后将模板文件(cra-template
)拷贝到项目里,下一步就可以执行 npm start
启动项目了,同样只截取关键部分代码我们细读下:
// react-scripts/scrips/start.js
.......
const {
choosePort,
createCompiler,
prepareProxy,
prepareUrls,
} = require('react-dev-utils/WebpackDevServerUtils');
const configFactory = require('../config/webpack.config');
const createDevServerConfig = require('../config/webpackDevServer.config');
.......
// Create a webpack compiler that is configured with custom messages.
// compiler是 webpack 的核心模块
// createCompiler方法:将写好的 config 也就是 webpack.config 配置传入 webpack 当中,并添加一些监听事件的 hooks 钩子,返回 compiler
// createDevServerConfig方法:开发下代理服务器的配置
const compiler = createCompiler({
appName,
config,
devSocket,
urls,
useYarn,
useTypeScript,
tscCompileOnError,
webpack,
});
// Load proxy config
const proxySetting = require(paths.appPackageJson).proxy;
const proxyConfig = prepareProxy(
proxySetting,
paths.appPublic,
paths.publicUrlOrPath
);
// Serve webpack assets generated by the compiler over a web server.
const serverConfig = createDevServerConfig(
proxyConfig,
urls.lanUrlForConfig
);
const devServer = new WebpackDevServer(compiler, serverConfig);
config/webpack.config.js
就是内置的写好的 webpack
配置文件,这就是为什么 cra
开箱就可以使用的原因,不需要做任何配置。最后传入 webpack
的配置以及代理服务器的配置,启动 devServer
,监听端口。
借用一张图来简单展现下整个流程:
注:图片来自 github.com/fi3ework/bl…
react-app-rewired是如何覆盖配置
首先来看下官方文档的步骤:
1) 安装 react-app-rewired
npm install react-app-rewired --save-dev
2) 在根目录中创建一个 config-overrides.js 文件
/* config-overrides.js */
module.exports = {
// The Webpack config to use when compiling your react app for development or production.
webpack: function(config, env) {
// ...add your webpack config
return config;
},
devServer: function(configFunction) {
return config;
}
}
3) 替换 package.json 中 scripts 执行部分
/* package.json */
"scripts": {
- "start": "react-scripts start",
+ "start": "react-app-rewired start",
- "build": "react-scripts build",
+ "build": "react-app-rewired build",
- "test": "react-scripts test --env=jsdom",
+ "test": "react-app-rewired test --env=jsdom",
"eject": "react-scripts eject"
}
4) 启动 Dev Server
npm start
来看 react-app-rewired
的源码,从上面的步骤可以看到启动命令变成了 react-app-rewired start
,老规矩,就直接看下这个命令做了什么东西,先看 package.json
,找到 bin
:
"bin": {
"react-app-rewired": "./bin/index.js"
},
打开 ./bin/index.js
文件,截取关键部分代码
// bin/index.js
.......
switch (script) {
case 'build':
case 'eject':
case 'start':
case 'test': {
const result = spawn.sync(
'node',
nodeArgs
.concat(require.resolve('../scripts/' + script))
.concat(args.slice(scriptIndex + 1)),
{ stdio: 'inherit' }
);
......
没什么好说的,看到最终执行的是 scripts
文件夹下的脚本,打开 script/start.js
文件:
// scritps/start.js
......
const { scriptVersion } = require('./utils/paths');
const overrides = require('../config-overrides');
......
const webpackConfigPath = `${scriptVersion}/config/webpack.config${!isWebpackFactory ? '.dev' : ''}`;
const devServerConfigPath = `${scriptVersion}/config/webpackDevServer.config.js`;
const webpackConfig = require(webpackConfigPath);
const devServerConfig = require(devServerConfigPath);
// override config in memory
require.cache[require.resolve(webpackConfigPath)].exports = isWebpackFactory
? (env) => overrides.webpack(webpackConfig(env), env)
: overrides.webpack(webpackConfig, process.env.NODE_ENV);
require.cache[require.resolve(devServerConfigPath)].exports =
overrides.devServer(devServerConfig, process.env.NODE_ENV);
// run original script
require(`${scriptVersion}/scripts/start`);
这里简单讲明下代码开头引入的两个文件:
scriptVerson
: 可以去看下./utils/paths
这个文件,其实返回的是react-scrips
overrides
: 就是从指定的地方比如在根目录下config-overrides.js
读取我们自定义的一些webpack
配置,具体可以看看文件内容,这里就不赘述。
接下来这段代码
const webpackConfigPath = `${scriptVersion}/config/webpack.config${!isWebpackFactory ? '.dev' : ''}`;
const devServerConfigPath = `${scriptVersion}/config/webpackDevServer.config.js`;
const webpackConfig = require(webpackConfigPath);
const devServerConfig = require(devServerConfigPath);
明显可以知道是去读取了在 react-scripts
包里内置的 webpack
配置和 devServer
配置,然后最关键的一段就是:
// override config in memory
require.cache[require.resolve(webpackConfigPath)].exports = isWebpackFactory
? (env) => overrides.webpack(webpackConfig(env), env)
: overrides.webpack(webpackConfig, process.env.NODE_ENV);
require.cache[require.resolve(devServerConfigPath)].exports =
overrides.devServer(devServerConfig, process.env.NODE_ENV);
// run original script
require(`${scriptVersion}/scripts/start`);
一眼就可以看出关键的 require.cache
,其实从名字可以看出跟缓存相关,简单讲就是先检查本地缓存有没有该模块,有的话直接取缓存的 export
内容
也就是说当执行 require('react-scripts/config/weboack.config.js')
,实际上由于这了做了缓存,所以引入的其实是 overrides.webpack(webpackConfig, process.env.NODE_ENV)
返回的内容,传入原本内置的配置 webpackConfig
和当前的环境,然后进行自定义修改添加配置,比如一些插件等,再返回 config
。
最后还是执行原来的 react-scripts
的 start
脚本,回过头来看这个脚本
// react-scripts/scrips/start.js
const configFactory = require('../config/webpack.config');
const config = configFactory('development');
const compiler = createCompiler({
appName,
config,
devSocket,
urls,
useYarn,
useTypeScript,
tscCompileOnError,
webpack,
});
结合起来就很明朗了, react-app-rewired
其实就是利用 require.cache
添加缓存,使得在 react-scrips/start.js
里引入的实际上是我们修改后的配置来达到重写的目的。
小结
本文从 create-react-app
到 react-app-rewired
进行了源码分析。当然,只是分析大致的流程,里面有很多细致的地方值得细敲,比如 react-dev-utils
里的内容。
最后,感兴趣的同学可以再去看看另一个覆盖 cra
配置的库 CRACO
,会发现其实原理都是差不多的。
既然看到这里,不点个赞再走??👍