闲话JS模块体系发展过程

138 阅读13分钟

前言

闲话 js 模块化发展历程,了解不同时期模块化方案解决了什么问题,有什么优劣势,以及简单了解模块化方案的使用方式和区别。

js模块体系发展过程

最早时期 ajax还没提出,js只是一种"玩具语言",js 只是用来写简单的交互、点击事件、表单校验以及实现简单的动画等,整个网站也就代码量不大,根本没有命名冲突,变量污染,耦合等问题,也没有区分前端后端工程师,都是由服务端工程师顺手写的。

  • 1992 年 Nombase公司,开发出第一门客户端脚本语言,专门用于表单的校验。命名:C--,后来更名为:ScriptEase。
  • 1995 年 javascript诞生,由Netscape(网景)公司的Brendan Eich 设计出来的 命名为:LiveScript,后来,请来SUN公司的专家,修改LiveScript,命名JavaScript。
  • 1996 年,微软抄袭JavaScript开发出 Jscript语言。
  • 1996 年 javascript 被交给 ECMA(欧洲计算机制造商协会) 标准化
  • 1998 年 ajax 诞生
  • 1998 年,网景公司被IE击败破产,同时 ajax被IE内嵌。
  • ...

无模块化时期

最早时间没有模块化概念,我们引入js文件方式也比较单一。代码简单的堆在一起,按顺序从上往下执行就可以了。

以前代码从上到下依次执行就可以了。

if (xxx) {
    // ---
} else { 
    // ---
}
for(var i = 0; i < 10; i++) {
    // ---
}
element.onclick = function() {
    // ---
}

image.png image.png 这样的引入方式会带来什么问题?

  • 全局变量污染:各个文件的变量都是挂载到window对象上,污染全局变量。
  • 变量重名:不同文件中的变量如果重名,后面的会覆盖前面的,造成程序运行错误。
  • 文件依赖顺序:多个文件直接存在的依赖关系,需要保证一定加载顺序问题严重。 image.png

命名空间模式

2002年,有人提出了命名空间模式的思路,用于解决全局变量被污染问题。

  • 无法避免的全局变量污染。
  • 本质上就是全局对象,谁都可以来访问并操作,不安全。
  • 模块很臃肿,拆除模块麻烦,使用比较乱,不灵活。
  • 多个文件直接存在依赖关系,需要保证加载顺序。 image.png image.png

闭包模块化模式(IIFE结合闭包)

2003年,有人提出利用IIFE结合闭包特性。以解决私有变量的问题,这种模式被称为闭包模块化模式。 IIFE : 立即执行的函数表达式。

image.png 利用闭包特性,创建了一个私有的作用域,在这个作用域内他们享有自己的变量和方法,不会影响到他的外部作用域,这样也就形成了一个不受影响的私有空间。

  • 私有属性比较乱,代码不灵活。
  • 命名会重复,文件依赖顺序需要严格保证加载顺序。

image.png

CommonJS规范

NodeJs采用的模块化方案。简称 CJS。2009年1月份诞生。主要用于服务端nodejs环境。从 Node.js v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持。Node.js 要求 ES6 模块采用.mjs后缀文件名。也就是说,只要脚本文件里面使用import或者export命令,那么就必须采用.mjs后缀名。Node.js 遇到.mjs文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定"use strict"。如果不希望将后缀名改成.mjs,可以在项目的package.json文件中,指定type字段为module

如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成.cjs。如果没有type字段,或者type字段为commonjs,则.js脚本会被解释成 CommonJS 模块。

总结为一句话:.mjs文件总是以 ES6 模块加载,.cjs文件总是以 CommonJS 模块加载,.js文件的加载取决于package.json里面type字段的设置。

注意,ES6 模块与 CommonJS 模块尽量不要混用。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import

  • 可以直接使用 node xxx.js去执行commonJS规范的脚本。
  • 浏览器并不能直接直接编译导入和导出模块的语法,所以要编译,方能使用。 工具的官网:browserify.org/ ,我们使用安装命令: npm install browserify -g 安装好之后运行命令 : browserify bbb.js -o bundle.js,然后你就会发现你的文件下自动生成了一个bundle.js的文件,然后在html中引入该文件

