一篇文章搞定 create react app 核心思路

image.png

Create React App is an officially supported way to create single-page React applications. It offers a modern build setup with no configuration.

create react appReact 官方建立單頁應用的方式,爲了方便,下文皆簡稱 CRAjavascript

它的核心思想我理解主要是:java

  1. 腳手架核心功能中心化:使用 npx 保證每次用戶使用的都是最新版本,方便功能的升級
  2. 模板去中心化:方便地進行模板管理,這樣也容許用戶自定義模板
  3. 腳手架邏輯和初始化代碼邏輯分離:在 cra 中只執行了腳手架相關邏輯,而初始化代碼的邏輯在 react-scripts 包裏執行

本文主要就是經過源碼分析對上述的理解進行闡述。node

按照本身的理解,畫了個流程圖,你們能夠帶着該流程圖去閱讀源碼(主要包含兩個部分 create-react-appreact-scripts/init):react

若是圖片不清晰能夠微信搜索公衆號 玩相機的程序員,回覆 CRA 獲取。git

0. 用法

CRA 的用法很簡單,兩步:程序員

  1. 安裝:npm install -g create-react-app
  2. 使用:create-react-app my-app

這是常見的用法,會在全局環境下安裝一個 CRA,在命令行中能夠經過 create react app 直接使用。github

如今更推薦的用法是使用 npx 來執行 create react appnpm

npx create-react-app my-app

這樣確保每次執行 create-reat-app 使用的都是 npm 上最新的版本。json

注:npxnpm 5.2+ 以後引入的功能,如需使用須要 check 一下本地的 npm 版本。promise

默認狀況下,CRA 命令只須要傳入 project-directory 便可,不須要額外的參數,更多用法查看:https://create-react-app.dev/docs/getting-started#creating-an-app,就不展開了。

能夠看一下官方的 Demo 感覺一下:

咱們主要仍是經過 CRA 的源碼來了解一下它的思路。

1. 入口

本文中的 create-react-app 版本爲 4.0.1。若閱讀本文時存在 break change,可能就須要本身理解一下啦

按照正常邏輯,咱們在 package.json 裏找到了入口文件:

{
  "bin": {
    "create-react-app": "./index.js"
  },
}

index.js 裏的邏輯比較簡單,判斷了一下 node 環境是不是 10 以上,就調用 init 了,因此核心仍是在 init 方法裏。

// index.js
const { init } = require('./createReactApp');
init();

打開 createReactApp.js 文件一看,好傢伙,1017 行代碼(別慌,跟着我往下看,1000 行代碼也分分鐘看明白)

吐槽一下,雖然代碼邏輯寫得很清楚,可是爲啥不拆幾個模塊呢?

找到 init 方法以後發現,其實就執行了一個 Promise

// createReactApp.js
function init() {
  checkForLatestVersion().catch().then();
}

注意這裏是先 catchthen

跟着我往下看唄 ~ 一步一步理清楚 CRA,你也能依葫蘆畫瓢造一個。

2. 檢查版本

checkForLatestVersion 就作了一件事,獲取 create-react-app 這個 npm 包的 latest 版本號。

若是你想獲取某個 npm 包的版本號,能夠經過開放接口 [https://registry.npmjs.org/-/package/{pkgName}/dist-tags](https://registry.npmjs.org/-/package/%7BpkgName%7D/dist-tags "https://registry.npmjs.org/-/package/{pkgName}/dist-tags") 得到,其返回值爲:

{
  "next": "4.0.0-next.117",
  "latest": "4.0.1",
  "canary": "3.3.0-next.38"
}

若是你想獲取某個 npm 包完整信息,能夠經過開放接口 [https://registry.npmjs.org/{pkgName}](https://registry.npmjs.org/%7BpkgName%7D "https://registry.npmjs.org/{pkgName}") 得到,其返回值爲:

{
  "name": "create-react-app",       # 包名
  "dist-tags": {},                  # 版本語義化標籤
  "versions": {},                   # 全部版本信息
  "readme": "",                     # README 內容(markdown 文本)
  "maintainers": [],
  "time": {},                       # 每一個版本的發佈時間
  "license": "",
  "readmeFilename": "README.md",
  "description": "",
  "homepage": "",                   # 主頁
  "keywords": [],                   # 關鍵詞
  "repository": {},                 # 代碼倉庫
  "bugs": {},                       # 提 bug 連接
  "users": {}
}

回到源碼,checkForLatestVersion().catch().then(),注意這裏是先 catchthen,也就是說若是 checkForLatestVersion 裏拋錯誤了,會被 catch 住,而後執行一些邏輯,再執行 then

是的,Promisecatch 後面的 then 仍是會執行。

2.1 Promise catch 後的 then

咱們能夠作個小實驗:

function promise() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject('Promise 失敗了');
    }, 1000)
  });
}

