从源码角度理解CommonJs与ES Module

1,116 阅读5分钟

一、 为什么需要模块化

在最早做前端开发的时候,我们经常会遇到一个问题,在引入脚本的时候,变量会对全局造成污染,如下:

a.js

function log() {
    console.log('a')
    return 'a'
}

b.js

function log() {
    console.log('b')
    return 'b'
}

index.html

<meta charset="utf-8" />
<script src="./a.js"></script>
<script src="./b.js"></script>
<script>
    console.log('log: ', log()) // b
</script>

上面的例如,我们发现无论如何都无法调用到a文件的log方法 原因是原始的这种写法会导致log污染了全局,所以特别是大型的项目,引用了较多的第三方库的情况下,问题是比较难排查出来的。所以就一种模块化的解决方案来解决这个问题。

目前市面上的解决方案有AMD,CMD,UMD,CommonJs,ES Module,这里面我们就只介绍其中的CommonJs和ES Module

AMD,CMD,UMD其实是最早的模块化解决方案,代表的类库有Require.js和Sea.js,这里就不多做介绍,因为目前前端的开发以打包工具构建编译后代码为主,主流的打包工具都是支持Commonjs和ES Module所以其他的解决方案不做介绍

二、CommonJs

在node.js出现之后,CommonJs被率先的引入进来解决污染全局环境的问题。

CommonJs是通过require方法来导入模块,通过module.exports和exports来实现导出的

例如:

a.js

module.exports = {
    module: 'a'
}

b.js

module.exports = {
    module: 'b'
}

index.js

const a = require('./a.js')
const b = require('./b.js')
console.log('a: ', a)
console.log('b: ', b)

运行的结果是分别打印a和b

我们来解析一下源码:

(() => {
    var o = {
            847: o => {
                o.exports = { module: "a" }
            },
            996: o => {
                o.exports = { module: "b" }
            }
     },
     r = {};

    function e(t) {
        var s = r[t];
        if (void 0 !== s)
            return s.exports;
        var n = r[t] = {
            exports: {}
        };
        return o[t](n, n.exports, e), n.exports
    }
    
    (() => {
        const o = e(847),
            r = e(996);
        console.log("a: ", o), console.log("b: ", r)
    })()
})();
//# sourceMappingURL=main.js.map

我们可以看到实现的原理是,把所有的代码包装在一个自执行的函数里面,然后通过将导入的模块转化成为一个对象的形式来实现模块化方案

由上面的源码我们知道CommonJs最大的特点是:

  1. 模块化的方案是动态的,也就是调用的时候引入模块
  2. CommonJs会缓存之前被调用过的模块

这里补充一下前端关键字void,关键字void修饰之后不返回内容,如上所述,void 0也就是undefined

下面我们将a文件改为:

const getMes = require('./b')
console.log('我是 a 文件')
exports.say = function() {
    const message = getMes()
    console.log(message)
}

b文件改为:

const say = require('./a')
const object = {
    author: '我不是外星人'
}
console.log('我是 b 文件')
console.log('打印 a 模块', say)

setTimeout(() => {
    console.log('异步打印 a 模块', say)
}, 0)

module.exports = function() {
    return object
}

编译后的源码如下:

(() => {
    var o = {
            847: (o, n, e) => {
                const t = e(996);
                console.log("我是 a 文件"),
                    n.say = function() {
                        const o = t();
                        console.log(o)
                    }
            },
            996: (o, n, e) => {
                const t = e(847),
                    s = {
                        author: "我不是外星人"
                    };
                console.log("我是 b 文件"),
                    console.log("打印 a 模块", t),
                    setTimeout((() => {
                        console.log("异步打印 a 模块", t)
                    }), 0),
                    o.exports = function() { return s }
            }
        },
        n = {};

    function e(t) {
        var s = n[t];
        if (void 0 !== s)
            return s.exports;
        var r = n[t] = {
            exports: {}
        };
        return o[t](r, r.exports, e), r.exports
    }
    e(847), e(996), console.log("node 入口文件")
})();
//# sourceMappingURL=main.js.map![]()

运行之后的结果如下:

2021-09-20_152705.png

  • 首先我们会加载a文件(对应的编号是847)

      var s = n[t];   
            if (void 0 !== s)
                return s.exports;
            var r = n[t] = {
                exports: {}
            };
    
  • 通过上述的加载代码我们可以知道在初次加载A模块的时候,s=undefined,这个时候就会对A模块分配一个含有exports的空对象,接着把空对象的exports属性导出,因为A模块第一行代码调用了b模块,重复加载a模块的步骤的步骤,也就是b模块的say常量是一个空对象(对应的打印结果是:打印a模块{})

  • 接着按照顺序进行执行,输出我是b文件,按照顺序接着输出打印a模块{}

  • 接着是执行setTimeout宏任务,所以setTimeout会在最后输出

  • 最后在b模块中,我们会对其进行一个导出操作,并通过e方法传递的缓存对象进行更新exports操作

  • 接着按照顺序分别打印我是a文件和node入口文件

  • 最后因为setTimeout是宏任务最后执行,且a模块方法已经被执行exports导出say方法,所以打印:异步打印a模块:{say:Fn}

note:为什么 exports={} 直接赋值一个对象就不可以呢

我们将a文件改为:

const getMes = require('./b')
console.log('我是 a 文件')
exports = {
    say: function() {
        const message = getMes()
        console.log('a 文件 say', message)
    }
}

编译之后如下:

2021-09-20_174515.png

从这里我们可以看出,本质上的原因是没有生成导出的源码,这个可能与其他的技术文章有点出入,也就是我想说明的一点,不看源码来做分析多多少少都有点误区。

三、ES Module

Es Module与CommonJs 的本质上的区别是CommonJs是动态加载的,Es Module是静态编译的,这个也就限制了ES Module可以在webpack中进行tree shaking来减少代码,动态执行的CommonJs是不能进行tree shaking。

将a文件改为:

export default {
    a: true
}

b文件改为:

export default {
    b: true
}

index文件改为:

import a from './a.js'
import b from './b.js'

console.log('a: ', a)
console.log('b: ', b)

编译后的源码如下:

(() => {
    "use strict";
    console.log("a: ", { a: !0 }), console.log("b: ", { b: !0 })
})();
//# sourceMappingURL=main.js.map

下面我们来探究一下在ES Module中是怎么处理模块相互依赖的

a文件改为:

import b from './b'
console.log('a模块加载')
export default function say() {
    console.log('hello , world')
}

b文件改为:

console.log('b模块加载')
export default function sayhello() {
    console.log('hello,world')
}

index文件为:

console.log('main.js开始执行')
import say from './a'
import say1 from './b'
console.log('main.js执行完毕')

编译后如下:

(() => {
    "use strict";
    console.log("b模块加载"), console.log("a模块加载"), console.log("main.js开始执行"), console.log("main.js执行完毕")
})();
//# sourceMappingURL=main.js.map

我们可以得出结论:

  1. import导入的模块会优先执行
  2. 没有使用到的代码不会被编译到最终执行的代码里面去
  3. ES 模块会在预加载的时候分析模块依赖,在执行模块的时候采用深度遍历的形式执行

四、小结

通过从源码的角度去解读CommonJs和ES Module的差别,能够让我们更清晰的理解他们之间的同异点,笔者认为,单纯的阅读网上的长篇大论的文章去了解他们的区别,不利于记忆和应用。所以阅读源码是一种简单而且方便的学习的方法

五、参考资料

「万字进阶」深入浅出 Commonjs 和 Es Module

前端模块化:CommonJS,AMD,CMD,ES6

JS魔法堂:从void 0 === undefined说起