浅谈前端模块化的发展历程

什么是模块

在早期的前端开发中,并没有模块的概念,模块只在服务端存在,用于处理复杂的业务通信等,但是随着互联网与前端的不断发展,网站功能越来越多,逐渐成为了web-app,嵌入网站的js代码越来越大,需要一个团队分工协作,开发者就要使用软件工程的方法来管理复杂的业务逻辑,此时js的模块化编程就成为了迫切的需求。

理想情况下,开发者只需要关心自己的业务实现,对于其他的功能只需要加载对应的业务模块即可,但是js本身不是一种模块化编程语言,不支持类,更别想着使用module了,为此js社区做了很大努力,在现有的运行环境中,实现了类似模块的效果。本文将总结ES module规范,以及AMD、CMD、UMD等优秀实践。

一、早期模块

模块就是实现特定功能的一种方法,例如一个函数就可以被称为模块

function foo() {}
function bar() {}

这样看起来没什么问题,但是如果其他地方也声明了foo或者bar,上面的模块就不可用了,因为他们都暴露在全局,没有统一的name-space,命名空间。

然后我们为了不冲突,将其放到Object中

var obj = {
  foo: function() {},
  bar: function() {}
}

可是我们发现,这样还是不够安全,外部依然可以对模块进行修改

所以我们要对他封装自己的作用域,可以使用IIFE立即执行函数对其进行私有化

(function() {
  function foo() {
    
  }
  function bar() {
    
  }
})()

这下好了,我们内部的变量始终不会被外部获取到,并且外部也无法对内部进行篡改,可是这样太私有化了,我们无法对其扩展,那又演化为这样

var module = (function(module) {
  module.foo = function() {}
  return module
})(module)

上面可以为我们的module进行扩展foo方法,然后返回新的module暴露给外界

好了,但是这在我们本地执行起来看着没问题,但是客户端到服务器可不是本地同步读硬盘那么快,可能module并没有加载,我们也就无法增强故可以传递一个空对象进去容错。

现在有一个场景,你的一个模块使用了jQuery,但是你没有显式地声明,这就导致了模块依赖关系错综复杂,所以推荐写成这样

(function(jQuery) {
  jQuery.xxx 
})(jQuery)
二、 模块规范

我们在第一章讲了模块的演变史,现在可以使用模块了,但是我们别忘记模块的初衷是让我们更易用别人的代码,业务更加抽象,使用起来没有心智负担。所以这就要对模块的编写者有约束,那么大家就坐到一块儿商量了一个规范。

目前主流的JavaScript规范有两种:CommonJs和AMD。

2.1 什么是commonJs

我们要知道,前端现在生态这么繁荣,离不开node.js的推广,早在09年node.js问世,将js运用于服务端编程,服务端可不能像客户端那样一个js走天下了,所以模块化是必然的。

node.js的模块系统就是根据CommonJs规范实现的。CommonJs有一个require方法可以为我们引入模块,module.exports = xxx 可以为我们导出模块。

2.2 客户端怎么使用CommonJs

随着服务端模块化的推进,客户端的模块化需求也日益剧增,最好能有一个统一规范可以实现两边兼顾,但是客户端和服务端不一样,例如

var foo = require('./foo.js')
foo()

上述代码在服务端看起来没什么问题,因为服务端大多是读取磁盘,同步代码没什么问题,但是在客户端,我们知道浏览器是需要网速支持的,要是等待执行的话,浏览器就会出现阻塞假死,所以必须要采用异步加载的模式,也就是AMD的async。

2.3 AMD

AMD是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"。他采用了异步加载模块的方式,也就是我们要把代码放到加载完毕的回调中,这样我们的代码就不会阻塞DOM解析,很显然,AMD的方式比较符合客户端需求,require.js就是根据AMD规范实现的。

其实原理类似这样

var script = document.createELment('script')
script.src = './modules/foo.js'
script.onload = function() { console.log(foo) }
document.body.appenChild(script)
2.4 require.js

为什么要用require.js?

相信你肯定看过这样的代码




