欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

前端工程化实战 - 模块化开发

程序员文章站 2022-04-07 18:22:26
前端工程化实战 - 模块化开发4.1模块化开发概述4.2模块化演变过程4.3模块化规范的出现4.1模块化开发概述模块化可以说是最重要的前端开发范式之一。随着前端应用的日益复杂,我们的项目代码已经逐渐膨胀到了不得不花大量时间管理的程度了。而模块化就是一种最主流的代码组织方式,它通过把复杂代码按照功能的不同,划分为不同的模块单独维护的这种方式提高我们的开发效率,降低维护成本。但就模块化这个词而言,它仅仅是一种思想或者说是一种理论,并不包含具体实现。早期的前端技术标准根本没有预料到前端行业会有今天这样一...

4.1模块化开发概述

模块化可以说是最重要的前端开发范式之一。随着前端应用的日益复杂,我们的项目代码已经逐渐膨胀到了不得不花大量时间管理的程度了。

而模块化就是一种最主流的代码组织方式,它通过把复杂代码按照功能的不同,划分为不同的模块单独维护的这种方式提高我们的开发效率,降低维护成本。

但就模块化这个词而言,它仅仅是一种思想或者说是一种理论,并不包含具体实现。

早期的前端技术标准根本没有预料到前端行业会有今天这样一个规模,所以很多设计上的遗留问题就导致我们现在去实现前端模块化的时候会遇到很多的问题。虽然现如今这些问题都被一些标准或者工具去解决了,但是它的解决的一个演进过程是值得我们去思考的。

4.2模块化演变过程

最早期,JavaScript当中的模块化实际上就是基于文件划分的方式实现的,这也就是Web中最原始的模块系统。

具体做法就是将每个功能以及它相关的状态数据单独存放到不同的文件当中,去约定每一个文件就是一个独立的模块。我们去使用这个模块就是将这个模块引入到页面当中,一个 script 标签就对应一个模块。再在代码中直接调用模块中的全局成员,这个成员有可能是一个变量,也有可能是一个函数。

// module a 相关状态数据和功能函数

var name = 'module-a'

function method1 () {
  console.log(name + '#method1')
}

function method2 () {
  console.log(name + '#method2')
}
// module b 相关状态数据和功能函数

var name = 'module-b'

function method1 () {
  console.log(name + '#method1')
}

function method2 () {
  console.log(name + '#method2')
}
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
  // 命名冲突
  method1()
  // 模块成员可以被修改
  name = 'foo'
</script>

这种方式的缺点也十分明显,就是所有的模块都直接在全局范围去工作,并没有一个独立的私有空间,这样就导致模块当中所有的成员都可以在模块外部被任意的访问或修改,也就是污染全局作用域,而且模块多了过后还会存在命名冲突问题,我们也无法管理模块依赖的关系。它完全依靠约定,项目一但上了体量,就彻底不行了。

所以就有了第二阶段,在第二阶段当中,我们约定每一个模块只暴露一个全局对象,所有模块的成员都挂载到对象下面。

具体做法就是在第一阶段的基础上,通过将每个模块「包裹」为一个全局对象的形式实现,有点类似于为模块内的成员添加了「命名空间」的感觉。

// module a 相关状态数据和功能函数

var moduleA = {
  name: 'module-a',

  method1: function () {
    console.log(this.name + '#method1')
  },

  method2: function () {
    console.log(this.name + '#method2')
  }
}
// module b 相关状态数据和功能函数

var moduleB = {
  name: 'module-b',

  method1: function () {
    console.log(this.name + '#method1')
  },

  method2: function () {
    console.log(this.name + '#method2')
  }
}
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
  moduleA.method1()
  moduleB.method1()
  // 模块成员可以被修改
  moduleA.name = 'foo'
</script>

通过「命名空间」减小了命名冲突的可能,但是同样没有私有空间,所有模块成员也可以在模块外部被访问或者修改,而且也无法管理模块之间的依赖关系。

到了第三阶段,第三阶段使用立即执行函数的方式为模块提供私有空间。

具体做法就是将模块中每一个成员都放在一个函数提供的私有作用域中,对于需要暴露给外部的成员可以通过挂载到全局对象的方式来实现,这种方式实现了私有成员的概念,也就是说私有成员只能是模块内部的成员通过闭包来访问,而在外部是无法访问的。

