模块化开发

390 阅读10分钟

模块化开发是当下最重要的前端开发范式之一。模块化是最主流的代码组成方式,通过把复杂代码按照功能的不同划分成不同模块单独维护,提高开发效率,降低维护成本。模块化只是思想,并不包括具体的实现。

模块化的演进过程

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

Stage 1 - 文件划分方式

具体做法就是将每个功能及其相关状态数据各自单独放到不同的文件中,约定每个文件就是一个独立的模块,使用模块就是将模块引入到页面当中,一个 script 标签就对应一个模块,然后直接调用模块中的成员(变量 / 函数)。

问题:

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

早期模块化完全依靠约定。

Stage 2 - 命名空间

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

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

Stage 3 - IIFE

使用立即执行函数表达式(IIFE:Immediately-Invoked Function Expression)为模块提供私有空间。

具体做法就是将每个模块成员都放在一个函数提供的私有作用域中,对于需要暴露给外部的成员可以挂载到全局对象的方式去实现,私有成员只能通过内部的成员通过闭包的方式去访问,这样确保了私有变量的安全,而且自执行函数的参数可以传入模块的依赖

早期在没有工具和规范的情况下对模块化的落地方式

模块化规范的出现

上面的方式以原始的模块系统为基础,通过约定的方式去实现模块化的代码组织,对于不同的开发者去实施会有细微的差别,为了统一不同的开发者和不同的项目之间的差异,就需要一个标准去规范模块化的实现方式。且对于模块加载的方式都是通过手动写 script 的方式去加载的,模块的加载并不受代码的控制,一旦时间久了,维护就变十分棘手。所以我们需要 模块化规范 + 模块加载器。

CommonJS 规范是 node.js 中提出的标准

CommonJS 规范规定:

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

如果在浏览器端也使用这个规范的话就会出现一些问题,CommonJS 是以同步模式加载模块,因为 node 的执行机制是在启动时去加载模块,执行过程中是不需要去加载的,它只会去使用到模块,这个模式在 node 中是没有问题的,但在浏览器端就不太合适了,因为每个页面的加载都会导致大量的同步请求出现,所以在早期的模块化当中,并没有选择 CommonJS 这个规范,而是专门为浏览器端,结合浏览器的特点,重新设计了一个规范AMD,同时还有 Require.js 的出现,它实现了 AMD 这个规范,它本身也是强大的模块加载器。

目前绝大多数第三方库都支持 AMD 规范,但也有别的问题。

  • AMD 使用起来相对复杂
  • 模块分得过于细致的话,模块 JS 文件请求频繁

AMD 只能算是前端模块化演进道路上的一步,它是一种妥协的实现方式,并不能算是最终的解决方案,不过在当时的环境背景下,它还是很有意义的,它毕竟给了前端模块化提了一个标准,除此之外,还有淘宝推出的一个 Sea.js 的库,它实现的是 CMD 的标准,有点类似 CommonJS,在使用上也和 Require.js 差不多,为的就是 CMD 写出来的代码尽量和 CommonJS 类似,从而减轻开发者的学习成本,后来这种方式也被 Require.js 所兼容了。

模块化标准规范

而现在 JavaScript 的标准也越来越完善了,现如今的前端模块化已经非常成熟了,而且目前大家针对前端模块化的最佳实践方式也都基本统一了。

在 node.js 环境下遵循 CommonJS 规范去组织模块,在浏览器环境中采用 ES Module 的规范。

在 node.js 内置 CommonJS 的模块系统,没有什么环境问题,ES Module 就不一样了,它是在 ECMAScript 2015 中定义的最新的模块系统,所有会有环境兼容问题,随着 webpack 一系列打包工具的流行,这一规范在逐渐开始普及,现在 ES Module 已经是最主流的前端模块化方案了。

相比于 AMD 这种社区提出的开发规范,ES Module 可以说是在语言层面实现了模块化,现如今大多数浏览器也已经支持 ES Module 的特性了,原生支持,这样以后我们就可以直接用 ES Module 去开发我们的网页应用了,短期内也不会再有针对模块化的一个轮子或者标准出现了,所以在不同的环境如何使用好 ES Module 就成了重点。

ES Module 特性

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>ES Module - 模块的特性</title>
</head>
<body>
  <!-- 通过给 script 添加 type = module 的属性,就可以以 ES Module 的标准执行其中的 JS 代码了 -->
  <script type="module">
    console.log('this is es module')
  </script>

  <!-- 1. ESM 自动采用严格模式,忽略 'use strict' -->
  <script type="module">
    console.log(this) // undefined
  </script>

  <!-- 2. 每个 ES Module 都是运行在单独的私有作用域中 -->
  <script type="module">
    var foo = 100
    console.log(foo) // 100
  </script>
  <script type="module">
    console.log(foo) // Uncaught ReferenceError: foo is not defined
  </script>

  <!-- 3. ESM 是通过 CORS 的方式请求外部 JS 模块的 -->
  <script type="module" src="https://unpkg.com/jquery@3.4.1/dist/jquery.min.js"></script>

  <!-- 4. ESM 的 script 标签会延迟执行脚本 -->
  <script defer src="demo.js"></script>
  <p>需要显示的内容</p>
</body>
</html>
  1. ESM 自动采用严格模式,忽略 'use strict'
  2. 每个 ES Module 都是运行在单独的私有作用域中。
  3. ESM 是通过 CORS 的方式请求外部 JS 模块的,所以也不支持文件形式去访问,所以要用 http 的方式去让页面工作起来
  4. ESM 的 script 标签会自动延迟执行脚本,相当于加了 defer 属性,网页对默认的 script 标签采用的是立即执行的机制,页面的渲染会等待这个脚本执行完成才会往下渲染

