你真的了解JavaScript模块化吗?

·  阅读 64

模块化编程:

  • 目标:用不同作者和来源的代码模块组装成大型程序。
  • 作用:主要体现在封装和隐藏私有实现细节,以及保证全局命名空间清洁上。

主要内容:

  • 基于类、对象和闭包的模块;
  • Node中使用require()的模块;
  • ES6中使用export、import和import()的模块。

1 基于类、对象和闭包的模块

类的一个重要特性,就是他们充当了自己方法的模块。

不相关的类的方法之所以能够相互独立,是因为每个类的方法都被定义为独立原型对象的属性。

js定义了不少数学函数和常量,但并没有把他们定义在全局命名空间中,而是把他们分组作为属性定义在全局Math对象上。

实用类和对象实现模块化是js中常见且有用的技术。但是不够,特别是类和对象没有提供任何方式来隐藏模块的内部实现细节。

在函数中声明的局部变量和嵌套函数都是函数私有的。这意味着可以使用立即调用的函数表达式来实现某种模块化,把实现细节和辅助函数隐藏在包装函数中,只降模块的公共API作为函数的值返回。

示例:

// 像这样定义stats模块
const stats = (function () {
  // 模块私有的辅助函数
  const sum = (x, y) => x + y;
  const square = (x) => x ** 2;

  //   要导出的公有函数
  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 };
})();
console.log(stats.mean([1, 3, 5, 7, 9]));// 5
console.log(stats.stddev([1, 3, 5, 7, 9]));// 3.1622776601683795
复制代码

1.1 基于闭包的自动化模块

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

示例:

const modules = {};

function require(moduleName) {
  return modules[moduleName];
}

modules["sets.js"] = (() => {
  const exports = {};

  //   sets.js文件内容
  exports.BitSet = class BitSet {};

  return exports;
})();
复制代码

把所有的模块都打包到类似上面的单个文件中之后,可以像下面这样写代码来使用他们。

const BitSet = require("sets.js").BitSet;
let s = BitSet();
复制代码

以上展示了针对浏览器的代码打包工具的基本工作原理,也是对Node程序中使用require()函数的一个简单介绍。

2 Node中的模块

在Node中,每个文件都是一个拥有私有命名空间的独立模块。在一个文件中定义的变量、常量、函数和类对该文件而言都是私有的,除非该文件会导出它们。二被模块导出的值只有被另一个模块显示导入后才会在该模块中可见。

Node模块使用require()函数导入其它模块,通过设置Exports对象的属性或完全替换module.exports对象来导出公共API。

2.1 Node的导出

Node定义了一个全局exports对象,这个对象始终有定义。如果要写一个导出多个值的Node模块,可以直接把这些值设置为exports对象的属性:

const sum = (x, y) => x + y;
const square = (x) => x ** 2;

exports.mean = function (data) {
  return data.reduce(sum) / data.length;
};

exports.stddev = function (data) {
  let m = mean(data);
  return Math.sqrt(
    data
      .map((x) => x - m)
      .map(square)
      .reduce(sum) /
      (data.length - 1)
  );
};
复制代码

更多的时候我们指向让模块导出一个函数或类,而非一个,包含很多函数或类的对象。为此,只要把想导出的值直接赋给module.exports即可:

module.exports=class BitSet extends AbstractWritableSet{
    //省略实现
}
复制代码

module.exports的默认值与exports引用的是同一个对象。

const sum = (x, y) => x + y;
const square = (x) => x ** 2;

const mean = function (data) {
  return data.reduce(sum) / data.length;
};

const stddev = function (data) {
  let m = mean(data);
  return Math.sqrt(
    data
      .map((x) => x - m)
      .map(square)
      .reduce(sum) /
      (data.length - 1)
  );
};

module.exports={mean,stddev}
复制代码

2.2 Node的导入

Node模块调用require()函数导入其它模块。这个函数的参数是要导入模块的名字,返回值是该模块导出的值(通常是一个函数、类或对象)。

