• Node.js精进(1)——模块化


      模块化是一种将软件功能抽离成独立、可交互的软件设计技术,能促进大型应用程序和系统的构建。

      Node.js内置了两种模块系统,分别是默认的CommonJS模块和浏览器所支持的ECMAScript模块

      其中,ECMAScript模块是在8.5.0版本中新增的,后面又经过了几轮的迭代。本文若无特别说明,那么分析的都是CommonJS模块。

      顺便说一句,本系列分析的是Node.js的最新版本18.0.0,在Github上下载源码后,可以关注下面3个目录。

    ├── deps          第三方依赖
    ├── lib           对外暴露的标准库JavaScript源码,例如path、fs等
    ├── src           支撑Node运行的C/C++ 源码文件,例如HTTP解析、进程处理等

      本系列所有的示例源码都已上传至Github,点击此处获取。

      还有一点需要指出,Node.js的官方说明文档,是我目前为止遇到的比较符合人类阅读的文档。

    一、基础语法

      先来分析一下CommonJS模块的基础语法,在Node.js中,可通过 module.exports 和 exports 来导出一个模块,再通过 require() 来导入一个模块。

      来看个简单的示例,先在 1.js 文件中声明 human 对象,然后使用 module.exports 导出,然后在 2.js 中导入 1.js 文件,打印输出。

    // 1.js
    const human = {
      name: 'strick'
    }
    module.exports = human;
    // 2.js
    const human = require('./1.js');
    console.log(human);  // { name: 'strick' }

      exports 是 module.exports 的快捷方式,但是不能对其直接赋值,像下面这样导出的就是一个空对象。

    // 3.js
    exports = {
      name: 'strick'
    };
    // 2.js
    const human = require('./3.js');
    console.log(human);  // {}

      接下来换一种写法,为 exports 添加一个属性,这样就能正确导出。

    // 3.js
    exports.human = {
      name: 'strick'
    };
    // 2.js
    const human = require('./3.js');
    console.log(human);  // { human: { name: 'strick' } }

      module.exports 导出了它所指向的对象,而 exports 导出的是对象的属性。

    二、CommonJS原理

      在Node.js中,可分成两大类的模块:核心模块和第三方模块。

      其中核心模块又分成 built-in 模块和 native 模块,前者由C/C++编写,存在于源码的src目录中;后者由JavaScript编写,存在于lib目录中。

      注意,在 lib/internal/modules 目录中,可以查看两种模块系统的源码。

      所有非Node.js自带的模块统称为第三方模块,也就是任意文件,大家自己写的业务代码以及依赖的第三方应用库都属于此范畴。

      Node.js会使用模块封装器(如下所示)将模块中的代码包裹,形成模块作用域,这样就能避免模块之间的作用域污染。

    (function(exports, require, module, __filename, __dirname) {
        // 模块代码实际存在于此处
    });

      __filename可以得到当前模块的绝对路径加文件名。__dirname表示当前模块的目录名,也包含绝对路径,与 path.dirname() 相同。

    console.log(__filename);    // /Users/strick/code/web/node/01/4.js
    console.log(__dirname);     // /Users/strick/code/web/node/01

    1)require()

      在lib/internal/modules/cjs/loader.js中声明了 require() 函数,requireDepth 记载了模块加载的深度。

    Module.prototype.require = function(id) {
      validateString(id, 'id');    // 判断id变量是否是字符串类型
      if (id === '') {
        throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string');
      }
      requireDepth++;
      try {
        return Module._load(id, this, /* isMain */ false);
      } finally {
        requireDepth--;
      }
    };

      在 _load() 中实现了主要的加载逻辑,源码比较长,做了些删减,只列出了关键部分。

    Module._load = function(request, parent, isMain) {
      // 解析模块的路径和名称
      const filename = Module._resolveFilename(request, parent, isMain);
      // 核心模块使用 node: 前缀,会绕过 require 缓存
      if (StringPrototypeStartsWith(filename, 'node:')) {
        const id = StringPrototypeSlice(filename, 5);    // Slice 'node:' prefix
        const module = loadNativeModule(id, request);
        if (!module?.canBeRequiredByUsers) {
          throw new ERR_UNKNOWN_BUILTIN_MODULE(filename);
        }
        return module.exports;
      }
      // 第一种情况:如果缓存中已经存在此模块,那么返回模块的 exports 属性
      const cachedModule = Module._cache[filename];
      if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        if (!cachedModule.loaded) {
          const parseCachedModule = cjsParseCache.get(cachedModule);
          if (!parseCachedModule || parseCachedModule.loaded)
            return getExportsForCircularRequire(cachedModule);
          parseCachedModule.loaded = true;
        } else {
          return cachedModule.exports;
        }
      }
      // 第二种情况:如果是核心模块,那么调用 NativeModule.prototype.compileForPublicLoader() 返回模块的 exports 属性
      const mod = loadNativeModule(filename, request);
      if (mod?.canBeRequiredByUsers &&
          NativeModule.canBeRequiredWithoutScheme(filename)) {
        return mod.exports;
      }
      // 第三种情况:如果是第三方文件,那么创建一个新模块并加载文件内容,再将其保存到缓存中
      const module = cachedModule || new Module(filename, parent);
      Module._cache[filename] = module;
      return module.exports;
    };

      在 _load() 方法中,会先判断 node: 前缀(在官方文档的核心模块中有过介绍),然后列出3种加载情况:

    1. 如果缓存中已经存在此模块,那么返回模块的 exports 属性。
    2. 如果是核心模块,那么调用 NativeModule.prototype.compileForPublicLoader() 返回模块的 exports 属性。
    3. 如果是第三方文件,那么创建一个新模块并加载文件内容,再将其保存到缓存中。

      Node.js在加载JS文件时,会先判断是否有缓存,然后读取文件内容,再调用 _compile() 进行编译,下面的源码也做了删减。

      还有另外两种 .json 和 .node 后缀的文件加载过程在此省略。

    Module._extensions['.js'] = function(module, filename) {
      // 如果已经分析了源,那么它将被缓存
      const cached = cjsParseCache.get(module);
      let content;
      if (cached?.source) {
        content = cached.source;
        cached.source = undefined;
      } else {
        content = fs.readFileSync(filename, 'utf8');
      }
      module._compile(content, filename);
    };

      在 _compile() 方法中会调用vm模块创建沙盒,再执行函数代码,源码比较长,在此省略。

      注意,虽然 vm 可以在V8虚拟机的上下文中编译和执行JavaScript代码,但是它比eval()更为安全,因为它运行的脚本无权访问外部作用域。

    2)加载顺序

      经过上面的源码分析,可知加载顺序是先缓存,再核心模块,最后第三方模块,再详细一点的话就是:

      (1)缓存,模块在第一次加载后被缓存,也就是说,解析相同的文件,会返回完全相同的对象,除非修改require.cache

      (2)核心模块,部分核心模块已被编译成二进制文件,加载到了内存中。

      (3)文件模块的加载过程如下:

    1. 优先加载带' /'、'./' 或 '../' 路径前缀的模块。
    2. 若文件没有后缀,则依次添加 .js、.json 和 .node 尝试加载。
    3. 若模块没有路径来指示文件,则该模块必须是核心模块或从 node_modules 目录加载。
    4. 再找不到就抛出 MODULE_NOT_FOUND 错误。

      (4)目录作为模块的加载过程如下:

    1. 先将目录当成包来处理,查找 package.json 文件,读取 main 字段描述的入口文件。
    2. 若没有 package.json,main 字段缺失或无法解析时,尝试依次加载目录中的 index.js、index.json 或 index.node 文件。
    3. 如果这些尝试都失败,则抛出错误,Error: Cannot find module 'xx/xx.js'。

      (5)从 node_modules 目录加载,若不是核心模块并且没有路径前缀,那么从当前模块的目录向上查找,并添加 /node_modules,直至根目录为止。

      例如,在'/Users/strick/code/tmp.js' 中调用require('test.js'),那么将按以下顺序查找:

    1. /Users/strick/code/node_modules/test.js
    2. /Users/strick/node_modules/test.js
    3. /Users/node_modules/test.js
    4. /node_modules/test.js

      (6)从全局目录加载,一种官方不推荐的加载方式。

      如果 NODE_PATH 环境变量设置为以冒号分隔的绝对路径列表,则 Node.js 将在这些路径中搜索模块(如果它们在其他地方找不到)。

    3)循环引用

      在Node.js中,当两个模块通过 require() 函数加载对方时,就形成了循环引用,但不会形成死循环。

      下面的示例来自于官网,对其做了些调整。

      先创建 a.js,在加载 b 模块之前,done 是 false,并且声明了一个 globalVar 变量,没有为其添加任何声明变量的关键字,在 b 模块加载完成后,done 赋值为 true。

    console.log('a starting');
    exports.done = false;
    globalVar = '全局变量'; // 在a模块中声明的全局变量
    const b = require('./b.js');
    console.log('在a模块中, b.done = %j', b.done);
    exports.done = true;
    console.log('a done');

      再创建 b.js,在加载 a 模块之前,done 也是 false,在 a 模块加载完成之后,done 也赋值为 true。

    console.log('b starting');
    exports.done = false;
    const a = require('./a.js');
    console.log('在b模块中, a.done = %j', a.done);
    console.log('globalVar: ', globalVar);
    exports.done = true;
    console.log('b done');

      最后创建 main.js,再加载 b 模块。

    console.log('main starting');
    const a = require('./a.js');    // 先导入a模块
    const b = require('./b.js');    // 再导入b模块
    console.log('在main模块中, a.done = %j, b.done = %j', a.done, b.done);

      最终的打印顺序如下所示,在 main.js 中,先加载 a 模块,而在 a 模块中会尝试加载 b 模块。那么在进入到 b 模块后,为了防止无限死循环,会导出 a 模块已执行完成的部分。

    main starting
    a starting
    b starting
    在b模块中, a.done = false
    globalVar:  全局变量
    b done
    在a模块中, b.done = true
    a done
    在main模块中, a.done = true, b.done = true

      在上述示例中,还涉及到另一个问题,那就是在 a 模块中声明的 globalVar 变量,能在 b 模块中被成功打印。

      在上文中也曾提到过模块封装器,那么 globalVar 变量的声明和打印,相当于下面这样,如果在函数内声明变量时省略 var 关键字,那么这个变量就会变成全局变量。

    // a.js
    (function (exports, require, module, __filename, __dirname) {
      globalVar = '全局变量';
    });
    // b.js
    (function (exports, require, module, __filename, __dirname) {
      console.log(globalVar);
    });

      若要避免污染全局作用域,那么可以声明严格模式,禁止隐式的全局声明,如下所示。

    'use strict';
    globalVar = '全局变量';

    5)与ECMAScript模块的差异

      (1)import 语句只允许在 ES 模块中使用,但可以导入两种模块;而 CommonJS 的 require() 不能导入 ES 模块。

      (2)ES 模块的 import 是异步执行的;而 CommonJS 模块的 require() 是同步执行的。

      (3)ES 模块没有 __filename、__dirname、require.cache、module.exports 等变量。

      (4)ES 模块是编译时输出,可以静态分析模块依赖;而 CommonJS 是运行时加载。

      (5)ES 模块输出的是值引用;而 CommonJS 模块输出的是值副本。

      需要通过一个示例来理解第五点差异,首先创建 lib.mjs 文件,.mjs 是 Node.js 为 ES 模块保留的后缀,在此类文件内可使用 export 和 import 语法。

      在 lib.mjs 文件中,声明 digit 变量和 increase() 函数,在函数中对 digit 执行递增,通过 export 将它们导出。

    // lib.mjs
    export let digit = 0;
    export function increase() {
      digit++;
    }

      在 main.mjs 文件中,加载 lib.mjs,打印 digit 变量,值为 0,调用 increase() 函数,再打印,值变为 1。由此可知,外部可以修改模块内部的值。

    // main.mjs
    import { digit, increase } from './lib.mjs';
    console.log(digit);  // 0
    increase();
    console.log(digit);  // 1

      接下来创建 lib.js 文件,同样是 digit 变量和 increase() 函数,通过 module.exports 将它们导出。

    // lib.js
    let digit = 0;
    function increase() {
      digit++;
    }
    module.exports.digit = digit;
    module.exports.increase = increase;

      在 main.js 文件中,加载 lib.js,打印 digit 变量,值为 0,调用 increase() 函数,再打印,仍然是 0。由此可知,外部无法修改模块内部的值。

    // main.js
    const lib = require('./lib');
    console.log(lib.digit);  // 0
    lib.increase();
    console.log(lib.digit);  // 0

      (6)ES 模块不管是否遇到循环引用,其 import 导入的变量都会成为一个指向被加载模块的引用,而 CommonJS 模块遇到循环引用只会导出模块已执行完成的部分。

      这其实也是两者加载机制的不同所导致的,参考第四点不同。

      CommonJS 对循环引用的处理过程在上文中已介绍,现在改造之前官网的示例,在 main.mjs 中导入 a 和 b 两个模块,并打印 a 和 b 的值。

    // main.mjs
    import a from './a.mjs';
    import b from './b.mjs';
    console.log('在main模块中, a = %j, b = %j', a, b);

      在 a.mjs 中,会导入 b.mjs,并打印 b 的值。而在 b.mjs 中,会导入 a.mjs,并打印 a 的值,如此就形成了循环引用。

    // a.mjs
    import b from './b.mjs';
    let done = false;
    export default done;
    console.log('在a模块中, b = %j', b);
    
    // b.mjs
    import a from './a.mjs';
    let done = false;
    export default done;
    console.log('在b模块中, a = %j', a);

      运行 main.mjs,马上就会报错:ReferenceError: Cannot access 'a' before initialization。

      在 main.mjs 中读取 a 的值时,会执行 a.mjs 并读取 b 的值,而在 b.mjs 中,默认会认为 a 已存在,但在访问的时候就会发现被欺骗,然后就报错了。

    参考资料:

    CommonJS模块

    ECMAScript模块

    使用 exports 从 Node.js 文件中公开功能

    饿了么模块题目

    为什么 Node.js 不给每一个.js文件以独立的上下文来避免作用域被污染? 

    Node.js技术栈

    深入理解Node.js:核心思想与源码分析

    Node.js 模块系统源码探微

    Node.js VM 不完全指北

    What’s the difference between CommonJS and ES6 modules?

    ECMAScript6入门之ES6模块的循环加载

  • 相关阅读:
    10进制转换为二十六进制字符串A-Z
    解决Missing artifact jdk.tools:jdk.tools:jar:1.8报错
    JAVA中AES对称加密和解密以及与Python兼容
    Nginx配置客户端SSL双向认证
    (备忘)最全的正则表达式
    (备忘)Python字符串、元组、列表、字典互相转换的方法
    (备忘)Nodepad++常用快捷键
    (备忘)正则表达式全部符号解释
    (备忘)Window7下安装Python2.6及Django1.4
    (备忘)Java Map 遍历
  • 原文地址:https://www.cnblogs.com/strick/p/16215749.html
Copyright © 2020-2023  润新知