webpack-dev-server 运行原理分析

前言

现代 web 开发者们对于 webpack 想必已经很熟悉了,webpack-dev-server 几乎都是标配。可是 webpack-dev-server 背后的运行原理是怎样的呢?想了解 how 咱们先看看 what。vue

webpack 将咱们的项目源代码进行编译打包成可分发上线的静态资源,在开发阶段咱们想要预览页面效果的话就须要启动一个服务器伺服 webpack 编译出来的静态资源。webpack-dev-server 就是用来启动 webpack 编译、伺服这些静态资源,node

除此以外,它还默认提供了liveReload的功能,就是在一次 webpack 编译完成后浏览器端就能自动刷新页面读取最新的编译后资源。为了提高开发体验和效率,它还提供了 hot 选项开启 hotReload,相对于 liveReload, hotReload 不刷新整个页面,只更新被更改过的模块。react

图片altwebpack

上图是我对 webpack-dev-server 的一个简单的整理。具体的实现原理是怎样的咱们接着往下看。web

版本

本文基于如下版本进行分析:express

  • webpack-dev-server@3.11.0
  • webpack@4.44.1

入口

若是做为命令行启动,webpack-dev-server/bin/webpack-dev-server.js 就是整个命令行的入口。贴出来的代码进行了一些精简,忽略了一些非核心的分支处理,只关心 webpack-dev-server 的核心逻辑。能够按照注释标注的顺序简单阅读下代码。json

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

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);
});

webpack-dev-server 做为命令行启动,首先是调用了 webpack-cli 模块下的两个文件,分别配置了命令行提示选项、和从命令行和配置文件收集了 webpack 的 config,这样复用了webpack-cli 的代码,保持行为一致,上面贴出来的代码省略了这部分代码,有兴趣的能够本身翻阅源码。bootstrap

以后调用 processOptions 对收集的参数进行一些默认处理后获得须要传给 webpack 的 config 和须要传给 wepack-dev-server 的 options。传入这两个配置参数调用 startDevServer,startDevServer 这个函数主要是先调用 webpack 函数实例化了 compiler,注意这里没有给 webpack 函数传入回调函数,根据 webpack 源码实现,不传入回调函数就不会直接运行 webpack 而是返回 webpack compiler 的实例,供调用方自行启动 webpack 运行。拿到 webpack compiler 实例和先前的 webpack-dev-server 的 options 就去实例化 Server,这个 Server 类就是实现 webpack-dev-server 的核心逻辑。api

最后调用 Server 类的 listen 方法,就正式开启监听请求,listen 方法后面会再解析具体逻辑。这就是 webapck-dev-server 大体的启动过程,后面来看下 Server 类具体作了什么。promise

核心框架

// webpack-dev-server/lib/Server.js

class Server {
  constructor(compiler, options = {}, _log) {
    // 0. 校验参数是否符合 schema, 不符合会抛出错误
    validateOptions(schema, options, 'webpack Dev Server');
    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, { logLevelthis.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');
    }
  }
}

这部分代码稍长,主逻辑都在构造函数里。

在构造函数中进行参数校验,参数缺省值处理,注入客户端代码,绑定 webpack compiler 钩子,这里主要关注是 done 钩子,(在 webpack compiler 实例每次触发编译完成后就会进行 webscoket 广播 webpack 的编译信息)。实例化 express 服务器,添加 webpack-dev-middleware 中间件用于处理静态资源的请求,而后初始化 HTTP 服务器。

咱们在上面的 webpack-dev-server.js 中调用的 listen 方法就是开始监听配置的端口,监听回调里再初始化 websocket 的服务端。代码执行到这已经完成了服务器端全部的逻辑,可是 webpack 尚未启动编译,用户打开浏览器后请求设置的IP和端口服务端又是怎么处理的呢?这部分暂时被咱们略过了,这部分就是 webpack-dev-middleware 处理的内容了。

webapck-dev-middleware 初始化

webapck-dev-middleware 做为一个独立的模块,如下是它的目录结构:

.
├── README.md
├── index.js
├── lib
│   ├── DevMiddlewareError.js
│   ├── context.js
│   ├── fs.js
│   ├── middleware.js
│   ├── reporter.js
│   └── util.js
└── package.json

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);
};

wdm 函数返回结果是 express 标准的 middleware 用于处理浏览器静态资源的请求。执行过程当中显示初始化了一个 context 对象,默认非 lazy 模式,开启了 webpack 的 watch 模式开始启动编译。

而后将 compiler 的原来基于 fs 模块的 outputFileSystem 替换成 memory-fs模块的实例。memory-fs 是实现了 node fs api 的基于内存的 fileSystem,这意味着 webpack 编译后的资源不会被输出到硬盘而是内存。最后将真正处理请求的 middleware 返回装载在 express 上。

webapck-dev-middleware 处理请求

