0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

前端技术探秘-Nodejs的CommonJS规范实现原理

京东云 来源:jf_75140285 作者:jf_75140285 2024-11-05 11:56 次阅读

了解Node.js

Node.js是一个基于ChromeV8引擎的JavaScript运行环境,使用了一个事件驱动、非阻塞式I/O模型,让JavaScript 运行在服务端的开发平台,它让JavaScript成为与PHP、Python、Perl、Ruby等服务端语言平起平坐的脚本语言。Node中增添了很多内置的模块,提供各种各样的功能,同时也提供许多第三方模块。

模块的问题

为什么要有模块

复杂的前端项目需要做分层处理,按照功能、业务、组件拆分成模块, 模块化的项目至少有以下优点:

1.便于单元测试

2.便于同事间协作

3.抽离公共方法, 开发快捷

4.按需加载, 性能优秀

5.高内聚低耦合

6.防止变量冲突

7.方便代码项目维护

几种模块化规范

•CMD(SeaJS 实现了 CMD)

AMD(RequireJS 实现了 AMD)

•UMD(同时支持 AMD 和 CMD)

• IIFE (自执行函数)

• CommonJS (Node 采用了 CommonJS)

• ES Module 规范 (JS 官方的模块化方案)

Node中的模块

Node中采用了 CommonJS 规范

实现原理:

Node中会读取文件,拿到内容实现模块化, Require方法 同步引用

tips:Node中任何js文件都是一个模块,每一个文件都是模块

Node中模块类型

1.内置模块,属于核心模块,无需安装,在项目中不需要相对路径引用, Node自身提供。

2.文件模块,程序员自己书写的js文件模块。

3.第三方模块, 需要安装, 安装之后不用加路径。

Node中内置模块

fs filesystem

操作文件都需要用到这个模块

const path = require('path'); // 处理路径
const fs = require('fs'); // file system
// // 同步读取
let content = fs.readFileSync(path.resolve(__dirname, 'test.js'), 'utf8');
console.log(content);

let exists = fs.existsSync(path.resolve(__dirname, 'test1.js'));
console.log(exists);

path 路径处理

const path = require('path'); // 处理路径


// join / resolve 用的时候可以混用

console.log(path.join('a', 'b', 'c', '..', '/'))

// 根据已经有的路径来解析绝对路径, 可以用他来解析配置文件
console.log(path.resolve('a', 'b', '/')); // resolve 不支持/ 会解析成根路径

console.log(path.join(__dirname, 'a'))
console.log(path.extname('1.js'))
console.log(path.dirname(__dirname)); // 解析父目录

vm 运行代码

字符串如何能变成 JS 执行呢?

1.eval

eval中的代码执行时的作用域为当前作用域。它可以访问到函数中的局部变量。

let test = 'global scope'
global.test1 = '123'
function b(){
  test = 'fn scope'
  eval('console.log(test)'); //local scope
  new Function('console.log(test1)')() // 123
  new Function('console.log(test)')() //global scope
}
b()

1.new Function

new Function()创建函数时,不是引用当前的词法环境,而是引用全局环境,Function中的表达式使用的变量要么是传入的参数要么是全局的值

Function可以获取全局变量,所以它还是可能会有变量污染的情况出现

function getFn() {
  let value = "test"
  let fn = new Function('console.log(value)')
  return fn
}

getFn()()

global.a = 100 // 挂在到全局对象global上
new Function("console.log(a)")() // 100

1.vm

前面两种方式,我们一直强调一个概念,那就是变量的污染

VM的特点就是不受环境的影响,也可以说他就是一个沙箱环境

在Node中全局变量是在多个模块下共享的,所以尽量不要在global中定义属性

所以,vm.runInThisContext可以访问到global上的全局变量,但是访问不到自定义的变量。而vm.runInNewContext访问不到global,也访问不到自定义变量,他存在于一个全新的执行上下文

const vm = require('vm')
global.a = 1
// vm.runInThisContext("console.log(a)")
vm.runInThisContext("a = 100") // 沙箱,独立的环境
console.log(a) // 1
vm.runInNewContext('console.log(a)')
console.log(a) // a is not defined

Node模块化的实现

node中是自带模块化机制的,每个文件就是一个单独的模块,并且它遵循的是CommonJS规范,也就是使用require的方式导入模块,通过module.export的方式导出模块。

node模块的运行机制也很简单,其实就是在每一个模块外层包裹了一层函数,有了函数的包裹就可以实现代码间的作用域隔离。