这是早期实现中,js没有模块化,只能把所有的js写到一个文件中,文件越写越大,于是就问了几个文件去存放代码,然后依次引入,浏览器会依次加载解析执行,但是加载的文件越多,浏览器就假死时间越长,而且要严格保证加载顺序,并且这种“模块”不够显式,你不知道谁在依赖谁,长此以往代码维护将是噩梦,require.js就是为了解决上述问题

  1. 实现js异步加载
  2. 管理模块的依赖关系

具体怎么使用这里不做介绍,可以去http://www.requirejs.cn详细阅读

2.5 CMD

AMD虽然实现了异步加载,但是开始的时候就会把依赖关系都整理好,可不可以向CommonJs一样使用的时候再加载呢?所以AMDj就诞生了,sea.js是比较好的实现,原则是依赖就近

function() {
  ....
  var foo = require('foo')
  foo()
  ....
}
2.6 AMD和CMD的区别

最大的区别就是对依赖模块的加载时机不同,二者虽然都是异步加载,但是AMD依赖前置,我们会在使用时一股脑加载完依赖再执行,CMD依赖后置,我们在使用的时候才会加载,这样就可能造成一点点的延迟,这也是大部分人诟病的地方,用性能换便利性;但是对于代码中不使用的模块,CMD确实能够节省一些资源,好吧这些在后面的发展都显得不重要了。

2.7 UMD

啊这,怎么又蹦出的个UMD??

UMD是AMD和CommonJs的结合,是一种跨平台解决方案。

UMD会判断是不是AMD,define存在就用AMD;判断是不是CommonJs,是就用Node.js的模块方式

(function (window, factory) {
    if (typeof exports === 'object') {
     
        module.exports = factory();
    } else if (typeof define === 'function' && define.amd) {
     
        define(factory);
    } else {
     
        window.eventUtil = factory();
    }
})(this, function () {
    //module ...
});
2.8 Node.js模块加载机制

如果 require 参数不以“ / ”、“ ./ ”或“ ../ ”开头,而该模块又不是核心模块,那么就要通过查找 node_modules 加载模块了。我们使用npm获取的包通常就是以这种方式加载的。

例如你需要使用koa模块,在 node_modules 目录的外面一层,我们可以直接使用 require('koa') 来代替require('./node_modules/koa') 。这是Node.js模块加载的一个重要特性:通过查找 node_modules 目录来加载模块。当 require 遇到一个既不是核心模块,又不是以路径形式表示的模块名称时,会试图在当前目录下的 node_modules 目录中来查找是不是有这样一个模块。如果没有找到,则会在当前目录的上一层中的 node_modules 目录中继续查找,反复执行这一过程,直到遇到根目录为止。

三、 ES Module

ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

ES Module的设计思想是静态化,即编译时就能确定模块的依赖关系,以及输入输出的变量。CommonJs和AMD都只能在运行时才能确定,是动态的。

例如

// CommonJS模块
let { stat } = require('fs');

// 等同于
let _fs = require('fs');
let stat = _fs.stat;

我们发现,我只要一个stat方法就好了,可是却引入了很多不必要的方法。那为什么不能帮我们tree-shaking呢?因为我们只有在运行时才可以知道到底需要什么,所以动态的规范可不敢随意删减。

ES Module就不一样了

例如

// ES6模块
import { stat } from 'fs';

静态编译使得,我们在编译时就知道程序只用了stat方法,其他方法是不需要的,所以ES6就可以在编译时去除不必要代码进行tree-shaking。

这时你会发现,我们甚至都不用冗余的UMD了,因为服务端和客户端都将支持ES Module这种易用通用高性能的规范。

需要注意的是,使用ES6会默认开启严格模式哦。

3.1 ES Module 命令

简单来说,export 用于导出,import 用于导入,export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,import命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了 ES6 模块的设计初衷。

具体怎么使用相信大家都了然于胸,本文旨在帮助大家系统梳理模块化的前世今生,使用方式大家可以多去官方文档看看。

3.2 import()

上节我们提到,import命令会被JS引擎解析,先于模块内的其他语句执行,这样的设计虽然有利于编译器提高效率,但是也失去了一些动态加载的便利性与性能上异步加载的可优化方案,所以推出了import函数进行动态加载。

