likes
comments
collection
share

前端热更新原理-上篇

作者站长头像
站长
· 阅读数 10

什么是热更新

热更新一般指app有小规模更新,以直接打补丁的形式更新app,对比之下,另一种更新就是重新下载整个app包,热更新方式常见于手游,因为现在的手游都比较大,一般都是几百兆到几个G之间,如果有小的更新就让用户重新下载完整包,估计没几天这个app就没人用了

web热更新&热加载实现的前提

如果按照上面的说法,可能web就没有热更新了,主要因为web是B/S(浏览器+服务端),浏览器主要和服务器通过http协议通信,服务端响应http请求之后,本次http连接就结束了,不像app是c/s(客户端+服务端),服务端无法推送补丁到浏览器更新,但是为什么现在web可以实现 热加载 hot-loader 或者是模块热更替 HMR 主要归功于HTML5中提出的俩种新的通信方式

  • WebSocket: 一种允许浏览器与服务器间建立tcp长链接的通信机制

  • EventSource(Server-Sent-Events): 服务器推送的一个网络事件接口。一个EventSource实例会对HTTP服务开启一个持久化的连接,以 text/event-stream 格式发送事件, 会一直保持开启直到被要求关闭。(具体的可以看这篇文章 www.ruanyifeng.com/blog/2017/0…

区别:

  • 与WebSocket不同的是,EventSource服务端推送是单向的。数据信息被单向从服务端到客户端分发. 当不需要以消息形式将数据从客户端发送到服务器时,这使EventSource成为绝佳的选择。

  • SSE 支持断开重连,WebSocket需要自己实现

  • SSE 使用 HTTP 协议,现有的服务器软件都支持。WebSocket 是一个独立协议。

  • SSE一般只用来传送文本,二进制数据需要编码后传送,WebSocket 默认支持传送二进制数据

  • SSE 支持自定义发送的消息类型。

webpack热更新原理

热加载:不保留页面状态,简单粗暴直接刷新浏览器,类似window.location.reload() 热更新: 另一种是基于 WDS (Webpack-dev-server) 的模块热替换,只需要局部刷新页面上发生变化的模块,同时可以保留当前的页面状态(webpack-dev-server自己做不到,需要配合react-hot-loader使用),比如复选框的选中状态、输入框的输入等

Webpack 构建

运行npm run start,会在控制台打印出如下信息

前端热更新原理-上篇

其中的 hash:8420886c15f084075f1d 字段,代表一次编译的标识。当修改代码保存后,webpack会重新编译代码,控制台显示如下

前端热更新原理-上篇

发现hash已经变为 94992a48d7e645922c7b 因为这代表了一次新的编译 对比发现,会出现新的文件 新的js: main.8420886c15f084075f1d.hot-update.js 新的json: 8420886c15f084075f1d.hot-update.json 观察仔细可以发现上一次的 hash:8420886c15f084075f1d 为本次生成js和json的前缀 再来看一下浏览器端,代码修改保存,webpack编译后,浏览器都会发出一下俩个请求

前端热更新原理-上篇

可以看出请求的文件即为本次编译生成的js和json文件,然后来看一下每一个文件的内容 首先看一下json文件的内容

前端热更新原理-上篇

c 字段代表,当前变化的 moduleh 字段代表当前编译的 hash ,用于作为下次请求文件的前缀 再看一下js文件的内容, xxx 即为 moduleId , 以后再讨论 webpackHotUpdate

webpackHotUpdate("main",{ 
    'xxx': (function(module, __webpack_exports__, __webpack_require__) {})) 
})

今天分享的内容是热更新的过程中服务端都做了什么

服务端都做了什么呢

讨论前,先运行以下几步,通过(create-react-app)来创建的react项目来调试

  • npm run eject 来生成配置

  • 修改 config 目录下的 webpack.config.js

const shouldUseReactRefresh = env.raw.FAST_REFRESH || false;
// 改为
const shouldUseReactRefresh = false;

这个是为了关闭 Fast Refresh , 这个是官方实现的热更新,更快更稳定,类似于 react-hot-loader 具体的可以看 blog.logrocket.com/whats-new-i…

  • 修改 config 目录下的 webpackDevServer.config.js
injectClient: false
// 改为
injectClient: true

关于这个的配置将在下面说

  • 去掉 webpack.config.js 中的entry中的 webpackDevClientEntry

  • src 目录下的 index.js 中增加以下代码

if (module.hot) {
  module.hot.accept(() => {
    ReactDOM.render(
      <React.StrictMode>
        <App />
      </React.StrictMode>,
      document.getElementById('root')
    );
  })
}

