879479119.github.io icon indicating copy to clipboard operation
879479119.github.io copied to clipboard

聊一聊webpack-dev-server和其中socket,HMR的实现

Open 879479119 opened this issue 6 years ago • 6 comments

上一次说了webpack打包的原理,但是仅仅是打包而已,没有涉及到服务器和中间件还有热加载相关的东西,这次就来聊一聊

我们写这篇博客是有一个目标的,就是想着把dev-server应用到rollup上面重新实现一次,不过碍于二者的打包方式以及输出资源的方式都有所不同,这里我们就先看看dev-server源代码的执行方式,看搞清楚他们的原理之后会不会有方法将他们组合起来

  • webpack-dev-server简写为DS
  • webpack-dev-middleware简写为DM
  • webpack-hot-middleware简写为HM
  • EventEmitter简写为EE

目标

  • 搞清楚dev-server中使用express做了哪些事
  • express和Socket是如何和谐相处的
  • HM是怎样进行模块的热处理的
  • express的中间件系统是怎样的,和koa的中间件系统有什么区别呢
  • 文件变化检测的底层是怎么做到的啊
  • 在不同平台,怎么『嘭』的一声打开浏览器呢
  • react-hot的插件做了什么操作才做到无痕刷新
  • 怎么react的热更新机制从之前的loader变成了现在的babel-plugin啊
  • 服务器这类node应用中,如何保证长时间运行下来调用栈保持较浅,内存不炸呢
  • 怎么不直接使用socket连接把我们新编译好的chunk代码发送到客户端,还要透过json和js文件进行请求

先理一理执行过程

服务端

  1. 服务器通过webpack-dev-server进行初始化,得到我们的compiler和express的对象实例
  2. webpack打包代码,根据我们的设置进行一些插件的使用,比如与此过程最相关联的添加热加载的相关代码
    1. 最重要的就是HotReplacement的插件,他承担了我们热加载中的大部分任务,生成对应的chunk资源,并把资源存储下来等等
    2. 另外就是我们的webpack-dev-server,他其实除了构建服务器也会在我们的代码中做一些手脚,比如添加上socket相关的入口代码,便于进入打开网页之后就进行socket连接
    3. 进行连接过后客户端会隔一段时间发送一个心跳包,告诉服务端这次链接还没有断掉
  3. 启动虚拟文件系统存储数据,webpack不会将处理好的文件放到磁盘中,而是生成到内存里
    1. 生成的文件交由memory-fs托管,我们每次compile过程实际上都是全部重新编译的
    2. 避免多次重复处理的方法是我们的record标识,用来标记哪些东西没有变化,不需要重新处理
  4. 把我们的express用sockjs进行处理,并开启端口监听,正式启动服务,发送第一轮编译好的代码
    1. 等待客户端进行socket连接,把得到的连接放到一个数组中进行维护,过后每次发送消息都是对数组里面的全部发送一遍
  5. 使用watch,按照一定频率开始检测文件是否发生变化,如果发生变化,则重新编译利用过程中的插件通知客户端现在正invaliid
    1. 检查过程可以且默认使用fs自带的watch模块,但是自带的模块监听会出现一些问题,比如同一个事件通知两次等,这时候可以用别的库监听
    2. 要注意,我们每次检测到有文件变化就会直接重新compile,不会给你文件名让你拿插件处理啥的,其实重新compile的过程利用record记录还是蛮快
  6. 编译完成,在done过程的插件中利用socket连接向客户端发送这一轮完成的一些编译数据(其实就是hash)
  7. 根据编译的实际情况看这次应该发送error,warning,还是ok

