JS模块化标准

307 阅读7分钟

模块化简介

什么是模块化

模块化就是按照一定的规则(规范) 将一个大的文件拆分为几个块(模块)文件,随后在按照规则将他们组合在一起

在块中的数据和实现都是私有的,我们可以向外暴露或引入一些接口(方法)或变量的方式来实现模块和模块之间的相互通信

模块化的发展

不拆分js代码

6xo0QU.png

此时就会发现 随着js文件的变大,维护成本也会随着指数级别的增加

此时容易出现的问题:

1. 文件非常的庞大,非常不利于维护
2. 文件变量烦琐,容易出现命名冲突和变量的污染

早期代码拆分

​ 此时我们需要将一个文件进行拆分的时候,就只能拆分为一个个的小的js文件

​ 然后在index.html中进行统一的引入

6xTpwj.png

此时可以发现的是 虽然一个大的js都被拆分为了一个个小的js文件,但是依旧存在以下问题:

  1. 每一个小的js文件中的变量在被统引入以后他们依旧是全局变量,也就是依旧在同一个作用域中

    那么依旧存在全局污染的问题

  2. 因为文件做了拆分,所以导致的问题是,引入的js文件会变得特别的多,而每一个文件的引入都有先后的顺序,也就是引入文件和引入文件之间

    存在着很强的耦合性

  3. 引入引入的js文件的增多,那在访问一个网站的时候,相应的网络请求也会随之增加

全局函数模式

模块1

var data = 'I am in module1'

function module1_print() {
  console.log(data)
}

模块2

var data = 'I am in module2'

function module2_print() {
  console.log(data)
}

主入口文件

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Module</title>
</head>
<body>
  <script src="./src/module1.js"></script>
  <script src="./src/module2.js"></script>
  <script>
    // 全局函数模式 就是将js文件 根据功能点 封装为多个不同的全局函数
    // 此时有一个很明显的问题, 就是所有的文件的作用域是共有的
    // 所以就会出现变量全局污染的问题
    var data = '这是被污染的数据'

    module1_print()
    module2_print()
  </script>
</body>
</html>

namespace模式

模块1

let module1 = {
  data: 'module1',

  print() {
    console.log(this.data)
  }
}

模块2

let module2 = {
  data: 'module2',

  print() {
    console.log(this.data)
  }
}

主入口文件

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Module</title>
</head>
<body>
  <script src="./src/module1.js"></script>
  <script src="./src/module2.js"></script>
  <script>
    // namespace模式 也被称之为简单对象封装模式
    // 将原本定义在全局的变量,转变为对应模块对象下的属性
    // 但是这个属性不是私有的,所以这个属性依旧存在被污染的可能性
    module1.data = '这是被污染的数据'

    module1.print()
    module2.print()
  </script>
</body>
</html>

IIFE

模块1

(() => {
  // 这个属性外部是无法被访问到的
  // 但是因为闭包的关系,这个变量依旧存在于内存中
  // 只有print函数和print_name函数才可以访问到这个变量
  let data = 'module1'

  function print() {
    console.log(data)
  }

  function print_name() {
    console.log('module1.js')
  }

  // 将我们需要外界使用的方法 定义在一个统一的对象中进行暴露
  window.module1 = {
    print,
    print_name
  }
})()

模块2

(() => {
  let data = 'module2'

  function print() {
    console.log(data)
  }

  function print_name() {
    console.log('module2.js')
  }

  window.module2 = {
    print,
    print_name
  }
})()

主入口文件

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Module</title>
</head>
<body>
  <script src="./src/module1.js"></script>
  <script src="./src/module2.js"></script>
  <script>
    // 这里定义的变量,因为导出的是一个对象,所以其会被挂载到module11所暴露出来的对象上
    // 但是其是无法修改module1内部的data变量的值的
    module1.data = '这是被污染的数据'

    module1.print()
    module2.print()

    module1.print_name()
    module2.print_name()
  </script>
</body>
</html>

IIFE 增强

模块1

(w => {
  let data = 'module1'

  function print() {
    console.log(data)
  }

  function print_name() {
    console.log('module1.js')
  }

  w.module1 = {
    print,
    print_name
  }
})(windows)

模块2

(w => {
  let data = 'module2'

  function print() {
    console.log(data)
  }

  function print_name() {
    console.log('module2.js')
  }

  w.module2 = {
    print,
    print_name
  }
})(window)

