阅读 159

node学习 --- 模块化

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

什么是模块化开发

  • 事实上模块化开发最终的目的是将程序划分成一个个小的结构
  • 这个结构中编写属于自己的逻辑代码,有自己的作用域,不会影响到其他的结构
  • 这个结构可以将自己希望暴露的变量、函数、对象等导出给其结构使用
  • 也可以通过某种方式,导入另外结构中的变量、函数、对象等

上面说提到的结构,就是模块;按照这种结构划分开发程序的过程,就是模块化开发的过程

IIFE

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

所以最早为了解决上述问题,我们需要使用立即函数调用表达式[IIFE (Immediately Invoked Function Expression)]来创建一个独立的函数作用域

var moduleFoo = (() => {
  var name = 'Klaus'

  /*
    IIFE向外暴露数据的时候可以使用两种
    1. 返回一个对象,并在外部进行接收
    2. 挂载到全局对象上,以便于在全局使用
  */
  return {
    name
  }
})()
复制代码

但是,我们其实带来了新的问题:

  1. 我必须记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用, 模块越来越多就意味着模块名的查找和命名会越来越麻烦
  2. 代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写,每一个模块中的外层函数其实都是冗余重复的
  3. 在没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况

因为,我们需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码

这个规范中应该包括两部分核心功能:

  • 每一个模块都有自己的独立的作用域
  • 模块本身可以导出暴露的属性,模块又可以导入自己需要的属性

在ES6的模块化出现之前,JavaScript社区为了解决上面的问题,涌现出一系列好用的规范,如CJS, AMD等

commonjs

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

  • Node是CommonJS在服务器端一个具有代表性的实现
  • Browserify是CommonJS在浏览器中的一种实现
  • webpack打包工具具备对CommonJS的支持和转换

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

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

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

    • exports和module.exports默认情况下,指向的其实是同一个对象,且默认值是一个空对象

    • 我们可以使用这些变量来方便的进行模块化开发

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

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

// 导出
const name = 'Klaus'
const sayHello = () => console.log('Hello World')

// 导出属性和方法
exports.name = name
exports.sayHello = sayHello
复制代码
// 导入
// require方法的返回值是一个对象,这个对象全等于 foo模块导出的exports对象
const { name, sayHello } = require('./foo')

console.log(name) // => Klaus
sayHello() // => Hello World
复制代码

所以, common.js的本质就是对象的浅拷贝(引用赋值)

