create-react-app 核心思路分析


原文鏈接:http://axuebin.com/articles/fe-solution/cli/cra.html,轉載請聯系

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 官方創建單頁應用的方式,為了方便,下文皆簡稱 CRA

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

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

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

按照自己的理解,畫了個流程圖,大家可以帶着該流程圖去閱讀源碼(主要包含兩個部分 create-react-appreact-scripts/init):

如果圖片不清晰可以微信搜索公眾號 玩相機的程序員,回復 CRA 獲取。

0. 用法

CRA 的用法很簡單,兩步:

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

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

現在更推薦的用法是使用 npx 來執行 create react app

npx create-react-app my-app

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

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

默認情況下,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. 看源碼細節


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM