js热更新简单分析

4,749 阅读9分钟

热更新

当然这里更多是菜狗子的一家之言,片面不全,居然还有一堆谬误。欢迎斧正。

前端热更新

既然说到热更新,我们不妨扩展下,补充下前端自动更新的实现。 个人才疏学浅,见过的方式大致分两种

  • 直接刷新界面,大致就是bowersync的方式,直接reload,简单粗暴,规避了许多问题
  • 增量更新webpack-dev-serverHMR

简单讨论下webpack-hot-middleware究竟是怎么实现了热更新。

这里咱不讨论如何替换和覆盖之前执行的结果

个人理解:其实就是一个简单的事件机制

服务端

本质就是一个连接往前端发数据

// server 端
...
publish: function(payload) {
  /** 
  * erveryClient 就是对每个连接都写入。
  * 链接报头,确保链接不会被断开
  *   Content-Type: 'text/event-stream;charset=utf-8',
  *   Connection: 'keep-alive', 这个只会在http1开启
  */
  everyClient(function(client) {
    client.write('data: ' + JSON.stringify(payload) + '\n\n');
  });
},
...

客户端

对接受到数据进行处理。当然这里的数据其实约定过格式。由于这部分代码是运行在前端,剩下的就是基础的dom操作云云了。

/**
* @see https://github.com/webpack-contrib/webpack-hot-middleware/blob/master/client.js
*/
function processMessage(obj) {
  switch (obj.action) {
    case 'building':
      if (options.log) {
        console.log(
          '[HMR] bundle ' +
            (obj.name ? "'" + obj.name + "' " : '') +
            'rebuilding'
        );
      }
      break;
    // ...
    default:
      if (customHandler) {
        customHandler(obj);
      }
  }

  if (subscribeAllHandler) {
    subscribeAllHandler(obj);
  }
}

之前采用 socket.io(目前webpack-dev-server你就能找到类似实现的方式),现在改用EventSource方式。

检活机制

顺带一提,里面实现的一套提高稳定性的机制。

// client
// 每次来信息更新最新活动时间,定时检查是否超出超时时间
function handleOnline() {
    if (options.log) console.log('[HMR] connected');
    lastActivity = new Date();
}

function handleMessage(event) {
    lastActivity = new Date();
    for (var i = 0; i < listeners.length; i++) {
      listeners[i](event);
    }
}
var timer = setInterval(function() {
    if (new Date() - lastActivity > options.timeout) {
      handleDisconnect();
    }
}, options.timeout / 2);

// server
// 定时给前端发信息,用于更新前端的活动时间
...
var interval = setInterval(function heartbeatTick() {
    everyClient(function(client) {
      client.write('data: \uD83D\uDC93\n\n');
    });
}, heartbeat).unref();
 ...

node 热更新

简单讨论完前端的热更新基础之后,我们来了解下node层面的热更新。

如何更新模块代码

首先,我们遇到第一个问题,如何将代码更新到运行中的程序中(emm这种表述怪怪的)

  • 第一步明确我们日出来的代码。也就是一个个字符串集合(一堆二进制数啥的就算了。强行字符串集合好吧) 把字符编译执行,我找到如下的法子
    • evel 执行文件
    • Function构造函数,创建函数
    • vm模块执行代码
小小补充

我试验的时候发现,其实有如下的一个小问题

const vm = require("vm");
var a = 1;
var b = 1;
var c = 1;
d = 1;

vm.runInNewContext(
  `
    console.log('vm',d)
    console.log('vm',typeof a);
    a = 2;
    console.log('vm',a);`,
  global
);
console.log("a", a);
console.log("-------");
eval(`
    console.log('eval',typeof b);
    b = 2;
    console.log('eval',b)
`);
console.log("b", b);

console.log("-------");
const test = Function(
  `
    console.log('function',d)
    console.log('function',typeof c);
    c = 2;
    console.log('function',c)`
);
test();
console.log("c", c);

/**
* 输出结果
    vm 1
    vm undefined
    vm 2
    a 1
    -------
    eval number
    eval 2
    b 2
    -------
    function 1
    function undefined
    function 2
    c 1
*/

