文章首发于@careteen/create-react-app,转载请注明来源即可。
Create React App是一个官方支持的创建React
单页应用程序的脚手架。它提供了一个零配置的现代化配置设置。
平时工作中一部分项目使用的React
,使用之余也需要了解其脚手架实现原理。
之前做的模板项目脚手架@careteen/cli,实现方式比较原始。后续准备通过
lerna
进行重构。
下面先做一些前备知识
了解。
如果对
monorepo和lerna
已经比较了解,可以直接移步CreateReactApp架构
Monorepo
是管理项目代码的一个方式,指在一个项目仓库(repo
)中管理多个模块/包(package
)。不同于常见的每个模块都需要建一个repo
。
babel的packages
目录下存放了多个包。
Monorepo
最主要的好处是统一的工作流和代码共享。
比如我在看babel-cli的源码时,其中引用了其他库,如果不使用Monorepo
管理方式,而是对@babel/core
新建一个仓库,则需要打开另外一个仓库。如果直接在当前仓库中查看,甚至修改进行本地调试,那阅读别人代码会更加得心应手。
import { buildExternalHelpers } from "@babel/core";
目前大多数开源库都使用Monorepo
进行管理,如react、vue-next、create-react-app。
babel
仓库下存放了所有相关代码,clone
到本地也需要耗费不少时间。如果对
monorepo和lerna
已经比较了解,可以直接移步CreateReactApp架构
Lerna
是babel
团队对Monorepo
的最佳实践。是一个管理多个npm
模块的工具,有优化维护多个包的工作流,解决多个包互相依赖,且发布需要手动维护多个包的问题。
前往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"
}
# 一路回车即可
$ lerna create create-react-app
$ lerna create react-scripts
$ lerna create cra-template
会在packages/
目录下生成三个子项目
默认是npm
,每个子package
都有自己的node_modules
。
新增如下配置,开启workspace
。目的是让顶层统一管理node_modules
,子package
不管理。
// package.json
{
"private": true,
"workspaces": [
"packages/*"
],
}
// lerna.json
{
"useWorkspaces": true,
"npmClient": "yarn"
}
前往Lerna查看各个
command
的详细使用
# 语法
$ 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 commander
默认是npm i
,指定使用yarn
后,就等价于yarn install
列出所有的包
$ 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 packages
建立软链,等价于npm link
$ 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 publish
在项目根目录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'
]`
先添加依赖
# 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-template
lerna 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`。
cra-tempalte
可以从cra-tempalte拷贝,启动一个简易React
单页应用。
对
React
原理感兴趣的可前往由浅入深React的Fiber架构查看。
同上,不是本文讨论重点。
安装依赖
# `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' }
)
对
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
目录下会生成编译打包后的所有文件
创建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/ 打开查看效果
上面两节实现没有源码考虑的那么完善。后面将针对源码中使用到的一些较为巧妙的第三方库和webpack-plugin
做讲解。
此子package
下存放了许多webpack-plugin
辅助于react-scripts/config/webpack.config.js文件。在文件中搜索plugins
字段查看。
此文先列举一些我觉得好用的plugins
node_modules
。<link rel="icon" href="%PUBLIC_URL%/favicon.ico">
中可以使用变量%PUBLIC_URL%
。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),
]
}
增加了对即插即用(Plug'n'Play)安装的支持,提高了安装速度,并增加了对遗忘依赖项等的保护。试图取代
node_modules
。
先来了解下使用node_modules
模式的机制
tar
报到本地离线镜像node_modules
目录PnP工作原理是作为上述第四步骤的替代方案
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
阻止用户从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解决。
那接下来看看是如何实现这个功能。
实现步骤主要是
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();
}
}
);
}
}
使一些环境变量在index.html中可用。 public URL在index中以%PUBLIC_URL%的形式存在。html,例如: <link rel="icon" href="%PUBLIC_URL%/favicon.ico"> 除非你指定"homepage"否则它将是一个空字符串 在包中。在这种情况下,它将是该URL的路径名。示例存放在plugins-example/InterpolateHtmlPlugin
实现思路主要是对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
);
});
});
});
}
}
如果你需要一个缺失的模块,然后用' npm install '来安装它,你仍然需要重启开发服务器,webpack才能发现它。这个插件使发现自动,所以你不必重新启动。 参见https://github.com/facebook/c...示例存放在plugins-example/WatchMissingNodeModulesPlugin
实现思路是在生成资源到 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