浅谈 Node 中的模块化

浅谈Node中的模块化

非模块化开发的问题

  • 命名冲突
  • 文件依赖高
  • 可扩展性低
  • 可重用性低
  • 等等......

一、模块化的演变过程

从最简单的加减乘除运算来举例说明,为了方便理解这里都没有采用ES6的语法。

  1. 全局函数的方式——最原始的写法
    // 早期的开发过程中就是将重复使用的代码封装到函数中
    // 再将一系列的函数放到一个文件中,称之为模块
    // 缺点:存在命名冲突,可维护性也不高的问题
    // 仅仅从代码角度来说:没有任何模块的概念
    function convertor(a) {
        return parseFloat(a);
    }
    
    function add(a, b) {
        return convertor(a) + convertor(b);
    }
    
  2. 封装对象的方式
    // 有了传统编程语言中的命名空间
    // 从代码层面就已经有了模块的感觉
    // 避免了多处全局污染
    // 缺点:没有私有空间,没有抽离私有成员
    var calculator = {
        add: function (a, b) {
            return this.convertor(a) + this.convertor(b);
        },
        convertor:function(a){
            return parseFloat(a)
        }
    };
    
  3. 私有空间的划分
    // 这里形成一个单独的私有的空间
    // 高内聚,低耦合:模块内部相关性强,模块之间没有过多相互牵连,如convertor和add
    // 缺点:可扩展性低
    var calculator = (function () {
        // 将一个成员私有化,外部无法访问和修改
        function convertor(a) {
            return parseFloat(a);
        }
        // 抽象公共方法(其他成员中都会用到的)
        function add(a, b) {
            return convertor(a) + convertor(b);
        }
        return {
            add:add
        }
    })();
    
  4. 模块的扩展
    // calc_v2016.js
    (function (window,calculator) {
        function convert(input) {
          return parseFloat(input);
        }
        calculator = {
          add: function (a, b) {
            return convert(a) + convert(b);
          }
        }
        window.calculator = calculator;
    })(window, {});
    
    // 新增需求 remain
    // calc_v2017.js
    // 开闭原则:对新增开放,对修改关闭
    (function (calculator) {
        function convert(input) {
          return parseInt(input);
        }
        // calculator 如果存在的话,我就是扩展,不存在我就是新加
        calculator.remain = function (a, b) {
          return convert(a) % convert(b);
        }
        window.calculator = calculator;
    })(window.calculator || {});
    
  5. 第三方依赖
    // calc_v2016.js
    (function (window,calculator) {
        //对全局产生依赖,不能这样用
        console.log(document);
        function convert(input) {
          return parseFloat(input);
        }
        calculator = {
          add: function (a, b) {
            return convert(a) + convert(b);
          }
        }
        window.calculator = calculator;
    })(window, {});
    
    // 新增需求
    // calc_v2017.js
    (function (calculator,document) {
        // 依赖函数的参数,是属于模块内部
        console.log(document);
        function convert(input) {
          return parseInt(input);
        }
        calculator.remain = function (a, b) {
          return convert(a) % convert(b);
        }
        window.calculator = calculator;
    })(window.calculator || {},document);
    

以上通过一些简短的代码介绍了模块化发展大致情况。

二、模块化规范

  1. 服务器端规范
    CommonJS---nodejs
  2. 浏览器端规范
    AMD---RequireJS
    CMD---SeaJS
  3. ES6的module规范
    ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。其模块功能主要由两个命令构成:exportimportexport 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。在这里就不具体展示每种规范的具体写法了,详情请点击阮一峰《ES6 入门教程》

