阅读 678
webpack 热更新

webpack 热更新

前言

个人的摘抄归纳总结,非原创

webpack-dev-server

webpack-dev-server/bin/webpack-dev-server.js

let configYargsPath;
try {
  require.resolve('webpack-cli/bin/config/config-yargs');
  configYargsPath = 'webpack-cli/bin/config/config-yargs';
} catch (e) {
  configYargsPath = 'webpack-cli/bin/config-yargs';
}

let convertArgvPath;
try {
  require.resolve('webpack-cli/bin/utils/convert-argv');
  convertArgvPath = 'webpack-cli/bin/utils/convert-argv';
} catch (e) {
  convertArgvPath = 'webpack-cli/bin/convert-argv';
}

function startDevServer(config, options) {

  let compiler;

  try {
    // 2. 调用webpack函数返回的是 webpack compiler 实例
    compiler = webpack(config);
  } catch (err) {
  }

  try {
    // 3. 实例化 webpack-dev-server
    server = new Server(compiler, options, log);
  } catch (err) {
  }

  if (options.socket) {
  } else {
    // 4. 调用 server 实例的 listen 方法
    server.listen(options.port, options.host, (err) => {
      if (err) {
        throw err;
      }
    });
  }
}

// 1. 对参数进行处理后启动
processOptions(config, argv, (config, options) => {
  startDevServer(config, options);
});
复制代码

如上代码:
(1) 最开始,调用了webpack-cli模块下的两个文件,分别配置了命令行提示选项、从命令行和配置文件收集了 webpack 的 config。再调用 processOptions 将收集到的参数传递给startDevServer方法,需要注意的是 config 是 webpack 需要的参数 、options是 wepack-dev-server 需要的参数。而 startDevServer 会调用 webpack 函数得到实例化结果 compiler。compiler 会被传递到Server进行实例化,即实例化 webpack-dev-server。最后就会调用实例中的listen方法进行监听。

webpack-dev-server/lib/Server.js

class Server {
  constructor(compiler, options = {}, _log) {
    this.compiler = compiler;
    this.options = options;
    // 1. 为一些选项提供默认参数
    normalizeOptions(this.compiler, this.options);
    // 2. 对 webpack compiler 进行一些修改  webpack-dev-server/lib/utils/updateCompiler.js
    //    - 如果设置了 hot 选项,自动给 webpack 配置 HotModuleReplacementPlugin
    //    - 注入一些客户端代码:webpack 的 websocket 客户端依赖 sockJS/websocket + websocket 客户端业务代码 + hot 模式下的 webpack/hot/dev-server
    updateCompiler(this.compiler, this.options);
    // 3. 添加一些 hooks 插件,这里主要关注 webpack compiler 的 done 钩子,即每次编译完成后的钩子 (编译完成触发 _sendStats 方法给客户端广播消息 )
    this.setupHooks();
    // 4. 实例化 express 服务器
    this.setupApp();
    // 5. 设置 webpack-dev-middleware,用于处理对静态资源的处理,后面解析
    this.setupDevMiddleware();
    // 6. 创建 HTTP 服务器
    this.createServer();
  }


  setupApp() {
    // Init express server
    // eslint-disable-next-line new-cap
    this.app = new express();
  }

  setupHooks() {
    const addHooks = (compiler) => {
      const { compile  } = compiler.hooks;
      done.tap('webpack-dev-server', (stats) => {
        this._sendStats(this.sockets, this.getStats(stats));
        this._stats = stats;
      });
    };
    addHooks(this.compiler);
  }

  setupDevMiddleware() {
    // middleware for serving webpack bundle
    this.middleware = webpackDevMiddleware(
      this.compiler,
      Object.assign({}, this.options, { logLevel: this.log.options.level })
    );
    this.app.use(this.middleware);
  }


  createServer() {
    this.listeningApp = http.createServer(this.app);

    this.listeningApp.on('error', (err) => {
      this.log.error(err);
    });
  }


  listen(port, hostname, fn) {
    this.hostname = hostname;

    return this.listeningApp.listen(port, hostname, (err) => {
      this.createSocketServer();
    });
  }

  createSocketServer() {
    const SocketServerImplementation = this.socketServerImplementation;
    this.socketServer = new SocketServerImplementation(this);

    this.socketServer.onConnection((connection, headers) => {
      // 连接后保存客户端连接
      this.sockets.push(connection);

      if (this.hot) {
        // hot 选项先广播一个 hot 类型的消息
        this.sockWrite([connection], 'hot');
      }

      this._sendStats([connection], this.getStats(this._stats), true);
    });
  }


