webpack系列之浅谈热加载

1,216 阅读8分钟

webpack 是个很强大的构建工具,其丰富灵活的配置决定了使用也不简单。在面试中经常能遇到 webpack 相关的问题,如果平常只是使用脚手架如 vue-cli 而没有好好深入学习研究 webpack 的话,估计答不上什么。我相信,如果没有深入了解,部分面试官也问不出什么。可能也就变成了两个人侃侃如何配置出入口,常见的 loader,plugin 有哪些。

作为一名多年油条前端,一直没有正视 webpack 相关知识,面对 webpack 相关的面试题更是一问三不知。这次准备好好学习研究 webpack相关内容,并且将学习内容记录成 webpack 系列,希望可以让不了解 webpack 的小白能对其有所掌握。

热加载

多年之前刚接触 vue,初次尝试到了修改前端代码不需要手动刷新,即可实现页面实时更新的神奇。后面了解到此神奇的功能叫热加载,但身为前端混混,一直没有去学习研究该功能的实现原理,甚至连配置都是脚手架完成的,没有亲自尝试。

什么是热加载

提到热加载,大家经常还能听到与其对应的热刷新,我们在这对其作下区分

  • 热加载

在修改前端业务代码的时候,浏览器页面能实时更新对应的模块内容,保持其它页面状态,无需重新构建工程,无需手动刷新浏览器。

  • 热刷新

在修改前端业务代码的时候,浏览器页面能实时刷新页面,无需重新构建工程,无需手动刷新浏览器。

可见热刷新和热加载都可以将前端开发者从构建和刷新页面中解放出来,提升开发效率。相比之下,热加载比热刷新更加智能些,他能更新对应模块的内容而避免刷新浏览器带来页面状态的改变,如弹窗打开状态,表单填入信息。

热加载的配置

先看看较为简单的热刷新配置

webpack.config.js

devServer: {
    inline: true,
    port: 8080,
    host: '0.0.0.0'
}

真的很简单吧,实际开启 devServer 的时候,inline 默认就是为 true,所以其实不用配置 inline,就是默认开启热刷新的。打扰了!

再来看看热加载的配置,稍微会麻烦一丢丢

webpack.config.js

devServer: {
    inline: true,
    hot: true,
    port: 8080,
    host: '0.0.0.0'
},
plugins: [
    new webpack.HotModuleReplacementPlugin()
]

在我实践中发现,HotModuleReplacementPlugin 插件也是可以不用配置的。后面在网上查阅后才知道,原来 当在 dev-Server 中开启热加载, 贴心的 webpack-dev-server 会自动检查是否配置了 HotModuleReplacementPlugin 插件,如果没有则自动添加。

经过上面的配置,当某一模块代码更新时,模块代码修改 -> webpack 编译 -> 浏览器获取更新资源 -> 调用前端回调函数,这一系列的操作都会自动完成。

但是,这还没完,以上的配置只是帮我们完成了对应模块的资源更新,资源更新以后也得执行些啥才能真正的更新页面吧,所以我们在客户端需要注册监听的回调函数,并在里面完成页面莫部分的更新操作。

示例:

在页面入口中注册回调函数

import count from './count.js';

if (module.hot) {
    module.hot.accept('./count.js', function(a) {
        // 监听count资源修改,并作相应页面更新
        document.getElementById('hot').innerHTML = count;
    });
}

一个新鲜的热加载就这样配置好了,有人疑惑,为什么我在开发的时候没有在客户端注册监听回调函数,也没有关注过 module.hot 也实现了热加载呢?其实是因为大家大家常用的 vue 或 react 框架的脚手架中已经为大家配置好了对应的客户端代码,开箱即用,所以我们无需再在业务代码中参杂对应加载逻辑。

以 vue 为例,其在 vue-loader 中配置热加载

module: {
  rules: [
    {
      test: /\.vue$/,
      loader: 'vue-loader',
      options: {
        hotReload: false // 关闭热重载
      }
    }
  ]
}

根据前面文章提到的 loader 的作用,想必通过 vue-loader 添加客户端的热加载执行逻辑应该不困难吧?(ps:对应实现 loader 源码的大神来说)

原理分析

上文和大家谈了如何配置热加载,包括 webpack 配置及客户端逻辑,应该说是比较简单的。现在我们再来简单聊聊其实现原理,以我的水平也就能侃个大概吧,有兴趣的可以继续往下看。

丰富的 webpack 中间件

热加载的实现涉及 webpack 不同的插件,其中有 webpack-dev-server,webpack-dev-middleware,webpack-hot-middleware,HotModuleReplacementPlugin。

真让人有点头大呢!我们来一个个捋清楚吧。

webpack-dev-server

webpack-dev-server 实际是 node server + webapck-dev-middleware 的组合,其用于开启 node server,并且使用 webapck-dev-middleware 来实现伺服内存目录。

  • webapck-dev-middleware

