你应该知道的-前端模块化

598 阅读11分钟

前言

模块化是前端开发和工程化的重要组成部分,也是前端的基石。在早期没有前端的概念,更多是叫做 UED(用户体验设计师)。鉴于前端功能比较简单,只需要完成与用户的基础交互即可。甚至很多站点基本都是静态页面,基本无需依赖数据完成交互,很多情况下基本由后端同学顺便写了。 但随着时间的推移个人电脑的普及,底层计算机处理能力的提升,同时浏览器能力也日益强大,迫使 JavaScript 这门语言必须承担更多能力。 在 Web1.0 向着 Web2.0 大步迈进的时代,前端代码日趋膨胀,对于模块化的需求呼声越来越高。 所以我们也就能看到在 ESM 横空出世之前,出现了 CommonJS、AMD、CMD、UMD 这些主流的模块化设计思想,这是一个野蛮发展的时代。

温馨提示:码字不易,先赞后看,养成习惯!!!

1:什么是模块化

模块化是将代码解耦并且分割成一个个文件进行管理,将变量私有化,每个模块只对外暴露指定的 API 接口。

2:什么是模块

模块就是完成特定功能的单元。在开发场景中,一个模块就是实现特定功能的文件。

3:模块化的远古时期

3.1:函数时代

早期我们为了实现最简单的模块化,开发中大量使用到函数来实现。毕竟函数是 JavaScript 唯一的 Local Scope

function bar() { ... } 
function foo() { ... }

实际开发中被很快被过渡,但是大量的 函数 变量 被挂载在 window 上,造成命名冲突。

3.2:命名空间时代

为了解决上面的问题,出现了命名空间这样的模块化设计

var obj = {
    price1: 1,
    price2: 2,
    sum: function c() {
      return this.price1 + this.price1
    }
  }

这种做法缓解了命名冲突的问题,但是随之而来的是安全问题。既然是一个对象,那么对象里的所有属性值本质上都是对外暴露,那么就有可能被意外更改。

3.3:IIFE时代(立即执行函数)

<!DOCTYPE html>
<html>
  <head>
    <title>IIFE</title>
  </head>
  <body>
    <div class="font-show">IIFE</div>
  </body>
  // 引入自定义模块
  <script type="text/javascript" src="module.js"></script>
  // 使用
  <script type="text/javascript">
    akubelaModule.bar()
  </script>
</html>
// module.js文件
(function (w) {
  var msg1 = 'msg1'
  var msg2 = 'msg2'
  function foo() {
    console.log('foo:', msg1)
  }
  function bar() {
    foo()
    console.log('bar:', msg2)
  }
  // 对外只暴露foo与bar这两个方法
  w.akubelaModule = { foo, bar }
})(window)

利用 IIFE 基本上可以认为解决了命名冲突及安全问题,但是如果该模块需要依赖第三方模块或者自定义模块该如何处理?

3.4:IIFE增强时代

只需要将以上函数做简易的修改就可满足要求,如下:

<!DOCTYPE html>
<html>
  <head>
    <title>IIFE</title>
  </head>
  <body>
    <div class="font-show">IIFE</div>
  </body>
+ // 引入百度jQuery库
+ <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
  // 引入自定义模块
  <script type="text/javascript" src="module.js"></script>
  // 使用
  <script type="text/javascript">
    akubelaModule.bar()
  </script>
</html>
// module.js文件
(function (w, $) {
  var msg1 = 'msg1'
  var msg2 = 'msg2'
  function foo() {
    console.log('foo:', msg1)
  }
  function bar() {
    foo()
    console.log('bar:', msg2)
+   $('body').css('background', 'lightgreen')
  }
  // 对外只暴露foo与bar这两个方法
  w.akubelaModule = { foo, bar }
})(window, jQuery)

运行:

企业微信截图_16868945839918.png

上图我们能看见 msg1 与 msg2 都正常输出页面也正常渲染了,但是如果访问其他变量或函数就出现了报错或者 undefined。这就很好的解决了我们上面提到了问题。

IIFE 增强也是现代模块化的基石,缘起于此思想。

3.5:IIFE增强带来的问题

