webpack增加外圈之 --热更新

713 阅读8分钟

1. 前言

webpack经过增加plugins、loader等流程后,整体的流程已经基本走通,下面我们研究一下webpack的外圈:很重要,但不那么底层的plugins。

今天要研究的是 webpack的热更新 ,对应需要研究的plugins是 webpack-dev-middlewarewebpack-hot-middleware

2. 热更新的好处

热更新的好处显而易见,在开发时,不需要手动的刷新页面,就可以做到局部更新。 这里有两个点: server不刷新页面局部更新。 我们知道有个plugin叫做 webpak-dev-server, 主要作用是两点:为编译好的静态文件提供web服务;提供热更新和热替换。 今天我要分析的其实就是替代webpack-dev-server, 自己实现一个。

3. 要实现一个热更新,我们应该怎么实现。

无论别人的源码写的如何花哨,自己总得找到底层实现逻辑,否则看得多,不一定理解的透。

实现原理,我认为从两个方面来:

1.需要一个服务器,当一个请求过来的时候,能够拿到webpack的最新编译,返回给浏览器(webpack-dev-middleware + express)。

  1. 服务器和浏览器之间如何通信,如何才能做到局部热更新(webpack-hot-middleware)

直白的说,就是: 如何把webpack、静态服务器、浏览器 三方统一起来。

有目标了,下面我们再通过分析webpack-dev-middlewarewebpack-hot-middleware的一部分源码来对照、求证我们上面的分析。

4. 源码逻辑实现

4.1 webpack-dev-middleware 做了什么

这个插件返回一个函数,这个函数支持express的接口。其实也是express的中间件。

当一个请求过来,我们需要能够拿到最新编译结果,那么也需要做以下事:

  • 需要启动webpack的watch功能,当业务代码改变时,能够进行自动重新编译;
  • 需要把webpack默认的产出放置地址由硬盘改为内存存储,这样速度会加快很多;
  • 返回一个express的中间件函数,使之能够在请求时获取内存中存储的数据返回给浏览器;

上面的这三点其实就是这个plugin要做的事。现在我们再回过头来看看源码进行验证。

  1. webpack-dev-middleware github源码地址 返回一个函数,从代码中可以看到:
// index.js

import middleware from "./middleware";
// 因为是一个plugin, 所以一定会接收 compiler, 也就是webpack的实例
export default function wdm(compiler, options = {}) {
    // 设置钩子,为了监听watch等事件
    setupHooks(context);
    // 对打包产出文件存放路径进行处理
    setupOutputFileSystem(context);
    // 开启watch监听
      if (context.compiler.watching) {
        context.watching = context.compiler.watching;
      } else {
        let watchOptions;
        if (Array.isArray(context.compiler.compilers)) {
          watchOptions = context.compiler.compilers.map(
            (childCompiler) => childCompiler.options.watchOptions || {}
          );
        } else {
          watchOptions = context.compiler.options.watchOptions || {};
        }

        context.watching = context.compiler.watch(watchOptions, (error) => {
          if (error) {
            //...
            context.logger.error(error);
          }
        });
      }
    // instance是向外暴露的express中间件函数
    const instance = middleware(context);
    instance.getFilenameFromUrl = (url) => getFilenameFromUrl(context, url);
      // 向外暴露的API
      instance.waitUntilValid = (callback = noop) => {
        ready(context, callback);
      };

      instance.invalidate = (callback = noop) => {
        ready(context, callback);

        context.watching.invalidate();
      };

      instance.close = (callback = noop) => {
        context.watching.close(callback);
      };

      instance.context = context;
    return instance

}

// middleware.js
import getFilenameFromUrl from "./utils/getFilenameFromUrl";
import handleRangeHeaders from "./utils/handleRangeHeaders";
import ready from "./utils/ready";
export default function wrapper(context) {
    // 这里可以看到是一个标准的express中间件
    return async function middleware(req, res, next) {
         ready(context, processRequest, req);
         async function processRequest() {
             // ...
             try {
                 //默认从内存中获取文件内容(也可以自己制定磁盘,但存取都会慢)
                content = context.outputFileSystem.readFileSync(filename);
            } catch (_ignoreError) {
                await goNext();
                return;
            }
         }
         // 调用express api 发送到浏览器
         if (res.send) {
            res.send(content);
        }
    }
}