promise().then(res => {
  console.log(res);
}).catch(error => {
  console.log(error);   // Promise 失敗了
  return `ErrorMessage: ${error}`;
}).then(res => {
  console.log(res);     // ErrorMessage: Promise 失敗了
});

原理也很簡單,thencatch 返回的都是一個 promise,固然能夠繼續調用。

OK,checkForLatestVersion 以及以後的 catch 都是隻作了一件事,獲取 latest 版本號,若是沒有就是 null

這裏拿到版本號以後也就判斷一下當前使用的版本是否比 latest 版本低,若是是就推薦你把全局的 CRA 刪了,使用 npx 來執行 CRA

3. 核心方法 createApp

再往下看就是執行了一個 createApp 了,看這名字就知道最關鍵的方法就是它了。

function createApp(name, verbose, version, template, useNpm, usePnp) {
  // 此處省略 100 行代碼
}

createApp 傳入了 6 個參數,對應的是 CRA 命令行傳入的一些配置。

我在思考爲啥這裏不設計成一個 options 對象來接受這些參數?若是後期須要增刪一些參數,是否是比較很差維護?這樣的想法是我過分設計嗎?

4. 檢查應用名

CRA 會檢查輸入的 project name 是否符合如下兩條規範:

  • 檢查是否符合 npm 命名規範
  • 檢查是否含有 react/react-dom/react-scripts 等關鍵字
    不符合規範則直接 process.exit(1) 退出進程。

5. 建立 package.json

和通常腳手架不一樣的是,CRA 會在建立項目時新建立一個 package.json,而不是直接複製代碼模板的文件。

const packageJson = {
  name: appName,
  version: '0.1.0',
  private: true,
};
fs.writeFileSync(
  path.join(root, 'package.json'),
  JSON.stringify(packageJson, null, 2) + os.EOL
);

6. 選擇模板

function getTemplateInstallPackage(template, originalDirectory) {
  let templateToInstall = 'cra-template';
  if (template) {
    // 一些處理邏輯 doTemplate(template);
    templateToInstall = doTemplate(template);
  }
  return Promise.resolve(templateToInstall);
}

默認使用 cra-template 模板,若是傳入 template 參數,則使用對用的模板,該方法主要是給額外的 templatescopeprefix,好比 @scope/cra-template-${template},具體邏輯不展開。

這裏 CRA  的核心思想是經過 npm 來對模板進行管理,這樣方便擴展和管理。

7. 安裝依賴

CRA 會自動給項目安裝 reactreact-domreact-scripts 以及模板。

command = 'npm';
args = [
  'install',
  '--save',
  '--save-exact',
  '--loglevel',
  'error',
].concat(dependencies);

const child = spawn(command, args, { stdio: 'inherit' });

8. 初始化代碼

CRA 的功能其實很少,安裝完依賴以後,實際上初始化代碼的工做還沒作。

接着往下看,看到這樣一段代碼代碼:

await executeNodeScript(
  {
    cwd: process.cwd(),
  },
  [root, appName, verbose, originalDirectory, templateName],
  `
var init = require('${packageName}/scripts/init.js');
init.apply(null, JSON.parse(process.argv[1]));
`
);

除此以外,CRA 貌似看不到任何複製代碼的代碼了,那咱們須要的「初始化代碼」的工做應該就是在這裏完成了。

爲了分析方便,忽略了上下文代碼,說明一下,這段代碼中的 packageName 的值是 react-scripts。也就是這裏執行了 react-scripts 包中的 scripts/init 方法,並傳入了幾個參數。

8.1 react-scripts/init.js

老規矩,只分析主流程代碼,主流程主要就作了四件事:

  1. 處理 template 裏的 packages.json
  2. 處理 package.jsonscripts:默認值和 template 合併
  3. 寫入 package.json
  4. 拷貝 template 文件

除此以外還有一些 gitnpm 相關的操做,這裏就不展開了。

// init.js
// 刪除了不影響主流程的代碼
module.exports = function (
  appPath,
  appName,
  verbose,
  originalDirectory,
  templateName
) {
  const appPackage = require(path.join(appPath, 'package.json'));

  // 經過一些判斷來處理 template 中的 package.json
  // 返回 templatePackage

  const templateScripts = templatePackage.scripts || {};

  // 修改實際 package.json 中的 scripts
  // start、build、test 和 eject 是默認的命令,若是模板裏還有其它 script 就 merge
  appPackage.scripts = Object.assign(
    {
      start: 'react-scripts start',
      build: 'react-scripts build',
      test: 'react-scripts test',
      eject: 'react-scripts eject',
    },
    templateScripts
  );

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

  // 拷貝 template 文件
  const templateDir = path.join(templatePath, 'template');
  if (fs.existsSync(templateDir)) {
    fs.copySync(templateDir, appPath);
  }
};

