前端模块化开发 - 开篇

240 阅读17分钟

今天我们来介绍 【前端模块化开发】。

模块化是当下最重要的前端开发范式之一,而掌握模块化开发也是在招聘 JD 里总会出现的条件。因此我们根据下面问题从 模块化的目的、演进过程 开始梳理,以此来快速了解 当下/未来 必要掌握的 【前端模块化开发】,本篇为开篇内容。

  1. 模块化的目的、概念 -> 解决的问题
  2. 模块化的演进过程
  3. 模块化的标准和规范
  4. 模块化 ES Modules 基本特性
  5. ES Module 导入和导出(最为核心和常用的功能)
  6. ES Modules Import 用法
  7. ES Modules 直接导出导入的成员(二次导出)
  8. ES Modules Polyfill 兼容方案
  9. ES Modules in Node.js

1. 模块化的目的、概念

随着前端应用的日益复杂,我们的项目代码已经逐渐膨胀到不得不花大量时间去管理的程度了,而模块化则是代码最主流的组织方式,它通过把我们的复杂代码按照功能的不同划分为不同的模块单独维护的方式去提高我们的开发效率、降低维护成本。

2. 模块化的演进过程

由于前端技术标准并没有预料到前端行业会有今天这样的规模,这就导致我们在实现前端模块化时遇到很多问题(因为没有标准吧)。现如今这些问题都被一些标准或者工具解决了,那么我们就先了解一下前端行业在实现模块化标准过程中遇到的是哪些问题以及解决问题的思想设计,以此渐进地了解如今模块化的演变过程。 我们先来看最早期的模块化方式:文件划分的方式。

  • 文件划分方式

这种方式会将每个功能及其依赖状态、数据等单独存放在不同的文件当中,然后通过约定的方式将每个文件看作是一个模块,如下图所示:

image.png

使用模块的方式则是在文件中通过 script 标签直接引用,如图: image.png 显而易见的,这种方式下,每个文件中定义的变量及函数将都是全局范围的。这样的危害也很明显:各个文件的命名空间很容易就会冲突,也会造成全局作用域污染,且模块内部对象在外部能够被任意的访问/修改,更会导致耦合严重不易维护... 还有很多危害,但我们只需简要叙述如下:

  • 命名冲突问题
  • 污染全局作用域
  • 耦合及安全问题
  • 无法管理模块依赖关系

这样就好记一些。 因此早期以文件划分的方式实际上就是完全依靠约定,当项目上到一定体量,这种方式就不行了。

  • 命名空间方式

为了解决命名冲突的问题,从文件划分的方式演变出命名空间方式来实现模块化,这种方式约定每个文件只暴露出一个全局对象,而文件中的所有成员只需挂载到这个对象下面,具体做法如下图:

image.png

这样,每个文件形成的模块就都拥有了各自的命名空间。

image.png

但这种方式仍然没能解决模块内部成员在外部被随意访问/修改的问题:模块仍然没有自己的私有空间,模块之间的依赖关系也没有解决。

  • IIFE(Immediately-Invoked Function Expression 立即执行函数)的方式

IIFE 这种方式会在文件(模块)内部声明一个匿名函数,且会立即执行。这样做的目的很明显:文件中所有的成员都将放置于立即执行的函数内部,以此形成该文件独立的作用域。 那么这样的话,外部岂不是访问不到文件(模块)内部了吗? 所以我们要把需要在外部使用/访问的成员通过上面提到的命名空间的方式,将需要在外部使用/访问的成员挂载在该文件(模块)暴露出的挂载在 window 上的全局对象上。示例如下:

image.png

这种方式实现了私有成员的概念,在模块外部是无法访问/修改其内部没有通过全局对象暴露出去的成员的,这些私有成员只能通过闭包的形式即对外暴露的模块方法去访问/修改,这就确保了私有成员的安全。如图所示:

image.png

我们还可以利用自执行函数的参数作为该文件(模块)的依赖声明去使用,这就使模块的依赖关系表现的更明显了。

image.png

就像上面我们能够很直接的看出这个文件(模块)依赖 JQuery。

以上就是前端行业在早期没有任何工具和规范下,对模块化落地的实践探究的几种可用的落地方式。 但这些方式也仍然无法很好地解决其它种种存在的问题,它们基本都是以约定的方式去实现的,且不同的开发者之间即使以相同的方式去实现模块化,也还是会出现些许细节上的不同,这都归结于模块化仍然没有统一的规范和标准。

3. 模块化的标准和规范

我们需要一个标准去规范模块化的实现方式,同时我们还需解决以上方式中存在的模块加载的问题(通过 script 标签直接加载)。