// module a 相关状态数据和功能函数

;(function () {
  var name = 'module-a'
  
  function method1 () {
    console.log(name + '#method1')
  }
  
  function method2 () {
    console.log(name + '#method2')
  }

  window.moduleA = {
    method1: method1,
    method2: method2
  }
})()
// module b 相关状态数据和功能函数

;(function () {
  var name = 'module-b'
  
  function method1 () {
    console.log(name + '#method1')
  }
  
  function method2 () {
    console.log(name + '#method2')
  }

  window.moduleB = {
    method1: method1,
    method2: method2
  }
})()
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
  moduleA.method1()
  moduleB.method1()
  // 模块私有成员无法访问
  console.log(moduleA.name) // => undefined
</script>

这样就确保了变量的安全,而且还可以利用自执行函数的参数去作为依赖声明使用,就使得每一个模块之间的依赖关系变得更加明显。

以上这几个阶段就是早期开发者在没有工具和规范的情况下对模块化的落地方式。这些方式确实解决了前端领域去实现模块化的问题,但是它仍然存在一些没有解决的问题。

在模块化当中针对模块加载的问题,在上述几种方式中都是通过 script 标签手动引入的,这也就意味着模块加载并不受代码的控制,一旦时间久了过后,维护就会出现问题。所以我们需要一些基础的公共代码去实现自动靠代码来加载模块。

  • 模块化标准 + 模块加载器

4.3模块化规范的出现

CommonsJS 规范

它是Node.js当中所提出的一套标准,我们在Node.js当中所有的模块代码必须要遵循CommonsJS规范,这个规范约定了

  • 一个文件就是一个模块
  • 每个模块都有单独的作用域
  • 通过 modeule.exports 导出成员
  • 通过 require 函数载入模块

但是如果我们想要在浏览器端也使用这个规范的话,就会出现一些问题,我们知道CommonsJS是以同步模式加载模块,因为node的执行机制是在启动时加载模块,执行过程当中是不需要加载的,它只会使用到模块。如果换到浏览器端使用CommonsJS规范的话,必然导致效率低下,因为每一次页面加载都会导致大量的同步模式请求出现。

所以在早期的前端模块化当中并没有选择CommonsJS这个规范,而是专门为浏览器端重新设计了一个规范,叫做AMD(Asynchronous Module Definition),而且同期还推出了一个非常出名的库 Require.js,它实现了AMD规范,另外它本身又是一个非常强大的模块加载器。

// 因为 jQuery 中定义的是一个名为 jquery 的 AMD 模块
// 所以使用时必须通过 'jquery' 这个名称获取这个模块
define('module1', ['jquery', './module2'], function ($, module2) {
  return {
    start: function () {
      $('body').animate({ margin: '200px' })
      module2()
    }
  }
})

在AMD这个规范当中,约定每一个模块都必须通过 define 这个函数定义,默认可以接收两个参数,也可以传递三个参数。
第一个参数是模块的名字;第二个参数是一个数组,用于声明模块依赖项;第三个参数是一个函数,函数的参数与前面的依赖项一一对应,每一项分别为依赖项这个模块导出的成员,这个函数的作用可以以理解为为当前的这个模块提供一个私有的空间。
如果需要在这个模块当中向外部导出一些成员,可以通过 return 实现。

除此之外,Require.js 当中还提供一个 require 函数用来自动加载模块,用法与 define 函数类似。区别在于 require 函数只是用来加载模块,而 define 函数是用来定义模块的。

require.config({
  paths: {
    // 但是 jQuery.js 并不一定在同级目录下,所以需要指定路径
    jquery: './lib/jquery'
  }
})

require(['./modules/module1'], function (module1) {
  module1.start()
})

当 Require.js 需要加载一个模块的话,内部会自动的创建一个 script 标签去发送对应脚本文件的请求,并且执行相应模块代码。

目前绝大多数第三方库都支持AMD规范,但是AMD使用起来相对复杂,如果项目中的模块划分的非常细致的话,模块JS文件请求频繁