我们先在一个js文件中直接打印arguments,得到的结果如下图所示,我们先记住这些参数。

console.log(arguments) // exports, require, module, __filename, __dirname

chaijie_default.png

Node中通过modules.export 导出,require 引入。其中require依赖node中的fs模块来加载模块文件,通过fs.readFile读取到的是一个字符串。

在javascrpt中可以通过eval或者new Function的方式来将一个字符串转换成js代码来运行。但是前面提到过,他们都有一个致命的问题,就是变量的污染

实现require模块加载器

首先导入依赖的模块path,fs,vm, 并且创建一个Require函数,这个函数接收一个modulePath参数,表示要导入的文件路径

const path = require('path');
const fs = require('fs');
const vm = require('vm');
// 定义导入类,参数为模块路径
function Require(modulePath) {
   ...
}

在Require中获取到模块的绝对路径,使用fs加载模块,这里读取模块内容使用new Module来抽象,使用tryModuleLoad来加载模块内容,Module和tryModuleLoad稍后实现,Require的返回值应该是模块的内容,也就是module.exports。

// 定义导入类,参数为模块路径
function Require(modulePath) {
    // 获取当前要加载的绝对路径
    let absPathname = path.resolve(__dirname, modulePath);
    // 创建模块,新建Module实例
    const module = new Module(absPathname);
    // 加载当前模块
    tryModuleLoad(module);
    // 返回exports对象
    return module.exports;
}

Module的实现就是给模块创建一个exports对象,tryModuleLoad执行的时候将内容加入到exports中,id就是模块的绝对路径。

// 定义模块, 添加文件id标识和exports属性
function Module(id) {
    this.id = id;
    // 读取到的文件内容会放在exports中
    this.exports = {};
}

node模块是运行在一个函数中,这里给Module挂载静态属性wrapper,里面定义一下这个函数的字符串,wrapper是一个数组,数组的第一个元素就是函数的参数部分,其中有exports,module,Require,__dirname,__filename, 都是模块中常用的全局变量.

第二个参数就是函数的结束部分。两部分都是字符串,使用的时候将他们包裹在模块的字符串外部就可以了。

// 定义包裹模块内容的函数
Module.wrapper = [
    "(function(exports, module, Require, __dirname, __filename) {",
    "})"
]

_extensions用于针对不同的模块扩展名使用不同的加载方式,比如JSON和javascript加载方式肯定是不同的。JSON使用JSON.parse来运行。



javascript使用vm.runInThisContext来运行,可以看到fs.readFileSync传入的是module.id也就是Module定义时候id存储的是模块的绝对路径,读取到的content是一个字符串,使用Module.wrapper来包裹一下就相当于在这个模块外部又包裹了一个函数,也就实现了私有作用域。



使用call来执行fn函数,第一个参数改变运行的this传入module.exports,后面的参数就是函数外面包裹参数exports, module, Require, __dirname, __filename。/

// 定义扩展名,不同的扩展名,加载方式不同,实现js和json
Module._extensions = {
    '.js'(module) {
        const content = fs.readFileSync(module.id, 'utf8');
        const fnStr = Module.wrapper[0] + content + Module.wrapper[1];
        const fn = vm.runInThisContext(fnStr);
        fn.call(module.exports, module.exports, module, Require,__filename,__dirname);
    },
    '.json'(module) {
        const json = fs.readFileSync(module.id, 'utf8');
        module.exports = JSON.parse(json); // 把文件的结果放在exports属性上
    }
}

tryModuleLoad函数接收的是模块对象,通过path.extname来获取模块的后缀名,然后使用Module._extensions来加载模块。

// 定义模块加载方法
function tryModuleLoad(module) {
    // 获取扩展名
    const extension = path.extname(module.id);
    // 通过后缀加载当前模块
    Module._extensions[extension](module); // 策略模式???
}

到此Require加载机制基本就写完了。Require加载模块的时候传入模块名称,在Require方法中使用path.resolve(__dirname, modulePath)获取到文件的绝对路径。然后通过new Module实例化的方式创建module对象,将模块的绝对路径存储在module的id属性中,在module中创建exports属性为一个json对象。



使用tryModuleLoad方法去加载模块,tryModuleLoad中使用path.extname获取到文件的扩展名,然后根据扩展名来执行对应的模块加载机制。



最终将加载到的模块挂载module.exports中。tryModuleLoad执行完毕之后module.exports已经存在了,直接返回就可以了。