如果想导入Node内置的系统模块或通过包管理器安装在系统上的模块,可以使用的非限定名,即不会被解析为文本系统路径的“/”字符的模块名。

// 这些都是Node内置系统模块
const fs=require('fs'); // 文件系统模块
const http =require('http'); // HTTP模块
复制代码

如果想导入你自己代码中的模块,则模块名应该是指向包含模块代码的模块文件的路径(相对当前模块文件)。推荐使用相对路径。

const stats = require('./stats.js');
复制代码

若模块导出一个带多个属性的对象,则有两个选择:一是导入整个对象;二是只导入打算使用的特定属性。

const stats = require('./stats.js');

const {mean} = require('./stats.js');
复制代码

3 ES6中的模块

ES6为js添加了import和export关键字,讲模块作为核心语言特性支持。

es6模块化与Node的模块化在概念上是相同的:每个文件本身都是模块,在文件中定义的常量、变量、函数和类对这个文件而言都是私有的,除非他们被显示导出。

es6模块化与Node的模块化的区别:

  • 导入和导出所用的语法;
  • 浏览器中定义模块的方式;

es6模块与常规js“脚本”的区别:

  • 模块化本身:常规脚本中,顶级声明的变量、函数和类会进入被所有脚本共享的全局上下文;在模块中,每个文件都有自己的私有上下文,可以使用import和export语句。
  • es6模块中的代码自动应用严格模式。这意味着:(1)不用在写use strict了;(2)模块中的代码无法使用with语句和arguments对象或未声明的变量。

严格模式下,在作为函数调用的函数中this是undefined。在模块中,即便是在顶级代码中this也是undefined(相对而言,浏览器和Node中的脚本都将this设置为全局对象)。

Node13开始支持es6模块。

3.1 es6的导出

使用export关键字。

示例一:

export const BASE = "http";
export function name(params) {
  return [];
}
复制代码

示例二:

const BASE = "http";
function name(params) {
  return [];
}

export { BASE, name };
复制代码

一个模块只导出一个值的情况,通常使用export default:

function name(params) {
  return [];
}

export default name;
复制代码

最后,注意export关键字只能出现在js代码的顶层。不能在类、函数、循环或条件内部导出值(这是es6谋爱系统的重要特性,用以支持静态分析:模块导出的值在每次运行时都相同,而导出的符号可以在模块实际运行前确定)。

3.2 es6的导入

使用关键字import导入其它模块导出的值。

最简单的形式是:导入定义了默认导出的模块:

import BitSet from './bitSet.js';
// 语法:import 标识符  from 导出模块的名字
复制代码

指定模块默认导出的值会变成当前模块中指定标识符的值。

获得导入值的标识符是一个常量。

导入只能出现在js代码的顶层。不能在类、函数、循环或条件内部出现。这个规则不是强制的,类似函数声明,会被提升到“顶部”。

模块标识符字符串必须是一个以“/”开头的绝对路径,或以“./”或“../”开头的相对路径,有或者是一个带有协议及主机名的完整URL。

到现在为止,我们只考虑了使用export default的模块导入一个值的情形。要从导出多个值的模块到入值,如下:

import { mean, stddev } from './stats.js';
复制代码

默认导出在定义他们的模块中不需要名字,在导入这些值的时候可以再给他们提供一个局部名。但非默认导出在导出它们的模块中则是有名字,在导入这些值时,需要通过名字引用它们。

花括号让import语句看起来像解构赋值。

花括号中的标识符会被提升到导入模块顶部,行为类似常量。

如下:import轻松导入所有值

import * as stats from './stats.js';
复制代码

像这样一条inport语句可以创建一个对象, 并将其赋值给一个名为stats的常量。被导入模块的每个非默认导出都会变成这个stats对象的一个属性。非默认导出始终有名字,这些名字将作为这个对象的属性名。这些属性是常量,不能被重写或删除在使用前面这个带通配符的导人语句时,导入模块需要通过stats对象使用导入的mean()和stddev()函數,即要通过stats.mean()和stats . stddev()调用它们。