通过 script 标签直接加载的方式,意味着我们的模块加载并不受代码的控制。 一旦时间久了,维护起来相当麻烦,设想如下:在代码中使用了一个模块,而这个模块却忘了使用 html 引用,这种体验相当糟糕啊。又或者是在代码里移除了这个模块的使用,然后我们又忘记了在 html 里删除模块的引用,哇这更是相当糟糕。 因此我们就需要公共代码来实现 自动通过代码来加载模块

所以我们需要的是:模块化标准 + 模块加载器(模块加载基础库)。

  • CommonJS 规范

NodeJS 中提出的一套标准,在 NodeJS 中我们必须要遵循这套规范,标准简述如下:

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

我们能够悄咪咪地发现,这几个简要标准都恰好解决了早期的那几种模块化方式解决的问题:作用域问题、命名空间问题、按功能划分问题、成员导出安全问题等

那么既然 CommonJS 是一个 Node 环境的模块化标准,那么我们在浏览器环境去使用不就会出现问题吗? 我们需要前提了解的是:Node 是在启动时才会加载模块,因此 CommonJS 约定的则是以同步的方式加载模块,这个约定在 Node 环境当然合理,但是如果换到浏览器端,这个约定必然会导致效率低下:页面在加载时必然会出现大量的同步模式请求出现,所以在早期前端模块化当中,并没有选择 CommonJS 规范。 那么在浏览器端应该采用哪种规范呢?

  • AMD(Asynchronous Module Definition 异步地模块定义)规范

结合浏览器的特点,AMD 规范应运而生。

Require.js 是 AMD 规范诞生同期非常出名的库,它实现了 AMD 规范,且它本身也是一个强大的模块加载器。

Require.js 约定需要通过一个叫做 define 的方法去定义一个模块

image.png

define 方法各参数涵义:

  • 第一个参数:模块名
  • 第二个参数:模块的依赖项
  • 第三个参数:一个函数,函数的参数和依赖项一一对应,分别是前面依赖项各自导出的成员。函数的作用则是为模块提供一个私有的空间,通过 return 的方式导出外部所需的成员。

Require.js 还约定通过一个叫做 require 的方法去加载一个模块

image.png

Require.js 在 require 的内部实际上就是通过创建 script 标签的方式去加载模块并执行模块相应代码。 目前绝大多数第三方库都支持 AMD 规范,但 AMD 仍旧有些许不足:

  • AMD 使用起来相对复杂,可维护性较差
  • 模块 JS 文件请求频繁(底层仍是 script)

同时期还有 ali 出了一个 Sea.js,它实现的是 Common Module Definition CMD 规范,目的是为了让代码看起来和使用 CommonJS 规范类似,从而减轻开发者的学习成本,以作了解。

  • ES Modules 规范(模块化的最佳实践)

目前前端模块化规范上统一为:Node.js 上使用 CommonJS 规范,浏览器 上使用 ES Modules 规范。 ES Modules 是 ES2015 提出的特性,刚开始时许多浏览器是不支持的,而随着时间渐渐和 webpack 等工具的流行,ES Modules 逐渐成为目前主流的浏览器端的前端模块化规范,目前的浏览器支持如下:

image.png

4. 模块化 ES Modules 基本特性

ES Modules 算是 JavaScript 语言级的模块化系统规范,既然是语言级别的,那么我们就一定需要首先了解它的语法和特性。

  • 语法上和平时使用 script 标签抒写 JS 代码无异,只是增加了一个 type="module",如下所示:
<!DOCTYPE html>
<html lang="en">
  <body>
    <script type="module">
      console.log('this is es module')
    </script>
  </body>
</html>

  • ESM 会自动采用严格模式,忽略 'use strict'

也就是即使不加 use strict ,它内部的 JS 也将是严格模式执行的。

严格模式下,this 为 undefined;非严格模式下,this 为 window

<!DOCTYPE html>
<html lang="en">
  <body>
    <script type="module">
      console.log(this)
    </script>
  </body>
</html>

  • 每个 ES Module 都是运行在单独的私有作用域中

也就是说我们能够使用多个 script 来分割代码为不同模块,模块间的作用域都将是独立互不影响的。

<!DOCTYPE html>
<html lang="en">
  <body>
    <script type="module">
      var foo = 100
      console.log(foo)
    </script>
    <script type="module">
      console.log(foo)
    </script>
  </body>
</html>

运行后如图:

    image.png

  • ESM 是通过 CORS 的方式请求外部 JS 模块的

既然是 CORS 的方式,那么说明我们通过 src 引用的 JS 必须是同源的,否则将会存在跨域问题。

