前端小魔女

V1

2023/04/06阅读:118主题:蔷薇紫

你真的了解ESM吗?

在努力堆数量的同时,要不断在小范围上,去找新的标杆,去找微创新,要不断迭代。不能一直停留在低水平的重复。

大家好,我是柒八九

最近在做基于Vite为模块打包工具的项目优化处理,在通过深入了解后,发现Vite开发环境下,真是提效神器。在基于ESM特性上,Vite抛弃了以往以bundle为主的开发服务器,进而选择了一种nobundle的资源处理模式。让在开发阶段有一个质的飞升。

而就在前天(2023/3/10)字节跳动发布了基于 Rust 的高性能模块打包工具Rspack。它的内核其实就是利用Rust的高性能编译器支持, Rust 编译生成的 Native Code 通常比 JavaScript 性能更为高效。同时得益于 Rust 语言的并行化的良好支持, Rspack 采用了高度并行化的架构,如模块图生成,代码生成等阶段,都是采用多线程并行执行,这使得其编译性能随着 CPU 核心数的增长而增长,充分挖掘 CPU 的多核优势。

如果,硬要从设计角度来看,在开发阶段Rspack还是基于bundle来提供页面内容。只不过是,它采用了性能更好的工具和优化方案。

Vite是在开发阶段真正的利用了浏览器内置的语言特性--ESM来实现极致的开发体验。

而为了能够更好理解Vite如何能够做到在开发模式下,如德芙般丝滑的开发体验。我们今天来讲讲之所以能够让Vite有如此魔力的关键先生 ---JS模块系统。 (这篇文章不会涉及具体API的使用,而是从上帝视角来查看ESM的各种特性)

好了,天不早了,我们开始今天JS模块的探索之旅。

你能所学到的知识点

  1. 模块兼容性 推荐阅读指数⭐️⭐️⭐️
  2. ESM是个啥 推荐阅读指数⭐️⭐️⭐️⭐️
  3. 在浏览器中使用ESM 推荐阅读指数⭐️⭐️⭐️⭐️
  4. 模块指定符 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  5. 默认情况下,模块是defer的 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  6. 动态导入 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  7. import.meta 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  8. 优化建议 推荐阅读指数⭐️⭐️⭐️
  9. 网站中使用ESM 推荐阅读指数⭐️⭐️⭐️

模块兼容性

在接触一个新的技术或者知识点时,首先要做的就是看看ta的兼容性如何?

因为,在如今技术大爆发的时代,技术更迭速度是远远超过浏览器更新速度。

好在,ESM是一个利国利民的技术,市面上主流的JS宿主环境

  • 常规浏览器(chrome 61+/firefox60+/safari11+)
  • Node 13.2.0+

都提供了对ESM的支持。

换句话说,针对新的浏览器环境和Node环境,可以肆无忌惮的使用ESM


ESM是个啥

先认识它,再驾驭它

JS模块(也被称为 ES模块ECMAScript模块)是一个重要的新功能,或者说是一个新功能的集合

模块化的不同规范

了解一件事情最好从它的历史开始

模块模式(IIFE)

