14
上周在四个不同的地方看到了推荐Using JavaScript modules on the web 这篇文章,之前一直没有去了解过原生模块在web浏览器中该如何使用,周末把这篇文章大致翻译了一下。

JS 模块 目前已得到所有主流浏览器的支持,本文将讲述什么是 JS 模块,如何使用 JS 模块,以及 Chrome 团队未来计划如何优化 JS 模块。

什么是 JavaScript 模块

JS modules 实际上是一系列功能的集合。之前你可能听过说 Common JSAMD 等模块标准,不同标准的模块功能都是类似的,都允许你 import 或者 export 一些东西。

JavaScript 模块目前有标准的语法,在模块中,你可以通过 export 关键字,导出一切东西(变量,函数,其它声明等等)

// 📁 lib.mjs
export const repeat = (string) => `${string} ${string}`;
export function shout(string) {
  return `${string.toUpperCase()}!`;
}

而想要导入该模块,只需要在其它文件中使用import 关键字引入即可

// 📁 main.mjs
import {repeat, shout} from './lib.mjs';
repeat('hello');
// → 'hello hello'
shout('Modules in action');
// → 'MODULES IN ACTION!'

模块中还可以导出默认值

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

具有默认值的模块可以以任意名字导入到其它模块中

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

模块和传统的script 标签引入脚本有一些区别,如下:

  • JS模块默认使用严格模式
  • 模块中不支持使用 html 格式的注释,即<!-- TODO: Rename x to y. -->
  • 模块会创建自己的顶级词义上下文,这意味着,当在模块中使用var foo = 42; 语句时,并不会创建一个全局变量foo, 因此也不能通过window.foo在浏览器中访问该变量。
  • importexport 关键字只在模块中有效。

由于存在上述不同,通过传统方式引入的脚本 和 以模块方式引入的脚本,就会有相同的代码,也会产生不同的行为,因而 JS 执行环节需要知道那些脚本是模块。

在 浏览器中使用模块

在 浏览器中,通过设置 <script> 元素的type 属性为 module 可以声明其实一个模块。

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

支持type="module"的浏览器会忽略带有nomudule属性的的<script>元素,这样就提供了降级处理的空间。其意义不仅如此,支持type="module"的环境意味着其也支持箭头函数,async-await等新语法功能,这样引入的脚本无须再做转义处理了。

浏览器会区别对待 JS模块 和传统方式引入的脚本

如果模块引入了多次,浏览器只会执行一次相同模块中的代码,而对传统的方式引入的脚本引入了多少次,浏览器就会执行多少次。

<script src="classic.js"></script>
<script src="classic.js"></script>
<!-- classic.js executes multiple times. -->

<script type="module" src="module.mjs"></script>
<script type="module" src="module.mjs"></script>
<script type="module">import './module.mjs';</script>
<!-- module.mjs executes only once. -->

此外,JS 模块对于的脚本存在跨域限制,传统的脚本引入则不存在。

对于async属性,浏览器对二者也会区别对待,async属性被用来告知浏览器下载脚本但不要阻塞 HTML 渲染,并且希望一旦下载完成,就立即执行,不用考虑顺序,不用考虑HTML渲染是否完成,async 属性在传统的行内<script>元素引入时是无效,但是在行内<script type="module">却是有效的。

关于扩展名的说明

上文中,我们一直在使用.mjs作为模块的拓展名,实际上,在web 上,拓展名本身并不重要,重要的是该文件的MIME type 需要设置为 text/javascript ,浏览器仅通过<script>元素上的type属性来识别其是否是一个模块。

不过我们还是推荐使用.mjs拓展名 ,有如下两个原因:

  1. 开发阶段,这个拓展名可以充分说明它是一个模块,毕竟模块和普通的脚本还是有区别的。
  2. .mjs和node兼容;

Module specifiers

当引入模块时,指明模块位置的部分被称为 Module specifiers,也叫做 import specifier 。

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

浏览器对模块的引入有一些严格的限制,裸模块目前是不支持的,这样是为了在将来为裸模块添加特定的意义,如下面这些做法是不行的:

// Not supported (yet):
import {shout} from 'jquery';
import {shout} from 'lib.mjs';
import {shout} from 'modules/lib.mjs';

下面这些的用法则都是支持的

// Supported:
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';

总的来说,目前模块引入路径要求必须是完整的URLs,或者是以/,./,../开头的相对URLs。

模块默认会deferred

传统的<script> 的下载默认会阻塞 HTML 渲染。不过可以通过添加defer属性,使得其下载与 HTML 渲染同步进行。
下图说明了不同的属性,脚本下载与执行对 HTML 渲染的影响

image.png

模块脚本默认为defer , 其依赖的所有其它模块也会以 defer 模式加载。

其它的模块特性

动态 import()

