前端模块化

模块化开发最终的目的是将程序划分为一个个小的结构,

每个结构中编写属于直接的逻辑代码,有自己的作用域,定义变量时不会影响到其他结。

在网页开发的早期,Brendan Eich开发JavaScript仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的:

这个时候我们只需要将JavaScript代码写到<script>标签中即可,没有必要放到多个文件中来编写;

早期甚至流行将 JavaScript 程序的长度规定为一行。

image-20230524235406315

上例中,utils.js和main.js都被嵌入到index.html中,此时utils.js中定义的变量和函数,在main.js是可以直接调用的。

这就是没有模块化的结果,所有js代码的作用域相同。

这跟将 JavaScript 代码写在同一个文件没有本质上的区别。

早期网站,由于功能简单,这样的设计并不会出现什么问题。

但是随着前端和JavaScript的快速发展,JavaScript代码变得越来越复杂了:

  • ajax的出现,前后端开发分离,意味着后端返回数据后,我们需要通过JavaScript进行前端页面的渲染;
  • SPA(Single Page Application)的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过JavaScript来实现;
  • Node的实现,JavaScript编写复杂的后端程序,没有模块化是致命的硬伤;

所以,模块化已经是JavaScript一个非常迫切的需求:

但是JavaScript本身,直到ES6(2015)才推出了自己的模块化方案;

在此之前,为了让JavaScript支持模块化,涌现出了很多不同的模块化规范:AMD、CMD、CommonJS等;

01 ConmonJS

CommonJS是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,(因为主要在服务端使用)

后来为了体现它的广泛性,修改为CommonJS,平时我们也会简称其为CJS。

  • Node是CommonJS在服务器端一个具有代表性的实现;

  • Browserify是CommonJS在浏览器中的一种实现;(现已逐渐淘汰)

  • webpack打包工具具备对CommonJS的支持和转换;

Node中对CommonJS进行了支持和实现,让我们在开发node的过程中可以方便的进行模块化开发:

  • 在Node中,每一个js文件都是一个单独的模块;

  • 每个模块包括CommonJS规范的核心变量:exports、module.exports、require;

模块化的核心是导出和导入,Node对其进行了实现:

  • exports和module.exports可以负责对模块中的内容进行导出;

  • require函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;


1.1 exports案例

utils.js:

const UTILS_NAME = 'utils_name'

function f1() {
    return '我是函数f1'
}

function f2() {
    return '我是函数f2'
}

exports.name=UTILS_NAME
exports.f1=f1
exports.f2=f2

main.js:

const utils = require("./utils")

console.log(utils.name)
console.log(utils.f1())
console.log(utils.f2())

// 执行main.js,输出
// utils_name
// 我是函数f1
// 我是函数f2

在main.js中对utils.js进行导入,也可以直接对对象进行结构,

const {name, f1, f2} = require("./utils")


console.log(name)
console.log(f1())
console.log(f2())

exports与require本质:

exports其实是一个对象,我们可以在这个对象中添加很多个属性,添加的属性会导出;

而require实现的效果是:在main.js中,utils变量就等于exports对象。

(require通过各种查找方式,最终找到了exports这个对象,并且将这个exports对象赋值给了utils变量)

1.2 module.exports

在Node开发中,我们导出东西的时候,经常是通过module.exports导出的,

以下的代码和上例实现效果是一样的。

utils.js:

const name = 'zibuyu'
const age = 18

function sayHello() {
    console.log('Hello')
}

module.exports.name = name
module.exports.age = age
module.exports.sayHello = sayHello

main.js:

const {name, age, sayHello} = require("./utils")


console.log(name)
console.log(age)
sayHello()

module.exports和exports有什么关系或者区别呢?

追根溯源,原因在于CommonJS规范。CommonJS中有exports概念,但是没有module.exports概念。

而Node一开始的设计并不符合CommonJS规范。

但为了实现模块的导出,Node中使用的是Module的类,每一个模块都是Module的一个实例,也就是module;

所以在Node中真正用于导出的是module.exports;因为module才是导出的真正实现者。

为了让Node符合CommonJS规范,

exports和module.exports做了一个绑定:

module对象的exports属性是exports对象的一个引用,两者的底层内存是一样的。

也就是说 module.exports = exports。

注意,以下的导出方式与上例是有区别的,实际开发中常常使用这一种导出方式:

const name = 'zibuyu'
const age = 18

function sayHello() {
    console.log('Hello')
}

module.exports = {
    name,
    age,
    sayHello,
}

区别在于:module.exports = {}将module.exports指向了一个新的对象。

在底层中,另辟了一块内存用来存放变量。

这样,module.exports与exports对象就再没有引用关系了。

1.3 require

require是一个函数,可以帮助我们引入一个文件(模块)中导出的对象。

那么,require的查找规则是怎么样的呢?

我们来总结一下常见的查找规则:

导入格式如下:require(X)