当用户在浏览器打开配置的IP和端口,如 https://localhost:8080 ,请求就会被 middleware 处理。middleware 使用 memory-fs 从内存中读到请求的资源返回给客户端。

// 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, proce***equest, req);
      // eslint-disable-next-line consistent-return
      function proce***equest({

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

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

webscoket 通讯

当咱们编辑了源代码,触发 webpack 从新编译,编译完成后执行 done 钩子上的回调。具体可参考上面 Server.js 中 setupHooks 方法。_sendStats 方法会先广播一个类型为 hash 的消息,而后再根据编译信息广播 warnings/errors/ok 消息。这里咱们只关注正常流程 ok 消息。

咱们已经很熟悉客户端接收到更新后都会对应用进行 Reload 来获取更好的开发体验。具体是 liveReload(刷新整个页面)仍是 hotReload(更新改动过的模块)就取决于咱们传入的 hot 选项。

如下代码就是咱们在上面就讲到的在 webpack 编译的时候注入到 bundle.js 进去的。当用户打开页面预览时,这些代码就会自动执行。

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


    reloadApp(options, status);
  }
};
socket(socketUrl, onSocketMessage);

client/index.js 主要就是初始化了 webscoket 客户端,而后为不一样的消息类型设置了相应的回调函数。

在前面 Server.js 中咱们看到若是 hot 选项为 true 时,当 websocket 客户端链接到服务端,服务端会先广播一个 hot 类型的消息,客户端接收到后会把 options 对象的 hot 设置为 true。

服务端在每次编译后都会广播 hash 消息,客户端接收到后就会将这个webpack 编译产生的 hash 值暂存起来。编译成功若是没有 warning 也没有 error 就会广播 ok 消息,客户端接收到 ok 消息就会执行 ok 回调函数中的 reloadApp 刷新应用。

webscoket 消息处理

// webpack-dev-server/client/utils/reloadApp.js

function reloadApp(_ref, _ref2{
  var hotReload = _ref.hotReload,
      hot = _ref.hot,
      liveReload = _ref.liveReload,
      currentHash = _ref2.currentHash;

  if (hot) {
    log.info('[WDS] App hot update...');

    var hotEmitter = require('webpack/hot/emitter');

    hotEmitter.emit('webpackHotUpdate', currentHash);
  }
  else if (liveReload) {
      var rootWindow = self; // use parent window for reload (in case we're in an iframe with no valid src)

      var intervalId = self.setInterval(function ({
        if (rootWindow.location.protocol !== 'about:') {
          // reload immediately if protocol is valid
          applyReload(rootWindow, intervalId);
        } else {
          rootWindow = rootWindow.parent;

          if (rootWindow.parent === rootWindow) {
            // if parent equals current window we've reached the root which would continue forever, so trigger a reload anyways
            applyReload(rootWindow, intervalId);
          }
        }
      });
    }

  function applyReload(rootWindow, intervalId{
    clearInterval(intervalId);
    log.info('[WDS] App updated. Reloading...');
    rootWindow.location.reload();
  }
}

Hot Module Replacement

触发 hot check

若是设置了 hot: true 客户端就会引入 webpack/hot/emitter,触发一个 webpackHotUpdate 事件,将 hash 值传递过去。这个webpack/hot/emitter 咱们查阅 webpack 源码看到其实就是 node 的 events 模块。咱们暂时不关注这个事件会触发什么回调后面再具体再看。若是没有设置 hot: true。那么就是使用 liveReload 模式,liveReload 就比较无脑,直接刷新整个页面。

再回到上一个问题,究竟是在哪里接收 webpackHotUpdate 事件并处理的呢?就是 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();
  }
 });

模块更新依赖判断

module.hot.check 方法位于 webpack/lib/HotModuleReplacement.runtime.js 中,是 webpack 内置的 HotModuleReplacementPlugin 注入在 webpack bootstrap runtime 中的。

因此 check 方法主要作了什么呢,这里提早总结一下。在 webpack 使用了 HotModuleReplacementPlugin 编译时,每次增量编译就会多产出两个文件,形如c390bbe0037a0dd079a6.hot-update.jsonmain.c390bbe0037a0dd079a6.hot-update.js,分别是描述 chunk 更新的 manifest文件和更新事后的 chunk 文件。那么浏览器端调用 hotDownloadManifest 方法去下载模块更新的 manifest.json 文件,而后调用 hotDownloadUpdateChunk 方法使用 jsonp 的方式下载须要更新的 chunk。

hotDownloadUpdateChunk 下载完成后调用 webpackHotUpdate 回调。回调内拿到更新的模块,而后从模块自身开始进行冒泡,若是发现只要有一条祖先路径没有 accept 此次改动就直接刷新页面实行降级强制更新, 若是有被 accept, 就会替换掉原来 webpack runtime 里 module 里旧的模块,而后再执行 accept 的 callback 进行更新。为何要执行这样的判断呢?