前面我们一直在使用静态import, 静态import 意味着所有的模块需要在主代码执行前下载完,有时候有些模块并不需要你提前加载,更合适的方案是按需加载,比如说用户点击了某个按钮的时候再加载。这样做能有效提升初始页面加载效率,Dynamic import()就是用来满足这种需求的。

<script type="module">
  (async () => {
    const moduleSpecifier = './lib.mjs';
    const {repeat, shout} = await import(moduleSpecifier);
    repeat('hello');
    // → 'hello hello'
    shout('Dynamic import in action');
    // → 'DYNAMIC IMPORT IN ACTION!'
  })();
</script>

不像静态import(), 动态import() 可以还在常规的脚本中使用,更多细节可以参考Dynamic import()

注:这和 webpack 提供的动态加载有所不同,webpack 有其独特的做法进行代码分割以满足按需加载。

import.meta

import.meta是模块相关的另一个特性,此特性包含关于当前模块的metadata,准确的metadata 并未定义为 ECMAScript 标准的一部分。import.meta的值其实依赖于宿主环境,在浏览器和 NodeJS 中可能就会得到不同的值。

以下是一个import.meta的使用示例,默认情况下,图片是基于当前 HTML 的 URL 的相对地址,import.meta.url使得基于当前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);

性能优化建议

还是需要打包的

使用模块,使得不使用诸如 webpack , Roolup 或者 Parcel 之类的构建工具成为可能。在以下情况下直接使用原生的 JS module 是可行的:

  • 在本地开发环境中
  • 小型项目(所依赖模块不超过100个,依赖树浅,比如依赖层级不超过5层)

参考Chrome 加载瓶颈一文,当加载模块数量为300个时,打包过的 app 的加载性能比未打包的好得多。

image.png

产生这种现象的原因在于,静态的import/export 会执行静态分析,用以帮助打包工具去除未使用的exports以优化代码,可见静态的importexport 不仅仅是起到语法作用,它们还起到工具的作用。

我们推荐在部署代码到生产环境之前继续使用构建工具,构建工具也会通过优化来减少你的代码,并由此带来运行性能的提升。

谷歌开发者工具中的 Code Coverage 功能可以帮你识别,那些是不必要的代码,我们推荐使用代码分割延迟加载非首屏需要的代码。

对使用打包文件和使用未打包的模块的权衡

在 web 上,很多事情都需要权衡,加载未打包的组件可能会降低初次加载的效率(cold cache),但是比起没有代码分割的打包,可以明显提高二次访问(warm cache)时的性能。比如说大小为 200kb 的代码,如果后期又改变了一个细粒度的模块,二次访问时,未打包的代码的性能会比打包的好得多。

这是矛盾所在,如果你不知道 二次访问的体验 和 首次加载的性能那个更重要,可以AB测试一下,用数据来看那种效果更好。

浏览器工程师们正在努力改进模块的性能。希望在不久的将来,未打包的模块可以在更多的场景中使用。

使用细粒度的模块

我们应该养成使用细粒度模块的习惯。在开发过程中,通常来说,一个文件只有少数几个export比包含大量export的要好。

比如说在./utils.mjs模块中,export了三个方法,drop,pluck,zip:

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

如果你的函数只需要pluck方法,你会以下面的方法引入:

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

这种情况下,如果没有不通过构建过程,浏览器依旧会下载并解析整个./utils.mjs文件,这样明显有些浪费。

如果pluck()zip(),drop()没有什么共用的代码,更好的实现是将其移动到自己独立的细粒度模块中:

export function pluck() { /* … */ }

这样再导入 pluck 时就无需解析没有用到的模块了。

这样做不仅保持了你的源码的简洁干净,同时还能减轻了构建工具的压力,如果你的源代码中某个模块从未被import过,浏览器就永远不会下载它,而那些用到了的模块则会被浏览器缓存。

使用细粒度的模块,也使得在将来原生的打包方案到来时,你现有的代码能更好的进行适配。

预加载模块

你可以通过使用<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>

这在处理依赖复杂的app时效果尤为明显,如果不使用rel="modulepreload",浏览器需要执行多个 HTTP 请求来获得完成的依赖,如果你使用上述方法指明了依赖,浏览器则不需要渐进的来查找相关依赖。

使用HTTP/2

如果可能,尽量使用HTTP/2 ,这对性能的提升也是显而易见的, multiplexing support

允许多请求和多响应可以同时进行,如果模块数量很大,这一点尤为有用。

Chrome 团队还调查过 HTTP/2 的另一个特性,server push 能不能也成为开发高模块化app的解决方案,但是不幸的是,HTTP/2 push is tougher than I thought - JakeArchibald.com,web 服务器和浏览器的实现目前还没有针对高模块化的 web 应用程序用例进行优化, 因此很难实现推送用户没有缓存的内容,而如果要对比整个cache,对用户来说存在隐私风险。

不过,不管怎么样,用 HTTP/2 还是很有好处的,不过 HTTP/2 server push 还不是一个有效的方案.

