模块化编程的目标是能够用不同来源的代码模块组装成大型程序,在这过程中,模块化的作用主要体现在封装和隐藏私有实现细节,以及保证全局命名空间清洁上,因而模块之间不会意外修改各自定义的变量、函数和类。
1. 基于类、对象和闭包的模块
在import/export实现之前,Javascript并没有内置对模块的支持,对于大型项目只能利用类、对象以及闭包的弱模块化能力。由于打包工具的支持,基于闭包的模块化在实践中成为常用模块化形式,核心是沿用了Node的require()函数。ES6使用import和export关键字定义模块,实践中,Javascript的模块化仍然依赖代码打包工具。
为什么说类,对象,闭包具有弱模块化的能力? 不相关的类的方法能够相互独立,因为每个类的方法都定义为独立原型对象的属性,而类之所以成为模块,是因为对象是模块:给一个js对象定义属性类似于声明对象,但是给对象添加属性不影响程序的全局命名空间,也不影响其他对象的属性。
特别地,类与对象没有提供任何方式来隐藏模块的内部实现细节。(打印对象的方法,能够看到函数的源代码)
为解决这个问题,考虑到在函数中声明的局部变量和嵌套函数都是函数私有的,这意味着我们可以使用IIFE(立即调用函数表达式)来事件模块化,把实现细节和辅助函数隐藏在包装函数中,只将模块的公共API作为函数的值返回。
const stats = (function(){
// 模块的辅助函数
const sum = (x, y) => x + y
const square = x => x * x
// 要导出的公共函数
function mean(data) {
return data.reduce(sum) / data.length
}
function stddev(data) {
let m = mean(data)
return Math.sqrt(
data.map(x => x - m).map(square).reduce(sum) / (data.length - 1)
)
}
return { mean, stddev}
}())
stats.mean([1,2,3]) // 2, 均值
stats.stddev([1,2,3]) // 1, 标准差
IIFE有以下特性:1)创建独立的作用域;2)立即执行;3)不会有变量提升。
程序执行到给stats赋值完毕之后,形成了闭包,辅助函数只能通过mean,stddev调用,没有实现细节。
基于闭包的自动化模块
首先,想象有这样一个工具,能够解析代码文件,把每个文件的内容包装在一个立即调用的函数表达式中,还可以跟踪函数的返回值,并将所有内容拼接为一个大文件,拼接结果如下
const modules = {}
function require(moduleName) { return modules[moduleName] }
modules['stats.js'] = (function() {
const exports = {}
// stats.js文件的内容
sum = (x, y) => x + y
square = x => x * x
exports.mean = function(data){
return data.reduce(sum) / data.length
}
exports.stddev = function(data){...}
return exports
}())
将模块打包到类似上面的的单个文件之后,下面来使用模块
const stats = require('stats.js')
console.log(stats); // {mean: ƒ, stddev: ƒ}
stats.mean([1,2,3]) // 2
以上就是针对浏览器的代码打包工具(如webpack和Parcel)的基本工作原理。
ES5可以通过打包工具和立即执行函数实现模块化。
顺带一提,为什么需要打包模块(.js文件) 如果不打包的话,我们的项目中有很多文件会相互依赖,有几个文件就要发多少次请求来获取文件,如果一个文件因为网络问题延误了整个页面也会延误,而当我们的项目逐渐变大,有几十个到上百个JavaScript文件的时候,那问题会更严重,不但有延迟问题,还会遇到很难维护的问题。
2. Node中的模块
编写Node程序时,可以按功能将程序拆分到任意多个文件,这些js代码问价被假定始终存在于一个快速文件系统。与通过相对较慢的网络连接读取js文件的浏览器不同,把所有的Node代码都写到一个js文件中没有太大的必要性。 在Node中,每个文件都是拥有私有命名空间的独立模块。A模块中的值只有被B模块导入后才会在B模块中可见。
Node模块使用require()导入,设置Exports对象的属性或者完全替代module.exports对象导出
// 导入内置模块
const fs = require('fs')
// 导入第三方模块
const express = require('express')
// 导入自定义模块
const stats = require('./stats.js)
// 按需导入--解构赋值
const { mean } = require('./stats.js')
var demoVar = {a:1, b:2}
// 导出
module.exports = demoVar
// 或者
exports.demoVar = demoVar
注:不能直接将exports变量指向一个值,因为这样等于切断了exports与module.exports的联系.
通过Express对象和require()定义和使用的模块是内置与Node中的。NodeJS 模块并不能直接在浏览器端应用中使用,原因在于引用这些模块时需要使用 NodeJS 中的 require 方法,而该方法在浏览器端并不存在。
但是如果使用webpack等打包工具来处理代码,也可以对浏览器中运行的代码使用Node风格的模块。
3. ES6 中的模块
ES6 在语言标准上层面为Javascript添加了 import 和 export 关键字,将模块作为核心语言来支持了。
// 导入
import stats from 'stats.js'
// 按需导入,重命名
import { mean as m} from 'stats.js'
// 导出
export {...}
import会存在变量提升,在预编译阶段,遇到import关键字会去加载模块,进而形成ES6模块依赖关系。
在网页中使用JavaScript模块
ES6代码在在线上部署时还要依赖打包工具,但是鉴于目前浏览器对javascript模块的原生支持,开发期间打包工具并不是必需的了。同时,模块代码默认在严格模式下运行,因此与传统非模块代码以不同方式运行,所以必须修改HTML和JavaScript才能使用模块。
如果想在浏览器中以原生方式使用import指令,必须通过<script type="module">标签告诉浏览器这是一个模块。
ES6 模块和js常规脚本的区别
1. 常规脚本中,顶级声明的变量、函数和类会进入被所有脚本共享的全局上下文;
模块中,每个文件有自己的私有上下文,可以使用import/export
2. 模块自动应用严格模式
3. 跨源加载。常规<script>标签可以从任何服务器加载javascript代码文件,而<script type="module">增加了跨源的限制。
import()动态导入
ES6 的 import/export 是静态的,因此javascript编译器可以确定加载之后的模块之间的依赖关系,而不必执行模块代码。静态导入模块可以保证导入的值在任何模块代码运行之前都可以使用。
对于web应用来说,先加载足够的代码用于首页渲染是很常见的,而不需要将代码全部加载。这个时候就可以使用动态导入。
import('./stats.js').then(stats => {}) // import 返回一个Promise对象
import 看起来像是函数调用,实际上是一个操作符,而圆括号是这个操作符必需的部分。