如何使node也支持从url加载一个module详解

前言

最近两天 ry 大神的 deno 火了一把。作为 node 项目的发起人,现在又基于 go 重新写了一个类似 node 的项目命名为 deno,引发了大家的强烈关注。

在 deno 项目 readme 的开始就列举出了这个项目的优势和需要解决的问题,里面最让我瞩目的就是模块原生支持 ts ,同时也能也必须从 url 加载模块,这也是与现有的 CommonJS 最大的不同。

仔细思考一下,deno 的模块化与 CommonJS 相比,更多的是一些 runtime 的能力。现有的 CommonJS 底层实现过程并不是静态化,考虑了很多的动态配置,所以基于现有到 CommonJS 改造起来还是比较容易的,支持 url 加载或者 ts 模块也并不复杂,主要难点在于与系统调用的耦合度上。所以周六在家准备撸个小项目,从上层入手,算是仿照 deno 的这几个特性使得一个仿原生 node 的 CommonJS 模块语法也能支持这些特性。

CommonJS 的执行过程

想要让 CommonJS 支持 url 访问或者原生加载 ts 模块,必须从 CommonJS 的执行过程中入手,在中间阶段将模块注入进去。而 CommonJS 的执行过程其实总结起来很简单,大概分为以下几点:

  • 处理路径依赖

处理路径依赖应该也是所有模块化加载规范的第一步,换言之就是根据路径找到文件的位置。无论是 CommonJS 的 require 还是 ESModule 的 import,无论是相对路径还是绝对路径,都必须首先在内部对这个路径进行处理,找到合适的文件地址。

模块路径有可能是绝对路径,有可能是相对路径,有可能省略了后缀(js、node、json),有可能省略了文件名(index),甚至是动态路径(运行时基于变量的动态拼接)等等。

首先就是遵守约定,同时按照一定的策略找到这个文件的真实位置,中间的过程就是补齐上面模块化省略的东西。一般都是根据 CommonJS 的这张流程图

  • 加载文件

确认了路径并且确保了文件存在之后,加载文件这一步就简单粗暴的多。最简单的方式就是直接读取硬盘上的文件,将纯文本的模块源代码读取至内存。

  • 拼接函数

在上一步中获取到的只是代码的文本形式源文件,并不具有执行能力。在接下来的步骤中需要将它变为一个可执行的代码段。

如果有同学看过 webpack 打包出来的结果,可以发现有这么一个现象,所有模块化的内容都处在一个函数的闭包中,内部所有的模块加载函数都替换成了 __webpack_require__ 这类的 webpack 内部变量。

还有一个问题,在 CommonJS 模块化规范中我们或多或少在每个文件中会写 module, module.exports require 等等这样的「字眼」,因为这里的 module 和 require 讲道理并不能称为关键字,JS 中关于模块加载方面的关键字只有 ESModule 中 import 和 export 等等相关的内容,他们是真真正正的关键字。而这里 CommonJS 里面带来的 module 和 require 则完全算是自己实现的一种 hack,在日常的 CommonJS 模块书写过程中,module 对象和 require 函数完全是 node 在包解析时注入进去的(类似上面的 __webpack_require__)

这也就给了我们极大的想象空间,我们也完全可以将上面拿到的 module 进行包裹然后注入我们传递的每一个变量。简单的例子:

// 纯文本代码 无法执行
var str = 1;
console.log(str);

将函数进行拼接,结果依旧是一个纯文本代码。但是已经可以给这个文件内部注入 require module 等变量,只需后续将它变为可执行文件并执行,就能把模块取出来。

function(require, module, exports, __dirname, __filename) {
 // 纯文本代码
 var str = 1;
 console.log(str);
}
  • 转化为可执行代码

拼接完成之后我们拿到的是还是纯字符串的代码,接下来就需要将这个字符串变成真正的代码,也就是将字符串变为可执行代码片段,这种操作在 JS 的历史上一直是危险的代名词…一直以来也有多种方法可以使用,eval、new Function(str) 等等。而在 node 环境中可以直接使用原生提供的 vm 模块,内部的沙盒环境支持我们手动注入一些变量,相对来说安全性还有所保证。

var txt = "function(require, module, exports, __dirname, __filename) {
 module.exports = 1;
}"