主入口文件

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Module</title>
</head>
<body>
  <script src="./src/module1.js"></script>
  <script src="./src/module2.js"></script>
  <script>
    // 所谓的IIFE 就是在 模块调用IIFE的时候,将该模块所需要的外部依赖 作为函数的参数进行传递
    // 例如 此处的module1 和 module2 就将global对象window作为了参数,也就是外部依赖传入到模块中
    // 这种模块方式就被称之为IIFE增强, IIFE增强 也是现代模块化的基石
    module1.print()
    module2.print()

    module1.print_name()
    module2.print_name()
  </script>
</body>
</html>

目前常见的模块化标准

标准名称实现描述
common.js(CJS)node,browserify这是目前唯一一个双端的模块化标准
ES6 module (ESM)es6这是es6原生的语法,但是目前大多数主流浏览器并不支持,所以需要babel作为loader进行转换
AMD (Asynchronous Module Definitions)Require.jsESM出来后使用较少
CMDsea.jsESM出来后使用较少( sea.js 本质也是AMD+CJS,但是已经不维护,官网也已经消失 )
UMDUMD = AMD + CJSUMD 本身不是一个规范,而是对于不同规范进行的组合,
通过判断当前运行环境而决定需要使用哪一种模块化标准
所以多数情况下既要写AMD标准又要写CJS
ESM出来后,AMD使用较少,UMD使用也相应的减少

common.js

common.js 简称为 CJS 标准

其最早叫做server.js,也就是其最早是为node环境所存在的一个模块化标准

但是自从browserify存在以后,其也可以进行浏览器端的模块化

所以改名叫做common.js,也是现在唯一一个可以支持双端(服务器端和浏览器端)模块化的标准

服务器端的实现

  1. 使用npm init初始化一个包

    • 包名应该遵循如下的定义规则
      • 包名应该全部遵循js的变量名定义规则(但是所有的英文字母必须都是小写的)
      • 包名尽可能不应该和已经存在的npm包重名
  2. 我们可以使用npm init [--yes]来快速将一个普通文件夹转换为一个符号npm规范的包

    一个符合npm规范的包需要满足以下几点

    • 存在package.json --- 类似于包的说明书文件

    • 不一定存在package-lock.json --- 这个是包的缓存文件,其中存放了当前包中各项依赖的下载地址版本号依赖包的相关依赖

      目的是为了提升下一次的安装依赖包的速度

    • package.json中必须存在nameversion这2个字段

module1

// 第一种暴露的方式 module.exports = 任何合法的数据类型(对象,数组,数字,字符串等)
module.exports = {
  data:  'module1',
  print() {
    console.log(this.data)
  }
}

module2

// 第二种暴露的方式: exports = 任何的合法的数据类型
exports.arr = [1, 2, 3, 34, 13, 12, 3, 4]

app.js

// 主入口文件,引入所有的外部模块

// 引入CJS模块 使用的是 require函数将 模块中暴露的数据进行导出,并使用一个变量来进行接收
const module1 = require('./modules/module1')

// 如果是自定义的模块,引入模块的时候需要使用相对路径
const module2 = require('./modules/module2')
// 如果使用的是第三方模块,那么直接书写模块名即可
// 因为其默认会去node_modules这个文件夹中去进行查找
// 而我们使用npm安装的第三方依赖的默认下载地址也是在node_modules这个文件夹下
const uniq = require('uniq')

// 如果引入的是一个对象,那么可以直接对引入的对象进行解构赋值
// let { print } = require('./module/module1')

// 注意: 在服务器端,CJS的引入是同步的
// 也就是只有module1被引入完毕才会去加载module2,依此类推

console.log(module1.data)
module1.print()

// uniq包的作用是对传入的数组进行
//  1. 数组去重
//  2. 对数组中的元素进行字典排序
console.log(uniq(module2.arr))

CJS的本质

  1. 暴露出来的是module.exports最终指向的哪一个对象

  2. exports只是为了简化module.exports的书写而产生的一个对象

  3. module.exports === exports => true

  4. module.exportsexports初始指向的那个对象是空对象

  5. 如果module.exports最终指向了一个新的对象,也就是module.exports导出的对象和exports导出的对象发生了冲突

    那么应以module.exports所导出的那个对象为主

  6. 在一个模块中尽可能的不要混用module.exportsexports

exports.name = 'Klaus'

// 此时module.exports 指向了一个新的对象
// 也就是module.exports 和 exports 导出的那个对象发生了冲突
// 那么应该以module.exports 为主
// 也就是说exports.name = 'Klaus' 这句代码无效了
module.exports =  {
  name: 'Steven',
  age: 18
}