模块通常要么定义一个默认导出,要么定义多个命名导出。一个模块同时使用export和export default 虽然合法,但并不常见。不过要是真有模块这么做,也可以只通过一条import语句同时导人默认值和命名值:

import Histogran, { mean, stddev } from "./histogran-stats.js";
复制代码

前面我们介绍了如何从带有默认导出的模块导人,以及如何从带有非默认导出或已命名导出的模块导入。而import语句还有另一种形式,用于导入没有任何导出的模块。要在程序中包含没有导出的模块,只要在import关键字后面直接写出模块标识符即可:

import './analytics.js';
复制代码

**这样的模块会在被首次导入时运行一次(之后再导入时则什么也不做)。**如果模块中只定义了一些函数,那么它至少要导出其中一个函数才能有用。而如果模块中运行一些代码,那么即便没有符号导入也会很有用。Web 应用可以使用分析模块(如analytics.js)运行注册各种事件处理程序的代码,然后通过这些事件处理程序在合适的时机向服务器发送遥测数据。虽然模块是自包含的,不需要导出任何值,但仍然需要通过import导入才能让它作为程序的一部分运行。

注意,对那些有导出的模块也可以使用这种什么也不导入的import语法。如果模块定义了与它的导出值无关的有用行为,而你的程序不需要它的任何导出值,那么可以只为它的默认行为导入这个模块。

3.3 导入和导出时重命名

如果两个模块使用相同的名字导出了两个不同的值,而你希望同时导入这两个值,那必须在导入时对其中一个或这两个进行重命名。类似地,如果在导入某个值时发现它的名字已经被占用了,则需要重命名这个导入值。可以在命名导入时使用as关键字对导入值进行重命名:

import { render as renderImage } from "./inageutils.js";
import { render as renderUI } from "./ut.js";
复制代码

这两行代码向当前模块导入了两个函数。这两个函教在定义它们的模块中都被命名为render(),但在导人时被重命名为更好理解且没有歧义的renderImage()和renderUI().

我们知道默认导出没有名字。导入模块在导入默认导出时始终需要选择一个名字。因此这种情况下不需要特殊语法。

尽管如此,导入时重命名的机制也为同时定义了默认导出和命名导出的模块提供了另一种导入方式。上一节的示例中有一个“./histogran-stats.js"模块,下面是同时导入其默认导出和命名导出的另一种方式:

import { default as Histogran, mean, stddev } from "./histogran-stats.js"
复制代码

在这种情况下,js 关键字default 充当一个占位符,允许我们指明想导入横块的默认导出并为其提供-一个名字。导出值时也可以重命名,但仅限于使用export语句的花括号形式。通常并不需要这样做,但如果你在模块内部使用了简洁短小的名字,那在导出值时可能希望使用更有描述性同时也不容易与其他模块冲突的名字。与导入时重命名类似,导出时重命 名也要使用as关键字:

export {
	layout as calculateLayout,
    render as renderLayout
};
复制代码

请大家始终记住,虽然这里的**花括号看起来像对象字面量,但其实并不是。**而且,export关键字需要as前面是一个标识符,而非表达式。这意味着不能像下面这样在导出时重命名:

export { Math.sin as sin, Math.cos as cos }; // SyntaxError
复制代码

3.4 再导出

本章我们一直在用一个假想的“./stats.js" 模块作示例,这个模块导出了mean()和stddev()函数。如果我们确实要写这样一个模块,但考虑到很多用户可能只需要其中某个函数,那我们可能会在“./stats/mean.js" 模块中定义mean(),在“./stats/stddev.js'模块中定义stddev().这样,程序只需导入真正要用的函数,而不会因导入不需要的代码造成体积膨胀。

不过,就算在单独的模块里定义了这些统计函数,仍然会有很多程序需要同时使用这两个函数。这时候如果有一个方便的“/stats.js" 模块,它们只要一行 代码就可以全都导入了。

在通过独立文件实现的情况下,定义这样一个“/statsjs"模块也很简单:

import { mean } from "./stats/mean.js";
import { stddev } from "./stats/stddev.js";
export { mean, stdev } ;
复制代码

