create-react-app 原始碼解析之react-scripts

Jsonz發表於2018-05-14

本文連結:jsonz1993.github.io/2018/05/cre…

上一篇我們已經講了 create-react-app裡面建立package.json安裝依賴並且拷貝可執行的demo等步驟。傳送門

這一篇我們來講一下 create-react-app裡面的啟動服務等部分,就是平時我們安裝完依賴之後,啟動開發服務:npm start。這一塊涉及到太多關於webpack與配置的東西,加上第一篇覺得描述的太過冗餘~所以這篇不會講得很細,只是大概把他運轉的邏輯思路寫出來,具體原始碼會提供傳送門。

推薦大家看第一篇的 專案初始化斷點除錯 部分,這裡就不在贅述。傳送門 專案初始化斷點除錯部分

準備階段

這裡我們討論的create-react-app版本依舊是v1.1.4

既然這篇我們主要講的是 create-react-app裡面的webpack服務,那我們肯定要先新建一個專案。

  • npm install create-react-app -g 全域性安裝create-react-app
  • create-react-app my-react-project 用create-react-app新建一個專案
  cd my-react-project
  yarn start
複製程式碼

新建完之後,終端提示了我們直接進入專案,跑 yarn(npm) start 就可以開發了。我們開啟 package.json就可以看到 yarn start 跑的命令是 "react-scripts start"

那麼這個 react-scripts 命令到底是哪一個呢?

一般寫在 package.json=> scripts 的命令,都會先去 project_path(專案目錄)/node_modules/.bin 查詢,找不到再找全域性安裝的包。

那麼 node_modules/.bin 裡面的檔案又是怎麼來的呢? 我們如果在包的 package.json 加上 bin 欄位,npm就會自動幫我們對映到 node_modules/.bin 裡面 npm bin文件傳送門

我們直接開啟 node_modules/react-scripts/package.json 能看到這麼一行"react-scripts": "./bin/react-scripts.js",直接把命令指向node_modules/react-scripts/.bin/react-scripts.js,也驗證了我們的觀點。

還記得上一篇,我們在 create-react-app/packages 裡面發現了有一個 react-scripts。其實是同一個東西來的,那麼接下來的步驟就很明確了,直接用老辦法,改下配置,然後用vscode跑斷點除錯閱讀project_path/node_modules/react-scripts/.bin/react-scripts.js的原始碼 一探究竟。

vscode launch.json

這裡我們傳入 start 作為引數,模擬在專案裡跑 yarn start 的效果。

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "啟動程式",
      "program": "${workspaceFolder}/node_modules/react-scripts/bin/react-scripts.js", //除錯的檔案路徑
      "args": [
        "start" // 傳入 start 做為引數
      ]
    }
  ]
}
複製程式碼

ps:下面的react-scripts沒有特殊說明,都代表project_path/node_modules/react-scripts目錄方便閱讀

react-scripts/.bin/react-scripts.js

檔案傳送門 這裡我們還是老辦法,先不看依賴 看主流程理解先,我們能看到這個檔案也是一個入口檔案,非常簡短。

const args = process.argv.slice(2);

const scriptIndex = args.findIndex(
  x => x === 'build' || x === 'eject' || x === 'start' || x === 'test'
);
const script = scriptIndex === -1 ? args[0] : args[scriptIndex];
const nodeArgs = scriptIndex > 0 ? args.slice(0, scriptIndex) : [];
複製程式碼

首先處理傳進來的引數,用script變數來獲取我們跑的命令是哪一個,有['build', 'eject', 'start', 'test']這麼幾種,分別對應 構建、暴露配置、開發、測試命令。 然後再獲取一起傳入的其他的引數,比如npm test命令就會帶一個額外的引數--env=jsdom

switch (script) {
  case 'build':
  case 'eject':
  case 'start':
  case 'test': {
    // 用 cross-spawn 去跑一個同步的命令
    // 根據傳入的命令來拼接對應的路徑 用node去跑
    const result = spawn.sync(
      'node',
      nodeArgs
        .concat(require.resolve('../scripts/' + script))
        .concat(args.slice(scriptIndex + 1)),
      { stdio: 'inherit' }
    );
    if (result.signal) {
      if (result.signal === 'SIGKILL') {
        // 輸出錯誤提醒日誌
      } else if (result.signal === 'SIGTERM') {
        // 輸出錯誤提醒日誌
      }
      process.exit(1); // 退出程式, 傳1代表有錯誤
    }
    process.exit(result.status);
    break;
  }
  default:
    // 這裡輸出匹配不到對應的命令
    break;
}
複製程式碼

