CommonJS 模块化

510 阅读5分钟

commonjs

1.模块如何切分

不管是 commonjs 还是 ESM 都不约而同的选择了以'文件'为基本单位来划分模块. 文件是组织代码结构的基本单位,在模块加载器的自身处理来说,也是一个相对来说更容易的颗粒度

2.如何使用
//a.js
exports.action = function(){
    console.log('Action!');
}
//b.js
const a = require('./a.js')
a.action();

其实模块机制也是在设计API,在设计层面,要注重考虑简洁,易用,commonjs统一使用require关键字来引入模块,以暴露模块的方式挂载到exports 对象引用上(当然也可以重写这个对象引用),这样的设计是的用户学习起来没有过的心智负担,如果一个模块加载器暴露出的基本API有十几个,并不好用

3.同步?异步?

这也是设计之初就需要考虑的问题,对模块的加载解析过程如果是异步,那必然和同步的处理方式有极大的区别,commonjs是之所以被Node采用,也是因为其设计之初的考虑就不是浏览器层面,更偏向Server,Node本身在IO上就有足够的底气和实力,同步的方式更契合需求,但是目前推崇的ESM是官方的,未来的Node必妥协于大流

4.实现
4-1 文件颗粒度

Node本身就是底层可操作文件,但是浏览器层面的话是不支持也不认识commonjs的模块加载器,更不用说拿文件了,所以得依赖第三方工具,比如webpack,当然,本职也是通过Node暴露的能力去拿文件内容的. demo是从浏览器层面模拟, 所以用简单的方式,直接写文件字符串,

4-2 关键接口设计

第一,引入的方法require 第二,导出的方式exports(module.exports)

4-3 如何隔离,封闭作用域

闭包,IIFE

4-4 如何绑定 require 的能力

IIFE有的能力,可以注入依赖,这个能力基本就是模块化能力的基石了

(function (global, exports){
    console.log(exports.test)
})(window,{test:'测试'})
4-5 code
//loader.js
/**
    实现一个简单的 commonjs 模块加载器,偏浏览器实现
    2 个部分:
    模块加载器:解析文件地址,有一个寻找的规则,目的肯定就是找到文件
    模块解析器:执行文件内容的,Node 里面是使用了 v8 执行的
*/
class Module {
    constructor(moduleName,source){
        //暴露数据
        this.exportss = {};
        //保存一下模块的信息
        this.moduleName = moduleName;
        //缓存
        this.$cacheModule = new Map();
        //源代码
        this.$source = source;
    }
    
    /**
    require
    useage: require('./a.js')
    @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 {sting} code 代码字符串
    */
    $wrap= (code)=>{
        const wapper = [
            'return (function (module,exports,require){',{'\n});'
        ]
        return wapper(0)+code+wapper[1];
    }
    
    /**
        简单实现一个能在浏览器跑的解释器 vm.runInThisContext
        核心的点是要创建一个隔离的沙箱环境,来执行我们的代码字符串
        
        @param {string} 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'){
                throw Error ('sandbox parameter must be an object');
            }
            // 代理
            const proxiedObject = new Proxy (sandbox,{
                //专门处理 in 操作符
                has(taryget,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);
        }
    }
    
    /**
        执行文件内容,入参是文件源代码字符串
        IIFE: (function(){})(xxx,yyy);
        function(proxiedSandbox){
            with(proxiedSanbox){
                return (function (module, exports, require)){
                    //文件内容字符串
                }
            }
        }
    */
    compile = (module, source) => {
        //return (function (module, exports, require){//xxx})
        cons t iifeString = this.$wrap(source);
        //创建沙箱的执行环境
        const compiler = this.$runInThisContext(iifeString)({});
        
        compiler.call (module,module,module.exports, this.require);
        
        return module.exports;
    }
}

//demo 验证

const 吗= new Module();

//a.js
const m = new Module();

// a.js
const sourceCodeFromAModule = `
  const b = require('b.js', 'const a = require("a.js"); console.log("a module: ", a); exports.action = function() { console.log("execute action from B module successfully 🎉") }');

  b.action();

  exports.action = function() {
    console.log("execute action from A module!");
  }
`
m.require('a.js', sourceCodeFromAModule);

//require->[1.模块加载(获取文件字符串) , 2.解释执行字符串, 3.exports, 4.缓存]
//IIFE 的方式把 require 塞进文件模块所在的域里面

未来--ESM

commonjs对比看

  1. 使用方式不同
  2. 对基本类型,commonjs是值拷贝,ESM则是引用
  3. 动态运行时,静态编译(import语句都是静态执行,export则是动态绑定)
  4. ESM提升特性
  5. ESM支持Top-level await,this-undefined
  6. ESM天然支持dynamic import, commonjs本身则是基于运行时
  7. ESM现在被大多数现代浏览器原生支持,通过type="module"进行标识
  8. 同步,异步

ESM将流程拆分为了 3 个步骤进行,首先是[构建阶段]解析模块,创建底层数据结构Module Record(可以看成是AST结构节点),然后[实例化阶段]解析import,export存入内存(这个时候代码并没执行,[执行阶段]最后才是执行

然后将执行得到的结果放进对应的内存中,这样的过程拆分为 3 个主要步骤,意味着ESM拥有了commonjs不具备的异步能力

为什么要拆成这么几个步骤

前端常常面临的场景是多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 Recird的数据结构)

JS引擎深度有限后序遍历模块树,完成实例化过程,采用动态绑定的方式联系,export import值,这是和commonjs非常不同的地方

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

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