JavaScript模块化:从闭包到ES Module

Posted 余烬的小舟

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JavaScript模块化:从闭包到ES Module相关的知识,希望对你有一定的参考价值。

模块化是一种将 javascript 程序拆分为可按需导入的单独模块的机制,随着如今JavaScript脚本体积越来越大、越来越复杂,JavaScript的模块化机制也变得越来越重要,现在,几乎所有最新浏览器都支持js原生模块化机制。

模块化的意义何在?

js模块化机制将js代码拆分到不同的细小文件中,有以下优点:

  • 每个文件都具有私有命名空间,避免全局污染、变量冲突。
  • 逻辑分离,可以将不同逻辑代码放在不同的js文件中。
  • 提高代码的可复用性、可维护性和可读性

基于对象、闭包的模块化

基于对象的模块化

在CommonJs、ES6Module出现以前,为了避免全局变量污染,常用的一种方法就是将一类变量放到一个对象中,这样每个对象里的属性(变量)就都是该对象私有的,避免了变量冲突的问题。

let a = {
    sayHello: \'hello1\'
}
let b = {
    sayHello: \'hello2\'
}

这样即使出现相同的变量名,也不会造成冲突,将每个逻辑点相关的变量放到一个对象中,尽量减少变量污染的情况。js内置对象Math也是由这种思路实现的。

基于闭包的模块化

IIFE(立即调用函数表达式)

IIFE是一个定义时就会调用的函数,定义一个IIFE很简单,只需要写两个小括号,第一个括号里声明一个匿名函数,第二个括号里传入实参。

// IIFE有两种写法风格,两种都可以正常使用
(function (arg) {
    console.log(arg)
})(1); // IIFE后面必须要加分号,表示结束
// 1

(function (arg) {
    console.log(arg)
}(2)); // IIFE后面必须要加分号,表示结束
// 2

立即调用函数表达式(IIFE)具有以下优点:

  • 函数内的变量不会造成全局污染。
  • 函数执行完后就会立即销毁,不会造成资源浪费。

想象一下有一个工具,它能解析代码文件,将每个文件的内容包装成一个立即调用函数表达式中,还可以跟踪每个函数的返回值,将所有内容拼装成一个大文件。

一些代码打包工具就是基于这种思想实现的。

两种方式的不足之处

两种方法虽然都能实现私有命名空间,避免变量污染问题,但是仍然存在一些明显的缺陷。

对于基于对象实现的模块化而言:

  • 声明变量就变成了声明对象的一个属性,没办法使用声明变量的一些有用机制,可能会导致重复命名属性造成属性覆盖的问题。
  • 对象之间可能出现覆盖的情况
  • 代码仍在一个文件里,会导致文件代码量越来越大
// 意外地覆盖了属性b
let o = {
    b: 1
}
o.a = 1;
o.b = 2; // 覆盖了属性b,并且js不会出现任何提示

let a = 1;
let b = 2;

let b = 3; // js会报错,提示不能重复声明

对于基于闭包的模块化而言:

  • IIFE中的变量和函数不可复用
  • 使用不方便
  • 难以测试,难以维护

此外两种方式都并不是真正的理想下的模块化,都存在不能将代码拆分到不同文件中,难以维护,私有命名空间的实现有缺陷等问题。

Node.js的模块化(CommonJs)

理想中的模块化,应该是可以将不同代码拆分到不同的文件中,这样有利于可维护性和可读性,不同代码文件之间可以互相导入,有利于可复用性,每个代码文件都具有私有命名空间,这样可以避免变量冲突和污染。

CommonJs模块机制实现了以上要求,CommonJs是Nodejs内置的模块化机制。它可以将复杂的程序拆分成任意多个代码文件,每个文件都是一个拥有私有命名空间的独立模块,可以选择导出其中一个或者所有的变量和函数,另一个代码文件可以导入到自己的文件中,实现变量及函数的复用。

node的导出

node的导出有两种方式,一种是module.exports,另一种是exports。这两个对象都是全局内置的,可以直接使用,他们的用法如下

