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对比看
- 使用方式不同
- 对基本类型,
commonjs是值拷贝,ESM则是引用 - 动态运行时,静态编译(
import语句都是静态执行,export则是动态绑定) ESM提升特性ESM支持Top-level await,this-undefinedESM天然支持dynamic import,commonjs本身则是基于运行时ESM现在被大多数现代浏览器原生支持,通过type="module"进行标识- 同步,异步
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引擎深度有限后序遍历模块树,完成实例化过程,采用动态绑定的方式联系,exportimport值,这是和commonjs非常不同的地方
三阶段设计,天生支持循环引用
没有完成三阶段的时候,会标记为
Fetching状态, 循环引用的时候,看到是Fetching状态就先不管这里了,继续执行,等完成执行阶段,就会把对应的import和export链接到一个内存地址 这样就访问到了