【记一忘三二】ES6模块化

121 阅读12分钟

前言

BUG总是改不完的,但是要在无限的BUG中寻找有限的知识,平时导入组件模块都会使用import,但是有没有疑惑 import 是怎样工作的喃?如果不懂,那还等什么,不去冲,难道等着被技术淘汰吗?

模块导出和导入

export 导出

export 导出语法是:export ①

①指要导出的语句,必须包含声明,可以不存在赋值

export 能够导出使用了 functionclassvarletconst 的关键字的语句

 export var a = 5
 export let b = 6
 export const c = 7
 export function fun() { }
 export class obj { }

可能很多都会疑惑,为什么要着重去强调语句喃?这就要从我们常常使用过的错误导出入手了,先看几个错误例子


     var a = 5 
     export a                // 错误;不能是a变量的表达式,必须是完整的变量声明语句
     
     let b
     export b                // 错误;不能是b变量的赋值表达式,必须是完整的变量声明语句
     
     function fun() {} 
     export fun              // 错误;同理变量,导出函数也必须是函数声明语句
     
     export function () { }  // 错误;导出函数匿名函数,不能进行导出
     export () => { }        // 错误;箭头函数因为没有名称,也不能进行导出
  • 导出 functionclassvarletconst 声明的变量或函数时,需要提供完整的声明过程,不能值导出表达式,也就是说只能导出语句
  • 导出 function 声明的函数时,必须采用命名函数,不能采用匿名函数,当然,就算是匿名函数天花板的箭头函数也是不能直接进行导出的

export{}导出

export{}导出语法是:export { ① }

①指要导出的变量名称,这里只能是名称,不能是语句和表达式

看完上面的错误例子,就用朋友想问了,如果只能导出语句,那怎么动态导出变量函数呢?那就要提出export另外一种导出规范了,使用export{}

 var a = 5
 let b
 function fun() { }
 let fun1 = () => { }
 
 export {
     a, b, fun, fun1
 }

这种方式导出只需要在 {} 写上对应的名称就可以了,可能熟悉 ES6 语法的朋友,就会联想到 ES6 中的对象属性简写吧,但是 export{} 和对象属性简写没有写法没有任何关联,所以不要有下面这几种写法出现

     export {
         a:2
     }
     
     export {
         fun(){ }
     }
     
     var a = 5
     export {
         a: a
     }

import导入

ES6导入语法是:import { ① } from ②

① 指的是导入的参数名称列表

②是要导入模块的路径,可以是相对路径、绝对路径、包名;至于编译器如何区分不同类型模块和如何正确根据地址寻找到模块,这里就不细说了,要不又是长篇大论

 // util.js
 export var a = 5
 export class obj { }
 function fun() { }
 export {
     fun
 }
 
 // index.js
 import { a, fun, obj } from "./util.js"

不要把 { a, fun, obj } 当做对 a.js 的结构运算,所以对于结构运算的默认值、深度结构等的功能都不能用在这里用

模块对象导出

上面的 import 导入方式有在多变量导入时存在缺陷

  • 不知道需要导入变量的名称,就无法使用了
  • 导入的变量很多,那就要写很多变量名称,又丑又长

要解决这些问题,可以采用下面的全部入,也就是常说的模块对象

 import * as util from "./util.js"
 console.log(util.a)

重命名导出和导入

其实在上面的导入全部变量的时候就已经使用了 as 重命名关键字

在使用导入时,导入的变量名碰巧与你使用的变量名发生冲突,就需要重命名

 import { a as o1, fun as o2, obj as o3 } from "./util.js"
 console.log(o1, o2, o3);

当然重命名之后访问导入变量,就不能再通过原来的变量名称了

导出变量重命名的方式和解决的问题也是有同理的(使用的很少)

 var a = 5
 export {
     a as o1
 }

export default默认导入和导出

使用过 Nodejs 的或者 webpack 的朋友应该都知道require模块化

// util.js
exports.a = 5

// index.js
var util = require('./util.js');

CommonJS 可以直接导出一个模块对象,ES6模块化当然也不例外,ES6模块化需要配合使用默认导出 export default

export default导入语法是:export default { ① }

只能表达式,不能携带声明信息

// util.js
let a = 5
export default a

// index.js
import a from "./util.js"
console.log(a)

其实默认导入也可以理解为

// util.js
let a = 5
export {
	a as default
}

// index.js
import { default as a } from "./util.js"
console.log(a)

既然只能导出表达式,那就来几个错误案例吧

export default var a = 5
export default let b = 6
export default const c = 7
export default function fun() { }
export default class obj { }

ES6模块化 是兼容已经存在的 CommonJS 。如果你有一个Node项目,并且你已经执行了 npm install lodash 。你使用 ES6 能够单独导入 Lodash 中的函数:

import {each, map} from "lodash";

简单总结一下,对于上面的几种不同的导入可以多种方式混用,当然导出也是可以的

聚合模块

这里假设场景,在util目录中存放着整个项目的所以工具模块,因为模块文件文件很多,所以每次在使用到来自不同模块的方法时都需要引用多个文件,十分不方便,现在团队老大说写一个集中管理工具模块的主模块,每次只需要导入这个主模块就可以使用所以工具模块导出的变量;

那这个 main.js ,该怎么写喃?

按照我们已知的知识可以这样来实现,在 mian.js 中先导入使用组件,再统一导出使用组件

// a模块
export default {
    getName() {
        return "名称"
    }
}
// b模块
export default {
    getAge() {
        return "年龄"
    }
}
// main.js
import { getName } from "./a.js"
import { getAge } from "./b.js"
export {
    getName, getAge
}

ES6模块化 还给我们提供了一个更好的方式: export { ① } from ②

这里的 ①和 ②和 import xxx from xxx 所代表的是一样,这里就不赘述了

// main.js
export { getName } from "./a.js"
export * from "./b.js"

这里需要注意

  • mian.js 中是无法使用导入变量的,main.js 只是一个模块的中转站
  • * 代表的导入模块中的所以变量,最好不要用,因为名称容易发生冲突
  • 聚合导入只能处理 export的内容,对于 export default 无效

ES6模块化的特点

声明提升顶部执行

import 的执行是同步的,并且 import 在运行阶段会提升到代码顶部提前执行,这也代表可以把 import 模块的任意顶层使用,不是顶级的位置不能使用,比如函数体内、判断体内、循环体内等

// util.js
console.log("util模块内");
var a = 5
export default a

// index.js
console.log("index执行");
import { a } from "./util"
> node ./index.js
util执行    
index.js执行

可以发现在运行 index.js ,会发现在执行index.js的时候没有首先运行 index.js 内部代码,而是首先执行的是 util.js ,运行了 util.js 内部的代码,无论把 import 写在代码的什么位置,都会在运行阶段提前执行,但是一般还是要把 import 写在顶部,利于知道这个模块导入了那些组件

导入变量是引用值

import导入的模块对象是值的引用,并且无法在模块外对顶层数据进行修改

// util.js
var a = 5
export default a

// index.js
import util from "./util"
util = 6 
> node ./index.js
util = 6
  ^
TypeError: Assignment to constant variable.

控制提示在a=6的位置出现错误,这是因为模块暴露出来的变量只能访问不允许修改的;但是凡事不是绝对的,看一下下面的例子

// util.js
export default {
    name: "李白"
}

// index.js
import util from "./util.js"
console.log(util.name);    
util.name = "杜甫"
console.log(util.name);
> node ./index.js
util模块内
index.js模块内

其实不难发现导入的模块变量如果是基本类型才不能改变,对于引用类型的属性也是可以改变的,但是一般不这样做

导出值响应

注意:只有使用 export 导出的变量才具有相应变化

当使用 export 导出时,就算导出的 基本类型 ,还是具有 响应变化,也就是在导出模块中海边导出值,会直接影响到导出结果

// util.js
export var a = 2;
setTimeout(() => {
  a = 3;
});

// export { a }; 也是可以实现响应

// index.js
import { a } from "./util.js";
console.log("index.js中a的值", a);
setTimeout(() => {
  console.log("index.js中a的值", a);
}, 500);
> node ./index.js
mian.js中a的值 2
index.js中a的值 2
mian.js中a的值 3
index.js中a的值 3

export default 导出的值不具有该特性

// util.js
var a = 2;
setTimeout(() => {
  a = 3;
});

export default a;

// index.js
import a from "./util.js";
console.log("index.js中a的值", a);
setTimeout(() => {
  console.log("index.js中a的值", a);
}, 500);