客户端

  1. 收到socket链接发送过来的hash值,更新了自己目前的hash值,不过并没有下载json文件

  2. (其实我想问这里真的能够保证两次的顺序吗?)收到ok,或者error等,这里只讨论ok

  3. 通知其他的iframe和worker等,发送OK消息并清除错误显示屏overlay,之后重新加载reloadApp

  4. 利用hotEmitter的共享实例,发送出一个『webpackHotUpdate』的事件,注意这里在客户端是由webpack的polyfill实现的

  5. 发出的事件会被dev-server中的代码接收到,执行check操作,取回并检查我们的json文件

    1. 初次进入到这里,或者是没有完全搞明白webpack的目录结构的同学可能会很懵逼,这个module.hot.check方法是哪里那进来的啊混蛋?!其实要知道这个首先得知道我们的module不能当成一个普通的对象来看待,她和require一样都是webpack和我们文件沟通的桥梁,很多时候webpack会在她上面动一些手脚

      WX20171016-195239

      这里就是通过上面的方法,在module对象上面添加上我们的对象和参数等,我们看到这里的hot被设置成为执行一个函数的返回值,发现这个函数在HotModuleReplacement.runtime.js中

    2. 这里面有我们的check,decline,accept等方法,也即是我们在代码中执行的那些方法的实际实现

    3. 仔细看看hotCheck方法的实现,把状态变成check,并执行hotDownloadManifest去取我们的描述json文件,返回一个Promise

    4. 这个下载方法在不同环境又有不同实现方式,我们现在心里只有浏览器!所以只看浏览器的!

      不过也只是自己发了一个request请求而已,拿到那一段json,从这里可以看出,其实现在已经没有必要管古代的浏览器了,直接使用的XMLHttprequest,然后去服务端拿数据

服务端

  1. 收到[hash].hot-update.json的请求,进行回复
    1. 进入dev-server收到请求,但是交由dev-middleware进行处理

    2. 对于这一次请求我们得到的路径是/Users/rocksama/project-name/public/9c531a0d5c8a256697b3.hot-update.json,可以确定是我们在那个目录下是没有那个文件的

    3. 在memory-js寻寻觅觅,终于找到了我们的文件,读出来,并把它返回给我们

    4. 这个json文件是什么时候写入的呢?是在additional-chunk-assets阶段,在内存中存储了一个json文件,c代表的是变化的chunk是哪个,h代表的是hash值,而经常还会看到l代表的是是否需要重新加载,比如只是改了个空格肯定就不会有变化啦

    5. 那么这个c中的值是囊个计算出来的呢?之前也说过我们的文件变化过后,会导致重新编译,(重新编译不一定hash变化,这个hash值是AST相关,不是纯粹的与文件内容相关联的)这个时候会拿之前编译后留下的record中记录的hash和我们现在模块的hash,进行对比看哪些module发生变化

      知道module后就好办了,往上面找到引用了他的父chunk,就能得到哪些是需要进行更新的了

    6. 把我们做好的json串放到虚拟文件系统中,等着前端来请求,美滋滋(不过好像没看到删除操作)