浏览器端的实现

CJS 默认是不可以在浏览器端执行的

如果需要在浏览器端执行,那么需要使用browserify来将我们的模块编译为浏览器可以识别的代码

也就是说browserify就是我们的编译器,或者叫做翻译官,也就是俗称的loader

它可以将浏览器不可以识别的代码转变为浏览器可以直接识别的,具有良好兼容性的代码

$ npx browserify <源文件路径/源文件名> -o <输出文件路径/输出文件名>
<!-- 将编译后的文件引入到html文件中就可以正常运作 -->
<!-- browserify 在编译的时候,会自动将所有的CJS模块打包(合并)成一个浏览器可以识别的js文件 -->
<script src="./dist/bundle.js"></script>

ES6

ES6的模块化标准可以被简写为 ESM

现代浏览器依旧不可以使用ESM, 所以需要一个loader来帮助我们进行编译,她就是babel

但是babel就是一个工具平台,其本身就是一个运行平台,但是其实不会做任何的事情的

所以为了这个运行平台可以正常工作,我们需要插上我们对应的扩展卡

也就是我们的预设(也就是ES5和ES5的映射包,或者JSX和JS的映射包),

babel会解析我们的代码,遇到解析不了的高级语法会去对应的预设包找到对应的映射后的代码

此处就是寻找ES6对应的ES5的实现,从而完成代码的转换

此处我们需要使用的功能是ES6 -> ES5。所以我们需要安装的预设是babel-preset-es2015

我们如果需要在命令行中使用babel,还需要安装babel-cli,

所谓的cli就是command line interface,也就是命令行接口

通过这个工具我们可以将babel作为命令行指令去进行使用

随后babel-cli会去识别在命令行中传入的参数,随后在传给babel,并进行调用

Babel 转换后的代码其实是基于CJS来实现的,此时浏览器依旧无法实现对应的模块化代码

所以此时我们需要使用browserify来帮组我们将其转换为浏览器可以正常识别和运行的代码

# 安装对应的依赖
# 注意安装babel-cli 后其会自动安装babel-core 这个babel核心
# 所以在安装完babel-cli后不需要在安装babel
$ npm i babel-cli browserify -g
$ npm i babel-preset-es2015

模块1

// ESM 暴露方式1 --- 分别暴露

// 这是私有属性
const data = 'module1'

// 单独暴露我们所需要的方法
export function print_name1() {
  console.log('this is module1')
}

// 单独暴露我们所需要的方法
export function print1() {
  console.log(data)
}

模块2

// ESM的第二种暴露方式 --- 统一暴露

const data = 'module2'

const print_name2 = () => console.log('this is module2')

const print2 = () => console.log(data)

// 注意这里的导出不是对象的简写方式
// 而是ES6中特有的一种方式
// 所以按照如下的方式去书写是会直接报错的
// export {
//   print_name2: print_name2,
//   print: print
// }

// 这里导出的格式不是对象的简写方式
export {
  print_name2,
  print2
}

模块3

// 暴露方式3 --- 默认暴露
// module.export = 合法的数据类型
// 默认导出的形式 有且只能被导出一次
// 在绝大多数的第三方库中,一般都存在一个默认暴露给用户使用
export default {
  name: 'Klaus',
  age: 23
}

.babelrc

rcrun control的简写

{
  "presets": ["es2015"]
}

主入口文件

// 使用什么方式进行暴露的 就使用什么方式来进行引入

// 如果是默认暴露的就是使用默认引入的方式
// import xxx from 'xxx.js'

// 如果是 分别暴露的或者是统一暴露的 就是用以下方式进行引入
// import { xxx, yyy } from 'xxx.js'
// 注意这里不是对象解构,这和CJS是不同的

/**
   这里导出的格式不是对象的简写方式
    export {
      xxx,
      yyy
    }
    所以导出的不是一个对象
    因此使用分别暴露和统一暴露的时候,如果引入使用
    import x from 'xxx.js'
    const { xxx, yyy } = x
    这么做是会报错的
    只能使用import { xxx, yyy } = 'xxx.js'
 */

import { print_name1, print1 } from './modules/module1'
import { print_name2, print2 } from './modules/module2'
import module3 from './modules/module3'

print_name1()
print1()

print_name2()
print2()

console.log(`My name is ${module3.name} , age is ${module3.age}`)

执行指令