接下来,我们给模块添加缓存。就是文件加载的时候将文件放入缓存中,再去加载模块时先看缓存中是否存在,如果存在直接使用,如果不存在再去重新加载,加载之后再放入缓存。

// 定义导入类,参数为模块路径
function Require(modulePath) {
  // 获取当前要加载的绝对路径
  let absPathname = path.resolve(__dirname, modulePath);
  // 从缓存中读取,如果存在,直接返回结果
  if (Module._cache[absPathname]) {
      return Module._cache[absPathname].exports;
  }
  // 创建模块,新建Module实例
  const module = new Module(absPathname);
  // 添加缓存
  Module._cache[absPathname] = module;
  // 加载当前模块
  tryModuleLoad(module);
  // 返回exports对象
  return module.exports;
}

增加功能:省略模块后缀名。

自动给模块添加后缀名,实现省略后缀名加载模块,其实也就是如果文件没有后缀名的时候遍历一下所有的后缀名看一下文件是否存在。

// 定义导入类,参数为模块路径
function Require(modulePath) {
  // 获取当前要加载的绝对路径
  let absPathname = path.resolve(__dirname, modulePath);
  // 获取所有后缀名
  const extNames = Object.keys(Module._extensions);
  let index = 0;

  // 存储原始文件路径
  const oldPath = absPathname;
  function findExt(absPathname) {
      if (index === extNames.length) {
         return throw new Error('文件不存在');
      }
      try {
          fs.accessSync(absPathname);
          return absPathname;
      } catch(e) {
          const ext = extNames[index++];
          findExt(oldPath + ext);
      }
  }
  
  // 递归追加后缀名,判断文件是否存在
  absPathname = findExt(absPathname);
  // 从缓存中读取,如果存在,直接返回结果
  if (Module._cache[absPathname]) {
      return Module._cache[absPathname].exports;
  }
  // 创建模块,新建Module实例
  const module = new Module(absPathname);
  // 添加缓存
  Module._cache[absPathname] = module;
  // 加载当前模块
  tryModuleLoad(module);
  // 返回exports对象
  return module.exports;
}

源代码调试

我们可以通过VSCode 调试Node.js

步骤

创建文件a.js

module.exports = 'abc'

1.文件test.js

let r = require('./a')

console.log(r)

1.配置debug,本质是配置.vscode/launch.json文件,而这个文件的本质是能提供多个启动命令入口选择。

一些常见参数如下:

•program控制启动文件的路径(即入口文件)

•name下拉菜单中显示的名称(该命令对应的入口名称)

•request分为 launch(启动)和 attach(附加)(进程已经启动)

•skipFiles指定单步调试跳过的代码

•runtimeExecutable设置运行时可执行文件,默认是 node,可以设置成 nodemon,ts-node,npm 等



修改launch.json,skipFiles指定单步调试跳过的代码

chaijie_default.png

wKgaomcoYTSAHjOJAAVVxgFJTj4097.png

1.将test.js 文件中的require方法所在行前面打断点

2.执行调试,进入源码相关入口方法

梳理代码步骤

1.首先进入到进入到require方法:Module.prototype.require

chaijie_default.png

wKgZoWcoYS6ANSXmAACOg1io-KA134.pngchaijie_default.png

2.调试到Module._load 方法中,该方法返回module.exports,Module._resolveFilename方法返回处理之后的文件地址,将文件改为绝对地址,同时如果文件没有后缀就加上文件后缀。

wKgZoWcoYSWAFi2HAAwqRMgHBFc765.pngchaijie_default.png

3.这里定义了Module类。id为文件名。此类中定义了exports属性

wKgaoWcoYSCAd4J6AAQv-Cy1vUc975.pngchaijie_default.png

4.接着调试到module.load 方法,该方法中使用了策略模式,Module._extensions[extension](this, filename)根据传入的文件后缀名不同调用不同的方法

wKgaoWcoYRuAbjAEAAJ-IVVB6Ws513.pngchaijie_default.png

5.进入到该方法中,看到了核心代码,读取传入的文件地址参数,拿到该文件中的字符串内容,执行module._compile

wKgZoWcoYReAIo7ZAAkyQrvDNos369.pngchaijie_default.png

6.此方法中执行wrapSafe方法。将字符串前后添加函数前后缀,并用Node中的vm模块中的runInthisContext方法执行字符串,便直接执行到了传入文件中的console.log代码行内容。

