JavaScript权威指南 第10章 模块

18 篇文章 0 订阅
1 篇文章 0 订阅

第10章 模块

模块化编程的目标是能够用不同作者和来源的代码模块组装成大型程序,即使不同模块的作者无法预知如何使用,代码仍然可以正确运行。实践中,模块化的作用主要体现在封装和隐藏私有处理细节,以及保证在全局命名空间清洁上,因而模块之间不会意外修改各自定义的变量、函数和类。

直到几年前,JavaScript还没有内置对模块的支持。大型项目的程序员想方设法地利用类、对象和闭包的弱模块化能力。由于打包工具的支持,基于闭包的模块化在实践中称为常用模块化形式,核心是沿用了Node的require()函数。基于require()的模块是Node编程环境的基础,但并未被作为JavaScript语言的官方部分采用。事实上,ES6使用import和export关键字定义了自己的模块。尽管import和export在多年前就已经被列为这门语言的关键字,但直到最近才真正被浏览器和Node实现。实践中,JavaScript的模块化仍然以来代码打包根据。

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

尽管可能显而易见,但还是有必要指出:类的一个重要特性,就是它们充当了自己方法的模块。大家可以回头看一看示例9-8,示例9-8中定义了几个不同的类,这些类都有一个名叫has()的方法。你可以在一个程序中同时使用该实例定义的多个集合类,而不必担心BitSet()的has()方法会被SingletonSet的has()方法重写。

不相关的类的方法之所以能够相互独立,是因为每个类的方法都被定义为独立原型对象的属性。而类之所以成为模块,是因为对象是模块:给一个JavaScript对象定义属性非常想声明变量,但给对象添加属性不影响程序的全局命名空间,也不影响其他对象的属性。JavaScript定义了不少数学函数和常量,但并没有把它们定义在全局命名空间中,而是将它们分组作为数学定义在全局Math对象上。实例9-8也可以借鉴同样的思想。例如,不是把SingletonSet和BitSet定义为全局类,而是只定义一个全局Sets对象,通过这个对象的属性引用不同的类。然后,这个Sets库的用户可以通过类似Sets.Singleton和Sets.Bit这样的方式引用这些类。

使用类和对实现模块化是JavaScript编程中常见且有用的技术,但这还不够。特别地,类和对象没有提供任何方式来隐藏模块的内部实现细节。再看看实例9-8,如果我们把该实例写成一个模块,那么可能会希望把各种抽象类作为模块的内部代码,只对模块用户暴露具体的子类。类似地,在BitSet中,_valid()和_has()是内部辅助方法,也不应该暴露给具体的子类。而BitSet.bits和BitSet.masks也是实现细节,最好也隐藏。

正如我们在8.6节中看到的,在函数中声明的局部变量和嵌套函数都是函数私有的。这意味着我们可以使用立即调用的函数表达式来实现某种模块化,把实现细节和辅助函数隐藏在包装函数中,只将模块的公共API作为函数的值返回。以BitSet为例,可以像下面这样实现这个模块:

const BitSet=(function(){ //将BitSet设置为这个函数的返回值
    //这里私有实现细节
    function isValid(set,n){}
    function has(set,byte,bit){}
    const BITS=new Uint8Array([1,2,4,8,16,32,64,128]);
    const MASKS=new Uint8Array([~1,~2,~4,~8,~16,~32,~64,~128]);
    //这个模块的公共API就是BitSet类,在这里定义并返回
    //这个类可以使用上面定义的私有函数和常量,但这些私有
    //函数和变量对这个类的用户是不可见的
    return class BitSet extends AbstractWritableSet{
        //...省略实现...
    };
}());

如果模块需要暴露多个值,这种实现模块化的方式就比较有意思了。例如,以下代码定义了一个小型统计模块,暴露了mean()和stddev()函数,同时隐藏了实现细节:

const status=(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};
}());

//下面是使用这个模块的实例
status.mean([1,3,5,7,9])
=>5
status.stddev([1,3,5,7,9])
=>3.1622776601683795

10.1.1 基于闭包的自动模块化

我们注意到,在一个JavaScript代码文件开头和模块插入一些文本,把它转换为类似的模块是一个相当机械的过程。这里所需要的就是对JavaScript代码文件设定一些规则,按照规则可以指定哪些值要导出,哪些值不导出。

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

const modules={}
function require(moduleName){
    return modules[moduleName];
}
modules["set.js"]=(function(){
    const exports={};
    //set.js文件的内容在这里
    exports.BitSet=class BitSet{
        //省略
    }
    
    return exports;
}());
modules["stats.js"]=(function(){
    const exports={};
    
    //stats.js文件的内容在这里
    const sum=(x,y)=>x+y;
    const square=x=>x*x;
    exports.mean=function(data){
        //省略
    }
    exports.stddev=function(data){
        //省略
    }
    
    return exports;
}());

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