# -d 表示的是编译
# 在这里源文件和目标文件都是文件夹的路径,而不是对应的具体文件的路径
# 因为其编译的时候不单单需要编译主入口文件,其主入口中引入的别的文件全部都需要进行编译
$ babel <源文件夹路径> -d <模板文件夹路径>

# -o 是 --output-file的简写
# 这边只要将入口文件作为参数去进行传递即可
# 因为此处是模块的编译,其会自动根据相关的依赖关系进行打包
# 最后输出的是 一个 单一的可以直接被浏览器所识别和运行的js文件
$ browserify <源文件路径/源文件名> -0 <目录文件路径/目标文件名>

网页文件

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script src="./bundle.js"></script>
</body>
</html>

ESM的其它补充

  1. 在导出的时候可以给导出的变量起别名

    // 这是模块文件
    function demo1() {
      console.log('demo1')
    }
    
    function demo2() {
      console.log('demo2')
    }
    
    // 如果起了别名的话
    // 那么导出的对象可以认为是
    /**
     *  {
     *    fun1,
     *    fun2
     *  }
     */
    // 在这个导出的函数中就不存在 demo1 和 demo2了
    export {
      demo1 as fun1,
      demo2 as fun2
    }
    
    

这是引入模块的文件

import { fun1, fun2 } from './modules/module1'

fun1()
fun2()
  1. 无论哪一种暴露的方式都可以使用全部导出的方式进行引入
// 这个使用统一暴露导出的模块文件
function demo1() {
	console.log('demo1')
}

function demo2() {
	console.log('demo2')
}

export {
  demo1,
  demo2
}
// 这是引入了使用统一暴露方式导出数据的模块
import * as module1 from './modules/module1'

console.log(module1)
/**
*  output =>
*  {
*    demo1: <function>,
*    demo2: <function>,
*    __esModule: true   // => 这个属性是为了标识这个对象是由模块暴露出来的对象,不是一个普通的对象
*  }
*/
// 这是使用默认暴露导出的模块
export default {
  name: 'Klaus',

  say() {
    console.log('Hello World')
  }
}
// 这是引入默认暴露导出模块的文件
import * as module1 from './modules/module1'

console.log(module1)
/**
 * {
 *   default: <你所暴露出来的数据>,
 *   __esModule: true
 * }
 */

// 也就是我们默认暴露导出的对象,其会被存放于对象的default属性上
// 这就是我们有且只能暴露一次默认暴露的原因,因为后暴露的数据会将之前的default属性给覆盖
  1. 模块中各种暴露的方式在本质上是可以混用的,但是默认暴露有且只能使用一次

    使用混合暴露的模块

// 这里使用的是混合暴露的方式

export const func = () => console.log('func')

function demo1() {
  console.log('demo1')
}

function demo2() {
  console.log('demo2')
}

export {
  demo1,
  demo2
}

export default {
  default_func() {
    console.log('default_func')
  }
}

引入的文件

// import { func } from './modules/module1'
// import { demo1, demo2 } from './modules/module1'
// import modules from './modules/module1'

// 上述代码可以简写为
// 默认导出的内容,必须写在统一暴露和混合暴露之前
// 写在后边是会报错的
import modules,{ func, demo1, demo2 } from './modules/module1'


func()
demo1()
demo2()
modules.default_func()
  1. 如果一个文件是对导入的文件起到中转的作用的时候, 这个变量并不会再中转的文件中进行定义
// 实际导出数据的模块
export const name = 'Klaus'
export const age = 23
// 起到中转数据的模块
export { name, age } from './module2'

// 这么做是会报错的
// 也就是说如果一个文件是对导入的文件起到中转的作用的时候
// 这个变量并不会再中转的文件中进行定义, 也就无法进行使用
// console.log(name, age)
// 引入并使用模块
import { name } from './modules/module1'

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

AMD

AMD 是 Asynchronous Module Definition 也就是 异步模块定义

其主打的是专门用于浏览器端的,因为其所有的模块的加载都是异步的

但是在实际开发中,有些模块和模块之间的依赖是存在先后顺序的,而且ESM诞生后,其使用就变得越来越少

基本使用

没有外部依赖的模块

// 定义一个没有外部依赖的模块

/*
	// 模块名是可以省略的,如果没有设置,那么默认选取的就是当前文件的文件名
  define('模块名', () => {
    // 这里返回输出的内容是给define方法的
    // 不是直接输出的
    return <需要暴露出去的模块>
  })
*/

define(() => {
  const data = 'this is private data'

  function getDataU() {
    return data.toUpperCase()
  }

  function getDataL() {
    return data.toLowerCase()
  }

  return {
    getDataU,
    getDataL
  }
})

