前端模块化知多少

时间: 2023-07-11 admin 互联网

前端模块化知多少

前端模块化知多少

背景

前端技术不断发展,代码日益膨胀,所以需要一种规范,方便对复杂的程序进行拆分和组合,以便开发大型的、复杂的项目。

什么是模块化

模块化开发在一些高级语言中都是非常成熟的语言特性,具体来讲,主要特点有:

  • 将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起
  • 块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与外部其它模块通信

模块化和组件化区别:
首先两者是两个完全不同的概念,主要区别体现在颗粒度层面,模块侧重的是对属性的封装,重心在设计和开发,不关注运行时,是一个白盒;组件是一个可以独立部署的单元,面向运行时,侧重产品功能性,是一个黑盒,内部逻辑不可见。

模块化开发的价值

  • 避免命名冲突
  • 便于依赖管理
    • 不需要顺序加载script,依赖关系更清晰
    • 开发者和构建工具通过相同的规范处理模块化代码,生成AST获取各模块之间详细依赖关系
  • 利于性能优化
    • 按需加载的模块更易于管理
    • 合并散列模块,减少http请求
  • 提高可维护性
  • 利于代码复用
    早期的前端模块化方式,主要有:全局function模式、namespace模式、匿名函数自调用(闭包)
    这些方式都有一定的缺点,总结来看:
  • 命名空间污染(全局function模式)
  • 模块作用域不安全(namespace模式)
  • 代码组织、语义不清晰,难维护(匿名函数自调用)
    比较接近现代模块化的实现是引入依赖方式
// module.js文件
(function(window, $) {let data = 'www.baidu.com'//操作数据的函数function foo() {//用于暴露有函数console.log(`foo() ${data}`)$('body').css('background', 'red')}function bar() {//用于暴露有函数console.log(`bar() ${data}`)otherFun() //内部调用}function otherFun() {//内部私有的函数console.log('otherFun()')}//暴露行为window.myModule = { foo, bar }
})(window, jQuery)

这种方式的缺点,也需要保证依赖关系,也就是script的加载顺序,增加了维护成本以及过多的http请求

 // index.html文件<!-- 引入的js必须有一定顺序 --><script type="text/javascript" src="jquery-1.10.1.js"></script><script type="text/javascript" src="module.js"></script><script type="text/javascript">myModule.foo()</script>

以上问题都可以通过模块化规范解决,开发中最流行的模块化规范有CommonJS, AMD, CMD,UMD,ES6模块规范

模块化规范对比

Commonjs
  • 概述
    同步模块加载规范,即模块加载完成,才会执行后面的操作;每一个文件就是一个模块,模块里定义的变量、函数、类,都是私有的,对其他文件不可见。
  • 特点
    • 所有代码都运行在模块作用域,不会污染全局作用域
    • 同步加载,运行时执行加载逻辑
    • 重复加载只运行一次,运行结果在第一次执行后缓存
    • 输出和导入的模块在一个对象(module.export)的属性里
  • 使用
    • 暴露模块:module.exports = value或exports.xxx = value
    • 引入模块:require(xxx),如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径
  • 缺点:更适用于nodejs开发,不适合浏览器环境:
    • 模块文件在服务端本地硬盘,加载比较快,但是浏览器环境还要考虑网络延迟,影响整个应用体验
    • 所有模块都是同步非阻塞加载,无法实现按需加载

规范本身并不具备模块加载能力,需要具体的实现,例如nodejs中实现和遵守CommonJS规范,通过module.export和require导出加载模块,在浏览器端还需要构建工具提供类似的导出加载方法

AMD
  • 概述
    异步模块加载规范,模块的加载不影响后面语句的运行。所有依赖模块的语句,都定义在一个回调函数中,等到加载完成之后,回调函数才执行,require.js是AMD的一个代表性实现。
  • 特点
    • 适合在浏览器环境中异步加载模块,推崇依赖前置,提前执行
    • 可以并行加载多个模块
  • 使用
    • 1、定义模块:
    // 定义
    define("module", ["dep1", "dep2"], function(d1, d2) {...});
    
    • 2、导入模块
    // 加载模块
    require(["module", "../app"], function(module, app) {...});
    
    • 3、页面引入
    // index.html文件
    <!DOCTYPE html>
    <html>
    <head><title>Modular Demo</title>
    </head>
    <body><!-- 引入require.js并通过data-main指定js主文件的入口 --><script data-main="js/main" src="js/libs/require.js">  </script>  
    </body>
    </html>
    
  • 执行流程
    • require函数检查依赖的模块,根据配置文件,获取js文件的实际路径
    • 根据js文件实际路径,在dom中插入script节点,并绑定onload事件来获取该模块加载完成的通知
    • 依赖script全部加载完成后,调用回调函数
  • 缺点:
    • 应用场景单一,无法在nodejs环境使用
    • 提高了开发成本,代码的阅读和书写比较困难,模块定义方式的语义不顺畅
