Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Node.js 热更新(一) #2

Open
zhenyong opened this issue Apr 11, 2017 · 1 comment
Open

Node.js 热更新(一) #2

zhenyong opened this issue Apr 11, 2017 · 1 comment

Comments

@zhenyong
Copy link
Owner

背景

刚思考这个话题的时候,首先想到的是 VueReact 的组件热更新(基于 Webpack HMR),后来又想到了 LuaErlang 等语言的热更新,不过在实际开发 Node.js 后台时,使用 remy/nodemon 之类的热重启(侦测代码改动重启程序)工具也够用,于是 Node.js 的热更新(替换模块,无须重启)的验证就一直搁置。

直到最近在使用[「微信机器人」](Chatie/wechaty: Wechat for Bot. Powered by WebDriver / Node.js / Docker)(Node.js) 时,遇到了强烈的需求。这类机器人程序就是:启动了一个网页,登录 Web 微信,通过抓取识别页面中的元素获得一些状态信息,如:消息、好友请求等等,由于它的启动时间也比较长,如果每次修改业务代码后都要重启,那么等待程序启动就要消耗不少时间,导致开发体验很差,于是实践 Node.js 的热更新就迫在眉睫了。

目标

以下是机器人的核心用法:

robot = new Robot()
robot.addEventListener('msg', ...)
robot.removeEventListener('msg', ...)

那么我们的目标:增/删/改 业务逻辑(事件处理器)的时候程序无须重启,自动热更新业务逻辑代码,从而提高开发效率。

思路一:基于 Webpack 验证可行

从 Webpack Wiki hot module replacement · webpack/docs Wiki 了解到,Webpack 能知道「哪个模块需要热更新」,并提供一些钩子,另外 webpack 自有一套模块管理,能够管理替换模块,让你访问的是热更新之后的模块。另外,要实现热加载的不仅要满足「再次加载」,还要考虑如何清空相关的「持久资源」。

所以说,如果基于 webpack HMR 来实现的话,需要完成几件事情:

  1. 把事件处理器的代码模块化,便于 webpack 管理。
  2. 自动加载所有处理器模块
  3. 某个事件处理模块更新后需要拿到老的模块,用来移除老的监听处理器。
  4. 要知道文件的增加和删除,并且拿到模块内容。

1. 业务代码模块化

简单地把每个事件处理器定义为一个文件 *.biz.js

// msg.biz.js
module.exports = {
    evt: 'msg',
    fn() {
        console.log('msg hanlder....')
    }
};

其中 evt 是事件名, fn 是处理器,于是加载一个业务模块后就能拿到事件名称和处理器。
(可能不满足实际要求,先简单验证热更新是否可行哈!)

2. 自动加载

我们约定,业务模块 *.biz.js 都放在 /biz 目录下,该目录下的 index.js 会加载所有业务模块,而 main.js 就只需加载 /biz/index.js

src
 |--- /biz
       |--- a.biz.js
       |--- b.biz.js
       |--- index.js
 		 
 |--- main.js
	

借助 webpack 的 require-context 加载所有 *.biz.js 模块,避免手写 require:

// index.js
// 加载当前目录下所有 `*.biz.js`
const requireContext = require.context('./', true, /\.biz.js/);

// 此时 requireContext.keys() 为 ['./a.biz.js', './b.biz.js']
requireContext.keys().forEach(key => {

    const module = requireContext(key);
    // 相当于 module = require('./biz/a.biz.js')
    
    // 于是拿到事件名和处理器,然后进行事件监听
    // robot.addEventListener(module.evt, module.fn)
    
});

3. 修改后热更新

参考 Wiki 的例子 Example 3,知道 require.context 如何使用热更新机制

