fi3ework / blog

📝
861 stars 51 forks source link

create-react-app 原理及源码分析 #38

Open fi3ework opened 6 years ago

fi3ework commented 6 years ago

wip

fi3ework commented 6 years ago

分析

其实 create-react-app(以下简称 cra) 可以分为两个部分,react-scripts 的及剩下的部分,剩下的部分我称为项目初始化部分。顾名思义,项目初始化部分就是我们在命令行中输入 crate-react-app project-name 到结束所做的所有事情,而 react-scripts 负责了启动 npm start, npm eject, npm test, npm build 这些命令的 Webpack 配置及报错位置提示等等。

详细来说,项目初始化部分做了:

  1. 初始化 package.json
  2. 安装所需的包
  3. 将 react 的模板代码及 README 等复制到项目中

react-scripts 中做了:

  1. start:各种 Webpack 的配置,包括 HMR,错误提示,自动打开浏览器等。
  2. eject:将本来通过代码配置的 Webpack 配置弹出到 webpack.config 中。
  3. build:执行 webpack 的构建。
  4. test:执行 Jest 的测试。

其实 cra 最精髓的部分是 react-scripts,它赋予了我们在开发时的极佳体验,也占了整个包大部分的代码;至于项目构建部分其实难度不大,就是一步步像流水线一样对命令行及文件进行操作,但是由于 node,npm,yarn,网络,文件夹情况等各种环境的干扰,整个流畅要各种防御式编程并设计的非常鲁棒。

调试方法

使用 VSCode 进行调试,create-react-app 的入口为 index.js,附加一个要创建的项目的名字即可,配置如下

{
  // 使用 IntelliSense 了解相关属性。
  // 悬停以查看现有属性的描述。
  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "init",
      "program": "${workspaceFolder}/packages/create-react-app/index.js",
      "args": ["test-app"]
    },
    {
      "type": "node",
      "request": "launch",
      "name": "start",
      "program":
        "${workspaceFolder}/test-app/node_modules/react-scripts/bin/react-scripts.js",
      "args": ["start"]
    }
  ]
}

初始化流程

下图是初始化工程的流程图(就是 create-react-app myapp 之后执行的事),整个流程为了健壮期间进行了很多的判断,这里是列出了比较主要的,整个 pipeline 很清晰明确,大量的代码都集中在了各种判断上来保证 cra 是一个 "battle tested" cli,具体源码的细节可以参见我写了注释的版本:???

启动开发模式

react-scrits 中 start.js 的代码量不多,因为主要都是 Webpack 的配置,这些都集中在了 react-dev-utils 这个独立的包中,后面会着重分析,start.js 仅仅是调用并串起整个流程:

image

react-dev-utils 源码分析

react-dev-utils 中承载了 start、build、eject 中的主要逻辑,其文件目录及作用如下:

  1. checkRequiredFiles.js:同步的检验传入的文件是否都可以存在,返回一个 bool 值。
  2. clearConsole.js:跨平台的清空控制台。
  3. crossSpawn.js:导出 cross-spawn,跨平台的创建进程。
  4. errorOverlayMiddleware.js:可以直接通过报错打开本地的编辑器并跳转到对应的位置,在 webpackDevServer 的 before 钩子上。
  5. eslintFormatter.js:给 Webpack 配置 cra 默认的 eslint 配置,在对警告/错误的信息做了美化。
  6. FileSizeReporter.js:统计 build 前后文件的大小。
  7. formatWebpackMessages.js:通过 Webpack 的 stats 来输出一个更好的警告和错误提示。
  8. getProcessForPort.js:获得当前项目运行的线程的端口,并返回包含 localhost 和 ip 的字符串。
  9. ignoredFiles.js:返回一个忽略 node_modules 的正则表达式。
  10. inquirer.js:导出 inquirer,命令行交互的库。
  11. InterpolateHtmlPlugin.js:webpack 插件,可以向 html 中插入全局变量,需要与 HtmlWebpackPlugin 配合使用。
  12. launchEditor.js:启动本地的编辑器。
  13. launchEditorEndpoint.js:配合 launchEditor.js 使用的一个参数。
  14. ModuleScopePlugin.js:Webpack 插件,报应引入的模块不会包含 src 之外的文件。
  15. noopServiceWorkerMiddleware.js:避免在开发环境中使用生产版本到的 /service-worker.js,返回一个使用重置的 service worker 配置。
  16. openBrowser.js:在浏览器中打开指定的 url。
  17. openChrome.applescript:在 openBrowser.js 调用,在 macOS 中使用 applescript 脚本打开浏览器。
  18. printBuildError.js:输出更好的 build 时的报错提示。
  19. printHostingInstructions.js:Prints hosting instructions after the project is built.
  20. WatchMissingNodeModulesPlugin.js:如果你先引入一个还没有安装的包再 npm install,webpack 无法检测到这个包,需要重启。这个插件可以让 webpack 来检测到这个包。
  21. WebpackDevServerUtils.js:webpackDevServer 的配置入口。
  22. webpackHotDevClient.js:cra 自己实现的一个 WebpackDevServer 的 client 端。