<!DOCTYPE html>
<html>
  <head>
    <title>IIFE增强</title>
  </head>
  <body>
    <div class="font-show">IIFE增强</div>
  </body>
  // 引入百度jQuery库
  <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
  <script src="http://xxx1.baidu.com/n.js"></script>
  <script src="http://xxx2.baidu.com/in.js"></script>
  <script src="http://xxx3.baidu.com/min.js"></script>
  <script src="http://xxx4.baidu.com/lnx.js"></script>
  // 引入自定义模块
  <script type="text/javascript" src="module1.js"></script>
  <script type="text/javascript" src="module2.js"></script>
  <script type="text/javascript" src="module3.js"></script>
  <script type="text/javascript" src="module4.js"></script>
  <script type="text/javascript" src="module5.js"></script>
  <script type="text/javascript" src="module6.js"></script>
  <script type="text/javascript" src="module7.js"></script>
  <script type="text/javascript" src="module8.js"></script>
  <script type="text/javascript" src="module9.js"></script>
  <script type="text/javascript" src="module10.js"></script>
  // 使用
  <script type="text/javascript">
    akubelaModule.bar()
    akubelaModule.foo()
    akubelaModule.bar1()
    akubelaModule.bar2()
    akubelaModule.bar3()
  </script>
</html>
  1. 请问这些脚本文件的顺序能调换吗?
  2. 这些脚本之间依赖关系明确吗?
  3. 脚本这么多首屏不要了?
  4. 请你重构你敢吗?

带着这些问题我们看看前人是如何解决的

4:野蛮生长时代

4.1:CommonJS

1)历史

CommonJS 项目由 Mozilla 工程师 Kevin Dangoor2009年1月 发发布了一篇 《What Server Side JavaScript needs》 ,最初名为 ServerJS。 在 2009年8月,这个项目被改名为 CommonJS 来展示其 API 的广泛的应用性。后由 nodejs 之父 Ryan Dahl2009年11月  实现了 CommonJS。 之后 node 的应用越来越广。在 npm + node 的组合下,一统了前端的包管理模块的天下。

2)理念

每一个文件就是一个模块,有自己的作用域,文件内的所有对象都可以私有化,对外只暴露指定的 API

3)特点

1:同步加载

2:一次加载后后期再访问直接读取缓存

3:每个模块都有自己的作用域,不污染全局作用域

4)语法

// 引入
const module = require(path/moduleName)
// 暴露
module.exports = value
exports.xxx= value

5)建立目录

5.1)建立如下目录结构:

企业微信截图_16868965468664.png

5.2)modules.js
var aaa = 10
function fff() {
  aaa = aaa + 5
}
module.exports = {
  aaa,
  a:1,
  b:2,
  c(){
    return this.a + this.b + 3
  },
  d: {
    e:1
  },
  fff,
  f() {
    this.aaa = this.aaa + 10
  }
}
5.3)index.js
let part = req('./modules.js')
console.log('a:', part.aaa)
console.log('f:', part.fff())
console.log('a:', part.aaa)
console.log('a:', part.a)
console.log('f:', part.f())
console.log('aaa:', part.aaa)
console.log('f:', part.fff())
console.log('aaa:', part.aaa)
5.4)CommonJS实现源码
let fs = require('fs')
let path = require('path')
// v8虚拟机
let vm = require('vm')
function Module(p){
 // 当前模块的标识(绝对路径作为模块的key)
 this.id = p
 // 模块将挂载到exports属性上
 this.exports = {}
}

// js文件自加载的包装类,类似eval或者是settimeout
Module._wrapper = ['(function(exports,require,module){', '})']

//所有的加载策略
Module._extensions = {
 '.js': function(module){
  // 将模块用字符串拼接的形式包入一个函数中
  let fn = Module._wrapper[0] + fs.readFileSync(module.id,'utf8') + Module._wrapper[1]
  // 将fn挂载在module.exports上,同时传入参数
  vm.runInThisContext(fn).call(module.exports, module.exports, req, module)
  // 返回
  return module.exports
 },
 '.json': function(module){
  // 如果匹配到的扩展是json,直接返回该模块
  return JSON.parse(fs.readFileSync(module.id,'utf8'))
 },
 '.node': 'xxx',
}

// 缓存对象
Module._cacheModule = {}

// 输入相对路径或模块名称,返回一个绝对路径
Module._resolveFileName = function(moduleId){
 let p = path.resolve(moduleId)
 // 通过路径直接去找该文件
 try{
  // 验证权限,有就返回
  fs.accessSync(p)
  // 找到直接返回  
  return p
 }catch(e){
  console.log(e)
 }
 //对象中所有的key做成一个数组[]
 let arr = Object.keys(Module._extensions)
 // 以上找不到的时候,加后缀再找
 for(let i=0; i<arr.length; i++){
  // 拼接完整路径
  let file = p+arr[i]
  try{
   fs.accessSync(file)   
   return file
  }catch(e){
   console.log(e)
  }
 }
}

