js 模块化进阶

357 阅读10分钟

js 模块化进阶

对编译原理不熟悉的建议可以将探秘esmoudle放在熟悉编译相关知识以后再来看探秘esmoudle

js 模块发展历程

浏览器加载script

在浏览器发展早期通过srcpit标签进行加载外部嵌入的script以及html页面内部书写js

<!-- 页面内嵌的脚本 -->
<script type="application/javascript"></script>

<!-- 外部脚本 -->
<script type="application/javascript" src="./zgl.js">
</script>

浏览器是同步加载 JavaScript 脚本,边下script脚本的内容边解析,即渲染引擎遇到

如果脚本体积很大,下载和执行的时间就会很长,如果script标签中存在比较耗时的任务,

因此造成浏览器堵塞,就会导致白屏时间变长甚至会页面崩溃,用户会感觉到浏览器“卡死”了

我们是否能够延迟script标签的解析时间或者等待html解析完毕以后再去解析script标签呢?答案是可以的.

浏览器提供了script标签异步加载方案

async

<script src="./zgl.js" async></script>

上面代码中如果浏览器解析script标签的时候遇到了async,那么渲染引擎会忽略当前script标签的解析,但是并不会暂停对当前script脚本的下载,直到当前脚本下载完毕以后会停止对html的解析,然后去解析已经下载完毕的script标签的内容,等待script内容解析完再继续去解析html

defer

<script src="./zgl.js" defer></script>

​ 上面代码中如果浏览器解析script的时候遇到了defer,那么渲染引擎会忽略当前script标签的解析,但是并不会暂停对当前script脚本的下载,直到都没解析完毕以后,然后按照下载脚本书写的顺序去解析已经下载完毕的脚本

动态加载script脚本信息

const script = document.createElement('script');

window.onload = function () {
  script.src = './zgl/js';
  document.body.appendChild(script)
}

上面代码中等待浏览器解析html完成以后,然后再去执行脚本的下载以及解析

IIFE

由于script脚本在加载过程中可能会存在多个不一样的脚本中命名会发生覆盖的问题,就会产生命名冲突,我们是否有一种方式能够解决当前模块只能访问当前模块的变量,不能让其他模块中变量进行对当前模块的变量进行覆盖呢?答案是有的,我们可以利用js执行上下所产生的闭包来解决模块命名问题

const moduleA = (function(){
  return {
    name: 'zgl'age: 26getName() {
      return this.name;
    }
  }
})();

const moduleB = (function(){
  return {
    name: 'zgl'age: 26getName() {
      return this.name;
    }
  }
})();

// 由于js闭包的关系,模块只能访问模块内部的变量

上面代码利用js的执行上下文规则有效的解决变量覆盖以及变量冲突带来的一些问题

探秘现代模块化

模块进行加载的方案主要科研归为编译时加载和运行时加载,

大部分语言都选择了运行加载接下来我们分析一下为什么选择会编译时

编译时加载的优点
  • 编译时加载能够提早发现一些错误,而不用等到运行时,比如引用的时候能够及时发现当前引用的路径是否正确以及引用的名称是否正确等。
  • 编译时能够删除某些没有使用的模块,比如某些模块引用了但是实际并没有使用到当前模块可以进行删除。
  • 编译时能够对引用的模块进行优化, 比如A模块中暴露了a,b2个变量,而实际引用的时候,其实只引用了a变量,那么b变量相关的代码可以被优化掉
  • 编译时能够提前给模块分配内存,而不用等到运行时分配内存,内存分配不够的问题也能在编译中提前解决
  • 能够提前发现模块之间的依赖关系以及是否存在循环问题
运行时加载的优点
  • 能够在任何地方(比如函数中)进行模块的引用,使用方式比较灵活没有任何约束

探秘commonjs

伴随着nodejs的兴起,模块化越来越成为一个亟待解决的问题,到底应该怎么去解决呢?

接下来我们一起去探索一下commonjs是怎么实现的以及为什么这么去实现

  • 难点—: 加载方式是同步的还是异步的?
  • 难点二: 加载时机是运行时还是编译时(ps: 不懂的可以去问度娘)
  • 难点三: 如何解决模块与模块之间的循环问题
  • 难点四: 如何定义模块的暴露方式以及引用方式

由于nodejs主要是I/O密集型的,也就是说主要是用来读取文件,操作文件等,并不涉及到页面的渲染,以及服务器提供的资源相较于浏览器是比较充分的,因此为了降低实现难度选择了同步的加载方式

加载时机是由语法决定的
// 第一种语法
const fs = require('fs');

const fileContent = fs.readFileSync('./a.js', { encoding: 'utf-8'});

// 第二种语法
function readFile(filePath) {
  cons fs = require('fs');
  const fileContent = fs.readFileSync('./a.js', { encoding: 'utf-8' });
  return fileContent;
}


node模块的加载时机

上述罗列了编译进行加载的优点,那么nodejs为什么放弃编译时而选择运行时呢?

nodejs需要提供像第二段代码这样的使用方式,是编译时做不到的。编译时并不能提前去加载某个函数中引用了某个模块,因此只能选择运行时

模块与模块的循环引用问题
// a.js
 const b = require("./b.js");

// b.js
const a = require('./a.js');

循环加载: 2个模块或者多个模块之间存在强耦合关系,相互之间引用,可能会导致递归加载依赖,使程序崩溃

循环引用带来的危害是比较大的,那么我们如何去避免加载循环引用呢

我们先来看一段代码