有些模块其实不难,但是涉及到很多琐碎的知识,在这里不对所有的模块进行分析,只分析一些比较复杂的模块。

WatchMissingNodeModulesPlugin

代码并不长

'use strict';

class WatchMissingNodeModulesPlugin {
  constructor(nodeModulesPath) {
    this.nodeModulesPath = nodeModulesPath;
  }

  apply(compiler) {
    compiler.plugin('emit', (compilation, callback) => {
      var missingDeps = compilation.missingDependencies;
      var nodeModulesPath = this.nodeModulesPath;

      // If any missing files are expected to appear in node_modules...
      if (missingDeps.some(file => file.indexOf(nodeModulesPath) !== -1)) {
        // ...tell webpack to watch node_modules recursively until they appear.
        compilation.contextDependencies.push(nodeModulesPath);
      }

      callback();
    });
  }
}

module.exports = WatchMissingNodeModulesPlugin;

一个标准的 webpack 插件写法,通过实现 apply 方法调用 emit 这个钩子函数,emit 的时间点是“在生成资源并输出到目录之前“,???为什么是 emit,插入到 contextDependencies ,contextDependencies 是一个保存依赖的绝对路径的数组,也就是在 emit 时检测如果有丢失的依赖那么给 compilation 补充上。

webpackHotDevClient

cra 使用了自己的 webpackDevClient,提供了包括:

  1. 更好的 react 内部报错提示界面
  2. 可以直接打开本地编辑器并定位到报错位置

webpackDevServer 的原理简单来说就是 webpack 作为 server 为每一次代码更改带来的编译会向 client 通信,webpackHotDevClient.js 这个文件的代码是跑在浏览器中,通过 socket 和 server 通信。

var connection = new SockJS(
  url.format({
    protocol: window.location.protocol,
    hostname: window.location.hostname,
    port: window.location.port,
    // Hardcoded in WebpackDevServer
    pathname: "/sockjs-node"
  })
);

然后根据 server 传过来的 message 决定更新策略

// 接收 server 发过来的信号
connection.onmessage = function(e) {
  var message = JSON.parse(e.data);
  switch (message.type) {
    case "hash": // 更新 hash
      handleAvailableHash(message.data);
      break;
    case "still-ok": // 所有更新的代码是否已经被编译到本地
    case "ok":
      handleSuccess();
      break;
    case "content-changed": // contentBase 更新时直接 reload 浏览器,与 HMR 无关
      // Triggered when a file from `contentBase` changed.
      window.location.reload();
      break;
    case "warnings": // 编译遇到 warning
      handleWarnings(message.data);
      break;
    case "errors": // 编译遇到 error
      handleErrors(message.data);
      break;
    default:
    // Do nothing.
  }
};

