模块化开发

440 阅读14分钟

概述

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

而就单模块化这个词而言,它仅仅是一个思想,或者是一个理论,并不包含具体的实现。接下来我们就学习如何在前端项目当中去实践模块化这样一个思想,以及目前行业中主流的工具。

概要

  • 模块化演变过程
  • 模块化规范

模块化演变过程

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

Stage1 - 文件划分方式

这是Web中最原始的模块系统。具体的做法就是将每个功能以及它相关的状态数据单独存放在不同的文件当中,我们去约定每个文件就是一个独立的模块,使用时将这个模块引用到页面当中,一个Script标签就对应一个模块,然后在代码当中直接调用模块的成员(变量、函数)。 module-a.js

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

var name = 'module-a'

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

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

module-b.js

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

var name = 'module-b'

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

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

index.html

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

这种方式的缺点十分明显:

  • 污染全局作用域:所有模块都直接在全局工作,没有独立的私有空间,导致模块的所有成员都可以在模块外部被任意地去访问或者修改;
  • 命名冲突问题:模块多了过后很容易产生命名冲突;
  • 无法管理模块依赖关系 早期模块化完全依靠约定,项目体量多了之后就完全不行了。

Stage2 - 命名空间方式

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

module-a.js

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

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

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

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

module-b.js

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

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

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

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

index.html

<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
  moduleA.method1()
  moduleB.method1()
  // 模块成员可以被修改
  moduleA.name = 'foo'
</script>

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

Stage3 - IIFE

使用立即执行函数表达式(IIFE:Immediately-Invoked Function Expression)为模块提供私有空间。具体做法就是将每个模块成员都放在一个函数提供的私有作用域中,有了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问。确保了私有成员的安全。对于需要暴露给外部的成员,通过挂在到全局对象上的方式实现。

module-a.js

// 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.js

// 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
  }
})()

index.html

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

还可以利用自执函数的参数作为我们的依赖声明去使用,这样模块之间的依赖关系就变得明显了。例如在模块A当中使用jQuery,就可以在该模块的立即调用函数中接受一个参数,在立即调用时就传递jQuery参数,这样在后续维护这个模块时就很清楚地知道这个模块依赖jQuery。

module-a.js

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

;(function ($) {
  var name = 'module-a'
  
  function method1 () {
    console.log(name + '#method1')
    $('body').animate({ margin: '200px' })
  }
  
  function method2 () {
    console.log(name + '#method2')
  }

  window.moduleA = {
    method1: method1,
    method2: method2
  }
})(jQuery)

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

模块化规范的出现

以上的方式都是以原始模块为基础,通过约定的方式去实现模块化的代码组织,这些方式对于不同的开发者去实施的时候会有差别,所以为了统一不同的开发者和不同的项目之间的差异,我们需要一个标准区规范模块化的实现方式。另外,在模块化当中针对模块加载的问题在这几种方式当中都是通过script标签手动引入每一用到的模块,意味着模块加载不受代码控制,一旦时间久了维护起来会非常麻烦。试想如果代码中依赖了一个模块而html当中忘记引入就会出现问题,又或者在代码当中移除了某个模块的引用,然后又忘记在html中删除这个模块的引用,这些都会产生很大的问题,所以说我们需要一些基础的公共代码去实现自动加载模块,也就是说我们需要模块化标准+模块加载器。

CommonJS 规范

它是Node.js当中提出的一套标准,在Node.js中所有模块化代码必须遵循CommonJS 规范。它约定了一下内容

  • 一个文件就是一个模块
  • 每个模块都有单独的作用域
  • 通过module.exports导出成员
  • 通过require函数载入模块 但是当我们想在浏览器端使用这个规范就会出现一些问题,因为CommonJS是以同步的模式加载模块,Node.js的机制是在启动时去加载模块,执行过程中是不需要加载的,只会去使用模快,所以说这种方式在Node中使用不会有问题。但是如果是浏览器端去使用CommonJS规范必然导致我们效率低下,因为每次页面加载都会导致大量的同步模式请求出现,所以在早期的前端模块化当中并没有选择CommonJS这个规范,而是针对浏览器的特点重新设计了一个规范AMD。