<!DOCTYPE html>
<html lang="en">
  <body>
    <script type="module" src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
  </body>
</html>

image.png

我们能够看到请求被浏览器终止掉了,所以我们务必要知道如果我们使用 ESM 的方式引入资源,那么服务的就一定要支持 CORS,不然会报错的哦。

  • ESM 的 script 标签会延迟执行脚本

说到延迟执行脚本,我们就能够想到 script 标签的 defer 属性,那么 ESM 的 script 标签就相当于添加了一个 defer 属性。

我们的网页默认是先执行 JS 再进行渲染的,而延迟执行则会先使网页完成渲染,再执行 JS 代码。

**

5. ES Module 导入和导出(最为核心和常用的功能)

上面我们讲到:ESM 都有独立的作用域,因此如果你想使用某个模块中声明的变量/方法,就总是需要在模块内导出该变量/方法,在希望使用的地方导入方可使用。那么显而易见的,如果某个模块没有导出任何变量/方法,那么你通过导入方法也无法导入任何东西。话不多说,我们直接通过示例来说明:

  • 基础导入和导出/批量导出

index.html

<!DOCTYPE html>
<html lang="en">
  <body>
    <script type="module" src="./app.js"></script>
  </body>
</html>

app.js

// 批量导入,导入后正常使用即可
import { text, testFunc } from './module.js'

console.log(text)

testFunc()

module.js

// 第一种导出方式 - 单独导出该变量
export const text = 'hello, this is es module'

const testText = 'this is also es module' // 该变量未导出,因此外部无法使用

const testFunc = () => {
  console.log(testText)
}
// 第二种导出方式 - 批量导出变量/方法
export { testFunc }

运行后如下图所示:(如果是 CORS 问题导致运行不成功,需要使用 browser-sync 启动服务运行)

image.png