同期出现的还有淘宝推出的 Sea.js + CMD ,可以算是一个重复的「*」。它当时的想法就是希望让CMD写出来的代码尽可能与CommonJS类似,从而减轻开发者的学习成本。这种方式后来也被 Require.js 兼容。

// 兼容 CMD 规范(类似 CommonJS 规范)
define(function (require, exports, module) {
	// 通过 require 引入依赖
  var $ = require('jquery')
  // 通过 exports 或者 module.exports 对外暴露成员
  module.exports = function () {
    console.log('module 2~')
    $('body').append('<p>module2</p>')
  }
})

4.4模块化标准规范

随着技术的发展,JavaScript的标准也在逐渐完善。而模块化的实现方式相对以往已经有了很大的变化。目前针对前端模块化的实现方式也逐渐统一。

前端工程化实战 - 模块化开发

在Node.js中会遵循 CommonJS 规范,而在浏览器环境中可以采用 ES Modules 的规范

我们在Node.js中使用CommonJS没有任何环境问题,它是内置模块系统,我们直接去遵顼CommonJS规范去使用require载入模块,通过module.export导出模块就可以了。

但是对于ES Modules,其情况就相对复杂一些,我们知道,ES Modules是ECMAScript2015当中定义的最新的模块系统,也就是说它是最近几年才被定义的一个标准,所以它肯定会存在各种各样的环境兼容性问题。

前端工程化实战 - 模块化开发

早期在这个标准刚推出的时候,所有主流浏览器基本都是不支持这样一个特性的,但是随着Webpack等一系列打包工具的流行,这一规范才逐渐开始普。

截止到目前,ES Modules可以说是最主流的前端模块化方案了。相比于AMD这种社区提出来的开发规范,ES Modules在语言层面实现了模块化。现如今绝大多数浏览器已经开始支持 ES Modules 这个特性了,原生支持就意味着我们可以在以后直接使用这样一个特性开发我们的网页应用。

4.5ES Modules

ES Modules 基本特性

通过给 script 添加 type = module 的属性,就可以以 ES Module 的标准执行其中的 JS 代码了

<script type="module">
  console.log('this is es module')
</script>
  1. ESM 自动采用严格模式,忽略 ‘use strict’
  2. 每个 ES Module 都是运行在单独的私有作用域中
  3. ESM 是通过 CORS 的方式请求外部 JS 模块的
  4. ESM 的 script 标签会延迟执行脚本(等同于 script 标签的 defer 属性)

ES Modules 导出

如果我们需要对外提供某些成员,必须使用 export 关键词修饰变量、函数、类等的声明,此时就可以通过 import 载入其他模块当中通过 export 导出的成员了。

export var name = 'foo module'

export function hello () {
  console.log('hello')
}

export class Person {}
import { name, hello, Person } from './module.js'
console.log(name, hello, Person)

除此之外,我们还可以通过 export { … } 的形式导出

var name = 'foo module'

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

class Person {}

export { name, hello, Person }

我们还可以通过这种方式使用 as 关键词为输出的成员进行重命名

export {
  name as fooName,
  hello as fooHello
}

重命名使用过程中有一个特殊情况,一旦将导出成员的名称设置为 default 的话,这个成员就会作为当前模块默认导出的成员,导入这个成员的时候就必须给这个成员重命名,因为default是一个关键词,不能将其作为变量使用。

export {
  name as default,
  hello as fooHello
}
import { default as fooName } from './module.js'
console.log(fooName)

在 ES Modules 当中还为 default 成员单独设计了一个特殊的用法

export default name
import fooName from './module.js'
console.log(fooName)

注意:不要误认为 export{ … } 导出的是ES6中的字面量对象,从而误认为导入的是对这个对象的解构。export{ … } 是一个固定的语法。

如果你需要导出一个对象,则可以通过

export default { name, hello, Person }

除此之外,还需要注意的是:导出成员导出的是一个引用,而非拷贝。并且,这个暴露出来的引用关系是只读的,并不能在模块外部修改该成员。

ES Modules 导入

1.在 import 在导入模块时,from写的是导入模块的路径,它是一个字符串,在这个字符串当中,必须使用完整的文件名称,不能省略扩展名。