// load方法
Module.prototype.load = function (abPath) {
 // 获取扩展名
 let ext = path.extname(abPath);
 // 找对应的扩展方法,并注入该模块对象
 let content = Module._extensions[ext](this);
 return content;
}

// require方法
function req(moduleId){
 // 得到绝对路径
 let p = Module._resolveFileName(moduleId)
 // 查缓存
 if(Module._cacheModule[p]){
  // 存在返回
  return Module._cacheModule[p].exports
 }
 // 没有缓存创建一个
 let module = new Module(p)
 Module._cacheModule[p] = module
 //加载模块
 module.exports = module.load(p)
 return module.exports
}
5.5)node对比执行

先用原生 require 执行,再用自己写的 req 执行对比如下:

commonjs.gif

4.2:AMD(异步模块定义)

由于 CommonJS 在服务端的出色表现,开发者就萌生了将他移植到浏览器端的想法。 然而 CommonJS 的模块是同步加载的,要在浏览器端实现势必造成严重的体验问题。所以 AMD 规范应运而生。而大名鼎鼎的 RequireJS 就是 AMD 规范的实现。我们一般说的 RequireJS 也指 AMD 规范。

AMD 推崇的是依赖前置,提前执行

1)语法

define(id?, dependencies?, factory)

  • id:指定义中模块的名字(可选)。如果没有提供该参数,模块的名字应该默认为模块加载器请求的指定脚本的名字。如果提供了该参数,模块名必须是“顶级”的和绝对的(不允许相同名字)。
  • dependencies:当前模块依赖的,已被模块定义的模块标识的数组字面量(可选)。
  • factory:一个需要进行实例化的函数或者一个对象。
1.1)暴露模块
// 定义没有依赖的模块
define(function(){
 return ...
})

// 定义有依赖的模块
define(['module1', 'module2'], function(module1, module2){
 return ...
})
1.2)引用模块
require(['module1', 'module2'], function(module1, module2){
  ...
})

2)引入RequireJS

AMD 是为了在浏览器宿主环境中实现异步加载模块化方案的规范之一,由于不是 JavaScript 原生支持,使用 AMD 规范进行页面开发需要用到对应的库函数。

github下载地址

2.1)创建目录结构

image.png

2.2)module1.js
define(function () {
  var msg = 'module1'
  function show() {
    console.log('module1:', msg)
  }
  return { show }
})
2.3)module2.js
define(['module1', 'jquery'], function (module1, $) {
  module1.show()
  var msg = 'module2'
  // 暴露对象
  function showModule2() {
    console.log('module2:', msg)
    $('body').css('background', 'lightgreen')
  }
  return { showModule2 }
})
2.4)main.js
// main.js文件
(function() {
  // 配置
  require.config({
    // baseUrl: 'modules/', // 基础地址,如果配置该参数路径指向就会从根目录开始
    paths: {
      // 映射: 模块标识名: 路径
      module1: './module1',
      module2: './module2',
      jquery: './libs/jquery'
    }
  })
  require(['module2'], function(module2) {
    module2.showModule2()
  })
})()
2.5)运行

image.png

4.3:CMD(常规模块定义)

CMD 规范是由 SeaJS 实现的,同时又是 SeaJS 在推广的过程中产生 CMD 的规范。 为什么会出现 CMD 这种规范呢?主要在于设计理念与实际开发中的不同需求导致。 AMD 推崇依赖前置,提前执行,CMD 遵循的是就近依赖,延迟执行。 SeaJS 的作者是玉伯。

1)语法

1.1)定义模块
// modules1.js文件
// 无依赖定义
define(function (require, exports, module) {
  var msg = 'module1'
  function show() {
    console.log('module1:', msg)
  }
  // 单一暴露
  exports.show = show
})

// modules2.js文件
// 有依赖定义
define(function (require, exports, module) {
  var module1 = require('./module1')
  module1.show()
  // 暴露对象
  module.exports = {
    msg: 'module2'
  }
})

// modules3.js文件
// 有依赖定义
define(function (require, exports, module) {
  var module2 = require('./module2')
  console.log('module2:', module2.msg)
  var msg = 'module3'
  module.exports = msg
})