web 上目前JS 模块的使用情况

JS 模块在逐步被 web 采用,据 usage counters 统计,大概有0.08%的网页目前在使用<script type="module">, 不过需要注意,这类数据中包括动态import()worklets 相关的数据。

JS modules 未来会如何发展

Chrome 团队致力于改进开发阶段使用 JS modules 的体验,以下是一些方向:

更快更准确的模块解析算法

谷歌提出了一种更快更准确的模块解析算法,目前这种算法已经存在于 HTML 规范ECMA 规范中,该算法在Chrome63 中已经开始使用,可以预见在不久的将来将会应用于更多的浏览器中。

旧算法的时间复杂度为O(n²),而新算法则为O(n)

新算法还可以针对错误给出更有效的提示,相比较而言,旧算法对错误的处理就没那么有效。

Worklets 和 workers

Chrome 现在可以执行 worklets 了,worklets 允许 web 开发者在web浏览器的底层执行复杂的逻辑运算,通过 worklets ,web 开发人员可以将 JS 模块提供给渲染 pipeline 或音频处理pipeline 使用,未来会有更多的pipeline 支持。

Chrome 65 支持 PaintWorklet (CSS 渲染API)来控制如何渲染一个DOM。

const result = await css.paintWorklet.addModule('paint-worklet.mjs');

Chrome66 支持 AudioWorklet 允许你在代码中控制音频的处理,该版本还开始试验支持 AnimationWorklet,它允许创建滚动链接和其他高性能的过程动画。

layoutWorklet,(CSS 布局 API) 已经开始在Chrome 67 中试用。

Chrome 团队 还在努力 在 Chrome 中增加支持使用 JS 模块的 web worker 。可以通过 chrome://flags/#enable-experimental-web-platform-features 来启用这一功能。

const worker = new Worker('worker.mjs', { type: 'module' });

支持共享worker 和 服务worker 的 JS 模块也即将到来:

const worker = new SharedWorker('worker.mjs', { type: 'module' });
const registration = await navigator.serviceWorker.register('worker.mjs', { type: 'module' });

Package name maps

在 NodeJS/npm 中,直接使用包名字来引用模块是很常见的,如:

import moment from 'moment';
import { pluck } from 'lodash-es';

但是目前依据 HTML 标准,此类裸引用会抛出错误,Package name maps 提议则允许在 web 和生产环境的 app 上支持此类用法,一个 package name map 实际上是一个帮助浏览器转换 specifiers 为完整 URLs 的JSON。

package name map 还处于提议阶段,尽管Chrome 团队已经提出了多种使用示例, 但是目前还处于和社区的沟通中, 目前也还没有成文的规范。

Web package:原生打包

Chrome loading 团队,目前正在探索一种原生的 web 构建模式来分发 web app。web packaging 的关键点在于:
Signed HTTP Exchanges 允许浏览器信任单个 HTTP 请求/响应对由它声称的来源生成;
Bundled HTTP Exchanges, 一系列交换的集合,可以是签名的或无签名的, 其中包含一些元数据来描述了如何将包解释为一个整体。

有上述作为基础, web 打包就可以把多个相同来源的资源安全地嵌入到单个 HTTP 获取响应中.

现存的诸如 webpack, Rollup,Parcel 等打包工具目前都将文件打包为一个单一的 JS 文件,这会导致原始模块语义的丢失,而通过原生的打包,浏览器可以解压打包资源为原始的状态。这就保持了单个资源的独立性。原生打包由此可以改进调试的体验,当在devtools 中查看资源时,浏览器可以指明原始的模块,而不再需要使用复杂的 source-map 了。

原生打包还提供了其它优化的可能,比如说,如果浏览器已经缓存了部分内容在本地,浏览器可以只在服务器下载缺失的部分。

Chrome 已经支持这个提议的一部分(SignedExchange),不过原生打包本身即其在高模块化app中的应用还处于探索阶段。

Layers APIs

每个新功能都可能会污染浏览器命名空间, 增加启动成本, 在整个代码库中引入 bug。Layers APIs 是在将更高层次的 api 与 web 浏览器结合在一起所做的努力。JS 模块是分层 api 的关键依赖技术:

  • 由于模块是显式导入的, 因此需要通过模块公开分层 api, 以确保开发人员只用管他们使用的Layers APIs;
  • 模块加载是可配置的, 因此Layers APIs 也可以有一个内置机制, 用于在不支持Layers APIs 的浏览器中自动加载 polyfills。

模块与 Layers APIs 该如何协同使用目前还没有定论,目前的提议用法如下:

<script
  type="module"
  src="std:virtual-scroller|https://example.com/virtual-scroller.mjs"
></script>

浏览器按照上述方法在<script>标准中加载 Layers APIs。


zhangwang
8k 声望1.8k 粉丝

前端,摄影,阅读,好奇