热更新简单实现

808 阅读4分钟

一直对webpack热更新非常好奇,在看了一些资料之后。尝试写一个简单的demo模仿。

step1

第一步要实现一个跟webpack类似的模块处理工具。这里直接用极客时间上的一个课程的demo. 简单介绍一下这几个文件:

simple-webpack
├─package-lock.json
├─package.json
├─simplepack.config.js
├─src
|  ├─greeting.js
|  ├─index.html
|  └index.js
├─lib
|  ├─compiler.js
|  ├─index.js
|  └parser.js
  • simplepack.config.js:类似webpack.config.js
  • src: 源码
  • lib:类似webpack。里面的parser.js主要使用babylon这个库根据ast树分析依赖

step2

1.过程

第二步,开一个websocket服务,并且在html插入通讯脚本。webpack中,客户端会收到hot、liveReload、invalid、hash、still-ok等15种类型的通知,其中,wanring类型和ok类型会触发reloadApp函数,开了热更新后,这个函数里面会触发webpackHotUpdate事件,这个事件的处理事件如下:

hotEmitter.on("webpackHotUpdate", function (currentHash) {
  lastHash = currentHash;
  if (!upToDate() && module.hot.status() === "idle") {
    log("info", "[HMR] Checking for updates on the server...");
    check();
  }
});
log("info", "[HMR] Waiting for update signal from WDS...");

function check() {
  module.hot
    .check(true)
    .then(function (updatedModules) {
    if (!updatedModules) {
      log("warning", "[HMR] Cannot find update. Need to do a full reload!");
      log(
        "warning",
        "[HMR] (Probably because of restarting the webpack-dev-server)"
      );
      window.location.reload();
      return;
    }

    if (!upToDate()) {
      check();
    }

    __webpack_require__(374)(updatedModules, updatedModules);

    if (upToDate()) {
      log("info", "[HMR] App is up to date.");
    }
  })
    .catch(function (err) {
    // 略
  });
};

经过打点可以知道更新的执行部分就在module.hot.check这个方法里面,如果找不到更新的模块就刷新页面。继续找hot.check方法

function hotCheck(applyOnUpdate) {
  if (currentStatus !== "idle") {
    throw new Error("check() is only allowed in idle status");
  }
  setStatus("check");
  return __webpack_require__.hmrM().then(function (update) {
    if (!update) {
      setStatus(applyInvalidatedModules() ? "ready" : "idle");
      return null;
    }
    setStatus("prepare");
    var updatedModules = [];
    blockingPromises = [];
    currentUpdateApplyHandlers = [];
    return Promise.all(
      Object.keys(__webpack_require__.hmrC).reduce(function (promises,key) {
        __webpack_require__.hmrC[key](
          update.c,
          update.r,
          update.m,
          promises,
          currentUpdateApplyHandlers,
          updatedModules
        );
        return promises;
      },[])
    ).then(function () {
      return waitForBlockingPromises(function () {
        if (applyOnUpdate) {
          return internalApply(applyOnUpdate);
        } else {
          setStatus("ready");
          return updatedModules;
        }
      });
    });
  });
}

setStatus就是更改currentStatus,这些状态的判断跳过。__webpack_require__.hmrM()方法是用fetch发起请求。后面执行hmrC上的方法,根据我的搜索只有一个叫jsonp的方法,这里面根据chunkId执行了一些加载,还有一些找不到出处和用处代码,比如__webpack_require__.f。然后真正触发更改的,是internalApply,这个函数主要是把currentUpdateApplyHandlers里面的每个对象的dispose方法和apply方法各自跑一轮。对象是从applyHandler这个函数里面产生的,这个函数有点长。主要是定义了两个方法:

  • dispose: 更改不需要的模块的各种状态,调用 module.hot._disposeHandlers方法
  • Apply: 更新模块,重新加载模块,调用module.hot._acceptedDependencies[dependency]

然后,这些带下划线的方法是从哪里来的呢? __webpack_require__里面会给每个模块初始化parent、children、hot等对象属性,这些方法在hot对象里面。以_acceptedDependencies为例,当你在模块中调用accept方法的时候,就会加进去。没见过这个方法?我也没用过,仔细看了一下webpack官网才发现

+ if (module.hot) {
+   module.hot.accept('./print.js', function() {
+     console.log('Accepting the updated printMe module!');
+     printMe();
+   })
+ }

像vue、react这种框架,本身在对应的loader里面就实现了该方法,所以我们不用自己写。

2.demo

上面的过程是在是太复杂,搞不定。我要简化一下,首先,修改后的代码直接在socket中返回;其次,也不分析模块之间的父子关系。我就写死一个模块名,模块更新之后的处理也写死统一调用accept函数,也只监听改这个文件的变动。在step1的demo的基础上修改,修改如下

2.1 配置部分改动

'use strict';

const path = require('path');

module.exports = {
    entry: path.join(__dirname, './src/index.js'),
    output: {
        path: path.join(__dirname, './dist'),
        filename: 'main.js'
    },
    template: {
        src: './src/index.html',
        replaceTag: '<!-- [123] -->'
    },
    mode: 'server'
};

加了template配置用来指定html便于插入js,加了mode配置用来区分是打包还是本地运行

2.2 lib/index.js 部分变动