// modules4.js文件
// 有依赖定义
define(function (require, exports, module) {
  // 异步挂起(事件循环)
  require.async('./module3', function (module3) {
    console.log('异步模块3:', module3)
  })
  var module4 = 'module4'
  function show() {
    console.log('module4:', module4)
  }
  exports.show = show
})

// main.js文件
define(function (require) {
  var m4 = require('./module4')
  m4.show()
})
// html文件
<!DOCTYPE html>
<html>
  <head>
    <title>CMD</title>
  </head>
  <body>
    <div>CMD</div>
  </body>
  <script type="text/javascript" src="./modules/libs/sea.js"></script>
  <script type="text/javascript">
    seajs.use('./modules/main')
  </script>
</html>
1.2)下载引入SeaJS

目录结构

image.png

1.3)执行

image.png

4.4:ESM(ES6规范)

2015年6月TC39 发布了 ES6规范 也就是 ESM 规范,ESMJavaScript 官方的标准化模块系统,在 2017年 得到了大多数主流浏览器的支持。在 2018年5月 Firefox 60 发布之后,所有的主流浏览器就都已开始广泛支持原生 ESM,这是 ES当前提案NodeJs的 8.9 之后的版本就开始支持 ES6了,在 13.2 版本之后才开启默认支持运行 ES Modules

可见 ECMA 有着极强的号召力,在规范发布后,以上提及的各种模块化的的规范开始步入生命的倒计时。前端生态开始全面拥抱 ESM

该规范有什么优点呢?

1:官方规范、先天权威性

2:语言级别的支持

3:模块静态化

4:自带树摇buff

5:默认严格模式

6:语法简洁易理解

1)语法

1.1)export 导出
// 命名导出
export const dataType = (v) => {
  return Object.prototype.toString.call(v).slice(8, -1).toLocaleLowerCase()
}

// 导出所有
export * form './module.js'

// 默认导出
export default xxx
1.2)import 导入
// 命名导入
import { dataType } from '@/utils/base'

// 导入模块中的所有变量
import * as module from './module.js'

// 默认导入
import xxx from './zhTw.js'

// 别名导入
import { foo as bar } from './math'

// 执行模块中的代码
import './module.js'

// 动态导入
xxx: () => import('@/views/login/indexTest.vue')

5:各规范对比

CommonJSAMDCMDESM
发布时间2009201120112015
作者Mozilla-Kevin DangoorJames Burke阿里-玉伯ECMA
引入方式同步同步同步/异步同步/异步
使用场景服务端客户端/服务端客户端/服务端客户端/服务端
输出变量拷贝方式值拷贝值拷贝值拷贝引用拷贝
加载时机运行时加载运行时加载运行时加载编译时确定依赖,输出接口(静态化)
特点1:同步加载
2:首次次加载后缓存,后期引入直接读取缓存
依赖前置,提前执行就近依赖,延迟执行1:官方规范、先天权威性
2:语言级别的支持
3:模块静态化
4:自带树摇buff
5:默认严格模式
6:语法简洁易理解

6:总结

模块化到现在前前后后经历了将近15个年头,从远古时代的 全局函数 → 命名空间 → IIFE → IIFE增强。 再到野蛮生长时代, CommonJS 主攻服务端。 AMD,CMD 主攻浏览器,是开发者自己定义的一种开发模式,因为设计的好用,逐渐被推广开最终形成规范。 在ESM规范没有发布之前他们承担了前端世界的绝大部分实际开发需求,但是在 ESM 规范发布后他们的历史使命也正式宣告结束。

最后历史的车轮碾碎了那个野蛮的时代,前端模块的世界也就此拉开了新的序幕。

6:推荐好文

  1. 最佳实践 monorepo + pnpm + vue3 + element-plus 0-1 完整教程
  2. Vite+rollup项目如何大幅提升性能体验
  3. 面试官系列:请说说你对深拷贝、浅拷贝的理解
  4. 面试官系列:请你说说原型、原型链相关
  5. 面试官系列:请手写防抖或节流函数debounce/throttle
  6. 面试官系类:请手写instanceof
  7. 10分钟快速手写实现:call/apply
  8. 5分钟快速手写实现:bind
  9. 5分钟快速手写实现:new
  10. 10分钟入门SVG:SVG如何画出单身狗?来本文告诉你

7:参考

  1. huangxuan.me/js-module-7…
  2. juejin.cn/post/684490…
  3. juejin.cn/post/684490…