create-react-app 源碼解析之react-scripts

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

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

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

推薦你們看第一篇的 項目初始化斷點調試 部分,這裏就不在贅述。傳送門 項目初始化斷點調試部分java

準備階段

這裏咱們討論的create-react-app版本依舊是v1.1.4node

既然這篇咱們主要講的是 create-react-app裏面的webpack服務,那咱們確定要先新建一個項目。react

  • 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"webpack

那麼這個 react-scripts 命令究竟是哪個呢?git

通常寫在 package.json=> scripts 的命令,都會先去 project_path(項目目錄)/node_modules/.bin 查找,找不到再找全局安裝的包。github

那麼 node_modules/.bin 裏面的文件又是怎麼來的呢? 咱們若是在包的 package.json 加上 bin 字段,npm就會自動幫咱們映射到 node_modules/.bin 裏面 npm bin文檔傳送門web

咱們直接打開 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 控制終端轉義和控制序列(轉) - 木瓜腦殼 - 博客園

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

相關文章
相關標籤/搜索