使用方式:

  1. 通过require引入模块;
  2. 通过 module.exports,exports.xxx导出模块; 语法实例:
// 导入模块(使用模块)
var fs = require('fs') // node内置模块
var path = require('path') // node内置模块
var util = require('../util/index') // 自定义或者第三方模块
// 解构自定义模块中的 exports.add、module.expors.add、module.exports = {add: Function}定义的模块;
var { add } = require('../main/index') 


// 导出模块(定义模块)
// 注意:定义模块的代码是按顺序执行的, 后面的覆盖前面的;
// 语法:
exports.num = 2
exports.add = function(){}
module.export.add = function() {}
module.export = ['a', 'b', 'c']
module.export = {
    increment: Function,
    active: 0,
}
// 如果module.export = {} || [] 这样的语法放在文件的最后面,
// 会覆盖上面所有使用 exports.xxx 和 module.export.xxx 定义的模块

模块定义注意:

  • 在模块定义的文件中,可以定义多个exports.xxxmodule.exports.xxx;
  • 如果module.export = {} || [] 放在文件的最后面,会覆盖上面所有使用 exports.xxx 和 module.export.xxx 定义的模块
  • 使用 exports.xxx ,module.export.xxx,同个模块中定义同名变量,后面的会覆盖前面的;
  • 模块中的顶层this指向:指向当前模块, 输出当前模块中,使用this位置之前所有使用模块标识符定义的变量集合; image.png image.png

模块加载过程:

  1. 优先从缓存加载:
  2. 路径分析和文件定位
  3. 编译执行:

特点:

  1. 模块可以多次加载,CommonJS模块是运行时加载; require()是同步加载模块;
  2. CommonJS模块的顶层this指向当前模块,使用this位置之前所有使用模块标识符定义的变量集合;
  3. CommonJS模块输出的是值的复制。模块导出原始类型数据,会被缓存,模块内部或者使用模块中修改的方法都无法改变导出的值; 若在外面重新赋值,则与原来的模块关系脱钩模块导出引用类型时,外部和内部的修改,都可以得到被变动后的值。
    image.png image.png

AMD规范

Asynchronous Module Definition; 异步加载所需要的模块,然后在回调函数中执行逻辑。 require.js采用的模块方案; require.js是JS模块载入框架,是AMD规范的实现者;对于依赖模块提前执行,推崇依赖倒置。api默认是一个当多个用,比如:require有全局的和局部的。

require.js:requirejs.org官网

reuire.js v2.3.6 下载地址:reuire.min.js

使用方式:

    <!-- index.html --> 
    <!DOCTYPE html>
    <html>
        <head>
            <script type="text/javascript" src="require.js"></script>
            <script type="text/javascript">
                // 加载模块,第一个参数:模块路径,第二个参数:处理加载完毕后的逻辑
                require(['js/a'], function() {
                    console.log('a模块加载成功')
                    return {
                        name: 'a'
                    }
                })
            </script>
        </head>
        <body>
          <span>body</span>
        </body>
    </html>
    // a.js 定义模块
    define(function() {
        function fun1() {
            console.log('a.js')
        }
    fun1()
})

require.js特点:

  • 防止js加载阻塞页面渲染。
  • 使用程序调用的方式加载js,防止一次性加载很多js的场景;

CMD规范

CMD 延迟执行,异步加载,推崇依赖就近,职责单一,可以使得构建时复杂度降低。代表:sea.js

github地址; Sea.js官网

  • 提高代码可维护性,能模块化编程;
  • 动态加载模块,

Sea.js Examples:

image.png 代码:

    <!-- index.js -->
    <div id="test">test内容</div>
    <script src="./lib/sea.js"></script>
    <script>
        seajs.config({
            // 设置路径,方便跨目录调用
            paths: {
            'arale': 'https://a.alipayobjects.com/arale',
            'jquery': 'https://a.alipayobjects.com/jquery'
            },

            // 设置别名,方便调用
            alias: {
            'class': 'arale/class/1.0.0/class',
            'jquery': 'jquery/jquery/1.10.1/jquery'
            }

        });

        seajs.use('./js/a', function(a) {
            a.doSomething()
        })
    </script>
    // a.js
    define(function(require,exports,module) {
        // 加载 jq
        require.async('jquery', function($) {
            // test内容
            console.log($('#test').text()) 
        })
        // 异步加载其他模块
        require.async('./b', function(b) {
            // {name: 'b module', doSomething: ƒ}
            console.log(b) 
        })
        // 异步加载多个模块 在加载完成时,执行回调
        require.async(['./c', './d'], function(c, d) {
            // {name: 'c module', doSomething: ƒ} 
            // {name: 'd module', doSomething: ƒ}
            console.log(c, d) 
        })
        // 对外暴露模块
        exports.doSomething = function() {
            console.log('a.js doSomething')
        }
    })
    // b.js
    define(function(require,exports,module) {
        // 与exports类似,对外提供接口
        module.exports = {
            name: 'b module',
            doSomething: function() {
                console.log('b module doSomething')
            }
        }
    })
    // c.js
    define(function(require,exports) {
        // c模块对外暴露接口
        exports.name = 'c module',
        exports.doSomething = function() {}
    })
    // d.js
    define(function(require,exports,module) {
    module.exports = {
        name: 'd module',
        doSomething: function() {
            console.log(this.name)
        }
    }
})

UMD特殊模块

又称通用模块;兼容AMD,CommonJs 模块化语法;

ES Module

简称 ESM; module是编译时加载,可以静态分析,拓宽了js的语法。比如引入宏和类型检验。

  • 不再需要 umd 模块格式了。将来服务器和浏览器都会支持ES6模块格式。
  • 将来浏览器的新API就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性。
  • 不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供。
  • ES6 模块不会缓存运行结果,而是动态地去被加载的模块取值,并且变量总是绑定其所在的模块。
  • ES6输入的模块变量,只是一个"符号连接",所以这个变量只是只读的,对它进行重新赋值会报错。

es6 module语法

es module 自动采用严格模式,不管文件头部有没有"use strict";模块中的顶层this 指向 undefined,即不应该在顶层代码使用 this。

语法: 导入模块:使用 import; 定义模块:export, export default;

如果想写一个html+js测试或学习es模块的话:要在http环境下

  • 先下载 node server:
        npm i -g serve  || npm i -g http-server
        serve || http-server
    
  • serve 或 http-server 然后就可以用es模块方案写 html + js,在浏览器运行。
  • 在引用 js 文件时,路径必须带上文件后缀名 .js

commonJs模块 和 ES模块差异

commonJs 和 es模块 的差异?

  • CommonJS模块输出的是一个值的拷贝,ES6模块输出的是值得引用。
  • CommonJS模块是运行时加载,ES6模块是编译时输出接口。
  • CommonJS模块的require()是同步加载模块,ES6模块的import命令是异步加载,有一个独立的模块依赖的解析阶段。

经典问题:

为什么模块循环依赖不会死循环? CommonJS 和 ES Module的处理有什么不同?

CommonJS如何处理循环引入

CommonJS的引入特点是值的拷贝,简单来说就是把导出值复制一份,放到一块新的内存中。

CommonJS循环引入

入口文件(index.js) 引入 a模块a模块引入b模块b模块却又引用a模块

image.png image.png a,b模块间的相互引用,本应该是个死循环,但是实际上没有,因为 CommonJS做了特殊处理 -- 模块缓存

执行过程:

  1. 【入口文件index.js】开始执行,把index.js加入缓存。
  2. var a = require('/a')执行,将a模块加入缓存,进入a模块,
  3. 【a模块】exports.a = '原始值-a模块内变量' 执行,a模块的缓存为变量a初始化,为原始值。
  4. 执行 var b = require('/b'),将b模块加入缓存,进入b模块,
  5. 【b模块】exports.b = '原始值-b模块内变量',b模块的缓存为变量b初始化,为原始值。
  6. var a = require('/a'),尝试导入a模块,发现已有a模块的缓存,所以不会进入执行,而是直接取a模块的缓存。所以打印 {a: '原始值-a模块内变量'}
  7. exports.b = '修改值-b模块内变量' 执行,将b模块的缓存中变量替换为修改值。
  8. 【a模块】console.log('a模块引用b模块:',b),执行,取缓存中的值,打印 { b: '修改值-b模块内变量' }
  9. exports.a = '修改值-a模块内变量'执行,将a模块缓存中的变量a替换为修改值。
  10. 【入口模块】console.log('入口模块引用a模块': 'a')执行,取值缓存中的值,打印 {a: '修改值-a模块内变量'}