三、CommonJS 模块规范

  1. Node 采用的模块化结构是按照 CommonJS 规范

    • 模块与文件是一一对应关系,即加载一个模块,就是加载对应的一个模块文件。
    • CommonJS 就是一套约定标准,不是技术; 用于约定我们的代码应该是怎样的一种结构。
  2. CommonJS 模块的特点

    • 所有代码都运行在模块作用域,不会污染全局作用域。
    • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载就直接读取缓存结果;module.exports 不会再次执行该模块。
    • 模块加载的顺序按照其在代码中出现的顺序。
  3. 模块的分类

    1. 自定义模块:就是我们自己写的功能模块文件。
    2. 核心模块:Node 平台自带的一套基本的功能模块。
    3. 第三方模块:社区或第三方开发好的功能模块,可以直接拿回来用。
  4. 模块的定义

    1. Node 内部提供一个 Module 构建函数。所有模块都是 Module 的实例,属性如下:
      • module.id 模块的识别符,通常是带有绝对路径的模块文件名。
      • module.filename 模块定义的文件的绝对路径。
      • module.loaded 返回一个布尔值,表示模块是否已经完成加载。
      • module.parent 返回一个对象,表示调用该模块的模块。
      • module.children 返回一个数组,表示该模块要用到的其他模块。
      • module.exports 表示模块对外输出的值。
    2. 载入一个模块就是构建一个 Module 实例,一个新的 JS 文件就是一个模块
    // 导出方式,`module.exports` 和 `exports`
    exports.name = value;
    module.exports = { name: value };
    

    module.exports 是用于为模块导出成员的接口;
    exports 是指向 module.exports 的别名,相当于在模块开始的时候执行:var exports = module.exports

  5. 用Node手写一个简单的 require

    function $require(files) {
        const fs = require('fs');
        const path = require('path');
        // 注意,这里实现的缓存不是Node的缓存机制
        const filename = path.join(__dirname, files);
        $require.cache=$require.cache||{};
        if($require.cache[filename]) return $require.cache[filename].exports;
    
        const dirname=path.dirname(filename);
        const file = fs.readFileSync(filename);
        const module = {
            id:filname,
            exports: {}
        };
        // 保存module.exports重新赋值前的值
        const { exports } = module;
        const code = `
            (function (module,exports,__dirname,__filename) {
                ${file}
            })(module,exports,dirname,filename)
        `;
        eval(code);
    
        $require.cache[filename]=module;
        return module.exports;
    }
    

    从以上代码我们可以知道:

    • 模块只执行一次 之后调用获取的module.exports都是缓存哪怕这个js还没执行完毕(因为先加入缓存后执行模块)。
    • 模块导出就是return这个变量的其实跟a = b赋值一样, 基本类型导出的是引用类型导出的是指针(内存地址)
    • exportsmodule.exports持有相同引用,因为最后导出的是module.exports,所以对exports进行赋值会导致exports操作的不再是module.exports的引用。

四、require 加载文件规则

Node 使用 CommonJS 模块规范,内置的 require 函数用于加载模块文件。require 的基本功能是,读入并执行一个 javascript 文件,然后返回该模块的 exports 对象。 如果没有发现指定模块,会报错。

require 加载文件规则如下:

  1. require 加载 js 文件时可以省略扩展名,也可以直接加载 json 文件
  2. 通过 ./ 或 ../ 开头:则按照相对路径从当前文件所在文件夹开始寻找模块
require('../file.js'); // 上级目录下找 file.js 文件
require('./file.js');  // 同级目录找 file.js 文件
require('file.js');    // 同级目录找 file.js 文件
  1. 通过 / 开头:则以系统根目录开始寻找模块
require('/vue-template/src/main.js'); // 以绝对路径的方式找
  1. 如果 require 传入的是一个目录的路径,会自动查看该目录的 package.json 文件,然后加载 main 字段指定的入口文件
  2. 如果 package.json 文件没有main字段,或者根本就没有 package.json 文件,则默认找目录下的 index.js 文件作为模块
  3. 如果参数字符串不以.././/开头,则表示加载的是一个默认提供的核心模块(位于 Node 的系统安装目录 node_modules)中
  4. 缓存文件的加载优先级最高,同名的系统模块要比自定义模块优先级高
  5. Node 在加载系统模块的时候,如果当前文件夹里面没有 node_modules 文件夹就会去逐层向上查找至项目根目录直到找到为止,如果没有就会报错。

