在Node.js中看JavaScript的引用

StephaineRF 7年前
   <p>对于从PHP转到Node.js的作者而言,Node.js编辑完代码后必须重启真是件麻烦事。在不重启情况下热更新Node.js代码,是本文重要讨论的话题。而解决该问题,JavaScript的引用成为了关键。层层剖析,抽丝剥茧,带你了解问题本质及解决之道。</p>    <p>早期学习Node.js的时候,有挺多是从PHP转过来的,当时有部分人对于Node.js编辑完代码需要重启一下表示麻烦(PHP不需要这个过程),于是社区里的朋友就开始提倡使用node-supervisor这个模块来启动项目,可以编辑完代码之后自动重启。不过相对于PHP而言依旧不够方便,因为Node.js在重启以后之前的上下文都丢失了。</p>    <p>虽然可以通过将session数据保存在数据库或者缓存中来减少重启过程中的数据丢失,不过如果是在生产的情况下,更新代码的重启间隙是没法处理请求的(PHP可以,另外那个时候还没有cluster)。由于这方面的问题,加上本人是从PHP转到Node.js的,于是从那时开始思考有没有办法可以在不重启的情况下热更新Node.js的代码。</p>    <p>最开始把目光瞄向了require这个模块。想法很简单,因为Node.js中引入一个模块都是通过require这个方法来加载的。于是就开始思考require能不能在更新代码之后再次require一下。尝试如下:</p>    <pre>  <code class="language-javascript">var express = require('express');  var b = require('./b.js');    var app = express();    app.get('/', function (req, res) {    b = require('./b.js');    res.send(b.num);  });    app.listen(3000);</code></pre>    <pre>  <code class="language-javascript">exports.num = 1024;</code></pre>    <p>两个JS文件写好之后,从a.js启动,刷新页面会输出b.js中的1024,然后修改b.js文件中导出的值,例如修改为2048。再次刷新页面依旧是原本的1024。</p>    <p>再次执行一次require并没有刷新代码。require在执行的过程中加载完代码之后会把模块导出的数据放在require.cache中。require.cache是一个{}对象,以模块的绝对路径为key,该模块的详细数据为value。于是便开始做如下尝试:</p>    <pre>  <code class="language-javascript">var path = require('path');  var express = require('express');  var b = require('./b.js');    var app = express();    app.get('/', function (req, res) {    if (true) { // 检查文件是否修改      flush();    }    res.send(b.num);  });    function flush() {    delete require.cache[path.join(__dirname, './b.js')];    b = require('./b.js');  }    app.listen(3000);</code></pre>    <p>在再次require之前将require之上关于该模块的cache清理掉之后,用之前的方法再次测试。结果发现,可以成功的刷新b.js的代码,输出新修改的值。</p>    <p>了解这个点,原本以为通过这个原理就可以写一个跟node-supervisor类似的模块,将起重启的部分换成通过该原理刷新就可以写一个更好的。但是在实际的开发过程中马上就碰到了问题。在封装模块的过程中,出于情怀的原因考虑提供一个类似PHP中include的函数来代替require去引入一个模块。实际内部依旧是使用require去加载。以b.js为例,原本的写法就写作var b = include(‘./b’),在文件b.js更新之后include内部可以自动刷新,让外面拿到最新的代码。</p>    <p>但是实际的开发过程中,这样很快就碰到了问题。我们希望的代码可能是这样:</p>    <pre>  <code class="language-javascript">var include = require('./include');  var express = require('express');  var b = include('./b.js');  var app = express();    app.get('/', function (req, res) {    res.send(b.num);  });    app.listen(3000);</code></pre>    <p>但是在按照这个目标封装include的时候,我们发现了问题。无论我们在include.js内部中如何实现,都不能像开始那样让拿到新的b.num。</p>    <p>对比开始的代码,我们发现问题出在少了b = xx。也就是说这样写才可以:</p>    <pre>  <code class="language-javascript">var include = require('./include');  var express = require('express');  var app = express();    app.get('/', function (req, res) {    var b = include('./b.js');    res.send(b.num);  });    app.listen(3000);</code></pre>    <p>修改成这样就可以保证每次能可以正确的刷新到最新的代码,并且不用重启实例了。读者有兴趣的可以研究这个include怎么实现,本文就不深入讨论了,因为这个技巧使用度不高,写起起来不是很优雅①,反而这之间有一个更重要的问题————JavaScript的引用。</p>    <p><strong>JavaScript的引用与传统引用的区别</strong></p>    <p>要讨论这个问题,我们首先要了解JavaScript的引用于其他语言中的一个区别,在C++中引用是直接可以修改外部的值:</p>    <pre>  <code class="language-javascript">#include <iostream>    using namespace std;    void test(int &p) // 引用传递  {      p = 2048;  }    int main()  {      int a = 1024;      int &p = a; // 设置引用p指向a        test(p); // 调用函数        cout << "p: " << p << endl; // 2048      cout << "a: " << a << endl; // 2048      return 0;  }</code></pre>    <p>而在JavaScript中:</p>    <pre>  <code class="language-javascript">var obj = { name: 'Alan' };    function test1(obj) {    obj = { hello: 'world' }; // 试图修改外部obj  }    test1(obj);  console.log(obj); // { name: 'Alan' } // 并没有修改②    function test2(obj) {    obj.name = 'world'; // 根据该对象修改其上的属性  }    test2(obj);  console.log(obj); // { name: 'world' } // 修改成功③</code></pre>    <p>我们发现与C++不同,根据②可知JavaScript中并没有传递一个引用,而是拷贝了一个新的变量,即值传递。根据③可知拷贝的这个变量是一个可以访问到对象属性的“引用”(与传统的C++的引用不同,下文中提到的JavaScript的引用都是这种特别的引用)。这里需要总结一个绕口的结论: Javascript中均是值传递,对象在传递的过程中是拷贝了一份新的引用。</p>    <p>为了理解这个比较拗口的结论,让我们来看一段代码:</p>    <pre>  <code class="language-javascript">var obj = {    data: {}  };    // data 指向 obj.data  var data = obj.data;    console.log(data === obj.data); // true-->data所操作的就是obj.data    data.name = 'Alan';  data.test = function () {    console.log('hi')  };    // 通过data可以直接修改到data的值  console.log(obj) // { data: { name: 'Alan', test: [Function] } }    data = {    name: 'Bob',    add: function (a, b) {      return a + b;    }  };    // data是一个引用,直接赋值给它,只是让这个变量等于另外一个引用,并不会修改到obj本身  console.log(data); // { name: 'Bob', add: [Function] }  console.log(obj); // { data: { name: 'Alan', test: [Function] } }    obj.data = {    name: 'Bob',    add: function (a, b) {      return a + b;    }  };    // 而通过obj.data才能真正修改到data本身  console.log(obj); // { data: { name: 'Bob', add: [Function] } }</code></pre>    <p>通过这个例子我们可以看到,data虽然像一个引用一样指向了obj.data,并且通过data可以访问到obj.data上的属性。但是由于JavaScript值传递的特性直接修改data = xxx并不会使得obj.data = xxx。</p>    <p>打个比方最初设置var data = obj.data的时候,内存中的情况大概是:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/4ae2975c0eedba763444bee3a536b1f6.png"></p>    <p>所以通过data.xx可以修改到obj.data的内存1。</p>    <p>然后设置data = xxx,由于data是拷贝的一个新的值,只是这个值是一个引用(指向内存1)罢了。让它等于另外一个对象就好比:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/b5f5cfa8cfdd00fe69a8c357e8033c8b.png"></p>    <p>让data指向了新的一块内存2。</p>    <p>如果是传统的引用(如上文中的C++的情况),那么obj.data本身会变成新的内存2,但 JavaScript中均是值传递,对象在传递的过程中拷贝了一份新的引用。 所以这个新拷贝的变量被改变并不影响原本的对象。</p>    <p><strong>Node.js中的module.exports与exports</strong></p>    <p>上述例子中的obj.data与data的关系,就是Node.js中的module.exports与exports之间的关系。让我们来看看Node.js中require一个文件时候的实际结构:</p>    <pre>  <code class="language-javascript">function require(...) {    var module = { exports: {} };    ((module, exports) => { // Node.js 中文件外部其实被包了一层自执行的函数      // 这中间是你模块内部的代码.      function some_func() {};      exports = some_func;      // 这样赋值,exports便不再指向module.exports      // 而module.exports依旧是{}        module.exports = some_func;      // 这样设置才能修改到原本的exports    })(module, module.exports);    return module.exports;  }</code></pre>    <p>所以很自然的:</p>    <pre>  <code class="language-javascript">console.log(module.exports === exports); // true --> exports所操作的就是module.exports</code></pre>    <p>Node.js中的exports就是拷贝的一份module.exports的引用。通过exports可以修改Node.js当前文件导出的属性,但是不能修改到当前模块本身。通过module.exports才可以修改到其本身。表现上来说:</p>    <pre>  <code class="language-javascript">exports = 1; // 无效  module.exports = 1; // 有效</code></pre>    <p>这是二者表现上的区别,其他方面用起来都没有差别。所以你现在应该知道写module.exports.xx = xxx;的人其实是多写了一个module.。</p>    <p>更复杂的例子</p>    <p>为了再练习一下,我们在来看一个比较复杂的例子:</p>    <pre>  <code class="language-javascript">var a = {n: 1};    var b = a;   a.x = a = {n: 2};    console.log(a.x);  console.log(b.x);</code></pre>    <p>按照开始的结论我们可以一步步的来看这个问题:</p>    <pre>  <code class="language-javascript">var a = {n: 1};    // 引用a指向内存1{n:1}  var b = a;  // 引用b => a => { n:1 }</code></pre>    <p style="text-align:center"><img src="https://simg.open-open.com/show/0968c7cdf4fc853f3b2b7edbff5d25e2.png"></p>    <pre>  <code class="language-javascript">a.x = a = {n: 2};  //  (内存1 而不是 a ).x = 引用 a = 内存2 {n:2}</code></pre>    <p>a 虽然是引用,但是JavaScript是值传的这个引用,所以被修改不影响原本的地方。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/fa73736a5dadf4490ec478bace9acd7a.png"></p>    <p>所以最后的结果</p>    <ul>     <li>a.x 即(内存2).x ==> {n: 2}.x ==> undefined</li>     <li>b.x 即(内存1).x ==> 内存2 ==> {n: 2}</li>    </ul>    <p><strong>总结</strong></p>    <p>Javascript中没有引用传递,只有值传递。对象(引用类型)的传递只是拷贝一个新的引用,这个新的引用可以访问原本对象上的属性,但是这个新的引用本身是放在另外一个格子上的值,直接往这个格子赋新的值,并不会影响原本的对象。本文开头所讨论的Node.js热更新时碰到的也是这个问题,区别是对象本身改变了,而原本拷贝出来的引用还指向旧的内存。</p>    <p>Node.js并没有对JavaScript施加黑魔法,其中的引用问题依旧是JavaScript的内容。如module.exports与exports这样隐藏了一些细节容易使人误会,本质还是JavaScript的问题。另外推荐一个关于 Node.js 的进阶教程 《 <a href="/misc/goto?guid=4959742673068480907" rel="nofollow,noindex">Node.js 面试</a> 》。</p>    <p><strong>注①:</strong></p>    <ul>     <li>老实说,模块在函数内声明有点谭浩强的感觉。</li>     <li>把b = include(xxx)写在调用内部,还可以通过设置成中间件绑定在公共地方来写。</li>     <li>除了写在调用内部,也可以导出一个工厂函数,每次使用时b().num一下调用也可以。</li>     <li>[*]还可以通过中间件的形式绑定在框架的公用对象上(如:ctx.b = include(xxx))。</li>    </ul>    <p> </p>    <p>来自:http://www.iteye.com/news/32243</p>    <p> </p>