2.在原生 ES Modules 中不能自动载入 index.js。

3.其次,如果我们使用的是相对路径时的’./…'无法省略,如果省略以字母开头,ES Moudles 会认为是在加载第三方模块。

4.除了使用绝对路径,我们还可以使用相对路径甚至是完整的 URL 去加载模块,这也意味我们可以直接引用 cdn 上的模块。

5.如果我们只需要去执行某个模块,并不需要提取其中的成员的话,可以保持 import 后的 {} 为空。且可以简写为

import ' 路径 '

6.如果一个模块需要导出的的成员特别多,且在导入时都会用到,则可以使用

import * as 对象名 from '路径' 

提取所有成员。

7.如果模块路径是在运行阶段才知道的,或者当某些条件满足采取导入模块,此时不能使用 import 关键词,因为 import 关键词只能出现在最顶层(最外层作用域)。此时就需要动态导入模块的机制了,ES Modules 提供了全局的 import 函数专门用来动态导入模块。

import('./module.js').then(function (module) {
  console.log(module)
})

并且这个import函数返回的是一个Promise,当次模块加载完成过后会自动执行then()当中指定的回调函数,模块对象可以通过参数获取。

8.如果在一个模块中同时导出了命名成员和默认成员,可以简写为

// import { name, age, default as title } from './module.js'
import abc, { name, age } from './module.js'
console.log(name, age, abc)

ES Modules 导出导入成员

除了导入模块,import 还可以配合 export 使用,效果就是将导入的结果直接作为当前模块的导出成员。

具体用法就是将 import 声明当中的 import 关键词修改为 export。这样一来,所有导入的成员将直接作为当前模块的导出成员,在当前作用域中就不再允许访问成员了。

这样一个特性一般用于 index.js 文件:通过index文件把某一个目录下散落的一些模块通过这种方式组织到一起导出,方便外部的使用。

如果组件文件当中导出的是一个默认成员的话,就不能再通过提取的方式导出,就必须提取它的default,为default重命名,否则它会作为当前index文件的默认导出,外界就只能通过defaule使用了。

ES Modules 浏览器环境 Polyfill

因为 ES Modules 是2014年才被提出的,这也就意味着早期的浏览器不可能支持这一特性。IE以及一些国产浏览器到目前为止也还未支持,所以我们在使用 ES Modules 时还是需要去考虑兼容性的。

export var foo = 'bar'
<script src="https://unpkg.com/promise-polyfill@8.1.3/dist/polyfill.min.js"></script>
<script src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script>
<script src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script>
<script type="module">
  import { foo } from './module.js'
  console.log(foo)
</script>

我们可以通过使用一个 Polyfill 让我们在浏览器当中直接支持 ES Modules 中绝大多数特性。这个模块的名字叫做 ES Module Loader,这个模块实际上就是一个JS文件,我们只需要将这个JS文件引入到网页当中,就可以让这个网页运行 ES Modules 了。

通过 unpkg.com 提供的cdn服务拿到它下面所有的JS文件,点我前往 通过script 标签分别引入两个文件的路径。

我们还需要为 IE 单独再去引入一个Promise的Polyfill,点我前往 此时重新刷新页面就可以看到 bar 打印出来了。

其实 ES Module Loader 的工作原理就是将浏览器当中不识别的 ES Modules 交给 Babel 转换,对于需要 import 进来的文件再通过 Ajax 的方式请求,把请求回来的代码再通过 Babel 转换从而支持。

这里有一个问题:如果在支持 ES Modules 的浏览器中代码会被执行两次。我们可以及借助 script 标签的一个新属性 nomodule 解决。

<script nomodule src="https://unpkg.com/promise-polyfill@8.1.3/dist/polyfill.min.js"></script>
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script>
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script>
<script type="module">
  import { foo } from './module.js'
  console.log(foo)
</script>

当然这种方式只适合于开发阶段本地测试,因为它的原理都是在运行阶段动态地解析脚本,效率就会非常差,在生产阶段还是应该预先将这些代码编译出来,让它可以直接在浏览器当中工作。

ES Modules in Node.js - 支持情况