AMD(Asynchronous Module Definition)

异步的模块定义规范。同期还推出了一个非常出名的库Require.js,它实现了AMD规范,另外它本身还是一个强大的模块加载器。

在AMD中约定通过define函数去定义一个模块,这个函数默认可以接受两个参数,也可以传递三个参数,如果传递三个参数的话,第一个参数就是模块的名字,可以在后期加载这个模块的时候使用;第二个参数是一个数组,用来声明该模块的依赖项;第三个参数是一个函数,函数的参数与前面的依赖项一一对应,每一项为依赖项导出的成员,函数的作用可以理解为当前模块提供一个私有的空间,如果说需要为该模块导出一些成员,可以通过return的方式实现。

// 定义一个模块
define('module1', ['jquery', './module2'], function ($, module2) {
  return {
    start: function () {
      $('body').animate({ margin: '200px' })
      module2()
    }
  }
})

除此之外还提供require函数去自动加载一个模块,用法与define类似。一旦用require加载模块,它内部会自动创建script标签去发送对应的脚本文件请求并且执行相应的模块代码。

// 载入一个模块
require(['module1'], funciton(){
  module1.start()
})

目前绝大多数第三方库都支持AMD规范,也就是说AMD生态是比较好的,但是它

  • 使用起来相对复杂:因为在代码的编写过程当中除了业务的代码还需要去使用很多的define和require操作模块的代码,导致代码的复杂程度提高;
  • 模块JS文件请求频繁:当项目中模块划分得过于细致,那在同一个页面中对JS文件的请求次数会特别多,导致页面效率低下 所以说个人认为AMD只能算是前端模块化演进道路上的一步,是一种妥协的实现方式,并不能算是最终的解决方案。除此之外,同期淘宝推出了Sea.js + CMD(Common Module Definition),它类似于CommonJS,在使用上和Require.js也基本差不多,可以算是一个重复的轮子,后来被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>')
  }
})

模块化标准规范

模块化的最佳实践

CommonJS in Node.js

CommonJS 是内置的模块系统没有任何的环境问题直接遵循CommonJS规范即可,使用require载入模块,通过mudule.export导出模块。

ES Modules in Browers

ES Modules是ECMAScript 2015(ES6)定义的最新的模块系统,所以会存在环境兼容问题。最早在这个标准刚推出的时候,所有的主流浏览器都是不支持这样特性的,但是随着Webpack等一系列打包工具的流行这一规范才逐渐开始普及。截止目前ES Modules已经是前端最主流的前端模块化方案了。

相比AMD这种社区提出来的开发规范,ES Modules是在语言层面实现了模块化,所以说它更为完善。另外,现如今绝大数浏览器已经开始支持ES Modules特性了,原生支持也就是意味着我们可以直接使用开发应用了。终上所述,针对于在不同的环境当中更好地使用ES Modules会成为我们接下来学习的重点。

ES Modules

基本特性

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

<script type="module">
  console.log('this is es module')
</script>

相对于普通的script标签会有新的特性:

  • 自动采用严格模式 ('use strict'文件头声明)
<script type="module">
  // 严格模式不能在全局模式下使用this,非严格模式下this指向全局对象
  console.log(this) // undefined
</script>
  • 每个ESM模块都是单独的私有作用域
<script type="module">
  var foo = 100
  console.log(foo)
</script>
<script type="module">
  console.log(foo) // foo is not defined
</script>
  • ESM是通过CORS去请求外部JS模块的
<script type="module" src="https://unpkg.com/jquery@3.4.1/dist/jquery.min.js"></script>

注意请求的地址必须要支持CORS

  • ESM的script标签会延迟执行脚本 demo.js
alert('hello')
<script defer src="demo.js"></script>
<p>需要显示的内容</p>
<!-- 正常情况下脚本的加载会阻塞页面的渲染 -->
<!-- defer 网页渲染过后再执行 -->

导入和导出

基本用法

  • export导出
  • import导入
export var name = 'foo module'

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

export class Person {}

也可以在末尾一起导出,可以更直观地描述该模块向外提供了哪些成员。