var vm = require('vm');
var script = new vm.Script(txt);
var func = script.runInThisContext();

上面这个示例中,func 就已经是经过 vm 从字符串变为可执行代码段的结果,我们的 txt 给定的是一个函数,所以此时我们需要调用这个函数来最后完成模块的导出。

var m = {
 exports: {}
};
func(null, m, m.exports);

这样的话,内部导出的内容就会被外面全局对象 m 所截获,将每一个模块导出的结果缓存到全局的 m 对象上面来。

而对于 require 函数来讲,注入时我们需要考虑的就是走完上面的几个步骤,require 接受一个字符串变量路径,然后依次通过路径找到文件,获取文件,拼接函数,变为可执行代码段并执行,之后仍给全局的缓存对象,这就是 「require」需要做的内容。

过程中的切面

  • 最终形态是什么

对于最终的形态,本质上我们是要提供一个 require 函数,它的目标就是在 runtime 能够从远端 url 加载 js 模块,能够加载 ts 模块甚至类似 babel 提供 preset 加载各种各样的模块。

但是我们的 require 无法注入到 node bootstrap 阶段,所以最终结果一定得是 bootsrap 文件使用 CommonJS 模块加载,通过我们自定义的 require 加载的所有文件都能实现功能。

  • 生命周期的设计

就如上面的第二部分介绍的那样,对于 require 函数我们要依次做这些事情,完全可以把每个阶段看做一个切面,任何一个阶段只关注输入和输出而不关注上个阶段是如何产出的。

经过仔细的思考,最终设置了两个核心的过程,包裹模块内容 和 编译文件结果。

包裹模块内容就是将字符串的文件结果包裹一下函数,专注于处理字符串结果,将普通文件的文本进行包裹。

编译文件结果这一步就是将代码结果编译成 node 能够直接识别的 js 而使得下一步沙盒环境进行执行,每次通过文件结果动态在内存进行编译,从而使得下一步 js 的执行。

  • 同步还是异步?

这个问题其实困扰了很久。最大的问题就是里面涉及了部分异步加载的问题,按照传统前端的做法,这里一般都是使用 callback 或者 promise(async/await) 的方式,但这样就会带来一个很大的问题。

如果是 callback 的方式,那么意味着最终我的 require 可能得这样调用:

var r = require("nedo");
var moduleA = r("./moduleA");
var moduleB = r("./moduleB");

function log(module) {
 // 所有执行过程作为 callback
 // 这里拿到 module 的结果
 console.log(module);
}

moduleA(log); // 传入 callback,moduleA 加载结束执行回调
moduleB(log); // 传入 callback,moduleB 加载结束执行回调

这样就显得很愚蠢,即使改成 AMD 那样的 callback 调用也感觉是在开历史的倒车。

如果是 promise(async/await) 这样的异步方式,那么意味着最终我的 require 可能得这样调用:

var r = require("nedo");
var moduleA = r("./moduleA");

moduleA.then(module => {
 // 这里拿到 module 结果
});

(async function() {
 var moduleB = await r("./moduleB");
 // 这里拿到 module 的结果
})();

说实话这种方式也显得很愚蠢。不过中间我想了个方法,包裹函数时多包一层,包一个 IIFE 然后自执行一个 async 的 wrapper,不过这样的话 bootstrap 文件就必须还得手动包裹在 async 的函数中,子函数的问题解决了但是上层没有解决,不够完美。

其实后来仔细的思考了一下,造成这样的问题的原因究其根本是因为 request 是 async 的,这就导致了后续的代码必须以 async 的方式出现。如果我们想要从硬盘读取一个文件,那么我们可以使用 promise 包裹的 fs.readFile,当然我们也可以使用 fs.readFileSync 。前者的方法会让后续的所有调用都变成异步,而后者的代码还是同步,虽然性能很差但是完全符合直觉。

所以就必须找到一个 sync 的 request 的形式,才能让最终调用变的完美,最终的想法结果应该如下:

var r = require("nedo");
var moduleA = r("./moduleA");
// moduleA 结果

var moduleB = r("https://baidu.com");
// moduleB 结果,同步阻塞

