我们在本地开发 Node.js 程序时通常会使用 nodemon 或者 supervisor 这种进程管理工具,当有文件修改时自动重启应用。小项目还好,项目大了(尤其是前端应用)每次重启应用都用几秒到几十秒的时间,大部分时间都花在了加载及编译代码上。
这让笔者联想到前端比较火的一个名词——Hot Reload(热加载),比如 React 静态资源的热加载通过 webpack-dev-server 和 react-hot-loader 实现,webpack-dev-server 负责重新编译代码,react-hot-loader 负责热加载。
那在 Node.js 应用中,如何实现 Hot Reload 呢?最好能实现不重启应用便使新代码生效。幸好 ES6 引入了一个新特性——Proxy。
Proxy 用于修改对象的默认行为,等同于在语言层面做出修改,属于一种 “元编程”。Proxy 在要访问的对象之前架设一层拦截,在访问该对象成员时必须先经过这层拦截。示例代码如下:
const obj = new Proxy({}, { get: function (target, key) { console.log(`getting ${key}!`) return 'haha' } }) console.log(obj.name) // getting name! // haha console.log(obj.age) // getting age! // haha
可以看出:我们并没有在 obj 上定义 name 和 age 属性,所有获取 obj 上属性都会执行 get 方法然后打印 getting xxx! 和返回 haha。
这里 Proxy 的第 1 个参数是一个空对象,也可以是一个其他的对象,比如函数(毕竟在 JavaScript 中函数也是对象)。
function user () {} const obj = new Proxy(user, { get: function (target, key) { console.log(`getting ${key}!`) return 'haha' } }) console.log(user.name) // user console.log(user.age) // undefined console.log(obj.name) // getting name! // haha console.log(obj.age) // getting age! // haha new Proxy(1, {}) // TypeError: Cannot create proxy with a non-object as target or handler
核心原理:使用 Proxy 将模块导出的对象包装一层 “代理”,即 module.exports 导出的是一个 Proxy 实例,定义一个 get 方法,使得获取实例上的属性其实是去获取最新的 require.cache 中的对象上的属性。同时,监听代码文件,如果有修改,则更新 require.cache。
简而言之:我们在获取对象的属性时,中间加了一层代理,通过代理间接获取原有属性的值,如果属性值有更新,则会更新 require.cache 的缓存,那么下次再获取对象的属性时,通过代理将获取该属性最新的值。可见,Proxy 可以实现属性访问拦截,也可实现断开强引用的作用。
笔者发布了一个 proxy-hot-reload 模块,核心代码如下:
module.exports = function proxyHotReload(opts) { const includes = [ ... ] const excludes = [ ... ] const filenames = _.difference(includes, excludes) chokidar .watch(filenames, { usePolling: true }) .on('change', (path) => { try { if (require.cache[path]) { const _exports = require.cache[path].exports if (_.isPlainObject(_exports) && !_.isEmpty(_exports)) { delete require.cache[path] require(path) } } } catch (e) { ... } }) .on('error', (error) => console.error(error)) shimmer.wrap(Module.prototype, '_compile', function (__compile) { return function proxyHotReloadCompile(content, filename) { if (!_.includes(filenames, filename)) { try { return __compile.call(this, content, filename) } catch (e) { ... } } else { const result = __compile.call(this, content, filename) this._exports = this.exports // non-object return original compiled code if (!_.isPlainObject(this._exports)) { return result } try { this.exports = new Proxy(this._exports, { get: function (target, key, receiver) { try { if (require.cache[filename]) { return require.cache[filename]._exports[key] } else { return Reflect.get(target, key, receiver) } } catch (e) { ... } } }) } catch (e) { ... } } } }) }
简单讲解一下:
使用示例:
user.js
module.exports = { id: 1, name: 'nswbmw' }
app.js
if (process.env.NODE_ENV !== 'production') { require('proxy-hot-reload')({ includes: '**/*.js' }) } const Paloma = require('paloma') const app = new Paloma() const user = require('./user') app.route({ method: 'GET', path: '/', controller (ctx) { ctx.body = user }}) app.listen(3000)
浏览器访问 localhost:3000 查看结果,修改 user.js 中字段的值,然后刷新浏览器查看结果。
proxy-hot-reload 有个非常明显的缺点:只支持对导出的是纯对象的文件做代理,而且程序入口文件不会生效,比如上面的 app.js,修改端口号只能重启才会生效。Proxy 再怎么黑魔法也只能做到这个地步了,退一步想,如果修改了 proxy-hot-reload 覆盖不到的文件(例如:app.js)降级成自动重启就好了,如果将 proxy-hot-reload 和 supervisor 结合,会怎么样呢?
如果要将 proxy-hot-reload 结合 supervisor 使用,需要解决以下几个难点:
if (process.env.NODE_ENV !== 'production') { require('proxy-hot-reload')({ includes: '**/*.js' }) }
参数统一。supervisor 可接受 -w 参数表明监听哪些文件,-i 参数表明忽略哪些文件,这两个参数怎么与 proxy-hot-reload 的 includes 和 excludes 参数整合。
职责分明。修改代码文件并保存后,优先尝试 proxy-hot-reload 的热更新,如果 proxy-hot-reload 热更新不了,则使用 supervisor 重启。
首先,我们来看下 supervisor 的源码(lib/supervisor.js),源码中有这么一段代码:
var watchItems = watch.split(','); watchItems.forEach(function (watchItem) { watchItem = path.resolve(watchItem); if ( ! ignoredPaths[watchItem] ) { log("Watching directory '" + watchItem + "' for changes."); if(interactive) { log("Press rs for restarting the process."); } findAllWatchFiles(watchItem, function(f) { watchGivenFile( f, poll_interval ); }); } });
以上代码的作用是:遍历找到所有需要监听的文件,然后调用 watchGivenFile 监听文件。watchGivenFile 代码如下:
function watchGivenFile (watch, poll_interval) { if (isWindowsWithoutWatchFile || forceWatchFlag) { fs.watch(watch, { persistent: true, interval: poll_interval }, crashWin); } else { fs.watchFile(watch, { persistent: true, interval: poll_interval }, function(oldStat, newStat) { // we only care about modification time, not access time. if ( newStat.mtime.getTime() !== oldStat.mtime.getTime() ) { if (verbose) { log("file changed: " + watch); } } crash(); }); } if (verbose) { log("watching file '" + watch + "'"); } }
watchGivenFile 的作用是:用 fs.watch/fs.watchFile 监听文件,如果有改动则调用 crashWin/crash 程序退出。supervisor 使用 child_process.spawn 将程序运行在子进程,子进程退出后会被 supervisor 重新启动。相关代码如下:
function startProgram (prog, exec) { var child = exports.child = spawn(exec, prog, {stdio: 'inherit'}); ... child.addListener("exit", function (code) { ... startProgram(prog, exec); }); }
大体理清 supervisor 的关键源码后,我们就知道如何解决上面提到的几个难点了。
首先需要修改 proxy-hot-reload,添加以下几个功能:
难点及解决方案如下:
node -r xxx
node -r proxy-hot-reload.js app.js
supervisor 相关代码如下:
// 获取 watchFiles fs.writeFileSync(path.join(__dirname, 'proxy-hot-reload.js'), ` require('${path.join(__dirname, "..", "node_modules", "proxy-hot-reload")}')({ includeFiles: ${JSON.stringify(watchFiles)}, excludeFiles: [], watchedFileChangedButNotReloadCache: function (filename) { console.log(filename + ' changed, restarting...'); setTimeout(function () { process.exit(); }, ${poll_interval}); } });`); // startChildProcess()
var watchFiles = [] var watchItems = watch.split(','); watchItems.forEach(function (watchItem) { watchItem = path.resolve(watchItem); if ( ! ignoredPaths[watchItem] ) { log("Watching directory '" + watchItem + "' for changes."); if(interactive) { log("Press rs for restarting the process."); } findAllWatchFiles(watchItem, function(f) { watchFiles.push(f) // watchGivenFile( f, poll_interval ); }); } });
注意:这里 findAllWatchFiles 虽然有回调函数,但却是同步的。将 findAllWatchFiles 内的 fs.lstat/fs.stat/fs.readdir 分别改为 fs.lstatSync/fs.statSync/fs.readdirSync,这里就不贴代码了。
笔者将改进后的 supervisor 发布成一个新的包——supervisor-hot-reload 。使用如下:
const Paloma = require('paloma') const app = new Paloma() const user = require('./user') app.route({ method: 'GET', path: '/', controller (ctx) { ctx.body = user }}) app.listen(3000)
全局安装并使用 supervisor-hot-reload:
$ npm i supervisor-hot-reload -g $ DEBUG=proxy-hot-reload supervisor-hot-reload app.js
修改 user.js,程序不会重启,打印:
proxy-hot-reload Reload file: /Users/nswbmw/Desktop/test/user.js
修改 app.js,程序会重启,打印:
/Users/nswbmw/Desktop/test/app.js changed, restarting... Program node app.js exited with code 0 Starting child process with 'node app.js' ...
这里需要声明一下,虽然修改 require.cache + Proxy 实现了我们想要的功能,但这样做存在内存泄漏问题,因为即使删除了一个模块的缓存,但父模块的缓存中还引用着旧的模块导出的对象。这个问题可以不用太关心,知道为什么就好,因为我们只是在开发环境使用 proxy-hot-reload。
上一节:4.4 debug + repl2 + power-assert
下一节:5.1 NewRelic
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8