// 再看看 watch是怎么处理的
// **setupHooks.js**
export default function setupHooks(context) {
    // ... 前置逻辑或方法
  // 监听watchRun (在监听模式下,一个新的 compilation 触发之后,但在 compilation 实际开始之前执行)
  context.compiler.hooks.watchRun.tap("webpack-dev-middleware", invalid);
  // 对监听无效情况处理(在一个观察中的 compilation 无效时执行)
  context.compiler.hooks.invalid.tap("webpack-dev-middleware", invalid);
  // 在 compilation 要完成时以及完成时执行,触发 done这个钩子的时候,说明当次的编译完成
  (context.compiler.webpack
    ? context.compiler.hooks.afterDone
    : context.compiler.hooks.done
  ).tap("webpack-dev-middleware", done);
}


那么经过这么关键的几步,webpack-dev-middleware框架就搭建起来了。 大家可以去源码中仔细看一下。相信大家根据我上面的分析,对这个plugin的认知会事半功倍。

后面我会自己重写一个 webpack-dev-middleware的轮子, 敬请期待。

4.2 webpack-hot-middleware做了什么

按照上面分析的套路,我们需要先想一下如果我们做完了上面的逻辑,自己实现了一个webpack-> express 间的链路, 那么怎么实现 express到浏览器间的链路呢? 要实现局部刷新,那么看起来确实使用长链接处理方法比较有效,否则通信一次就断了,还需要重新连接,会比较麻烦,服务器无法自主向浏览器推送。

假如我们做到了长链接,那么剩下的就是浏览器的局部更新。 要做到局部更新其实并不难。不需要把局部更新dom想的太过复杂。 从最常用的ajax来说,我们获取到了新的内容,然后把某个dom的innerHTML更新,那么浏览器就会进行重绘操作,页面就能在不刷新页面的情况下渲染。那么同样,webpack中,一定也是通过某种机制,最后让某个模块的dom进行了更新。

再从某个模块的dom进行更新往前推,那就需要浏览器端知道要更新哪个模块,再往前就需要知道哪些模块变了,浏览器要去拉变化的模块,把原来的删除,把最新的模块进行渲染。

这样看来,我们好像知道了貌似可行的链路: 浏览器和服务器建立长链接 -> 有新的产出时,服务器需要把更新的内容或者标识发给浏览器 -> 浏览器拿到最新的模块 -> 渲染新的模块 -> 局部热更新完成

好,目前这个链路我们还有需要细化和明确的点: 服务器把内容还是什么标识发给浏览器? 随之对应的是 浏览器是直接拿到新的产出执行么?

这个问题,我认为都是可以的。

webpack-hot-middleware中,是分开处理的。下面我们可以看一下这个plugin的脉络了:

    function webpackHotMiddleware(compiler, opts) {
        //...
        var middleware = function (req, res, next) {
            // ...
        }
        return middleware;
    }

跟 webpack-dev-middleware一样,也是传入了webpack的实例,并且返回了一个express的中间件,那么我就就可以在webpack中使用了:

    const express = require('express');
    const webpackHotMiddleware = require("webpack-Hot-middleware");
    
    const hotMiddleware = webpackHotMiddleware(compiler, {
        heartbeat: 2000,
    })
    express.use(hotMiddleware);