以上Function执行结果我发现与浏览器端并不一致,细究原因: 1. Function只能读取全局变量与其内部的变量 2. node加载执行都会包裹一层函数,而我们在浏览器中直接在全局声明一个变量相当于为当前全局对象附加一个属性,值为变量值。这里看变量d就能比较清楚的知道。

小结

  1. Function 取不到当前作用域的变量,只能获取全局变量的,这点会让我们热加载的代码很难作为一个独立安全的模块运行(我能想到只有把变量都放在全局维护。这样会带来很多问题)
  2. eval,Function 不容易调试。用调试工具无法打断点调试,所以麻烦的东西也是不推荐使用。
  3. eval,function运行更慢。这个没有测试下,其实上述方式都是加载字符串,好像都没有办法预编译处理把

JavaScript 为什么不推荐使用 eval?

node 模块加载方式

回到正题,其实我们都知道关于加载模块,其实node中本身就提供了一个超级好的方法,可以引入一个文件(js,node等)基于这个我们似乎就能很简单就可以实现拍黄片那样的热更新了~我们不妨先一起看下node中是怎么实现的require

Talk is cheap ,show me the code. 先扔两个源码地址

node 模块加载与运行

现在我们深入下,看下node是怎么处理js的模块的,估计我写的也不咋地,大家可以先看下阮一峰大佬的讲解 不过好像他的版本有点点老TAT

node模块加载部分的一些源码,简单讲下整个程序的运行,其实就是程序引入初始化一个main module之后,require的时候每个引用文件在新建module,同时缓存,记录关系,最后形成一个树形结构。整个过程可以从这里起步开始看。

/**
* 删除了debug的一些输出具体,具体要看可以看下源码 
* @link https://github.com/nodejs/node/blob/0817840f775032169ddd70c85ac059f18ffcc81c/lib/internal/modules/cjs/loader.js#L874:33
*/ 
Module._load = function(request, parent, isMain) {
  const filename = Module._resolveFilename(request, parent, isMain);
  /**
  * 会缓存一次执行结果,所以每个模块引入只会被执行一次。
  * 如果想再次执行需要清楚掉
  * 这个cache 会被代理到require上
  * 在reqire定义那可以看到:`require.cache = Module._cache;`
  */ 
  const cachedModule = Module._cache[filename];
  if (cachedModule) {
    // 这里会记录一次模块的引用关系,对gc回收的引用有影响。
    updateChildren(parent, cachedModule, true);
    return cachedModule.exports;
  }

  const mod = NativeModule.map.get(filename);
  if (mod && mod.canBeRequiredByUsers) {
    return mod.compileForPublicLoader(experimentalModules);
  }

  /*
  * Don't call updateChildren(), Module constructor already does.
  * 上面这个是原注释。就不翻译了
  */ 
  const module = new Module(filename, parent);

  if (isMain) {
     // 要项目的引用结构其实可以mainModule,递归打印
    process.mainModule = module;
    module.id = '.';
  }
  Module._cache[filename] = module;
  
  /**
  * 有兴趣可以看下这个函数
  * 这里我只大概梳理下加载过程
  * 1. 设定引用可能的文件夹(在module的paths里你可以很清楚的看到结果)
  *     + 所在目录
  *     + 所在目录的node_modules
  *     + /node_modules 目录
  * 2. 获取文件后缀(最后一个“.”开始的内容,tips:以“.”不算哈,会被排除)
  * 3. 根据文件后缀调用预设的函数解析。
  *
  * 其实这个过程会有一堆的尝试操作。这里不做过多赘述。
  * 自行查看'tryPackage,tryFile,tryExtensions'
  * 所以为了性能能好一丢丢,大家可以把引用尽量写的清晰。
  * 省去程序猜你引用的路径,还要读package.json云云
  *****
  *****
  * emmm 这里其实他还约定了一个experimentalModules 
  * @link https://github.com/nodejs/node-eps/blob/master/002-es-modules.md
  */
  tryModuleLoad(module, filename);

  return module.exports;
};