这里不代表default 不能实现响应,只要通过 ``export` 也是可以实现响应的

// util.js
var a = 2;
setTimeout(() => {
  a = 3;
});

export {
  a as default
} ;


// index.js

import { default as a } from "./util.js";
console.log("index.js中a的值", a);
setTimeout(() => {
  console.log("index.js中a的值", a);
}, 500);

这里 default 也是响应的

// util.js
var a = 2;
setTimeout(() => {
  a = 3;
});

export default a;

换成 export default 就不行了

只会执行一次

模块只会在第一次导入的时候执行,并且会且只会执行这一次,以后的导入对于不会再执行模块

// util.js
console.log("util.js执行了");
export default {}

// main.js
console.log("main.js执行了");
import util from "./util.js";
export default {}

// index.js
console.log("index.js执行了");
import main from "./main.js"
import util from "./util.js";
> node ./index.js
util.js执行了
main.js执行了
index.js执行了

就算 util.jsmain.jsindex.js 都引入了,但是还是只执行了一次

编译预处理

import会在编译阶段提升到代码顶部,方便在运行阶段提前执行

export会在编译阶段提前暴露当前模块的变量,这也是为什么 export 无法导出匿名的函数和箭头函数的原因,因为 export 在编译阶段就要提前确定模块暴露的变量,所以必须要确定名称,不能匿名或者动态生成的名称

因为是编译阶段发生的事情,这个特点只能依靠循环导入来证明了

// util.js
console.log("util.js执行了");
import { a, getName } from "./index.js";
console.log(a);   // undefined
console.log(getName());  // name
export let b = 3

// index.js
console.log("index.js执行了");
import { b } from "./util.js";
console.log(b); // 3
export var a = 3
export function getName(){
    return "name"
}
> node ./index.js
util.js执行了
undefined
name
index.js执行了
3

这里可以因为import会在编译阶段预先处理,提升到代码顶端,所以提前执行util.js,在util.js中首先执行import,因为index.js已经进入执行了,所以这里不会再次执行index.js,因为index.js已经提前通过export确定了模块的导出变量,所以继续往下执行,在输出agetName不会报错,util.js执行完毕就继续执行index.js

按需加载模块

按需加载场景

通过ES6模块化的特点我们了解到import预编译和顶部提前执行,所以是无法动态引入模块的,也就是无法按需引入,无论你是否会使用,都会在代码中最先执行引入;但是在实际开发中,按需引入是也是开发中所必须的,比如在VUE路由中,如果在首屏加载的时候就把所以的路由组件都加载完毕,那么首屏渲染时间是很慢的,在这个时候只需在加载首屏时只加载所需要的路由组件就行!!!

在下文中我会把通过动态import导入的方式称为动态import,而非动态import导入的方式称为静态import

动态import可以理解为模块懒加载,而静态import就是模块的预加载

基本使用

在ES6模块化中还未有这种API,但是我们可以通过webpack来实现这一需求

 // util.js
 export default {
     name: "李白"
 }
 
 // index.js
 import("./util.js").then(res => {
     console.log(res);
 })

这里不能通过node去运行代码了,因为按需导入模块是webpack对ES6模块化的扩充,使用必须使用webpack进行打包运行

image-20221029170847934

这里可以发现通过then方法获取的值,也可以推理出动态import采用了Promise的方式获取模块中的值,那就不难发现动态import的特点

  • 动态import是异步加载模块获取值,而静态import是同步获取值
  • 动态import不会在预提升到模块顶部最先执行,而是代码执行到具体位置再执行加载

不同导出的差异

我相信很多朋该或多或少都使用过这种用法,但是可能不知道使用这种方式导入,具体会带来怎样的效果,那我们先从打包出来的目录来探索吧

首先看看静态import浏览器所加载的文件

 // index.js
 import util from "./util.js"

image-20221030121513917

可以发现只加载了index.js文件,因为同步import是在编译阶段就进行了模块加载,使用在webpack打包的时候就会预先处理模块直接的关系,打包成一个文件

再看看动态import所加载的文件

 // index.js
 import("./util.js").then(res => { })

image-20221030123511475

不难看出加载了两个文件,这个是因为动态import需要代码执行到import才进行加载,如果打包成一个文件,那么就失去了使用动态import的意义,如果代码执行不到这一行,还可以减少加载文件

动态文件名(路径)

在讲静态import引入的时候说了,静态import会预加载,所以不能采用动态名称,但是动态import是在代码执行中才去加载的,显然可以动态名称

 // index.js
 let fileName = "util.js"
 import(`./${fileName}`).then(res => {
     console.log(res);
 })

但是还是有两个注意项

  • webpack 不支持完全的动态文件名
 let path = "./util.js"
 import(path).then(res => {
     console.log(res);
 })

image-20221030134959473

控制台直接报错,找不到util.js,但是我们可以通过路径找到这个文件的存在,为什么webpack却找不到喃?

webpack 的工作原理是对文件进行静态扫描,然后根据一定规则处理的。webpack 在扫描到动态import语法时,会将路径中的变量转换成正则表达式的 .* ,然后根据这个正则匹配文件名,对匹配上的文件独立chunk模块打包输出。如果文件路径名只有一个变量,那么就是匹配目录下的所有文件,这明显是不合理的,所以 webpack 不支持完全的动态文件名

  • 在使用动态变量时,符合webpack打包规则的文件最好都能被使用,否则就浪费打包效率

参考文献

webpack 按需加载模块 import()

深入理解ES6的模块

前端基础进阶(三):变量对象详解

必须要知道的 CommonJS 和 ES6 Modules 规范

CommonJS vs ES6 import/export

深入理解 ES6 模块机制