运行 npm run start 会调用 scripts 中的 start.js start.js

const devServer = new WebpackDevServer(compiler, serverConfig);

webpack-dev-server包下的 Server.js , Server.js 构造函数

class Server {
    constructor(compiler, options = {}, _log) {
        // webpack 的 compiler
        this.compiler = compiler;
        this.options = options;
        updateCompiler(this.compiler, this.options);
        this.setupHooks();
        // 初始化app
        this.setupApp();
        this.setupDevMiddleware();
        this.setupFeatures();
        // 创建server
        this.createServer();
    }
}

首先看一下 updateCompiler() 函数,截取了其中主要的代码 webpack-dev-server/lib/utils/updateCompiler.js

'use strict';

/* eslint-disable
  no-shadow,
  no-undefined
*/
const webpack = require('webpack');
const addEntries = require('./addEntries');
const getSocketClientPath = require('./getSocketClientPath');

function updateCompiler(compiler, options) {
  if (options.inline !== false) {
    // 找 hmr 插件
    const findHMRPlugin = (config) => {
      if (!config.plugins) {
        return undefined;
      }
      return config.plugins.find(
        (plugin) => plugin.constructor === webpack.HotModuleReplacementPlugin
      );
    };

    const compilers = [];
    const compilersWithoutHMR = [];
    let webpackConfig;
    webpackConfig = compiler.options;
    compilers.push(compiler);
    // 没有找到
    if (!findHMRPlugin(compiler.options)) {
      compilersWithoutHMR.push(compiler);
    }
    // 向入口文件中添加东西
    addEntries(webpackConfig, options);
    
    // 如果找到了插件,那么运行插件(即调用插件的apply方法)
    if (options.hot || options.hotOnly) {
      compilersWithoutHMR.forEach((compiler) => {
        const plugin = findHMRPlugin(compiler.options);
        if (plugin) {
          plugin.apply(compiler);
        }
      });
    }
  }
}


看一下 addEntries() 方法 webpack-dev-server/lib/utils/addEntries.js

'use strict';

const webpack = require('webpack');
const createDomain = require('./createDomain');

/**
 * A Entry, it can be of type string or string[] or Object<string | string[],string>
 * @typedef {(string[] | string | Object<string | string[],string>)} Entry
 */

/**
 * Add entries Method
 * @param {?Object} config - Webpack config
 * @param {?Object} options - Dev-Server options
 * @param {?Object} server
 * @returns {void}
 */
function addEntries(config, options, server) {
  if (options.inline !== false) {
    const app = server || {
      address() {
        return { port: options.port };
      },
    // clientEntry
    const domain = createDomain(options, app);
    const sockHost = options.sockHost ? `&sockHost=${options.sockHost}` : '';
    const sockPath = options.sockPath ? `&sockPath=${options.sockPath}` : '';
    const sockPort = options.sockPort ? `&sockPort=${options.sockPort}` : '';
    const clientEntry = `${require.resolve(
      '../../client/'
    )}?${domain}${sockHost}${sockPath}${sockPort}`;
    // hotEntry
    if (options.hotOnly) {
      hotEntry = require.resolve('webpack/hot/only-dev-server');
    } else if (options.hot) {
      hotEntry = require.resolve('webpack/hot/dev-server');
    }

    const checkInject = (option, _config, defaultValue) => {
      if (typeof option === 'boolean') return option;
      if (typeof option === 'function') return option(_config);
      return defaultValue;
    };

      const additionalEntries = checkInject(
        options.injectClient,
        config,
        webTarget
      )
        ? [clientEntry]
        : [];

      if (hotEntry && checkInject(options.injectHot, config, true)) {
        additionalEntries.push(hotEntry);
      }
      // 
      config.entry.unshift(additionalEntries)
      // 如果没有hmr插件时,将hmr插件加入
      config.plugins.push(new webpack.HotModuleReplacementPlugin());
      
      
      
    });
  }
}

module.exports = addEntries;

通过上面可知整个 updateCompiler() 函数主要做了

  • 将以下俩个文件加入到入口中 xxx/node_modules/webpack-dev-server/client/index.js 文件主要负责和服务端进行通信 xxx/node_modules/webpack/hot/dev-server.js 主要负责热更新的逻辑
[
    'xxx/node_modules/webpack-dev-server/client/index.js?[http://localhost:3000](http://localhost:3000/)'
    'xxx/node_modules/webpack/hot/dev-server.js'
    '../src/index.js'
]
  • 加入 HotModuleReplacementPlugin 插件,并调用执行插件(调用apply方法)