这里在简单补充下js文件的解析过程

首先我们的文件其实都是一些文本信息,首先会被包裹在一个函数里。
tips:(在字符串前加上 `(function (exports, require, module, __filename, __dirname) { `尾部加上后`\n}`);
然后依据是否修改过包裹方式调用`compileFunction` 或者vm模块的`runInThisContext`方法,

emmm这两个方法都是c++那边实现了,就不是很看得懂了TAT
@link https://github.com/nodejs/node/blob/5f8ccecaa2e44c4a04db95ccd278a7078c14dd77/src/node_contextify.h。

emmmm 到这里我们大概能知道node里模块是怎么加载的。大概都忘记标题了吧,回顾下,我们目前要解决的是如何把内容加载进程序。也顺带知道了这些__filename这些常量是怎么来的。

如何删除旧的引用

【FBI WARNING】这个其实很麻烦,我的做法一定不会很全很完美,但是做的不好就很容易导致内存消耗越来越高

旧的不去新的不来,我们现在已经有法子引来新的,那接着来了解下node中如何去掉旧的。简单了解下gc机制。

我只是菜狗子,看不懂c++源码。webkit技术内幕啥的也没耐着性子看完,只看了一些文章和深入浅出nodejs,以下内容来自于归纳,主要来源于深入nodejs第五章。

裂墙安利【深入浅出nodejs】

  • 在V8中,主要将内存分为新生代和老生代两代,
  • 新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。
  • V8堆的整体大小就是新生代所用内存空间加上老生代的内存空间。不会动态的扩展!默认64位系统大概约1.4 GB和32位系统约0.7 GB
  • 通过 --max-old-space-size--max-new-space-size设置最大值
  • 新生代中的对象主要通过Scavenge算法进行垃圾回收
    • Scavenge算法主要采用Cheney算法,采用复制的方式实现的垃圾回收算法
    • 将堆内存分为两个semispace,一个使用,一个闲置
    • 分配对象时,分配在from中,回收时检查存活则复制到to,释放非存活,最后交换空间
  • 在新生代被Scavenge搞过多次(一些文章指出时两次)仍旧存活,升级进入老生代
  • 老生代用Mark-Sweep与Mark-Compact结合的方式进行回收
    • Mark-Sweep在标记阶段遍历堆中的所有对象,并标记活着的对象,清除 没有被标记的对象
    • Mark-Compact用于解决前面Mark-Sweep导致的内存不连续的问题,对象在标记为死亡后,在整理的 过程中,将活着的对象往一端移动。
  • 因为gc清理必须让程序暂停下来(否则内存和对象对不上,笋干爆炸),所以其实gc会导致node暂时不执行其他内容,所以大佬们还引入了 Incremental Marking
    • 将原本要一口气停顿完成 的动作改为增量标记(incremental marking)
    • V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction)
  • 其实还有个点在一些文章看到的,如果判断是否存活
    • 最开始大家把这个命题转化成是否有引用,但是引入了一个问题,递归引用就砸了。也容易有外部引用导致的内存泄漏,大概在ie6 7 还能复现
    • 后面改善了一种方式,从根开始往下找,找不到的算是没有死了。就完美解决上述的问题了。
  • 还有个点,就是大对象,对gc其实时很不友好的,每次要判断这个对象真的超级累,救救gc孩子吧
小结

经过上面那一串逼逼,我们大致明白了,要删掉旧的,就删除引用就玩球。所以我们只需要

  • 每次require之后删除require.cache上的内容
  • 删除引用链(ps:前面require 源码里updateChildren 记录的)的引用
  • 自己七七八八的引用就好了
  • 完结撒花

总结

所以针对node热更新(热部署)我个人给以下方法

  • 主动
    • 监听文件变化(设定接口)总之找个法子把你更新文件这事偷偷告诉程序,触发新内容加载
    • 搞死node那些缓存机制,删除require.cache等等,还有主要是你的引用!
  • 被动
    • 搞死node那些缓存机制,删除require.cache等等,还有主要是你的引用!
    • 被调用的时候再去require

完结撒花