关于Create-react-app 以及 react-app-rewired原理浅究

4,776 阅读6分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

前言

ceate-react-app (下面简称cra)相信使用 React 技术栈的小伙伴都很熟悉,无论是从刚开始学习 React ,亦或者已经轻车熟路的,可以说大部分都以其作为起手式,搭建地基。然后使用 react-app-rewiredwebpack 的配置进行复写。本文就从源码角度分析这两个库是怎么运行的,这些背后的原理是什么。毕竟,只有知己知彼,才能百战百胜鸭!看完本文你可以了解到:

  • create-react-app 帮我们做了什么以及为什么开箱即可用
  • react-app-rewired 是怎么做到覆盖配置的

注:本文篇幅不多,只涉及主要的流程,并没有对细节部分深究,请谅解!

看 create-react-app 做了什么

准备

准备什么? 我们分析源码,当然是准备好源码啦。大家可以从 github 上自行下载源码,但我这边推荐一种更快更好的方法,那就是 github1s墙裂推荐

前菜

先来看下使用 cra 时,一般都是怎么初始化的,看官方文档

npx create-react-app my-app

关于 npxnpm 区别: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 ,不是很明白的同学可以自行搜索相关资料了解)。虽然没找到相关命令,但我们找到了:

image.png

红框部分 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-domreactreacr-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-scriptsinit.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.jsongitignoreREADME等,然后将模板文件(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,监听端口。

借用一张图来简单展现下整个流程:

image.png

注:图片来自 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-scriptsstart 脚本,回过头来看这个脚本

// 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-appreact-app-rewired 进行了源码分析。当然,只是分析大致的流程,里面有很多细致的地方值得细敲,比如 react-dev-utils 里的内容。

最后,感兴趣的同学可以再去看看另一个覆盖 cra 配置的库 CRACO,会发现其实原理都是差不多的。

既然看到这里,不点个赞再走??👍