我们继续详细分析一下中间件中的重要脉络:

    function webpackHotMiddleware(compiler, opts) {
        // 创建eventStram,后面的跟浏览器的通信需要
        var eventStream = createEventStream(opts.heartbeat);
        var latestStats = null;
        var closed = false;
        // 同样监听hooks,触发对应的操作
        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);
        }
        // 请求过来的时候,执行其中逻辑
        var middleware = function (req, res, next) {
            eventStream.handler(req, res);
            if (latestStats) {
                // Explicitly not passing in `log` fn as we don't want to log again on
                // the server
                publishStats('sync', latestStats, eventStream);
            }
        }
        // 静态方法 publish
        middleware.publish = function (payload) {
            if (closed) return;
            eventStream.publish(payload);
        };
        // 关闭方法
        middleware.close = function () {

        };
        return middleware;
    }

下面详细看一下 createEventStream这个方法很重要。

function createEventStream(heartbeat) {
  var clientId = 0;
  var clients = {};
  function everyClient(fn) {
    Object.keys(clients).forEach(function (id) {
      fn(clients[id]);
    });
  }
  var interval = setInterval(function heartbeatTick() {
    everyClient(function (client) {
      // 这里是个💓的符号,在后面会详细讲这个符号
      client.write('data: \uD83D\uDC93\n\n');
    });
  }, heartbeat).unref();
  
  return {
     // 关闭连接流
    close: function () {
      clearInterval(interval);
      everyClient(function (client) {
        if (!client.finished) client.end();
      });
      clients = {};
    },
    handler: function (req, res) {
      // 准备的headers,一看是为了跨域处理的
      var headers = {
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'text/event-stream;charset=utf-8',
        'Cache-Control': 'no-cache, no-transform',
        // While behind nginx, event stream should not be buffered:
        // http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering
        'X-Accel-Buffering': 'no',
      };
      // 如果是http1, 那么需要手动设置keepAlive
      var isHttp1 = !(parseInt(req.httpVersion) >= 2);
      if (isHttp1) {
        req.socket.setKeepAlive(true);
        Object.assign(headers, {
          Connection: 'keep-alive',
        });
      }
      
      res.writeHead(200, headers);
      res.write('\n');
      var id = clientId++;
      clients[id] = res;
      req.on('close', function () {
        if (!res.finished) res.end();
        delete clients[id];
      });
    },
    // 向客户端发送数据
    publish: function (payload) {
      everyClient(function (client) {
         // 向客户端发送数据,后面也会讲
        client.write('data: ' + JSON.stringify(payload) + '\n\n');
      });
    },
  };
}

再看看对webapck钩子的处理:

function onInvalid() {
    if (closed) return;
    latestStats = null;
    if (opts.log) opts.log('webpack building...');
    // 向浏览器发送 正在打包的状态
    eventStream.publish({ action: 'building' });
}
function onDone(statsResult) {
    if (closed) return;
    // Keep hold of latest stats so they can be propagated to new clients
    latestStats = statsResult;
    // 向浏览器发送模块信息(模块名、hash等)
    publishStats('built', latestStats, eventStream, opts.log);
}

上面的这些操作是在做什么?貌似看不懂? 其实,这里就是我们上面讲的步骤中的一步: 当编译出新结果后,发送给浏览器的是什么。 通过看这部分,可以看到并不是把新的产出结果直接发送,而是通过心跳检测和监听webpack hooks的方式向客户端发送 stats,也就是更改的模块信息,包括模块名称、模块的新hash等。

发送的这些东西有什么用? 其实这就涉及到马上要讲的一环: 浏览器的更新问题。

浏览器中拿到这些数据后,实际上通过jsonp的形式,再去请求真正的模块数据 这也就是为什么我们在上面看到的对跨域请求的处理增加 Access-Control-Allow-Origin

这里就需要去client.js中仔细看看了。

client.js做了什么

这个js实际上是需要我们手动在webpack中配置,一起打到bundle中的。

module.exports = {
    entry: [
    'webpack-hot-middleware/client?noInfo=true&reload=true',
    '项目入口文件...'
     ],
}


加上的意义在于,浏览器第一次在解析我们的打包文件时,也会把这个client.js顺道执行了,继而在浏览器和服务器建立长连接。

详细看看client.js做了什么:

时间有点晚了,先写到这。 欢迎关注我, 我会尽快补全~~