思考了半天不知道 sync 的 request 应该怎么写,后来只得求助万能的 npmjs,结果真的发现了一个 sync-request 的包,仔细研究了一下代码发现核心是借助了 sync-rpc 这个包,虽然这个包 github 只有 5 个 star,下载量也不大。但是感觉却是非常的厉害,能够将任何异步的代码转化为同步调用的形式,战略性 star,日后可能大有所为…

  • runtime 编译

解决了 request async 的问题之后其他问题都变的非常简单,ts 使用 babel + ts preset 在内存中完成了编译,如果想要增加任何文件的支持,只需要在 lib/compile 下加入对应的文件后缀即可,在内存中只要能够完成编译就能够最终保证代码结果。

  • top level await

在之前的过程中我们只是包了一层注入参数的函数进去,当然也可以上层包裹一层 async 函数,这样就可以在使用 nedo require 的包内部直接使用顶层 await,不需要再使用 async 进行包裹

最终结果

最后经过几个小时的不懈努力,最终能够将 hello world 跑起来了,代码还处于 pre-pre-pre-prototype 的阶段。仓库地址 nedo,希望大家多帮忙 review,提供更多建设性的意见…

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对我们的支持。

(0)

相关推荐

  • NodeJS学习笔记之Module的简介

    Node.js模块系统 Node.js有一个简单的模块加载系统. 在Node.js中,文件和模块是一一对应的(每个文件被视为单独的模块). 例如,考虑下面这个名为 foo.js 的文件: const circle = require('./circle.js'); console.log(`The area of a circle of radius 4 is ${circle.area(4)}`); 在第一行, foo.js 加载与 foo.js 同一目录的模块 circle.js . cir

  • 深入理解Node module模块

    在开发复杂Web应用的时候,通常需要把各个功能进行拆分.封装到不同的文件并在需要的时候引用该文件,即进行代码的模块化管理.几乎所有的编程语言都有自己的模块组织方式,比如Java中的包.C#中的程序集,而Node采用CommonJS模块规范. 模块规范 CommonJS旨在将运行在浏览器之外的JS进行标准化,并已经解决了大量的JS问题(如全局命名冲突).Node对CommonJS的实现中,每个模块都会被封装在一个单独的JS文件中,即一个文件就是一个模块,而文件路径就是模块名.在编写每个模块时,都有

  • Node.js学习教程之Module模块

    前言 采用了 Commonjs 规范,通过 module.exports.require 来导出和导入模块.模块加载机制中,采用了延迟加载的策略.就是说在用到的情况下,系统模块才会被加载,等加载完成后会放到 binding_cache 中. 分类(模块类型) 系统模块 核心模块(native 模块),http.buffer.fs 等,底层调用的内建模块 (C/C++): C/C++ 模块(built-in 内建模块),供 native 模块调用: 第三方模块 第三方维护的模块,比如 expres

  • nodejs中exports与module.exports的区别详细介绍

    你肯定非常熟悉nodejs模块中的exports对象,你可以用它创建你的模块.例如:(假设这是rocker.js文件) 复制代码 代码如下: exports.name = function() { console.log('My name is Lemmy Kilmister'); }; 在另一个文件中你这样引用 复制代码 代码如下: var rocker = require('./rocker.js'); rocker.name(); // 'My name is Lemmy Kilmiste

  • 如何使node也支持从url加载一个module详解

    前言 最近两天 ry 大神的 deno 火了一把.作为 node 项目的发起人,现在又基于 go 重新写了一个类似 node 的项目命名为 deno,引发了大家的强烈关注. 在 deno 项目 readme 的开始就列举出了这个项目的优势和需要解决的问题,里面最让我瞩目的就是模块原生支持 ts ,同时也能也必须从 url 加载模块,这也是与现有的 CommonJS 最大的不同. 仔细思考一下,deno 的模块化与 CommonJS 相比,更多的是一些 runtime 的能力.现有的 Common

  • vue+element树组件 实现树懒加载的过程详解

    一.页面样式 二.数据库 三.前端页面代码 <template> <el-tree :props="props" :load="loadNode" lazy show-checkbox> </el-tree> </template> <script> export default { data () { return { props: { label: 'name', children: 'zones',

  • mui上拉加载功能实例详解

    最近在做移动端的项目,用到了mui的上拉加载,整理如下: 1.需要引入的css.js <link rel="stylesheet" href="common/mui/css/mui.min.css" rel="external nofollow" > <script src="js/jquery-3.2.0.min.js"></script> <script src="com

  • JDBC数据库连接过程及驱动加载与设计模式详解

    首先要导入JDBC的jar包: 接下来,代码: Class.forName(xxx.xx.xx)返回的是一个类 Class.forName(xxx.xx.xx)的作用是要求JVM查找并加载指定的类,也就是说JVM会执行该类的静态代码段. JDBC连接数据库 • 创建一个以JDBC连接数据库的程序,包含7个步骤: 1.加载JDBC驱动程序: 在连接数据库之前,首先要加载想要连接的数据库的驱动到JVM(Java虚拟机), 这通过java.lang.Class类的静态方法forName(String

  • 前端JS图片懒加载原理方案详解

    目录 背景 原理 方案 方案一:img的loading属性设为“lazy” 使用方法 优点 兼容性 缺点 方案二:通过offsetTop来计算是否在可视区域内 优化 优点 缺点 方案三:通过getBoundingClientRect来计算是否在可视区域内 方案四:使用IntersectionObserver来判断是否在可视区域内 兼容性 优点 缺点 问题 布局抖动 响应式图片 SEO不友好 插件 背景 懒加载经常出现在前端面试中,是前端性能优化的常用技巧.懒加载也叫延迟加载,把非关键资源先不加载

  • JS图片懒加载库VueLazyLoad详解

    目录 背景 说明 实现原理 1. placeholder 的实现很细致和灵活 2. 添加图片缓存 3. 事件监听使用节流 4. 监听事件不止滚动事件 5. 事件列队的方式来处理懒加载 6. 支持 data-srcset 7. 自定义控制可视区的判定范围 待完善 1. 没有解决布局抖动 2. 跳过已经加载图片的判断方式 3. 局部懒加载 4. 性能不是很好 5. observer 模式配置简单 6. SEO 不友好 总结 背景 上篇<图片懒加载原理方案详解>中详细解析了图片懒加载的原理和方案.主

  • Swift 开发之懒加载的实例详解

    Swift 开发之懒加载的实例详解 /// A display link that keeps calling the `updateFrame` method on every screen refresh. private lazy var displayLink: CADisplayLink = { self.isDisplayLinkInitialized = true let displayLink = CADisplayLink(target: TargetProxy(target:

  • web.xml中如何设置配置文件的加载路径实例详解

    web.xml中如何设置配置文件的加载路径实例详解 web应用程序通过Tomcat等容器启动时,会首先加载web.xml文件,通常我们工程中的各种配置文件,如日志.数据库.spring的文件等都在此时被加载,下面是两种常用的配置文件加载路径,即配置文件可以放到 SRC目录下或者可以放到WEB-INF根目录下 第一种在web.xml中这样配置: <context-param> <param-name >contextConfigLocation </param-name >

  • webpack学习笔记之代码分割和按需加载的实例详解

    本文介绍了webpack学习笔记之代码分割和按需加载的实例详解,分享给大家,也给自己留个笔记 为什么需要代码分割和按需加载 代码分割就是我们根据实际业务需求将代码进行分割,然后在合适的时候在将其加载进入文档中. 举个简单的例子: 1.一个HTML中存在一个按钮 2.点击按钮出现一个包着图片的div 3.点击关闭按钮图片消失 Demo目录: 一.当未点击按钮时浏览器只加载了对入口文件打包后的js 二.点击按钮会对组件进行异步加载 这个clichunk就是我们打包好的click组件,包括相应的JS逻

  • SpringBoot配置文件的加载位置实例详解

    springboot采纳了建立生产就绪spring应用程序的观点. Spring Boot优先于配置的惯例,旨在让您尽快启动和运行.在一般情况下,我们不需要做太多的配置就能够让spring boot正常运行.在一些特殊的情况下,我们需要做修改一些配置,或者需要有自己的配置属性. SpringBoot启动会扫描以下位置的application.yml或者 application.properties文件作为SpringBoot的默认配置文件. -file:./config/    -file:./

随机推荐