2025年4月5日 星期六 乙巳(蛇)年 正月初六 设为首页 加入收藏
rss
您当前的位置:首页 > 计算机 > 编程开发 > JavaScript

在浏览器中使用原生 JavaScript 模块

时间:12-14来源:作者:点击数:12

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

什么是 JavaScript 模块

JS modules 实际上是一系列功能的集合。之前你可能听过说 Common JS ,AMD 等模块标准,不同标准的模块功能都是类似的,都允许你 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在浏览器中访问该变量。
  • import 和 export 关键字只在模块中有效。

由于存在上述不同,通过传统方式引入的脚本 和 以模块方式引入的脚本,就会有相同的代码,也会产生不同的行为,因而 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>
  • <!-- 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 渲染同步进行。模块脚本默认为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 的加载性能比未打包的好得多。

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

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

谷歌开发者工具中的 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 获取响应中。

现存的诸如 webpackRollup,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。

方便获取更多学习、工作、生活信息请关注本站微信公众号城东书院 微信服务号城东书院 微信订阅号
推荐内容
相关内容
栏目更新
栏目热门
本栏推荐