前端模块化-ES Modules

598 阅读12分钟

概述

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

模块化只是一个思想,不包含具体的实现。让我们看一看,目前我们是如何在前端项目中去实践模块化思想的,和目前的主流的方式和工具。

模块化演变过程

  1. 基于文件划分的方式实现模块化

具体做法:将每个功能,及其相关数据放到不同的文件当中,约定每个文件就是一个独立的模块,使用模块的方式就是,将每个文件引入到页面当中,一个script标签对应一个模块,再去在代码中去调用模块中的全局成员(变量/函数)

缺点:

  1. 污染全局作用域,所有模块都是在全局范围内工作,没有一个独立的私有空间,导致模块中所有的成员都可以在外部被任意的访问和修改
  2. 命名冲突问题,模块一旦多了之后,很容易产生命名冲突
  3. 无法管理模块之间的依赖关系 总的来说,这种方式完全依靠约定,项目体量上去之后,就不行了
  1. 命名空间方式 约定每个模块只暴露一个全局的对象,所有的模块成员都挂载到这个对象下面,将每个模块包裹到全局对象的方式去实现,有点类似于在模块内去为模块内的一些成员去添加了命名空间的一种感觉

    1. 减小了命名冲突的可能,
    2. 但是这种方式仍然没有私有空间,模块中的成员仍然可以在外部被访问被修改
    3. 也没有解决模块之间的依赖关系
  2. 使用立即执行函数的方式为模块提供私有空间 具体做法:将模块中的每个成员都放在一个函数提供的私有作用域当中,对于需要暴露给外部的成员,可以通过挂载到全局对象上面去实现,

;(function(){
	...
	window.moduleA ={	// 挂载到全局对象上面
    method1: method1,
    method1: method1,
    }
})()

这种方式实现了私有成员的概念,私有成员只能在模块内部的成员通过必报的方式去访问,而在外部是没有办法访问的,确保了私有变量的安全
还可以用自执行函数的参数,去作为依赖声明去使用,使每一个模块之间的依赖关系变得更加明显了,后期维护的时候,明显的知道当前模块需要依赖的模块

模块化规范

前几种对于模块加载的问题,都是通过script标签手动的去引入每个模块,这也就意味着我们的模块加载并不受我们代码的控制,一旦时间久了之后,不利于维护。
我们需要一些基础的公共代码,通过代码加载模块,所以说,我们需要一个模块化的标准和可以用来自动加载模块的基础库

CommonJS规范

node中的遵循的模块化规范

  • 一个文件就是一个模块
  • 每个模块都有单独的作用域
  • 通过module.exports导出成员,通过require函数载入成员 CommonJS约定是以同步模式加载模块,node的执行机制是在启动的时候加载模块,执行过程中不需要加载,只会使用这些模块,这种方式在node中不会有问题,但是在浏览器端,使用CommonJS,必然导致每次页面加载导致大量的同步请求出现

早期的浏览器端,没有使用CommonJS规范,而是专门为浏览器端设计了AMD规范,异步的模块定义规范,同期产生了requirejs库,约定每个模块都需要通过define函数定义,默认接受两个参数,也可以传三个参数,

  1. 第一个参数是模块名字,在后期加载这个模块的时候、使用
  2. 第二个参数是一个数组,声明当前模块的依赖项
  3. 第三个参数是一个函数,参数和第二个参数中的依赖项一一对应,每一个都是依赖项导出的成员,函数的作用就是为当前的这个模块提供一个私有的空间,模块中如果需要向外导出成员的话,可以用return的方式实现如下图

requirejs中还提供一个require函数去用来帮我们自动加载这个模块,只用来加载模块,define是用来定义模块的,一旦requirejs需要去加载一个模块的话,内部会自动创建一个script标签,去发送对应的脚本文件的请求,并且执行相应模块的代码

AMD的缺点
使用起来相对复杂,除了业务代码,还需要去使用很多require和define等操作模块的代码
模块划分比较细致的话,模块js文件请求频繁