  // eslint-disable-next-line
  sockWrite(sockets, type, data) {
    sockets.forEach((socket) => {
      this.socketServer.send(socket, JSON.stringify({ type, data }));
    });
  }


  // send stats to a socket or multiple sockets
  _sendStats(sockets, stats, force) {
    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');
    }
  }
}
复制代码

如上代码:
(1) 在构造函数中做了如下几件事情:提供一些默认参数、绑定 webpack compiler 钩子。其中的 done 钩子用于每次 compiler 实例触发编译完成,就会进行 webscoket 广播 webpack 的编译信息。实例化 express 服务器、设置 webpack-dev-middleware,用于处理对静态资源的处理,后面解析、创建 HTTP 服务器。
(2) webpack-dev-server.js中调用的listen方法,用于监听配置的端口。监听回调里再初始化 websocket 的服务端。

监听编译结束

如上一节代码,setupHooks 就是用于监听每次webpack编译完成。每次监听编译结束以后,会调用_sendStats方法通过websocket告诉浏览器 ok、hash等事件。此时客户端拿到最新的 hash 值。

监听文件变化

如上一节代码,setupDevMiddleware 用于实现监听本地文件变化。需要注意的是 webpack-dev-server 只负责启动服务和前置准备工作。文件相关的操作在 webpack-dev-middleware 中。compiler.watch 用于监听文件变化。而compiler主要有两个功能:(1) 对本地文件代码进行编译打包。(2) 编译结束后,对本地文件进行监听,若文件发生变化,则重新编译。
其中监听文件的变化主要通过文件的生成时间是否发生变化

webapck-dev-middleware

  • webpack-dev-middleware/index.js
module.exports = function wdm(compiler, opts) {
  const options = Object.assign({}, defaults, opts);
  // 1. 初始化 context
  const context = createContext(compiler, options);

  // start watching
  if (!options.lazy) {
    // 2. 启动 webpack 编译
    context.watching = compiler.watch(options.watchOptions, (err) => {
      if (err) {
        context.log.error(err.stack || err);
        if (err.details) {
          context.log.error(err.details);
        }
      }
    });
  } else {
    // lazy 模式是请求过来一次才webpack编译一次, 这里不关注
  }

  // 3. 替换 webpack 默认的 outputFileSystem 为 memory-fs, 存取都在内存上操作
  // fileSystem = new MemoryFileSystem();
  // compiler.outputFileSystem = fileSystem;
  setFs(context, compiler);

  // 3. 执行 middleware 函数返回真正的 middleware
  return middleware(context);
}
复制代码

如上代码:
(1) 该方法返回 middleware ,用于处理浏览器静态资源的请求。最开始会初始化 context ,默认非 lazy 模式,开启 webpack 的 watch 模式开启编译功能。而setFs的作用就是借助 memory-fs(memory-fs实现了 node fs api 的基于内存的 fileSystem)实现了 webpack 编译后的资源不会存放到硬盘而是内存中。而将真正处理请求的 middleware 返回装载在 express 上。

  • webpack-dev-middleware/lib/middleware.js
module.exports = function wrapper(context) {
  return function middleware(req, res, next) {
    // 1. 根据请求的 URL 地址,得到绝对路径的 webpack 输出的资源路径地址
    let filename = getFilenameFromUrl(
      context.options.publicPath,
      context.compiler,
      req.url
    );

    return new Promise((resolve) => {
      handleRequest(context, filename, processRequest, req);
      // eslint-disable-next-line consistent-return
      function processRequest() {

        // 2.从内存读取到资源内容
        let content = context.fs.readFileSync(filename);

        // 3. 返回给客户端
        if (res.send) {
          res.send(content);
        } else {
          res.end(content);
        }
        resolve();
      }
    });
  };
};
复制代码

如上代码:
(1) webapck-dev-middleware 处理请求:浏览器打开url: https://localhost:3000 ,请求会被 middleware 处理。middleware 使用 memory-fs 从内存中读到请求的资源返回给客户端。

webscoket通讯

(1) 修改代码以后,触发webpack重新编译,编译完成以后执行done钩子的回调。在server.js中,setupHooks中会调用_sendStats方法会广播一个类型为 hash 的消息,然后再根据编译信息广播 warnings/errors/ok 消息。 (2) 客户端接收到更新后都会对应用进行 Reload,reload 分为:刷新整个页面、更新改动的模块。至于这2中模式,取决于是否传入hot选项。

  • webpack-dev-server/client/index.js
