学习 create-react-app

最近在知乎上看到一个问题“有哪些优秀的中大型项目代码值得阅读学习”,有提到脚手架工具 create-react-app CRA。之前也写过一些简单的脚手架工具,正好借此机会借鉴学习下 React 官方的这款工具。

设计哲学

README 文档中开门见山介绍了该工具的设计哲学:

对于使用者来说,脚手架工具名字必须好记,易于快速安装,例如 npm -g create-react-app,除此之外不需要安装其他依赖。 对于初级使用者和大部分场景,令人头疼的配置文件最好也一并舍去,最被诟病的 Webpack@4.x 也效仿 Parcel,启用了默认配置。 另外,对于高级开发者和需要定制化的场景,脚手架工具也要提供可扩展机制。

模块拆分

中大型项目使用 lerna 管理各个子包是很常见的,这使得开发和维护成本大大降低,代码可阅读性能提高很多。 在 create-react-app 中,分成了以下子包:

packages
├── babel-plugin-named-asset-import
├── babel-preset-react-app
├── confusing-browser-globals
├── create-react-app
├── eslint-config-react-app
├── react-dev-utils
├── react-error-overlay
└── react-scripts

首先我们看一下其中的开发工具集。

开发工具集

首先是熟悉的 babel-preset-react-app,由于会单独发布,在脚手架项目之外也可以安装使用。通过判断环境变量,使用不同的 babel 插件,比如生产环境使用 babel-plugin-transform-react-remove-prop-types 移除 PropTypes,类似 Preact 的精简方式之一。

再比如 eslint-config-react-app,默认情况下 ESLint 会认为浏览器环境下的全局变量是合法的。 但是这会造成下面的错误通过检查:

handleClick() { // missing `event` argument
    this.setState({
        text: event.target.value // uses the `event` global: oops!
    });
}

所以脚手架中的 ESLint 配置加上了 confusing-browser-globals,其中列出了很多容易出错的全局变量,使用时必须加上 window

下面进入核心的代码分析。

唯一的依赖

create-react-app 这个包十分简单,由于需要用户全局安装,应该尽量避免代码改动造成的升级。在入口文件头部赫然写着 DO NOT MODIFY THIS FILE,显然是希望代码保持稳定。

代码确实也只专注一件事,那就是初始化项目。后续的开发调试,构建,测试等命令都交给另一个包 react-scripts 完成。

首先是读取命令行参数,使用了常见的 commander 库完成参数解析。

const program = new commander.Command(packageJson.name)
    .version(packageJson.version)
    .arguments('<project-directory>')
    .usage(`${chalk.green('<project-directory>')} [options]`)
    .action(name => {
        projectName = name;
    })
    .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')
    .allowUnknownOption()
    .on('--help', () => {
        //...
    })
    .parse(process.argv);

自定义模板

前面提到可扩展性,脚手架工具一般都会提供自定义模版的功能。比如 vue-cli 支持 vue-cli init webpack 这样指定按照某个模版创建项目。而 create-react-app 使用 --scripts-version 这个参数,可以执行用户自定义的创建脚本。

这就要求该参数支持多种场景,例如:

  1. 默认情况,安装 react-scripts
  2. 如果符合 semver 标准,安装指定版本的 react-scripts@1.2.3
  3. file 协议本地文件,根据当前项目路径解析
  4. 压缩包路径,本地或者 git 地址
function getInstallPackage(version, originalDirectory) {
  let packageToInstall = 'react-scripts';
  const validSemver = semver.valid(version);
  if (validSemver) {
    packageToInstall += `@${validSemver}`;
  } else if (version) {
    if (version[0] === '@') {
      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;
    }
  }
  return packageToInstall;
}

然后使用 npm/yarn 安装 react react-domreact-scripts 或者前面用户传入的自定义依赖。 随后执行其中的 scripts/init.js 脚本,其中执行了创建项目模版的操作:

const scriptsPath = path.resolve(
    process.cwd(),
    'node_modules',
    packageName,
    'scripts',
    'init.js'
);
const init = require(scriptsPath);
init(root, appName, verbose, originalDirectory, template);

对于自定义模版的场景,不妨以 create-react-app-typescript项目为例,为了添加 TS 特性,fork 了一份代码,只需要修改其中的 react-scripts 包单独发布即可。

create-react-app my-app --scripts-version=react-scripts-ts

项目初始化

下面我们看一下默认的 react-scripts 初始化做了哪些事情。

首先是写入 package.json。包括:

appPackage.scripts = {
    start: 'react-scripts start',
    build: 'react-scripts build',
    test: 'react-scripts test --env=jsdom',
    eject: 'react-scripts eject',
};
// 
appPackage.browserslist = defaultBrowsers;

fs.writeFileSync(
    path.join(appPath, 'package.json'),
    JSON.stringify(appPackage, null, 2) + os.EOL
);

拷贝 /template 下的文件也就是模版文件。

const templatePath = template
    ? path.resolve(originalDirectory, template)
    : path.join(ownPath, 'template');
if (fs.existsSync(templatePath)) {
    fs.copySync(templatePath, appPath);
}

最后初始化 git,展示 cd 信息。

启动项目

之前看到初始化阶段向 package.json 中写入了几条 react-scripts 命令。在 /bin 目录下定义了命令入口:

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' }
        );

先来看下开发模式下也就是 start 这条命令。

定义环境变量

会读取 .env .env.development.env.development.local 三种路径。 dotenv 这里借鉴了 Ruby dotenv 中的做法。 另外,使用了 dotenv-expand 支持变量的扩展。