同期还有淘宝的 seajs + cmd 类似commonjs ,使用上跟requirejs差不多

模块化标准规范

模块化的最佳实践

  1. node环境中遵循commonjs规范,node内置的模块系统,没有任何环境问题
  2. 浏览器端遵循ES Modules规范,es6中定义的标准,在语言层面实现了模块化,如今绝大多数浏览器开始支持了

ES Modules

由于目前绝大多数的浏览器都支持ES Modules,我们只需要通过给页面中的script标签添加type=module的属性,就可以直接使用ES Modules的标准去执行其中的js代码了

基本特性:

  1. ES Modules中自动采用严格模式,忽略use strict这种方式启用严格模式,不能在全局直接使用this
<script type="module">
  console.log('es module'); // this在非严格模式下直接使用指向的是window,而在严格模式下指向的是undefined
</script>
  1. 每个ES Modules都是运行在单独的私有作用域中的,意味着不用担心直接在全局中使用变量,造成全局变量污染的问题;模块内所有的成员都没有办法在外部被访问到
<script type="module">
  const foo = 100
  console.log(foo)	// 100
</script>
<script type="module">
  console.log(foo)	// 抛错 foo is not defined
</script>
  1. ES Modules中,外部的js文件是通过CORS的方式去请求的,意味着,我们的js模块不在同源地址下的话,需要我们请求的服务端地址在响应的响应头中提供有效的CORS标头,另外CORS不支持文件的形式去访问,必须要使用http server的形式去让页面工作起来
  2. ES Modules中的script标签会自动的延迟执行脚本,等同于defer属性,ES Modules等同于给script标签添加了defer属性。网页加载过程中对于script标签采用的是立即执行的机制,页面渲染会等待脚本渲染完成才会继续执行下面的内容

ES Modules的导入和导出

export 修饰变量,函数,class 修饰声明

var name= "name"

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

class Person {}

export {
  name as fooname,  // 重命名,导入的时候需要 import { fooname } from 'XXXX'
  hello,
  Person
}

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

export {
  name as fooname, 
  hello as default, // 当导出成员的名称设为`default`的话,这个成员就会作为当前模块默认导出的成员
}
import { default as fooname } from './XXX.js'	// 导入的时候,就必须为这个成员重命名

export default

export default name 直接作为一个模块的默认导出
import aaa from './XXX.js' 导入的时候,可以随便给变量取名

导出导入注意事项

  1. export { name, age }export导出的固定语法,里面的值不是对象字面量,同样的import { default as fooname } from './XXX.js'也并不是解构,是固定的语法,
  2. export导出的是一个值得引用,而requirejs中导出的就是一个值,两者是不一样的
  3. 外部import 导入的成员,是只读的,不能在外部修改

export default{ name, age }后面导出的值就是属于对象字面量的范畴了,export default后面可以跟一个变量或者一个值

关于import的注意事项

  1. import { name } from './esmodules.js'不能省略扩展名.js

import { name } from './util/index.js'commonjs中我们可以直接通过载入目录,就可以载入目录下面的index.js,但是ES Modules这里是不能省略的index.js

import { name } from './esmodules.js'不能省略相对路径的./,省略的话以字母开头ES Modules会认为这是在加载第三方模块,这一点和commonjs是一样的;也可以使用绝对路径和完整的url

  1. import {} from './esmodules.js'或者import './esmodules.js',只会去执行这个模块,不会去提取这个模块的成员,这个功能在我们去导入一些不需要外部控制的子功能模块的时候会非常有用
  2. 如果一个模块中导出的成员特别多,而且我们在导入的时候都回去用到他们,这个时候可以import * as mod from './esmodules.js使用as的方式将所有导出的成员放到一个对象mod当中,每个成员都会作为这个对象的属性出现,可以通过 mod.xxx 使用
  3. import我们可以理解成导入模块的声明, 需要我们在开发阶段就要明确我们需要导入文件的路径,有的时候,路径在运行阶段才能知道,这种状态下
