前端模块化

92 阅读9分钟

前端模块化的必要性

为什么需要模块化

当前端工程到达一定规模后,就会出现下面的问题:

  • 全局变量污染

  • 依赖混乱

模块化就是为了解决这些问题而出现的

模块化出现后,我们就可以把复杂的代码细分到各个文件中,便于后期的维护和管理

前端模块化标准

前端主要有两大模块化标准:

  • CommonJS,简称CJS,这是一个社区规范,出现时间较早,目前仅Node环境支持
  • ES Module,简称ESM,这是随着ES6发布的官方模块化标准,目前浏览器和新版本Node环境均支持(但仅限于在.mjs文件中使用)

CommonJS

CommonJS,简称CJS,这是一个社区规范,出现时间较早,目前仅Node环境支持

CommonJS使用module.exports来导出模块,使用require来导入模块

nodejs天生就支持CommonJS规范:

  1. node将每个js文件都视为一个模块

  2. node每次只能运行一个js文件,该js文件称为入口文件(入口模块)

  3. 模块中所有在全局定义的变量、函数,均不会在全局对象上形成属性,自然也不会污染到其他模块

  4. 使用module.exports来导出模块,使用require来导入模块

    module.exports默认为一个空对象,因此如果没有在模块中导出任何内容,其它模块所接收到的就是一个空对象

    // other.js
    
    module.exports = {
        a: 1,
        b: function(){}
    }
    
    // index.js
    
    var res = require("./other.js");
    
    console.log(res);
    
    /**
    	res = {
    		a: 1,
    		b: function(){}
    	}
    */
    
  5. 使用require导入自定义模块时,若传入的是相对路径,则必须以./../开头,相对路径是相对于该require函数所在模块的路径

    若相对路径省略了./../开头,则nodejs就会到当前模块所在目录中的node_modules目录中查找模块,若没有就返回上一级目录,去查找该目录中的node_modules目录

    查找node_modules目录时,会按照第6点的① ~ ⑤的顺序进行查找

  6. 导入模块时,可以省略.js后缀

    var a = require("./a");
    

    以上面的导入语句为例,nodejs会按照下面的规则查找模块:

    ① 查看模块所在目录中是否有a文件,若有就执行该文件,否则进入下一步

    ② 查看模块所在目录中是否有a.js文件,若有就执行该文件,否则进入下一步

    ③ 查看模块所在目录中是否有a.json文件,若有就执行该文件,否则进入下一步

    ④ 查看模块所在目录中是否有a.node文件,若有就执行该文件,否则进入下一步

    ⑤ 查看模块所在目录中是否有a目录

    ​ 若有就将其视为一个包,并读取其package.json文件,然后读取其中main配置来找到包的入口文件,如果存在main配置所对应的入口文件,则执行该文件

    ​ 若a目录中不是一个包,或者没有main字段所指定的入口文件,则查找目录下的index.js、index.json、index.node文件,若有文件存在就执行该文件,否则进入下一步

    ⑥ 抛出Cannot find module './a'错误

细节

  1. require可以在任何地方出现,包括判断或函数中,没有强制规定必须书写在模块的开头

  2. require是同步执行的,只有当运行到require时,才会去加载并执行该模块

    模块加载并执行完成后,require函数才算执行结束,而模块导出的内容就会作为require的返回值返回,之后nodejs便继续执行require函数后面的代码

  3. require可以出现在判断中,导入的路径字符串也可以动态改变,因此require是动态的,只有代码实际运行后才能推断出require究竟导入的是哪个模块

  4. require导入一个模块时,会通过模块路径找到本机文件,然后读取文件内容并执行,由于模块就在本机,因此读文件的速度比较快,就算require是同步执行的,也不会造成卡顿

  5. 为了避免重复加载同一个模块,nodejs默认开启了模块缓存(可以手动关闭),如果要导入的模块之前已经被导入过了,则会自动使用之前的导出结果,而不会重复导入

    var util1 = require("./utils.js");		// 加载本地文件并执行其中的代码
    var util2 = require("./utils.js");		// 不加载也不执行
    console.log(util1 === util2);				// true
    
  6. 当nodejs准备执行一个模块时,会自动对该模块进行如下处理:

    ① 将模块中的代码放入到一个函数环境中,以保证模块中的“全局变量”或“全局函数”不会污染全局

    ② 初始化module.exports = {}

    ③ 将全局的this绑定为module.exports

    ④ 声明了一个变量exports,并让exports = module.exports

    ⑤ 将module.exports作为函数的返回值返回,这也将作为该模块所导出的结果

    function(module){
        module.exports = {};
        this = module.exports;
        var exports = module.exports;
        
        // 模块中的代码
        
        return module.exports;
    }
    // 当该模块被另一个模块require时,实际上就是在另一个模块中执行这里的这个函数
    

    由于模块导出的是module.exports,因此对module.exports重新赋值,导出结果就变为了重新赋的值,而对exports重新赋值,导出结果则不受影响

    更详细的代码说明参见CommonJS的实现原理

  7. 当require导入的是json文件时,会自动将文件中的json内容解析为js对象,并作为函数返回值返回