webapck-dev-middleware 属于 node server 的一个中间件,有点我们前端的请求拦截器的意思,主要作用

  1. 主动调用 webapck watch api 来监听开发目录,当资源更改触发 webpack 编译,产生新的 bundle
// webapck-dev-middleware index.js
context.watching = context.compiler.watch(watchOptions, (error) => {
    if (error) {
    // TODO: improve that in future
    // For example - `writeToDisk` can throw an error and right now it is ends watching.
    // We can improve that and keep watching active, but it is require API on webpack side.
    // Let's implement that in webpack@5 because it is rare case.
    context.logger.error(error);
    }
});
  1. 在编译过程中,停止请求的响应,待编译完成后再返回请求。(这点大家平时有感受吧,当触发 webpack 重新编译的时候,刷新页面不会及时呈现,当完成编译时页面才会更新呈现)

监听 webapck 编译完成执行 done 函数

// webapck-dev-middleware setupHooks.js 
context.compiler.hooks.done.tap('DevMiddleware', done);

编译过程中的请求延迟处理

// webapck-dev-middleware ready.js 
export default function ready(context, callback, req) {
  if (context.state) {
    return callback(context.stats);
  }

  const name = (req && req.url) || callback.name;

  context.logger.info(`wait until bundle finished${name ? `: ${name}` : ''}`);

  context.callbacks.push(callback);
}

编译后执行的 done 函数

// webapck-dev-middleware setupHooks.js 
function done(stats) {
    ...
    // Execute callback that are delayed
    callbacks.forEach((callback) => {
        callback(stats);
    });
    ...
}
  1. 将 webpack 编译后的资源存储在内存中,减少读取硬盘的 I/O 耗时。这就解释了为什么在运行本地服务的时候没有编译输出,因为输出在了内存中,所以我们看不到。

设置 webpack 编译的输出目录为内存

// webapck-dev-middleware index.js 
setupOutputFileSystem(context);

// webapck-dev-middleware setupOutputFileSystem.js 
import { createFsFromVolume, Volume } from 'memfs';

outputFileSystem = createFsFromVolume(new Volume());

context.outputFileSystem = outputFileSystem;

请求读取资源时也从内存中寻找对应资源

// webapck-dev-middleware middleware.js 
const filename = getFilenameFromUrl(context, req.url);

content = context.outputFileSystem.readFileSync(filename);
webpack-hot-middleware

webpack-hot-middleware 包含了 dev server 的中间件及需要注入客户端的代码。

其中中间件用于拦截 server 的热更新请求

// webpack-hot-middleware middleware.js 
// opts.path = /__webpack_hmr
if (!pathMatch(req.url, opts.path)) return next();
eventStream.handler(req, res);

将请求加入客户端列表

// webpack-hot-middleware middleware.js 
var id = clientId++;
clients[id] = res;

同时监听 webpack 的编译,在完成时将模块信息(包括 hash,代码等)返回给客户端

// webpack-hot-middleware middleware.js 
if (compiler.hooks) {
  compiler.hooks.invalid.tap('webpack-hot-middleware', onInvalid);
  compiler.hooks.done.tap('webpack-hot-middleware', onDone);
} else {
  compiler.plugin('invalid', onInvalid);
  compiler.plugin('done', onDone);
}

// =>
function onDone(statsResult) {
  if (closed) return;
  // Keep hold of latest stats so they can be propagated to new clients
  latestStats = statsResult;
  publishStats('built', latestStats, eventStream, opts.log);
}

// =>
eventStream.publish({
  name: name,
  action: action,
  time: stats.time,
  hash: stats.hash,
  warnings: stats.warnings || [],
  errors: stats.errors || [],
  modules: buildModuleMap(stats.modules),
});

// =>
everyClient(function(client) {
  client.write('data: ' + JSON.stringify(payload) + '\n\n');
});

而客户端代码主要用于建立 EventSource 连接,处理服务端返回的更新模块信息

// webpack-hot-middleware client.js 

init();

// =>
function init() {
  source = new window.EventSource(options.path);
  source.onopen = handleOnline;
  source.onerror = handleDisconnect;
  source.onmessage = handleMessage;
}

// =>
processMessage(JSON.parse(event.data));

对于由服务端返回的信息会由 handleMessage 进行处理,再走到 processMessage 函数,在 processMessage 中会对消息类型进行判断,区分 building,built 及 sync 等不同状态,来做出打印日志或其它操作。当确定取到的消息是编译完成的最新信息时再执行 processUpdate。

if (applyUpdate) {
  processUpdate(obj.hash, obj.modules, options);
}

而在 processUpdate 中,将会先判断 hash 是否有更新,如果有更新则会调用 module.hot 的 apply 及 check 方法,具体干了啥我也没有去细究,猜测应该是通过模块信息对比出需要更新的模块及模块代码。