//取得对所需模块(或模块内容)的引用
const stats=require("stats.js")
const BitSet=require("sets.js").BitSet;

//接下来写使用这些模块的代码
let s = new BitSet(100);
s.insert(10);
s.insert(20);
s.insert(30);
let average = stats.mean([.. .s]); // 平均数是 2。

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

10.2 Node中的模块

编写Node程序时,可以随意将程序拆分到多个文件中。这些JavaScript代码文件被假定始终存在于一个快速文件系统中。与通过相对较慢的网络连接读取JavaScript文件的浏览器不同,把所有Node代码都写到一个JavaScript文件中既无必要也无用益处。

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

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

10.2.1 Node的导出

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

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

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

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

module.exports=class BitSet extends AbstractWritableSet{
    //省略实现
};

module.exports的默认值与exports引用的是同一个对象。在前面的统计模块中,实际上也可以直接把mean函数赋值给module.exports.mean,而不是exports.mean。另一种重写这个统计模块的方式是在模块末尾导出一个对象,而不是写一个函数导出一个函数:

//定义所有公有函数和私有函数
const sum=(x,y)=>x+y;
const square=x=>x*x;
const stddev=function(d){
    let m=exports.mean(d);
    return Math.sqrt(d.map(x=>x-m).map(square).reduce(sum)/(d.length-1));
};

//最后只导出公有函数
module.exports={mean,stddev};

10.2.2 Node的导入

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

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

//这些都是Node内置的模块
const fs=require("fs")    //内置的文件系统模块
const http=require("http")   //内置的HTTP模块

//Express HTTP服务框架是在第三方模块
//不属于Node,但已安装在本地
const express=require("express");

如果你想导入你自己代码中的模块,则模块名应该是指向包含模块代码的模块文件的路径(相对于当前模块文件)。虽然可以使用以“/”开头的绝对路径,但在导入自己程序中的模块时,通常都使用“./”或“…/”开头的模块名,以表示它们相对于当前的目录或父目录。例如:

const stats=require('./stats.js');
const BitSet=require('./utils/bitset.js');

虽然省略导入文件的.js后缀,Node仍然可以找到这些文件,但包含这些文件扩展名还是很常见的。

如果模块只导出一个函数或类,则只要调用require()取得返回值即可。如果模块导出一个带多个属性的对象,则有两个选择:一是导入整个对象;二是(通过解构赋值)只导入打算使用的特定属性。比较一下这两种方式:

//导入整个stats对象,包含所有函数
const stats=require('./stats.js')
//虽然导入了用不到的函数,但这些函数
//都隐藏在“stats”命名空间之后
let average=stats.mean(data);

//当然,也可以使用常见的解构赋值直接
//向本地命名空间中导入想用的函数
const {stddev}=require('./stats.js')

//这样当然间接明了,只是stddev()函数没有
//'stats'前缀作为命名空间,因此少了上下文消息
let sd=stddev(data);

10.2.3 在Web上使用Node风格的模块

通过Exports对象和require()函数定义和使用的模块是内置于Node中的。但如果使用webpack等打包工具来处理代码,也可以对浏览器中运行的代码使用这种风格的模块。目前,这种做法仍然非常常见,很多在浏览器上运行的代码都是这么做的。

10.3 ES6中的模块

ES6为JavaScript添加了import和export关键字,终于将模块作为核心语言特性来支持了。ES6模块化与Node的模块化在概念上是相同的:每个文件本身都是模块,在文件中定义的常量、变量、函数和类对这个文件而言都是私有的,除非它们被显式导出。另外,一个模块导出的只只有在显式导入它们的模块中才可以使用。ES6模块与Node模块的区别在于导入和导出所用的语法,以及浏览器中定义模块的方式。后面几节将详细介绍这些内容。

首先要注意,ES6模块与常规JavaScript“脚本”也有很多重要的区别。最明显的区别是模块化本身:在常规脚本中,顶级声明的变量、函数和类会进入被所有脚本共享的全局上下文。而在模块中,每个文件都有自己的私有上下文,可以使用import和export语句,当然这正是模块应有之义。但除此之外,模块和脚本还有其他区别。ES6模块中的代码(与ES6的class定义中的代码类似)自动应用严格模式。这意味着在使用ES6模块时,永远不用再写use strict了。同时也意味着模块中的代码无法使用with语句和arguments对象或未声明的变量。ES6模块甚至比严格模式还要更严格:在严格模式下,在作为函数调用的函数中this是undefined。而在模块中,即便在顶级代码中this也是undefined(相对而言,浏览器和Node中的脚本都将this设置为全局对象)。
在这里插入图片描述