Node 版本8.5以上可以使用 ES Modules

  1. 需要将扩展名更改为 .mjs ,提示不为模块更新路径。
  2. 启动 Node 时需要增加 --experimental-modules 地参数,代表启用ES Module 实验特性。

可以通过 ES Modules 的方式载入原生模块以及第三方模块。

import fs from 'fs'
fs.writeFileSync('./foo.txt', 'es module working')
import _ from 'lodash'
_.camelCase('ES Module')

但是在 ES Modules 中提取模块成员的用法不能实现,因为import { … } 内部并不是解构,第三方模块都是导出默认成员,因此需要通过默认导入的方式导入成员。

import { camelCase } from 'lodash'
console.log(camelCase('ES Module'))

但是我们可以通过提取的方式直接提取系统内置模块当中的成员,因为系统内置的模块,官方做了兼容,对其内部成员单独进行了导出且作为默认对象导出。

import { writeFileSync } from 'fs'
writeFileSync('./bar.txt', 'es module working')

ES Modules in Node.js - 与CommonJS交互

ES Module 中可以导入 CommonJS 模块

// commonjs.js
// CommonJS 模块始终只会导出一个默认成员
module.exports = {
  foo: 'commonjs exports value'
}

// 等价于
exports.foo = 'commonjs exports value'
// es-module.mjs
// import 默认成员
import mod from './commonjs.js'
console.log(mod)

// 不能直接提取成员,注意 import 不是解构导出对象
import { foo } from './commonjs.js'
console.log(foo)

不能在 CommonJS 模块中通过 require 载入 ES Module

// es-module.mjs
export const foo = 'es module export value'
// commonjs.js
const mod = require('./es-module.mjs')
console.log(mod)

ES Modules in Node.js - 与CommonJS的差异

CommonJS 的内置成员

// 加载模块函数
console.log(require)

// 模块对象
console.log(module)

// 导出对象别名
console.log(exports)

// 当前文件的绝对路径
console.log(__filename)

// 当前文件所在目录
console.log(__dirname)

在ES Modules 中没有 CommonJS 中的模块全局成员

// require, module, exports 自然是通过 import 和 export 代替

// __filename 和 __dirname 通过 import 对象的 meta 属性获取
// const currentUrl = import.meta.url
// console.log(currentUrl)

// 通过 url 模块的 fileURLToPath 方法转换为路径
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
console.log(__filename)
console.log(__dirname)

ES Modules in Node.js - 新版本进一步支持

在 Node 的最新版本当中进一步地支持了 ES Modules

Node v12 之后的版本,可以通过 package.json 中添加 type 字段为 module,将默认模块系统修改为 ES Module

// package.json

{
  "type": "module"
}
// index.js
// 此时就不需要修改文件扩展名为 .mjs 了

import { foo, bar } from './module.js'
console.log(foo, bar)

如果需要在 type=module 的情况下继续使用 CommonJS,需要将文件扩展名修改为 .cjs

// common.cjs

const path = require('path')
console.log(path.join(__dirname, 'foo'))

ES Modules in Node.js - Babel 兼容方案

对于早期的 Node.js 版本,可以使用 Babel 实现 ES Module 的兼容

前端工程化实战 - 模块化开发

Babel 是目前最主流的一款JavaScript编译器,它可以用来帮我们将一些使用了新特性的代码编译成当前环境支持的代码。

yarn add @babel/node  @babel/core @babel/preset-env --dev
// module.js

export const foo = 'hello'
export const bar = 'world'
// index.js

import { foo, bar } from './module.js'
console.log(foo, bar)
yarn babel-node index.js --presets=@babel/preset -env

如果觉着每次手动传参麻烦,可以放到配置文件

// .babelrc
{
  "plugins": [
    "@babel/preset-env"
  ]
}
yarn babel-node index.js

实际帮我们转换ES Modules 特性的是一个插件,并不是preset,preset只是一个集合,我们可以使用单独插件来转换

yarn remove @babel/preset-env
yarn add @babel/plugin-transform-modules-commonjs --dev
// .babelrc
{
  "plugins": [
    "@babel/plugin-transform-modules-commonjs"
  ]
}
yarn babel-node index.js

本文地址:https://blog.csdn.net/qq_45149256/article/details/109212140

相关标签: 大前端 esm