ES6模块预见到了这个使用场景,并为此提供了一种特殊语法。这种语法不需要先导入再导出,而是把导入和导出合二为一,通过组合export 和from关键字构造一条“再导出”语句:

export { mean } from "./stats/mean.js";
export { stddev } from "./stats/stddev.js";
复制代码

注意,这里的代码并未使用名字mean和stddev。如果不需要选择性地再导出,而是希望导出另一个模块的所有命名值,则可以使用通配符:

export * from "./stats/mean.js";
export * from "./stats/stddev.js";
复制代码

再导出语法允许使用as进行重命名,就像在常规import和export语句中一样,假设我们想再导出mean()函数,但又想用average()作为这个函数的另一个名字,那可以这样做:

export { mean,mean as average } from "./stats/mean.js";
export { stddev } from "./stats/stddev.js";
复制代码

这个示例中的所有再导出语句都假定“./stats/mean.js" 和“ ./stats/stddev.js"模块使用export而非export default 导出它们的函数。不过,因为这两个模块都只有一个导出,所以实际上使用export default 来定义更合理。假设我们已经这样做了,那么再导出语法会稍微复杂- 点,因为需要为没有命名的默认导出定义名字。我们可以这样做:

export { default as mean } from "./stats/mean.js";
export { default as stddev } from "./stats/stddev.js";
复制代码

如果想将另一个模块的命名符号再导出为当前模块的默认导出,可以在import语句后面加一个export default; 或者,可以像下面这样组合这两个语句:

// 从./stats.js中导入mean()酒数
// 并将其作为当前模块的默认导出
export { mean as default } from "./stats.js"
复制代码

最后,要把另一个模块的默认导出再导出为当前模块的默认导出(虽然这样做似乎没有什么意义,因为用户可以直接导出另一个模块),可以这样写:

// 这个average.js模块只是再导出了./stats/mean.js的默认导出
export { default } from "./stats/mean. js"
复制代码

3.5 在网页中使用JavaScript模块

前几节以比较抽象的方式介绍了ES6模块及其import和export声明。本节和后面将具体讨论如何在浏览器中使用ES6模块。如果你还没有太多Web开发经验,建议先阅读第15章之后再阅读后面的内容。

2020年年初,使用ES6的产品代码仍然要通过webpack等工具来打包。这样做有一定的代价(注1),但总体上来看,代码打包后的性能是比较好的。随着网络速度的提升和浏览器厂商不断优化自己的ES6模块实现,这种状况迟早会改变。

尽管在线上部署时还要依赖打包工具,但鉴于目前浏览器对JavaScript模块的原生支持,开发期间它们已经不是必需的了。我们知道,模块代码默认在严格模式下运行,this不引用全局对象,顶级声明默认不会全局共享。因为模块代码必须与传统非模块代码以不同方式运行,所以必须修改HTML和JavaSeript才能使用模块。如果想在浏览器中以原生方式使用import指令,必须通过<script type="module"> 标签告诉浏览器你的代码是一个模块。

**注1:**例如,对于更新比较频繁的Web应用,经常回访的用户会发现使用小模块而不是大文件的平均加载时间更短,因为可以更好地利用浏览器缓存。

ES6模块的一个非常棒的特性是每个模块的导入都是静态的。因此只要有一个起始模块,浏览器就可以加载它导入的所有模块,然后加载第一批模块导入的所有模块, 以此类推,直到加载完所有程序代码。前面我们已经看到,import 语句中的模块标识符可以被看成相对URL。而<script type="module"> 标签用于标记一个模块化程序的起点。这个起点模块导入的任何模块預期都不会出现在 标签定义模块化JavaSeript程序的主入口可以像下面这样简单:

<script type="module">
	import './main.js';
</script>
复制代码

位于行内

带有type="module"属性的脚本会像带有defer属性的脚本一样 被加载和执行。 htML解析器一碰到