情况一:X是一个Node内置模块,比如path、http。直接返回该模块,并且停止查找。

// node跟python类似,自带了很多模块。使用require可以直接导入这些模块。

const path = require('path')
const path = require('http')

情况二:X是以 ./ 或 ../ 或 /(根目录)开头的

  • 第一步:将X当做一个文件,在当前目录下进行查找;

    1. 如果有后缀名,按照后缀名的格式查找对应的文件

    2. 如果没有后缀名,会按照如下顺序:

      1. 直接查找文件X
      2. 查找X.js文件
      3. 查找X.json文件
      4. 查找X.node文件
  • 第二步:没有找到对应的文件,将X作为一个目录,查找该目录下面的index文件

    1. 查找X/index.js文件

    2. 查找X/index.json文件

    3. 查找X/index.node文件

    4. 如果没有找到,那么报错:not found

情况三:X并不是内置模块,也不是路径。直接是第三方模块。

  • 在当前目录下查找node_modules文件夹,(这个文件名是固定的),继而在node_modules文件夹中查找X;
  • 如果在当前目录下查找不到node_modules文件夹,则去上一层目录中查找,知道根目录。

实际上,通过npm install 模块名去安装模块时,就是将模块下载存放到node_modules文件夹中的。

1.4 模块的加载过程

  • 结论一:模块在被第一次引入时,模块中的js代码会被运行一次;

  • 结论二:模块被多次引入时,并不会运行多次;

  • 结论三:循环引入,根据图结构进行搜索。

每个模块对象module都有一个属性:loaded

为false表示还没有加载,为true表示已经加载;

第一次引入时,loaded便会被设置为true,所以此后的引入都不会再运行。

循环引入案例:

下图模块的引用关系,其加载顺序是什么?

image-20230524235423097

这个其实是一种数据结构:图结构;

图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search);

Node采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee ->bbb

1.5 CommonJS规范的缺点

CommonJS最大的确定是,其加载模块是同步的。

同步的意味着,只有等到对应的模块加载完毕,当前模块中的内容才能被运行;

同步加载,在服务器一般不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快;

但在浏览器端,这一点缺陷就非常明显。

浏览器加载js文件都是需要先从服务器上,将文件下载下来,之后再加载运行;

采用同步加载,就意味着后续的js代码都无法正常运行,即使是一些简单的DOM操作;

所以在浏览器中,通常不使用CommonJS规范。

在早期为了可以在浏览器中使用模块化,通常会采用AMD或CMD:

但目前AMD和CMD已经逐渐被淘汰了。

一方面现代的浏览器已经支持ES Modules,

另一方面借助于webpack等工具也可以实现对CommonJS或者ES Module代码的转换。

webpack会将所有的js文件整合成一个,浏览器端只需要下载一个js文件即可;

而如果整合的js文件过大,webpack也支持分包的操作。

02 AMD规范

如前所述,AMD主要是应用于浏览器的一种模块化规范:

AMD是Asynchronous Module Definition(异步模块定义)的缩写;

它采用的是异步加载模块;

事实上,AMD规范的出现还要早于CommonJS;

AMD比较常用的库是require.js和curl.js;

  • 第一步:下载require.js

下载地址:https://github.com/requirejs/requirejs

找到其中的require.js文件;

  • 第二步:定义HTML的script标签引入require.js和定义入口文件:
<script src="./lib/require.js" data-main="./index.js"></script>

data-main属性的作用是在加载完src的文件后,会立即加载执行该文件

03 CMD规范

CMD 是Common Module Definition(通用模块定义)的缩写;

它也是采用异步加载模块,但是它将CommonJS的优点吸收了过来;

CMD也有自己比较优秀的实现方案:SeaJS。

其使用与AMD规范非常类似。

  • 第一步:下载SeaJS

下载地址:https://github.com/seajs/seajs

找到dist文件夹下的sea.js

  • 第二步:引入sea.js和使用主入口文件

seajs是指定主入口文件的

image-20230524235436547

04 ES Module

JavaScript没有模块化一直是它的痛点,所以才会产生社区规范:CommonJS、AMD、CMD等,

在2015年,ECMA推出自己的模块化系统时,大家也是兴奋异常。

ES Module和CommonJS的模块化有一些不同之处:

  • 使用了import和export关键字;

    • export负责将模块内的内容导出;
    • import负责从其他模块导入内容;
  • 采用编译期的静态分析,并且也加入了动态引用的方式;

注意,ES Module使用的导出关键字是export,而Common JS使用的导出关键字是exports。

4.1 基本使用

在HTML中,只需要在引入js文件时,添加type="module"属性,即可标记该js文件为模块。

<script src="./modules/foo.js" type="module"></script>
<script src="main.js" type="module"></script>

4.1.1 使用export导出

util.js:

const name = 'zibuyu'
const age = 18

function sayHello() {
    console.log('Hello')
}