然後根據獲取到的命令,對應到react-scripts/scripts下面的檔案去跑,比如 react-scripts start 就會去跑 react-scripts/scripts/start.js

這裡插幾句講一下一個專案上比較常見的類庫解耦方式,我們可以看到這裡的 spawn引用的是react-dev-utils/crossSpawn。而在react-dev-utils/corssSpawn裡面也只是簡簡單單的幾句,引入cross-spawn再把cross-spawn暴露出去。 但是這麼寫就可以起到類庫解耦的作用,比如以後這個庫被爆出有重大的bug或者停止維護了,直接修改這個檔案引入其他的類庫,其他引用該檔案的程式碼就不需要改動。

// react-dev-utils/corssSpawn
'use strict';

var crossSpawn = require('cross-spawn');

module.exports = crossSpawn;
複製程式碼

react-scripts/scripts/start.js

檔案傳送門

看過第一篇的人對這個資料夾應該不陌生,create-react-app 在安裝完 react 等依賴之後,就會跑這個資料夾下面的init.js來拷貝模版檔案,修改package.json等操作。

既然我們已經知道他要執行 start.js, 接下來我們把vscode除錯檔案修改為 start.js 檔案"program": "${workspaceFolder}/node_modules/react-scripts/scripts/start.js", 之所以要修改是因為他這裡不是引用js檔案來執行,而是用終端來跑,所以不屬於我們的專案除錯範圍~

process.env.BABEL_ENV = 'development';
process.env.NODE_ENV = 'development';

// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
  throw err;
});
複製程式碼

檔案的最開頭設定了兩個環境變數,因為 start 是用來跑開發的,所以這裡的環境變數都是 development,然後再給 process 繫結一個錯誤監聽函式,這個錯誤監聽實質上是用來監聽 一些沒有被.catch的Promise具體可以看node的文件, 關於 Promise可以看一下之前寫過的一篇介紹Promise的文章從用法和實現原理都有所涉及

接著引進一個 ../config/env, 看檔名猜應該是做一些關於環境配置的事情,找到檔案斷點進來

react-scripts/config/env.js

const fs = require('fs');
const path = require('path');
const paths = require('./paths');
// Make sure that including paths.js after env.js will read .env variables.
delete require.cache[require.resolve('./paths')];
複製程式碼

env.js 檔案在引入 ./paths.js 之後,立即把他從cache中刪除掉,這樣下次如果有其他的模組引入paths.js,就不會從快取裡面去獲取,保證了paths.js裡面執行邏輯都會用到最新的環境變數。

var dotenvFiles = [
  // 舉個例子:第一個元素在我的電腦路徑是這樣的 Users/jsonz/Documents/my-react-project/.env.development.local.js
  `${paths.dotenv}.${NODE_ENV}.local`,
  `${paths.dotenv}.${NODE_ENV}`,
  NODE_ENV !== 'test' && `${paths.dotenv}.local`,
  paths.dotenv,
].filter(Boolean);
複製程式碼

然後再根據paths給出的地址去拿其他的環境變數,這裡paths.js會根據不同的情況給出不同的路徑,我們討論的是正常的建立專案情況。 其他的幾種情況有:

  1. 我們在已經建立的專案跑了 npm(yarn) eject,這時候 react-scripts會把配置都暴露到 project_path/config 方便我們去根據專案修改配置,這個操作是不可逆的。
  2. 我們正常建立專案,直接跑專案,這時候配置就存放在project/node_modules/react-scripts
  3. 開發人員自己除錯用的,這時候配置存放在create-react/packages/react-scripts/config

拼裝完路徑之後,用dotenv-expanddotenv來把檔案裡面的環境變數載入進來,這一塊一般場景用不上。

function getClientEnvironment(publicUrl) {
  const raw = Object.keys(process.env)
    .filter(key => REACT_APP.test(key))
    .reduce(
      (env, key) => {
        env[key] = process.env[key];
        return env;
      },
      {
        NODE_ENV: process.env.NODE_ENV || 'development',
      }
    );
  const stringified = {
    'process.env': Object.keys(raw).reduce((env, key) => {
      env[key] = JSON.stringify(raw[key]);
      return env;
    }, {}),
  };
  return { raw, stringified };
}
複製程式碼

然後返回一個 getClientEnvironment函式,這個函式執行後會返回客戶端的環境變數。