var onSocketMessage = {
  hot: function hot() {
    options.hot = true;
    log.info('[WDS] Hot Module Replacement enabled.');
  },
  liveReload: function liveReload() {
    options.liveReload = true;
    log.info('[WDS] Live Reloading enabled.');
  },
  hash: function hash(_hash) {
    status.currentHash = _hash;
  },
  ok: function ok() {
    if (options.initial) {
      return options.initial = false;
    } // eslint-disable-line no-return-assign


    reloadApp(options, status);
  }
};
socket(socketUrl, onSocketMessage);
复制代码

如上代码:
初始化 webscoket 客户端,为不同的类型('hot'、'liveReload'、'hash'、'ok'等)设置响应回调函数。Server.js 中我们看到如果 hot 选项为 true 时,当 websocket 客户端连接到服务端,服务端会先广播一个 hot 类型的消息,客户端接收到后会把 options 对象的 hot 设置为 true。服务端在每次编译后都会广播 hash 消息,客户端接收到后就会将这个 webpack 编译产生的 hash 值暂存起来。编译成功如果没有 warning 也没有 error 就会广播 ok 消息,客户端接收到 ok 消息就会执行 ok 回调函数中的 reloadApp 刷新应用。

触发 hot check

  • webpack/hot/dev-server.js
var lastHash;
 var upToDate = function upToDate() {
  return lastHash.indexOf(__webpack_hash__) >= 0;
 };
 var log = require("./log");
    // 2. 检查更新
 var check = function check() {
    // 3. 具体的检查逻辑
  module.hot
   .check(true)
   .then(function(updatedModules) {
        // 3.1 更新成功
   })
   .catch(function(err) {
    var status = module.hot.status();
        // 3.2 更新失败,降级为重新刷新整个应用
    if (["abort", "fail"].indexOf(status) >= 0) {
     log(
      "warning",
      "[HMR] Cannot apply update. Need to do a full reload!"
     );
     window.location.reload();
    } else {
     log("warning", "[HMR] Update failed: " + log.formatError(err));
    }
   });
 };
 var hotEmitter = require("./emitter");
  // 1. 注册事件回调
 hotEmitter.on("webpackHotUpdate", function(currentHash) {
  lastHash = currentHash;
  if (!upToDate() && module.hot.status() === "idle") {
   log("info", "[HMR] Checking for updates on the server...");
   check();
  }
 });
复制代码

如上代码:
(1) 设置了 hot: true 客户端就会引入 webpack/hot/emitter,触发一个 webpackHotUpdate 事件,将 hash 值传递过去。如果没有设置 hot: true,一旦更改,触发liveReload,直接刷新整个页面。同时 check 方法也会进行检测是否更新了,如果更新失败,则刷新整个页面来刷新代码。
(2) 模块更新依赖判断:
module.hot.check的实现位置在:webpack/lib/HotModuleReplacement.runtime.js 中,是 webpack 内置的 HotModuleReplacementPlugin 注入在 webpack bootstrap runtime 中的。使用了该插件以后,每次增量编译就会多产出两个文件,如:3jsbdjh223234.hot-update.json,main.3jsbdjh223df68.hot-update.js,前者代表需要更新文件的json列表,后者代表更新过后的thunk文件。然后浏览器会调用hotDownloadManifest方法下载manifest.json,manifest.json代表需要更新的模块列表。然后通过hotDownloadUpdateChunk(chunkId) 以jsonp的方式,下载需要更新的thunk,下载完成以后就会执行回调 webpackHotUpdate。回调内拿到更新后的模块,就会判断是否该模块是否同意更改,如果不同意更改,则会刷新整个页面强制更新。如果同意更改,那么就会将新的模块替换老的模块。替换成功以后就会执行回调进行更新。

  • module.hot.accept
function hotCreateModule(moduleId) {
  var hot = {
    // private stuff
    _acceptedDependencies: {},
    _declinedDependencies: {},
    _selfAccepted: false,
    _selfDeclined: false,
    _disposeHandlers: [],
    _main: hotCurrentChildModule !== moduleId,

    // Module API
    active: true,
    accept: function(dep, callback) {
        if (dep === undefined) hot._selfAccepted = true;
        else if (typeof dep === "function") hot._selfAccepted = dep;
        else if (typeof dep === "object")
            for (var i = 0; i < dep.length; i++)
                hot._acceptedDependencies[dep[i]] = callback || function() {};
        else hot._acceptedDependencies[dep] = callback || function() {};
    },


    // Management API
    check: hotCheck,
    apply: hotApply,
    // .....
  };
  hotCurrentChildModule = undefined;
  return hot;
}