export {
    name,
    age,
    sayHello,
}

注意,此处export后面跟上的{}并不是一个对象,它是ES Module 的语法。

与Common JS的module.exports新建对象导出有本质的区别。

main.js:

import {name, age, sayHello} from "./utils.js"


console.log(name)
console.log(age)
sayHello()

在ide运行js代码是,可能会以下报错:

(node:10812) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.

解决办法:

1、安装新版node.js
2、使用npm init -y初始化项目,生成一个pakeage.json文件
3、在pakeage.json文件中添加"type": "module"

4.1.2 在定义时导出

ES Module支持在定义变量时,直接将该变量导出。

export const name = 'zibuyu'
export const age = 18

export function sayHello() {
    console.log('Hello')
}

4.1.3 导出时取别名:

utils.js

const name = 'zibuyu'
const age = 18

function sayHello() {
    console.log('Hello')
}

export {
    name as utils_name,
    age  as utils_age,
    sayHello,
}

main.js

import {utils_name, utils_age, sayHello} from "./utils.js"


console.log(utils_name)
console.log(utils_age)
sayHello()

4.1.4 导入时起别名

ES Module支持在导入模块时,对变量取别名。

import {name as u_name, age as u_age, sayHello} from "./utils.js"


console.log(u_name)
console.log(u_age)
sayHello()

4.1.5 导入整个模块

import * as util from "./utils.js"

console.log(util.name)
console.log(util.age)
util.sayHello()

4.1.6 综合使用

在实际开发中,往往会有很多的模块,逐一导入会显得非常臃肿。

常见的做法是,在存放模块的文件夹中,新建一个index.js文件,在此文件中导入其他模块。

最后,再将index.js中的函数与变量进行统一的导出。

utils/hello.js

export function sayHello() {
    console.log('Hello!')
}

utils/hi.js

export function sayHi() {
    console.log('Hi!')
}

utils/index.js

import {sayHello} from "./hello.js";
import {sayHi} from "./hi.js";

export {
    sayHello,
    sayHi
}

最后,在需要导入模块的地方,导入index.js即可:

import {sayHi,sayHello} from "./utils/index.js"

sayHello()
sayHi()

utils/index.js文件作为一个中转站,导入模块后再统一导出,也有更简洁的语法:

export {sayHello} from "./hello.js";
export {sayHi} from "./hi.js";

// 或者
export * from "./hello.js";
export * from "./hi.js";

4.2 default用法

前面学习的导出功能都是有名字的导出(named exports);

  • 在导出export时指定了名字;

  • 在导入import时需要知道具体的名字;

还有一种导出叫做默认导出(default export)

  • 默认导出export时可以不需要指定名字;

  • 在导入时不需要使用 {},并且可以自己来指定名字;

  • 它也方便我们和现有的CommonJS等规范相互操作;

注意:在一个模块中,只能有一个默认导出(default export);

default.js:

function parseLyric() {
    return ['解析好的歌词']
}

export default parseLyric

// 更简洁的写法
export default function () {  // 由于一个模块只能由一个默认导出,此处也可以省略函数名
    return ['解析好的歌词']
}

main.js:

import aa from './default.js'  // 其中,‘aa’为任意起的名字

console.log(aa())

4.3 import函数

通过import加载一个模块,是不可以在其放到逻辑代码中的,比如:

if (true){
    import {sayHi,sayHello} from "./utils/index.js"
    sayHello()
    sayHi()
}
// 不支持这种写法

但是某些情况下,我们确确实实希望动态加载某一个模块,

这个时候我们需要使用 import() 函数。

import函数返回一个Promise,可以通过then获取结果;

let flag = true

if (flag){
    const importPromise = import('./utils/index.js')
    importPromise.then(res=>{
        res.sayHello()
        res.sayHi()
    })
}

简洁写法:

let flag = true

if (flag){
    import('./utils/index.js').then(res=>{
        res.sayHello()
        res.sayHi()
    })
}

在ES11(ES2020)中新增了一个特性import.meta;

import.meta是一个给JavaScript模块暴露特定上下文的元数据属性的对象。

它包含了这个模块的信息,比如说这个模块的URL。

4.4 ES Module的解析过程

ES Module是如何被浏览器解析并且让模块之间可以相互引用的呢?

官方文档:https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/

ES Module的解析过程可以划分为三个阶段:

  • 阶段一:构建(Construction),根据地址查找js文件,并且下载,将其解析成模块记录(Module Record);

  • 阶段二:实例化(Instantiation),对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址;

  • 阶段三:运行(Evaluation),运行代码,计算值,并且将值填充到内存地址中;

image-20230524235448518

阶段一:构建阶段

image-20230524235453326

生成模块记录:

image-20230524235501038

阶段二与阶段三:实例化阶段与求值阶段

image-20230524235506031

posted @ 2023-05-24 23:56  子不语2015831  阅读(63)  评论(0)    收藏  举报