我在今年1月23日於
segmentfault
上釋出了此文。今天突發奇想在create-react-app
原始碼的文章,發現掘金多篇類似文章...我真的想貼圖,改了兩個字就算原創(雖然好像風氣就是這樣),那就這樣吧,我還能說些什麼!
本文原文移至新部落格地址,轉載註明作者出處謝謝!!!如果你覺得改了一兩個字就算是你的了,就TM當我沒說。
前言
這段時間公司的事情變得比較少,空下了很多時間,作為一個剛剛畢業初入職場的菜鳥級程式設計師,一點都不敢放鬆,秉持著我為人人的思想也想為開源社群做點小小的貢獻,但是一直又沒有什麼明確的目標,最近在努力的準備吃透react
,加上react
的腳手架工具create-react-app
已經很成熟了,初始化一個react
專案根本看不到它到底是怎麼給我搭建的這個開發環境,又是怎麼做到的,我還是想知道知道,所以就把他拖出來溜溜。
文中若有錯誤或者需要指正的地方,多多指教,共同進步。
使用說明
就像我開頭說的那樣,學習一個新的東西,應該是先知道如何用,然後在來看他是怎麼實現的。create-react-app
到底是個什麼東西,總結一句話來說,就是官方提供的快速搭建一個新的react
專案的腳手架工具,類似於vue
的vue-cli
和angular
的angular-cli
,至於為什麼不叫react-cli
是一個值得深思的問題...哈哈哈,有趣!
不說廢話了,貼個圖,直接看create-react-app
的命令幫助。
概略說明
畢竟它已經是一個很成熟的工具了,說明也很完善,重點對其中--scripts-version
說一下,其他比較簡單,大概說一下,注意有一行Only <project-directory> is required
,直譯一下,僅僅只有專案名稱是必須的,也就是說你在用create-react-app
命令的時候,必須在其後跟上你的專案名稱,其實這裡說的不準確,像--version --info --help
這三個選項是不需要帶專案名稱的,具體看下面:
create-react-app -V(or --version)
:這個選項可以單獨使用,列印版本資訊,每個工具基本都有吧?create-react-app --info
:這個選項也可以單獨使用,列印當前系統跟react
相關的開發環境引數,也就是作業系統是什麼啊,Node
版本啊之類的,可以自己試一試。create-react-app -h(or --help)
:這個肯定是可以單獨使用的,不然怎麼列印幫助資訊,不然就沒有上面的截圖了。
也就是說除了上述三個引數選項是可以脫離必須引數專案名稱以外來單獨使用的,因為它們都跟你要初始化的react
專案無關,然後剩下的引數就是對要初始化的react
專案進行配置的,也就是說三個引數是可以同時出現的,來看一下它們分別的作用:
create-react-app <my-project> --verbose
:看上圖,列印本地日誌,其實他是npm
和yarn
安裝外部依賴包可以加的選項,可以列印安裝有錯時的資訊。create-react-app <my-project> --scripts-version
:由於它本身把建立目錄初始化步驟和控制命令分離了,用來控制react
專案的開發、打包和測試都放在了react-scripts
裡面,所以這裡可以單獨來配置控制的選項,可能這樣你還不是很明白,我下面具體說。create-react-app <my-project> --use-npm
:這個選項看意思就知道了,create-react-app
預設使用yarn
來安裝,執行,如果你沒有使用yarn
,你可能就需要這個配置了,指定使用npm
。
定製選項
關於--scripts-version
我還要多說一點,其實在上述截圖中我們已經可以看到,create-react-app
本身已經對其中選項進行了說明,一共有四種情況,我並沒有一一去試他,因為還挺麻煩的,以後如果用到了再來補,我先來大概推測一下他們的意思:
- 指定版本為0.8.2
- 在
npm
釋出自己的react-scripts
- 在自己的網站上設定一個
.tgz
的下載包 - 在自己的網站上設定一個
.tar.gz
的下載包
從上述看的出來create-react-app
對於開發者還是很友好的,可以自己去定義很多東西,如果你不想這麼去折騰,它也提供了標準的react-scripts
供開發者使用,我一直也很好奇這個,之後我在來單獨說官方標準的react
配置是怎麼做的。
目錄分析
隨著它版本的迭代,原始碼肯定是會發生變化的,我這裡下載的是v1.1.0
,大家可以自行在github
上下載這個版本,找不到的戳連結。
主要說明
我們來看一下它的目錄結構
├── .github
├── packages
├── tasks
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .travis.yml
├── .yarnrc
├── appveyor.cleanup-cache.txt
├── appveyor.yml
├── CHANGELOG-0.x.md
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── lerna.json
├── LICENSE
├── package.json
├── README.md
└── screencast.svg
複製程式碼
咋一看好多啊,我的天啊,到底要怎麼看,其實仔細一晃,好像很多一眼就能看出來是什麼意思,大概說一下每個檔案都是幹嘛的,具體的我也不知道啊,往下看,一步一步來。
.github
:這裡面放著當你在這個專案提issue
和pr
時候的規範packages
:字面意思就是包們.....暫時不管,後面詳說 ----> 重點tasks
:字面意思就是任務們.....暫時不管,後面詳說 ----> 重點.eslintignore
:eslint
檢查時忽略檔案.eslintrc
:eslint
檢查配置檔案.gitignore
:git
提交時忽略檔案.travis.yml
:travis
配置檔案.yarnrc
:yarn
配置檔案appveyor.cleanup-cache.txt
:裡面有一行Edit this file to trigger a cache rebuild
編輯此檔案觸發快取,具體幹嘛的,暫時不議appveyor.yml
:appveyor
配置檔案CHANGELOG-0.x.md
:版本0.X開頭的變更說明檔案CHANGELOG.md
:當前版本變更說明檔案CODE_OF_CONDUCT.md
:facebook
程式碼行為準則說明CONTRIBUTING.md
:專案的核心說明lerna.json
:lerna
配置檔案LICENSE
:開源協議package.json
:專案配置檔案README.md
:專案使用說明screencast.svg
:圖片...
看了這麼多檔案,是不是打退堂鼓了?哈哈哈哈,好了好了,進入正題,其實上述對於我們閱讀原始碼有用的只有packages
、tasks
、package.json
三個檔案而已,而且本篇能用到的也就packages
和package.json
,是不是想打我.....我也只是想告訴大家這些檔案有什麼用,它們都是有各自的作用的,如果還不瞭解,參考下面的參考連結。
參考連結
eslint
相關的:eslint官網
travis
相關的:travis官網 travis入門
yarn
相關的:yarn官網
appveyor
相關的:appveyor官網
lerna
相關的:lerna官網
工具自行了解,本文只說原始碼相關的packages
、package.json
。
尋找入口
現在的前端專案大多數都有很多別的依賴,不在像以前那些原生javascript
的工具庫,拿到原始碼檔案,就可以開始看了,像jQuery
、underscore
等等,一個兩個檔案包含了它所有的內容,雖然也有很框架會有umd
規範的檔案可以直接閱讀,像better-scroll
等等,但是其實他在書寫原始碼的時候還是拆分成了很多塊,最後在用打包工具整合在一起了。但是像create-react-app
這樣的腳手架工具好像不能像之前那種方法來看了,必須找到整個程式的入口,在逐步突破,所以最開始的工具肯定是尋找入口。
開始關注
拿到一個專案我們應該從哪個檔案開始看起呢?只要是基於npm
管理的,我都推薦從package.json
檔案開始看,人家是專案的介紹檔案,你不看它看啥。
它裡面理論上應該是有名稱、版本等等一些說明性資訊,但是都沒用,看幾個重要的配置。
"workspaces": [
"packages/*"
],
複製程式碼
關於workspaces
一開始我在npm
的說明文件裡面沒找到,雖然從字面意思我們也能猜到它的意思是實際工作的目錄是packages
,後來我查了一下是yarn
裡面的東東,具體看這篇文章,用於在本地測試,具體不關注,只是從這裡我們知道了真正的起作用的檔案都在packages
裡面。
重點關注
從上述我們知道現在真正需要關注的內容都在packages
裡面,我們來看看它裡面都是有什麼東東:
├── babel-preset-react-app --> 暫不關注
├── create-react-app
├── eslint-config-react-app --> 暫不關注
├── react-dev-utils --> 暫不關注
├── react-error-overlay --> 暫不關注
└── react-scripts --> 核心啊,還是暫不關注
複製程式碼
裡面有六個資料夾,哇塞,又是6個單獨的專案,這要看到何年何月.....是不是有這種感觸,放寬心大膽的看,先想一下我們在安裝了create-react-app
後在,在命令列輸入的是create-react-app
的命令,所以我們大膽的推測關於這個命令應該都是存在了create-react-app
下,在這個目錄下同樣有package.json
檔案,現在我們把這6個檔案拆分成6個專案來分析,上面也說了,看一個專案首先看package.json
檔案,找到其中的重點:
"bin": {
"create-react-app": "./index.js"
}
複製程式碼
找到重點了,package.json
檔案中的bin
就是在命令列中可以執行的命令,也就是說我們在執行create-react-app
命令的時候,就是執行create-react-app
目錄下的index.js
檔案。
多說兩句
關於package.json
中的bin
選項,其實是基於node
環境執行之後的內容。舉個簡單的例子,在我們安裝create-react-app
後,執行create-react-app
等價於執行node index.js
。
create-react-app目錄解析
經過以上一系列的查詢,我們終於艱難的找到了create-react-app
命令的中心入口,其他的都先不管,我們開啟packages/create-react-app
目錄,仔細一瞅,噢喲,只有四個檔案,四個檔案我們還搞不定嗎?除了package.json
、README.md
就只剩兩個能看的檔案了,我們來看看這兩個檔案。
index.js
既然之前已經看到packages/create-react-app/package.json
中關於bin
的設定,就是執行index.js
檔案,我們就從index.js
入手,開始瞅瞅原始碼到底都有些蝦米。
除了一大串的註釋以外,程式碼其實很少,全貼上來了:
var chalk = require('chalk');
var currentNodeVersion = process.versions.node; // 返回Node版本資訊,如果有多個版本返回多個版本
var semver = currentNodeVersion.split('.'); // 所有Node版本的集合
var major = semver[0]; // 取出第一個Node版本資訊
// 如果當前版本小於4就列印以下資訊並終止程式
if (major < 4) {
console.error(
chalk.red(
'You are running Node ' +
currentNodeVersion +
'.\n' +
'Create React App requires Node 4 or higher. \n' +
'Please update your version of Node.'
)
);
process.exit(1); // 終止程式
}
// 沒有小於4就引入以下檔案繼續執行
require('./createReactApp');
複製程式碼
咋一眼看過去其實你就知道它大概是什麼意思了....檢查Node.js
的版本,小於4
就不執行了,我們分開來看一下,這裡他用了一個庫chalk
,理解起來並不複雜,一行一行的解析。
chalk
:這個對這段程式碼的實際影響就是在命令列中,將輸出的資訊變色。也就引出了這個庫的作用改變命令列中輸出資訊的樣式。npm地址
其中有幾個Node
自身的API
:
process.versions
返回一個物件,包含Node
以及它的依賴資訊process.exit
結束Node
程式,1
是狀態碼,表示有異常沒有處理
在我們經過index.js
後,就來到了createReactApp.js
,下面再繼續看。
createReactApp.js
當我們本機上的Node
版本大於4
的時候就要繼續執行這個檔案了,開啟這個檔案,程式碼還不少,大概700
多行吧,我們慢慢拆解。
這裡放個小技巧,在讀原始碼的時候,可以在開一個寫程式碼的視窗,跟著寫一遍,執行過的程式碼可以在原始檔中先刪除,這樣
700行
程式碼,當你讀了200行
的時候,原始檔就只剩500行
了,不僅有成就感繼續閱讀,也把不執行的邏輯先刪除了,影響不到你讀其他地方。
const validateProjectName = require('validate-npm-package-name');
const chalk = require('chalk');
const commander = require('commander');
const fs = require('fs-extra');
const path = require('path');
const execSync = require('child_process').execSync;
const spawn = require('cross-spawn');
const semver = require('semver');
const dns = require('dns');
const tmp = require('tmp');
const unpack = require('tar-pack').unpack;
const url = require('url');
const hyperquest = require('hyperquest');
const envinfo = require('envinfo');
const packageJson = require('./package.json');
複製程式碼
開啟程式碼一排依賴,懵逼....我不可能挨著去查一個個依賴是用來幹嘛的吧?所以,我的建議就是先不管,用到的時候在回來看它是幹嘛的,理解更加透徹一些,繼續往下看。
let projectName; // 定義了一個用來儲存專案名稱的變數
const program = new commander.Command(packageJson.name)
.version(packageJson.version) // 輸入版本資訊,使用`create-react-app -v`的時候就用列印版本資訊
.arguments('<project-directory>') // 使用`create-react-app <my-project>` 尖括號中的引數
.usage(`${chalk.green('<project-directory>')} [options]`) // 使用`create-react-app`第一行列印的資訊,也就是使用說明
.action(name => {
projectName = name; // 此處action函式的引數就是之前argument中的<project-directory> 初始化專案名稱 --> 此處影響後面
})
.option('--verbose', 'print additional logs') // option配置`create-react-app -[option]`的選項,類似 --help -V
.option('--info', 'print environment debug info') // 列印本地相關開發環境,作業系統,`Node`版本等等
.option(
'--scripts-version <alternative-package>',
'use a non-standard version of react-scripts'
) // 這我之前就說過了,指定特殊的`react-scripts`
.option('--use-npm') // 預設使用`yarn`,指定使用`npm`
.allowUnknownOption() // 這個我沒有在文件上查到,直譯就是允許無效的option 大概意思就是我可以這樣`create-react-app <my-project> -la` 其實 -la 並沒有定義,但是我還是可以這麼做而不會儲存
.on('--help', () => {
// 此處省略了一些列印資訊
}) // on('--help') 用來定製列印幫助資訊 當使用`create-react-app -h(or --help)`的時候就會執行其中的程式碼,基本都是些列印資訊
.parse(process.argv); // 這個就是解析我們正常的`Node`程式,可以這麼理解沒有這個東東,`commander`就不能接管`Node`
複製程式碼
在上面的程式碼中,我把無關緊要列印資訊省略了,這段程式碼算是這個檔案的關鍵入口地此處他new
了一個commander
,這是個啥東東呢?這時我們就返回去看它的依賴,找到它是一個外部依賴,這時候怎麼辦呢?不可能開啟node_modules
去裡面找撒,很簡單,開啟npm
官網查一下這個外部依賴。
commander
:概述一下,Node
命令介面,也就是可以用它代管Node
命令。npm地址
上述只是commander
用法的一種實現,沒有什麼具體好說的,瞭解了commander
就不難,這裡的定義也就是我們在命令列中看到的那些東西,比如引數,比如列印資訊等等,我們繼續往下來。
// 判斷在命令列中執行`create-react-app <name>` 有沒有name,如果沒有就繼續
if (typeof projectName === 'undefined') {
// 當沒有傳name的時候,如果帶了 --info 的選項繼續執行下列程式碼,這裡配置了--info時不會報錯
if (program.info) {
// 列印當前環境資訊和`react`、`react-dom`, `react-scripts`三個包的資訊
envinfo.print({
packages: ['react', 'react-dom', 'react-scripts'],
noNativeIDE: true,
duplicates: true,
});
process.exit(0); // 正常退出程式
}
// 在沒有帶專案名稱又沒帶 --info 選項的時候就會列印一堆錯誤資訊,像--version 和 --help 是commander自帶的選項,所以不用單獨配置
console.error('Please specify the project directory:');
console.log(
` ${chalk.cyan(program.name())} ${chalk.green('<project-directory>')}`
);
console.log();
console.log('For example:');
console.log(` ${chalk.cyan(program.name())} ${chalk.green('my-react-app')}`);
console.log();
console.log(
`Run ${chalk.cyan(`${program.name()} --help`)} to see all options.`
);
process.exit(1); // 丟擲異常退出程式
}
複製程式碼
還記得上面把create-react-app <my-project>
中的專案名稱賦予了projectName
變數嗎?此處的作用就是看看使用者有沒有傳這個<my-project>
引數,如果沒有就會報錯,並顯示一些幫助資訊,這裡用到了另外一個外部依賴envinfo
。
envinfo
:可以列印當前作業系統的環境和指定包的資訊。 npm地址
到這裡我還要吐槽一下
segmentfault
的編輯器...我同時開啟檢視和編輯好卡...捂臉.png!
這裡我之前省略了一個東西,還是拿出來說一下:
const hiddenProgram = new commander.Command()
.option(
'--internal-testing-template <path-to-template>',
'(internal usage only, DO NOT RELY ON THIS) ' +
'use a non-standard application template'
)
.parse(process.argv);
複製程式碼
create-react-app
在初始化一個專案的時候,會生成一個標準的資料夾,這裡有一個隱藏的選項--internal-testing-template
,用來更改初始化目錄的模板,這裡他已經說了,供內部使用,應該是開發者們開發時候用的,所以不建議大家使用這個選項。
我們繼續往下看,有幾個提前定義的函式,我們不管,直接找到第一個被執行的函式:
createApp(
projectName,
program.verbose,
program.scriptsVersion,
program.useNpm,
hiddenProgram.internalTestingTemplate
);
複製程式碼
一個createAPP
函式,接收了5個引數
projectName
: 執行create-react-app <name>
name的值,也就是初始化專案的名稱program.verbose
:這裡在說一下commander
的option
選項,如果加了這個選項這個值就是true
,否則就是false
,也就是說這裡如果加了--verbose
,那這個引數就是true
,至於verbose
是什麼,我之前也說過了,在yarn
或者npm
安裝的時候列印本地資訊,也就是如果安裝過程中出錯,我們可以找到額外的資訊。program.scriptsVersion
:與上述同理,指定react-scripts
版本program.useNpm
:以上述同理,指定是否使用npm
,預設使用yarn
hiddenProgram.internalTestingTemplate
:這個東東,我之前給他省略了,我在前面已經補充了,指定初始化的模板,人家說了內部使用,大家可以忽略了,應該是用於開發測試模板目錄的時候使用。
找到了第一個執行的函式createApp
,我們就來看看createApp
函式到底做了什麼?
createApp()
function createApp(name, verbose, version, useNpm, template) {
const root = path.resolve(name); // 獲取當前程式執行的位置,也就是檔案目錄的絕對路徑
const appName = path.basename(root); // 返回root路徑下最後一部分
checkAppName(appName); // 執行 checkAppName 函式 檢查檔名是否合法
fs.ensureDirSync(name); // 此處 ensureDirSync 方法是外部依賴包 fs-extra 而不是 node本身的fs模組,作用是確保當前目錄下有指定檔名,沒有就建立
// isSafeToCreateProjectIn 函式 判斷資料夾是否安全
if (!isSafeToCreateProjectIn(root, name)) {
process.exit(1); // 不合法結束程式
}
// 到這裡列印成功建立了一個`react`專案在指定目錄下
console.log(`Creating a new React app in ${chalk.green(root)}.`);
console.log();
// 定義package.json基礎內容
const packageJson = {
name: appName,
version: '0.1.0',
private: true,
};
// 往我們建立的資料夾中寫入package.json檔案
fs.writeFileSync(
path.join(root, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
// 定義常量 useYarn 如果傳參有 --use-npm useYarn就是false,否則執行 shouldUseYarn() 檢查yarn是否存在
// 這一步就是之前說的他預設使用`yarn`,但是可以指定使用`npm`,如果指定使用了`npm`,`useYarn`就是`false`,不然執行 shouldUseYarn 函式
// shouldUseYarn 用於檢測本機是否安裝了`yarn`
const useYarn = useNpm ? false : shouldUseYarn();
// 取得當前node程式的目錄,之前還懂為什麼要單獨取一次,之後也明白了,下一句程式碼將會改變這個值,所以如果我後面要用這個值,後續其實取得值將不是這個
// 所以這裡的目的就是提前存好,免得我後續使用的時候不好去找,這個地方就是我執行初始化專案的目錄,而不是初始化好的目錄,是初始化的上級目錄,有點繞..
const originalDirectory = process.cwd();
// 修改程式目錄為底下子程式目錄
// 在這裡就把程式目錄修改為了我們建立的目錄
process.chdir(root);
// 如果不使用yarn 並且checkThatNpmCanReadCwd()函式 這裡之前說的不是很對,在重新說一次
// checkThatNpmCanReadCwd 這個函式的作用是檢查程式目錄是否是我們建立的目錄,也就是說如果程式不在我們建立的目錄裡面,後續再執行`npm`安裝的時候就會出錯,所以提前檢查
if (!useYarn && !checkThatNpmCanReadCwd()) {
process.exit(1);
}
// 比較 node 版本,小於6的時候發出警告
// 之前少說了一點,小於6的時候指定`react-scripts`標準版本為0.9.x,也就是標準的`react-scripts@1.0.0`以上的版本不支援`node`在6版本之下
if (!semver.satisfies(process.version, '>=6.0.0')) {
console.log(
chalk.yellow(
`You are using Node ${process.version} so the project will be bootstrapped with an old unsupported version of tools.\n\n` +
`Please update to Node 6 or higher for a better, fully supported experience.\n`
)
);
// Fall back to latest supported react-scripts on Node 4
version = 'react-scripts@0.9.x';
}
// 如果沒有使用yarn 也發出警告
// 這裡之前也沒有說全,還判斷了`npm`的版本是不是在3以上,如果沒有依然指定安裝`react-scripts@0.9.x`版本
if (!useYarn) {
const npmInfo = checkNpmVersion();
if (!npmInfo.hasMinNpm) {
if (npmInfo.npmVersion) {
console.log(
chalk.yellow(
`You are using npm ${npmInfo.npmVersion} so the project will be boostrapped with an old unsupported version of tools.\n\n` +
`Please update to npm 3 or higher for a better, fully supported experience.\n`
)
);
}
// Fall back to latest supported react-scripts for npm 3
version = 'react-scripts@0.9.x';
}
}
// 傳入這些引數執行run函式
// 執行完畢上述程式碼以後,將執行`run`函式,但是我還是先把上述用到的函式全部說完,在來下一個核心函式`run`
run(root, appName, version, verbose, originalDirectory, template, useYarn);
}
複製程式碼
我這裡先來總結一下這個函式都做了哪些事情,再來看看他用到的依賴有哪些,先說做了哪些事情,在我們的目錄下建立了一個專案目錄,並且校驗了這個目錄的名稱是否合法,這個目錄是否安全,然後往其中寫入了一個package.json
的檔案,並且判斷了當前環境下應該使用的react-scripts
的版本,然後執行了run
函式。我們在來看看這個函式用了哪些外部依賴:
之後函式的函式依賴我都會進行詳細的解析,除了少部分特別簡單的函式,然後我們來看看這個函式的函式依賴:
checkAppName()
:用於檢測檔名是否合法,isSafeToCreateProjectIn()
:用於檢測資料夾是否安全shouldUseYarn()
:用於檢測yarn
在本機是否已經安裝checkThatNpmCanReadCwd()
:用於檢測npm
是否在正確的目錄下執行checkNpmVersion()
:用於檢測npm
在本機是否已經安裝了
checkAppName()
function checkAppName(appName) {
// 使用 validateProjectName 檢查包名是否合法返回結果,這個validateProjectName是外部依賴的引用,見下面說明
const validationResult = validateProjectName(appName);
// 如果物件中有錯繼續,這裡就是外部依賴的具體用法
if (!validationResult.validForNewPackages) {
console.error(
`Could not create a project called ${chalk.red(
`"${appName}"`
)} because of npm naming restrictions:`
);
printValidationResults(validationResult.errors);
printValidationResults(validationResult.warnings);
process.exit(1);
}
// 定義了三個開發依賴的名稱
const dependencies = ['react', 'react-dom', 'react-scripts'].sort();
// 如果專案使用了這三個名稱都會報錯,而且退出程式
if (dependencies.indexOf(appName) >= 0) {
console.error(
chalk.red(
`We cannot create a project called ${chalk.green(
appName
)} because a dependency with the same name exists.\n` +
`Due to the way npm works, the following names are not allowed:\n\n`
) +
chalk.cyan(dependencies.map(depName => ` ${depName}`).join('\n')) +
chalk.red('\n\nPlease choose a different project name.')
);
process.exit(1);
}
}
複製程式碼
它這個函式其實還蠻簡單的,用了一個外部依賴來校驗檔名是否符合npm
包檔名的規範,然後定義了三個不能取得名字react
、react-dom
、react-scripts
,外部依賴:
validate-npm-package-name
:外部依賴,檢查包名是否合法。npm地址
其中的函式依賴:
printValidationResults()
:函式引用,這個函式就是我說的特別簡單的型別,裡面就是把接收到的錯誤資訊迴圈列印出來,沒什麼好說的。
isSafeToCreateProjectIn()
function isSafeToCreateProjectIn(root, name) {
// 定義了一堆檔名
// 我今天早上仔細的看了一些,以下檔案的來歷就是我們這些開發者在`create-react-app`中提的一些檔案
const validFiles = [
'.DS_Store',
'Thumbs.db',
'.git',
'.gitignore',
'.idea',
'README.md',
'LICENSE',
'web.iml',
'.hg',
'.hgignore',
'.hgcheck',
'.npmignore',
'mkdocs.yml',
'docs',
'.travis.yml',
'.gitlab-ci.yml',
'.gitattributes',
];
console.log();
// 這裡就是在我們建立好的專案資料夾下,除了上述檔案以外不包含其他檔案就會返回true
const conflicts = fs
.readdirSync(root)
.filter(file => !validFiles.includes(file));
if (conflicts.length < 1) {
return true;
}
// 否則這個資料夾就是不安全的,並且挨著列印存在哪些不安全的檔案
console.log(
`The directory ${chalk.green(name)} contains files that could conflict:`
);
console.log();
for (const file of conflicts) {
console.log(` ${file}`);
}
console.log();
console.log(
'Either try using a new directory name, or remove the files listed above.'
);
// 並且返回false
return false;
}
複製程式碼
他這個函式也算比較簡單,就是判斷建立的這個目錄是否包含除了上述validFiles
裡面的檔案,至於這裡面的檔案是怎麼來的,就是create-react-app
在發展至今,開發者們提出來的。
shouldUseYarn()
function shouldUseYarn() {
try {
execSync('yarnpkg --version', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
複製程式碼
就三行...其中execSync
是由node
自身模組child_process
引用而來,就是用來執行命令的,這個函式就是執行一下yarnpkg --version
來判斷我們是否正確安裝了yarn
,如果沒有正確安裝yarn
的話,useYarn
依然為false
,不管指沒有指定--use-npm
。
execSync
:引用自child_process.execSync
,用於執行需要執行的子程式
checkThatNpmCanReadCwd()
function checkThatNpmCanReadCwd() {
const cwd = process.cwd(); // 這裡取到當前的程式目錄
let childOutput = null; // 定義一個變數來儲存`npm`的資訊
try {
// 相當於執行`npm config list`並將其輸出的資訊組合成為一個字串
childOutput = spawn.sync('npm', ['config', 'list']).output.join('');
} catch (err) {
return true;
}
// 判斷是否是一個字串
if (typeof childOutput !== 'string') {
return true;
}
// 將整個字串以換行符分隔
const lines = childOutput.split('\n');
// 定義一個我們需要的資訊的字首
const prefix = '; cwd = ';
// 去整個lines裡面的每個line查詢有沒有這個字首的一行
const line = lines.find(line => line.indexOf(prefix) === 0);
if (typeof line !== 'string') {
return true;
}
// 取出後面的資訊,這個資訊大家可以自行試一試,就是`npm`執行的目錄
const npmCWD = line.substring(prefix.length);
// 判斷當前目錄和執行目錄是否是一致的
if (npmCWD === cwd) {
return true;
}
// 不一致就列印以下資訊,大概意思就是`npm`程式沒有在正確的目錄下執行
console.error(
chalk.red(
`Could not start an npm process in the right directory.\n\n` +
`The current directory is: ${chalk.bold(cwd)}\n` +
`However, a newly started npm process runs in: ${chalk.bold(
npmCWD
)}\n\n` +
`This is probably caused by a misconfigured system terminal shell.`
)
);
// 這裡他對windows的情況作了一些單獨的判斷,沒有深究這些資訊
if (process.platform === 'win32') {
console.error(
chalk.red(`On Windows, this can usually be fixed by running:\n\n`) +
` ${chalk.cyan(
'reg'
)} delete "HKCU\\Software\\Microsoft\\Command Processor" /v AutoRun /f\n` +
` ${chalk.cyan(
'reg'
)} delete "HKLM\\Software\\Microsoft\\Command Processor" /v AutoRun /f\n\n` +
chalk.red(`Try to run the above two lines in the terminal.\n`) +
chalk.red(
`To learn more about this problem, read: https://blogs.msdn.microsoft.com/oldnewthing/20071121-00/?p=24433/`
)
);
}
return false;
}
複製程式碼
這個函式我之前居然貼錯了,實在是不好意思。我之前沒有弄懂這個函式的意思,今天再來看的時候已經豁然開朗了,它的意思上述程式碼已經解析了,其中用到了一個外部依賴:
cross-spawn
:這個我之前說到了沒有?忘了,用來執行node
程式。npm地址
為什麼用單獨用一個外部依賴,而不是用node
自身的呢?來看一下cross-spawn
它自己對自己的說明,Node
跨平臺解決方案,解決在windows
下各種問題。
checkNpmVersion()
function checkNpmVersion() {
let hasMinNpm = false;
let npmVersion = null;
try {
npmVersion = execSync('npm --version')
.toString()
.trim();
hasMinNpm = semver.gte(npmVersion, '3.0.0');
} catch (err) {
// ignore
}
return {
hasMinNpm: hasMinNpm,
npmVersion: npmVersion,
};
}
複製程式碼
這個能說的也比較少,一眼看過去就知道什麼意思了,返回一個物件,物件上面有兩個對對,一個是npm
的版本號,一個是是否有最小npm
版本的限制,其中一個外部依賴,一個Node
自身的API我之前也都說過了,不說了。
看到到這裡createApp()
函式的依賴和執行都結束了,接著執行了run()
函式,我們繼續來看run()
函式都是什麼,我又想吐槽了,算了,忍住!!!
run()
函式在createApp()
函式的所有內容執行完畢後執行,它接收7個引數,先來看看。
root
:我們建立的目錄的絕對路徑appName
:我們建立的目錄名稱version
;react-scripts
的版本verbose
:繼續傳入verbose
,在createApp
中沒有使用到originalDirectory
:原始目錄,這個之前說到了,到run
函式中就有用了tempalte
:模板,這個引數之前也說過了,不對外使用useYarn
:是否使用yarn
具體的來看下面run()
函式。
run()
function run(
root,
appName,
version,
verbose,
originalDirectory,
template,
useYarn
) {
// 這裡對`react-scripts`做了大量的處理
const packageToInstall = getInstallPackage(version, originalDirectory); // 獲取依賴包資訊
const allDependencies = ['react', 'react-dom', packageToInstall]; // 所有的開發依賴包
console.log('Installing packages. This might take a couple of minutes.');
getPackageName(packageToInstall) // 獲取依賴包原始名稱並返回
.then(packageName =>
// 檢查是否離線模式,並返回結果和包名
checkIfOnline(useYarn).then(isOnline => ({
isOnline: isOnline,
packageName: packageName,
}))
)
.then(info => {
// 接收到上述的包名和是否為離線模式
const isOnline = info.isOnline;
const packageName = info.packageName;
console.log(
`Installing ${chalk.cyan('react')}, ${chalk.cyan(
'react-dom'
)}, and ${chalk.cyan(packageName)}...`
);
console.log();
// 安裝依賴
return install(root, useYarn, allDependencies, verbose, isOnline).then(
() => packageName
);
})
.then(packageName => {
// 檢查當前`Node`版本是否支援包
checkNodeVersion(packageName);
// 檢查`package.json`的開發依賴是否正常
setCaretRangeForRuntimeDeps(packageName);
// `react-scripts`指令碼的目錄
const scriptsPath = path.resolve(
process.cwd(),
'node_modules',
packageName,
'scripts',
'init.js'
);
// 引入`init`函式
const init = require(scriptsPath);
// 執行目錄的拷貝
init(root, appName, verbose, originalDirectory, template);
// 當`react-scripts`的版本為0.9.x發出警告
if (version === 'react-scripts@0.9.x') {
console.log(
chalk.yellow(
`\nNote: the project was boostrapped with an old unsupported version of tools.\n` +
`Please update to Node >=6 and npm >=3 to get supported tools in new projects.\n`
)
);
}
})
// 異常處理
.catch(reason => {
console.log();
console.log('Aborting installation.');
// 根據命令來判斷具體的錯誤
if (reason.command) {
console.log(` ${chalk.cyan(reason.command)} has failed.`);
} else {
console.log(chalk.red('Unexpected error. Please report it as a bug:'));
console.log(reason);
}
console.log();
// 出現異常的時候將刪除目錄下的這些檔案
const knownGeneratedFiles = [
'package.json',
'npm-debug.log',
'yarn-error.log',
'yarn-debug.log',
'node_modules',
];
// 挨著刪除
const currentFiles = fs.readdirSync(path.join(root));
currentFiles.forEach(file => {
knownGeneratedFiles.forEach(fileToMatch => {
if (
(fileToMatch.match(/.log/g) && file.indexOf(fileToMatch) === 0) ||
file === fileToMatch
) {
console.log(`Deleting generated file... ${chalk.cyan(file)}`);
fs.removeSync(path.join(root, file));
}
});
});
// 判斷當前目錄下是否還存在檔案
const remainingFiles = fs.readdirSync(path.join(root));
if (!remainingFiles.length) {
console.log(
`Deleting ${chalk.cyan(`${appName} /`)} from ${chalk.cyan(
path.resolve(root, '..')
)}`
);
process.chdir(path.resolve(root, '..'));
fs.removeSync(path.join(root));
}
console.log('Done.');
process.exit(1);
});
}
複製程式碼
他這裡對react-script
做了很多處理,大概是由於react-script
本身是有node
版本的依賴的,而且在用create-react-app init <project>
初始化一個專案的時候,是可以指定react-script
的版本或者是外部自身定義的東東。
他在run()
函式中的引用都是用Promise
回撥的方式來完成的,從我正式接觸Node
開始就習慣用async/await
,所以對Promise
還真不熟,惡補了一番,下面我們來拆解其中的每一句和每一個函式的作用,先來看一下用到外部依賴還是之前那些不說了,來看看函式列表:
getInstallPackage()
:獲取要安裝的react-scripts
版本或者開發者自己定義的react-scripts
getPackageName()
:獲取到正式的react-scripts
的包名checkIfOnline()
:檢查網路連線是否正常install()
:安裝開發依賴包checkNodeVersion()
:檢查Node
版本資訊setCaretRangeForRuntimeDeps()
:檢查發開依賴是否正確安裝,版本是否正確init()
:將事先定義好的目錄檔案拷貝到我的專案中
知道了個大概,我們在來逐一分析每個函式的作用:
getInstallPackage()
function getInstallPackage(version, originalDirectory) {
let packageToInstall = 'react-scripts'; // 定義常量 packageToInstall,預設就是標準`react-scripts`包名
const validSemver = semver.valid(version); // 校驗版本號是否合法
if (validSemver) {
packageToInstall += `@${validSemver}`; // 合法的話執行,就安裝指定版本,在`npm install`安裝的時候指定版本為加上`@x.x.x`版本號,安裝指定版本的`react-scripts`
} else if (version && version.match(/^file:/)) {
// 不合法並且版本號引數帶有`file:`執行以下程式碼,作用是指定安裝包為我們自身定義的包
packageToInstall = `file:${path.resolve(
originalDirectory,
version.match(/^file:(.*)?$/)[1]
)}`;
} else if (version) {
// 不合法並且沒有`file:`開頭,預設為線上的`tar.gz`檔案
// for tar.gz or alternative paths
packageToInstall = version;
}
// 返回最終需要安裝的`react-scripts`的資訊,或版本號或本地檔案或線上`.tar.gz`資源
return packageToInstall;
}
複製程式碼
這個方法接收兩個引數version
版本號,originalDirectory
原始目錄,主要的作用是判斷react-scripts
應該安裝的資訊,具體看每一行。
這裡create-react-app
本身提供了安裝react-scripts
的三種機制,一開始初始化的專案是可以指定react-scripts
的版本或者是自定義這個東西的,所以在這裡他就提供了這幾種機制,其中用到的外部依賴只有一個semver
,之前就說過了,不多說。
getPackageName()
function getPackageName(installPackage) {
// 函式進來就根據上面的那個判斷`react-scripts`的資訊來安裝這個包,用於返回正規的包名
// 此處為線上`tar.gz`包的情況
if (installPackage.match(/^.+\.(tgz|tar\.gz)$/)) {
// 裡面這段建立了一個臨時目錄,具體它是怎麼設定了線上.tar.gz包我沒試就不亂說了
return getTemporaryDirectory()
.then(obj => {
let stream;
if (/^http/.test(installPackage)) {
stream = hyperquest(installPackage);
} else {
stream = fs.createReadStream(installPackage);
}
return extractStream(stream, obj.tmpdir).then(() => obj);
})
.then(obj => {
const packageName = require(path.join(obj.tmpdir, 'package.json')).name;
obj.cleanup();
return packageName;
})
.catch(err => {
console.log(
`Could not extract the package name from the archive: ${err.message}`
);
const assumedProjectName = installPackage.match(
/^.+\/(.+?)(?:-\d+.+)?\.(tgz|tar\.gz)$/
)[1];
console.log(
`Based on the filename, assuming it is "${chalk.cyan(
assumedProjectName
)}"`
);
return Promise.resolve(assumedProjectName);
});
// 此處為資訊中包含`git+`資訊的情況
} else if (installPackage.indexOf('git+') === 0) {
return Promise.resolve(installPackage.match(/([^/]+)\.git(#.*)?$/)[1]);
// 此處為只有版本資訊的時候的情況
} else if (installPackage.match(/.+@/)) {
return Promise.resolve(
installPackage.charAt(0) + installPackage.substr(1).split('@')[0]
);
// 此處為資訊中包含`file:`開頭的情況
} else if (installPackage.match(/^file:/)) {
const installPackagePath = installPackage.match(/^file:(.*)?$/)[1];
const installPackageJson = require(path.join(installPackagePath, 'package.json'));
return Promise.resolve(installPackageJson.name);
}
// 什麼都沒有直接返回包名
return Promise.resolve(installPackage);
}
複製程式碼
他這個函式的目標就是返回一個正常的依賴包名,比如我們什麼都不帶就返回react-scripts
,在比如我們是自己定義的包就返回my-react-scripts
,繼續到了比較關鍵的函式了,接收一個installPackage
引數,從這函式開始就採用Promise
回撥的方式一直執行到最後,我們來看看這個函式都做了什麼,具體看上面每一行的註釋。
總結一句話,這個函式的作用就是返回正常的包名,不帶任何符號的,來看看它的外部依賴:
hyperquest
:這個用於將http請求流媒體傳輸。npm地址
他本身還有函式依賴,這兩個函式依賴我都不單獨再說,函式的意思很好理解,至於為什麼這麼做我還沒想明白:
getTemporaryDirectory()
:不難,他本身是一個回撥函式,用來建立一個臨時目錄。extractStream()
:主要用到node
本身的一個流,這裡我真沒懂為什麼藥改用流的形式,就不發表意見了,在看其實我還是沒懂,要真正的明白是要去試一次,但是真的有點麻煩,不想去關注。
PS:其實這個函式很好理解就是返回正常的包名,但是裡面的有些處理我都沒想通,以後理解深刻了在回溯一下。
checkIfOnline()
function checkIfOnline(useYarn) {
if (!useYarn) {
return Promise.resolve(true);
}
return new Promise(resolve => {
dns.lookup('registry.yarnpkg.com', err => {
let proxy;
if (err != null && (proxy = getProxy())) {
dns.lookup(url.parse(proxy).hostname, proxyErr => {
resolve(proxyErr == null);
});
} else {
resolve(err == null);
}
});
});
}
複製程式碼
這個函式本身接收一個是否使用yarn
的引數來判斷是否進行後續,如果使用的是npm
就直接返回true
了,為什麼會有這個函式是由於yarn
本身有個功能叫離線安裝,這個函式來判斷是否離線安裝,其中用到了外部依賴:
dns
:用來檢測是否能夠請求到指定的地址。npm地址
install()
function install(root, useYarn, dependencies, verbose, isOnline) {
// 封裝在一個回撥函式中
return new Promise((resolve, reject) => {
let command; // 定義一個命令
let args; // 定義一個命令的引數
// 如果使用yarn
if (useYarn) {
command = 'yarnpkg'; // 命令名稱
args = ['add', '--exact']; // 命令引數的基礎
if (!isOnline) {
args.push('--offline'); // 此處接上面一個函式判斷是否是離線模式
}
[].push.apply(args, dependencies); // 組合引數和開發依賴 `react` `react-dom` `react-scripts`
args.push('--cwd'); // 指定命令執行目錄的地址
args.push(root); // 地址的絕對路徑
// 在使用離線模式時候會發出警告
if (!isOnline) {
console.log(chalk.yellow('You appear to be offline.'));
console.log(chalk.yellow('Falling back to the local Yarn cache.'));
console.log();
}
// 不使用yarn的情況使用npm
} else {
// 此處於上述一樣,命令的定義 引數的組合
command = 'npm';
args = [
'install',
'--save',
'--save-exact',
'--loglevel',
'error',
].concat(dependencies);
}
// 因為`yarn`和`npm`都可以帶這個引數,所以就單獨拿出來了拼接到上面
if (verbose) {
args.push('--verbose');
}
// 這裡就把命令組合起來執行
const child = spawn(command, args, { stdio: 'inherit' });
// 命令執行完畢後關閉
child.on('close', code => {
// code 為0代表正常關閉,不為零就列印命令執行錯誤的那條
if (code !== 0) {
reject({
command: `${command} ${args.join(' ')}`,
});
return;
}
// 正常繼續往下執行
resolve();
});
});
}
複製程式碼
又到了比較關鍵的地方了,仔細看每一行程式碼註釋,此處函式的作用就是組合一個yarn
或者npm
的安裝命令,把這些模組安裝到專案的資料夾中,其中用到的外部依賴cross-spawn
前面有說了,就不說了。
其實執行到這裡,create-react-app
已經幫我們建立好了目錄,package.json
並且安裝了所有的依賴,react
、react-dom
和react-scrpts
,複雜的部分已經結束,繼續往下走。
checkNodeVersion()
function checkNodeVersion(packageName) {
// 找到`react-scripts`的`package.json`路徑
const packageJsonPath = path.resolve(
process.cwd(),
'node_modules',
packageName,
'package.json'
);
// 引入`react-scripts`的`package.json`
const packageJson = require(packageJsonPath);
// 在`package.json`中定義了一個`engines`其中放著`Node`版本的資訊,大家可以開啟原始碼`packages/react-scripts/package.json`檢視
if (!packageJson.engines || !packageJson.engines.node) {
return;
}
// 比較程式的`Node`版本資訊和最小支援的版本,如果比他小的話,會報錯然後退出程式
if (!semver.satisfies(process.version, packageJson.engines.node)) {
console.error(
chalk.red(
'You are running Node %s.\n' +
'Create React App requires Node %s or higher. \n' +
'Please update your version of Node.'
),
process.version,
packageJson.engines.node
);
process.exit(1);
}
}
複製程式碼
這個函式直譯一下,檢查Node
版本,為什麼要檢查了?之前我已經說過了react-scrpts
是需要依賴Node
版本的,也就是說低版本的Node
不支援,其實的外部依賴也是之前的幾個,沒什麼好說的。
setCaretRangeForRuntimeDeps()
function setCaretRangeForRuntimeDeps(packageName) {
const packagePath = path.join(process.cwd(), 'package.json'); // 取出建立專案的目錄中的`package.json`路徑
const packageJson = require(packagePath); // 引入`package.json`
// 判斷其中`dependencies`是否存在,不存在代表我們的開發依賴沒有成功安裝
if (typeof packageJson.dependencies === 'undefined') {
console.error(chalk.red('Missing dependencies in package.json'));
process.exit(1);
}
// 拿出`react-scripts`或者是自定義的看看`package.json`中是否存在
const packageVersion = packageJson.dependencies[packageName];
if (typeof packageVersion === 'undefined') {
console.error(chalk.red(`Unable to find ${packageName} in package.json`));
process.exit(1);
}
// 檢查`react` `react-dom` 的版本
makeCaretRange(packageJson.dependencies, 'react');
makeCaretRange(packageJson.dependencies, 'react-dom');
// 重新寫入檔案`package.json`
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2));
}
複製程式碼
這個函式我也不想說太多了,他的作用並沒有那麼大,就是用來檢測我們之前安裝的依賴是否寫入了package.json
裡面,並且對依賴的版本做了檢測,其中一個函式依賴:
makeCaretRange()
:用來對依賴的版本做檢測
我沒有單獨對其中的子函式進行分析,是因為我覺得不難,而且對主線影響不大,我不想貼太多說不完。
到這裡createReactApp.js
裡面的原始碼都分析完了,咦!你可能會說你都沒說init()
函式,哈哈哈,看到這裡說明你很認真哦,init()
函式是放在packages/react-scripts/script
目錄下的,但是我還是要給他說了,因為它其實跟react-scripts
包聯絡不大,就是個copy
他本身定義好的模板目錄結構的函式。
init()
它本身接收5
個引數:
appPath
:之前的root
,專案的絕對路徑appName
:專案的名稱verbose
:這個引數我之前說過了,npm
安裝時額外的資訊originalDirectory
:原始目錄,命令執行的目錄template
:其實其中只有一種型別的模板,這個選項的作用就是配置之前我說過的那個函式,測試模板
// 當前的包名,也就是這個命令的包
const ownPackageName = require(path.join(__dirname, '..', 'package.json')).name;
// 當前包的路徑
const ownPath = path.join(appPath, 'node_modules', ownPackageName);
// 專案的`package.json`
const appPackage = require(path.join(appPath, 'package.json'));
// 檢查專案中是否有`yarn.lock`來判斷是否使用`yarn`
const useYarn = fs.existsSync(path.join(appPath, 'yarn.lock'));
appPackage.dependencies = appPackage.dependencies || {};
// 定義其中`scripts`的
appPackage.scripts = {
start: 'react-scripts start',
build: 'react-scripts build',
test: 'react-scripts test --env=jsdom',
eject: 'react-scripts eject',
};
// 重新寫入`package.json`
fs.writeFileSync(
path.join(appPath, 'package.json'),
JSON.stringify(appPackage, null, 2)
);
// 判斷專案目錄是否有`README.md`,模板目錄中已經定義了`README.md`防止衝突
const readmeExists = fs.existsSync(path.join(appPath, 'README.md'));
if (readmeExists) {
fs.renameSync(
path.join(appPath, 'README.md'),
path.join(appPath, 'README.old.md')
);
}
// 是否有模板選項,預設為當前執行命令包目錄下的`template`目錄,也就是`packages/react-scripts/tempalte`
const templatePath = template
? path.resolve(originalDirectory, template)
: path.join(ownPath, 'template');
if (fs.existsSync(templatePath)) {
// 拷貝目錄到專案目錄
fs.copySync(templatePath, appPath);
} else {
console.error(
`Could not locate supplied template: ${chalk.green(templatePath)}`
);
return;
}
複製程式碼
這個函式我就不把程式碼貼全了,裡面的東西也蠻好理解,基本上就是對目錄結構的修改和重名了那些,挑了一些來說,到這裡,create-react-app
從零到目錄依賴的安裝完畢的原始碼已經分析完畢,但是其實這只是個初始化目錄和依賴,其中控制環境的程式碼都存在react-scripts
中,所以其實離我想知道的關鍵的地方還有點遠,但是本篇已經很長了,不打算現在說了,多多包涵。
希望本篇對大家有所幫助吧。
囉嗦兩句
本來這篇我是打算把create-react-app
中所有的原始碼的拿出來說一說,包括其中的webpack
的配置啊,eslint
的配置啊,babel
的配置啊.....等等,但是實在是有點多,他自己本身把初始化的命令和控制react
環境的命令分離成了packages/create-react-app
和packages/react-script
兩邊,這個篇幅才把packages/create-react-app
說完,更復雜的packages/react-script
在說一下這篇幅都不知道有多少了,所以我打算之後空了,在單獨寫一篇關於packages/react-script
的原始碼分析的文。
碼字不易,可能出現錯別字什麼的,說的不清楚的,說錯的,歡迎指正,多多包涵!