刚思考这个话题的时候,首先想到的是 Vue 或 React 的组件热更新(基于 Webpack HMR),后来又想到了 Lua、Erlang 等语言的热更新,不过在实际开发 Node.js 后台时,使用 remy/nodemon 之类的热重启(侦测代码改动重启程序)工具也够用,因而 Node.js 的热更新(替换模块,无须重启)的验证就一直搁置。node
直到最近在使用「微信机器人」)(Node.js) 时,遇到了强烈的需求。这类机器人程序就是:启动了一个网页,登陆 Web 微信,经过抓取识别页面中的元素得到一些状态信息,如:消息、好友请求等等,因为它的启动时间也比较长,若是每次修改业务代码后都要重启,那么等待程序启动就要消耗很多时间,致使开发体验不好,因而实践 Node.js 的热更新就迫在眉睫了。webpack
如下是机器人的核心用法:git
robot = new Robot() robot.addEventListener('msg', ...) robot.removeEventListener('msg', ...)
那么咱们的目标:增/删/改 业务逻辑(事件处理器)的时候程序无须重启,自动热更新业务逻辑代码,从而提升开发效率。github
从 Webpack Wiki hot module replacement · webpack/docs Wiki 了解到,Webpack 能知道「哪一个模块须要热更新」,并提供一些钩子,另外 webpack 自有一套模块管理,可以管理替换模块,让你访问的是热更新以后的模块。另外,要实现热加载的不只要知足「再次加载」,还要考虑如何清空相关的「持久资源」。web
因此说,若是基于 webpack HMR 来实现的话,须要完成几件事情:segmentfault
把事件处理器的代码模块化,便于 webpack 管理。缓存
自动加载全部处理器模块微信
某个事件处理模块更新后须要拿到老的模块,用来移除老的监听处理器。ide
要知道文件的增长和删除,而且拿到模块内容。模块化
简单地把每一个事件处理器定义为一个文件 *.biz.js
:
// msg.biz.js module.exports = { evt: 'msg', fn() { console.log('msg hanlder....') } };
其中 evt
是事件名, fn
是处理器,因而加载一个业务模块后就能拿到事件名称和处理器。
(可能不知足实际要求,先简单验证热更新是否可行哈!)
咱们约定,业务模块 *.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) });
参考 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
的代码都能自动热更新了。
上面的代码已经不当心实现了 「增长文件后热更新」,由于 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 来玩是能够的,固然咱们做了很多严格约定,不过不影响这一阶段的思路。
上面一种思路存在一些问题
业务代码的格式限制太死,不够灵活
在生产阶段也耦合了 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 玩了一轮,看来择日还得再写一篇(二)了
热更新的主要目的是为了提升开发效率,并非为了在生产上玩热更新,毕竟还有不少潜在问题,例如,模块中涉及全局状态或者单例资源,经过热更新可能会引发混乱......