文章说明:本文章为拉钩大前端训练营所做笔记和心得,若有不当之处,还望各位指出与教导,谢谢 !
模块化开发
一、模块化开发概述
随着前端应用复杂,我们的项目的代码已经逐渐膨胀到不得不花大量时间管理程度,而模块化是最主流的代码组织方式,它通过把我们的复杂代码按照功能不同分为不同的模块,然后单独维护的这种方式去提高我们的开发效率,降低维护成本,模块化只是一个思想或者理论,并不包含实现。以下介绍我们模块化的主流方式
二、模块化的演进过程
早期第一阶段,我们JavaScript的模块化实际上是基于文件划分的方式去实现的,这也就是我们web当中最原始的模块系统。将每一个功能以及相关的一些状态数据单独存放在不同的文件夹当中,约定每一个文件夹就是一个独立的模块,我们使用这个模块的话就是将这个模块引入到页面当中,一个script标签对应一个模块,然后再去代码当中直接调用模块当中的全局成员这个成员可能是个变量,也有可能是个函数。缺点十分明显,所有模块都在全局范围内工作,并没有一个独立的私有空间,导致模块所有成员在模块外面也能被访问(污染全局作用域),而且模块一旦多了过后容易产生命名上的冲突(命名冲突问题),以及无法管理模块依赖关系
总的来说,早起模块化完全依靠约定,当项目体量变大时,就不行了。
第二阶段采用命名空间方式,约定每一个模块只暴露一个全局的对象,所有的模块成员都挂载到这个对象下面,具体做法就是在第一个阶段的基础之上,将每一个模块包裹成为全局对象的方式去实现,有点类似于我们在模块内去为我们的模块一些成员去添加命名空间的一种感觉
但是这种方式仍然没有私有空间,污染全局作用域和无法依赖模块关系仍然没有解决。
第三阶段我们使用立即执行函数这种方式去为我们的模块提供私有空间
实现了私有成员的概念,实现了我们私有成员内部的这些成员通过闭包的方式去访问,在外边我们是没有办法去使用的,这样就确保了我们私有变量的安全,还可以利用自执行函数的参数去做为我们依赖声明,使得我们每一个模块他们之间的依赖关系就变得更加明显了。
以上这几个阶段就是早期开发者在没有工具和规范的情况下对模块化的落地方式。
三、模块化规范的出现
以上这些模块化方式在不同的开发者去实现时会有一些细微的差别,为了统一不同的开发者和不同的项目之间的差异,我们需要一个标准去规范化我们模块化的实现方式,另外我们在模块化当中针对于模块加载的问题在以上这几种方式当中都是通过script标签手动的去引入每一个模块,意味着模块加载并不受代码控制,一旦时间久了过后,维护起来就非常的麻烦,试想一下,如果你的代码当中依赖了一个模块,而html代码却忘记引用这个模块的话,这时候又会出现问题,又或是我们在代码当中移除了某个模块的引用,然后又忘记在html删除这个模块的引用,这些都会产生很大的问题,所以我们需要基础的公共代码,去实现自动通过代码去帮我们加载模块,所以现在需要模块化标准和一个可以自动加载模块的基础库。
提到了模块化规范,个人首先想到的是CommonJS规范,它是node.js当中所提出的一个标准,在node.js当中所有的模块代码都必须要遵循CommonJS规范:
如果想要在浏览器端也想使用这种规范的话,就会出现一些问题,如果对node模块加载机制有所了解的话,应该知道commonJs约定的是以同步模式加载模块,因为node的执行机制是在去启动时去加载模块,执行过程当中是不需要加载的,只会去使用到模块,这种模式在node当中不会有问题,但是换到浏览器端,去使用commonJs规范的话必然导致效率低下,因为每次页面加载,都会导致大量的同步模式请求出现,所以说在早期的前端模块化当中,并没有commonjs规范,而是结合浏览器端重新设计了规范,称为AMD(Asynchronous Module Definition),意识就是异步的模块定义规范,同时推出了出名的库称为Require.js,实现了AMD规范,另外它本身又是一个强大的模块加载器,在AMD规范当中,约定每一个模块用define这个函数定义:
除此之外,require当中还提供require函数,去帮我们加载这个模块,与define这个函数类似,区别在于
require这个函数只是用来加载模块,define这个函数使用来定义模块,当require.js需要去加载一个模块的话,内部会自动去创建一个script标签发送对脚本文件的请求,并执行相应的模块代码。
目前绝大多数第三方库都支持AMD规范:
四、模块化标准规范
针对模块化实现的方式基本统一,在node.js环境当中我们会遵循CommonJs规范,在浏览器环境当中,我们会采用ES Modules规范,也会有极少部分规范出现,总的来说,前端模块化,算是统一成了CommonJS和ES Modules规范。
ES Modules
ES Modules 规范 是在 ECMAScript2015(ES6)中提出的 Module 模块标准。通过给 script 添加 type = module 的属性,就可以以 ES Module 的标准执行其中的 JS 代码
一、基本特性
1.ESM 自动采用严格模式,忽略 ‘use strict’
<script type="module">
console.log(this) // undefined this在严格模式下,为undefined
</script>
2.每个 ES Module 都是运行在单独的私有作用域中
<script type="module">
var foo = 100
console.log(foo)
</script>
<script type="module">
console.log(foo) //foo is not defined,该模块调用上一个模块的foo 时,无法调用
</script>
3.ESM 是通过 CORS 的方式请求外部 JS 模块的
<script type="module" src="https://umpkg.com/jquery@3.4.1/dist/jquery.min.js">
//我们的js模块如果不在同源地址下面的话,就需要请求的服务端地址,它在响应的响应头当中必须要提供有效的cors标头,
//我们通过百度cdn的地址做一个尝试,这个地址是不支持cors的。
//请求的时候发现会报一个跨域的错误,所以说如果我们要请求一个web地址的话需要注意服务端必须要支持cors
</script>
4.ESM 的 script 标签会延迟执行脚本,相当于 script 的 defer 属性
<script type="module" src = "demo.js">
</script>
<p> 需要显示的内容</p>
//当没添加type=“module”属性时,刷新01-features.html页面弹出的hello要点击确定或者取消才能显示p里面
//的文字,而添加之后,p中的文字在刷新页面后能同时出来
二、ES Modules 导入和导出
导出: export是在模块内去对外暴露接口。
1.直接成员导出,导出变量、函数、类等。
export var name = 'foo module'
export function hello () {
console.log('hello')
}
export class Person {}
2.成员集中导出,可以更加直观的看到向外部导出了那些成员
var name = 'foo module'
function hello () { }
class Person { }
export { name, hello, Person }
3.使用重命名导出,用as
var name = 'foo module'
function hello () { }
class Person { }
export { name as fooName, hello as fooHello, Person as fooPerson }
导入时要用重命名后的名字导入
import { fooName, fooHello, fooPerson } from './module.js'
4.默认导出,设置某一个成员的别名是default
var name = 'foo module'
export { name as default }
// 推荐下面的书写方式
export default name
三、导入导出注意事项
1.export单独使用时,{}时固定语法,虽然看起来跟字面量对象一样,但是导出的并不是字面量对象
var name = 'jack'
var age = 18
export { name, age }
导入模块,{} 固定语法,不是对象的解构
import { name, age } from './module.js'
2.export default 组合使用时,{} 代表导出的是对象字面量
var name = 'jack'
var age = 18
export default { name, age }
导入模块,不可以使用 {} 写法
// module_obj 自定义名字,最好和模块名保持一致
import module_obj from './module.js'
// 访问导出的成员
console.log(module_obj.name, module_obj.age)
3.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); // jack 18
setTimeout(function () {
console.log(name, age); // ben 18
}, 1500)
4.导出的值是只读的,无法在模块外部修改成员
import { name, age } from './module.js'
name = 'tom' // Uncaught TypeError: Assignment to constant variable.
四、导入语法
1.导入时不能省略js扩展名
// import { name } from './module'//错误
import { name } from './module.js'// 导入时不能省略js扩展名
console.log(name)
2.手动填写完整路径,对于文件名称,我们在后期去使用打包工具打包我们的模块时就可以省略扩展名,也可以省略index.js这个默认文件的操作
// import { lowercase } from './utils'
//手动填写完整路径,对于文件名称,我们在后期去使用打包工具打包我们的模块时就可以省略扩展名,
//也可以省略index.js这个默认文件的操作
import { lowercase } from './utils/index.js'
console.log(lowercase('HHH'))
3.导入模块时必须以点开头,不然语法会认为要加载第三方模块,还有以下其它导入方式
// import { name } from 'module.js'
//导入模块时必须以点开头,不然语法会认为要加载第三方模块
import { name } from './module.js'
import { name } from '/04-import/module.js'//也可以写这种绝对路径
//也可以使用完整的url模块,意味着我们可以直接引用cdn上的模块问文件
import { name } from 'http://localhost:3000/04-import/module.js'
console.log(name)
4.如果说我们只需要执行某个模块,而并不需要提取这个模块当中的成员,可以保持{}里面为空
import {} from './module.js'
//也可以这样简写
import './module.js'
5.如果说我们要从一个模块当中要导入成员的非常多,而且在导入时都会去用到他们,就可以使用*的方式把模块当中的所有成员全部导入,提取出来过后需要通过as的方式将提取出来的成员放入一个对象当中,每一个成员都会作为这个对象的属性出现
import * as mod from './module.js'
console.log(mod)
6动态导入模块.
在使用导入模块的时候,import这个关键词可以理解为是一个导入模块的声明,它需要在开发阶段就明确我们需要导入的模块文件的路径,但是有时候这个模块路径在运行阶段我们才知道的,这种情况下我们不能使用import关键词去from一个变量
var modulePath = './module.js'
import { name } from modulePath
console.log(name)
而且有的时候我们是需要在某个情况下当某些条件满足过后我们再去导入模块,在这种方式下我们也不能使用import,import只能使用在最顶层或者其他外部作用域里
if (true) {
import { name } from './module.js'
}
如果说遇到以上两种情况的话,就是要动态的去导入模块的这个机制了,提供了全局的import函数,用法就是通过import这个函数去传入需要导入的模块的路径,可以在任何地方去调用,这个函数返回的是promise,当模块加载完成过后(模块加载是一个异步的过程),会自动执行then当中指定的回调函数,模块的对象可以通过参数去拿到
import('./module.js').then(function (module) {
console.log(module)
})
7.一起导入具名和默认成员
如果我们在一个模块当中同时导出了一些命名成员,再导出一个默认成员,在我们导入这些成员的时候,像我们之前一样name和age正常的导入进来,如果说想要同时导入default的话,要重命名
import { name, age, default as title } from './module.js'
console.log(name, age, title)
还有一个简写的写法,就是直接通过这一个在花括弧之前加上一个默认成员提取的方式,中间通过一个逗号分割,逗号左边用来提取这个模块里面默认的成员,逗号右边就是提取具名的成员,逗号左边的名字可以随便取,跟之前提取默认成员是一样的。
import abc, { name, age } from './module.js'
console.log(name, age, abc)
五、ES Modules 的直接导出导入成员
把import换成export,这样所有导入的成员将会直接作为当前这个模块的导出成员,这样在当前的作用域当中也就不再可以去访问这些成员了,这样的一些特性一般在写index文件的时候会用到,通过index文件把某一个目录下散落的一些模块通过这种方式组织在一起然后导出,这样方便外部使用。
index.js,集中导出成员
export { Avatar} from './button.js'
// 默认成员的导出,必须重命名,以别名的形式导出
export { default as Button} from './avatar.js'
button.js作为一个分散的子模块,导出 默认Button 成员
//导出一个变量去表示一下组件
var Button= 'Avatar Component'
export default Button
Avatar.js作为一个分散的子模块,导出 Avatar 成员
export var Avatar= 'Avatar Component'
app.js 引入各个模块
//例如定义了一个components组件模块,然后去新建一个button组件模块,这个模块当中导出一个字符串变量。同理avatar也是,然后再app.js当中引入
// 此时需要单个导入,组件多会很麻烦
// import {Button} from './components/button.js'
// import {Avatar} from './components/avatar.js'
// console.log(Button)
// console.log(Avatar)
//============================================================
import { Button, Avatar } from './components/index.js'
console.log(Button)
console.log(Avatar)
六、ES Modules在浏览器环境中Polyfill的兼容方案
ES Modules 在2014年才被提出来的,意味着早期的浏览器不可能支持这一个特性,另外在ie,还有一些国产的浏览器上截止到目前为止都还没有支持,所以说我们在使用ES Modules的时候还是需要去考虑兼容性所带来的一个问题
Polyfill可以在我们的浏览器当中直接去支持ES Modules当中绝大多数的特性,这个模块的名字叫做ES Module loader,这个模块实际上就是js的文件,将这个js文件引入到网页当中。
<!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 浏览器环境 Polyfill</title>
</head>
<body>
<!-- -->
<script nomodule src="https://unpkg.com/promise-polyfill@8.1.3/dist/polyfill.min.js"></script>
<!-- 引入的是babel的一个及时运行在浏览器上的一个版本 -->
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script>
<!-- 引入的是es MOdule的loader,工作的时候就是通过es module loader通过把代码读出来然后交给babel去转换从而让代码正常工作 -->
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script>
<!--ES Module的工作原理非常简单,就是将我们这些浏览器当中不识别的ES Module去交给babel去转换,
对于那些需要import 进来的文件再去通过babel转换从而去支持我们的ES Module -->
<!-- 在支持ES Module的浏览器当中例如Chrome浏览器,代码会被执行两次,一次是浏览器加载页面的时候,
一次是ES Module的polyfill也会帮我们执行一次,我们在script上面添加一个新属性nomodule-->
<script type="module">
import { foo } from './module.js'
console.log(foo)
</script>
</body>
</html>
nomodule 属性表示只在不支持 ES Modules 的浏览器中运行,避免支持的浏览器多次运行 。不建议在生产版本中使用,影响效率。
七、ES Modules 在node.js 的支持情况
新建文件,后缀名设置为xxx.mjs
在终端运行命令中添加参数 --experimental-modules,启动ES Modules的实验特性
$ node --experimental-modules xxx.mjs // node 8.5+
1.node内置模块兼容了ESM的提取成员方式
index.mjs:
// 此时我们也可以通过 esm 加载内置模块了
import fs from 'fs'
fs.writeFileSync('./foo.txt', 'es module working')//写完后安装:yarn add lodash 然后使用命令 node --experimental-modules index.mjs 运行
// 也可以直接提取模块内的成员,内置模块兼容了 ESM 的提取成员方式
import { writeFileSync } from 'fs'
writeFileSync('./bar.txt', 'es module working')
2.第三方模块都是导出默认成员,不支持使用{}雨大导入成员
index.mjs
// 对于第三方的 NPM 模块也可以通过 esm 加载
import _ from 'lodash'
console.log(_.camelCase('ES Module'))
// 不支持,因为第三方模块都是导出默认成员
// import { camelCase } from 'lodash'
// console.log(camelCase('ES Module'))
八、ES Modules 在 Node.js当中与CommonJS模块的交互
定义两个文件来模拟两个模块,es-module.mjs和commonjs.js
1.ES Modules中可以导入CommonJS模块
es-module.mjs:
// ES Module 中可以导入 CommonJS 模块
import mod from './commonjs.js'
console.log(mod)
commonjs.js:
// CommonJS 模块始终只会导出一个默认成员
// module.exports = {
// foo: 'commonjs exports value'
// }
exports.foo = 'commonjs exports value'//等价于上面的代码
2.CommonJS 中不能导入 ES Modules 模块
commonJS.js:
const mod = require('./es-module.mjs')
console.log(mod)//Error [ERR_REQUIRE_ESM]: Must use import to load ES Module
es-modules.mjs:
export const foo = 'es module export value'
3.CommonJS 始终只会导出一个默认成员,不能直接提取成员
commonJs.js:
exports.foo = 'commonjs'
es-modules.mjs:
import { foo } from './common.js' // SyntaxError:...
console.log(foo);
4.注意 import 不是解构导出对象,只是一个固定的用法,提取模块导出的那些命名成员
九、ES Modules in Node.js 中与CommonJS模块的差异
新建两个文件cjs.js模拟Commonjs模块,esm.mjs模拟es Module在nodejs环境
ESM 中没有模块全局成员了,以下无法打印出来,意味着这几个成员在我们使用esmodule的时候就不能再去使用了,这5个成员实际上都是commonjs把我们的模块包装成一个函数过后通过参数提供进来的成员
esm.mjs:
// ESM 中没有模块全局成员了,以下无法打印出来,意味着这几个成员在我们使用esmodule的时候就不能再去使用了,这5个成员实际上都是commonjs把我们的模块包装成一个函数过后通过参数提供进来的成员
// 加载模块函数
// console.log(require)
// // 模块对象
// console.log(module)
// // 导出对象别名
// console.log(exports)
// // 当前文件的绝对路径
// console.log(__filename)
// // 当前文件所在目录
// console.log(__dirname)
// -------------
// require, module, exports 自然是通过 import 和 export 代替
// __filename 和 __dirname 通过 import 对象的 meta 属性获取
// const currentUrl = import.meta.url 这个url实际上拿到的是我们当前工作的文件的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)
cjs.js
//在这个文件当中,它是按照commonj的标准去打印了5个成员,这5个成员实际上是Commonjs当中模块的全局成员,可以理解为全局变量,但是实际上是模块内置的
// 加载模块函数
console.log(require)
// 模块对象
console.log(module)
// 导出对象别名
console.log(exports)
// 当前文件的绝对路径
console.log(__filename)
// 当前文件所在目录
console.log(__dirname)
十、ES Modules在nodejs新版本中进一步的被支持
为了使项目中所有的js文件,都可以使用 ES Modules ,在 package.json 中添加属性 type 进行设置。
package.json:
{
type: 'module'
}
此时,无需再将 .js 改为 .mjs。但是要将 CommonJS 的 .js 改为 .cjs,保证兼容 CommonJS。
十一、ES Modules 在 低版本nodejs中的Babel兼容方案
1.安装babel相关依赖模块
$ yarn add @babel/node @babel/core @babel/preset-env --dev
2.运行 ES Modules 的 JS 文件,需要添加特性转换的预设参数
$ yarn babel-node index.js --presets=@babel/preset-env
3.@babel/preset-env 只是一个插件集合,真正起作用的是 @babel/plugin-transform-modules-commonjs 插件
$ yarn add @babel/plugin-transform-modules-commonjs --dev
4.若不想在执行命令时添加参数,可以配置 .babelrc 文件,这是 babel 的配置文件
.babelrc:
{
"presets": ["@babel/preset-env"],
"plugins": ["@babel/plugin-transform-modules-commonjs"]
}
5.运行命令,进行测试
$ yarn babel-node index.js