客户端

  1. 客户端拿到json文件,对其中的c字段进行检查,对于需要改变的chunk请求对应的新的js

    1. 注意这里重新请求的是chunk,为什么不是module呢?~~我知道个屁~~

    2. 在不同环境下利用hotDownloadUpdateChunk下载新的chunk文件

    3. 添加一个新的hot-update.js的文件script到head里面进行下载工作

    4. 我们的hot-chunk又是在哪里生成的呢?之前不是compiler一直都watch着吗,这一轮的complication执行下来得到的hot资源就是通过Jsonp的插件在render的时候进行的格式化然后插入到我们的内存中的

      1. 执行加载好的js文件,她和其他异步模块最大的不同就是脚本一开始的执行函数,这个函数直接决定了现在模块的执行方式
      2. 在异步加载脚本时,这个函数使用的是webpackJsonp,但是在现在这里是用的是webpackHotUpdate,并且这两个东西你无法直接在源码中找到对应名字的函数,他们是挂在window上面的,所以在中途还被改了个名字

      WX20171017-113112

      1. 具体到源代码里存在的函数就是webpackHotUpdateCallback了,在里面会执行hotAddUpdateChunk把我们的对应资源的下载标记位给清除掉,并且把更新的chunk放到hotUpdate这个对象里面
    5. hotUpdateDownloaded方法开始执行,标记现在的状态为ready,并把之前下载manifest的deffer给置为null清空了;忽略特殊情况开始处理apply的事件

    6. 用getAffectedStuff处理后,从更改的节点往上找到拿到被影响的模块,最终返回几个列表,这样就能开始我们的替换工作啦,同时方法执行最后会返回一个对象

      return {
        type: "accepted",
        moduleId: updateModuleId,	//	当前hot的module的id
        outdatedModules: outdatedModules,	//	被影响的module的id
        outdatedDependencies: outdatedDependencies  //   存在根节点和当前module的关系?不懂不敢乱说
      };
      
    7. 这次处理由于只有这么一个模块发生了修改,而且没有把本次的热加载往上冒泡(可能是因为dva的作用,我们这个页面实际上没有hot相关配置),所以直接得到了accepted的许可,如果有设置则执行onAccepted方法。继续往后把doApply设置为true接着往下执行

    8. 当doApply为true时会往outdatedModules中添加不重复的module元素id,用于一会儿的移除并更新操作

    9. 改变标记位,进入dispose阶段,进入对过期模块的清理工作,但是只是从installedMoudules的列表中把他们delete掉了,和node还需要清除cache不一样,简单删除掉就好了;当然除了把自身卸载掉还有之前说的dependency也需要处理,我不是很清楚就不再赘述了

    10. 删除完成进入apply阶段,将新的代码模块都应用上去,这里终于发现我们的dependency其实是设置好了module.hot.accept的模块,拥有这个配置的模块会将回调函数放到_acceptedDependencies里面存好,过后边开始执行hot中的操作,比如利用ReactDOM重新挂载,或者重新加载模块等,具体操作以dva为例,请前往dva部分查看

    11. 处理完更新过后,有错误就触发fail标识,不然直接进入idle的空闲状态

  2. 调用栈一直退出直到check中,进行收尾工作打印操作正确与否的日志


这下面的内容属于笔记性质的了,还是需要配合源代码食用,而且质量不高很容易造成消化不良的症状,请酌情使用

webpack-dev-server

本是同根生,所以webpack还是使用了yargs进行参数的处理,其他还有optimist和commander等等,大家都大同小异,而且诶个人使用下来还是yargs舒服而且相对活跃,那这就是用他的原因吗?

其实我倒觉得是因为两个包都用一样的yargs会保证npm下载的时候更快更稳啦~

之后会对里面拿到的参数进行格式化(convert)处理,这里有个选项直接会导致他的输出统一变成bundle.js,当然这也是为了方便DS读取固定且单一的路径

WX20171010-202208

processOptions

对设置的参数进行处理,如果设置了stdin的参数的话会打开输入流,是用来后面直接读取资源数据?方便管道处理?

WX20171011-191538

之后还有其他初始化,比如这个读取证书的操作,DS实际上是支持https的,不过少有用到,跟着研究一下

WX20171011-192037

DS是真的皮,如果-p参数带来的端口刚好是默认端口号的话,他反而会先尝试去使用在文件中设置的值

  // Kind of weird, but ensures prior behavior isn't broken in cases
  // that wouldn't throw errors. E.g. both argv.port and options.port
  // were specified, but since argv.port is 8080, options.port will be
  // tried first instead.
  options.port = argv.port === DEFAULT_PORT ?
    defaultTo(options.port, argv.port):
    defaultTo(argv.port, options.port);

如果我们没有在任何地方设置好服务启动端口的话,那就会通过portfinder从8080一直往上面找,直到找到一个可以用的端口,但是如果指定了某一个值就没那么好玩了,不行的话直接GG

addDevServerEntrypoints

听名字就知道不是什么好事,他会在我们webpack的配置中添加上两个entry,其中一个是我们必要的DS客户端,就是干响应新数据啊,发起链接这些事情的

但是另一个就要分两种情况了,但是都是和热加载有关的文件,可能是only-dev-server也可能是dev-server。有个什么区别呢?区别在于webpack/hot/dev-server 在 HMR 更新失败之后会刷新整个页面,如果你想查看错误自己刷新页面, 可以改用 webpack/hot/only-dev-server