ES Module

ES Module,简称ESM,这是伴随着ES6发布的官方模块化标准,目前浏览器和新版本Node环境均支持

在浏览器环境下,html文件中使用script元素引入的JS文件默认不是一个模块,要想将其作为模块使用,需要在script元素上添加type="module"属性

<script type="module" src="./index.js"></script>

这种方式引入的js文件,就成为了一个模块,模块中所有顶级变量、顶级函数均不会成为window对象的属性

在HTML页面中引入的模块,称为入口模块

导入和导出

导入使用import,导出使用export

注意:只有在模块中才允许使用import和export

ESM的导入导出分为两种:

  1. 具名导入导出
  2. 默认导入导出

具名导出

具名导出可以导出多个内容,并且每个内容都必须要有名称

export 声明表达式					// 这里声明的内容在模块的其他地方也能正常使用
export { 标识符 }					 // 这里的{}可不是对象
export { 原始名称 as 新名称 }		  // 其它模块在导入时,就需要使用新名称来对应这里的内容

例:

export var a = 10;
export let b = 20;
export const c = 30;
export function d(){}
export class E {}

var f = 40;
var g = 50;
export { f, g as temp }

默认导出

默认导出只能导出一个内容,因此不需要为其命名

export default 内容

export { 标识符 as default }

例:

export default 1;

export default function (){};

var a = 2;
export default a;

export { a as default }

默认导出和具名导出可以在同一条导出语句中使用:

var a = 10;
var b = 20;

export { a, b as default }

// a为具名导出的内容,b为默认导出的内容

具名导入

具名导入用于导入其他模块具名导出的内容

import { 标识符列表 } from "路径"				// 这里的{}可不是对象,也不是解构符号
import { 标识符 as 新名称 } from "路径"			// 使用时,应该使用新名称

例:

import { a, b, temp as g } from "/module.js"

默认导入

默认导入用于导入其他模块默认导出的内容

import 接收名称 from "/module.js"

import { default as 接收名称 } from "/module.js"

例:

import a from "/module.js"

import { default as a } from "/module.js"

默认导入可以和具名导入在同一个导入语句中使用:

import a, { b, c } from "/module.js"

import { default as a, b, c } from "/module.js"

// a为默认导入的内容,b、c为具名导入的内容

导入所有内容

import * as obj from "./module.js"

将模块中导出的所有内容(包括具名导出和默认导出),并用obj接收,此时接收的所有内容会聚合为一个Module对象,具名导出的内容会成为该对象中的同名属性,默认导出的内容会成为该对象的default属性

仅运行模块

import "./module.js"

仅仅运行一遍module.js,而不使用它所导出的任何内容

这种方式导入的模块同样会被缓存

动态导入

import("./module.js")

将import作为一个函数使用,传入模块的路径,即可动态导入该模块,该函数的返回值为一个Promise,当模块加载并执行结束后Promise完成,相关数据是模块所导出的所有内容所形成的Module对象(类似于* as obj

动态导入可以出现在模块中的任何地方,包括判断或函数体中

细节

  1. import的导入路径如果是相对路径,则必须以./../开头

    如果是不完整的绝对路径,则必须以/开头

    相对路径相对的是模块的路径,不是HTML页面的路径

  2. 文件名必须加上后缀.js

  3. ES Module有模块缓存,不会再次执行同一个模块

  4. 静态导入所接收的符号为常量,不可以被重新赋值

  5. 静态的导入导出语句,必须为顶级代码,不能出现在{}

  6. 静态导入的语句,建议书写在模块的开头

    即使没有书写在模块开头,浏览器也会在代码编译时自动将它们移动到开头

  7. 静态导入的模块路径必须是一个字符串常量,不可以在此放一个标识符

  8. 带有type="module"的script,同带有defer一样,其引用的js文件会在HTML解析完毕后执行

    如果网页有多个<script type="module">,它们会按照在页面出现的顺序依次执行

  9. 模块内部使用静态导入的其他模块是同步加载的,当静态导入的模块加载并执行完成后,其后面的代码才能够执行

  10. 模块内部的代码是在严格模式下执行的

  11. Module对象中的属性是只读的,不允许更改,也不允许向Module对象中添加或删除属性

阮一峰ES6模块化手册

模块化中的更多细节

CommonJS的实现原理

function require(path) {
    if(该模块有缓存) {
        return 缓存结果;
    }
    
    function _run(exports, require, module, __filename, __dirname) {
		// 模块代码会被放到这里
    }
    
    var module = {
        exports: {}
    };
    
    _run.call(
        module.exports,
        module.exports,
        require,
        module,
        模块的完整绝对路径,
        模块所在目录的完整绝对路径
    );
    
    把 module.exports 加入到缓存中;
    
    return module.exports;
}

ES Module的符号绑定

符号绑定是指两个模块之间使用的变量使用的是同一个内存空间(址传递),一个模块对变量的值进行变化时,另一个模块的变量值也会发生变化

// a.js
export let count = 0;
export function increase() {
    count++;
}
// index.js
import { count, increase } from "/a.js";

console.log(count);		// 0
increase();
console.log(count);		// 1