// webpack-hot-middleware process-update.js 
if (!upToDate(hash) && module.hot.status() == 'idle') {
  if (options.log) console.log('[HMR] Checking for updates on the server...');
  check();
}

// => 
function upToDate(hash) {
  if (hash) lastHash = hash;
  return lastHash == __webpack_hash__;
}

// => check
var result = module.hot.check(false, cb);

// => cb
var applyResult = module.hot.apply(applyOptions, applyCallback);

// => 
var applyCallback = function(applyErr, renewedModules) {
  if (applyErr) return handleError(applyErr);

  if (!upToDate()) check();

  logUpdates(updatedModules, renewedModules);
};

最后执行的 logUpdates 函数,就比较简单了,大概就是通过对比从服务端获取到的模块列表(模块ID列表) updatedModules 和需要更新的模块列表(模块ID列表) renewedModules,判断资源是否正常获取,如果有误则不走热加载,直接刷新页面,如果没有问题,则打印相关日志。

// webpack-hot-middleware process-update.js 

function logUpdates(updatedModules, renewedModules) {
    var unacceptedModules = updatedModules.filter(function(moduleId) {
      return renewedModules && renewedModules.indexOf(moduleId) < 0;
    });

    if (unacceptedModules.length > 0) {
      if (options.warn) {
        console.warn(
          "[HMR] The following modules couldn't be hot updated: " +
            '(Full reload needed)\n' +
            'This is usually because the modules which have changed ' +
            '(and their parents) do not know how to hot reload themselves. ' +
            'See ' +
            hmrDocsUrl +
            ' for more details.'
        );
        unacceptedModules.forEach(function(moduleId) {
          console.warn('[HMR]  - ' + (moduleMap[moduleId] || moduleId));
        });
      }
      performReload(); // window.location.reload();
      return;
    }

    if (options.log) {
      if (!renewedModules || renewedModules.length === 0) {
        console.log('[HMR] Nothing hot updated.');
      } else {
        console.log('[HMR] Updated modules:');
        renewedModules.forEach(function(moduleId) {
          console.log('[HMR]  - ' + (moduleMap[moduleId] || moduleId));
        });
      }

      if (upToDate()) {
        console.log('[HMR] App is up to date.');
      }
    }
}

有个疑惑就是,看上面的分析并没有客户端如何获取及替换更新模块的代码

回看一下 client.js,发现有以下代码

// webpack-hot-middleware client.js 

if (subscribeAllHandler) {
  subscribeAllHandler(obj);
}

if (module) {
  module.exports = {
    subscribeAll: function subscribeAll(handler) {
      subscribeAllHandler = handler;
    },
    subscribe: function subscribe(handler) {
      customHandler = handler;
    },
    useCustomOverlay: function useCustomOverlay(customOverlay) {
      if (reporter) reporter.useCustomOverlay(customOverlay);
    },
    setOptionsAndConnect: setOptionsAndConnect,
  };
}

所以猜测,obj 为服务端的返回消息,这里通过 subscribeAll 方法暴露了对返回数据进行监听的能力,所以返回数据都可以被其它外部方法所获取,分析及使用。

HotModuleReplacementPlugin

这个,这个代码没怎么去研究,但结合前面的 webpack-hot-middleware 中间件中缺少客户端获取模块数据及具体分析,对比差异及模块更新的功能,大胆猜测下 HotModuleReplacementPlugin 就是完成那些功能的吧。

热加载原理

前面我们分析了热加载配置中涉及的中间件及插件,有了以上知识的铺垫,我们再来分析下热加载的流程及原理。

  1. webpack-dev-middleware 开启 webpack 的 watch 功能,监听开发者代码修改

  2. 代码修改触发 webpack 重新编译

  3. 编译之后向内存中写入编译后代码(得益于webpack-dev-middleware 将 webpack 输出目录更改为内存)

  4. webpack-dev-middleware 等中间件监听到编译完成

  5. node server 通过 websocket 向客户端推送本次编译 hash。(客户端由 EventSource 接收)

  6. 客户端逻辑判断编译 hash 更新,向服务器发送 ajax 请求获取模块更新列表 hot-update.json

  7. 根据 hot-update.json 返回的模块列表发送 ajax 请求进一步从内存中获取模块数据(代码段)

  8. 客户端使用最新模块代码替换旧数据,再调用相关回调更新页面,至此实现页面热加载

结语

热更新的原理实际是比较简单的, watch代码 -> 实时编译 -> socket推送 -> 客户端请求新数据 -> 替换数据及更新页面。但是如果要分析源码是如何一步步实现这一过程的,各个中间件及插件又是在其中扮演怎样的角色就显得比较晦涩。因为本菜鸟的水平能力有限,所以也是通过查阅网上文章及翻阅部分源码得出本篇文章,其中多有错误的地方,还希望能够多多理解,也很期待大家指出及讨论。

参考


欢迎到前端菜鸟群一起学习交流~516913974