JavaScript - module

  • import和require的区别

    # 遵循规范

    • require 是 AMD规范引入方式
    • import是es6的一个语法标准,如果要兼容浏览器的话必须转化成es5的语法

    # 调用时间

    • require是运行时调用,所以require理论上可以运用在代码的任何地方
    • import是编译时调用,所以必须放在文件开头

    # 本质

    • require是赋值过程,其实require的结果就是对象、数字、字符串、函数等,再把require的结果赋值给某个变量
    • import是解构过程,但是目前所有的引擎都还没有实现import,我们在node中使用babel支持ES6,也仅仅是将ES6转码为ES5再执行,import语法会被转码为require
  • 循环加载 CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。 CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

    # CommonJS 模块的加载原理

    CommonJS 的一个模块,就是一个脚本文件。require命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。

    1
    2
    3
    4
    5
    6
    
    {
      id: '...',
      exports: { ... },
      loaded: true,
      ...
    }
    

    上面代码就是 Node 内部加载模块后生成的一个对象。该对象的id属性是模块名,exports属性是模块输出的各个接口,loaded属性是一个布尔值,表示该模块的脚本是否执行完毕。其他还有很多属性,这里都省略了。

    以后需要用到这个模块的时候,就会到exports属性上面取值。即使再次执行require命令,也不会再次执行该模块,而是到缓存之中取值。也就是说,CommonJS模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。

    # CommonJS 模块的循环加载

    CommonJS 模块的重要特性是加载时执行,即脚本代码在require的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。 让我们来看,Node 官方文档里面的例子。脚本文件a.js代码如下。

    1
    2
    3
    4
    5
    
    exports.done = false;
    var b = require('./b.js');
    console.log('在 a.js 之中,b.done = %j', b.done);
    exports.done = true;
    console.log('a.js 执行完毕');
    

    上面代码之中,a.js脚本先输出一个done变量,然后加载另一个脚本文件b.js。注意,此时a.js代码就停在这里,等待b.js执行完毕,再往下执行。 再看b.js的代码。

    1
    2
    3
    4
    5
    
    exports.done = false;
    var a = require('./a.js');
    console.log('在 b.js 之中,a.done = %j', a.done);
    exports.done = true;
    console.log('b.js 执行完毕');
    

    上面代码之中,b.js执行到第二行,就会去加载a.js,这时,就发生了“循环加载”。系统会去a.js模块对应对象的exports属性取值,可是因为a.js还没有执行完,从exports属性只能取回已经执行的部分,而不是最后的值。 a.js已经执行的部分,只有一行。

    1
    
    exports.done = false;
    

    因此,对于b.js来说,它从a.js只输入一个变量done,值为false。 然后,b.js接着往下执行,等到全部执行完毕,再把执行权交还给a.js。于是,a.js接着往下执行,直到执行完毕。我们写一个脚本main.js,验证这个过程。

    1
    2
    3
    
    var a = require('./a.js');
    var b =  require('./b.js');
    console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);
    

    执行main.js,运行结果如下。

    1
    2
    3
    4
    5
    6
    7
    
    $ node main.js
    
    在 b.js 之中,a.done = false
    b.js 执行完毕
    在 a.js 之中,b.done = true
    a.js 执行完毕
    在 main.js 之中, a.done=true, b.done=true
    

    上面的代码证明了两件事。一是,在b.js之中,a.js没有执行完毕,只执行了第一行。二是,main.js执行到第二行时,不会再次执行b.js,而是输出缓存的b.js的执行结果,即它的第四行。

    1
    
    exports.done = true;
    

    总之,CommonJS 输入的是被输出值的拷贝,不是引用。 另外,由于 CommonJS 模块遇到循环加载时,返回的是当前已经执行的部分的值,而不是代码全部执行后的值,两者可能会有差异。所以,输入变量的时候,必须非常小心。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    var a = require('a'); // 安全的写法
    var foo = require('a').foo; // 危险的写法
    
    exports.good = function (arg) {
      return a.foo('good', arg); // 使用的是 a.foo 的最新值
    };
    
    exports.bad = function (arg) {
      return foo('bad', arg); // 使用的是一个部分加载时的值
    };
    

    上面代码中,如果发生循环加载,require(‘a’).foo的值很可能后面会被改写,改用require(‘a’)会更保险一点。

    # ES6 模块的循环加载

    ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用import从一个模块加载变量(即import foo from ‘foo’),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。 请看下面这个例子。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    // a.mjs
    import {bar} from './b';
    console.log('a.mjs');
    console.log(bar);
    export let foo = 'foo';
    
    // b.mjs
    import {foo} from './a';
    console.log('b.mjs');
    console.log(foo);
    export let bar = 'bar';
    

    上面代码中,a.mjs加载b.mjs,b.mjs又加载a.mjs,构成循环加载。执行a.mjs,结果如下。

    1
    2
    3
    
    $ node --experimental-modules a.mjs
    b.mjs
    ReferenceError: foo is not defined
    

    上面代码中,执行a.mjs以后会报错,foo变量未定义,这是为什么?

    让我们一行行来看,ES6循环加载是怎么处理的。首先,执行a.mjs以后,引擎发现它加载了b.mjs,因此会优先执行b.mjs,然后再执行a.js。接着,执行b.mjs的时候,已知它从a.mjs输入了foo接口,这时不会去执行a.mjs,而是认为这个接口已经存在了,继续往下执行。执行到第三行console.log(foo)的时候,才发现这个接口根本没定义,因此报错。

    解决这个问题的方法,就是让b.mjs运行的时候,foo已经有定义了。这可以通过将foo写成函数来解决。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    // a.mjs
    import {bar} from './b';
    console.log('a.mjs');
    console.log(bar());
    function foo() { return 'foo' }
    export {foo};
    
    // b.mjs
    import {foo} from './a';
    console.log('b.mjs');
    console.log(foo());
    function bar() { return 'bar' }
    export {bar};
    

    这时再执行a.mjs就可以得到预期结果。

    1
    2
    3
    4
    5
    
    $ node --experimental-modules a.mjs
    b.mjs
    foo
    a.mjs
    bar
    

    这是因为函数具有提升作用,在执行import {bar} from’./b’时,函数foo就已经有定义了,所以b.mjs加载的时候不会报错。这也意味着,如果把函数foo改写成函数表达式,也会报错。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    // a.mjs
    import {bar} from './b';
    console.log('a.mjs');
    console.log(bar());
    const foo = () => 'foo';
    export {foo};
    上面代码的第四行,改成了函数表达式,就不具有提升作用,执行就会报错。
    
    我们再来看 ES6 模块加载器SystemJS给出的一个例子
    
    // even.js
    import { odd } from './odd'
    export var counter = 0;
    export function even(n) {
      counter++;
      return n === 0 || odd(n - 1);
    }
    
    // odd.js
    import { even } from './even';
    export function odd(n) {
      return n !== 0 && even(n - 1);
    }
    

    上面代码中,even.js里面的函数even有一个参数n,只要不等于 0,就会减去 1,传入加载的odd()。odd.js也会做类似操作。

    运行上面这段代码,结果如下。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    $ babel-node
    > import * as m from './even.js';
    > m.even(10);
    true
    > m.counter
    6
    > m.even(20)
    true
    > m.counter
    17
    

    上面代码中,参数n从 10 变为 0 的过程中,even()一共会执行 6 次,所以变量counter等于6。第二次调用even()时,参数n从 20 变为 0,even()一共会执行 11 次,加上前面的 6 次,所以变量counter等于 17。

    这个例子要是改写成 CommonJS,就根本无法执行,会报错。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    // even.js
    var odd = require('./odd');
    var counter = 0;
    exports.counter = counter;
    exports.even = function (n) {
      counter++;
      return n == 0 || odd(n - 1);
    }
    
    // odd.js
    var even = require('./even').even;
    module.exports = function (n) {
      return n != 0 && even(n - 1);
    }
    

    上面代码中,even.js加载odd.js,而odd.js又去加载even.js,形成“循环加载”。这时,执行引擎就会输出even.js已经执行的部分(不存在任何结果),所以在odd.js之中,变量even等于null,等到后面调用even(n-1)就会报错。

    1
    2
    3
    4
    
    $ node
    > var m = require('./even');
    > m.even(10)
    TypeError: even is not a function
    
  • 为什么 Node.js 不给每一个.js文件以独立的上下文来避免作用域被污染?

    回答这个问题,要从 Node.js 对 CommonJS 的实现说起。上 Node 的源码 https://github.com/nodejs/node/blob/master/lib/module.js#L556

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    var wrapper = Module.wrap(content);
    var compiledWrapper = vm.runInThisContext(wrapper, {
      filename: filename,
      lineOffset: 0,
      displayErrors: true
    });
    
    // ...
    
    var result = compiledWrapper.call(this.exports, this.exports, require, this,
      filename, dirname);
    

    关键代码就这么几行。content 可以认为是你的 .js 文件源码,例如,我们简单点:‘console.log(module)’ Module.wrap(content) 后:

    1
    
    '(function (exports, require, module, __filename, __dirname) { console.log(module)\n});'
    

    注意:上面是字符串操作。vm.runInThisContext 后:[Function] 将上面的字符串输出变成了可执行的 JS 函数。实际上这个函数就是:

    1
    2
    3
    
    function(exports, require, module, __filename, __dirname) {
      console.log(module)
    });
    

    最后执行这个函数,也就是

    1
    
    compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname)
    

    就是执行了这个模块。以上,就是 Node.js 对一个文本的 .js 模块转换成一个可使用的 JS 模块的大致过程。好了,显然,.js 文件的代码都是包裹在一个函数里执行的,并不会产生作用域污染。我们再追问一下,如果你不小心没有写var,定义了全局变量怎么办?,例如一不小心写了这行代码:globalVar = 1 包裹之后变成了:

    1
    2
    3
    
    function(exports, require, module, __filename, __dirname) {
      globalVar = 1
    });
    

    那这个 globalVar 是啥样子?这其实是由 vm.runInThisContext 决定的,查看 Node.js 的文档:

    1
    
    vm.runInThisContext() compiles code, runs it within the context of the current global and returns the result. Running code does not have access to local scope, but does have access to the current global
    

    object.vm.runInThisContext 使得包裹函数执行时无法影响本地作用域;但 global 对象是可以访问的,因此 globalVar = 1 等价于 global.globalVar = 1如何避免这种对全局作用域的污染呢?

    1
    2
    
    'use strict';
    globalVar = 1
    

    添加 ‘use strict’;,禁止这样意外创建全局变量,代码执行时将抛出 globalVar 未定义的错误。 更准确地回答题主的问题:Node.js模块正常情况对作用域不会造成污染,意外创建全局变量是一种例外,可以采用严格模式来避免。

Licensed under CC BY-NC-SA 4.0