if(module.hot) {
    module.hot.accept('./某模块url', function() {
        rerender()
    })
}
复制代码

如上代码:
举一个例子:有组件ABC,其中组件A分别应用用于组件B、c,当组件A发生改变以后,如果只是组件B发生改变,那么是不允许的。因为C组件也引用了组件A,于是就会造成页面强行刷新。判断模块是否需要更新,是根据依赖路径来的。而上文提到的同意,代表的是 module 的祖先模块调用了 module.hot.accept,而这个属性来自于 HotModuleReplacement.runtime.js 模块 hotCreateModule 方法生成的。

// HotModuleReplacement.runtime.js
function hotCheck(apply) {
  // 1. 拿这次编译后的 hash 请求服务器,拿到结构为 {c: {main: true} h: "ac69ee760bb48d5db5f5"} 的数据
  return hotDownloadManifest(hotRequestTimeout).then(function(update) {
    // 需要更新的文件
    hotAvailableFilesMap = update.c;
    // 更新下次热更新hash值
    hotUpdateNewHash = update.h;
    // 进入热更新准备状态
    hotSetStatus("prepare");
    
    // 2. 生成一个 defered promise,供上面提到的 promise 链消费
    var promise = new Promise(function(resolve, reject) {
      hotDeferred = {
        resolve: resolve,
        reject: reject
      };
    });

    hotUpdate = {};
    // 3. 这个方法里面调用的就是 hotDownloadUpdateChunk,就是发起一个 jsonp 请求更新过后的 chunk,
    // jsonp的回调是 HMR runtime 里的 webpackHotUpdate
    {
      hotEnsureUpdateChunk(chunkId);
    }

    return promise;
  });
}
复制代码
// HotModuleReplacement.runtime.js
function hotAddUpdateChunk(chunkId, moreModules) {
    if (!hotAvailableFilesMap[chunkId] || !hotRequestedFilesMap[chunkId])
        return;
    hotRequestedFilesMap[chunkId] = false;
    for (var moduleId in moreModules) {
        if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
            hotUpdate[moduleId] = moreModules[moduleId];
        }
    }
    if (--hotWaitingFiles === 0 && hotChunksLoading === 0) {
        hotUpdateDownloaded();
    }
}
复制代码
// HotModuleReplacement.runtime.js
function hotUpdateDownloaded() {
    hotSetStatus("ready");
    var deferred = hotDeferred;
    hotDeferred = null;
    if (!deferred) return;
    if (hotApplyOnUpdate) {
        // Wrap deferred object in Promise to mark it as a well-handled Promise to
        // avoid triggering uncaught exception warning in Chrome.
        // See https://bugs.chromium.org/p/chromium/issues/detail?id=465666
        Promise.resolve()
            .then(function() {
                return hotApply(hotApplyOnUpdate);
            })
            .then(
                function(result) {
                    deferred.resolve(result);
                },
                function(err) {
                    deferred.reject(err);
                }
            );
    } else {
        var outdatedModules = [];
        for (var id in hotUpdate) {
            if (Object.prototype.hasOwnProperty.call(hotUpdate, id)) {
                outdatedModules.push(toModuleId(id));
            }
        }
        deferred.resolve(outdatedModules);
    }
}
复制代码

如上代码:
hotCheck的作用就是:和服务器进行通讯拿到更新后的chunk,下载完成以后执行 webpack/lib/HotModuleReplacement.runtime.js 中的 hotAddUpdateChunk。hotAddUpdateChunk 的作用就是将那些要更新的模块moreModules赋值给全局全量hotUpdate。而hotUpdateDownloaded的作用就是调用hotApply进行代码的替换。

hotApply

  • 旧代码删除