CommonJS通过缓存来解决:每一个模块都加入缓存再执行,每次遇到require都先检查缓存,这样就不会出现死循环;借助缓存,输出的值也很简单能找到。

多次引入

同样由于缓存,一个模块不会被多次执行,发现有缓存,则直接读取,而不会再去执行一次。 image.png

路径解析规则

导入内置模块,自定义本地模块,第三方模块,是怎么正确找到包的位置?

加载第三方包:
打个断点进入 -- module --> paths属性,

image.png

  • 核心模块,node将其已经编译成二进制代码,直接写标识符 fs,path,http就可以。
  • 自定义本地模块,需要以 "../../" 路径开头,require会将这种相对路径转换为真实路径,找到模块。
  • 第三方模块,也就是使用npm下载的包,就会用到这个paths,会一次查找当前路径下的node_modules,如果没有,则在父级目录找,一直找到根目录为止。

ES Module

ES Module循环引入

入口文件(index.js) 引入a模块,a模块引入b模块,b模块引入a模块

image.png

b模块中引入a模块时,得到的值是 uninitialized,

在代码执行前,首先要进行预处理,这一步会根据import和export来构建模块地图(Module Map),它类似于一棵树,树中的每一个"节点",就是一个模块记录,这个记录上会标注导出变量的内存地址,将导入的变量和导出的变量连接,即他们指向的同一个内存地址。不过此时这些内存地址都是空的。也就是看到的 uninitialized。

代码过程:

  1. 【入口模块】首先进入入口模块,在模块地图中把入口模块记录标记为"获取中"(Fetching),表示已经进入,但还没执行完毕,
  2. import * as a from './a.mjs'执行,进入a模块,此时模块地图中的a模块的记录标记为"获取中",
  3. 【a模块】import * as b from './b.mjs' 执行, 进入b模块,此时模块地图中的b模块的模块记录为"获取中",
  4. 【b模块】import * as a from './a.mjs' 执行,检查模块地图,模块a已经是Fething状态,不再进去,
  5. let b = '原始值-b模块内变量' 模块记录中,存储b的内存块初始化,
  6. console.log('b模块引用a模块', a),根据模块记录到指向的内存中取值,是 {a:}
  7. b = '修改值-b模块内变量' 模块记录中,存储b的内存块值修改。
  8. 【a模块】let a = '原始值-a模块内变量' 模块记录中,存储a的内存块初始化,
  9. console.log('a模块引用b模块:', b) 根据模块记录到指向的内存中取值,是{ b: '修改值-b模块内变量' }
  10. a = '修改值-a模块内变量' 模块记录中,存储a的内存块值修改
  11. 【入口模块】console.log('入口模块引用a模块:',a) 根据模块记录,到指向的内存中取值,是{ a: '修改值-a模块内变量' }

总结: ES Module来处理循环使用一张模块间的依赖地图来解决死循环问题,标记进入过的模块为"获取中",所以循环引用时不会再次进入;使用模块记录,标注要取哪块内存中取值,将导入导出做连接,解决了输出什么值。

ES Module多次引入

  • 重复执行import ,只会执行一次,不会多次执行;
  • import同一个模块的不同方法,意味着的 import语句是单例模式;
  • 对export default new XX(), 多个脚本引入它,得到的都是同一个实例; image.png

经典问题总结:
CommonJS 和 ES Module都对循环引入做了处理,不会进入死循环,但是方式不同:

  • CommonJS借助模块缓存,遇到require函数会先检查是否有缓存,已经有的则不会进入执行,在模块缓存中还记录着导出的变量值的拷贝值;
  • ES Module 借助模块地图,已经进入过的模块标注为获取中,遇到import语句会去检查这个地图,已经标注为获取中的则不会进入,地图中的每一个节点都是一个模块记录,上面有导出变量的内存地址,导入时会做一个连接,即指向同一个块内存。

总结

学习代码地址: js-module-development