五、ES6 模块

  1. 区别 CommonJS 模块
    1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
      • CommonJS 模块输出的是值的拷贝,也就是说一旦输出一个值,模块内部的变化就影响不到这个值。
        // lib.js
        const counter = 3;
        const incCounter = () => {
          counter++;
        };
        module.exports = {
          counter,
          incCounter,
        };
        
        // main.js
        const mod = require('./lib');
        console.log(mod.counter); // 3
        mod.incCounter();
        console.log(mod.counter); // 3
        
      • ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
        // lib.js
        export let counter = 3;
        export const incCounter = () => {
          counter++;
        };
        
        // main.js
        import { counter, incCounter } from './lib';
        console.log(counter); // 3
        incCounter();
        console.log(counter); // 4
        
        JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,原始值变了,import加载的值也会跟着变。再举一例子:
        // m1.js
        export var foo = 'bar';
        setTimeout(() => foo = 'baz', 500);
        
        // m2.js
        import {foo} from './m1.js';
        console.log(foo); // bar
        setTimeout(() => console.log(foo), 500); // baz
        
        上面代码表明,ES6 模块不会缓存运行结果,而是动态地去被加载的模块取值,并且变量总是绑定其所在的模块。
    2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
      • CommonJS 模块加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成,然后再从这个对象上面读取相关方法,这种加载称为运行时加载
      • ES6 模块不是对象,而是通过 export命令显式指定输出的代码,import时采用静态命令的形式,即在代码静态解析阶段就会生成,而不是加载整个模块,这种加载称为编译时输出。这也是ES6 模块非常轻松的实现Tree Shaking的重要因素。
    3. 使用 import 被导入的变量是只读的,不能被重新赋值。
    4. import 会自动提升到代码的顶层,以下代码都会报错:
      const num = 100;
      import xxx from 'xxx-module';
      
      // if for while ...
      if(boolExp){
          import xxx from 'xxx-module';
      }
      
      因为,CommonJS 模块是动态语法可以写在判断里,ES6 模块静态语法只能写在顶层。
    5. CommonJS 模块的顶层作用域里 this 指向当前模块,ES6 模块的顶层作用域里 this 指向 undefined
    6. CommonJS 模块的循环引用
      // a.js
      module.exports.a = 1;
      var b = require('./b');
      console.log(b);
      module.exports.a = 2;
      
      // b.js
      module.exports.b = 11;
      var a = require('./a');
      console.log(a);
      module.exports.b = 22;
      
      //main.js
      var a = require('./a');
      console.log(a);
      
      运行此段代码结合上面的require demo,分析每一步过程:
      1. 执行 node main.js -> 第一行 require(a.js),(node 执行也可以理解为调用了require方法,我们省略require(main.js)内容)
      2. 进入 require(a)方法: 判断缓存(无) -> 初始化一个 module -> 将 module 加入缓存 -> 执行模块 a.js 内容,(需要注意 是先缓存后执行模块内容)
      3. a.js: 第一行导出 a = 1 -> 第二行 require(b.js)(a 只执行了第一行)
      4. 进入 require(b) 内 同 1 -> 执行模块 b.js 内容
      5. b.js: 第一行 b = 11 -> 第二行 require(a.js)
      6. require(a) 此时 a.js 是第二次调用 require -> 判断缓存(有)-> cachedModule.exports -> 回到 b.js(因为js对象引用问题 此时的 cachedModule.exports = { a: 1 }
      7. b.js:第三行 输出 { a: 1 } -> 第四行 修改 b = 22 -> 执行完毕回到 a.js
      8. a.js:第二行 require 完毕 获取到 b -> 第三行 输出 { b: 22 } -> 第四行 导出 a = 2 -> 执行完毕回到 main.js
      9. main.js:获取 a -> 第二行 输出 { a: 2 } -> 执行完毕
    7. CommonJS 模块的循环引用
      // bar.js
      import { foo } from './foo';
      console.log(foo);
      export const bar = 'bar';
      
      // foo.js
      import { bar } from './bar';
      console.log(bar);
      export const foo = 'foo';
      
      // main.js
      import { bar } from './bar';
      console.log(bar);
      
      1. 执行 main.js -> 导入 bar.js
      2. bar.js -> 导入 foo.js
      3. foo.js -> 导入 bar.js -> bar.js 已经执行过(它认为这个接口已经存在了,就不会再去执行) -> 输出 bar -> bar is not defined, bar 未定义报错
        我们可以使用function的方式解决:
        // bar.js
        import { foo } from './foo';
        console.log(foo());
        export function bar(){
          return 'bar';
        }
        
        // foo.js
        import { bar } from './bar';
        console.log(bar());
        export function foo(){
          return 'foo';
        }
        
        // main.js
        import { bar } from './bar';
        console.log(bar());
        
        这是因为函数具有提升作用,在执行import { foo } from './foo'时,函数bar就已经有定义了,所以foo.js加载的时候不会报错。这也意味着,如果把函数foo改写成函数表达式,也会报错。

六、Node 加载

Node 对 ES6 模块的处理比较麻烦,因为它有自己的 CommonJS 模块格式,与 ES6 模块格式是不兼容的。目前的解决方案是,将两者分开,ES6 模块 和 CommonJS 模块采用各自的加载方案。从 v13.2 版本开始,Node 已经默认打开了 ES6 模块支持。在此版本之前,想要在 Node 中使用 ES6 模块,需要添加--experimental-modules,如:node --experimental-modules ./index.mjs

Node 要求使用 ES6 模块需要采用.mjs后缀文件名。也就是说,Node 遇到.mjs文件,就认为它是ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定"use strict"

如果不希望将后缀名改成.mjs,可以在项目的package.json文件中,指定type字段为module。如果不希望将后缀名改成.mjs,可以在项目的package.json文件中,指定type字段为module。一旦设置了以后,该目录里面的 JS 脚本,就被解释用 ES6 Module。

{
   "type": "module"  // 开启 ES6 Module 模式
}

如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 模块脚本的后缀名都改成.cjs。如果没有type字段,或者type字段为commonjs,则.js脚本会被解释成 CommonJS 模块。

1、main 和 exports 字段

package.json文件有两个字段可以指定模块的入口文件:mainexports

  1. main字段
    比较简单的模块,可以只使用main字段,指定模块加载的入口文件。
    {
      "type": "module",
      "main": "./index.js"
    }
    
    上面代码指定项目的入口脚本为./index.js,它的格式为 ES6 模块。如果没有type字段,index.js就会被解释为 CommonJS 模块。
  2. exports 字段
    exports字段的优先级高于main字段。它有多种用法。
    (1)子目录别名
    package.json文件的exports字段可以指定脚本或子目录的别名。
    // package.json
    {
      "exports": {
        "./xxx-file-name": "./xxx-dir/xxx.js", // 指定脚本别名
        "./xxx-dir-name/": "./xxx-dir/", // 指定子目录别名
      }
    }
    
    // 模块引入
    import module1 from 'project-name/xxx-file-name';
    import module2 from 'project-name/xxx-dir-name/xxx.js';
    
    (2)main 字段的别名
    exports字段的别名如果是.,就代表模块的主入口,优先级高于main字段,并且可以直接简写成exports字段的值。
    {
      "exports": {
        ".": "./main.js"
      }
    }
    
    // 等同于
    {
      "exports": "./main.js"
    }
    
    由于exports字段只有支持 ES6 的 Node 才认识,所以可以用来兼容旧版本的 Node。
    {
      "main": "./old-version.js",
      "exports": {
        ".": "./new-version.js"
      }
    }
    
    (3)条件加载
    利用.这个别名,可以为 ES6 Module 和 CommonJS 模块指定不同的入口。目前,这个功能需要在 Node 运行的时候,打开--experimental-conditional-exports标志。
    {
      "type": "module",
      "exports": {
        ".": {
          "require": "./main.cjs", // require 规定 CommonJS 模块的入口
          "default": "./main.js"   // default 规定 `default` 条件指定其他情况的入口,即 ES6 模块的入口
        }
      }
    }
    
    上面可以简写如下:
    {
      "exports": {
        "require": "./main.cjs",
        "default": "./main.js"
      }
    }
    
    如果有别名则只能如下:
    {
      "exports": {
        ".": {
          "./xxx-file-name": "./xxx-dir/xxx.js",
          "require": "./main.cjs",
          "default": "./main.js"
        }
      }
    }
    

2、ES6 模块加载 CommonJS 模块

有了上一节的条件加载以后,Node 本身就可以同时处理两种模块。

{
  "type": "module",
  "main": "./index.cjs",
  "exports": {
    "require": "./index.cjs",
    "default": "./wrapper.mjs"
  }
}

注意,import命令加载 CommonJS 模块,只能整体加载,不能只加载单一的输出项。

// 正确
import packageMain from 'commonjs-package';

// 报错
import { method } from 'commonjs-package';

3、CommonJS 模块加载 ES6 模块

CommonJS 模块的require命令不能加载ES6 模块,会报错,只能使用import()这个方法加载。

4、Node 的内置模块

Node 的内置模块可以整体加载,也可以加载指定的输出项。

七、总结

以上就是我对模块化加载的理解,当然这里没有去过多的写案例,目的是为了以后回来查阅方便,方便自己记忆。

参考链接


posted @ 2020-06-11 15:54  Feesir  阅读(188)  评论(0编辑  收藏  举报