那么我们就很愉快的拿到了两个入口,看看这次测试是什么样子

  • /Users/you/project/node_modules/webpack-dev-server/client/index.js?http://0.0.0.0:8080,后面的query字符串会在webpack处理之后变成我们的熟人__resourceQuery

说一句题外话,不知道大家觉得下面这种写法有没有什么问题呢,path.resolve(__dirname, 'project/main.js?http://0.0.0.0:8888'),试一试打印出来就知道问题在哪里了哈哈

  • webpack/hot/dev-server,一般都是用hot,所以相当于默认的就是这个

相关:github-issue

startDevServer

创建一个新的webpack实例,拿到我们的compiler

创建一个新的Server,美滋滋,就是简单的express服务器加上我们的相关中间件

  1. 设置好相关的插件,分别在compile,invalid,done阶段向客户端的socket连接发送信息告知详情,done的时候会整个发送stat过去,就是我们的分析数据摘要,包含了请求资源json和补丁js文件的hash值等等,另外这几个里面只有process的过程是按需的,根据process的设置启动

  2. 创建express服务器,拿到所有的请求,先把host过滤一遍,不知道意义何在;添加上我们的中间件DM

    1. 他会把我们的文件存到虚拟系统里,你别无选择,如果input的时候是虚拟系统就直接用那个,没有就新建一个memory-fs的实例拿来存取东西

    2. 给done,invalid,watch-run,run阶段添加上一些管理函数

    3. 开始调用compiler的watch方法对文件进行监视

      1. 创建一个Watcher对象,进行初始化,由于fs其实还是扫描文件是否发生变化,所有有一个时间间隔,这里默认的值是200ms

      2. 试图读取record的缓存记录,但是很可惜,什么都没有那么直接执行_go,跟我们之前所说的的compile差不多,不过是里面和正统的webpack打包对比起来,有一些生命周期发生了变化,比如不会有什么资源存储,本来的run变身为watch-run等

      3. 首先会进行一次编译操作,然后回到我们的onCompiled函数,这样构成了一个闭环,不断的做递归,每次检查我们的资源有没有变化

        WX20171011-204137

      4. 在_done方法的回调中,进行相关的状态发送,不只是hash,还可能出现still-ok,errors的情况,浏览器端会给出相应的反映

      5. 本来以为会在某个插件中卡住一直等待文件状态刷新,但是其实这个东西是webpack自己的方法watch,如果发现我们的文件发改变进入invalidate方法,把之前的watcher扔掉(watcher实际上一个Watchpack对象,这个对象还是webpack的大佬们定制的,毫无疑问继承自EventEmitter)

  3. 给线程绑定上两个信号的监听,虽然只有这么几种信号,但是姑且也算是一种通信方式吧

    1. SIGINT——程序终止(interrupt)信号, 在用户键入INTR字符(通常是Ctrl-C)时发出,用于通知前台进程组终止进程。
    2. SIGTERM——程序结束(terminate)信号, 与SIGKILL不同的是该信号可以被阻塞和处理。通常用来要求程序自己正常退出,shell命令kill缺省产生这个信号。如果进程终止不了,我们才会尝试SIGKILL。
    3. SIGKILL——用来立即结束程序的运行. 本信号不能被阻塞、处理和忽略。如果管理员发现某个进程终止不了,可尝试发送这个信号。(所以理所当然这里没有监听他)
  4. 我们现在已经有了一个express的服务器,不过还没有启动(只是一个实例,没有监听端口),那剩下来要做的就是创建我们的socket服务,实现双向数据传输

    1. 如果我们配置了socket,那就直接使用我们配置的socket而不是重新起一个sockjs的实例来处理,因为是继承自EventEmitter,所以监听error事件,如果出现了端口占用的情况,则创建一个新的socket连接,要是再拒绝了链接那就再重新尝试连接一次,然后抛出错误,真是坚韧不拔啊

      WX20171012-114300

      1. 执行我们server的listen方法,这里的具体执行下面再看,我们先看回调;unix中把所有东西都视为文件,我们使用的socket也不例外,所以在这里面把socket给chmod了变成了0x666,也就是十进制的438
    2. app这个属性中存储的是我们的express服务器,用于接收来自我们页面的所有http请求,用作分发处理,中间代理等等

    3. listeningApp中存储的是一个新的httpServer,如果没有启用https的话会很简单直接是把之前的express服务器拿过来启动就好,但是如果是https的话会用到spdy这个库进行创建

      WX20171012-112827

      spdy相当于是HTTP2的前身,有chrome指定的协议,但是根据wiki,在2015年9月,Google 已经宣布了计划,移除对SPDY的支持,拥抱 HTTP/2,并将在Chrome 51中生效。那为什么还会用SPDY?多半是在这个库里面设置成了能用HTTP2就直接使用,不再深究,扯太远了

    4. 执行server的listen方法,处理监听操作

      1. 现在的server是真正的httpServer(其实是net的实例,具体深入到底层这里先不做研究),所以我们调用他的listen方法就开始真正的监听端口启动服务器啦!要注意不只是一个HTTP服务器哦,还提供socket的链接方式
      2. 这里设置的回调会在服务器启动完成后执行,这里就是继承自EE的优势,站在net模块开发者的角度,我们不需要去思考之后创建会有什么异步同步的操作,到哪里放置我们的回调,只需要在所有操作完成后触发listening事件,并添加once的监听放入我们设置的回调函数
      3. 勇敢的跳转到sockjs的启动

