Skip to content

一. 认识模块化开发


1. 什么是模块化?

  • 到底什么是模块化、模块化开发呢?
    • 事实上模块化开发最终的目的是将程序划分成一个个小的结构
    • 这个结构中编写属于自己的逻辑代码,有自己的作用域,定义变量名词时不会影响到其他的结构
    • 这个结构可以将自己希望暴露的变量、函数、对象等导出给其结构使用
    • 可以通过某种方式,导入另外结构中的变量、函数、对象等
  • 上面说提到的结构,就是模块;按照这种结构划分开发程序的过程,就是模块化开发的过程
  • 无论你多么喜欢 js,以及它现在发展的有多好,它都有很多的缺陷:
    • 比如 var 定义的变量作用域问题
    • 比如 js 的面向对象并不能像常规面向对象语言一样使用 class
    • 比如 js 没有模块化的问题
  • 对于早期的 js 没有模块化来说,确确实实带来了很多的问题

2. 模块化的历史

  • 在网页开发的早期,Brendan Eich 开发 js 仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的:
    • 这个时候我们只需要将 js 代码写到 <script> 标签中即可
    • 并没有必要放到多个文件中来编写;甚至流行:通常来说 js 程序的长度只有一行
  • 但是随着前端和 js 的快速发展,js 代码变得越来越复杂了:
    • ajax 的出现,前后端开发分离,意味着后端返回数据后,我们需要通过 js 进行前端页面的渲染
    • SPA 的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过 js 来实现
    • 包括 Node 的实现,js 编写复杂的后端程序,没有模块化是致命的硬伤
  • 所以,模块化已经是 js 一个非常迫切的需求:
    • 但是 js 本身,直到 ES6(2015) 才推出了自己的模块化方案
    • 在此之前,为了让 js 支持模块化,社区涌现出了很多不同的模块化规范:AMDCMDCommonJS
  • 在我们的课程中,将详细讲解 js 的模块化,尤其是 CommonJSES6 的模块化