react-scripts/scripts/start.js(2)

const fs = require('fs');
const chalk = require('chalk');
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const clearConsole = require('react-dev-utils/clearConsole');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const {
  choosePort,
  createCompiler,
  prepareProxy,
  prepareUrls,
} = require('react-dev-utils/WebpackDevServerUtils');
const openBrowser = require('react-dev-utils/openBrowser');
const paths = require('../config/paths');
const config = require('../config/webpack.config.dev');
const createDevServerConfig = require('../config/webpackDevServer.config');

const useYarn = fs.existsSync(paths.yarnLockFile);
const isInteractive = process.stdout.isTTY;
複製程式碼

載入完各種環境變數之後,我們回到react-scripts/scripts/start.js,老規矩,一系列的依賴先跳過不看,後面用到再來看。 還記得我們在env.js裡面delet掉node.catch嗎,這裡conts paths = require('../config/paths)就不會從快取裡面去拿而是重新去載入。

if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
  process.exit(1);
}
複製程式碼

先判斷一下我們兩個入口檔案有沒有存在,分別是project_path/public/index.htmlproject_path/src/index.js,如果不存在給出提示結束程式。

const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000;
const HOST = process.env.HOST || '0.0.0.0';
複製程式碼

然後設定預設的埠和host,如果有特殊的需求,可以從環境變數傳進去改變,沒有就會用預設的3000埠。

choosePort(HOST, DEFAULT_PORT).then(...) // @return Promise
複製程式碼

設定完預設的埠與host之後,開始判斷這個埠有沒有被其他的程式佔用,有的話會提供下一個可用的埠,我們順著choosePort去檔案頭找依賴,找到該方法位於依賴react-dev-utils/WebpackDevServerUtils

function choosePort(host, defaultPort) {
  return detect(defaultPort, host).then(
    port =>
      new Promise(resolve => {
        if (port === defaultPort) {
          return resolve(port);
        }
        const message =
          process.platform !== 'win32' && defaultPort < 1024 && !isRoot()
            ? `Admin permissions are required to run a server on a port below 1024.`
            : `Something is already running on port ${defaultPort}.`;
        if (isInteractive) {
          clearConsole();
          const existingProcess = getProcessForPort(defaultPort);
          const question = {
            type: 'confirm',
            name: 'shouldChangePort',
            message:
              chalk.yellow(
                message +
                  `${existingProcess ? ` Probably:\n  ${existingProcess}` : ''}`
              ) + '\n\nWould you like to run the app on another port instead?',
            default: true,
          };
          inquirer.prompt(question).then(answer => {
            if (answer.shouldChangePort) {
              resolve(port);
            } else {
              resolve(null);
            }
          });
        } else {
          console.log(chalk.red(message));
          resolve(null);
        }
      }),
    err => {
      // 輸出錯誤日誌
    }
  );
}
複製程式碼

choosePort 裡面用到detect-port-alt去檢測埠占用,如果被佔用了返回一個最接近的遞增方向可用的埠,比如3000埠被佔用,3001沒被佔用就返回回來。 如果發現返回的可用埠不是預設的埠,給出一個互動式的命令詢問使用者是否要換一個埠去訪問,互動式命令用的是inquirer這個包。 這裡如果用vsCode來除錯,process.stdout.isTTY 返回的值是undefined。所以如果要測試這一塊互動式命令,只能切回系統的終端去除錯~

互動式命令詢問是否切換埠
互動式命令詢問是否切換埠

檔案傳送門 檢測完可用埠之後,回到start.js

前端處理一堆環境變數,還有載入一堆配置,全都用在這一塊。這裡主要做的就是把環境變數和配置組裝起來,開個webpack本地除錯服務。主要做的事情有:

  1. 如果沒有找到可用的埠直接返回不繼續執行下去
  2. 根據環境變數判斷是否啟用https,預設是http
  3. 根據 host, protocol, port 拼裝一系列的url,包括Browser的url與Terminal的url。
  4. 呼叫createCompiler 傳入webpack,webpack配置,appName,第三步獲取的url,還有是否使用Yarn等引數,生成一個 webpackCompiler。createCompiler負責的東西有: 4.1 根據環境變數判斷是否有冒煙測試的需求,如果有加一個 handleCompile,一有錯誤就中斷程式。 4.2 用傳進來的配置和handleCompile生成一個webpackCompiler 4.2 增加invalid鉤子,一檢測到更改檔案,而且是互動式終端的話,先清空控制檯,再輸出日誌 4.3 增加done鉤子,對webpack的輸出日誌整理統一輸出
  5. 建立開發服務配置 具體的配置程式碼放在webpackDevServer.config.js
  6. 把4和5丟給 WebpackDevServer,生成一個 webpack 本地開發服務
  7. 大功告成,清除螢幕,開啟除錯連線