sockjs

承接上文的利用sockjs创建一个新的socket服务器,我们探究一下其中的原理

看一下默认的配置呢,优先使用websocket咯,完全没毛病;jsessionid是什么?socket连接怎么会能用这种浪费流量的东西?好吧其实是拿给域名服务商看的

Some hosting providers enable sticky sessions only to requests that have JSESSIONID cookie set. This setting controls if the server should set this cookie to a dummy value. By default setting JSESSIONID cookie is disabled. More sophisticated behaviour can be achieved by supplying a function.

还有个sockjs_url这个就比较有意思了,这里的默认值是sockjs挂在线上CDN的sockjs-client库,我们会对他进行替换,只是换成本地的资源,没有一丁点变化

this.options = {
  prefix: '',
  response_limit: 128 * 1024,
  websocket: true,
  faye_server_options: null,
  jsessionid: false,
  heartbeat_delay: 25000,
  disconnect_delay: 5000,
  log: function(severity, line) {
    return console.log(line);
  },
  sockjs_url: 'https://cdn.jsdelivr.net/sockjs/1.0.1/sockjs.min.js'
};

那么我们现在要看sockjs的启动得去哪里找呢?hey,还记得之前在每个entry中添加的两个文件吗,现在就去看看他们做了什么

index.js

这里用到的sockjs不是之前直接设置的打包好的sockjs,而是在这里重新做的一次引用require('sockjs'),就目前看来这样会导致我们引入多余的js文件,我们一会儿看看打包出来的文件是什么样子。没准儿最后有什么奇妙的方法修复了

里面有个getCurrentScriptSource方法能够拿到现在正在执行的script脚本,本来是有一个document.currentScript应该能够获取到,但是没有浏览器支持,所以现在拿到正在执行的脚本的方法比较笨,就是直接拿到最后一个script标签;

有人会问了,我要是在这段JS里面动态添加上script标签可咋办,而且webpack本来就是用JSONP加载资源,这样岂不是要拿错?(记得补上)

对象里面定义了我们响应socket信息时可能会接受到的所有信号,看看都坐了些啥