var esmodule = './esmodules.js'
import { esmodule } from esmodule  // 不能使用import导入一个变量

if(true){
	import { name } from './esmodules.js'  // 不能嵌套在if或者函数中,只能在最顶层
}

动态导入机制ES Modules提供了一个全局的import('./esmodules.js')函数,用来动态导入模块,返回的是一个promise

import('./esmodules.js').then(function(module){
	console.log(module)
})
  1. 一个模块中同时导出了命名成员和一个默认成员,导入的时候
// module.js 导出
export { name,age }
export default 'abscdeee'
// app.js 导入
import {  name,age, default as title } from './module.js'  // 方式一:直接在后面跟上default ,只不过default需要重命名
import title, { name,age } from './module.js' // 方式二: 直接在花括号外面导入default成员,花括号里面导出的还是命名成员

导入导出成员

将导入的结果直接作为当前模块的导出成员,在当前作用域中,也就不再可以访问这些成员了。

import {  name,age } from './module.js'
export { name,age }
// 可以直接写成
export {  name,age } from './module.js'

在日常开发中,我们经常能在项目的某个目录的index.js文件中,把这个目录下散落的模块整合起来,这样的话别的模块使用这个目录下的工具的时候,只需要一个import做不同的提取就可以了

ES Modules在nodejs的支持情况

node中使用es-module首先需要把文件名改成.mjs结尾的文件,然后命令行需要加上node --experimental-modules index.mjs,新版本node只需要node index.mjs就可以了,我们还可以在package.json中添加"type":"module",这样的话所有的js文件都会支持ESM了,但是需要注意的是,如果我们添加了"type":"module",那么当我们需要在文件中使用CommonJS语法时,我们需要把文件改成.cjs结尾,才能顺利运行CommonJS的语法

import { camelCase } from 'lodash' 不支持,因为第三方模块都是导出默认成员,导入的时候也必须要使用默认导入的方式导入他们的成员。最后import {}不是解构不是解构不是解构,重要的说三遍。

import { writeFileSync } from 'fs'支持,可以通过提取的方式,提取内置模块的成员,因为系统内置的模块官方都做了兼容,会对系统内置的成员单独导出一次,再把它们整体作为一个对象,再做一个默认的导出,为了兼容默认导入或者单个命名导入的方式去使用

ES Modules在nodejs中与commonjs的交互

这里的说法都是指的是在node原生环境中

  1. exportsmodule.exports的一个别名,他们两个是等价的
  2. ES Modules中可以导入CommonJS模块
  3. CommonJS始终只会导出一个默认的成员,commonjs如果要在ES Modules中使用的话,我们只能通过import载入默认成员的形式去使用这个模块(不能直接提取commonjs导出的成员,注意 import不是解构导出对象)
  4. node原生环境中,不能在CommonJS模块中通过require载入ES Modules,在webpack打包的环境中是可以的

commonjs中的模块的全局成员,模块内置的 ,而ESM中没有这些commonjs的全局成员了

---加载模块函数模块对象导出对象别名当前文件的决定路径当前文件所在目录
commonjsrequiremoduleexports__filename__dirname

这几个成员在ES Modules都不能访问,这里面的require module exports可以使用ESM中的import export来代替

import { fileURLToPath } from 'url'
import { dirname } from 'path'
console.log(import.meta.url);   //  file:///Users/jiayin/Downloads/nodejsdemo/webDemo/src/demo.mjs
const __filename =  fileURLToPath(import.meta.url)
console.log(__filename);      //  /Users/jiayin/Downloads/nodejsdemo/webDemo/src/demo.mjs
const __dirname = dirname(__filename)
console.log(__dirname);     // /Users/jiayin/Downloads/nodejsdemo/webDemo/src
// 我们可以通过这种方式拿到当前文件所在的路径和文件夹路径

ES Modules在nodekjs中的Babel兼容方案

@babel/node依赖@babel/core@babel/preset-env这两个babel的核心模块,@babel/preset-envbabel的一个插件的集合