存在外部依赖的模块

// 引入module1 并进行相应的操作

/*
	// 第一个参数表示的是该模块的使用时候的模块名,那么在使用的时候,引入该模块的是,就需要使用这边定义的模块名
	// 模块名是可选的,如果没有定义,那么默认选取的就是当前文件的文件名
  define('模块名', [模块1, 模块2, ...], function(用以接收模块1的形参, 用以接收模块2的形参, ...) {
     .....
  })
*/

define(['module1'], m1 => {
  function print_upper_case() {
    console.log(m1.getDataU())
  }

  return print_upper_case
})

主入口文件

// 这是引入的汇总文件

requirejs.config({
  // 这个设置的baseUrl所相对的路径,是基于html文件的位置而言的
  // 这里只能提取到src,因为在使用require.js的文件结构中
  // 一般使用src/lib文件夹来存放所有的第三方依赖
  // 使用src/module文件夹来存放所有的自定义模块
  baseUrl: './src/',

  // 所有的模块都需要在入口文件中进行注册
  paths: {
    // 注意:
    // 1. 不可以使用 / 开头 否则会认为是系统的根目录

    // 2. 在引入模块的时候,其会自动添加js后缀,所以这边不可以加上js后缀,避免重复添加
    //    CJS 和 ESM 中加不加js后缀都是可以的,因为他们会自动进行判断
    //    但是Require.js中不可以

    // 3. 如果没有配置baseUrl的时候可以书写相对路径
    //    这个相对路径是相对于当前文件而言的,不是基于html文件而言的
    module1: 'modules/module1',
    module2: 'modules/module2'

    // 注意: require.js 不是在npm仓库中的
    // 也就是说使用require.js进行的模块化开发中
    // 其所有的第三方库都不是使用npm进行下载的
    // 其一般会存放于lib目录下,该目录专门用于存放第三方的依赖
    // jquery: 'lib/jquery.min.js'
    // uniq: 'lib/uniq.min.js'
  }
});

// 在入口文件中对引入的数据进行相应的操作
// 这里引入 jQuery的时候,没有使用jQuery,而是使用的是jquery
// 是因为 在jQuery的源码中,定义了AMD模块,并且该AMD模块的名称为jquery
requirejs(['module2', 'jquery'], {
   m2 => m2()
   $('body').css('background-color', 'skyblue')
})

界面文件

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <!-- 如果需要在页面中使用require.js,那么需要使用特殊的方法进行文件的引入 -->
  <script data-main="./app.js" src="./src/lib/require.js"></script>
</body>
</html>

CMD

CMD (common module definition ) 结合了 AMD 和 CJS的相关功能

使模CMD中的模块加载既可以是异步的,也可以是同步的

模块只有到被使用的时候,才会被加载执行

模块1

// 这是没有外部依赖的模块
define(function(require, exports, module) {
  let data = 'module1'

  function getData() {
    console.log(data)
  }

  module.exports = {
    getData
  }
})

模块2

define(function(require, exports, module) {
  let data = 'module2'

  function getData() {
    console.log(data)
  }

  module.exports = {
    getData
  }
})

模块3

define(function(require, exports, module) {
  let data = 'module3'

  function getData() {
    console.log(data)
  }

  module.exports = {
    getData
  }
})

模块4

// 这是使用了外部依赖的模块
define(function(require, exports, module) {
  let data = 'module4'

  function getData() {
    console.log(data)
  }

  // 同步引入模块
  const module2 = require('./module2')
  module2.getData()

  // 异步加载模块
  require.async('./module3', m3 => {
    m3.getData()
  })

  module.exports = {
    getData
  }
})

汇总文件

// 汇总文件
define(require => {
  // 在这里写不写js实际没有任何的作用
  let module1 = require('./module1')
  let module4 = require('./module4')
  module1.getData()
  module4.getData()
})

/*
  module2 // => 这是在module4中输出的,也就是在加载模块的时候,模块内部的代码会被执行一遍
  module1 // => 这是模块引入完毕以后 在汇总文件中输出
  module4 // => 这是模块引入完毕以后 在汇总文件中输出
  module3 // => 这是异步加载的模块,这个模块会在主线程全部执行完毕以后,才会从事件队列中被取出
*/

界面使用

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <!-- 引入并使用sea.js -->
  <script src="./src/lib/sea.js"></script>

  <script>
    seajs.use('./src/modules/app')
  </script>
</body>
</html>