var name = 'foo module'

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

class Person {}

export { name, hello, Person }

可以用as关键字重命名后再导出

export {
  name as fooName,
  hello as fooHello
}

当导出成员重命名为default,那这个成员就是该模块默认导出的成员,那在导入时必须给该成员重命名

export {
  name as default,
  hello as fooHello
}
import { defelut as fooName } from './xxx'

直接通过export default + 变量,则该变量就是该模块的默认导出,导入时直接import 变量名的方式接收默认导出的成员

export defalut name
import fooName from './xxx'

注意事项

  1. { } 是固定语法,不是对象字面量,也不是解构(只是直接提取模块导出成员)
var name = 'jack'
var age = 18

export { name, age }
// 可通过 import { name, age } from 'xxx'导出

这里的 { name, hello } 不是一个对象字面量,它只是语法上的规则而已。

var name = 'jack'
var age = 18

export default { name, age }

以上通过export default导出的{ name, age }是一个对象,对象中有两个属性。

  1. export时导出的是成员的引用地址,并不是复制一个副本 通过一个小例子说明

module.js

var name = 'jack'
var age = 18
export { name, age }

setTimeout(function () {
  name = 'ben'
}, 1000)

app.js

import { name, age } from './module.js'
console.log(name, age)
setTimeout(function () {
  console.log(name, age)
}, 1500)

=> jack 18
1.5s后 =>  ben 18
  1. 导入模块成员变量是只读的
import { name, age } from './module.js'
name = 'tom' // 报错 Uncaught TypeError: Assignment to constant variable.

如果导入的是一个对象,对象的属性读写不受影响

import 用法

  • 不能省略文件扩展名.js
// import { name } from './module'
import { name } from './module.js'
  • 不能省略index.js(CommonJS载入目录即可)
// import { lowercase } from './utils'
import { lowercase } from './utils/index.js'

对于文件路径名称,在后期使用打包工具去打包模块时就可以实现沈略扩展名、index.js的操作了。

  • 不能省略 . /(以字母开头会以为在加载第三方模块)
  1. . 开头相对路径
  2. / 开头绝对路径,从项目根目录
  3. 使用完整url加载模块,意味着可以直接使用cdn上的文件
// import { name } from 'module.js'
import { name } from './module.js'
import { name } from '/04-import/module.js' 
import { name } from 'http://localhost:3000/04-import/module.js'
  • 只需要执行某个模块,不需要提取模块中的成员 在导入一些不需要外界控制的子模块非常有用
import {} from './module.js'
简写成 import './module.js'
  • 提取模块中的所有成员,用as的方式将所有成员放到一个对象当中 模块中导出的成员非常多,导入时都会用到它们时
import * as mod from './module.js'
console.log(mod)
  • 动态import模块 不能使用import关键词去from一个变量,且import只能出现在最顶层
// var modulePath = './module.js'
// import { name } from modulePath
// console.log(name)

// if (true) {
//   import { name } from './module.js'
// }

ESM提供了一个全局的import函数用于动态导入模块

import('./module.js').then(function (module) {
  console.log(module)
})
  • 同一个模块中导出了一些命名成员又导出了一个默认成员
var name = 'jack'
var age = 18

export { name, age }

export default 'default export'
import { name, age, default as title } from './module.js'import abc, { name, age } from './module.js'

导出导入成员

export { foo, bar } from './module.js'

在当期作用域中不可使用导入成员foo、bar

通常在index中将散落的文件集中导出

export { Button } from './button.js'
export { Avatar } from './avatar.js'

ES Modules 浏览器环境 Polyfill 兼容方案

<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>

将浏览器不识别的ES Modules交给babel去转换,对于需要import进来的文件通过Ajax的方式请求,把请求回来的代码再通过babel去转换,从而支持ESM。

对于支持ES Modules 的浏览器以上方案会执行两次模块的代码(browser-es-module-loader执行一次,浏览器执行一次)导致脚本被重复执行。这个问题可以借助script的新属性(nomodule)解决,它是一个布尔值的属性,如果在script标签上添加了nomodule,那script里的内容只会在那些不支持ES Modules的浏览器中执行。