CMD
  • 概述
    通用模块加载规范,和AMD很相似,简单,并与CommonJS和Node.js的 Modules 规范保持了很大的兼容性,代表实现:sea.js
  • 特点
    • 适合在浏览器环境中异步加载模块,推崇依赖就近,用到哪个模块再去require,延迟执行
    • 在nodejs环境容易使用
  • 语法
define(function(require, exports, module) {var a = require('./a');a.doSomething();// 依赖就近书写,什么时候用到什么时候引入var b = require('./b');b.doSomething();
});
  • 缺点:依赖 SPM 打包,模块的加载逻辑偏重
UMD
  • 概述:是AMD和CommonJS的糅合
  • 特点:先判断是否支持Node.js的模块(exports)是否存在,存在则使用Node.js模块模式;在判断是否支持AMD(define是否存在),存在则使用AMD方式加载模块
  • 语法
(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 ...
});

CommonJS、AMD、CMD和UMD有一些共同的局限性:

  • 1、不同的规范模块无法混合使用,复用性不高;
  • 2、除规范本身外,各自有一定的学习成本,如commonjs的browserify, CMD的SPM;
  • 3、只能在运行时确定依赖关系,无法做一些静态的性能优化。
ES6模块
  • 概述
    一种静态模块体系,是语言层面上的规范,与运行环境无关,在编译时可以确定模块依赖关系,使得静态分析成为可能

  • 特点

    • 静态模块加载,容易进行静态分析
    • 语言层面上的模块化,相当于给每个模块提供连接作用,与应用场景无关
    • 面向未来的 ECMAScript 标准,会逐渐取代CommonJS和AMD规范
  • 缺点

    • 浏览器兼容性不好,需要通过构建工具转化成es5方式
  • 语法:import导入/export导出,几点注意

    • ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict";

    • import

      • 命令会被 JavaScript 引擎静态分析,优先于模块内的其他内容执行,这也造成了import不能进行一些动态的加载逻辑,所以引入了后面的import()函数(export同样不能放在语句块中)
       // a.jsconsole.log('a.js')import { foo } from './b';// b.jsexport let foo = 1;console.log('b.js 先执行');// 执行结果:// b.js 先执行// a.js
      
      • 输入的变量都是动态只读的,不允许在加载模块的脚本里面改写,接口的属性是可以更改的,但是不建议更改,因为难以排查错误
      	import {a} from './xxx.js'a = {}; // Syntax Error : 'a' is read-only;a.foo = 'hello'; // 写法很难查错
      
      • import不会重复执行加载模块,见下文中与CommonJS对比
    • export

      • 命令规定的是对外的接口,作用是和内部变量建立对应关系
      // 报错
      export 1;// 报错
      var m = 1;
      export m;// 写法一
      export var m = 1;// 写法二
      var m = 1;
      export {m};// 写法三
      var n = 1;
      export {n as m};
      
      • export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值,而1是值不是接口,所以前两种报错
      • 命令有变量声明提升效果,一般出现在循环依赖加载场景
      	// a.jsimport { foo } from './b';console.log('a.js');export const bar = 1;export const bar2 = () => {console.log('bar2');}export function bar3() {console.log('bar3');}// b.jsexport let foo = 1;import * as a from './a'; // 如果此处用大括号结构方式导入,会报错变量未声明console.log(a);// babel-node a.js// 执行结果:// { bar: undefined, bar2: undefined, bar3: [Function: bar3] }// a.js
      
    • 模块继承
      假设有一个circleplus模块,继承了circle模块

      // circleplus.jsexport * from 'circle';
      export var e = 2.71828182846;
      export default function(x) {
      return Math.exp(x);
      }
      

      上面代码中的export *,表示再输出circle模块的所有属性和方法。注意,export *命令会忽略circle模块的default方法。然后,上面代码又输出了自定义的e变量和默认方法。

      这时,也可以将circle的属性或方法,改名后再输出。

      // circleplus.js
      export { area as circleArea } from 'circle';
      

      注意的是,改名操作必须跟在export命令之后,不能放在执行时的语句逻辑中,因为这样违背了import/export静态分析的设计

    • import():ES2020新特性正式引入,类似于 Node 的require方法,区别主要是import()是异步加载,require是同步加载。解决import不能在运行时加载模块的问题(原因:es6模块设计思想是尽量静态化,引擎处理import是在编译时,而且是先于其他逻辑执行,所以无法放在动态代码块里)

      • 基于 Promise 的 API
      • 可以在脚本的任何地方使用
      • import() 接受字符串文字,你可以根据你的需要构造说明符
      // a.js
      const str = './b';
      const flag = true;
      if(flag) {
      import('./b').then(({foo}) => {console.log(foo);
      })
      }
      import(str).then(({foo}) => {
      console.log(foo);
      })// b.js
      export const foo = 'foo';// babel-node a.js
      // 执行结果
      // foo
      // foo
      
    • 浏览器加载方式

    	<script type="module" src="./foo.js"></script><script type="module">import utils from "./utils.js";// other code</script>

    浏览器对于带有type="module"的<script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>标签的defer属性。

    • node中使用:

      由于node中没有兼容es模块化,所以在node中还需要一些特定方法,主要方式有:
      • 1、require(‘babel-register’)
      • 2、下载babel-cli, 使用babel-node指令
      • 3、node升级到13.2, package.json中type 设置为module, 引入文件不能省略.js后缀

      为了保证es6模块的通用性,node中的es6模块无法使用CommonJS中的一些内置变量,如this, ES6中指向的是undefined, CommonJS指向的是当前模块,以下这些顶层变量在 ES6 模块之中也不存在:

      • arguments
      • require
      • module
      • exports
      • __filename
      • __dirname
  • es6模块与commonjs之间互相加载

    • es6 中加载commonjs模块
      • ES6 模块的import命令可以加载 CommonJS 模块,但是只能整体加载,不能只加载单一的输出项
      // 正确
      import packageMain from 'commonjs-package';// 报错
      import { method } from 'commonjs-package';	
      

      这是因为 ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是module.exports,是一个对象,无法被静态分析,所以只能整体加载。

    • CommonJS模块中加载ES6模块
      • CommonJS 的require()命令不能加载 ES6 模块,会报错,只能使用import()这个方法加载。
      (async () => {await import('./my-app.mjs');
      })();
      

      require()不支持 ES6 模块的一个原因是,它是同步加载,而 ES6 模块内部可以使用顶层await命令,导致无法被同步加载