browser-sync 安装:npm install -g browser-sync 使用:browser-sync . --files **/*.js files 参数为监听哪些文件变化

  • 导入和导出的重命名

我们可以在导入或导出时对变量/方法进行重命名来使用,这样就可以防止各模块里出现命名冲突的问题

const text = 'hello, this is es module'
// 导出时重命名
export { text as  appText}
// 导出的重命名
import { appText as t } from './module.js'
console.log(t)
  • 默认导入和导出

我们能够使用 default 关键字来定义一个模块默认导出什么东西,以此来方便我们在导入时通过直接导入(不使用花括号)的形式进行导入

const text = 'hello, this is es module'
export default text
import text from './module.js'
console.log(text)
  • ** 导入和导出需要注意的点**
    • export { text },export 使用的花括号并不是对象字面量的简写形式,而是一个固定的语法用法
    • export default { text },这里的花括号则实为对象字面量的简写形式
    • 同样地,import { appText as t } from './module.js',这里的花括号并不是结构的意思,而是和 export 同样固定的用法
    • export 导出的是变量/方法的引用,也就是只是把该变量/方法的内存地址导出了,并不是复制了一份导出的
    • ESM 导出的引用在外部是无法进行修改的,导入时会自动转为只读常量

6. ES Modules Import 用法

  • 导入时,不可省略文件扩展名
// .js 的文件扩展名是不可省略的,省略会直接报错
import text from './module.js'
  • 即使是导入目录下的 index 文件,也需填写完整,不会默认导出目录下的 index
// 即使导入目录下的 index 文件,也许填写完整
import text from './utils/index.js'
  • 点+斜线不可省略
// 这里的 ./ 开头是不可省略的,如果省略的话,ES Modules 会认为我们是在加载第三方库
import text from './module.js'
// 除了使用点+斜线的相对路径,我们还是可以使用斜线开头的绝对路径的,默认会从网站根目录查找
import text from '/module.js'
// 我们还可以使用完整的 url 引入模块
import text from 'http://localhost:3000/test/module.js'
  • 纯粹引入并触发使用
// 这个需求就在于我们的脚本并不需要导出,而是直接加载即可,那么我们可以这样加载该模块
import {} form './module.js'
// 简写如下
import './module.js'
  • 星号 * 以全部提取并重命名使用
import * as mod form './module.js'
console.log(mod.text)
  • 在脚本运行时动态引入模块

我们总会有这样的需求:模块的名字在脚本运行时才得到,或者在 if 条件里才可以导入而 import 关键字仅支持在全局最顶部出现 我们又不能像下面这样使用 import

// 不能这样用
const modulePath = './module.js'
import { text } from modulePath
// 也不能这样用
if(true){
	import { text } from './module.js'
}

ESM 提供了一个全局的函数,使之能够动态地导入模块,具体用法如下:

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

因为模块加载是一个异步过程,该函数返回一个 Promise

  • ****”和批量导出

假设我们存在下面模块,该模块使用 default 进行默认导出,同时又批量导出了一些变量/方法

const text = 'hello, this is es module'

const testText = 'this is also es module'

const testFunc = () => {
  console.log(testText)
}

// 批量导出
export { testFunc }
// 导出默认
export default {
	text, testFunc
}

我们在导入时如果希望既导入默认导出的对象,又进行批量导入,我们应该如何导入呢?

// modduleObj 为模块默认导出的对象,名字自定义随便起
// 花括号里仍然是批量导入的变量/方法
import modduleObj, { testFunc } from './module.js'

7. ES Modules 直接导出导入的成员(二次导出)

// 第一种情况
// 在导入时直接通过 export 关键字进行导出即可
export { text } from './module.js'
// 当然这种导出只针对下面的形式
// module.js
const text = 'this is es module'
export { text }

// 第二种情况
// 在导入时通过 default 重命名的方式来导出默认导出
export { default as mod} from './module.js'
// 这种导出针对的是默认到处形式
// module.js
const text = 'this is es module'
export default { text }

8. ES Modules Polyfill 兼容方案

因为 ES Modules 是近年出现的模块化标准方案,而 IE、UC、Baidu 等浏览器对此还没有出现相应的支持,那么我们就需要通过一些方案去做掉在不支持的浏览器上进行兼容的问题。 那么我们首先介绍一下 Polyfill 这个方案,它能够让浏览器支持绝大部分 ES Modules 的特性。 这个模块的名字叫做:ES Module Loader

image.png

这个模块实际上就是个 JS 文件,我们只需按 Readme 中的 Installation 和 Usage 提供的使用方法引入到页面当中即可。

babel-browser-build.js 是 babel 即时运行在浏览器的版本文件,polyfill 的工作原理实际上就是通过 es module 把代码读出来,然后交给 babel 去转换,从而让代码正常工作。 值得一提的是,在 IE 浏览器上,即使通过这样去引入 es module loader,也无法正常运行脚本,因为 IE 还没有支持 Promise 所以我们还需要专门为 IE 引入 Promise Polyfill:Promise Polyfill

我们成功引入 es module loader 后,在支持 ES Module 的浏览器中,我们的 JS 代码将会执行两次.... 就是因为 es module loader 会执行一次,浏览器自身也会执行一次。对于这个问题,我们可以通过 script 标签的 nomodule 属性来解决。 nomodule 属性让我们的 JS 代码仅在不支持 ES Modules 的浏览器中工作,那么我们就可以给引入的 polyfill script 添加 nomodule 属性来解决重复执行代码的问题,示例如下:

<script nomodule src="polyfill.min.js"></script>
<script nomodule src="babel-browser-build.js"></script>
<script nomodule src="browser-es-module-loader.js"></script>

这种兼容方式最好只是在我们开发环境玩一玩,因为这种动态转换是在运行时进行转换的,那么在生产环境就会导致性能特别的差。 那么我们又该如何进行兼容呢? 我们可以在发布到生产环境前直接编译时把 ES Module 代码转换为兼容浏览器能够工作的代码,这种编译方式我们放在后面使用模块打包工具时再详细说。

9. ES Modules in Node.js

ES Modules 既然是 JavaScript 语言层面的模块化标准,那么它一定会统一所有 JavaScript 领域的模块化需求,而 Node.js 作为 JS 非常重要的领域,它目前也正在逐步支持 ES Modules 的特性,自从 Node 8.5 版本过后,内部就开始以实验的方式支持 ES Modules 了:

image.png

但是我们不能太乐观,ES Modules 在 Node.js 的应用和 CommonJs 比的差距还是特别大的,因此目前这个特性还处于过渡状态。 我先放两张图,这个是在 Node 中使用 ES Modules 特性时报出的 Warning 以及使用一些尚未支持的 ES Modules 特性时报的错:

image.png image.png

所以,我们在 Node.js 里使用的方式就先不多加赘述了,我们了解之后在 JS 领域里,ES Modules 模块化方案一定会一统江湖就好了! 当然,如果你仍然希望知晓在 最新 Node 环境使用 ES Modules,请关注我的后续文章。

总结

以上内容仅为【前端模块化开发】专题的开篇内容,偏向历史概念了解与基础语法特性的使用。 接下来我会就 Webpack 、Rollup、ESLint及其它规范化标准三个方面,继续输出 【前端模块化开发】相关文章。 届时会将后续文章链接贴于此处,欢迎大家持续关注~