function getAffectedStuff(updateModuleId) {
  var outdatedModules = [updateModuleId];
  var outdatedDependencies = {};

  var queue = outdatedModules.map(function(id) {
    return {
      chain: [id],
      id: id
    };
  });
  // 1. 遍历 queue
  while (queue.length > 0) {
    var queueItem = queue.pop();
    var moduleId = queueItem.id;
    var chain = queueItem.chain;
    // 2. 找到改模块的旧版本
    module = installedModules[moduleId];

    // 3. 如果到根模块了,返回 unaccepted
    if (module.hot._main) {
      return {
        type: "unaccepted",
        chain: chain,
        moduleId: moduleId
      };
    }
    // 4. 遍历父模块
    for (var i = 0; i < module.parents.length; i++) {
      var parentId = module.parents[i];
      var parent = installedModules[parentId];

      // 5. 如果父模块处理了模块变更的话就跳过,继续检查
      if (parent.hot._acceptedDependencies[moduleId]) {
        continue;
      }
      outdatedModules.push(parentId);
      // 6. 没跳过的话推入队列,继续检查
      queue.push({
        chain: chain.concat([parentId]),
        id: parentId
      });
    }
  }

  // 7.如果所有依赖路径都有被 accept 就返回 accepted
  return {
    type: "accepted",
    moduleId: updateModuleId,
    outdatedModules: outdatedModules,
    outdatedDependencies: outdatedDependencies
  };
}

// ...

var queue = outdatedModules.slice();
while (queue.length > 0) {
    moduleId = queue.pop();
    
    // 删除旧模块
    delete installedModules[moduleId];
}
复制代码

如上代码:

  • 替换新模块
// 
if (doApply) {
    appliedUpdate[moduleId] = hotUpdate[moduleId];
    addAllToSet(outdatedModules, result.outdatedModules);
    for (moduleId in result.outdatedDependencies) {
        if (
            Object.prototype.hasOwnProperty.call(
                result.outdatedDependencies,
                moduleId
            )
        ) {
            if (!outdatedDependencies[moduleId])
                outdatedDependencies[moduleId] = [];
            addAllToSet(
                outdatedDependencies[moduleId],
                result.outdatedDependencies[moduleId]
            );
        }
    }
}

// 更换新的模块
for (moduleId in appliedUpdate) {
    if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
        modules[moduleId] = appliedUpdate[moduleId];
    }
}
复制代码
  • 关联代码模块
for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {
    var item = outdatedSelfAcceptedModules[i];
    moduleId = item.module;
    hotCurrentParents = [moduleId];
    try {
        $require$(moduleId);
    } catch (err) {
        if (typeof item.errorHandler === "function") {
            try {
                item.errorHandler(err);
            } catch (err2) {
                if (options.onErrored) {
                    options.onErrored({
                        type: "self-accept-error-handler-errored",
                        moduleId: moduleId,
                        error: err2,
                        originalError: err
                    });
                }
                if (!options.ignoreErrored) {
                    if (!error) error = err2;
                }
                if (!error) error = err;
            }
        } else {
            if (options.onErrored) {
                options.onErrored({
                    type: "self-accept-errored",
                    moduleId: moduleId,
                    error: err
                });
            }
            if (!options.ignoreErrored) {
                if (!error) error = err;
            }
        }
    }
}
复制代码

如上代码:
webpack_require(moduleId) 执行代码

总结

webpack-dev-server 可以作为命令行工具使用,核心模块依赖是 webpack 和 webpack-dev-middleware。webapck-dev-server 负责启动一个 express 服务器监听客户端请求;实例化 webpack compiler;启动负责推送 webpack 编译信息的 webscoket 服务器;负责向 bundle.js 注入和服务端通信用的 webscoket 客户端代码和处理逻辑。webapck-dev-middleware 把 webpack compiler 的 outputFileSystem 改为 in-memory fileSystem;启动 webpack watch 编译;处理浏览器发出的静态资源的请求,把 webpack 输出到内存的文件响应给浏览器。

每次 webpack 编译完成后向客户端广播 ok 消息,客户端收到信息后根据是否开启 hot 模式使用 liveReload 页面级刷新模式或者 hotReload 模块热替换。hotReload 存在失败的情况,失败的情况下会降级使用页面级刷新。

开启 hot 模式,即启用 HMR 插件。hot 模式会向服务器请求更新过后的模块,然后对模块的父模块进行回溯,对依赖路径进行判断,如果每条依赖路径都配置了模块更新后所需的业务处理回调函数则是 accepted 状态,否则就降级刷新页面。判断 accepted 状态后对旧的缓存模块和父子依赖模块进行替换和删除,然后执行 accept 方法的回调函数,执行新模块代码,引入新模块,执行业务处理代码。

参考文献

mp.weixin.qq.com/s/3fxWbEK22…

文章分类
阅读
文章标签