CommonJS和ES6模块机制对比

  • 输出值

    CommonJS输出的是原始值的拷贝
    // a.js
    var b = require('./b');
    console.log(b.foo);
    setTimeout(() => {
    console.log(b.foo);
    console.log(require('./b').foo);
    }, 1000);// b.js
    let foo = 1;
    setTimeout(() => {
    foo = 2;
    }, 500);
    module.exports = {
    foo: foo,
    };
    // 执行:node a.js
    // 执行结果:
    // 1
    // 1
    // 1
    
    怎么理解这个值的拷贝呢,导出的值,其实就是拷贝在module.exports上,因为是基本数据类型,所以不会改变,验证一下
      // b.js
    let foo = {num: 1};
    setTimeout(() => {foo.num = 2;
    }, 500);
    module.exports = {foo: foo,
    };// 执行:node a.js// 执行结果:{ num: 1 }{ num: 2 }{ num: 2 }
    
    显然拷贝在module.exports上是浅拷贝的方式,导入值的地方是否也是浅拷贝呢,验证一下
    // b.jssetTimeout(() => {module.exports.foo = 2;
    }, 500);
    module.exports.foo = 1;// 执行:node a.js
    // 执行结果:
    // 1
    // 2
    // 2
    
    导出时的其实是挂载在module.exports对象的属性上,导入的也是module.exports对象,所以改变module.exports的导出接口,使用导入值的地方也会发生改变
  • ES6导出的是值的引用
// a.js
import { foo } from './b';
console.log(foo);
setTimeout(() => {console.log(foo);import('./b').then(({ foo }) => {console.log(foo);});
}, 1000);// b.js
export let foo = 1;
setTimeout(() => {foo = 2;
}, 500);
// 执行:babel-node a.js
// 执行结果:
// 1
// 2
// 2

import导入的值,不再是挂载对象上,import更像是一个连接符的作用

  • 第二个区别是,ES6 静态编译,CommonJS 运行时加载