// 你可以将变量单独一个一个的导出
exports.a = "a";
exports.b = 123;
exports.fn = function() {
    console.log(\'我是一个函数,而且还是匿名的\')
}

// 切记,这样写是不行的,具体原因稍后解释
exports = {
    a,
    b
}

// 也可以一起导出
let c = true;
let fn2 = function(){
    console.log(\'我是一个函数,而且还是匿名的\') 
}

module.exports = {
    c,
    fn2
}

你可能会觉得module.exportsexports很像,事实上他们确实有关系,module.exportsexports引用的是同一个对象,也就是说exports.a等同于module.exports.a

详细点说就是module.exports是一个对象,node会将它里面的属性和方法都公开,供其他模块导入,而exports是指向module.exports的变量,exports.xx其实就是module.exports.xx

同时这也解释了前面为什么不能直接让exports = xx,因为这样会改变exports原本的指向。

node的导入

既然有导出,自然就有导入,nodejs模块通过调用require()实现对其他模块数据的导入。

你可能看到过以下这两种类型的导入方式

const fs = require(\'fs\');

const user = require(\'./user.js\')

第一种是导入nodejs内置的fs模块,无需写路径,而第二种是导入用户自己写的模块,因此要写路径(路径可以是相对路径,也可以是绝对路径)。

模块的分类

事实上,前面我们说的node内置模块也称为核心模块,它在nodejs源代码编译的时候就会被编译成二进制文件,nodejs启动时,这些核心模块就会直接被加载进内存,所以核心模块加载时,相对于文件模块,核心模块引用时不需要进行文件定位和动态编译,速度上有优势,导入时直接写模块名,不用填写路径,如http,fs,path等常用模块都属于核心模块。

而用户自己写的代码模块叫做文件模块,文件模块也根据导入的方式不同分为<u>路径形式引入的模块</u>和<u>自定义模块</u>。

const express = require(\'express\');//自定义模块

const usersRouter = require(\'./routes/users\');//以路径形式引入的模块

对于路径形式引入的模块,由于提供了确切的路径,引入时require方法会把指定的路径转化为硬盘上的真实路径,并用这个路径作为索引将编译后的结果进行缓存。由于指定了路径,这种形式的文件模块在路径分析时可以节省大量的时间,其速度比自定义模块要快,但是比核心模块要慢

  • 核心模块
  • 文件模块

    • 以路径导入的模块
    • 自定义模块

路径分析

对于自定义模块,自定义模块会遵循以下策略进行路径分析,这会耗费大量时间。

  1. 查找当前目录下的node_modules目录,看是否有匹配项
  2. 查找父级目录下的node_modules目录,看是否有匹配项
  3. 按照这个规则一直往父目录搜索直到到根目录下的node_modules

文件定位

当完成路径分析之后,导入的路径没有文件扩展名,node会对文件进行扩展名进行分析,会按照.js,.node,.json这个顺序逐个进行尝试。

如果路径指向的不是一个文件,而是一个目录,那么:

  • 首先会在命中的目录下寻找package.json这个文件并用JSON.parse进行解析,取出json文件中main属性的值,作为命中的文件
  • 如果找不到package.json或者对应的main属性,那么会用这个目录下面index文件作为命中文件,依旧是按照.js,.node,.json这个顺序逐个进行尝试
  • 如果依旧找不到index,那么此次文件定位失败,将会按照上面提到的路径遍历规则,往上一级继续寻找

由于一层一层的查找,自定义模块的路径分析需要耗费大量的事件,会导致搜索效率较为低下,所以自定义模块的加载性能要比以路径形式加载的方式要慢。

缓存

另外,node会将导入过的模块进行缓存,下一次引用时,会先检查缓存中有没有对应的文件,优先从缓存中进行加载,减少不必要的消耗。

总结

因此,node可以用module.exportsexports进行导出操作,其中module.exports指向的是要导出的对象,而exports是对module.exports的指向;node通过require()进行导入操作,导入的方式可以是有路径的,也可以是无路径的(核心模块,自定义模块)。

node的模块分以下几类:

  • 核心模块
  • 文件模块

    • 以路径导入的模块
    • 自定义模块

加载速度: 缓存 > 核心模块 > 以路径导入的模块 > 自定义模块

ES6的模块化

ES6为JavaScript添加了importexport关键字,将模块化作为核心语言特性来支持了。
ES6 Module在概念上和CommonJs基本相同,都是将代码拆分到不同的代码文件中,每个代码文件都是一个模块,模块之间可以互相导入和导出。

ES6 Module基本使用

// a.js
export a = \'a\';

export fn1(){
    console.log("hello ES6")
}

// 和CommonJs一样,es6 Module也可以一起导出
let b = 1;
let fn2 = function(){
    console.log("hello ES6模块")
}

export {b,fn2}
// b.js
import a from "./a.js"

console.log(a.a);

a.fn1()  // hello ES6

注意:export {b,fn2}看似好像是声明了一个对象字面量,但是实际上这里的花括号并不会定义对象字面量,这种导出语法仅仅是要求在一对花括号中给出一个逗号分隔的列表。

另外,ES6 的模块会自动采用严格模式,不管你有没有在模块头部加上"use strict";

扩展

除了上面的基本用法外,importexport还有一些扩展使用方法。

运用解构

import { a } from "./a.js"console.log(a) // 等于import a from "./a.js"console.log(a.a) 

通过as关键字重命名

import { a as b} from "./a.js"console.log(b) // 等于import { a } from "./a.js"console.log(a) // 也可以在导出时设置别名export {    a as b}

执行一个模块,但是不导入任何值可以这样写

import "./a.js"

如果多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。

整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。

import * as moduleA from \'./a.js\';console.log(moduleA.a)  // \'a\'

从上面的例子来看,导入需要先知道导出的函数/变量名,才能使用,这样并不是很方便,我们可以设置一个默认值。

// moduleA.jsexport default function () {  console.log(\'foo\');}

这样,导入的时候不需要知道moduleA导出的函数名或变量名。

// moduleB.js// 可以命名为任意名称import fn from "./moduleA"fn() // foo

上面代码的import命令,可以用任意名称指向moduleA.js输出的方法,这时就不需要知道原模块输出的函数名。需要注意的是,这时import命令后面,不使用大括号。

注意:一个模块中export default只能存在一个

本质上,export default就是输出一个叫做default的变量或方法,然后系统允许你为它取任意名字。所以,下面的写法也是有效的。

// modules.jsfunction add(x, y) {  return x * y;}export {add as default};

export 与 import 的复合写法

如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。

export { foo, bar } from \'my_module\';// 可以简单理解为import { foo, bar } from \'my_module\';export { foo, bar };

更多复合写法见网道-ES6

静态导入和动态导入

前面使用的import导入具有以下特点:

  1. 存在提升行为,会提升到整个模块的头部,首先执行
  2. 导入的变量和函数都是只读的,因为它的本质是输入接口
  3. 编译时导入

importexport命令只能在模块的顶层,不能在代码块之中(比如,在if代码块之中,或在函数之中),因为引擎处理import是在编译阶段,在代码执行前就先将模块内容导入,这时不会去分析或执行if语句。

// 报错if (true) {    import moduleA from "./a.js"}

正是因为import是静态导入,所以要导入的模块是编写代码时就确定了的,无法在代码执行时决定。

// 报错const path = \'./\' + fileName;import a from path;

为了解决这个问题,ES2020提案 引入import()函数,支持动态加载模块。

// 上面的例子可以这样写const path = \'./\' + fileName;import(path).then(module=>{}).catch(err=>{});

importimport()最大的区别就是前者是静态导入,后者是动态导入,并且后者返回的是一个Promise。

import()函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()函数与所加载的模块没有静态连接关系,这点也是与import语句不相同。import()类似于 Node 的require方法,区别主要是前者是异步加载,后者是同步加载。

import()适合:

  • 按需加载
  • 条件加载
  • 动态路径加载

import()还可以配合解构asyn/await使用。

import(\'./myModule.js\').then(({export1, export2}) => {  // ...·});async function main() {  const myModule = await import(\'./myModule.js\');  const {export1, export2} = await import(\'./myModule.js\');  // 同时加载多个模块  const [module1, module2, module3] =    await Promise.all([      import(\'./module1.js\'),      import(\'./module2.js\'),      import(\'./module3.js\'),    ]);}main();

CommonJs vs Es6 Module

CommonJsEs6 Module
支持node程序支持web,未来也将支持node
CommonJs可以动态加载语句,代码发生在运行时Es Module既能动态导入,也能静态导入
CommonJs导出值是拷贝,可以修改导出的值ES6 Module输出的是值的引用,并且是只读的
CommonJs会缓存导入的模块不会缓存值

写到最后

随着官方将模块化作为核心语言特性来支持,未来的模块化方案很可能会是统一使用ES6 Module(Node13开始支持ES6模块),但是由于目前绝大多数node程序都使用CommonJs,未来较长一段时间可能仍然是两种模块方案并行使用。

最后,码字不易,如果这篇文章对你有帮助,还请点个赞,谢谢。

参考

部分内容参考自:

网道-ES6 Module

《JavaScript权威指南第七版》

以上是关于JavaScript模块化:从闭包到ES Module的主要内容,如果未能解决你的问题,请参考以下文章

javaScript闭包实现类与继承(非ES6)

javasrcipt的作用域和闭包闭包与模块

从作用域链谈闭包

从混沌到规范:JavaScript模块化方案的演进史

从混沌到规范:JavaScript模块化方案的演进史

闭包函数详解