到這裏,CRA 的主流程就基本走完了,關於 react-scripts 的命令,好比 startbuild,後續會單獨有文章進行講解。

9. 從 CRA 中借鑑的工具方法

CRA 的代碼和思路其實並不複雜,可是不影響咱們讀它的代碼,而且從中學習到一些好的想法。(固然,有一些代碼咱們也是能夠拿來直接用的 ~

9.1 npm 相關

9.1.1 獲取 npm 包版本號

const https = require('https');

function getDistTags(pkgName) {
  return new Promise((resolve, reject) => {
    https
      .get(
        `https://registry.npmjs.org/-/package/${pkgName}/dist-tags`,
        res => {
          if (res.statusCode === 200) {
            let body = '';
            res.on('data', data => (body += data));
            res.on('end', () => {
              resolve(JSON.parse(body));
            });
          } else {
            reject();
          }
        }
      )
      .on('error', () => {
        reject();
      });
  });
}

// 獲取 react 的版本信息
getDistTags('react').then(res => {
  const tags = Object.keys(res);
  console.log(tags); // ['latest', 'next', 'experimental', 'untagged']
  console.log(res.latest]); // 17.0.1
});

9.1.2 比較 npm 包版本號

使用 semver 包來判斷某個 npm 的版本號是否符合你的要求:

const semver = require('semver');

semver.gt('1.2.3', '9.8.7');   // false
semver.lt('1.2.3', '9.8.7');   // true
semver.minVersion('>=1.0.0');  // '1.0.0'

9.1.3 檢查 npm 包名

能夠經過 validate-npm-package-name 來檢查包名是否符合 npm 的命名規範。

const validateProjectName = require('validate-npm-package-name');

const validationResult = validateProjectName(appName);

if (!validationResult.validForNewPackages) {
  console.error('npm naming restrictions');
  // 輸出不符合規範的 issue
  [
    ...(validationResult.errors || []),
    ...(validationResult.warnings || []),
  ].forEach(error => {
    console.error(error);
  });
}

對應的 npm 命名規範能夠見:Naming Rules

9.2 git 相關

9.2.1 判斷本地目錄是不是一個 git 倉庫

const execSync = require('child_process').execSync;

function isInGitRepository() {
  try {
    execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
    return true;
  } catch (e) {
    return false;
  }
}

9.2.2 git init

腳手架初始化代碼以後,正常的研發鏈路都但願可以將本地代碼提交到 git 進行託管。在這以前,就須要先對本地目錄進行 init

const execSync = require('child_process').execSync;

function tryGitInit() {
  try {
    execSync('git --version', { stdio: 'ignore' });
    if (isInGitRepository()) {
      return false;
    }
    execSync('git init', { stdio: 'ignore' });
    return true;
  } catch (e) {
    console.warn('Git repo not initialized', e);
    return false;
  }
}

9.2.3 git commit

對本地目錄執行 git commit

function tryGitCommit(appPath) {
  try {
    execSync('git add -A', { stdio: 'ignore' });
    execSync('git commit -m "Initialize project using Create React App"', {
      stdio: 'ignore',
    });
    return true;
  } catch (e) {
    // We couldn't commit in already initialized git repo,
    // maybe the commit author config is not set.
    // In the future, we might supply our own committer
    // like Ember CLI does, but for now, let's just
    // remove the Git files to avoid a half-done state.
    console.warn('Git commit not created', e);
    console.warn('Removing .git directory...');
    try {
      // unlinkSync() doesn't work on directories.
      fs.removeSync(path.join(appPath, '.git'));
    } catch (removeErr) {
      // Ignore.
    }
    return false;
  }
}

10. 總結

回到 CRA,看完本文,對於 CRA 的思想可能有了個大體瞭解:

  1. CRA  是一個通用的 React  腳手架,它支持自定義模板的初始化。將模板代碼託管在 npm  上,而不是傳統的經過 git  來託管模板代碼,這樣方便擴展和管理
  2. CRA  只負責核心依賴、模板的安裝和腳手架的核心功能,具體初始化代碼的工做交給 react-scripts  這個包

可是具體細節上它是如何作的這個我沒有詳細的闡述,若是感興趣的同窗能夠自行下載其源碼閱讀。推薦閱讀源碼流程:

  1. 看它的單測
  2. 一步一步 debug 它
  3. 看源碼細節

我的原創技術文章會同步更新在公衆號 玩相機的程序員 上,歡迎你們關注。我是 axuebin,用鍵盤和相機記錄生活。

相關文章
相關標籤/搜索