module.exports 和 exports

  • 在CommonJS中是没有module.exports的概念的
  • 在node中,为了实现模块化,Node中使用的是Module的类,每一个模块都是Module的一个实例,也就是 module
  • 所以在Node中真正用于导出的其实根本不是exports,而是module.exports, 因为module才是导出的真正实现者
  • 而module对象的exports属性是exports对象的一个引用 ( module.exports === exports => true
// 导出
const name = 'Klaus'
exports.name = name

// 此时,exports导出的属性就没有任何意义了
// 因为实际导出的实际上使用的是module.exports对象 
module.exports = {
  age: 23
}
复制代码
// 导入
const foo = require('./foo')
console.log(foo.age) // => 23
console.log(foo.name) // => undefined
复制代码

require的查找顺序

require是一个函数,可以帮助我们引入一个文件(模块)中导入的对象。那么,require的查找规则是怎么样的呢?

  1. 查看require方法的参数(假设值为X)是不是绝对路径

    • 如果不是绝对路径,而是一个 名称

      1. 查看X是不是一个核心模块,如果是,直接返回核心模块,并且停止查找
      2. 如果X不是一个核心模块,去node_modules下进行查找,看看是不是第三方模块, 如果当前目录下的node_modules中不存在,就会去上一层目录的node_modules中找,依次类推,直到/node_modules, 这个查找规则其实是保存在module.path
      console.log(module)
      
      /*
        Module {
          id: '.', // 执行的入口文件的id为., 其余模块的id为模块的路径
          path: '/Users/klaus/Desktop/node-demo/foo/baz/bar', // 模块所在的文件夹
          exports: {}, // 模块实际导出的内容
          parent: null,
          filename: '/Users/klaus/Desktop/node-demo/foo/baz/bar/index.js',
          loaded: false,
          children: [],
          paths: [  // 引入模块,如果没有路径也不是核心模块的时候的查找路径数组
            '/Users/klaus/Desktop/node-demo/foo/baz/bar/node_modules',
            '/Users/klaus/Desktop/node-demo/foo/baz/node_modules',
            '/Users/klaus/Desktop/node-demo/foo/node_modules',
            '/Users/klaus/Desktop/node-demo/node_modules',
            '/Users/klaus/Desktop/node_modules',
            '/Users/klaus/node_modules',
            '/Users/node_modules',
            '/node_modules'
          ]
        }
      */
      复制代码
    • 如果是一个相对路径 (X是以 ./ 或 ../ 或 /(根目录)开头的)

      1. 将X当做一个文件在对应的目录下查找

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

        • 如果没有后缀名,会按照如下顺序进行查找

          1. 直接查找文件X
          2. 查找X.js文件
          3. 查找X.json文件
          4. 查找X.node文件
      2. 没有找到对应的文件,将X作为一个目录

        1. 查找X/index文件
        2. 查找X/index.js文件
        3. 查找X/index.json文件
        4. 查找X/index.node文件

    如果全部查找完毕以后,依旧没有找到对应的资源,那么就会报错: not found

    这就是为什么,当我们在引入一些模块的时候,在node环境中进行引入时,可以省略js后缀

模块加载过程

  • 模块在被第一次引入时,模块中的js代码会被运行一次
  • 模块被多次引入时,会缓存,最终只加载(运行)一次
    • 每个模块对象module都有一个属性:loaded
    • 为false表示还没有加载完毕,为true表示已经加载完毕
  • 在项目中,模块之间相互依赖的关系可以被看成是图结构
    • 图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search)
    • Node采用的是深度优先算法

IEh7aQ.png

所以,上图中,模块的引入顺序为: main -> aaa -> ccc -> ddd -> eee ->bbb

cjs的特点:

CommonJS加载模块是同步的

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

  • 这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快

  • 浏览器加载js文件需要先从服务器将文件下载下来,之后在加载运行

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

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

  • 但是早期浏览器是没有ESM的,所以社区提供了AMD或CMD

  • AMD和CMD已经使用非常少了,所以这里我们进行简单的演练

AMD

AMD主要是应用于浏览器的一种模块化规范

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

它采用的是异步加载模块

事实上AMD的规范还要早于CommonJS,但是CommonJS目前依然在被使用,而AMD使用的较少了

AMD就是一种模块化规范,规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用

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

index.html

<!--
  引入require.js 在引入之后,会立即自动加载并执行data-main中配置的文件
-->
<script src="https://unpkg.com/requirejs@2.3.6/bin/r.js" data-main="./index.js"></script>
复制代码

主入口文件

// 主入口文件中需要有一个IIFE
(() => {
  // 配置require.js中对应的模块映射表
  require.config({
    // baseUrl基准路径,实际查找路径为 => baseUrl + paths['模块名']
    baseUrl: '',

    paths: {
      // 注意:模块对应的值是不要加引号的
      // require.js在编译的时候,会自动加引号
      // 不需要加,否则会重复添加js后缀
      'foo': './foo'
    }
  })

  // 引入模块
  // 参数1: 引入的模块数组
  // 参数2: 回调函数,参数为引入的模块对象
  // 引入了几个模块,参数就有几个模块
  // 顺序和参数1数组中的模块顺序是一一对应的
  require(['foo'], foo => {
    console.log(foo.name)
    foo.sayHello()
  })
})()
复制代码

模块

// 定义模块
// 参数1 引入的模块数组
// 参数2 回调函数 --- 参数为引入的模块
define([], () => {
  const name = 'Klaus'
  const sayHello = () => console.log('Hello World')

  // 如果一个模块需要向外暴露数据,直接作为回调函数的返回值返回即可
  return {
    name,
    sayHello
  }
})
复制代码

CMD

