代码实现
指导准则:commonjs规范
部分组成: 1.模块加载器:解析文件地址,有一个寻找的规则,目的肯定就是找到文件; 2.模块解析器:执行文件内容的,Node里面是使用了V8执行的。
class Module {
constructor(moduleName, source) {
// 暴露数据
this.exports = {};
// 保存一下模块的信息
this.moduleName = moduleName;
// 缓存
this.$cacheModule = new Map();
// 源代码
this.$source = source;
}
/**
* require
* 1. 模块加载(获取文件字符串)
* 2. 解释执行字符串
* 3. exports
* 4. 缓存
*
* @param {string} moduleName 模块的名称,其实就是路径信息
* @param {string} source 文件的源代码,因为省略了加载器部分的实现,所以这里直接传入文件源代码
*
* @return {object} require 返回的结果就是 exports 的引用
*/
require = (moduleName, source) => {
// 每次require都执行文件内容的话,开销太大,所以需要加缓存
if(this.$cacheModule.has(moduleName)) {
// 注意: 返回的是exports
return this.$cacheModule.get(moduleName).exports;
}
// 创建模块
const module = new Module(moduleName,source);
// 执行文件, 执行文件内容一定要在缓存之前
const exports = this.compile(module,source);
// 放进缓存
this.$cacheModule.set(moduleName, module);
// 返回exports
return exports;
}
/**
* 拼一个闭包出来,IIFE
* @param {string} code 代码字符串
*/
$wrap = (code) => {
const wrapper = [
'return (function (module, exports, require) {',
'\n});'
];
return wrapper[0] + code + wrapper[1];
}
/**
* 简单实现一个能在浏览器跑的解析器 vm.runInThisContext
*
* 目标:核心的点是要创建一个隔离的沙箱环境,来执行我们的代码字符串
* 隔离:不能访问闭包的变量 1. 不能访问全局的变量, 3. 只能访问我们传入的变量, 2
*
* eval:可以访问全局/闭包,但是需要解释执行; ES5之后,如果是间接使用eval,不可以访问闭包。
* -> 如:(0,eval)('var a = b + 1'); ❌
*
* new Function:不可以访问闭包,可以访问全局,只编译一次 ✅ 1
*
* with:with包裹的对象,会被防盗原型链的顶部,而且底层是通过 in 操作符判断的
* -> 如:with(Math) { function() { PI() }}
** 如果通过with 塞入我们传入的数据 ✅ 2
** 不管是啥属性,都从我们塞入的对象取值,取不到就返回 undefined, 这样就永远不会访问全局的域 ✅ 3
*
* unscopables:这个对象是不能够被 with 处理的
* @param {*} code 代码字符串
*/
$runInThisContext = (code, whiteList=['console']) => {
// 使用with保证可以通过我们传入的sandbox对象取数据
// new Function 不能访问闭包
const func = new Function('sandbox', `with(sandbox) {${code}}`);
return function(sandbox) { // 塞到文件源代码中的变量
if(!sandbox || typeof sandbox !== 'object') {
return Error('sandbox parameter must be an object');
}
// 代理
const proxiedObject = new Proxy(sandbox, {
// 专门处理 in 操作符的
has(target, key) {
if(!whiteList.includes(key)) {
return true;
}
},
get(target, key, receiver) {
if(key === Symbol.unscopables) {
return void 0;
}
return Reflect.get(target, key, receiver);
}
});
return func(proxiedObject);
}
}
/**
* 执行文件内容, 入参数是文件源代码字符串
*
* function(proxiedSandbox) {
* with(proxiedSandbox){
* return (function (module, exports, require) {
* // 文件内容字符串
* })
* }
* }
* @param {*} module
* @param {string} source
*/
compile = (module, source) => {
//return (function (module, exports, require) { xxxx }); ⚠️
// 创建沙箱的执行环境
const compiler = this.$runInThisContext(this.$wrap(source))({});
compiler.call(module, module, module.exports, this.require);
return module.exports;
}
}
验证demo
const m = new Module();
const sourceCodeFromAModule = `
const b = require('b.js', 'exports.action = function() { console.log("execute action from B model successfully")}')
b.action();
`
m.require('a.js',sourceCodeFromAModule); //输出 execute action from B model successfully
CMJ与ESM差异
与 CMJ 对比看看:
1、使用方式不同(略)
2、对基本类型,CMJ 是值拷贝,ESM 则是引用
3、动态运行时,静态编译 (import 语句都是静态执行,export 则是动态绑定的)
4、ESM 提升特性
5、ESM 支持 Top-level await,this-undefined
6、ESM 天然支持 dynamic import,CMJ 本身则是基于运行时
7、ESM 现在被大多数现代浏览器原生支持,通过 type="module" 进行标识
8、同步,异步
ESM将流程拆分为了三个步骤进行,首先是【构建阶段】解析模块,创建底层数据结构Module Record(可以看成是AST结构节点),然后【实例化阶段】解析import,export存入内存(这个时候代码并没执行),【执行阶段】最后才是执行
然后将执行得到的结果放进对应的内存中,这样的过程拆分为了三个主要步骤,意味着
ESM拥有了CMJ不具备的异步的能力!
为啥要拆成这么几个步骤?
前端常常面临的场景是多
chunk渲染,通过入口文件<script src='index.js' type='module'/>进来,可能需要加载很多js 模块,这个时候如果ESM机制本身是多过程且可分离的,
就可以最大限度的压榨浏览器并行下载能力,快速加载依赖(当然底层支持按需更
yyds),这是ES modules规范将算法分为多个阶段的原因之一
多阶段算法也有弊端,比如不能
import { foo } from "${fooPath}/a.js"这样使用,因为构建依赖图是在第一阶段,这个时候路径信息是没有的
为了解决这个问题提出了
dynamic import,底层其实单独给这种情况创建了Module Record,然后通过module map的方式管理起来(module map就是一种管理Module Record的数据结构)
JS引擎会深度优先后序遍历模块树,完成实例化过程,采用动态绑定的方式来联系exportimport值,这是和CMJ非常不同的地方
三阶段设计,天生支持循环引用
没有完成三阶段的时候,会标记为
Fetching状态,循环引用的时候,看到是Fetching状态就先不管这里了,继续执行,等完成执行阶段,就会把对应的import和export链接到一个内存地址
这样就可以访问到了