const onSocketMsg = {
  hot: function msgHot() {
    hot = true;
    log.info('[WDS] Hot Module Replacement enabled.');
  },
  invalid: function msgInvalid() {
    log.info('[WDS] App updated. Recompiling...');
    // fixes #1042. overlay doesn't clear if errors are fixed but warnings remain.
    if (useWarningOverlay || useErrorOverlay) overlay.clear();
    sendMsg('Invalid');
  },
  hash: function msgHash(hash) {
    currentHash = hash;
  },
  'still-ok': function stillOk() {
    log.info('[WDS] Nothing changed.');
    if (useWarningOverlay || useErrorOverlay) overlay.clear();
    sendMsg('StillOk');
  },
  'log-level': function logLevel(level) {
    const hotCtx = require.context('webpack/hot', false, /^\.\/log$/);
    const contextKeys = hotCtx.keys();
    if (contextKeys.length && contextKeys['./log']) {
      hotCtx('./log').setLogLevel(level);
    }
    switch (level) {
      case INFO:
      case ERROR:
        log.setLevel(level);
        break;
      case WARNING:
        // loglevel's warning name is different from webpack's
        log.setLevel('warn');
        break;
      case NONE:
        log.disableAll();
        break;
      default:
        log.error('[WDS] Unknown clientLogLevel \'' + level + '\'');
    }
  },
  overlay: function msgOverlay(value) {
    if (typeof document !== 'undefined') {
      if (typeof (value) === 'boolean') {
        useWarningOverlay = false;
        useErrorOverlay = value;
      } else if (value) {
        useWarningOverlay = value.warnings;
        useErrorOverlay = value.errors;
      }
    }
  },
  progress: function msgProgress(progress) {
    if (typeof document !== 'undefined') {
      useProgress = progress;
    }
  },
  'progress-update': function progressUpdate(data) {
    if (useProgress) log.info('[WDS] ' + data.percent + '% - ' + data.msg + '.');
  },
  ok: function msgOk() {
    sendMsg('Ok');
    if (useWarningOverlay || useErrorOverlay) overlay.clear();
    if (initial) return initial = false; // eslint-disable-line no-return-assign
    reloadApp();
  },
  'content-changed': function contentChanged() {
    log.info('[WDS] Content base changed. Reloading...');
    self.location.reload();
  },
  warnings: function msgWarnings(warnings) {
    log.warn('[WDS] Warnings while compiling.');
    const strippedWarnings = warnings.map(function map(warning) { return stripAnsi(warning); });
    sendMsg('Warnings', strippedWarnings);
    for (let i = 0; i < strippedWarnings.length; i++) { log.warn(strippedWarnings[i]); }
    if (useWarningOverlay) overlay.showMessage(warnings);

    if (initial) return initial = false; // eslint-disable-line no-return-assign
    reloadApp();
  },
  errors: function msgErrors(errors) {
    log.error('[WDS] Errors while compiling. Reload prevented.');
    const strippedErrors = errors.map(function map(error) { return stripAnsi(error); });
    sendMsg('Errors', strippedErrors);
    for (let i = 0; i < strippedErrors.length; i++) { log.error(strippedErrors[i]); }
    if (useErrorOverlay) overlay.showMessage(errors);
  },
  error: function msgError(error) {
    log.error(error);
  },
  close: function msgClose() {
    log.error('[WDS] Disconnected!');
    sendMsg('Close');
  }
};

这里面的sendMsg等方法都是用来通知其他页面的,利用的就是postMessage,其他页面只需要监听message事件并做出响应就好了,如果当前的工作环境是在WebWorker中那就不发消息通知。

self.postMessage({
  type: 'webpack' + type,
  data: data
}, '*')

但是这样发送消息会发送到所有的页面上去,也就是说我们如果有两个应用同时在调试的话,那么其中的iframe都会收到这些个消息并且打印到控制台

除了发送消息展示结果,更重要的就是刷新我们的引用了,reloadApp方法就是拿来做此事的。

WX20171012-173117

拿到资源还是广昭天下,给大家说说这次拿到的hash值,可以新添加上JSONP去请求新的资源,请求的时候是怎么请求的呢?这里就要详细的说一说了

资源异步加载