CMD规范也是应用于浏览器的一种模块化规范

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

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

CMD也有自己比较优秀的实现方案: sea.js

index.html

<!-- 引入sea.js -->
<script src="https://unpkg.com/seajs@3.0.3/dist/sea.js"></script>

<!-- 设置主入口文件 -->
<script>
  seajs.use('./index.js')
</script>
复制代码

主入口文件

// 定义模块
define((require, exports, module) => {
  const foo = require('./foo')

  console.log(foo.name)
  foo.sayHello()
})
复制代码

模块文件

// 定义模块
define((require, exports, module) => {
  const name = 'Klaus'

  const sayHello = () => console.log('Hello World')

  module.exports = {
    name,
    sayHello
  }
})
复制代码

ESM

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

  1. 一方面它使用了import和export关键字, 不是函数

  2. 另一方面它采用编译期的静态分析,并且也加入了动态引用的方式

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

ES Module模块采用export和import关键字来实现模块化:

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

tip:注意使用ESM模块化必须使用本地服务的方式来运行

如果你通过本地加载Html 文件 (比如一个 file:// 路径的文件), 你将会遇到 CORS 错误,因 为Javascript 模块安全性需要

<!-- 加上type="module"就意味着对应的js文件会按照模块的方式去进行解析和加载 -->
<script src="./index.js" type="module"></script>
复制代码

导出

方式一: 逐个导出,在每一个需要导出的变量之前写上export关键字

export const name = 'Klaus'

export const sayHello = () => console.log('Hello World')
复制代码

方式二:统一导出

const name = 'Klaus'

const sayHello = () => console.log('Hello World')

// 注意:export后面的大括号不是一堆对象,而是需要导出的变量的引用列表
export {
  name,
  sayHello
}
复制代码

所以我们在导出变量的时候,可以给变量起别名, 而对象是没有这个功能的

const name = 'Klaus'

const sayHello = () => console.log('Hello World')

// 注意:如果起了别名,那么导出的列表中就只存在fName和fSayHello,将不再存在name和sayHello
// 所以如果在别的模块需要引入的时候,需要使用的是fName和fSayHello,而不是name和sayHello
export {
  name as fName,
  sayHello as fSayHello
}
复制代码

导入

// 注意: 这里引入使用的大括号也不是对象的解构
import { name, sayHello } from './foo.js'

console.log(name)
sayHello()
复制代码
// 所以我们依旧可以在引入变量的时候,为这些变量起别名
import { name as fName, sayHello as fSayHello } from './foo.js'

console.log(fName)
fSayHello()
复制代码

全部导入

// 此时foo为*的别名
// 注意: 此时的foo就是一个对象,不在是引用列表
import * as foo from './foo.js'

console.log(foo.name)
foo.sayHello()
复制代码

export和import结合使用

在开发和封装一个功能库时,通常我们希望将暴露的所有接口放到一个文件中,进行统一导出

这样方便指定统一的接口规范,也方便阅读,这个时候,我们就可以使用export和import结合使用

// 只要将原本的import修改为export即可
// 此时这个文件只是进行中转,所以在这个中转的文件中是不可以使用name和sayHello变量的
export { name, sayHello } from './foo.js'
复制代码

default

前面我们使用的导出功能都是有名字的导出(named exports)

  • 在导出export时需要指定了名字
  • 导入import时需要知道具体的名字

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

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

在一个模块中,只能有一个默认导出

默认导出和具名导出可以同时存在

// 导出
export const name = 'Klaus'
export default () => console.log('Hello World')
复制代码
// 导入
// 可以同时导入具名导出和默认导出,默认导出需写在具名导出之前
import sayHello, { name } from './foo.js'

sayHello()
console.log(name)
复制代码

import函数

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

也就是import加载必须写在代码的最顶层级作用域中(全局作用域)

因为import是关键字,所以模块之间的依赖关系图是在编译的时候就需要被解析完毕,

而在逻辑代码中的模块是否被加载只能等到运行的时候才会知道, 是无法在解析阶段被确定的

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

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

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

function foo() {
  // import是异步加载的,返回返回的实际上是一个promise
  // then方法中的res实际上就是模块导出给我们的东西
  import('./foo.js').then(res => {
    // 返回的res不是引用列表,而是普通对象,所以可以解构
    // 具名导出的变量直接使用定义的名称来导出
    // 默认导出的变量,会自动设置属性名为default
    const { name, default: sayHello } = res
    console.log(name)
    sayHello()
  }).catch(err => {
    console.log(err)
  })
}

foo()
复制代码

模块加载过程

CJS

  1. CommonJS模块加载js文件的过程是运行时加载的,并且是同步的

    • 运行时加载意味着是js引擎在执行js代码的过程中加载模块

    • 同步的就意味着一个文件没有加载结束之前,后面的代码都不会执行

  2. CommonJS通过module.exports导出的是一个对象

    • 导出的是一个对象意味着可以将这个对象的引用在其他模块中赋值给其他变量
    • 但是最终他们指向的都是同一个对象,那么一个变量修改了对象的属性,所有的地方都会被修改

导出后重新修改导出的变量值

// 导出
let info = { age: 23 }
let name = 'Klaus'
setTimeout(() => info.age = 18, 1000)

module.exports = {
  info,
  age
}

// -----------------------------------------

// 导入
const { info, name } = require('./foo')

// 导出和导入指向的都是同一个对象
// 所以修改引用类型的值成功,修改非基本数据类型的值是无法修改的
setTimeout(() => {
  console.log(info.age) // => 18
  console.log(name) // => Klaus
}, 2000)
复制代码

在导入的js文件中,反向修改导出变量

// 导出
let info = { age: 23 }

// 因为导出和导入本质上都是一个对象,所以导入的文件也可以修改info对象
setTimeout(() => console.log(info.age), 2000) // => 18

module.exports = {
  info
}

// ------------------------------------------------

// 导入
const { info } = require('./foo')

setTimeout(() => {
  info.age = 18
}, 1000)
复制代码

ESM

  1. ES Module加载js文件的过程是编译(解析)时加载的,并且是异步的
  • 编译时(解析)时加载,意味着import不能和运行时相关的内容放在一起使用
  • ES Module是静态解析的,而不是动态或者运行时解析的
  • 异步的意味着:JS引擎在遇到import时会去获取这个js文件,但是这个获取的过程是异步的,并不会阻塞主线程继续执行
  • 也就是说设置了 type=module,相当于在script标签也加上了async属性
  • 如果我们后面有普通的script标签以及对应的代码,那么ES Module对应的js文件和代码不会阻塞它们的执行
<script src="./index.js" type="module"></script>
<script>
  console.log('这里的代码会优先被执行')
</script>
复制代码
  1. ES Module通过export导出的是变量本身的引用
    • export在导出一个变量时,js引擎会解析这个语法,并且创建模块环境记录(module environment record);
    • 模块环境记录会和变量进行绑定(binding),并且这个绑定是实时的
    • 在导入的地方,我们是可以实时的获取到绑定的最新值的

所以,如果在导出的模块中修改了变化,那么导入的地方可以实时获取最新的变量

导出后重新修改导出的变量值

// 导出
export let age = 23

export const info = {
  name: 'Klaus'
}

setTimeout(() => {
  info.name = 'Alex'
  age = 18
}, 1000)

// 导入
import { info, age } from './foo.js'

// 和CJS不同的是,info和age其实是导出变量的实时引用地址
// 所以无论是基本数据类型
setTimeout(() => {
  console.log(info.name) // => Alex
  console.log(age) // => 18
}, 2000)
复制代码

从导入文件反向修改导出的变量

// 导出
export let age = 23

export const info = {
  name: 'Klaus'
}

setTimeout(() => {
  console.log(info.name)
  console.log(age)
}, 2000)

// ------------------------
// 导入
import { info, age } from './foo.js'

setTimeout(() => {
  info.name = 'Alex' // => 引用变量是可以被正常修改的
  // age = 18 // => 基本数据类型无法被修改
}, 1000)
复制代码
文章分类
前端
文章标签