相關的程式碼執行寫到註釋裡面去了,沒辦法每個方法配置都拎出來講...不然篇幅會很長,這裡面很多點一講都可以是一個知識點。

choosePort(HOST, DEFAULT_PORT)
  .then(port => {
    // 沒有找到可用埠,直接return
    if (port == null) {
      return;
    }
    // 根據環境變數判斷是否要用https
    const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
    const appName = require(paths.appPackageJson).name;
    // 獲取當前的 host, port, protocol 生成一系列url
    const urls = prepareUrls(protocol, HOST, port);
    // 建立一個webpack compiler
    const compiler = createCompiler(webpack, config, appName, urls, useYarn);
    // 載入代理的配置,在 project_path/package.json 裡面載入配置
    const proxySetting = require(paths.appPackageJson).proxy;
    const proxyConfig = prepareProxy(proxySetting, paths.appPublic);
    // 生成 webpack dev server 的配置
    const serverConfig = createDevServerConfig(
      proxyConfig,
      urls.lanUrlForConfig
    );
    const devServer = new WebpackDevServer(compiler, serverConfig);
    // 監聽 devServer
    devServer.listen(port, HOST, err => {
      // 一些日誌輸出
      // 自動用預設瀏覽器開啟除錯連結
      openBrowser(urls.localUrlForBrowser);
    });
  })
  .catch(err => {
    // 錯誤處理
  });
複製程式碼

react-dev-utils/WebpackDevServerUtils.js

function createCompiler(webpack, config, appName, urls, useYarn) {
  let compiler;
  try {
    compiler = webpack(config, handleCompile); // handleCompile為冒煙測試的對應處理
  } catch (err) {
    // 錯誤提示
  }

  compiler.plugin('invalid', () => {
    // invalid 鉤子,如果當前處於TTY終端,那麼先清除控制檯再輸出 Compiling...
    if (isInteractive) {
      clearConsole();
    }
    console.log('Compiling...');
  });

  let isFirstCompile = true;

  compiler.plugin('done', stats => {
    // 監聽了 done 事件,對輸出的日誌做了格式化輸出
    // 正常情況下會直接輸出 `Compiled successfully!`
    // 如果有錯誤則輸出錯誤資訊,這裡對錯誤資訊做一些處理,讓其輸出比較友好
  });
  return compiler;
}
複製程式碼

輸出日誌格式化處理
輸出日誌統一格式化處理

最後講兩句

之前就一直好奇,這些腳手架是怎麼清空我們的終端螢幕的。在看create-react-app的時候,瞄到有這麼一個檔案react-dev-utils/clearConsole.js。這個檔案十分剪短,核心程式碼就那麼一句:

react-dev-utils/clearConsole.js

process.stdout.write(process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H');
複製程式碼

然後好奇心特別重,不知道後面兩串是什麼意思,一直搜沒有找到想要的答案。

問了身邊的同事,說是十六進位制,而在我狹隘的認知裡面一直以為十六進位制只能轉成數字....但是定睛一看,這有個J 明顯不是十六進位制。

一個女裝大佬和我說這是ASCII碼,百度了一下ASCII碼,看了 \x1B ASCII對應到 ESC。 但是後面的 [2J [3J [H 是什麼意思還是不清楚... 後面大佬又和我說找到可能是 Linux ANSI 控制碼 找來找去折騰了挺久的後面才揭開神祕面紗~

這幾個命令大概的意思是: [2J 清除控制檯 [H 將游標移至最頂部 [3J還是沒有找到,應該是更高階的系統層級的清除控制檯

給出幾個 Linux ANSI 控制碼資料網站有興趣可以自行了解一下作為知識儲備

Ubuntu Manpage: 控制終端程式碼 - Linux 控制終端轉義和控制序列

控制終端程式碼 - Linux 控制終端轉義和控制序列(轉) - 木瓜腦袋 - 部落格園

最後前端的小夥伴很多和我一樣不是科班出身的,真的得加把勁補補一些計算機比較原理性或比較接近系統層級的知識~

相關文章