假设给定这样的依赖路径:

componentA.js -> componentB.js -> app.js -> index.js
componentA.js -> componentC.js -> app.js -> index.js

参考如下的代码示例,accept 指该 module 的祖先模块调用了 module.hot.accept, 处理了该 module 更新事后的业务逻辑,通常都是 rerender。

// index.js
if(module.hot) {
    module.hot.accept('./app'function({
        rerender()
    })
}

若是咱们对 componentA.js 进行了更改,可是若是仅仅 componentB accept 了更改,componentC 却没 accept,那么这样是没有到达更新的目的的。因此在祖先路径回溯的时候,要保证每一条路径都被 accept。

function hotCheck(apply{
  // 1. 拿此次编译后的 hash 请求服务器,拿到结构为 {c: {main: true} h: "ac69ee760bb48d5db5f5"} 的数据
  return hotDownloadManifest(hotRequestTimeout).then(function(update{
    hotAvailableFilesMap = update.c;
    hotUpdateNewHash = update.h;

    // 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;
  });
}

hotCheck 方法就是和服务器进行通讯拿到更新事后的 chunk,下载好 chunk 后就开始执行 HMR runtime 里的 webpackHotUpdate 回调。

window["webpackHotUpdate"] = function webpackHotUpdateCallback(chunkId, moreModules{
 hotAddUpdateChunk(chunkId, moreModules);
 if (parentHotUpdateCallback) parentHotUpdateCallback(chunkId, moreModules);
} ;

通过一系列方法调用而后来到 hotApplyInternal 方法,这个方法把更新事后的模块 apply 到业务中,整个方法比较长,就不完整贴出来了。这里拿出核心的部分,

for (var id in hotUpdate) {
    if (Object.prototype.hasOwnProperty.call(hotUpdate, id)) {
        var result;
        if (hotUpdate[id]) {
            result = getAffectedStuff(moduleId);
        } else {
            result = {
                type"disposed",
                moduleId: id
            };
        }
        switch (result.type) {
            case "self-declined":
            case "declined":
            case "unaccepted":
                if (options.onUnaccepted) options.onUnaccepted(result);
                if (!options.ignoreUnaccepted)
                    abortError = new Error(
                        "Aborted because " + moduleId + " is not accepted" + chainInfo
                    );
                break;
            case "accepted":
                if (options.onAccepted) options.onAccepted(result);
                doApply = true;
                break;
            case "disposed":
                break;
            default:
                throw new Error("Unexception type " + result.type);
        }
    }
}

把更新过的模块进行遍历,找到被该模块影响到的祖先模块,返回一个结果,若是结果标识为 unaccepted 就会被抛出错误,而后走到 webpack/hot/dev-server.js 里的 catch 进行页面级刷新。若是被 accept 的话就会执行后面的 apply 的逻辑。

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
  };
}

module apply

看过 webpack runtime 代码以后咱们知道 runtime 里声明了 installedModules 这个变量,里面缓存了全部被__webpack_require__ 调用后加载过的模块,还有 modules 这个变量存储了全部模块。(若是不了解 webpack runtime 能够先了解 webpack runtime 的执行机制)。若是模块有被 accept 的话,那么就会从 installedModules 里删掉旧的模块,把模块从父子依赖中删除,而后把 modules 里面的模块替换成新的模块。

// remove module from cache
delete installedModules[moduleId];


// insert new code
for (moduleId in appliedUpdate) {
    if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
        modules[moduleId] = appliedUpdate[moduleId];
    }
}

这样仅仅完成了模块的替换,尚未执行过新模块代码,也就是没被__webpack_require__ 调用过。对于 ES Module,新模块代码的执行是在 accept 函数的 callback 里被 webpack 自动插入代码执行的。使用 require() 引入的模块不会被自动执行。

if(module.hot) {
    module.hot.accept('./App'function({
        console.log('accepted')
    })
}

会被 webpack 改造为:

if(true) {
    module.hot.accept("./src/App.js"function(__WEBPACK_OUTDATED_DEPENDENCIES__{
      _App__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/App.js");
      (function({
        console.log('accepted')
      })(__WEBPACK_OUTDATED_DEPENDENCIES__);
    }.bind(this))
}

因此新模块的代码是在 accept 方法回调执行以前被执行的。引入了新代码后就能够执行咱们的业务代码,这些业务代码通常都和框架相关,框架去处理模块的热更新逻辑。好比 react-hot-loader, vue-loader,想要了解更多能够参考 官方文档。

总结

最后总结一下,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 方法的回调函数,执行新模块代码,引入新模块,执行业务处理代码。

为了更加熟悉完整的编译流程能够初始化一个 webpack-dev-server 项目,使用 vscode 的 debug 功能进行断点调试的方式去阅读源码。