<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>

以上方案只能在开发阶段使用,因为是在运行阶段动态地解析脚本,效率会非常差,生产环境还是应该预先编译成生产环境支持的代码。

ES Modules in Node.js

支持情况

ES Modules作为JavaScript语言层面的模块化标准会逐渐统一JS应用的模块化需求,Node.js作为JS一个非常重要的领域,目前已经逐步支持该特性,自从Node 8.5 版本过后,内部已经以实验特性的方式支持ESM了。但是原来的CommonJS规范与ESM差距很大,所以目前该特性还一直处于过渡状态。

确认 Node version>8.5

在Node.js中使用ESM需要做一下两件事

  1. 将文件的扩展名由 .js 改为 .mjs
  2. 启动时需要额外添加 --experimental-modules 参数,表示启用ESM实验特性 module.mjs
export const foo = 'hello'

export const bar = 'world'

index.mjs

import { foo, bar } from './module.mjs'

console.log(foo, bar)
$ node --experimental-modules index.mjs
=>node:23052) ExperimentalWarning: The ESM module loader is experimental.
=>hello world

此时我们也可以通过 esm 加载内置模块了

import fs from 'fs'
fs.writeFileSync('./foo.txt', 'es module working')

对于第三方的 NPM 模块也可以通过 esm 加载

import _ from 'lodash'
_.camelCase('ES Module')

不支持提取第三方模块成员,因为第三方模块都是导出默认成员,{ camelCase }并不是解构

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

可以直接提取模块内的成员,内置模块兼容了 ESM 的提取成员方式,对成员单独导出了一次

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

与CommonJS模块交互

  • ES Modules 中可以导入 CommonJS 模块 commonjs.js
module.exports = {
  foo: 'commonjs exports value'
}
// (module.exports的别名) exports.foo = 'commonjs exports value'

es-module.mjs

import mod from './commonjs.js'
console.log(mod)

=> (node:39354) ExperimentalWarning: The ESM module loader is experimental.
=> { foo: 'commonjs exports value' }
  • 用ESM导出时,CommonJS 模块始终只会导出一个默认成员 es-module.mjs
import { foo } from './commonjs.js'
console.log(foo)
// 报错 不能直接提取成员,注意 import 不是解构导出对象
  • 不能在 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)
// 报错

与CommonJS模块的差异

  • ESM 中没有模块全局成员 commonjs.js
// 加载模块函数
console.log(require)

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

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

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

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

es-module.js

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

__filename 和 __dirname 通过 import 对象的 meta 属性获取

// 通过 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)
新版本进一步支持ESM

node v12.10.0

在package.json中添加type字段设置为mudule后项目的所有js文件默认以ESM工作不用再将扩展名改成.mjs了。此时需要将使用CommonJS规范的文件作单独的处理,修改成.cjs的扩展名。

Babel 兼容方案

早期的Node.js低版本可使用Bebel做兼容,Babel是目前最早的JavaScript编译器,可以用来将新特性的代码编译成当前环境支持的代码。

node v8.0.0

index.js

import { foo, bar } from './module.js'

console.log(foo, bar)
$ yarn add @babel/node @bebel/core @bebel/preset-env --dev
$ yarn bebel-node index.js

不过直接如上运行还是会报错(import不被支持),原因是babel是基于插件机制实现的,它的核心模块并不会去转换我们的代码,具体要去转换我们代码中的每一个特性是通过插件实现的。

preset-env是插件的集合,包含了最新的JS标准当中所有的新特性.

所以就可以借助preset直接转换ES Modules。

$ yarn bebel-node index.js --presets=@bebel/preset-env

每次传入这个参数比较麻烦,可以选择把它放在配置文件当中。

.babelrc

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

这样就可以直接运行yarn bebel-node index.js不用再添加参数了。

实际上帮我们转换的是一个插件不是preset(只是一个集合)。所以作一下尝试 先把preset移除掉yarn remove @bebel/preset-env,安装插件yarn add @babel/plugin-transform-modules-commonjs,然后把配置文件改成

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