策略分为 hash, still-ok, ok, content-changed, warnings, errors,这些信号是由 webpack 的 server 发送的,配合 server 的 源码 还有 官方文档,在除了 errors 的每种情况下,都会标记 isFirstCompilation 为 true,以便在后续更新中启用热更新。

以下是 server 的源码:

Server.prototype._sendStats = function (sockets, stats, force) {
  if (
    !force &&
    stats &&
    (!stats.errors || stats.errors.length === 0) &&
    stats.assets &&
    stats.assets.every(asset => !asset.emitted)
  ) {
    return this.sockWrite(sockets, 'still-ok');
  }

  this.sockWrite(sockets, 'hash', stats.hash);

  if (stats.errors.length > 0) {
    this.sockWrite(sockets, 'errors', stats.errors);
  } else if (stats.warnings.length > 0) {
    this.sockWrite(sockets, 'warnings', stats.warnings);
  } else {
    this.sockWrite(sockets, 'ok');
  }
};

一个一个来看:

重点说一下 ok 和 still-ok,如果是这两种情况,会执行 tryApplyUpdates

// Attempt to update code on the fly, fall back to a hard reload.
function tryApplyUpdates(onHotUpdateSuccess) {
  if (!module.hot) {
    // HotModuleReplacementPlugin is not in Webpack configuration.
    window.location.reload(); // 没开启热更新,直接刷新
    return;
  }

  // 只更新当前最新版本的 compilation || webpack 热更新模块状态为 idle
  if (!isUpdateAvailable() || !canApplyUpdates()) {
    return;
  }

  // check 的回调入口
  function handleApplyUpdates(err, updatedModules) {
    // 如果编译报错则直接刷新页面
    if (err || !updatedModules || hadRuntimeError) {
      window.location.reload();
      return;
    }

    // 主要目的就是引入 onHotUpdateSuccess
    if (typeof onHotUpdateSuccess === "function") {
      // Maybe we want to do something.
      onHotUpdateSuccess();
    }

    // 在更新期间又来了更新,则再执行一次
    if (isUpdateAvailable()) {
      // While we were updating, there was a new update! Do it again.
      tryApplyUpdates();
    }
  }

  // https://webpack.github.io/docs/hot-module-replacement.html#check
  // A check makes an HTTP request to the update manifest. If this request fails, there is no update available.
  // If it succeeds, the list of updated chunks is compared to the list of currently loaded chunks.For each loaded chunk,
  // the corresponding update chunk is downloaded.All module updates are stored in the runtime.
  // When all update chunks have been downloaded and are ready to be applied, the runtime switches into the ready state.
  // 热更新完成时触发回调
  var result = module.hot.check(/* autoApply */ true, handleApplyUpdates);

  // // Webpack 2 returns a Promise instead of invoking a callback
  if (result && result.then) {
    result.then(
      function(updatedModules) {
        handleApplyUpdates(null, updatedModules);
      },
      function(err) {
        handleApplyUpdates(err, null);
      }
    );
  }
}

tryApplyUpdates 主要目的就是为热更新完成时引入回调函数,在成功时,清除之前的编译报错信息

  if (isHotUpdate) {
    tryApplyUpdates(function onHotUpdateSuccess() {
      // 只要不是第一次编译就尝试热更新
      // Only dismiss it when we're sure it's a hot update.
      // Otherwise it would flicker right before the reload.
      ErrorOverlay.dismissBuildError();
    });
  }

有警告时,显示警告并清除之前的报错信息

  if (isHotUpdate) {
    tryApplyUpdates(function onSuccessfulHotUpdate() {
      // Only print warnings if we aren't refreshing the page.
      // Otherwise they'll disappear right away anyway.
      printWarnings();
      // Only dismiss it when we're sure it's a hot update.
      // Otherwise it would flicker right before the reload.
      ErrorOverlay.dismissBuildError();
    });
  } else {
    // Print initial warnings immediately.
    printWarnings();
  }

参考

zhangyouxin commented 4 years ago

干的漂亮