import()和require()类似,但是是可以异步加载的。

相信你肯定使用过这种写法

router = [{
  path: '/index'
  components: ()=> (import('./index.vue')) 
          }]
          
div.onclick = function() {
  import('index.js').then()
}

if(haha) {
   import('foo')
} else {
  import('bar')
}

这就使得我们可以异步加载,条件加载这些js,并且还可以进行js可以实现的操作,例如你可以用一个函数生成加载路径等。

四、浏览器加载模块

传统的HTML加载一个模块都需要引入一个script标签,例如




但是我们知道这样会造成浏览器阻塞解析DOM,影响构建渲染树,布局绘制页面的流程。

所以script标签有两个属性可以帮助我们告诉浏览器这些包可以异步加载,不影响DOM构建




都写是为了兼容,async不够稳定,一般都采用defer,具体可以自行查阅相关资料。

4.1 浏览器加载ES6模块

type为'module',浏览器会异步加载,等同于defer。

ES6也允许内嵌在网页


加载行为与外部一致,并且加载一个模块多次只会生效一次。





输出
3 bar VM5738 bar.js:1 bar
baz.js:1 baz
bar.js:1 bar
index.js:2 index
五、ES Module和CommonJs的差异
  • CommonJs输出的是一个值的拷贝,ES6输出的是值的引用
  • CommonJs是运行时加载,ES Module是编译时就确认了依赖关系

第二个差异是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

ES6 模块不会缓存运行结果,而是动态地去被加载的模块取值,并且变量总是绑定其所在的模块。

由于 ES6 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错。

六、Node.js模块

上面我们提到,最终目的是都使用ES Module规范,但是Node毕竟有属于自己的CommonJs也不弱,所以Node目前采取二者共存的方式,对于使用ES Module规范的文件要求后缀必须为.mjs

值得注意的是,require和import不可以共存
七、this

ES6的this在最顶层,指向undefined;CommonJs指向当前模块;这是主要差异。

八、循环引用、循环加载的处理
8.1 CommonJs

CommonJS 模块的重要特性是加载时执行,即脚本代码在require的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。

8.2 ES Module

ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用import从一个模块加载变量(即import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

例如

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';

上面代码中,a.mjs加载b.mjsb.mjs又加载a.mjs,构成循环加载。执行a.mjs,结果如下。

$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined

上面代码中,执行a.mjs以后会报错,foo变量未定义,这是为什么?

让我们一行行来看,ES6 循环加载是怎么处理的。首先,执行a.mjs以后,引擎发现它加载了b.mjs,因此会优先执行b.mjs,然后再执行a.mjs。接着,执行b.mjs的时候,已知它从a.mjs输入了foo接口,这时不会去执行a.mjs,而是认为这个接口已经存在了,继续往下执行。执行到第三行console.log(foo)的时候,才发现这个接口根本没定义,因此报错。

解决这个问题的方法,就是让b.mjs运行的时候,foo已经有定义了。这可以通过将foo写成函数来解决。

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
function foo() { return 'foo' }
export {foo};

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo());
function bar() { return 'bar' }
export {bar};

这时再执行a.mjs就可以得到预期结果。

$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar

这是因为函数具有提升作用,在执行import {bar} from './b'时,函数foo就已经有定义了,所以b.mjs加载的时候不会报错。这也意味着,如果把函数foo改写成函数表达式,也会报错。

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
const foo = () => 'foo';
export {foo};

上面代码的第四行,改成了函数表达式,就不具有提升作用,执行就会报错。

最后

模块这部分有些抽象,大家理解的时候一定不要光看,自己动手敲一套demo出来,就能理解模块为什么会经历如此的发展历程了,随着浏览器和硬件的不断推进,现在ES Module规范已经逐渐运用于开发过程中的快速开发,snowpack就是一个很好的实现,Vite基于snowpack也是一套很好的实现。未来ES Module规范很可能直接运行于生产环境,实现更完备的模块化解决方案还是有很长一段路要走的。

你可能感兴趣的:(浅谈前端模块化的发展历程)