Skip to content
This repository has been archived by the owner on Aug 7, 2024. It is now read-only.

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

Open
fi3ework opened this issue Sep 1, 2018 · 2 comments
Open

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

fi3ework opened this issue Sep 1, 2018 · 2 comments
Labels

Comments

@fi3ework
Copy link
Owner

fi3ework commented Sep 1, 2018

wip

@fi3ework fi3ework added the React label Sep 1, 2018
@fi3ework
Copy link
Owner Author

fi3ework commented Sep 1, 2018

分析

其实 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');
  }
};

一个一个来看:

  • still-ok:如果所有需要编译的模块都已经编译到了输出目录,则 still-ok
  • hash:记录当次编译的 hash
  • errors:本次编译的报错信息
  • warnings:本次编译的警告信息
  • ok:如果当前次的钩子没有问题则 ok
  • content-changed:如果文件的 contentBase 改变

重点说一下 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
Copy link

干的漂亮

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

No branches or pull requests

2 participants