當你習慣於使用 create-react-app 快速構建一個 React App 項目的時候,是否有想過 create-react-app 底層是用了什麼樣的魔法能讓 創建、運行、熱部署 一個 React App 變得如此簡單?
本文將帶領讀者一起解析 create-react-app 的源碼,不僅如此,我還會指出一些值得借鑑的有趣、實用的技術點/代碼寫法,讓你從解讀 create-react-app 的源碼 收穫更多!
文章篇幅原因,今天就只解讀 start.js 和部分相關的文件 —— start.js 就是當你在 使用 create-react-app 創建的React app 下運行 npm run start 時調用的腳本.
閱讀提示:
- 建議同時打開 create-react-app 源碼 (github鏈接),對照着閱讀本文。
- 由於代碼較多,手機閱讀體驗較差,建議先點贊、收藏,然後使用電腦閱讀。
開始解析 start.js
start.js 的 第二、三行是 (源碼鏈接)
process.env.BABEL_ENV = 'development';
process.env.NODE_ENV = 'development';
給 BABEL_ENV 和 NODE_ENV 環境變量都賦值爲 development
解讀:許多NodeJS工具庫會根據 環境變量 XXX_ENV 決定採取不同策略。例如:如果是 development 就打印出更多更詳細的日誌信息,如果是 production 就儘量減少日誌,採取更高效的代碼邏輯等。
接下來發現 start.js 調用了 env.js ,根據註釋,這個env.js 將幫助讀取更多環境變量
// Ensure environment variables are read.
require('../config/env');
讓我們一起看看 env.js
解析 env.js
在 env.js 的前面就出現了比較有趣的兩行代碼
const paths = require('./paths');
// Make sure that including paths.js after env.js will read .env variables.
delete require.cache[require.resolve('./paths')];
先簡單看看 paths.js, 這個文件裏的主要內容就是導出一些主要的文件的路徑,核心代碼如下
module.exports = {
dotenv: resolveApp('.env'),
appPath: resolveApp('.'),
appBuild: resolveApp('build'),
appPublic: resolveApp('public'),
appHtml: resolveApp('public/index.html'),
appIndexJs: resolveModule(resolveApp, 'src/index'),
appPackageJson: resolveApp('package.json'),
appSrc: resolveApp('src'),
appTsConfig: resolveApp('tsconfig.json'),
appJsConfig: resolveApp('jsconfig.json'),
yarnLockFile: resolveApp('yarn.lock'),
testsSetup: resolveModule(resolveApp, 'src/setupTests'),
proxySetup: resolveApp('src/setupProxy.js'),
appNodeModules: resolveApp('node_modules'),
swSrc: resolveModule(resolveApp, 'src/service-worker'),
publicUrlOrPath,
// These properties only exist before ejecting:
ownPath: resolveOwn('.'),
ownNodeModules: resolveOwn('node_modules'), // This is empty on npm 3
appTypeDeclarations: resolveApp('src/react-app-env.d.ts'),
ownTypeDeclarations: resolveOwn('lib/react-app.d.ts'),
};
是不是看到了幾個眼熟的文件名?
比如 public/index.html , src/index , node_modules, .env 等,同時也有一些平時不常見的文件名 如 jsconfig.json , src/setupTests, src/setupProxy.js
paths.js 給我們透露的信息就是 —— 這些文件在 create-react-app 中都是預設好的重點關注對象,熟悉它們的功能可以讓你更大限度地利用 create-react-app
--- 回到上面展示的 env.js 的源碼,第二行代碼是什麼意思呢?
delete require.cache[require.resolve('./paths')];
這就涉及到了 nodejs 的模塊緩存機制:在 nodejs 中,require('xxx') 的背後邏輯首先會在 require.cache 中查找,如果緩存已經存在就返回緩存的模塊,否則再去查找路徑並實際加載,並加入緩存。
寫點代碼來幫助理解 —— 創建以下 3 個 js 文件
mod1.js
console.log("someone require mod1");
mod2.js
console.log("someone require mod2");
const mod1 = require('./mod1')
delete require.cache[require.resolve('./mod1')] // 嘗試註釋掉這行,看看運行 node main.js 的結果有什麼不同
main.js
require('./mod2')
require('./mod1')
然後運行 node main.js 會得到以下結果
someone require mod2
someone require mod1
someone require mod1
即 mod1.js 被加載了2次 。
因此 env.js 裏那行代碼的意圖是,當外部代碼先調用了 env.js 再調用 paths.js 時,paths.js 也會被再次執行/加載
細解:
- env.js 執行了 const paths = require('./paths'); 因此 paths.js 內容被執行,作爲模塊被加載並緩存在 require.cache 裏
- env.js 執行了 delete require.cache[require.resolve('./paths')]; 刪除了 require.cache 裏對應的緩存
- env.js 的代碼會配置/更新 process.env 的環境變量
- 當外界再次調用 paths.js 時,由於查不到緩存就會再次執行 paths.js 內容 並加載爲模塊;paths.js 的部分代碼依賴了process.env 的環境變量,假如第3步中 process.env 環境變量別更新,此次 paths.js 就能使用到了最新的 process.env 的環境變量值 (而不是緩存的舊值)
--- 繼續看 env.js 的代碼
const NODE_ENV = process.env.NODE_ENV;
// ...
const dotenvFiles = [
`${paths.dotenv}.${NODE_ENV}.local`,
// Don't include `.env.local` for `test` environment
// since normally you expect tests to produce the same
// results for everyone
NODE_ENV !== 'test' && `${paths.dotenv}.local`,
`${paths.dotenv}.${NODE_ENV}`,
paths.dotenv,
].filter(Boolean);
可以看出 create-react-app 可以配合 process.env.NODE_ENV 的值,支持多類環境變量文件,如下:
https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
NODE_ENV !== 'test' && `${paths.dotenv}.local`
另外,這邊有個一個關於 javascript &&符號 的小知識點
不同於一些編程語言,JavaScript 的 && 前後可以跟上非布爾值,如果 && 前後項的值有一個爲非真的值,那麼結果就是這個非真值;如果&& 前後項都是真值,那麼返回後面那個值
console.log(0 && 'Dog'); // 0
console.log(false && 'Dog'); // false
console.log(null && 'Dog'); // null
console.log(1 && 'Dog'); // Dog
console.log('Cat' && 'Dog'); // Dog
console.log(true && 'Dog'); // Dog
而 || 有相似邏輯但是相反的結果,這邊就不贅述了,讀者可以自行測試。
在 create-react-app 的代碼中大量使用了 && 和 ||
--- 繼續看 env.js 的代碼
dotenvFiles.forEach(dotenvFile => {
if (fs.existsSync(dotenvFile)) {
require('dotenv-expand')(
require('dotenv').config({
path: dotenvFile,
})
);
}});
這邊使用到了 dotenv 和 dotenv-expand 兩個專門針對環境變量文件的庫 —— 這兩個庫支持將環境變量文件中的內容讀取、解析(支持變量)然後插入 process.env 中
--- 繼續看 env.js 的代碼
// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
// injected into the application via DefinePlugin in webpack configuration.
const REACT_APP = /^REACT_APP_/i;
function getClientEnvironment(publicUrl) {
const raw = Object.keys(process.env)
.filter(key => REACT_APP.test(key))
// ....
}
module.exports = getClientEnvironment;
這個看到一個特別的邏輯 —— 只有當環境變量中符合 REACT_APP_ 爲前綴格式的變量值會被保留(根據註釋,這些環境變量會被 webpack DefinePlugin 插入到代碼中)
這段代碼邏輯正好對應了create-react-app 關於自定義環境變量的文檔 Adding Custom Environment Variables | Create React App
另外值得注意的是, env.js 默認導出的是 getClientEnvironment 函數(在其他文件中這個函數被多次調用)
--- 簡單總結 env.js
env.js 的代碼就解析到此了,它的主要功能就是讀取環境變量文件插入到 process.env 中, 並導出一個可以讀取環境變量的函數
繼續解析 start.js
在 require('../config/env'); 之後,我們可以看到
const verifyPackageTree = require('./utils/verifyPackageTree');
if ( process.env.SKIP_PREFLIGHT_CHECK !== 'true') {
verifyPackageTree();
}
const verifyTypeScriptSetup = require('./utils/verifyTypeScriptSetup');
verifyTypeScriptSetup();
其中 verifyPackageTree 用於檢查一些依賴庫的版本是否正確,開發者是否自行在 package.json 里加入了不兼容的版本 —— 關注的依賴庫有:
const depsToCheck = [
// These are packages most likely to break in practice.
// See https://github.com/facebook/create-react-app/issues/1795 for reasons why.
// I have not included Babel here because plugins typically don't import Babel (so it's not affected).
'babel-eslint',
'babel-jest',
'babel-loader',
'eslint',
'jest',
'webpack',
'webpack-dev-server',
];
而 verifyTypeScriptSetup 主要用於驗證 TypeScript 相關的配置是否正確
這兩個文件不做詳細解析,有興趣的讀者可以自行研究。
接着看到了
const configFactory = require('../config/webpack.config');
稍微往下面翻,可以看到是這麼調用的
const config = configFactory('development');
看起來這是一個用於配置 webpack config 的工廠方法,讓我們深入看看 webpack.config.js 文件 (github 鏈接)
簡單解析 webpack.config.js
這個文件內容相對比較多,但是對於熟悉 webpack 配置的開發者而言其實不難; 如果你還不熟悉 webpack 配置,建議先去webpack 官網簡單瞭解一下 (Concepts | webpack)
這邊不對 webpack 配置做詳細解讀,只分享源碼中幾個有趣的點:
不同環境下的webpack配置
const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production';
根據是開發環境還是生產環境的不同,工廠方法生成的 webpack config 也有很多差異,如:
isEnvDevelopment && require.resolve('style-loader'),
isEnvProduction && {
loader: MiniCssExtractPlugin.loader,
// css is located in `static/css`, use '../../' to locate index.html folder
// in production `paths.publicUrlOrPath` can be a relative path
options: paths.publicUrlOrPath.startsWith('.')
? { publicPath: '../../' }
: {},
},
// ....
chunkFilename: isEnvProduction
? 'static/js/[name].[contenthash:8].chunk.js'
: isEnvDevelopment && 'static/js/[name].chunk.js',
// ...
sourceMap: isEnvProduction
? shouldUseSourceMap
: isEnvDevelopment,
env.js 的使用
簡單看看上面解析過的 env.js 在 webpack.config.js 中是如何被使用的
const getClientEnvironment = require('./env');
// ...
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
// ...
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// It will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV is set to production
// during a production build.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
即 env 環境變量被插入到了 index.html 和 js 代碼中,我們可以利用這個設定做一些有趣的事情:
比如我在 項目根目錄的 .env 文件寫入
REACT_APP_GREETING='Freewheel Lee'
然後在 public/index.html 的 header 里加入
<meta name="greeting" content="%REACT_APP_GREETING%" />
就能得到以下效果(我這邊的例子只是做示範的,讀者可以自行想象更有意義的應用)
React Fast Refresh
const shouldUseReactRefresh = env.raw.FAST_REFRESH;
在2020/08/03的 commit 上,FAST_REFRESH 被默認打開了,React Fast Refresh 是新一代熱部署webpack插件 (pmmmwh/react-refresh-webpack-plugin),有興趣的讀者可以讀讀這兩篇文章
https://mariosfakiolas.com/blog/what-the-heck-is-react-fast-refresh/
https://medium.com/javascript-in-plain-english/what-is-react-fast-refresh-f3d1e8401333
繼續解析 start.js
繼續看源碼可以發現 create-react-app 使用了 webpack-dev-server 作爲開發環境下的服務器
const WebpackDevServer = require('webpack-dev-server');
// ...
const devServer = new WebpackDevServer(compiler, serverConfig);
// Launch WebpackDevServer.
devServer.listen(port, HOST, err => {
if (err) {
return console.log(err);
}
if (isInteractive) {
clearConsole();
}
if (env.raw.FAST_REFRESH && semver.lt(react.version, '16.10.0')) {
console.log(
chalk.yellow(
`Fast Refresh requires React 16.10 or higher. You are using React ${react.version}.`
)
);
}
console.log(chalk.cyan('Starting the development server...\n'));
openBrowser(urls.localUrlForBrowser);
});
webpack-dev-server 的 GitHub 簡介如下
Use webpack with a development server that provides live reloading. This should be used for development only.
有興趣進一步瞭解 webpack-dev-server 的讀者, 可以看看他們的官網 (DevServer | webpack和 webpack/webpack-dev-server )
正是因爲 create-react-app 使用了 webpack-dev-server, 開發者才能在 http://localhost:3000/ 訪問到自己開發的 React APP
另外一些有意思的代碼
const useYarn = fs.existsSync(paths.yarnLockFile);
原來 create-react-app 是根據根目錄下是否有 yarn.lock 文件來判斷是否要使用 yarn
類似的
const useTypeScript = fs.existsSync(paths.appTsConfig);
create-react-app 是根據根目錄下是否有 tsconfig.json 文件來判斷是否要使用 TypeScript
此外start.js 裏還使用了一些非常流行的第三方庫,有興趣可以進一步研究:
- chalk 帶顏色的terminal 輸出
- semver nodejs 庫版本號工具庫
總結
create-react-app 中 start.js 和 幾個相關文件的源碼 的解析基本結束了,雖然過程比較粗粒度,但是我們已經收穫了不少:
- 發現了 create-react-app文檔中一些設定的底層實現邏輯
- 接觸了nodejs 的模塊緩存機制
- JavaScript && 和 || 的特殊使用方法
- 根據環境變量的不同採取不同策略的設計思想
- 初步瞭解 create-react-app 和 webpack 如何集成
- 一些有趣的三方庫
- ...
這就是閱讀源碼的魅力,我們不僅更瞭解了這個庫,還能借鑑它的代碼和設計,還能發現一些有趣的三方庫。
如果覺得這篇文章對你有用、有啓發,歡迎點贊、喜歡、收藏三連!