继续看一下 setupHooks 主要做了什么,截取了其中主要代码 Server.js

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

// send stats to a socket or multiple sockets
_sendStats(sockets, stats, force) {
  this.sockWrite(sockets, 'hash', stats.hash);
  this.sockWrite(sockets, 'ok');
}

setupHooks 主要做了

  • 监听webpack编译完成,通过 websocket 发送消息给前端

接着向下看 setupApp Server.js

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

没什么好说的,接着看 setupDevMiddleware

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

webpackDevMiddleware , 文件位于 webpack-dev-middleware 包下的index.js index.js

module.exports = function wdm(compiler, opts) {

  const context = createContext(compiler, options);
    // 监听代码变化,重新编译
    context.watching = compiler.watch(options.watchOptions, (err) => {
      if (err) {
        context.log.error(err.stack || err);
        if (err.details) {
          context.log.error(err.details);
        }
      }
    });

  setFs(context, compiler);
  // 暂时先不管返回的东西
};
  // 省略了一些没用的代码
  setFs(context, compiler) {

    let fileSystem;

    // store our files in memory
    const isConfiguredFs = context.options.fs;
    const isMemoryFs =
      !isConfiguredFs &&
      !compiler.compilers &&
      compiler.outputFileSystem instanceof MemoryFileSystem;

    if (isConfiguredFs) {
      // 省略
    } else if (isMemoryFs) {
      fileSystem = compiler.outputFileSystem;
    } else {
      fileSystem = new MemoryFileSystem();
      compiler.outputFileSystem = fileSystem;
    }
    context.fs = fileSystem;
  },

从上面代码可以看出到目前为止,主要做了

  • watch mode 的方式启动了 webpack ,一旦监测的文件变更,便会重新进行编译打包,

  • webpack 文件的存储方式改为了内存存储,提高了文件的存储读取效率。

继续看webpack-dev-middleware 中 index.js 返回的内容 webpack-dev-middleware/index.js

  function wdm (compiler, opts) {
      return Object.assign(middleware(context), {
          // 省略
      });
  }
  // middleware
  module.exports = function wrapper(context) {
  return function middleware(req, res, next) {
    let filename = getFilenameFromUrl(
      context.options.publicPath,
      context.compiler,
      req.url
    );
    return new Promise((resolve) => {
      // 如果内存中文件名满足hash,且是一个文件而不是目录,就会执行processRequest
      handleRequest(context, filename, processRequest, req);
      // eslint-disable-next-line consistent-return
      function processRequest() {
        try {
         // 省略代码
        // 读取文件内容
        let content = context.fs.readFileSync(filename);
        // 省略代码
        if (res.send) {
          res.send(content);
        } else {
          res.end(content);
        }

        resolve();
      }
    });
  };
};

middleware 方法是一个中间件包装函数,然后返回这个中间件,中间件的作用是拦截浏览器请求的更新文件,从内存中读取内容返回,回到 setupDevMiddleware 方法 webpack-dev-server/lib/Server.js

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

由此可知, this.middleware 即为返回的中间件 setupDevMiddleware 主要做了

  • watch mode 的方式启动了 webpack ,一旦监测的文件变更,便会重新进行编译打包,

  • webpack 文件的存储方式改为了内存存储,提高了文件的存储读取效率。

  • 初始化 this.middleware 中间件,中间件的作用是拦截浏览器请求更新文件,从内存中读取文件内容返回

接着向下看 setupFeature() 方法 webpack-dev-server/lib/Server.js

  setupFeatures() {
    const features = {
      middleware: () => {
        // include our middleware to ensure
        // it is able to handle '/index.html' request after redirect
        this.setupMiddleware();
      }
    };
    features['middleware']()
  }
  // this.middleware 为上面的中间件,加载该中间件
  setupMiddleware() {
    this.app.use(this.middleware);
  }

setUpFeature() 主要做了

  • 加载中间件

继续向下看 createServer() 函数

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

主要做了

  • 创建服务器

回到scripts/start.js 中 scripts/start.js

devServer.listen(port, HOST, err => {
  // 省略代码
  openBrowser(urls.localUrlForBrowser);
});

调用Server.js 中的 listen() 方法 webpack-dev-server/lib/Server.js

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

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

listen() 方法主要做了

  • 创建websocket

用流程图表示整个以上过程

前端热更新原理-上篇

热更新完整的流程如下

前端热更新原理-上篇

图片来自( segmentfault.com/a/119000002…

参考文档