10.3.1 ES6的导出

要从ES6模块导出常量、变量、函数或类,只要在声明前加上export关键字即可:

export const PI=Math.PI;

export function degressToRadians(d){ return d*PI/180 }

export class Circle{
    constructor(r){ this.r=r; }
    area() { return PI*this.r * this.r; }
}

要取代使用多个export关键字的做法,可以先正常定义常量、变量、函数和类,不加export关键字。然后(通常在模块末尾)只用一个export语句声明真正要导出的值。也就是说,前面使用三个export的代码等价于下面这一行代码:

export { Circle, degressToRadians ,PI };

这个语法看起来像是export关键字后跟一个对象字面量(使用了简化写法),但这里的花括号实际上不会定义对象字面量。这个导出语法仅仅是要求在一对花括号中给出一个逗号分隔的标识符列表。

一个模块只导出一个值(通常是一个函数或类)的情况是很常见的,此时通常可以使用export default而不是export:

export default class BitSet{
       //省略实现
 }

与非默认导出相比,默认导出(export default)在导入时稍微简单一些。因此只有一个导出值得情况下,使用export default可以简化使用导出值的模块代码。

使用export的常规导出只对有名字的声明有效。而使用export default的默认导出则可以导出任意表达式,包括匿名函数表达式和匿名表达式。这意味着如果使用export default,则可以导出对象字面量。因此,与export语法不同,位于export default后面的花括号是实实在在会被导出的对象字面量

模块中同时有一些常规导出和一个默认导出是合法的,只是不太常见。如果模块中有默认导出,那就只能导出一个。

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

10.3.2 ES6的导入

导入其他模块导出的值要使用import关键字。最简单的形式是导入定义了默认导出的模块:

import BitSet from './bitset.js';

首先是import关键字,跟着一个标识符,再跟着一个from关键字,最后的字符串字面值是要导入其默认导出的模块的名字。指定模块默认导出的值会变成当前模块中指定标识符的值。

获得导入值得标识符是一个常量,就像是使用const关键字声明得一样。与导出类似,导入也只能出现在顶层,不允许再类、函数、循环或条件中出现。按照近似普适得惯例,一个模块所需得导入都应该放在这个模块的开头。不过有意思的是,这个规则并不是强制性的。导入与函数声明类似,会被“提升”到顶部,因此所有导入的值在模块代码运行时都是可用的。

从中导入值的模块以常量字符串字面量的形式在单引号或双引号中给出(不能使用变量或其他值作为字符串的表达式,也不能把字符串放在反引号中,因为模板字面量有可能插入变量,并非只包含常量值)。在浏览器中,这个字符串会被解释为一个相对于导入模块位置的URL(在Node中,或当使用打包工具时,这个字符串会被解释为相对于当前模块的文件名,不过这在实践中没有太大差别)。模块标识符必须是一个以“/”开头的绝对路径,或者是一个以“./”或“…/”开头的相对路径,又或者是一个带有协议及主机名的完整URL。ES6规范不允许类似“util.js”的非限定模块标识符字符串,因为它存在歧义:它是当前模块同级目录下的一个模块呢,还是安装在特殊位置的某个系统模块呢?(webpack等代码打包工具不会限制这种“裸模块标识符”,因为通过配置很容易在指定的库目录中找到裸模块。)JavaScript语言未来的某个版本可能会允许“裸模块标识符”,但现在还不允许。如果想从当前模块的统计目录导入某个模块,只需要在模块名前面加上“./”,也就是使用“./util.js”而非“util.js”。

到现在为止,我们只考虑了从使用export default的模块导入一个值的情形。要从导出多个值的模块导入值,就要使用稍微不一样的语法:

import {mean,stddev} from './stats.js';

前面提到过,默认导出在定义它们的模块中不需要名字,在导入这些值的时候可以再给它们提供一个局部名。但非默认导出再导出它们的模块中则是有名字的,在导入这些值时,需要通过名字引用它们。导出模块可以导出任意多个命名的值。引用该模块的import语句可以导入这些值的任意子集,只要在花括号中列出它们的名字即可。花括号让import语句看起来像解构赋值,而解构赋值也确实是这种导入风格一个不错的类比。花括号中的标识符都会被提升到导入模块顶部,行为类似常量。

风格指南有时会推荐显式导入模块将用到的所有符号。不过在从定义了很多导出的模块导入值时,可以像下面这样以一条import语句轻松导入所有值:

import * as stats from './stats.js';

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

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

import Histogram,{mean,stddev} from './histogram-stats.js';

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

import "./analytics.js"

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

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

10.3.3 导入和导出时重命名

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

import {render as renderImage} from './imageutils.js";
import {render as renderUI} from './ui.js'

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

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

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

