前端模块化

2,848 阅读5分钟

一. 背景

作为前端开发,模块化我们已经耳熟能详,我们平时接触到的 ES6 的 import,nodejs 中的 require 他们有啥区别?我们也听过 CommonJS、CMD、AMD、ES6模块系统,这些都有什么联系呢?

二. 为何需要模块化?

2.1 起源

最开始 js 是没有模块化的概念的,就是普通的脚本语言放到 script 标签里,做些简单的校验,代码量比较少。随着 ajax 的出现,前端可以请求数据了,做的事情更多了,逻辑越来越复杂,就会出现很多问题。

2.1.1全局变量冲突,因为大家的代码都在一个作用域,不同人定义的变量名可能重复,导致覆盖。
    var a = 1123;
    var a = 6666;
2.1.2 需要清楚相互的依赖关系,难以维护
    // 依赖关系--自上问下 ↑↑↑
    <script src="a.js"></scrpit>
    <script src="b.js"></scrpit>
    <script src="c.js"></scrpit>

2.2早期的解决方案

2.2.1 命名空间

命名空间是将一组实体、变量、函数、对象封装在一个空间的行为。这里展现了模块化思想雏形,通过简单的命名空间进行「块儿」的切分,体现了分离和内聚的思想。

    var people = {
        name: "zhao",
        sayHai(){
            console.log("Hello")
        },
        sayName: ()=>{
            console.log(this.name)
        }
    }
    // 上面示例可以发现可能存在问题,比如我们修改了 people 的 name,会导致原有的 name 被更改
    people.name = "qian"
2.2.2 闭包

再次提升模块化的解决方案,利用闭包使污染的问题得到解决,更加纯粹的内聚。

    moduleA = function() {   
        var name = '内部模块';   
        return {      
            start: function (c){         
                    return name + '引入';      
                };   
            }
    }()

上面示例中 function 内部的变量就对全局隐藏了,达到了封装的目的。但是模块名称暴露在全局,还是存在命名冲突的问题。

    (function(global){
        var name = '内部模块';
        function start(){};
        global.module = {name, start}
    })(window)
    // 上面表达式中的变量 name 不能直接从外部访问。

三. 模块化

模块化已经是现代前端开发中不可或缺的一部分了,把复杂的问题分解成相对独立的模块,这样的设计可以降低程序复杂性,提高代码的重用,也有利于团队协作开发与后期的维护和扩展 从 ECMAScript2015 开始引入了模块的概念,我们称为:ECMAScript Module,简称:ESM。 核心:

  • 独立的作用域与依赖关系处理
  • 如何导出模块内部数据
  • 如果导入外部模块数据

把复杂的问题分解成相对独立的模块,这样的设计可以降低程序复杂性,提高代码的重用,也有利于团队协作开发与后期的维护和扩展

3.1 模块化解决的问题
  • 解决命名污染,全局污染,变量冲突等问题
  • 内聚私有,变量不能被外面访问到
  • 怎么引入其它模块,怎样暴露出接口给其它模块
  • 引入其他模块可能存在循环引用的问题
3.2 基于 JavaScript 的模块系统分类
  • CommonJS(适用于服务端)
  • AMD/CMD
  • UMD
  • ESM - EcmaScript Module

四.** 主流模块化解决方案**

4.1 CommonJS

  1. 一个文件就是模块拥有独立的作用域:每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。
  2. 通过 module.exports 或 exports 对象导出模块内部数据:每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。
    //导出模块内部数据
    // a.js 
    let a = 1; 
    let b = 2; 
    module.exports = { x: a, y: b } 
    // or 
    exports.x = a; 
    exports.y = b;
    
    //导入外部模块数据
    // b.js 
    let a = require('./a'); 
    a.x; 
    a.y;

4.1.1 Node.js的模块化

说到 CommonJS 我们要提一下 Node.js,Node.js 的出现让我们可以用 JavaScript 来写服务端代码,而 Node 应用由模块组成,采用的是 CommonJS 模块规范,当然并非完全按照 CommonJS 来,它进行了取舍,增加了一些自身的特性。

  1. Node内部提供一个Module构建函数。所有模块都是Module的实例,每个模块内部,都有一个module对象,代表当前模块。包含以下属性:
  • module.id 模块的识别符,通常是带有绝对路径的模块文件名。
  • module.filename 模块的文件名,带有绝对路径。
  • module.loaded 返回一个布尔值,表示模块是否已经完成加载。
  • module.parent 返回一个对象,表示调用该模块的模块。
  • module.children 返回一个数组,表示该模块要用到的其他模块。
  • module.exports 表示模块对外输出的值。
  1. Node 使用 CommonJS 模块规范,内置的require命令用于加载模块文件。
  2. 第一次加载某个模块时,Node会缓存该模块。以后再加载该模块,就直接从缓存取出该模块的module.exports 属性。所有缓存的模块保存在 require.cache 之中。
   // a.js
   let name = "zhao"
   exports.name = name;
   
   // b.js
   let a = require('./a.js');
   console.log(a.name) // zhao
   a.name = "qian"
   //删除缓存
   // delete require.cache[require.resolve('./a.js')]
   var b = require('./a.js');
   console.log(b.name); // "qian", 如果清除缓存则为 zhao
   // 上面第一次加载以后修改了name值,第二次加载的时候打印的name是上次修改的,证明是从缓存中读取的。
  1. CommonJS 模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。

    // a.js
    var counter = 3;
    exports.counter = counter;
    exports.addCounter = function(){    
        counter++;
    };
    // b.js
    var a = require('a.js');
    console.log(a.counter); // 3
    a.addCounter();
    console.log(a.counter); // 3

前面所说的 CommonJS 规范,都是基于 node 来说的,所以 CommonJS 都是针对服务端的实现。为什么呢?因为 CommonJS 规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。由于 Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以 CommonJS 规范比较适用。如果是浏览器环境,要从服务器端加载模块,用 CommonJS 需要等模块下载完并运行后才能使用,将阻塞后面代码的执行,这时就必须采用非同步模式,因此浏览器端一般采用 AMD 规范,解决异步加载的问题。

4.2 AMD(Asynchronous Module Definition)

浏览器并没有具体实现该规范的代码,我们可以通过一些第三方库 **RequireJS** 来解决 <script src="https://cdn.bootcss.com/require.js/2.3.6/require.min.js"></script>

4.2.1 模块定义

1)独立模块(不需要依赖任何其他模块)

    define({
        method1: function(){},
        method2: function(){},
    })
    
    define(function(){
        returen {
            method1: function(){},
            method2: function(){},
        }
    })

2)非独立模块(需要依赖其他模块)

    // 不带 .js 后缀
    define(['module1', 'module2'], function(m1, m2){
        returen {
            m1.methodA: function(){},
            m2.methodA: function(){},
        }
    })

define方法:

  • 第一个参数是一个数组,它的成员是当前模块所依赖的模块
  • 第二个参数是一个函数,当前面数组的所有成员加载成功后,它将被调用。它的参数与数组的成员一一对应,这个函数必须返回一个对象,供其他模块调用。
4.2.2 模块调用
    require(["a", "b"], function(a, b){
         a.methodA()
    })

define和require这两个定义模块、调用模块的方法,合称为AMD模式。它的模块定义的方法非常清晰,不会污染全局环境,能够清楚地显示依赖关系。

4.3 CommonJS 和 AMD 的对比

  • CommonJS一般用于服务端比如 node,AMD 一般用于浏览器环境,并且允许非同步加载模块,可以根据需要动态加载模块。
  • CommonJS 和 AMD 都是运行时加载。就是都只能在运行时才能确定一些东西,所以是运行时加载。
    // CommonJS模块
    let { stat, exists, readFile } = require('fs');
    // 等同于
    let _fs = require('fs');
    let stat = _fs.stat;
    let exists = _fs.exists;
    let readfile = _fs.readfile;
    // 上面代码其实是整体加载了fs模块,生成了一个_fs 的对象,然后从这个对象上读取三个方法。因为只有运行时才能得到这个对象,所以成为运行时加载。
    

    // AMD
    define('a', function () {  
        console.log('a 加载')
        return {    
            run: function () { 
                console.log('a 执行') 
            } 
        }
    })
    define('b', function () {  
        console.log('b 加载')  
        return {    
            run: function () { 
                console.log('b 执行') 
            }  
        }
    })
    //运行
    require(['a', 'b'], function (a, b) {  
        console.log('main 执行')    
        a.run()  
        b.run()
    })
    // 运行结果:
    // a 加载
    // b 加载
    
    // main 执行
    // a 执行
    // b 执行
    
    //我们可以看到执行的时候,a和b先加载,后面才从main开始执行。
    // 所以require一个模块的时候,模块会先被加载,并返回一个对象,并且这个对象是整体加载的,也就是常说的 依赖前置。

4.4 UMD(Universal Module Definition)

    (function (global, factory)(){
        if(typeof exports === 'object' && typeof module !== 'undefined') {
            // Node, CommonJS-like
            module.exports = factory(require('lodash'))
        }else if(typeof define === "function" && define.amd){
            // CMD 
            define(['lodash'], factory)
        }else {
            global = typeof globalThis !== 'undefined' ? globalThis : global || self;
            factory(global.lodash)
        }
    })(this, function(lodash){
        'use strict';
        ...
    })

4.5 ESM ES6 模块

ECMAScript2015/ECMAScript6 开始,JavaScript 原生引入了模块概念,而且现在主流浏览器也都有了很好的支持

4.5.1 导出模块内部数据

import 命令具有提升效果,会提升到整个模块的头部,首先执行。

    // 导出单个特性
    export let name1, name2, …, nameN;
    export let name1 = …, name2 = …, …, nameN;
    export function FunctionName(){...}
    export class ClassName {...}

    // 导出列表
    export { name1, name2, …, nameN };

    // 重命名导出
    export { variable1 as name1, variable2 as name2, …, nameN };

    // 默认导出
    export default expression;
    export default function () { … }
    export default function name1() { … }
    export { name1 as default, … };
    
    var a = 1;
    export default a;
    // export default a 的含义是将变量 a 的值赋给变量 default 。
    // 注意:正是因为export default命令其实只是输出一个叫做default的变量,所以它后面不能跟变量声明语句。
    // export default var a = 1; 错误的
    

    // 模块重定向导出
    export * from …;
    export { name1, name2, …, nameN } from …;
    export { import1 as name1, import2 as name2, …, nameN } from …;
    export { default } from …;
    

上面代码中,exportimport语句可以结合在一起,写成一行。但需要注意的是,写成一行以后,引入的模块实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用引入的模块。

4.5.2 导入模块内部数据
  • 静态导入:在浏览器中,import 语句只能在声明了 type="module" 的 script 的标签中使用。
  • 动态导入
    import defaultExport from "module-name";
    import * as name from "module-name";
    import { export } from "module-name";
    import { export as alias } from "module-name";
    import { export1 , export2 } from "module-name";
    import { export1 , export2 as alias2 , [...] } from "module-name";
    import defaultExport, { export [ , [...] ] } from "module-name";
    import defaultExport, * as name from "module-name";
    import "module-name";

五. ES6模块、CommonJS和AMD模块区别

  1. 值拷贝 和 引用拷贝

CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。

    // lib.js
    var counter = 3;
    function incCounter() {
      counter++;
    }
    module.exports = {
      counter: counter,
      incCounter: incCounter,
    };
    
    // main.js
    var mod = require('./lib');

    console.log(mod.counter);  // 3
    mod.incCounter();
    console.log(mod.counter); // 3

上面代码说明,lib.js模块加载以后,它的内部变化就影响不到输出的mod.counter了。这是因为mod.counter是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。

    // lib.js
    var counter = 3;
    function incCounter() {
      counter++;
    }
    module.exports = {
      get counter() {
        return counter
      },
      incCounter: incCounter,
    };

ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

    // lib.js
    export let counter = 3;
    export function incCounter() {
      counter++;
    }

    // main.js
    import { counter, incCounter } from './lib';
    console.log(counter); // 3
    incCounter();
    console.log(counter); // 4
  1. 编译时加载 和 运行时加载ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。所以ES6是编译时加载。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。