ES Module 导出和导入

// module.js
var name = 'foo module'
export { name as default }

// app.js
import { default as name } from './module.js'
// 这里必须重命名 default,因为 default 是关键字

注意事项

// module.js
var name = 'jack'
var age = 18
// export { name, age } // 这里不是导出的字面量声明的对象,这里是语法,必须使用花括弧
export default { name, age } // 这里导出的才是字面量声明的对象,这个不一定是对象,只要是一个值就行

// app.js
import { name, age } from './module.js' 
// 如果上面是 export default,那这里的 import 是要报错的
// import 后面的跟的这个花括弧也不是结构的意思,就只是语法

es module export 导出的都是引用,node 中导出的是值的浅拷贝。

export 的是引用,即使声明的不是常量,但是 import 的值是只读的。

// module.js
var name = 'jack'
var age = 18
export { name, age }

// app.js
import { name, age } from './module.js'
name = 'tom' // 报 TypeError,import 的 name 是常量,即使在 module 中声明不是常量

原生的 import xxx from './xxx.js' 这里的 .js 是不能省略的,必须是完整的路径,哪怕是 index.js 都是不能省略的。

// 1
import { name } from './module.js' // 相对路径的要以 `.` 开头,不然的话会被当成第三方模块
import { name } from '/src/module.js' // 也可以用绝对路径,就是从项目的根目录下去算路径
import { name } from 'http://localhost:8080/src/module.js' // 也可以用完整的 url 去加载模块,这样也可以直接去引用 cdn 上的资源

// 2
import {} from './module.js' 
import './module.js' // 只执行模块,但是不需要提取模块的成员的话可以简写

// 3
import * as mod from './module.js' // 如果导出和使用的对象特别多

// 4
var modulePath = './module.js'
import { name } from modulePath
if (true) {
  import { name } from './module.js'
}
// 上面这个变量和条件判断的导入方法都是不可行的
// 需要用到根据逻辑去导入的时候就可以用 import 函数,函数返回 promise
// 模块加载结束之后,会自动去执行 then 里的函数
import('./module.js').then((module) => console.log(module))

// 5
import { default as title, name } from './module.js' // 当有默认成员和具名成员都要提取的时候,可以这样写
import title, { name } from './module.js' // 简写

导入导出结合

export { foo, bar } from './module.js' // 导出了刚引入的成员,但是在这个模块内也是不能使用了

export { default as foo } from './module.js'

可以在文件夹下写一个 index.js 去集中导出文件夹下的所有其他模块

ES Modules in Node.js

js 的后缀改成 mjs

node --experimental-modules index.mjs

// 第一,将文件的扩展名由 .js 改为 .mjs;
// 第二,启动时需要额外添加 `--experimental-modules` 参数;

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

// 此时我们也可以通过 esm 加载内置模块了
import fs from 'fs'
fs.writeFileSync('./foo.txt', 'es module working')

// 也可以直接提取模块内的成员,内置模块兼容了 ESM 的提取成员方式
import { writeFileSync } from 'fs'
writeFileSync('./bar.txt', 'es module working')

// 对于第三方的 NPM 模块也可以通过 esm 加载
import _ from 'lodash'
_.camelCase('ES Module')

// 不支持,因为第三方模块都是导出默认成员
// import { camelCase } from 'lodash' // 这个 {} 不是解构
// console.log(camelCase('ES Module'))

与 CommonJS 模块交互

common.js

// CommonJS 模块始终只会导出一个默认成员

module.exports = {
  foo: 'commonjs exports value'
}

exports.foo = 'commonjs exports value'

// 原生不能在 CommonJS 模块中通过 require 载入 ES Module,在 webpack 也许可以
const mod = require('./es-module.mjs')
console.log(mod)

es-module.js

// ES Module 中可以导入 CommonJS 模块
import mod from './commonjs.js'
console.log(mod)

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

export const foo = 'es module export value'
  • ES Modules 中可以导入 CommonJS 模块
  • CommonJS 中不能导入 ES Modules 模块
  • CommonJS 始终只会导出一个默认成员
  • 注意 import 不是解构导出对象

与 CommonJS 模块的差异

在 mjs 中不能使用 requiremoduleexports__filename__dirname,其实这五个成员其实是 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)

新版本 node 中的 ES Module

node 在 12.10 版本下,在 package.json 里,"type" 设置为 "module" 的话,项目里的所有 js 后缀就不需要改写成 mjs,但这种情况下,CommonJS 的模块后缀就要改成 cjs

Babel 兼容方案

如果使用的是早期的 node.js 版本,那可以使用 babel 去 esm 的兼容,babel 是目前主流的一块 JavaScript 编译器,它可以将我们使用了新特性的代码编译成我们当前环境支持的代码,有了 babel 我们就可以发货新的在大多数环境下去使用 babel 的新特性了

yarn add @babel/node @babel/core @babel/preset-env --dev

yarn babel-node index.js 直接运行可能会报错,babel 是基于插件机制去实现的,它的核心模块并不会去转换我们的代码,具体要去转换项目中的每一个新特性是通过插件去实现的,preset-env 其实是插件的集合,在这个插件的集合中包含了最新的 JS 中的所有的新特性

yarn babel-node index.js --presets=@babel/perset-env

如果不想跟上 --presets=@babel/perset-env 参数的话就使用配置文件

{
  "presets": ["@babel/preset-env"]
}

preset-env 也只是插件的集合,实际上去转换 module 的是插件,也就是 @babel/plugin-transform-modules-commonjs,可以删除 @babel/perset-env,然后安装对应新特性的插件

{
  "plugins": [
    "@babel/plugin-transform-modules-commonjs"
  ]
}