我们在本地开发 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。

4.5.1 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

4.5.2 Proxy 实现 Hot Reload

核心原理:使用 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) { ... }
      }
    }
  })
}

简单讲解一下:

  1. 可传入 includes 和 excludes 参数,支持 glob 写法,用来设置监听哪些代码文件。
  2. 用 chokidar 模块监听文件,如果有改动则重新加载该文件。这里只针对 module.exports 导出的是纯对象的模块有用,做这个限制的原因是:对于非对象比如函数,一般我们导出一个函数会直接调用执行而不是获取函数上的属性或方法,这种导出非纯对象模块即使重建缓存也不会生效,所以干脆忽略。幸运的是,module.exports 导出对象占了大多数场景。
  3. 用 shimmer 模块重载 Module.prototype._compile 方法,如果是被监听的文件并且导出的是纯对象,则尝试将导出的对象包装成 Proxy 实例。这样,在获取该对象上的属性时,将从 require.cache 中读取最新的值。

使用示例:

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 结合,会怎么样呢?

4.5.3 supervisor-hot-reload

如果要将 proxy-hot-reload 结合 supervisor 使用,需要解决以下几个难点:

  1. 非侵入式。即代码里不再写:
if (process.env.NODE_ENV !== 'production') {
  require('proxy-hot-reload')({
    includes: '**/*.js'
  })
}
  1. 参数统一。supervisor 可接受 -w 参数表明监听哪些文件,-i 参数表明忽略哪些文件,这两个参数怎么与 proxy-hot-reload 的 includes 和 excludes 参数整合。

  2. 职责分明。修改代码文件并保存后,优先尝试 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,添加以下几个功能:

  1. 添加 includeFiles 和 excludeFiles 选项,值为数组,用来接收 supervisor 传来的文件列表。
  2. 添加 watchedFileChangedButNotReloadCache 参数。proxy-hot-reload 可以知道哪些代码文件可以热更新,哪些不可以。当监听到不能热更新的文件有修改时,则调用 watchedFileChangedButNotReloadCache 函数,这个函数里有 process.exit() 使进程退出。

难点及解决方案如下:

  1. 非侵入式。因为真正的程序试运行在 supervisor 创建的子进程中,所以我们无法在 supervisor 进程中引入 proxy-hot-reload,只能通过子进程用 node -r xxx 提前引入并覆盖 Module.prototype._compile。解决方案:将 supervisor 需要监听的文件数组(watchFiles)和 proxy-hot-reload 配置写到一个文件(例如:proxy-hot-reload.js)里,子进程通过 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()
  1. 参数统一。将上面的 watchItems.forEach 内异步遍历需要监听的文件列表修改为同步,代码如下:
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,这里就不贴代码了。

  1. 职责分明。子进程使用 node -r proxy-hot-reload.js app.js 启动后,能热更新的则热更新,不能热更新的执行 watchedFileChangedButNotReloadCache,子进程退出,supervisor 会启动一个新的子进程,实现了职责分明。

笔者将改进后的 supervisor 发布成一个新的包——supervisor-hot-reload 。使用如下:

user.js

module.exports = {
  id: 1,
  name: 'nswbmw'
}

app.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)

全局安装并使用 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'
...

4.5.4 内存泄露问题

这里需要声明一下,虽然修改 require.cache + Proxy 实现了我们想要的功能,但这样做存在内存泄漏问题,因为即使删除了一个模块的缓存,但父模块的缓存中还引用着旧的模块导出的对象。这个问题可以不用太关心,知道为什么就好,因为我们只是在开发环境使用 proxy-hot-reload。

4.5.5 参考链接

上一节:4.4 debug + repl2 + power-assert

下一节:5.1 NewRelic

results matching ""

    No results matching ""