概述
随着前端应用的日益复杂,我们的项目代码逐渐膨胀到不得不花大量时间去管理的程度了;
模块化是目前最主流的代码组织方式,通过把复杂代码按照功能不同,划分为不同的模块,单独维护的方式,提高效率,降低维护成本;
模块化只是一个思想,不包含具体的实现。让我们看一看,目前我们是如何在前端项目中去实践模块化思想的,和目前的主流的方式和工具。
模块化演变过程
- 基于文件划分的方式实现模块化
具体做法:将每个功能,及其相关数据放到不同的文件当中,约定每个文件就是一个独立的模块,使用模块的方式就是,将每个文件引入到页面当中,一个script标签对应一个模块,再去在代码中去调用模块中的全局成员(变量/函数)
缺点:
- 污染全局作用域,所有模块都是在全局范围内工作,没有一个独立的私有空间,导致模块中所有的成员都可以在外部被任意的访问和修改
- 命名冲突问题,模块一旦多了之后,很容易产生命名冲突
- 无法管理模块之间的依赖关系 总的来说,这种方式完全依靠约定,项目体量上去之后,就不行了
-
命名空间方式 约定每个模块只暴露一个全局的对象,所有的模块成员都挂载到这个对象下面,将每个模块包裹到全局对象的方式去实现,有点类似于在模块内去为模块内的一些成员去添加了命名空间的一种感觉
- 减小了命名冲突的可能,
- 但是这种方式仍然没有私有空间,模块中的成员仍然可以在外部被访问被修改
- 也没有解决模块之间的依赖关系
-
使用立即执行函数的方式为模块提供私有空间 具体做法:将模块中的每个成员都放在一个函数提供的私有作用域当中,对于需要暴露给外部的成员,可以通过挂载到全局对象上面去实现,
;(function(){
...
window.moduleA ={ // 挂载到全局对象上面
method1: method1,
method1: method1,
}
})()
这种方式实现了私有成员的概念,私有成员只能在模块内部的成员通过必报的方式去访问,而在外部是没有办法访问的,确保了私有变量的安全
还可以用自执行函数的参数,去作为依赖声明去使用,使每一个模块之间的依赖关系变得更加明显了,后期维护的时候,明显的知道当前模块需要依赖的模块
模块化规范
前几种对于模块加载的问题,都是通过script
标签手动的去引入每个模块,这也就意味着我们的模块加载并不受我们代码的控制,一旦时间久了之后,不利于维护。
我们需要一些基础的公共代码,通过代码加载模块,所以说,我们需要一个模块化的标准和可以用来自动加载模块的基础库
CommonJS规范
node中的遵循的模块化规范
- 一个文件就是一个模块
- 每个模块都有单独的作用域
- 通过
module.exports
导出成员,通过require
函数载入成员CommonJS
约定是以同步模式加载模块,node的执行机制是在启动的时候加载模块,执行过程中不需要加载,只会使用这些模块,这种方式在node中不会有问题,但是在浏览器端,使用CommonJS
,必然导致每次页面加载导致大量的同步请求出现
早期的浏览器端,没有使用CommonJS
规范,而是专门为浏览器端设计了AMD
规范,异步的模块定义规范,同期产生了requirejs
库,约定每个模块都需要通过define
函数定义,默认接受两个参数,也可以传三个参数,
- 第一个参数是模块名字,在后期加载这个模块的时候、使用
- 第二个参数是一个数组,声明当前模块的依赖项
- 第三个参数是一个函数,参数和第二个参数中的依赖项一一对应,每一个都是依赖项导出的成员,函数的作用就是为当前的这个模块提供一个私有的空间,模块中如果需要向外导出成员的话,可以用return的方式实现如下图
requirejs
中还提供一个require
函数去用来帮我们自动加载这个模块,只用来加载模块,define是用来定义模块的,一旦requirejs
需要去加载一个模块的话,内部会自动创建一个script
标签,去发送对应的脚本文件的请求,并且执行相应模块的代码
AMD的缺点
使用起来相对复杂,除了业务代码,还需要去使用很多require和define等操作模块的代码
模块划分比较细致的话,模块js文件请求频繁
同期还有淘宝的 seajs + cmd
类似commonjs
,使用上跟requirejs
差不多
模块化标准规范
模块化的最佳实践
- node环境中遵循commonjs规范,node内置的模块系统,没有任何环境问题
- 浏览器端遵循ES Modules规范,es6中定义的标准,在语言层面实现了模块化,如今绝大多数浏览器开始支持了
ES Modules
由于目前绝大多数的浏览器都支持ES Modules
,我们只需要通过给页面中的script
标签添加type=module
的属性,就可以直接使用ES Modules
的标准去执行其中的js代码了
基本特性:
ES Modules
中自动采用严格模式,忽略use strict
这种方式启用严格模式,不能在全局直接使用this
。
<script type="module">
console.log('es module'); // this在非严格模式下直接使用指向的是window,而在严格模式下指向的是undefined
</script>
- 每个
ES Modules
都是运行在单独的私有作用域中的,意味着不用担心直接在全局中使用变量,造成全局变量污染的问题;模块内所有的成员都没有办法在外部被访问到
<script type="module">
const foo = 100
console.log(foo) // 100
</script>
<script type="module">
console.log(foo) // 抛错 foo is not defined
</script>
ES Modules
中,外部的js文件是通过CORS
的方式去请求的,意味着,我们的js模块不在同源地址下的话,需要我们请求的服务端地址在响应的响应头中提供有效的CORS
标头,另外CORS
不支持文件的形式去访问,必须要使用http server
的形式去让页面工作起来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'
导入的时候,可以随便给变量取名
导出导入注意事项
export { name, age }
是export
导出的固定语法,里面的值不是对象字面量,同样的import { default as fooname } from './XXX.js'
也并不是解构,是固定的语法,export
导出的是一个值得引用,而requirejs
中导出的就是一个值,两者是不一样的- 外部
import
导入的成员,是只读的,不能在外部修改
export default{ name, age }
后面导出的值就是属于对象字面量的范畴了,export default
后面可以跟一个变量或者一个值
关于import的注意事项
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
import {} from './esmodules.js'
或者import './esmodules.js'
,只会去执行这个模块,不会去提取这个模块的成员,这个功能在我们去导入一些不需要外部控制的子功能模块的时候会非常有用- 如果一个模块中导出的成员特别多,而且我们在导入的时候都回去用到他们,这个时候可以
import * as mod from './esmodules.js
使用as的方式将所有导出的成员放到一个对象mod
当中,每个成员都会作为这个对象的属性出现,可以通过mod.xxx
使用 - 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)
})
- 一个模块中同时导出了命名成员和一个默认成员,导入的时候
// 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原生环境中
exports
是module.exports
的一个别名,他们两个是等价的ES Modules
中可以导入CommonJS
模块CommonJS
始终只会导出一个默认的成员,commonjs
如果要在ES Modules
中使用的话,我们只能通过import
载入默认成员的形式去使用这个模块(不能直接提取commonjs导出的成员,注意 import不是解构导出对象)- node原生环境中,不能在
CommonJS
模块中通过require
载入ES Modules
,在webpack打包的环境中是可以的
commonjs中的模块的全局成员,模块内置的 ,而ESM中没有这些commonjs
的全局成员了
--- | 加载模块函数 | 模块对象 | 导出对象别名 | 当前文件的决定路径 | 当前文件所在目录 |
---|---|---|---|---|---|
commonjs | require | module | exports | __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-env
是babel
的一个插件的集合