// index.js
// 启动 webpack HRM 时则 module.hot 为 true
if (module.hot) {
	// 表示该 context 下的模块都要检测更新
    module.hot.accept(requireContext.id, () => {

        const requireContext = require.context('./', true, /\.biz.js/);
        requireContext.keys().forEach(key => {
        
            const newModule = requireContext(key);

            // 前面首次自动加载所有模块后,记录到 oldModules 对象(<key,module>)
            // 如果模块内容不一样,则表示要作热更新处理了
            if (oldModules[key] !== newModule) {
               	// ... 对老模块 oldModules[key] 移除事件监听
               	// ... 对新模块 newModule 注册事件监听
					
					// 同时更新缓存记录
                oldModules[key] = newModule;
            }
        });
    });
}

到了这一步,修改任何 *.biz.js 的代码都能自动热更新了。

4. 增删文件后热更新

上面的代码已经不小心实现了 「增加文件后热更新」,因为 module.hot.accept(requireContext.id 表示检测 ./biz/*.biz.js 的更新,如果增加一个 c.biz.js,那么 requireContext.keys() 就变成 [ ..., './c.biz.js'],于是新模块不等于老模块(不存在),从而使用 c.biz.js 注册事件监听器。

对于删除文件后的热更新,则在上面代码基础上增加:

    if (module.hot) {
        module.hot.accept(requireContext.id, () => {
            
            // 在重新加载目录下的所有模块前,对老记录作个副本
            const oldKeysRetain = {};
            Object.keys(oldModules)
            	.forEach(k => (oldKeysRetain[k] = true));

            const requireContext = require.context('./', true, /\.biz.js/);
            requireContext.keys().forEach(key => {
            
            	  // 如果某模块存在当前目录,则从临时记录中抹去
                delete oldKeysRetain[key];
                const newModule = requireContext(key);
                if (oldModules[key] !== newModule) {
                   ...
                }
            });

			// 未抹去的部分,意味着不存在当前目录下了,也就是被删除了
            Object.keys(oldKeysRetain).forEach(key => {
                // ... 对老模块移除事件监听
                delete oldModules[key];
            });
        });
    }

经过以上四步,算是初步验证了,借助 Webpack 来玩是可以的,当然我们作了不少严格约定,不过不影响这一阶段的思路。

完整代码请移步:zhenyong/webpack-hot-nodejs-demo: Webpack HMR demo use in Node.js, showing how to auto add/remove listeners.

思路二:基于 Webpack 进阶

上面一种思路存在一些问题

  1. 业务代码的格式限制太死,不够灵活
  2. 在生产阶段也耦合了 webpack

于是我想,约定业务代码格式是为了方便通过模块管理事件的注册和移除,假如说在不侵入代码,不作任何约定的情况下,也能知道某个模块注册了哪些事件,是不是就不需约定了,好像是的:

//## a.biz.js 不约定业务代码格式
robot.addLisenter('msg', ...)


//## 入口.js
robot = new Robot();

_add = robot.addLisenter
robot.addLisenter = () => {
	// 拦截注册事件方法
	// 从而记录下 a.biz 模块都注册了哪些事件处理器
}
require('a.biz')
robot.addLisenter = _add

但是问题来了,我们的目标包括「自动加载所有业务模块,增删文件都能热更新」,那么在开发阶段我们还是借助 webpack 的 require.context 方法,并且约定每个业务模块的入口文件命名为 *.biz.js,至于里面代码怎么写就随意了,而在生产阶段可以遍历文件找到所有 *.biz.js 进行加载,无须依赖 webpack。

剩下的大部分思路跟 #思路一 类似,代码可参考 zhenyong/webpack-hot-nodejs-demo: Webpack HMR demo use in Node.js, showing how to auto add/remove listeners.

更多思路

最开始写这篇文章是想深扒一下 Node.js 的模块管理和缓存结构,然后验证一下通过清除模块缓存来做热更新是否可行,后来感觉 webpack 给我们作了很多工作,于是就先用 webpack 玩了一轮,看来择日还得再写一篇(二)了

问题

热更新的主要目的是为了提高开发效率,并不是为了在生产上玩热更新,毕竟还有很多潜在问题,例如,模块中涉及全局状态或者单例资源,通过热更新可能会引起混乱......

参考

@xmsz
Copy link

xmsz commented Nov 24, 2018

想知道后续

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants