【2024秋第9节课】前端模块化

398 阅读13分钟

前端模块化

什么是前端模块化?

前端模块化指的是将前端代码分解成独立的可复用的模块,以便更好地组织、维护和扩展代码。模块可以包括JavaScript、CSS、HTML等各种前端资源。前端模块化的目标是将复杂的前端应用程序分解为小块,每个块都有特定的功能,可以独立开发和测试。

前端模块是一种规范,而不是具体的实现。比方说 Node.js 实现了 CommonJS 规范,ES6 模块提供了 ESM 规范。这些规范有两个共性:

  • 将复杂程序根据规范拆分成若干模块,一个模块包括输入和输出
  • 模块的内部实现是私有的,对外暴露接口与其他模块通信

它的出现主要完成了几个问题的解决:

独立性 - 每个模块都是相对独立的代码片段

可复用性 - 模块可以被多个不同的应用程序重复使用

可维护性 - 每个模块都可以独立开发、测试和维护

作用域隔离 - 模块内部的变量和函数不会污染全局作用域

这里举个例子,如果一个团队里面有几十个人都在开发同一个项目,而这个时候只有一个js文件,那么这个项目的可读性和可维护性就会变得很差,所以有了模块化把我们一个个的功能把一个几十万行的项目拆开来,这样每个人开发的时候就只需要开发自己需要的那个部分就行了,以及也可以快速的找到每个人需要的那部分代码。

现在常用的四个模块化规范:

167b650e8d1fcc23~tplv-t2oaga2asx-jj-mark_3024_0_0_0_q75.png

模块化有哪些实现?

全局函数实现:

这是最开始也是大家可能最熟悉的实现方法,将一个个功能封装到函数中,在需要用这个功能的时候,只需要调用这个函数就可以了。

eg:

function sum(a, b) {
    return a + b;
}
console.log(sum(1,2))

但是全局函数也是有缺点的,如果我有两个人同时开发一个项目,都写了sum函数,那么我在调用的时候到底听谁的呢?所以这个时候,就容易出现一些问题,但是全局函数还是解决了一些模块化的需求。

面向对象实现

众所周知,js也是一门面向对象的语言,所以我们也可以使用面向对象的方法对方法进行封装

eg:

class MyModule {
    number = 0;
    
    add() {
        return this.number++;
    }
   
    reduce() {
        return this.number--;
    }
    
    getNumber() {
        return this.number;
    }
}
​
const myModule = new MyModule();
myModule.add();
console.log(myModule.getNumber());

这样也可以实现函数的封装,在需要的时候通过命名空间来进行调用就可以了,这样A写的add就是A.add B写的add就是B.add,好像解决了一些问题,但是这里还是有一个痛点,就是这些方法其实是可以通过外部来重写的,如果我直接在外部写一个

myModule.add=function(){
return this.number--;
}

那么这个方法就被覆盖掉了,当然我们可以通过私有化这些方法的方式来解决这些问题,但是这样的话就会导致类在继承的时候这些方法被隐去,而且这样写会导致代码的可测试性和可拓展性变得非常的差,所以也不太推荐这么写。

IIFE实现(不推荐仅作了解)

因为js存在的函数闭包功能,我们可以通过创建一个闭包的私有作用域来实现封装,但是它非常的麻烦且可读性差,以及IIFE在现在的编程中已经很少出现了,所以这部分我给大家写了个demo自行了解一下即可。

(function () {
    var x = 1;
​
    function getX() {
        return x;
    }
​
    function setX(val) {
        x = val;
    }
​
    function sum(a, b) {
        return a + b;
    }
​
    window.__Module = {
        x,
        setX,
        getX,
        sum,
    };
})();

现代的模块化系统

在开头我们讲过,模块化实际上是一个规范模式,并不是某个特定的方式,所以这里给大家介绍四个最常见的模块化规范。

CommonJs

CommonJSNode.js 中默认的模块化规范:

  • 文件级别的模块作用域(每个文件就是一个作用域):每个 CommonJS 模块都有自己的作用域。
  • 使用 require 函数来导入,通过 module.exports 导出。
  • CommonJS 模块是同步加载的,这意味着模块在导入时会阻塞执行,直到模块完全加载并可用,并且模块加载的顺序会按照其在代码中出现的顺序。
  • 模块可以多次加载,首次加载的时候会运行模块并对输出结果进行缓存,再次加载时会直接使用缓存中的结果。

接下来我们讲讲Commonjs到底要怎么做来实现模块化呢?

模块的导入导出

先讲讲一个模块我们要如何导出去吧,一般我们是使用modules.export来做一个导出

eg:

module.exports = {
    method1: function() {},
    value1: 123
}
​

接下来就是模块怎么导入了,我们一般使用require来进行导入

eg:

// 1. 解析模块路径
const module = require('./module')
​
// 2. 检查模块缓存
// 如果已缓存,直接返回 module.exports
// 如果未缓存,继续后续步骤// 3. 加载模块文件
.....

1.解析模块路径就是看看这个模块到底在哪,它支持绝对路径和相对路径两种方法。

2.缓存检查,在commonjs中一个模块只有一次导入,所以第二次导入的时候就会检查有没有缓存如果之前缓存过了就直接把之前的给导出就行了,没缓存才会进行下一步。

3.加载模块文件,我们在导入的时候不一定都是js,也可能是json或者其他文件,那么在这个时候我们就会解析这个文件到底是哪种文件做对应的处理。

后面还有其他的过程,但是这涉及node的一些底层实现,我们这里就先不做展开避免大家产生疑惑了。

缺点:

那commonjs有没有缺点和局限性呢?

其实是有的,首先我们的浏览器没有原生支持commonjs,只有node环境中原生支持,所以第一个问题就是在浏览器端使用common要用打包工具来辅助。

第二就是,一般在服务器端我们的文件都是在本地的,加载的很快,但是在浏览器端这些模块往往不在本地,而commonjs的加载又是同步的,在遇到大的模块的时候,就很容易卡死。

第三就是值拷贝的问题,在commonjs中导出的模块是值拷贝的,也就是说一旦导出一次这个值和原来的模块就没有关系了

eg:

// CommonJS 的值拷贝
// counter.js
let count = 0;
module.exports = {
    count,
    increment: () => count++
};
​
// main.js
const counter = require('./counter');
console.log(counter.count);  // 0
counter.increment();
console.log(counter.count);  // 仍然是 0,因为导出的是值的拷贝

AMD

所以针对那个同步加载问题,我们有了AMD,它允许浏览器进行异步的加载,所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

定义模块

define(['dependency1', 'dependency2'], function(dep1, dep2) {
​
    // 模块的功能代码
​
    return {
​
        // 导出的功能
​
    };
​
});
​
​

第一个参数是一个数组,用来定义这个模块依赖的模块,比方说这个模块中我需要依赖一些其他的模块,比如jQuery,那么我们就可以写在这个数组中,第二个参数就是一个回调函数了,接受模块实例为参数,并定义当前这个模块的功能代码,可以在回调函数中去返回对象把需要导出的功能暴露。

导入模块

require(['module1', 'module2'], function(mod1, mod2) {
​
  // 使用模块的功能
​
});
​

第一个参数依然是数组用来定义这个模块的依赖,第二个就是回调函数,接受引入的模块实例作为参数,并且在回调函数中使用这些功能

Require.js

在最开始学习的时候,我们把所有的代码都放在一个script标签中,但是后面代码越来越多,我们开始拆分代码然后依次加载,就出现了一个问题

<script src="hello1.js"></script><script src="hello2.js"></script><script src="hello3.js"></script><script src="hello4.js"></script><script src="hello5.js"></script>
​
...
​
​

在加载的时候浏览器会直接停止渲染,这里的文件数量越多意味着用户网页渲染停止的时间越长,第二就是如果这些js文件中有相互依赖,那么我们调换顺序的话,就会出现问题.而Require可以一定程度解决这个问题.

安装和使用

我们只需要去官网下载Require然后加载在最前面就行了.

<script src="js/require.js" defer async="true" ></script>
​
​

async属性表明这个文件需要异步加载,避免网页失去响应。IE不支持这个属性,只支持defer,所以把defer也写上。

剩下的使用方式和AMD差不多,就是使用回调函数来进行异步的操作,使用方法见上文AMD的导入导出即可.

这里实现了异步的加载,只有所有模块都加载成功才会运行,就可以避免依赖冲突问题啦.

使用注意:

如果说我们的main.js和其他的js文件没有在一个目录下的话,我们需要使用require.config()

如果我们的目录是这样的

requirejs-demo/ ├── index.html └── js/ ├── lib/ │ ├── require.js │ │ └── jquery.min.js ├── modules/ │ ├── math.js │ └── app.js └── main.js

在我们的main.js中就需要

// 配置 RequireJS
require.config({
    baseUrl: 'js',  // 基础路径
    paths: {
        'jquery': 'lib/jquery.min',  // jQuery 路径
        'math': 'modules/math',      // 数学模块路径
        'app': 'modules/app'         // 应用模块路径
    }
});
​
// 加载应用模块
require(['app'], function(app) {
    app.init();
});

不过现在我们也不常用这个就是了.

CMD

sea.js中实现了CMD(Common Module Definition) ,它整合了 CommonJS 和 AMD 的优点。

如果我们项目目录是这样的

seajs-demo/ ├── index.html └── js/ ├── sea.js ├── modules/ │ ├── math.js │ └── app.js └── main.js

<!DOCTYPE html>
<html>
<head>
    <title>Sea.js Demo</title>
</head>
<body>
    <h1>Sea.js 示例</h1>
    <div id="result"></div>
​
    <!-- 引入 sea.js -->
    <script src="js/sea.js"></script>
    <!-- 引入并执行主文件 -->
    <script>
        seajs.config({
            base: './js/',  // 基础路径
            alias: {
                'math': 'modules/math.js',
                'app': 'modules/app.js'
            }
        });
​
        seajs.use('main');
    </script>
</body>
</html>

以及main.js大概是这样的:

// 使用应用模块
define(function(require) {
    var app = require('app');
    app.init();
});

剩余就不具体实现了,大家大概能够明白怎么操作即可。

CMD最大的优势就是可以动态的加载模块,在AMD中我们加载模块往往是一开始全部下载完,就算我们不用这个模块但是导入了,一样需要花费时间去加载,而CMD中则可以支持动态的加载

define(function(require, exports, module) {
    var $ = require('jquery');
    
    exports.init = function() {
        // 点击按钮时动态加载模块
        $('#loadButton').click(function() {
            require.async('modules/heavy-module', function(heavyModule) {
                heavyModule.init();
            });
        });
    };
});

但是不管是AMD还是CMD都有一个很大的缺陷,就是它们不原生被es标准支持,都需要使用第三方库来管理,在那个遥远的年代,确实有人用,但是,随着esm的出现,它们也都慢慢淘汰了

ESM(重要!)

前面所说的几种模块化规范都必须在运行时才能确定依赖和输入输出,而 ESModule 的理念是在编译时就确定模块依赖的输入输出。

⭐ CommonJS 和 ESModule 规范对比:

  • CommonJS 模块输出的是值的拷贝,ESM 模块输出的是值的引用
  • CommonJS 模块是运行时加载,ESM 模块是编译时输出接口。
  • CommonJS 是单个对象导出,多次导出会覆盖之前的结果;ESM 可以导出多个。
  • CommonJS 模块是同步加载,ESM 支持异步加载
  • CommonJS 的 this 是当前模块,ESM 的 this 是 undefined。

现在大多数浏览器中默认的模块化规范都是 ESM 了,作为一种规范它已经比较成熟了,但是我们在浏览器模块化问题上仍有一些问题未能解决:

  • 浏览器没有模块管理能力,模块分散在各个项目中无法复用。
  • 性能加载慢,大型项目中无法直接使用。
export关键字

export关键字将一个模块中的变量、函数、类等导出。

我们希望将其他中内容全部导出,它可以有如下的方式

方式一:在语句声明的前面直接加上export关键字。

export const name ="lanshan"
export const age = 19

方式二:将所有需要导出的标识符,放到export后面的 {}

const name ="lanshan"
const age =18
function Hello(){
console.log(Hello!)
}
export{
name,
age,
Hello
}

方式三:导出时给标识符起一个别名。(基本没用,一般在导入文件中起别名)。然后在导入文件中就只能使用别名来获取。

export{
name as Name,
age as Age,
hello as Hello
}
import关键字

import关键字负责从另外一个模块中导入内容。

注意,使用import关键字导入的时候不能把导入放到逻辑中

if(true) {
​
        import foo from './foo.js'
​
    }
​

这样是不被允许的

这是因为ES Module在被JS引擎解析时,就必须知道它的依赖关系。

由于这个时候js代码没有任何的运行,所以无法在进行类似于if判断中根据代码的执行情况。

导入内容的方式也有多种:

  • 方式一:import {标识符列表} from '模块'。
import { name, age,hello } from "./Hello.js"
  • 方式二:导入时给标识符起别名。
import { 
    name as Name,
age as Age,
hello as Hello } from './Hello.js'
  • 方式三:通过 * 将模块功能放到一个模块功能对象(a module object)上。然后通过起别名来使用。
    import * as foo from './foo.js'

如果一定要使用动态加载,就用import函数(不是关键字!!!)

以下例子就是通过了一个异步函数配合import函数来实现的动态加载

loadBtn.addEventListener('click', async () => {
    try {
        // 动态导入模块
        const module = await import('./modules/hello.js');
        
        // 使用模块中的函数
        const message = module.sayHello('张三');
        result.textContent = message;
    } catch (error) {
        result.textContent = '模块加载失败:' + error;
    }
});
export和import的组合

eg:

import { 
name as Name,
age as Age,
hello as Hello } from './Hello.js'
import{
    hey as Hey,
    bye as Bye
 } from './Bye.js'

有些人就会疑惑为什么会有这种操作呢?直接导入到我需要的地方不久可以了吗?

因为在我们使用的时候,我们往往希望有一个统一的出口和入口,方便我们管理以及有一个统一的管理,也更有利于我们的封装,所以就会出现这样的组合。

default用法:

还有一种导出就是我们的默认导出,我们可以自由的给默认导出取名来使用

eg:

 // foo.js
​
    const name = "lanshan"
​
    const age = 18 
​
    export {
​
      name,
​
    }
   export default age
import foo, {name} from './foo.js'
console.log(foo, name) // 18 zh
  • 默认导出export时可以不需要指定名字。
  • 在导入时不需要使用 {},并且可以自己来指定名字。

额外参考:

学习前端工程化1️⃣——前端工程化与模块化新人前端往往在学习过程中由于找不到体系化的工程化课程,导致对这一块的理解很浅薄 - 掘金

前端模块化:CommonJS,AMD,CMD,ES6模块化的开发方式可以提高代码复用率,方便进行代码的管理。通常一个文件 - 掘金

「前端工程四部曲」模块化的前世今生(上)在日益复杂和多元的Web业务背景下,前端工程化这个概念经常被提及。“说说你对We - 掘金