都知道我们的webpack不止能用于browser中,也可能存在于worker或者是直接的node环境中使用,如果我们是一套同构代码的话,那么也会碰上不同环境下异步模块的处理问题,这里来看下不同环境是怎么做到的

为了方便大家找到,直接在webpack目录里面找到这几个文件就行了,直接搜索hotDownloadUpdateChunk这个方法找到相关线索

WX20171016-165905

这里的几个文件都存在着这个函数的不同实现方式,而且他们都有一个runtime的中间名,实际上这一点代表了他们是会被作为插入的模块打包到我们的运行时环境中的,而不是在打包的时候执行的代码,那就来看看看看每一种实现

  1. NodeMainTemplate,简单粗暴,直接用require就能解决的问题
  2. NodeMainTemolateAsync,计算出我们文件的位置,读取出其中的代码,然后使用vm模块创建单独的上下文进行执行,注意外面包的函数只有exports这一个参数
  3. JsonpMainTemplate,和常识一样,就是找到第一个head元素在里面插入一个script标签指向我们要新下载的文件
  4. WebWorker,不用说了,他自己有个方法importScripts可以引入新的脚本并执行,这里提一下区分环境的问题,worker中的self是一个WorkerGlobalScope的实例,我们判断环境的时候可以直接这么判断

异步主要逻辑

逻辑部分,很多是HotReplacement的插件和插入进去的runtime做的,我们现在观察一下这两个文件

HotReplacementPlugin

最主要的文件:/webpack/lib/HotModuleReplacementPlugin.js

其他运行时环境的:/webpack/lib/node/NodeMainTemplatePlugin.js等相似的名字