const Compiler = require('./compiler');
const options = require('../simplepack.config');
const path = require('path');
const fs = require('fs');
const WebSocket = require('ws');
const socketScript = `
  <script>
    var ws = new WebSocket('ws://localhost:8090');
    ws.onmessage = function (evt)
    {
      var receivedMsg = evt.data;
      receivedMsg = JSON.parse(receivedMsg);
      receivedMsg.forEach(item => {
        modules[item.filename] = new Function('require', 'module', 'exports', item.transformCode)
        __require__(item.filename)
        if (item.filename === './greeting.js') {
          __require__(item.filename).accept();
        }
      })
    };
  </script>
`;

const compiler = new Compiler(options)
if (options.mode === 'server') {
  const Koa = require('koa');
  const app = new Koa();
  // server部分, 提供资源
  compiler.hooks.afterEmitFiles.tapPromise('afterEmitFiles', res => {
    let html = fs.readFileSync(path.resolve(__dirname, '../', options.template.src)).toString();
    html = html.replace(options.template.replaceTag, `<script>${res}</script>`)
    html = html.replace('</head>', socketScript + '</head>')
    app.use(async ctx => {
      ctx.header['content-type'] = 'text/html';
      ctx.body = html;
    });
    app.listen(8010);
    return Promise.resolve(console.log("Koa运行在:http://127.0.0.1:8010"));
  })
  // socket部分, 提供更新
  let wss = new WebSocket.Server({ port: 8090 });
  let wsList = [];
  let greetingFile = path.resolve(__dirname, '../src/greeting.js');
  fs.watch(greetingFile, () => {
    compiler.run('./greeting.js');
    compiler.hooks.build.tapPromise('rebuild', res => {
      wsList.forEach(item => item.ws.send(JSON.stringify(res)));
      return Promise.resolve(console.log('rebuild'));
    });
  });
  wss.on('connection', function connection(ws, req) {
    const ip = req.socket.remoteAddress;
    wsList.push({ ip, ws })

    ws.on('message', function incoming(message) {
      console.log('message')
      console.log('received: %s', message);
    });
    ws.on('close', function () {
      console.log('close')
      wsList = wsList.filter(item => item.ip !== ip);
    })
  });
}
compiler.run();

这部分主要增加了一个websocket通讯,客户端接受的代码用Function重新替换了一下。监听到文件变动,重新编译一次。run结束后会触发build.tapPromise,随后通知客户端更新

2.3 lib/compiler.js 部分改动


const fs = require('fs');
const path = require('path');
const { getAST, getDependencis, transform } = require('./parser');
const { AsyncSeriesWaterfallHook } = require('tapable');

module.exports = class Compiler {
    constructor(options) {
        const { entry, output } = options;
        this.entry = entry;
        this.output = output;
        this.modules = [];
        this.mode = options.mode;
        this.hooks = {
            afterEmitFiles: new AsyncSeriesWaterfallHook(['arg1']),
            build: new AsyncSeriesWaterfallHook(['arg1'])
        }
    }

    run(filename = null) {
        if (filename) {
            var newModulesList = [];
            const entryModule = this.buildModule(filename);
            newModulesList.push(entryModule);
            newModulesList.map((_module) => {
                _module.dependencies.map((dependency) => {
                    newModulesList.push(this.buildModule(dependency));
                });
            });
            // 对比
            const diff = [];
            newModulesList.forEach(newModule => {
               const target = this.modules.find(item => item.filename === newModule.filename);

               if (!target || target.transformCode !== newModule.transformCode) {
                diff.push({
                    filename,
                    content: newModule.transformCode
                });
               }
            })
            this.hooks.build.promise(newModulesList);
        } else {
            const entryModule = this.buildModule(this.entry, true);
            this.modules.push(entryModule);
            this.modules.map((_module) => {
                _module.dependencies.map((dependency) => {
                    this.modules.push(this.buildModule(dependency));
                });
            });
            this.emitFiles();
        }
    }

    buildModule(filename, isEntry) {
        let ast;
        if (isEntry) {
            ast = getAST(filename);
        } else {
            let absolutePath = path.join(process.cwd(), './src', filename);
            ast = getAST(absolutePath);
        }

        return {
          filename,
          dependencies: getDependencis(ast),
          transformCode: transform(ast)
        };
    }

    emitFiles() { 
        const outputPath = path.join(this.output.path, this.output.filename);
        let modules = '';
        this.modules.map((_module) => {
            modules += `'${ _module.filename }': function (require, module, exports) { ${ _module.transformCode } },`
        });
        const bundle = `
            var modules = {${modules}};
            function __require__(fileName) {
                const fn = modules[fileName];

                const module = { exports : {
                    accept () {}
                } };

                fn(__require__, module, module.exports);

                return module.exports;
            }
            __require__('${this.entry}');
        `;
            
        if (this.mode !== 'server') {
            fs.writeFileSync(outputPath, bundle, 'utf-8');
        }
        this.hooks.afterEmitFiles.promise(bundle);
    }
};

最后是compile.js。增加了两个hook用来通知外部编译结束。run函数增加了针对某个模块编译的处理。因为我其实只监听了一个依赖,所以map和foreach其实可以省一下。emitFiles函数里面对bundle做一些更改,方便替换和更新。

效果

src下html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <p id="p">123</p>
</body>
<!-- [123] -->
</html>

src下index.js

import { greeting } from './greeting.js';
greeting();

src下greeting.js

var count = 0;
export function greeting() {
    document.querySelector('#p').innerHTML = count;
}
export function accept () {
    count++;
    document.querySelector('#p').innerHTML = count;
}

效果如下:

demo