//创建一个存在循环引用的对象
const moduleA = {
  b: 'zgl'
};
moduleA.b = moduleA;

JSON.stringify(moduleA) // TypeError: Converting circular structure to JSON

// 那么我们应该如何去解决在json序列化过程中产生的循环引用问题呢
// 其实我们只要去比对当前对象是否已经被遍历过,如果当前对象被遍历过,我们就应该停止对当前的对象
// 可以使用一个数组去缓存已经被遍历的引用数据类型,下一次遍历的时候先判断缓存中是否存在,如果存在说明已经被遍历过,停止对引用数据类型的遍历

上述代码注释向我们阐述了如何解决在json序列化的过程中存在循环引用问题,那么我们能否借鉴到模块循环引用的问题上呢

当然是可以的

// 缓存模块
const modules = [ ];
function require(module) {
  
  const index = modules.indexOf(module);
  if (index < 0) {
    modules.push(module);
  };
  return modules[index];
}

// 我们来分析一下上面的这段代码存在什么问题呢

第一个问题: 判断当前模块是否在缓存中的时间复杂度为n

第二个问题: 无法判断当前模块是否加载阶段还是其他阶段

const modules = {

};

function require(module) {
  const { id } = module;
  
  // 增加loaded属性用于判断模块是否被加载过
  if (!module.loaded) {
    module.loaded = true;
    modules[id] = module;
  };
  
  // 降低当前模块在缓存中查找的时间复杂度,从O(n)降低为O(1);
  if (id in module) {
    return modules[id]
  }
  return module;
}

为了解决第二个问题我们引入了一个变量用来判断当前模块是否被加载过以及改变使用对象进行缓存降低查找的复杂度

我们从上面这段代码能够分析出来模块对象必须拥有一个属性用来表示是否被加载过以及模块的唯一的标识

// 模块对象
interface Module  {
  loaded: boolean;
  id: string;
}

通过上述手段我们已经成功的解决了模块循环的问题,简单的概括为如果当前模块第一次加载那么就会被缓存,下次读取的时候就会从缓存中进行读取,而不会第二次继续加载当前模块

如何暴露模块和引用模块

其实这也是一个最好解决的问题,就是通过定制一套规范,比如nodejs定义了通过module.export进行暴露,以及require进行引用

// a.js
module.exports = {
  name: 'zgl'
}

const name = require('./a.js');

但是这种书写格式太死板了并不符合node能够灵活书写的方式,因此又想出了一个巧妙的方法

exports.name = 'zgl';
const name = require('./a.js');

上述存在一个问题,由于一开始就定义了require引用的数据是来自module.exports,那么这样是不是违背了初衷,如果exports和module.exports存在不同数据,那么引用的是谁的数据的呢,得想办法解决

module.exports = exports

通过简单的复制就解决了这个问题

那么我们现在还剩一个require没有进行实现了, 接下来需要去实现require, require的逻辑其实很简单

function require(module, exports) {};

我们目前已经实现了模块的导入以及模块导出的逻辑

还有一个很关键的问题如何解决模块与模块之间的隔离问题,答案其实也很简单就是使用一个函数

(function (module,exports, require))(module,exports, require);
总结

至此我们已经完全解析完了commonjs的具体实现,简单回顾一下: 首先确定加载时机和如何暴露模块以及如何引用模块,模块之间的数据隔离

探秘esmodule

我们来思考一个问题,我们能不能直接在浏览器中使用commomjs呢

答案是否定的,理由如下

  • js脚本会阻塞浏览器渲染进程解析html
  • 浏览器提供给js使用的资源比较少
  • js文件不能过大
  • 报错信息需要在编译时确定

我们发现commojs如果需要在浏览器中使用,必须解决的问题

  • 模块需要支持异步加载
  • 需要充分利用模块中导出的数据
  • 模块相关的报错信息不能出现在运行时

我们反过来去思考如果需要对commonjs进行改造的成本是比较大的,但是我们发现要解决的问题如果将编译时机进行提前到编译时是不是也就解决了这些问题,因此决定浏览器的模块加载方式决定采用编译时

如何将代码进行编译

我们可以采用业界比较通用的手段将可执行代码进行编译成抽象语法树

import { parse } from '@babel/parser';
const hasParsers = parse(str, {sourceType: 'module'});

我们上面借助于babel提供的插件进行将可执行代码转为了抽象语法树

模块的加载时机是什么时候

如果我们从一开始通过深度优先遍历的时候就将模块进行缓存其实是不合理的一件事,缓存是需要一定的内存消耗的,但是浏览器提供我们的内存开销非常有限,也就是我们只能选择运行时才去解析模块的内容

import  traverse from '@babel/traverse';
import getPeopleName from './a.js';

const t = traverse.default;

t(hasParsers, {
  enter(path) {
    if (path.isFunctionDeclaration(getPeopleName)) {
      console.log(path.node, '222222');
    }
  }
});

​ 我们上面借助于babel实现了需要时才去解析对应的模块

模块与模块的循环问题

其实由于时运行是才去解析的模块其实产生模块循环的概率其实是微乎其微的,可解决或者不解决,现代模块化规范中是没有进行解决的

如何暴露模块和引用模块

参考现有比较成熟的java语言的模块化导入关键词,采用import进行模块化的引用

参考node的模块化导出模式采用 export default 和export进行模块的导出

总结

esmodule主要采用在代码编译阶段时初始化每个模块之间的依赖关系,在代码执行时再进行依赖的寻找