import {default as Histogram,mean,stddev} from "./histogram-stats.js"

在这种情况下,JavaScript关键字default充当一个占位符,允许我们指明导入模块的默认导出并为其提供一个名字。

导出值时也可以重命名,但仅限于使用export语句的花括号形式。通常并不需要这样做,但如果你在模块内部使用了简介短小的名字,那在导出值时可能希望使用使用更有更有描述性同时也不容易与其他模块冲突的名字。与导入时重命名类似,导出时重命名也要使用as关键字:

export {
   layout as calculateeLayout,
   render as renderLayout
};

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

export {Math.sin as sin,Math.cos as cos}  //SyntaxError

10.3.4 再导出

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

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

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

import {mean} from './stats.js/mean.js';
import {stddev} from './stats.js/mean.js';
export {mean,stddev}

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'

10.3.5 在网页中使用JavaScript模块

前几节以比较抽象的方式介绍了ES6模块及其import和export声明。本节和后面将具体讨论如何在浏览器中使用ES6模块。

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

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

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

<script type="module">import "./main.js";</script>

位于行内< script type=“module”>标签中的代码是一个ES6模块,因此可以使用export语句。不过,这样做没有任何意义,因为HTML的< script >标签语法没有提供为行内模块定义名字的方法。因此,即便这个模块导出了值,其他模块也没有办法导入。

带有type="module"属性的脚本会像带有defer属性的脚本一样被加载和执行。HTML解析器一碰到< script >标签,就会开始加载代码(对于模块而言,加载代码可能是一个递归加载多个JavaScript文件的过程)。不过,带啊吗执行则会推辞到HTML解析完成才开始。HTML解析一完成,脚本(包括模块和非模块)就会按照它们在HTML文档中出现的顺序执行。

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

支持< script type=“moddule” >的浏览器必须也支持< script nomodule >。支持模块的浏览器会忽略带有nomodule属性的脚本,不执行它们。不支持模块的浏览器因为不认识nomodule属性,所以会忽略这个属性的存在而运行其脚本。这样就为兼容旧版本浏览器提供了一个强大的技术。支持ES6模块的浏览器也支持类、箭头函数和for/of循环等其他现代JavaScript特性。如果用< script type=“module” >来加载现代JavaScript代码,你就知道它只会在支持它的浏览器中运行。同时为了兼容IE11(2020年唯一不支持ES6的浏览器),可以使用Babel和webpack等工具把代码转换为非模块的ES5代码,然后通过< script nomodule >来加载这些效率没那么高的转换代码。

常规脚本与模块脚本的另一个重要区别涉及跨源加载。常规< script >标签可以从互联网上的任何服务器加载JavaScript代码文件,而互联网广告、分析和追踪代码都依赖这个事实。但< script type=“module” >增加了跨源加载的限制,即只能从包含模块的HTML文档所在的域加载模块,除非服务器添加了适当的CORS头部允许跨源加载。这个新的安全限制带来了一个副作用,就是不能在开发模式下使用file:URL来测试ES6模块。为此在使用ES6模块时,需要启动一个静态Web服务器来测试。

有些程序员喜欢使用扩展名.mjs来区分模块化JavaScript文件和使用.js扩展名的常规、非模块化JavaScript文件。对浏览器和< script >标签而言,文件扩展名其实无关紧要(不过,MIME类型很重要,因此如果你使用.mjs文件,就需要配置Web服务器以跟.js文件相同的MIME类型来区分它们)。Node对ES6模块的支持则依赖文件扩展名,即要靠扩展名来区分要加载的文件使用了哪种模块系统。换句话说,如果你希望自己写
的ES6模块可以在Node中使用,就要考虑使用.mjs命名约定

10.3.6 通过import()动态导入

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

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

对于Web应用来说,先加载足够的代码用于渲染用户可见的第一个页面是很常见的。这样,当用户有了可以交互的预备内容后,可以再开始加载Web应用所需的其他(通常更庞大的)代码。适用浏览器提供的DOM API向当前HTML注入新 < script >标签可以方便地动态加载代码,Web应用在很多年前就开始这么做了。

虽然浏览器很早就可以动态加载脚本了,但JavaScript语言本身却一直不支持动态导入。随着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()调用,可以把这样一个大文件拆分成多个小文件,实现按需加载。

10.3.7 import.meta.url

关于ES6模块系统,还有最后一个特性需要讨论。在ES6模块(而非常规< script >或通过require()加载的Node模块)中,import.meta这个特殊语法引用一个对象,这个对象包含当前执行模块的元数据。其中,这个对象的url属性是加载模块时使用的URL(在Node中是file://URL)。

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

function localStringsURL(locale){
    return new URL(`l10n/${locale}.json`,import.meta.url);
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值