该插件做了几件事

  1. 引入我们的runtime文件,并且把里面的代码拿出来进行一些必要的代码替换(会被替换的字段写到了文件中最上方的global注释里面,可以参考看一看)

    /*global $hash$ $requestTimeout$ installedModules $require$ hotDownloadManifest hotDownloadUpdateChunk hotDisposeChunk modules */
    

    就像这样子,其中以$$框起来的变量会在本次处理的时候被替换掉,具体的逻辑是

    return this.asString([
      source,		//	其他地方的源代码
      "",
      hotInitCode	//	我们引入的runtime文件代码
      .replace(/\$require\$/g, this.requireFn)			//	替换成__webapck_require__
      .replace(/\$hash\$/g, JSON.stringify(hash))		//	当前的hash数字
      .replace(/\$requestTimeout\$/g, requestTimeout)	//	超时时间(默认10000)
      .replace(/\/\*foreachInstalledChunks\*\//g, chunk.chunks.length > 0 ? "for(var chunkId in installedChunks)" : `var chunkId = ${JSON.stringify(chunk.id)};`)
    ]);
    

    发现在这个过程里面还有很多变量没有被替换是怎么回事?他们是在哪里定义的?installedModules这种,还有其他相关的函数

dva-hmr

我们这里是用的是dva的babel-plugin,至于为什么没有使用在loader和plugin中添加操作,可以看看redux作者,同时也是react-hot-loader的作者写的一篇博客,详细的阐述了判断一个东西是不是组件有多少困难

最终选择了到babel的层面来注入代码也是有原因的,对于dva来说他是把hmr的兼容操作进行了一层封装放到了整个系统内部作为一个插件,将onHmr的入口留给webpack进行hash的注入

WX20171017-142227

紧接上文,我们提到把存在module.hot.accept的模块称之为dependency,对应到dva里面就是我们在上面展示的这一段代码了,他会出现在我们使用app.router的地方,把每一个路由中的内容做了一个热处理

这里的render实际上。。。只是重新挂载了一次到dom上面,是我设置有错吗??这个东西应该做到的其实是保留当前的store中的状态,重新挂载更新的组件啊!

我们来看看performance中记录的是怎样的情景(部分,过深处的调用栈也是差不多),看看这么整个重新挂载dom树会有哪些动作

QQ20171017-193242

从上图中可以发现这样重新挂载到DOM节点上面实际上是会导致我们的项目整个重新来过;我们逐个山峰进行分析

  1. 第一个山峰代表了我们旧的React结构的全部Unmount过程,可以看到山谷的地方调用了unmountComponentFromNode的方法,这个方法就是留给我们合法移除某个DOM节点上的React元素的,接下来的就是逐个unmount,然后调用removeChild移出我们的DOM节点
  2. 到达第二个山峰,可以看到这边挂载比之前乱很多,主要是他们都不是一般用在项目开发中的简单组件,而是我们页面的Redux,I18N,Router等等的组件,在这一层组件挂载完成后,进行路由匹配得到自己要用的模块
  3. 第三座大山前的平原,我们注意到__webpack_require__,就是他来加载了我们的异步模块(只是说成异步,但是这里是已经下载好的模块资源),趁机搞一波垃圾回收(黄色部分)收集了多达797K的内存垃圾,就是刚才的DOM搞剩下的
  4. 加载我们的新的节点朋友们,之前就存在Node可能会被update,新增加的节点当然就是通过mount进行加载,加载完就好啦
  5. 这一帧的执行时间长达60ms,本来说可能导致我们的页面闪烁,但是并没有发生,发生这一情况的原因也正是这是在一帧中完成的,浏览器在这过程中没有重新渲染,直到反映过来要重新渲染时发现现在的结构居然和之前一样,不过最后计算属性和layout等(重排)也是一样的费时间

react-hot-loader

不知道这个插件的可以先看一下要做哪些配置,这也是我们从无到有解析的一个起点 ——> https://github.com/gaearon/react-hot-loader

看了上面的dva-hmr是不是有点沮丧。这也能叫热加载?感觉就比刷新页面少了个加载其他资源的步骤,其实要求也不要那么高,这只是dva顺手做的一个功能而已,我们具体来看看 Dan Abramov是怎么做的吧,如何才能做到保留我们组件的状态和store的状态

ReactDOM.render原来不是我想的那样,把所有组件卸载了然后重新挂载,根据官方解释,其实是做一次更新,那前面dva怎么搞成了这样

If the React element was previously rendered into container, this will perform an update on it and only mutate the DOM as necessary to reflect the latest React element.

为了便于于dva做一个对比,我们看一下他的函数调用图谱(部分)是怎么样的

WX20171018-192911

可以看到和之前的全部unmount再进行mount不一样,这里都是在update我们的组件了,这个操作是和我们文档中的ReactDOM是完全相符合的,算下来其实是dva有点奇葩,她底层也是使用的ReactDOM.render,不过我们的react-hot-loader是利用了AppContainer在我们的组件最外层包裹上了一层,才达到这样的效果

根据AppContainer的源码,其主要是有一个展示编码错误的功能,还有就是手动对所有子元素进行深度强制更新(forceUpdate),当他的props发生改变的时候便会触发这一操作,而这一动作的触发想必也一定是和我们的热更新资源有关了

关于react-hot-loader我之前翻译了一篇相关文章,请跳转继续阅读

【【翻译】Hot Reloading in React】

不知道你看完有没有想到我上面所写的目标的答案?欢迎下方留言~

879479119 avatar Nov 01 '17 12:11 879479119

6啊

JoeCqupt avatar Nov 06 '17 01:11 JoeCqupt

大哥,我能转载吗?

proYang avatar Nov 10 '17 13:11 proYang

@proYang 转转转 就是写的有点水

879479119 avatar Nov 12 '17 08:11 879479119

@879479119 膜拜大佬!大佬有没有遇到过 webpack 4 + Babel 7 + dva这种情景,然后用dva-hmr无效,用react-hot-loader也无效的问题。

tylerrrkd avatar Sep 19 '19 10:09 tylerrrkd

1、文章很深刻,学到了很多 建议:代码图片很多展示不出来😄需要单独点击查看,读起来有点脱节了;要是直接md代码会更丝滑😄

feZsy avatar Dec 07 '21 12:12 feZsy

学习了

wuyanfeiying avatar Jul 24 '22 07:07 wuyanfeiying