• 浅谈 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 的内置模块可以整体加载,也可以加载指定的输出项。

    七、总结

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

    参考链接


  • 相关阅读:
    Django基础(一)
    CSS
    HTML
    python之路_面向对象
    python之路第六篇
    python之路第四篇
    python之路第三篇
    python之路第二篇
    python之路第一篇
    hdu 3551(一般图的匹配)
  • 原文地址:https://www.cnblogs.com/onebox/p/13093752.html
Copyright © 2020-2023  润新知