MONGOLAB_DATABASE=heroku_db
MONGOLAB_USER=username
MONGOLAB_PASSWORD=password
MONGOLAB_DOMAIN=abcd1234.mongolab.com
MONGOLAB_PORT=12345
MONGOLAB_URI=mongodb://${MONGOLAB_USER}:${MONGOLAB_PASSWORD}@${MONGOLAB_DOMAIN}:${MONGOLAB_PORT}/${MONGOLAB_DATABASE}

使用 Webpack 的 DefinePlugin 可以在入口文件及其依赖中使用环境变量,由于插件在编译时完成替换,需要将这些变量 stringify 处理。 变量包括 process.env 上的 NODE_ENV PUBLIC_URL 和以 REACT_APP 开头的变量名。

检查易出错的依赖

为了避免被 Webpack Jest ESlint 这些可能全局安装的依赖影响,或者说 create-react-app 本身就会安装这些依赖。 ISSUE

清空控制台

在运行过程中,向控制台输出信息是必不可少的。某些关键信息输出之前最好能清屏,更好地引起用户注意。

function clearConsole() {
    process.stdout.write(process.platform === 'win32'
        ? '\x1B[2J\x1B[0f'
        : '\x1B[2J\x1B[3J\x1B[H');
}

SF 上的一个回答解释了这里的神秘代码的含义。 首先 \x1B 是 ESC 的 16 进制码,而 ESC [ 后面可以跟上 CSI(Control Sequence Introducer)指令,做一些特殊的控制台操作。 这里的 CSI n J 是清除屏幕的命令,n 从 0 到 3 有不同的含义,这里的 2J 就是清楚整个屏幕的意思。

但是在执行清屏命令之前,需要先判断一下当前的输出是否指向控制台。 如果是输出到文件中,我们不希望保存清屏命令本身的内容。这时候就需要用到 TTY 来判断了。

$ node -p -e "Boolean(process.stdout.isTTY)"
true
$ node -p -e "Boolean(process.stdout.isTTY)" | cat
false

Webpack 配置

人口处没有引用 babel-polyfill,而是一个精简版的,包含 Promise fetchObject.assign()。 另外没有使用 HMR 中默认提供的 client.js,而是自定义的客户端脚本,通过 SocketJS 和开发服务器交互。

entry: [
    require.resolve('./polyfills'),
    // require.resolve('webpack-dev-server/client') + '?/',
    // require.resolve('webpack/hot/dev-server'),
    require.resolve('react-dev-utils/webpackHotDevClient'),
    paths.appIndexJs
]

其他特别的配置包括:

使用以上 Webpack 配置创建一个 Webpack Compiler,在开发模式下,invalid 事件在 watch 的文件发生变动后会被触发

compiler = webpack(config, handleCompile);
compiler.plugin('invalid', () => {
    if (isInteractive) {
        clearConsole();
    }
    console.log('Compiling...');
});

对于 Webpack 的统计信息,尤其是 Error 和 Warning,这里做了优化处理。 比如 Webpack 中附加的的 loader 信息会使资源请求变得很长,在出错时看的很费劲: ./~/css-loader!./~/postcss-loader!./src/App.css。这里做了精简:

if (lines[0].lastIndexOf('!') !== -1) {
    lines[0] = lines[0].substr(lines[0].lastIndexOf('!') + 1);
}

以之前创建的 compiler 和配置启动 webpack-dev-server。 其中重要的配置包括:

可扩展性

所谓的无配置,其实就是使用了最佳实践中的配置,好处是可以跟随 react-scripts 更新最新的配置。 而面对需要自定义的场景时,CRA 提供了 eject 方案,将 react-scripts 的配置和脚本输出到 /config/scripts 下,像通常的一个模版项目一样,给予用户完全的定制功能。

首先通过 git status 检查是否存在未提交的文件。

然后在输出配置和脚本前,检查 /config/scripts 下是否已经存在文件,防止二次执行 eject 覆盖已经修改过的文件。

然后输出文件时,注意替换掉原始文件中 @remove-on-eject 标记块中的内容,通常是一些 CRA 的文件信息之类。

最后修改 package.json 中的依赖,npm 命令,不再使用 react-scripts

无配置的取舍

CRA 虽然提供了 eject,但是如果用户只有一丁点自定义配置,难道也需要执行吗?对此 CRA 开发人员是这么说的:

“We expect that at early stages, many people will “eject” for one reason or another, but as we learn from them, we will make the default setup more and more compelling while still providing no configuration.”

所以无配置是必须要坚持的,react-scripts 会不断改良内置配置试图提供最佳实践,但不会暴露配置。对于仍然需要自定义的场景,需要 fork 一份 react-scripts。但是有的开发者并不买账,他们认为增加一个 Babel 插件,就需要维护一个 fork 项目是不合理的,并不愿意这么做

因此有了 react-app-rewired,用于替换 react-scripts,同时提供对于内置配置的扩展方法:

// config-overrides.js

const rewireMobX = require('react-app-rewire-mobx');
const rewirePreact = require('react-app-rewire-preact');
const {injectBabelPlugin} = require('react-app-rewired');

module.exports = function override(config, env) {
    // add a plugin
    config = injectBabelPlugin('emotion/babel',config)

    // use the Preact rewire
    if (env === "production") {
        console.log("⚡ Production build with Preact");
        config = rewirePreact(config, env);
    }

    // use the MobX rewire
    config = rewireMobX(config,env);

    return config;
}

个人觉得这种方式可能更适合复杂的配置需求,而 CRA 适合快速创建简单的 React 项目。