3. 没有模块化带来的问题

  • 早期没有模块化带来了很多的问题:比如多个文件中的命名冲突的问题

    • 文件本身是不存在作用域的,引入的文件,其实都是在一个全局作用域中的
    image-20220722140522222
  • 当然,我们有办法可以解决上面的问题:立即函数调用表达式(IIFE

    • IIFE (Immediately Invoked Function Expression)
  • 但是,我们其实带来了新的问题:

    • 第一,必须记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用

    • 第二,代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写

    • 第三,在没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况

      image-20220722140044982
  • 所以,我们会发现,虽然实现了模块化,但是我们的实现过于简单,并且是没有规范的

    • 我们需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码
    • 这个规范中应该包括核心功能:模块本身可以导出暴露的属性,模块又可以导入自己需要的属性
    • js 社区为了解决上面的问题,涌现出一系列好用的规范,接下来我们就学习具有代表性的一些规范

二. CommonJS 和 Node


1. CommonJS 规范和 Node 关系

  • 我们需要知道 CommonJS 只是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为 ServerJS,后来为了体现它的广泛性,修改为 CommonJS,平时我们也会简称为 CJS

    • Nodecjs 在服务器端一个具有代表性的实现

    • Browserify 库是 cjs 在浏览器中的一种实现,主要是应用在服务器里面,浏览器本身是没有实现 CommonJS 规范的

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

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

    • Node 中每一个 js 文件都是一个单独的模块,而每个模块是有自己独立的作用域的
    • 这个模块中包括 cjs 规范的核心变量:exportsmodule.exportsrequire
    • 我们可以使用这些变量来方便的进行模块化开发
  • 前面我们提到过模块化的核心是导出和导入,Node 中对其进行了实现:

    • exportsmodule.exports 可以负责对模块中的内容进行导出
    • require 函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容

2. 模块化案例

  • 我们来看一下两个文件:

    js
    // a.js
    console.log(name)
    console.log(age)
    sayHello('kobe')
    js
    // b.js
    const name = 'later'
    const age = 18
    function sayHello(name) {
      console.log('hello ', name)
    }
    • a.js 必须导入 b.js
    • b.js 必须导出自己的内容

3. exports 导出

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

    js
    exports.name = 'hehe'
    exports.age = 18
  • a.js 文件中可以导入 b.js

    js
    const b = require('./b');
  • 上面这行完成了什么操作呢?理解下面这句话,Node 中的模块化一目了然

    • 意味着 a.js 中的 b 变量等于 exports 对象

    • 导入的对象内存地址指向的就是导出模块的 exports 对象,本质就是引用赋值

    • 也就是 require 通过各种查找方式,最终找到了 exports 这个对象

    • 并且将这个 exports 对象赋值给了 b 变量

    • b 变量就是 exports 对象了

      image-20220722164846941

4. module.exports 导出

  • 但是 Node 中我们经常导出东西的时候,又是通过 module.exports 导出的:

    • module.exportsexports 有什么关系或者区别呢?
  • node 中每个模块中都有一个 module 对象,module 对象中有一个属性是 exports

  • 我们追根溯源,通过维基百科中对 cjs 规范的解析:

    • cjs 中是没有 module.exports 的概念的

    • 但是为了实现模块的导出,Node 中使用的是 Module 的类(function Module(){...}),每一个模块都是 Module 的一个实例,也就是 module

    • 所以Node 中真正用于导出的其实根本不是 exports,而是 module.exports

    • 因为 module 才是导出的真正实现者

      image-20220722181440759
  • 但是,为什么 exports 也可以导出呢?

    • 这是因为 module 对象的 exports 属性是 exports 对象的一个引用

    • 也就是说 module.exports = exports = a.js 中的 bar

    • module.exportsexports、导入模块中的对象,这三者都是指向同一个内存地址的对象

      js
      console.log(exports === module.exports) // true
  • 所以如果使用 exports 导出对象时,只能通过添加属性的方式导出,如果修改内存地址,将无法在 require 时获取到

    js
    // a.js
    const b = require('./b')
    console.log(b) // {} 
    // 获取到的是空对象,因为b.js中修改了exports原本指向module.exports的内存地址,而真正用于导出的是module.exports,所以是无效的
    
    // b.js
    exports = { 
      name: 'b'
    }

5. 内存表现

image-20220722182153104
  • 导出实际导出的是 module.exports 这个属性所指向的值,而不是 exports 对象所指向的值
  • module.exportsexports 对象默认是指向同一个内存地址的(exports = module.exports)
  • 如果查看 require 的源码,可以得知:
    • 通过显式绑定模块中的 this,该 this 也是等于 module.exportsexports
    • 而每个 module 之所以有这些全局变量,如:__dirnameexports
    • 其内部是将 module 中的代码放入到一个函数中执行,而该函数是有指定 __dirname, exports 这些形参的

三. require 函数解析


  • require(id)

    • id<string> 模块名称或路径
    • 返回:<any> 导出的模块内容
  • 用于导入模块、JSON 和本地文件

  • 模块可以从 node_modules 导入

  • 可以使用相对路径导入本地模块和 JSON 文件(例如 ././foo./bar/baz../foo),该路径将根据 __dirname(如果有定义)命名的目录或当前工作目录进行解析

补充:

  • __dirname:当前模块的目录名
  • 这与 __filenamepath.dirname() 相同

1. require 文件查找细节

  • 我们现在已经知道,require 是一个函数,可以引入一个文件(模块)中导出的对象

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

    • 这里总结比较常见的查找规则
    • 导入格式如下:require(X)
  • 情况一:X 是一个 Node 核心模块,比如 pathhttp

    • 直接返回核心模块,并且停止查找

      js
      // 导入node提供给内置模块
      const path = require("path")
      const http = require("http")
  • 情况二:X 是以 ./..// (根目录)开头的

    • 第一步:将 X 当做一个文件在对应的目录下查找

      • 如果有后缀名,按照后缀名的格式查找对应的文件
      • 如果没有后缀名,会按照如下顺序:
        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 文件
    • 如果没有找到,那么报错:not found

      js
      // 根据路径导入自己编写模块
      const utils = require("./utils")
  • 情况三:直接是一个 X(没有路径),并且 X 不是一个核心模块

    • 先在当前目录查找 node_modules,没有再依次往上查找 node_modules 文件,直至根目录,还没有就报错:not found

    • node_modules 文件的话,就会去查找 node_modules 中是否存在 x 文件,存在的话,再去查找 x 文件下的 index.js 文件

      js
      const axios = require("axios")
    • 如果上面的路径中都没有找到,那么报错:not found

2. 模块的加载过程顺序的解析

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

  • 结论二:模块被多次引入时,会缓存,最终只加载(运行)一次

    • 为什么只会加载运行一次呢?
    • 这是因为每个模块对象 module 都有一个属性:loaded
    • 默认为 false 表示还没有加载,为 true 表示已经加载,模块加载过一次就会设置为 true
  • 结论三:如果有循环引入,那么加载顺序是什么?

    image-20220722192646554
    • 如果出现上图模块的引用关系,那么加载顺序是什么呢?
    • 这个其实是一种数据结构:图结构
    • 图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search
    • Node 采用的是深度优先算法main -> aaa -> ccc -> ddd -> eee -> bbb
    image-20220722193121733

四. AMD 和 CMD


1. CommonJS 规范缺点

  • cjs 加载模块是同步的
    • 同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行,也就是说后面导入的模块将会受到阻塞
    • 这个在服务器不会有什么问题,因为服务器加载的 js 文件都是本地文件,加载速度非常快
  • 如果将它应用于浏览器呢?
    • 浏览器加载 js 文件需要先从服务器将文件下载下来,之后再加载运行
    • 那么采用同步的就意味着后续的 js 代码都无法正常运行,即使是一些简单的 DOM 操作
  • 所以在浏览器中,我们通常不使用 cjs 规范:
    • 当然在 webpack 中使用 cjs 是另外一回事
    • 因为它会将我们的代码转成浏览器可以直接执行的代码
  • 在早期为了可以在浏览器中使用模块化,通常会采用 AMDCMD
    • 但是目前一方面现代的浏览器已经支持 ESModules,另一方面借助于 webpack 等工具可以实现对 CommonJS 或者 ESModule 代码的转换
    • AMDCMD 的使用已经非常少了

2. AMD 规范

  • AMD 主要是应用于浏览器的一种模块化规范
    • AMDAsynchronous Module Definition(异步模块定义)的缩写
    • 它采用的是异步加载模块
    • 事实上 AMD 的规范还要早于 cjs,但是 cjs 目前依然在被使用,而 AMD 使用的较少了
  • 我们提到过,规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用:
    • AMD 实现的比较常用的库是 require.jscurl.js

3. require.js 的使用

  • 第一步:下载 require.js

  • 第二步:定义 HTMLscript 标签引入 require.js 和定义入口文件:

    • data-main 属性的作用是在加载完 src 的文件后会加载执行该文件
    • <script src="./lib/require.js" data-main="./index.js"></script>
    image-20220722202406773

4. CMD 规范

  • CMD 规范也是应用于浏览器的一种模块化规范
    • CMDCommon Module Definition(通用模块定义)的缩写
    • 它也采用的也是异步加载模块,但是它将 CommonJS 的优点吸收了过来
    • 但是目前 CMD 使用也非常少了
  • CMD 也有自己比较优秀的实现方案:
    • SeaJS

5. SeaJS 的使用

  • 第一步:下载 SeaJS

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

    • seajs 是指定主入口文件的
    image-20220722202616954

五. ESModule 用法详解


1. 认识 ES Module

  • js 没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:cjsAMDCMD 等,所以在 ECMA 推出自己的模块化系统时,大家也是兴奋异常

  • ES Module (简称:ES) 和 cjs 的模块化有一些不同之处:

    • 一方面它使用了 importexport 关键字
    • 另一方面它采用编译期的静态分析,并且也加入了动态引用 import() 的方式
  • ES Module 模块采用 exportimport 关键字来实现模块化:

    • export 负责将模块内的内容导出
    • import 负责从其他模块导入内容

    注意:

    • 采用 ES Module 将自动采用严格模式:use strict

2. 案例代码结构组件

  • 这里是在浏览器中演示 ES6 的模块化开发:

    html
    <!--
    	注意事项一: 在浏览器中直接使用esmodule时, 必须在文件后加上后缀名.js
    	import { name, age, sayHello } from "./foo.js"
    -->
    <!-- 注意事项二: 在我们打开对应的html时, 如果html中有使用模块化的代码, 那么必须开启一个服务来打开 -->
    <!-- type="module" 就是告诉浏览器这是一个模块化的文件,有自己的作用域,浏览器就会当成一个模块去解析 -->
    <script src="./foo.js" type="module"></script>
    <script src="./main.js" type="module"></script>
  • 如果直接在浏览器中运行代码,会报如下错误:

    image-20220722211222554
  • 这个在 MDN 上面有给出解释:

  • 我们这里使用的 VSCode 插件:Live Server

3. export 关键字

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

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

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

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

      注意:

      • 这里的 {} 里面不是ES6的对象字面量的增强写法,{}也不是表示一个对象的,就是一个特殊的写法
      • 所以:export {name: name},是错误的写法
    • 方式三:导出时给标识符起一个别名

      • 通过 as 关键字起别名
      js
      // 3.导出方式三: 这种方式不能起别名
      export const name = "why"
      export const age = 18
      export function sayHello() {
        console.log("sayHello")
      }
      export class Person {}
      
      // 1.导出方式一: 
      export {
      	name,
        age,
        sayHello
      }
      
      // 2.导出方式二: 导出时给标识符起一个别名
      export {
      	name as fname,
        age,
        sayHello
      }

4. import 关键字

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

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

    • 方式一:import {标识符列表} from '模块'

      注意:

      • 这里的{}也不是一个对象,里面只是存放导入的标识符列表内容
      • 浏览器中导入的模块需要加后缀名,否则会找不到对应的文件而报错,而webpack中不需要,会自动添加的
    • 方式二:导入时给标识符起别名

      • 通过 as 关键字起别名
    • 方式三:通过 * 将模块功能放到一个模块功能对象(a module object)上

      js
      // 1.导入方式一: 
      import { name, age, sayHello } from "./foo.js"
      // 2.导入方式二: 导入时给标识符起别名
      import { name as fname, age, sayHello } from "./foo.js"
      // 3.导入时可以给整个模块起别名
      import * as foo from "./foo.js"
      
      const name = "main"
      console.log(name)
      console.log(foo.name)
      console.log(foo.age)
      foo.sayHello()

5. export 和 import 结合使用

  • 补充:exportimport 可以结合使用

    • 特殊语法:export {...} from '...'

      js
      import { formatCount, formatDate } from './format.js'
      import { parseLyric } from './parse.js'
      
      export {
      	formatCount,
        formatDate,
        parseLyric
      }
      
      // 等价于上面写法 优化一
      export { formatCount, formatDate } from './format.js'
      export { parseLyric } from './parse.js'
      
      // 等价于上面写法 优化二
      export * from './format.js'
      export * from './parse.js'
  • 为什么要这样做呢?

    • 在开发和封装一个功能库时,通常我们希望将暴露的所有接口放到一个文件中
    • 这样方便指定统一的接口规范,也方便阅读
    • 这个时候,我们就可以使用 exportimport 结合使用

6. default 用法

  • 前面我们学习的导出功能都是有名字的导出(named exports):

    • 在导出 export 时指定了名字
    • 在导入 import 时需要知道具体的名字
  • 还有一种导出叫做默认导出(default export)

    • 默认导出 export 时可以不需要指定名字
    • 在导入时不需要使用 {},并且可以指定任意名字
    • 它也方便我们和现有的 cjs 等规范相互操作

    注意:

    • 在一个模块中,只能有一个默认导出
    js
    // 1.默认的导出:
    // 1.1. 定义函数
    function parseLyric() {
      return ["歌词"]
    }
    
    const name = 'hello esModule'
    export {
    	parseLyric,
      name
    }
    
    // 1.2.默认导出
    export default parseLyric
    
    // 2.定义标识符直接作为默认导出
    export default function() {
      return ["新歌词"]
    }
    // 可以允许别的导出,但是导入的时候需要使用import {},而默认导出是不需要{}
    export {
    	name
    }
    
    import parseLyric from "./parse_lyric.js"
    import { name } from './parse_lyric.js'
    
    console.log(parseLyric()) // ['新歌词']
    console.log(name) // hellow esModule

7. import() 函数

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

    js
    let flag = true
    if (flag) {
      // 不允许在逻辑代码中编写import导入声明语法, 只能写到js代码顶层
      import { name, age, sayHello } from "./foo.js" 
    }
  • 为什么会出现这个情况呢?

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

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

    • 甚至拼接路径的写法也是错误的:因为我们必须到运行时能确定 path 的值

      js
      import { name, age, sayHello } from "./foo" + ".js" // 报错不被允许
  • 但是某些情况下,我们确确实实希望动态的来加载某一个模块:

    • 如果根据不同的条件,动态来选择加载模块的路径

    • 这个时候我们需要使用 import() 函数来动态加载

      • import 函数返回一个 Promise,可以通过 then 获取结果
      js
      // foo.js
      // export const name = "foo"
      // export const age = 18
      
      let flag = true
      if (flag) {
        import("./foo.js").then(res => {
          console.log(res.name, res.age)
        })
      }

8. import meta

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

    • 它包含了这个模块的信息,比如说这个模块的 URL
    • ES11(ES2020) 中新增的特性
    js
    console.log(import.meta) 
    // {url: 'http://127.0.0.1:5500/02_%E5%89%8D%E7%AB%AF%E6%A8%…%E5%8C%96%E5%BC%80%E5%8F%91/10_ESModule_05/foo.js'}

六. ESModule 运行原理


1. ES Module 的解析流程

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

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

    • 阶段一:构建(Construction),根据地址查找并下载 js 文件,将其解析成模块记录(Module Record
    • 阶段二:实例化(Instantiation),对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址
      • 个人理解:也就是说 a 模块被多个地方引入,a 模块内部代码只会执行一次,指向的都是同一个内存地址
    • 阶段三:运行(Evaluation),运行代码,计算值,并且将值填充到内存地址中
    image-20220722224512083

2. 阶段一:构建阶段

image-20220722224533076

3. 阶段二和三:实例化阶段 – 求值阶段

image-20220722224555871

四. 验证执行流程

  • 先看个简单的:

    js
    // a.js
    console.log('---- a start ----')
    var done = false
    export { done as aDone }
    console.log('---- a end ----')
    js
    // main.js
    console.log('---- main start ----')
    import { aDone } from './a.js'
    console.log('in main, a.done = %j', aDone)
    console.log('---- main end ----')
  • 执行 main.js ,打印结果如下:

    js
    // output:
    ---- a start ----
    ---- a end ----
    ---- main start ----
    in main, a.done = false
    ---- main end ----
  • 由此可以推断出:

    • ES 模块中,当模块中存在有 非动态导入import 别的模块时,会优先加载并执行别的模块,执行完别的模块中的代码后,才会执行当前模块中的代码

      注意:

      • import() 语法,通常称为动态导入,是一种类似函数的表达式,允许将 ECMAScript 模块异步动态地加载到潜在的非模块环境中
  • 再来个循环引用的:

    js
    // a.js
    console.log('---- a start ----')
    var done = false
    const bar = 'bar'
    function foo() {
      console.log('foo execute~')
    }
    export { done as aDone, foo, bar }
    import { bDone } from './b.js'
    console.log('in a, b.done = %j', bDone)
    done = true
    console.log('---- a end ----')
    js
    // b.js
    console.log('---- b start ----')
    var done = false
    export { done as bDone }
    import { aDone, foo, bar } from './a.js'
    console.log('in b, a.done = %j', aDone)
    console.log('foo: ', foo)
    foo()
    console.log('bar: ', bar)
    done = true
    console.log('---- b end ----')
    js
    // main.js
    console.log('---- main start ----')
    import { aDone } from './a.js'
    console.log('in main, a.done = %j', aDone)
    console.log('---- main end ----')
  • 执行 main.js,打印结果如下:

    js
    // output:
    ---- b start ----
    in b, a.done = undefined
    foo: function() {//...}
    foo execute~
    ---- b end ----
    ---- a start ----
    in a, b.done = true
    ---- a end ----
    ---- main start ----
    in main, a.done = true
    ---- main end ----
  • 由此可以推断出:

    • ES 模块中,当前模块中导出的函数声明,在编译阶段就被提前处理,允许在本模块代码执行之前调用
    • ES 模块中,当前模块中导出的 var 定义的变量,因为作用域提升,允许在本模块代码执行之前访问(export default 导出除外),此时变量还未初始化,值为 undefined
    • ES 模块中,当前模块中导出的 letconst 定义的变量,没有作用域提升,不允许在本模块代码执行之前访问
  • 再来测试个多次导入同一模块是否会多次执行该模块中的代码

    js
    // a.js
    console.log('---- a start ----')
    import {} from './b.js'
    console.log('---- a end ----')
    js
    // b.js
    console.log('---- b start ----')
    console.log('---- b end ----')
    js
    // main.js
    console.log('---- main start ----')
    import {} from './a.js'
    console.log('--------')
    import {} from './b.js'
    console.log('---- main end ----')
  • 执行 main.js,打印结果如下:

    js
    ---- b start ----
    ---- b end ----
    ---- a start ----
    ---- a end ----
    ---- main start ----
    --------
    ---- main end ----
  • 由此可以得知:

    • ES 模块中多次导入同一个模块,只会执行第一次导入,后续的导入将不再执行被导入模块中的代码,而是将第一次导入时的执行结果(导出对象的内存地址)返回
    • CJS 模块测试也是多次导入只会执行第一次导入,后续导入直接返回第一次导入时的执行结果

Released under the MIT License.