添加async属性可以改变执行模块代码的时机。这个属性会像对常规脚本一样对模块起作用。添加了async 属性的模块会在代码加载完毕后立即执行,而不管HTML解析是否完成,同时也有可能改变脚本执行的相对顺序。

支持

常规脚本与模块脚本的另一个重要区别涉及跨源加载。常规

有些程序员喜欢使用扩展名.mjs来区分模块化JavaScript文件和使用.js扩展名的常规、非模块化JavaScript文件。对浏览器和

3.6 通过import()动态导入

前面说到ES6的import和export指令都是静态的,因此JvasScipip解释器和其他js工具可以通过简单的文本分析确定加载之后模块之间的关系,而不必实际执行模块代码。静态导入的模块可以保证导入的值在任何模块代码运行之前就可以使用。

我们知道,Web 应用中的代码必须通过网络传输,而不是从文件系统读取的。传输完成后,代码可能会在CPU相对较慢的移动设备上执行。这不是静态模块导入的适用场景,因为静态模块导入需要先加载完全部程序再执行。

对于Web应用来说,先加载足够的代码用于渲染用户可见的第一个页面是很常见的。这样,当用户有了可以交互的预备内容后,可以再开始加载Web应用所需的其他(通常更庞大的)代码。使用浏览器提供的DOM API向当前HTML文档注入新

虽然浏览器很早就可以动态加载脚本了,但js语言本身却一直不支持动态导入。随着ES2020引入import(),这个局面终于被扭转了(2020 年年初,所有支持ES6模块的浏览器都支持动态导入)。传给import()一个模块标识符,它就会返回一个期约对象,表示加载和运行指定模块的异步过程。动态导入完成后,这个期约会“兑现”(参见第13章关于异步编程及期约的详细介绍)井产生一个对象,与使用静态导入语句import * as得到的对象类似。

也就是说,如果是静态导入“/stats.js"模块,我们要这样写:

import * as stats from "./stats.js";
复制代码

如果要动态导入并使用这个模块,那就要这样写:

import("./stats.js").then(stats => {
   let average = stats.mean(data);
})
复制代码

成者,在一个async函数中(同样,要理解下面的代码可能需要先看第13章),可以通过await简化代码:

async analyzeData(data) {
    let stats=await import("./stats.js");
    return {
        average: stats.mean(data),
        stddev: stats.stddev(data)
    };
}
复制代码

传给import()的参数应该是一个模块标识符,与使用静态import指令时完全一样。但对于import(),则没有使用常量字符串字面量的限制。换句话说,任何表达式只要可以求值为一个字符串且格式正确,就没问题。

动态import()虽然看起来像函数调用,但其实并不是。事实上,import()是一个操作符,而圆括号则是这个操作符语法必需的部分。之所以使用如此特别的语法,是因为import()需要将模块标识符作为相对于当前运行模块的URL来解析,而这在实现上需要一些特殊处理,这些特殊处理在JavaScript函数中是不合法的。实践中,这个函数与操作符的区别极少显现,只有在编写类似console. log(import);或let require =import;这样的代码时才会被注意到。

另外,要注意动态import()不仅在浏览器中有,webpack 等打包工具也在积极地利用它。使用打包工具最简单的方式是告诉它程序的主入口,让它找到所有静态import指令并把所有代码汇总为一一个大文件。而通过有意识地使用动态import()调用,可以把这样一个大文件拆分成多个小文件,实现按需加载。

3.7 import.meta.url

关于ES6模块系统,还有最后一个特性需要讨论。在ES6模块(而非常规

import.meta.url的主要使用场景是引用与模块位于同一(或相对)目录下的图片、数据文件或其他资源。使用URL()构造的数可以非常方便地相对于import.meta.url这样的绝对URL来解析相对URL。例如,假设你要写一-个模块,其中包含需要本地化的字符串,而相关的本地化文件保存在l10n/目录下,这个目录也保存着模块本身。你的模块可以使用通过下面的函数创建的URL来加载其字符串:

function localstringsURL(locale) {
    return new URL(`l10n/${Locale}.json`,import.meta.url);
}
复制代码
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改