chaijie_default.png

wKgaoWcoYQiAT4ogAAH__0e2ado793.png

wKgZoWcoYQ6AAyjnAAqi7A9-Ml8367.png

至此,整个Node中实现require方法的整个流程代码已经调试完毕,通过对源代码的调试,可以帮助我们学习其实现思路,代码风格及规范,有助于帮助我们实现工具库,提升我们的代码思路,同时我们知道相关原理,也对我们解决日常开发工作中遇到的问题提供帮助。

审核编辑 黄宇

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • 前端
    +关注

    关注

    1

    文章

    190

    浏览量

    17721
  • nodejs
    +关注

    关注

    0

    文章

    20

    浏览量

    4220
  • commonjs
    +关注

    关注

    0

    文章

    2

    浏览量

    1107
收藏 人收藏

    评论

    相关推荐

    buildroot中添加nodejs后推荐加哪些包?

    现有一IoT中运行的nodejs 16项目,计划用buildroot部署在设备上。我将开发板中的buildroot升级后,成功编译运行了nodejs16本体。但在启动项目、依赖等时遇到各种“依赖
    发表于 11-01 12:57

    02.002 NodeJS入门 为什么要学习NodeJS

    nodejs
    充八万
    发布于 :2023年07月19日 14:27:44

    04.004 NodeJS入门 NodeJS的作用 #硬声创作季

    nodejs
    充八万
    发布于 :2023年07月19日 14:33:58

    05.005 NodeJS入门 NodeJS的安装

    nodejs
    充八万
    发布于 :2023年07月19日 14:35:12

    01.001 NodeJS视频简介

    nodejs
    充八万
    发布于 :2023年07月19日 19:05:48

    DVB条件接收同密前端系统结构和同步技术规范 pdf

    DVB条件接收同密前端系统结构和同步技术规范 
    发表于 05-08 09:02

    【深圳】诚聘web前端工程师

    猎头职位:web前端工程师(年薪:15-36W)工作职责:1.参与前端技术选型、架构;2.参与开发接入层(Node);3.开发mobile web以及webapp;4.完成各种网站项目需求。岗位要求
    发表于 07-21 10:33

    什么是前端技术

    前端技术概览
    发表于 06-04 09:48

    PLC通信原理探秘大讲堂幕后彩蛋之搁浅

    专家大讲堂《PLC通信原理探秘》系列视频:https://www.ad.siemens.com.cn/service/elearning/series/288.html连载之一:【PLC通信原理探秘
    发表于 07-01 12:58

    DVB条件接收同密前端系统结构和同步技术规范

    DVB条件接收同密前端系统结构和同步技术规范: Digital Video Broadcasting (DVB);DVB SimulCrypt;Head-end architecture and synchronization
    发表于 05-07 21:32 27次下载

    CommonJs,AMD,CMD区别

    CommonJs CommonJs是服务器端模块的规范,Node.js采用了这个规范。根据CommonJS
    发表于 11-27 13:33 1117次阅读

    创建 Web 前端开发环境

    Web 前端开发涉及多种工具,这里将常用工具的安装和配置进行说明,提供了详细的说明,为后继的开发创建一个坚实的基础。本文介绍的工具有:NodeJS, NPM, Bower, Git 和 Grunt。
    的头像 发表于 02-01 14:06 2317次阅读

    nodejs 后端技术介绍

    笔者最开始学的后端技术是 python 的 Django 框架,由于很久没有使用过 python 语法,便想着了解一些 nodejs 的后端技术。下面将最近的收获总结一下。
    的头像 发表于 05-05 16:41 1065次阅读

    使用Homebridge和HAP NodeJS来模拟HomeKit API

    电子发烧友网站提供《使用Homebridge和HAP NodeJS来模拟HomeKit API.zip》资料免费下载
    发表于 07-10 10:42 0次下载
    使用Homebridge和HAP <b class='flag-5'>NodeJS</b>来模拟HomeKit API

    NodejsCommonJS规范实现原理

    Node.js 是一个基于 ChromeV8 引擎的 JavaScript 运行环境,使用了一个事件驱动、非阻塞式 I/O 模型,让 JavaScript 运行在服务端的开发平台
    的头像 发表于 11-25 10:21 474次阅读
    <b class='flag-5'>Nodejs</b>的<b class='flag-5'>CommonJS</b><b class='flag-5'>规范</b><b class='flag-5'>实现</b>原理