学习 create-react-app
最近在知乎上看到一个问题“有哪些优秀的中大型项目代码值得阅读学习”,有提到脚手架工具 create-react-app
CRA。之前也写过一些简单的脚手架工具,正好借此机会借鉴学习下 React 官方的这款工具。
设计哲学
README 文档中开门见山介绍了该工具的设计哲学:
- One Dependency: There is just one build dependency. It uses Webpack, Babel, ESLint, and other amazing projects, but provides a cohesive curated experience on top of them.
- No Configuration Required: You don’t need to configure anything. Reasonably good configuration of both development and production builds is handled for you so you can focus on writing code.
- No Lock-In: You can “eject” to a custom setup at any time. Run a single command, and all the configuration and build dependencies will be moved directly into your project, so you can pick up right where you left off.
对于使用者来说,脚手架工具名字必须好记,易于快速安装,例如 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
这个参数,可以执行用户自定义的创建脚本。
这就要求该参数支持多种场景,例如:
- 默认情况,安装
react-scripts
- 如果符合 semver 标准,安装指定版本的
react-scripts@1.2.3
- file 协议本地文件,根据当前项目路径解析
- 压缩包路径,本地或者 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-dom
和 react-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
。包括:
- scripts 其他几条 npm 命令
react-scripts start
- browserlist 希望支持的浏览器列表,很多插件例如 autoprefixer 都会使用到。默认在开发模式下使用最近两个版本,生产环境使用最近4个版本。
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
fetch
和 Object.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
]
其他特别的配置包括:
- 关于 source-map 是否要生成,也有一个 ISSUE。
- 为了限制用户引用不在
/src
中的文件(/node_modules
除外),专门写了一个 plugin 给出提示。 - 禁止
require.ensure
这种 Webpack 特有的代码分割语法,使用 dynamic-import。 - 使用
thread-loader
加速babel-loader
编译速度,在大型项目中效果明显。 - 引用一个新的依赖时,安装前肯定会报
Module not found
,但是安装完毕后还需要重启开发服务器。CRA 在每次 Webpack 编译完成后,一旦发现有这种缺少依赖的情况,就会监听/node_modules
,这样一旦安装自动启动重新编译。ISSUE
使用以上 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。 其中重要的配置包括:
- proxy 代理请求。可以传入一个简单的字符串而非完整的代理配置,此时 CRA 会作出猜测,代理所有的非 GET 请求,以及针对除 HTML 外静态资源的 GET 请求。这是因为使用了
historyApiFallback
,SPA 中 HTML 请求统统返回唯一的一个index.html
。 - public 开发服务器地址本身可能被代理,客户端需要知道地址,例如 Nginx 后的
myapp.test:80
。 - publicPath 这个通常和 Webpack output 路径一致,指定了 bundle 的地址。
- contentBase 不同于 bundle 的地址,这是用来指定静态资源地址的,默认情况是
cwd()
。例如path.join(__dirname, "public")
。 - compress 开启 gzip
- overlay 出现警告和错误时在页面上加上遮罩层显示。CRA 禁用了 Webpack 提供的,使用了自己的 ErrorOverlay。
- before/after 可以加入自定义的中间件。比如:
- 错误遮罩层
app.use(errorOverlayMiddleware());
- 由于构建后会生成 Service Worker,开发时如果使用了和生产环境同样的 host 和 port,已经安装的 SW 会对开发造成困扰。因此需要在开发模式下安装一个
no-op
什么都不做的 SW。ISSUE
- 错误遮罩层
可扩展性
所谓的无配置,其实就是使用了最佳实践中的配置,好处是可以跟随 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 项目。