模块循环依赖

CommonJS中,看下面一个例子

```
// a.js
console.log('a starting');
exports.done = false;
const b = require('./b');
console.log('in a, b.done =', b.done);
exports.done = true;
console.log('a done');// b.js
console.log('b starting');
exports.done = false;
const a = require('./a');
console.log('in b, a.done =', a.done);
exports.done = true;
console.log('b done');// node a.js
// 执行结果:
// a starting
// b starting
// in b, a.done = false
// b done
// in a, b.done = true
// a done```

通过上面介绍特性,很好理解

  • 首先运行node a.js是会执行一次a模块加载,导出值为false,打印a starting
  • 到require(’./b’), 执行b文件,打印console.log(‘b starting’);
  • b中执行到require(’./a’),因为第一步已经加载过a,所以不会再次执行,取a模块缓存结果false,b文件执行完成,导出值为true, 同时b文件在函数调用栈中被弹出
  • a继续执行,获取b导出值,打印为false, a执行完成

ES6模块中跟 CommonJS 模块一样,ES6 不会再去执行重复加载的模块,又由于 ES6 动态输出绑定的特性,能保证 ES6 在任何时候都能获取其它模块当前的最新值

// a.js
console.log('a starting')
import {foo} from './b';
console.log('in b, foo:', foo);
export const bar = 2;
console.log('a done');// b.js
console.log('b starting');
import {bar} from './a';
export const foo = 'foo';
console.log('in a, bar:', bar);
setTimeout(() => {console.log('in a, setTimeout bar:', bar);
})
console.log('b done');// babel-node a.js
// 执行结果:
// b starting
// in a, bar: undefined
// b done
// a starting
// in b, foo: foo
// a done
// in a, setTimeout bar: 2

没看懂执行结果的话,可以在上面翻看一下es6模块的import和export特性

相同点:

  • 模块不会重复执行,都是单例模式
    // a.js
    import './b';
    import './b';// b.js
    console.log('只会执行一次');// 执行结果:
    // 只会执行一次
    

webpack中构建差异

AMD

AMD本身具备异步加载功能,比如使用require.js在index.js中编写以下代码实现异步加载

require(['./test.js'], function(fn) {fn();window.onload = function() {require(['./test02.js'],function(data) {console.log(data);});}
})

AMD代码在webpack构建之后产出以下文件:

可以看到,异步加载文件0.c7d7bdc7.js,chunk names为空值。其实0为此模块的id,由于AMD实现异步加载的require方法不支持定义模块的Chunk Name,所以webpack将其id作为命名的一部分。但是这种命名方式没有语义,无法追踪线上报错。

AMD起步的年代webpack还没有诞生,webpack对于落后于时代的规范支持性不佳,这种问题在所难免。

ES6和CommonJS中的异步加载

在webpack2以前,异步加载的方式通过require.ensure实现,webpack2以后ES6模块化开始支持import()函数,但是两者在异步文件命名上仍然存在差异。

const a = require('./test.js');
a();
window.onload = require.ensure([], require => {const b = require('./test02.js');b();
}, 'test02'); // chunk name

如上,使用require.ensure API加载异步模块,最终编译结果如下:

构建输出的异步文件名称由require.ensure传入的第三个参数指定

import()函数的用法于AMD的require API类似,它本身并不支持定义Chunk Name, webpack提供了特殊的注释以弥补此缺陷,如下

import a from './test.js';a();window.onload = import(
/* webpackChunkName: "test02Import " */ //chunk names
'./test02.js'
).then(b => {b();
});

import()函数加载异步文件后返回一个Promise,这更利于JS异步代码的编写,上述代码中的注释声明了被加载异步文件构建输出的名称,在webpack构建后如下:

AMD虽然具备异步加载功能,但webpack对其支持不理想,webpack 为CommonJS和ES6模块提供了require.ensureAPI用于弥补异步加载功能(CommonJS不支持异步加载,ES6浏览器环境未完全兼容),但require.ensure是webpack特有的能力,如果移植到其他构建系统,可能会引起未知的错误,随着ES2020 import函数正式列入规范,ES6模块化会逐渐替代其他模块化规范,唯一的缺点就是需要借助特殊注释定义异步文件名称

总结

  • CommonJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD CMD解决方案。
  • AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。
  • CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。不过,依赖SPM 打包,模块的加载逻辑偏重
  • ES6模块化在语言标准的层面上,实现了模块功能,而且实现得相当简单;因此ES6模块化完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案