CMJ 实现简单的加载器(偏浏览器端) CMJ与ESM差异

313 阅读4分钟

代码实现

指导准则: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 awaitthis-undefined

6、ESM 天然支持 dynamic importCMJ 本身则是基于运行时

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 引擎会深度优先后序遍历模块树,完成实例化过程,采用动态绑定的方式来联系 export import 值,这是和 CMJ 非常不同的地方

三阶段设计,天生支持循环引用

没有完成三阶段的时候,会标记为 Fetching 状态,循环引用的时候,看到是 Fetching 状态就先不管这里了,继续执行,等完成执行阶段,就会把对应的importexport链接到一个内存地址

这样就可以访问到了