let MODULE = (function ({
 let my = {},
    privateVariable = 1;

    function privateMethod({
      // ...
    }

    my.moduleProperty = 1;
    my.moduleMethod = function ({
      // ...
    };

 return my;
}());

模块模式JS语言在早期比较常用的模块化处理方式。模块模式就是利用IIFE实现的


CommonJS - 同步模块加载

Google Chrome 推出 V8 引擎后,基于其高性能和平台独立的特性,Nodejs 这个 JS 运行时也现世了。至此,JS 打破了浏览器的限制,拥有了文件读写的能力Nodejs 不仅在服务器领域占据一席之地,也将前端工程化带进了正轨。

CommonJS 作为非浏览器端JS 规范,从几个方面来描述该规范。

  • 模块定义
    • 一个模块即是一个 JS 文件,代码中module指向当前模块对象
  • 模块引用
    • 通过引用 require() 函数来实现模块的引用
    • 参数可以是相对路径也可以是绝对路径
    • 绝对路径的情况下,会按照 node_modules 规则递归查找,在解析失败的情况下,会抛出异常
  • 模块加载:
    • require() 的执行过程是同步的
    • 执行时即进入到被依赖模块的执行上下文中,执行完毕后再执行依赖模块的后续代码

AMD/CMD - 异步模块加载

为了解决浏览器端 JS 模块化的问题,出现了通过引入相关工具库的方式来解决这一问题。出现了两种应用比较广的规范及其相关库:

  1. AMD(RequireJs)
    • AMD 推崇依赖前置、提前执行
  2. CMD(Sea.js)
    • CMD 推崇依赖就近、延迟执行

Require.js

// 加载完jquery后,将执行结果 $ 作为参数传入了回调函数
define(["jquery"], function (${
    $(document).ready(function(){
        $('#root')[0].innerText = 'Hello World';
    })
    return $
})

Sea.js

// 预加载jquery
define(function(require, exports, module{
    // 执行jquery模块,并得到结果赋值给 $
    var $ = require('jquery');
    // 调用jquery.js模块提供的方法
    $('#header').hide();
});

AMD/CMD 解决问题:

  1. 手动维护代码引用顺序。
    • 从此不再需要手动调整 HTML 文件中的脚本顺序,依赖数组会自动侦测模块间的依赖关系,并自动化的插入页面。
  2. 全局变量污染问题。
    • 将模块内容在函数内实现,利用闭包导出的变量通信,不会存在全局变量污染的问题。

UMD - 兼容 CommonJSAMD

UMD 本质上是兼容 CommonJSAMD 这两种规范的代码语法糖

通过判断执行上下文中是否包含 definemodule 来包装模块代码,适用于需要跨前后端的模块


所有这些模块系统都有一个共同点:允许你导入和导出东西


ESM - JavaScript 官方的模块化

现在,JavaScript 有标准化的语法来实现这一点。

在一个模块中,你可以使用 export 关键字来导出任何东西。

  • 你可以导出一个常量,一个函数,或任何其他变量绑定或声明。
  • 只要在变量语句或声明前加上 export,你就可以了。
// 📁 lib.mjs
export const repeat = (string) => `${string} ${string}`;
export function shout(string{
  return `${string.toUpperCase()}!`;
}

然后你可以使用 import 关键字从另一个模块中导入模块。

在这里,我们从lib模块中导入repeatshout功能,并在mian.mjs中使用它。

// 📁 main.mjs
import {repeat, shout} from './lib.mjs';
repeat('hello');
// → 'hello hello'
shout('ni hao');
// → 'NI HAO'

你也可以从一个模块导出一个默认值

// 📁 lib.mjs
export default function(string{
  return `${string.toUpperCase()}!`;
}

这种默认出口可以使用任何名称导入。

// 📁 main.mjs
import shout from './lib.mjs';
//     ^^^^^

ESM VS 传统脚本

ESM传统脚本有一些不同。

  1. Module默认启用了严格模式
  2. Module中不支持HTML风格的注释语法,但在传统脚本中可以使用。
  3. Module有一个顶层词法作用域
    • 在一个Module中运行var foo = 42;并不会创建一个名为foo的全局变量,不可以通过浏览器中的window.foo来访问,
    • 在一个传统脚本中会出现这种情况。(window.foo是被赋值的)
  4. Module中的this也不会指代全局this,而是undefined的。
    • 如果需要访问全局this,可以使用globalThis
  5. 新的静态importexport语法只在Module中可用
    • 它在传统脚本中不起作用。
  6. 顶层的 awaitModule中可用,但在传统脚本中不行。
    • 与此相关,await 不能在模块中作为变量名使用

由于这些差异,同样的JavaScript代码在被当作Module和传统脚本时可能会有不同的表现。因此,JavaScript运行时需要知道哪些脚本是模块。


ESM VS CommonJS

  1. CommonJS同步加载模块,ES6异步加载模块
    • CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以CommonJS规范比较适用。
    • 浏览器加载 ES6 模块是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本
  2. CommonJS 模块输出的是一个值的拷贝ES6 模块输出的是值的引用
    • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值
    • ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值
  3. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口

在浏览器中使用ESM

在浏览器中,你可以通过设置<script>元素的type=module来将其设置为ESM

<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

能够理解type="module"的浏览器会忽略带有nomodule属性的脚本。这意味着你可以向支持ESM的浏览器提供基于ESM的有效载荷,同时向其他不支持该特性的浏览器通过设置nomodule来提供回退版本的脚本信息。

单纯的从性能角度出发,该特性是具有特殊的含义的。 试想一下:如果一个浏览器能理解你的模块代码,它也会支持模块之前的功能,比如箭头函数async-await。你不必再在你的模块包中转译这些功能了!

  • 你可以向现代浏览器提供更小的、基本没有转译的基于模块的有效载荷。
  • 只有低版本的浏览器才会得到nomodule的有效载荷。

由于模块默认是defer的,你也应该以延时的方式加载nomodule脚本,以便为那些低版本浏览器提供对应脚本信息。

<script type="module" src="main.mjs"></script>
<script nomodule defer src="fallback.js"></script>

浏览器环境下,模块和传统脚本之间差异

正如上文介绍的,模块与传统脚本不同。除了上面概述的与平台无关的差异之外,还有一些在浏览器环境下的差异。

ESM只被执行一次,而传统脚本被引用了几次就会被执行几次

<script src="classic.js"></script>
<script src="classic.js"></script>
<!-- classic.js 被执行多次. -->

<script type="module" src="module.mjs"></script>
<script type="module" src="module.mjs"></script>
<script type="module">import './module.mjs';</script>
<!-- module.mjs 只执行一次. -->

另外,模块脚本和它们的依赖关系是用CORS获取的。这意味着,任何跨源模块的脚本都必须使用适当的头信息,如Access-Control-Allow-Origin:*. 这对传统脚本是不需要的。

另一个区别与async属性有关,它使脚本在下载时不会阻塞HTML解析器(像defer一样),但它也会尽快执行脚本,不保证顺序,也不等待HTML解析的完成。async属性对内联的传统脚本不起作用,但它对内联的<script type="module">起作用


关于文件扩展名的说明

在上述的示例中,ESM中使用了.mjs文件扩展名。在网页上,文件扩展名其实并不重要,只要JS文件设置了正确的MIME类型(text/javascript)。根据<script/>元素上的type属性,浏览器能识别出它是一个模块文件。

尽管如此,我们还是建议对模块使用.mjs扩展名,原因有二。

  1. 在开发过程中,.mjs这个扩展名可以开发人员清楚地知道这个文件是一个模块,而不是一个传统脚本。
  2. 它可以确保你的文件被Node.jsd8等运行系统以及Babel等构建工具解析为模块。
    • 虽然这些环境和工具都有通过配置将其他扩展名的文件解释为模块的专有方式,但.mjs扩展名是确保文件被视为模块的交叉兼容的方式。

模块指定符

当导入模块时,指定模块位置的字符串被称为 模块指定符导入指定符

在前面的例子中,模块指定符"./lib.mjs"

import {shout} from './lib.mjs';
//                  ^^^^^^^^^^^

针对浏览器的模块解析器,目前不支持所谓的裸模块指定器

这点和vite/webpack/Rspack等常规的打包工具不一样,因为,他们在进行资源解析和打包的过程中,会对裸模块进行替换处理。

// 暂未支持
import {shout} from 'jquery';
import {shout} from 'lib.mjs';
import {shout} from 'modules/lib.mjs';

另一方面,下面的例子都是支持的。

// 支持:
import {shout} from './lib.mjs';
import {shout} from '../lib.mjs';
import {shout} from '/modules/lib.mjs';
import {shout} from 'https://simple.example/modules/lib.mjs';

目前,模块指定符必须是

  1. 完整的URL
  2. 或以/./../开头的相对URL

默认情况下,模块是defer的

传统<script>默认会阻止HTML解析器。我们可以通过添加defer属性来解决这个问题,它可以确保脚本下载与HTML解析并行进行

ESM 默认是延迟的

  • 因此,没必要在<script type="module">标签中加入defer!
  • 不仅是主模块的下载与HTML解析平行进行,所有的依赖模块也是如此

动态导入

到目前为止,我们只使用了静态导入

使用静态导入,整个模块图需要在主代码运行之前被下载和执行

有时,我们想在我们需要模块的时候,才去加载对应的模块信息。也就是我们常说的按需加载。例如,当用户点击一个链接或一个按钮时,才去加载指定的模块资源。

import()使之变的唾手可得

<script type="module">
  (async () => {
    const moduleSpecifier = './lib.mjs';
    const {repeat, shout} = await import(moduleSpecifier);
    repeat('hello');
    // → 'hello hello'
    shout('ni hao');
    // → 'NI HAO'
  })();
</script>

与静态导入不同,import()可以在常规脚本中使用。

<!DOCTYPE html>
<meta charset="utf-8">
<title>我的书房</title>
<nav>
  <a href="books.html" data-entry-module="books">我的书籍</a>
  <a href="movies.html" data-entry-module="movies">喜欢的电影</a>
  <a href="video-games.html" data-entry-module="video-games">我的视频</a>
</nav>
<main>此处的内容,会根据link标签的点击而变化</main>
<script>
  const main = document.querySelector('main');
  const links = document.querySelectorAll('nav > a');
  for (const link of links) {
    link.addEventListener('click'async (event) => {
      event.preventDefault();
      try {
        const module = await import(`/${link.dataset.entryModule}.mjs`);
        // The module exports a function named `loadPageInto`.
        module.loadPageInto(main);
      } catch (error) {
        main.textContent = error.message;
      }
    });
  }
</script>

上面的这个示例中,简单的展示了,import()如何在常规脚本中使用。通过对<a>标签设置click监听事件,在点击不同的标签,然后通过import()获取到对应链接想展示的内容信息,并插入到<main>中。从而实现,按需加载资源。


import.meta

ES2020import 命令添加了一个元属性import.meta,返回当前模块的元信息。

import.meta只能在模块内部使用,如果在模块外部使用会报错。

这个属性返回一个对象,该对象的各种属性就是当前运行的脚本的元信息。具体包含哪些属性,标准没有规定,由各个运行环境自行决定。一般来说,import.meta至少会有下面两个属性。

(1)import.meta.url

import.meta.url返回当前模块的 URL 路径。

举例来说,当前模块主文件的路径是https://foo.com/main.jsimport.meta.url就返回这个路径。如果模块里面还有一个数据文件data.txt,那么就可以用下面的代码,获取这个数据文件的路径。

new URL('data.txt'import.meta.url)

import.meta.url使得相对于当前模块加载图像成为可能。

function loadThumbnail(relativePath{
  const url = new URL(relativePath, import.meta.url);
  const image = new Image();
  image.src = url;
  return image;
}

const thumbnail = loadThumbnail('../img/thumbnail.png');
container.append(thumbnail);

(2)import.meta.scriptElement

import.meta.scriptElement是浏览器特有的元属性,返回加载模块的那个<script>元素,相当于document.currentScript属性。

// HTML 代码为
// <script type="module" src="my-module.js" data-foo="abc"></script>

// my-module.js 内部执行下面的代码
import.meta.scriptElement.dataset.foo
// "abc"

优化建议

继续打包处理

有了ESM,无需使用webpackRollupParcel等打包工具就可以开发网站。

在以下情况下,直接使用ESM就可以了。

  • 本地开发 (vite在开发环境就是利用这个特性)
  • 在生产过程中,对于总模块数少于100个且依赖树相对较浅(即最大深度小于5)的小型网络应用程序。

在Chrome浏览器加载由约300个模块组成的模块化库时的瓶颈分析中了解到的那样,打包后应用的加载性能要好于未打包应用

其中一个原因是,静态导入/导出语法是可以静态分析的,因此它可以帮助打包工具通过消除不使用的导出来优化代码。静态导入和导出不仅仅是语法,它们是一个重要的工具特性

我们的一般建议是,在将模块部署到生产中之前仍然对其进行打包处理。在某种程度上,打包处理是一种类似于最小化你的代码:它导致了性能上的好处,因为你最终产生的代码更少。


使用细粒度的模块

养成使用小的、细粒度的模块编写代码的习惯。在开发过程中,一般来说,每个模块只有几个出口,比手动将许多出口合并到一个文件中要好。

考虑一个名为./util.mjs的模块,它导出了三个名为droppluckzip的函数。

export function drop(/* … */ }
export function pluck(/* … */ }
export function zip(/* … */ }

如果你的代码库只真正需要pluck功能,你可能会按如下方式导入它。

import {pluck} from './util.mjs';

在这种情况下,(如果没有构建时的打包步骤)浏览器最终仍然不得不下载、解析和编译整个./util.mjs模块,尽管它真正需要的只是一个出口。

如果pluck不与dropzip共享任何代码,最好把它移到自己的细粒度模块,例如./pluck.mjs

export function pluck(/* … */ }

然后,我们可以导入pluck,而不需要处理dropzip的开销。

import {pluck} from './pluck.mjs';

这不仅可以使你的源代码简单明了,还可以减少打包程序对dead-code消除的需要。如果你的源码树中的某个模块没有被使用,那么它就不会被导入,因此浏览器也不会下载它。那些被使用的模块可以被浏览器单独进行代码缓存。

使用小的、细粒度的模块有助于为你的代码库做好准备,以应对未来可能出现的本地捆绑解决方案。


预加载模块

你可以通过使用<link rel="modulepreload">来进一步优化你的模块的交付。这样一来,浏览器就可以预加载,甚至准备和预编译模块及其依赖关系。

<link rel="modulepreload" href="lib.mjs">
<link rel="modulepreload" href="main.mjs">
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

这对较大的依赖关系树来说尤其重要。如果没有rel="modulepreload",浏览器需要执行多个HTTP请求来找出完整的依赖关系树。然而,如果你用rel="modulepreload"声明了依赖模块脚本的完整列表,浏览器就不必逐步发现这些依赖关系


网站中使用ESM

ESM正在慢慢地在网站开发上获得实战。我们的使用计数器显示,目前所有的页面加载中有8%使用<script type="module">


后记

分享是一种态度

参考资料

  1. Rspack
  2. vite
  3. modules
  4. import-meta

全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。

分类:

前端

标签:

JavaScript

作者介绍

前端小魔女
V1

微信公众号:前端柒八九