什么是JS module
JS module(也称为“ES module”或“ECMAScript module”)是一个主要的新功能,或者更确切地说是新功能的集合。您过去可能使用过userland JavaScript模块系统。也许你在Node.js中使用了CommonJS,也许是AMD,或者其他什么。所有这些模块系统都有一个共同点:它们允许您导入和导出内容。
JavaScript现在已经有了标准化的语法。在模块中,您可以使用export关键字导出几乎所有内容。您可以导出常量、函数或任何其他变量绑定或声明。只需在变量语句或声明前面加上export,就可以完成以下操作:
// 📁 lib.mjs
export const repeat = (string) => `${string} ${string}`;
export function shout(string) {
return `${string.toUpperCase()}!`;
}
然后,您可以使用import关键字从另一个模块导入模块。在这里,我们从lib模块导入repeat和should功能,并在我们的主模块中使用它:
// 📁 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';
//
Modules are a little different from classic scripts:
- Modules have strict mode enabled by default.
- HTML-style comment syntax is not supported in modules, although it works in classic scripts.
模块与经典脚本(script)有点不同:
1、模块默认启用严格模式。
2、HTML风格的注释语法在模块中不受支持,尽管它在经典脚本中有效。
// Don’t use HTML-style comment syntax in JavaScript!
const x = 42; <!-- TODO: Rename x to y.
// Use a regular single-line comment instead:
const x = 42; // TODO: Rename x to y.
1、模块具有词法顶层作用域。这意味着,例如,运行var foo=42;在一个模块中不会创建一个名为foo的全局变量,该变量可以通过浏览器中的window.foo访问,尽管在经典脚本中会出现这种情况。
类似地,模块中的this并不是指全局this,而是未定义的。(如果您需要访问全局this,请使用globalThis。)
2、新的静态导入和导出语法仅在模块中可用,在经典脚本中不起作用。
3、顶级await在模块中可用,但在经典脚本中不可用。与此相关的是,await不能用作模块中任何位置的变量名,尽管经典脚本中的变量可以在异步函数之外命名为await。
由于这些差异,当将相同的JavaScript代码作为模块与经典脚本处理时,其行为可能会有所不同。因此,JavaScript运行时需要知道哪些脚本是模块。
在浏览器中使用JS modules
在web上,您可以通过将type属性设置为module来告诉浏览器将<script>元素视为模块。
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>
理解type=“module”的浏览器会忽略具有nomodule属性的脚本。这意味着您可以向支持模块的浏览器提供基于模块的有效负载,同时向其他浏览器提供回退。做出这种区分的能力是惊人的,哪怕只是为了表现!想想看:只有现代浏览器才支持模块。如果浏览器理解您的模块代码,它还支持模块之前的功能(codepen.io/samthor/pen… ,如箭头函数或异步等待。您不再需要在模块捆绑包中转换这些功能!您可以为现代浏览器提供更小且大部分未经传输的基于模块的有效载荷。只有传统浏览器才能获得nomodule负载。
由于模块在默认情况下是延迟的,因此您可能也希望以延迟的方式加载nomodule脚本
<script type="module" src="main.mjs"></script>
<script nomodule defer src="fallback.js"></script>
模块和经典脚本之间特定于浏览器的差异
正如您现在所知,模块与经典脚本不同。除了我们上面概述的与平台无关的差异之外,还有一些特定于浏览器的差异。
例如,模块只被加载一次,而经典脚本无论您将它们添加到DOM中多少次都会被加载。
<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. -->
此外,模块脚本及其依赖关系是通过CORS获取的。这意味着任何跨源模块脚本都必须具有适当的头,例如Access Control Allow origin:*。对于经典脚本来说,情况并非如此。
另一个区别与async属性有关,它可以在不阻塞HTML解析器的情况下下载脚本(如defer),只是它也可以尽快执行脚本,没有保证顺序,也不用等待HTML解析完成。async属性不适用于内联经典脚本,但适用于内联
关于文件扩展名的说明
您可能已经注意到,我们正在为模块使用.mjs文件扩展名。在Web上,文件扩展名其实并不重要,只要文件使用JavaScriptMIME类型text/JavaScript即可。由于script元素上的type属性,浏览器知道它是一个模块。
尽管如此,我们还是建议对模块使用.mjs扩展,原因有两个:
1、在开发过程中,.mjs扩展让您和其他查看您的项目的人清楚地看到,该文件是一个模块,而不是经典脚本。(光看代码并不总是可以判断的。)如前所述,模块与经典脚本的处理方式不同,因此差异非常重要!(Node.js 会将.cjs文件视为 CommonJS 模块,将.mjs文件视为 ECMAScript 模块)
2、它确保您的文件被Node.js和d8等运行时以及Babel等构建工具解析为模块。虽然这些环境和工具都有通过配置将具有其他扩展名的文件解释为模块的专有方式,但.mjs扩展名是确保文件被视为模块的交叉兼容方式。
注意:要在web上部署.mjs,您的web服务器需要配置为使用适当的Content-Type:text/javascript头提供具有此扩展名的文件,如上所述。
此外,您可能需要将编辑器配置为将.mjs文件视为.js文件,以获得语法高亮显示。大多数现代编辑已经默认这样做了。
模块说明符
导入模块时,指定模块位置的字符串称为“模块说明符”或“导入说明符”。在我们前面的示例中,模块说明符是“”/lib.mjs’:
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';
目前,模块说明符必须是完整的URL,或以/、./开头的相对URL,或../。
Modules are deferred by default
默认情况下,经典<script>s会阻止HTML解析器。您可以通过添加defer属性来解决这个问题,这可以确保脚本下载与HTML解析并行进行。
默认情况下,模块脚本是延迟的。因此,不需要在
其他模块功能
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>
与静态导入不同,动态导入()可以在常规脚本中使用。这是一种简单的方法,可以逐步开始使用现有代码库中的模块。有关更多详细信息,请参阅我们关于动态导入()的文章。(web.dev/use-long-te…
import.meta
另一个与模块相关的新功能是import.meta,它为您提供有关当前模块的元数据。您获得的确切元数据并没有被指定为ECMAScript的一部分;这取决于主机环境。例如,在浏览器中,您可能会获得与Node.js不同的元数据。
以下是网络上import.meta的一个例子。默认情况下,图像是相对于HTML文档中的当前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);
性能建议#
继续捆绑
有了模块,就可以在不使用webpack、Rollup或Parcel等捆绑器的情况下开发网站。在以下场景中直接使用本机JS模块是可以的
- 本地开发期间
- 针对总共少于100个模块且依赖树相对较浅(即最大深度小于5)的小型web应用程序的生产
然而,正如我们在对Chrome加载管道进行瓶颈分析时所了解到的,当加载由大约300个模块组成的模块化库时,绑定应用程序的加载性能优于未绑定应用程序。
其中一个原因是静态导入/导出语法是可静态分析的,因此它可以通过消除未使用的导出来帮助bundler工具优化代码。静态导入和导出不仅仅是语法;它们是一个关键的工具特性!
我们的一般建议是在将模块部署到生产之前继续使用捆绑器。从某种程度上说,捆绑是一种类似于缩小代码的优化:它带来了性能优势,因为您最终交付的代码更少。捆绑也有同样的效果!继续捆扎。
和往常一样,DevTools的代码覆盖功能可以帮助您识别是否向用户推送了不必要的代码。我们还建议使用代码拆分来拆分捆绑包,并推迟加载非First Meaningful Paint关键脚本。
捆绑模块与运输未捆绑模块的权衡 #
在web开发中,一切都是一种权衡。运送未捆绑的模块可能会降低初始加载性能(冷缓存),但与运送没有代码拆分的单个捆绑相比,实际上可以提高后续访问的加载性能(热缓存)。对于200 KB的代码库,更改单个细粒度模块并将其作为从服务器进行后续访问的唯一获取,比重新获取整个捆绑包要好得多。
如果您更关心具有热缓存的访问者的体验,而不是首次访问性能,并且站点的细粒度模块少于几百个,那么您可以尝试运送未捆绑的模块,测量冷负载和热负载对性能的影响,然后做出数据驱动的决策!
浏览器工程师正在努力提高开箱即用模块的性能。随着时间的推移,我们预计在更多情况下运输未捆绑的模块将变得可行。
使用细粒度模块
养成使用小的、细粒度的模块编写代码的习惯。在开发过程中,通常每个模块只有几个导出比手动将大量导出组合到一个文件中要好。
考虑一个名为的模块/util.mjs,它导出三个名为drop、pull和zip的函数:
export function drop() { /* … */ }
export function pluck() { /* … */ }
export function zip() { /* … */ }
如果你的代码库真的只需要提取功能,你可能会按照如下方式导入它:
import {pluck} from './util.mjs';
在这种情况下,(没有构建时绑定步骤)浏览器最终仍然需要下载、解析和编译整个内容/util.mjs模块,尽管它实际上只需要一个导出。太浪费了!
如果pull不与drop和zip共享任何代码,最好将其移动到自己的细粒度模块,例如./pull.js。
export function pluck() { /* … */ }
然后,我们可以导入pluck,而无需处理drop的开销:
import {pluck} from './pluck.mjs';
注意:根据您的个人偏好,您可以在此处使用默认导出而不是命名导出。
这不仅使您的源代码保持美观和简单,还减少了捆绑器执行的死代码消除的需要。如果源代码树中的某个模块未使用,则它永远不会被导入,因此浏览器永远不会下载它。使用的模块可以由浏览器单独缓存代码。(实现这一目标的基础设施已经在V8中实现,并且正在Chrome中实现这一功能。)
使用小的、细粒度的模块有助于为将来可能提供本机捆绑解决方案的代码库做好准备。
预加载模块
您可以使用[<link rel=“module preload”>]进一步优化模块的交付(developers.google.com/web/updates…). 通过这种方式,浏览器可以预加载甚至准备和预编译模块及其依赖项。
<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=“modulepoload”,浏览器需要执行多个HTTP请求才能找到完整的依赖树。然而,如果您使用rel=“modulepoload”声明依赖模块脚本的完整列表,则浏览器不必逐步发现这些依赖关系。
使用HTTP/2
如果只是为了支持多路复用,那么在可能的情况下使用HTTP/2总是很好的性能建议。通过HTTP/2多路复用,可以同时传输多个请求和响应消息,这有利于加载模块树。
Chrome团队调查了另一个HTTP/2功能,特别是HTTP/2服务器推送,是否可以成为部署高度模块化应用程序的实用解决方案。不幸的是,HTTP/2服务器推送很难做到正确,而且web服务器和浏览器的实现目前还没有针对高度模块化的web应用程序用例进行优化。例如,很难只推送用户尚未缓存的资源,而通过将源的整个缓存状态传达给服务器来解决这一问题是一种隐私风险。
所以,无论如何,继续使用HTTP/2!请记住,HTTP/2服务器推送(不幸的是)并不是灵丹妙药。
JS模块的Web采用
JS模块在网络上慢慢被采用。我们的使用计数器显示,目前0.08%的页面加载使用
JS模块的下一步是什么?
Chrome团队正在以各种方式改进JS模块的开发时间体验。让我们来讨论其中的一些。
更快且具有确定性的模块解析算法
我们提出了对模块解析算法的更改,以解决速度和确定性方面的不足。新算法现在同时存在于HTML规范和ECMAScript规范中,并在Chrome 63中实现。期待这一改进很快登陆更多浏览器!
新算法效率更高,速度更快。旧算法的计算复杂度是二次型的,即。𝒪(n²),依赖关系图的大小,Chrome当时的实现也是如此。新算法是线性的,即。𝒪(n) 。
此外,新算法以确定性的方式报告分辨率错误。给定一个包含多个错误的图,旧算法的不同运行可能会报告不同的错误,作为解析失败的原因。这使得调试变得不必要地困难。新算法保证每次都报告相同的错误。
Worklets和web工作者
Chrome现在实现了worklets,它允许web开发人员在web浏览器的“低级部分”中自定义硬编码逻辑。有了worklets,web开发人员可以将JS模块输入到渲染管道或音频处理管道(未来可能还会有更多管道!)。
Chrome 65支持PaintWorklet(也称为CSS Paint API)来控制如何绘制DOM元素。
const result = await css.paintWorklet.addModule('paint-worklet.mjs');
Chrome 66支持AudioWorklet,它允许您使用自己的代码控制音频处理。同一个Chrome版本启动了AnimationWorklet的OriginTrial,它可以创建滚动链接和其他高性能的程序动画。
最后,LayoutWorklet(也称为CSS布局API)现在在Chrome 67中实现。
我们正在努力增加对在Chrome中使用JS模块和专用网络工作者的支持。您已经可以使用尝试此功能chrome://flags/#enable-启用了实验性web平台功能。
const worker = new Worker('worker.mjs', { type: 'module' });
JS模块对共享工作者和服务工作者的支持即将推出:
const worker = new SharedWorker('worker.mjs', { type: 'module' });
const registration = await navigator.serviceWorker.register('worker.mjs', { type: 'module' });
导入地图
在Node.js/npm中,通过js模块的“包名”导入js模块是很常见的。例如:
import moment from 'moment';
import {pluck} from 'lodash-es';
目前,根据HTML规范,这样的“裸导入说明符”会引发异常。我们的导入地图提案允许此类代码在网络上工作,包括在生产应用程序中。导入映射是一种JSON资源,它可以帮助浏览器将裸导入说明符转换为完整的URL。
导入地图仍处于提案阶段。尽管我们已经考虑了很多关于它们如何解决各种用例的问题,但我们仍在与社区接触,尚未编写完整的规范。欢迎反馈!
Web打包:本地打包
Chrome加载团队目前正在探索一种原生的网络打包格式,作为分发网络应用程序的一种新方式。网页包装的核心功能是:
签名的HTTP交换,允许浏览器相信单个HTTP请求/响应对是由其声称的来源生成的;捆绑的HTTP交换,即交换的集合,每个交换都可以是签名的或未签名的,其中一些元数据描述了如何将捆绑作为一个整体进行解释。
结合起来,这样的web打包格式将使多个相同来源的资源能够安全地嵌入到单个HTTPGET响应中。
现有的捆绑工具,如webpack、Rollup或Parcel,目前只发出一个JavaScript捆绑包,其中丢失了原始单独模块和资产的语义。使用本机捆绑包,浏览器可以将资源拆分回其原始形式。简单地说,你可以将捆绑的HTTP Exchange想象成一个资源捆绑包,可以通过目录(manifest)以任何顺序访问,并且可以根据其相对重要性有效地存储和标记所包含的资源,同时保持单个文件的概念。正因为如此,本机捆绑包可以改善调试体验。当在DevTools中查看资产时,浏览器可以精确定位原始模块,而不需要复杂的源映射。
原生捆绑包格式的透明性带来了各种优化机会。例如,如果浏览器已经在本地缓存了本机捆绑包的一部分,它可以将其通信到web服务器,然后只下载缺失的部分。
Chrome已经支持该提案的一部分(SignedExchanges),但捆绑格式本身及其在高度模块化应用程序中的应用仍处于探索阶段。非常欢迎您在存储库中或通过电子邮件向loading-dev@chromium.org!
分层API
运送新功能和web API会带来持续的维护和运行成本——每一个新功能都会污染浏览器名称空间,增加启动成本,并代表一个新的表面,在整个代码库中引入错误。分层API是以更可扩展的方式在web浏览器中实现和发布更高级别的API。JS模块是实现分层API的关键技术:
1、由于模块是显式导入的,因此要求通过模块公开分层API可以确保开发人员只为他们使用的分层API付费。
2、因为模块加载是可配置的,所以分层API可以有一个内置机制,用于在不支持分层API的浏览器中自动加载polyfill。
模块和分层API如何协同工作的细节仍在制定中,但目前的提案看起来是这样的:
<script
type="module"
src="std:virtual-scroller|https://example.com/virtual-scroller.mjs"
></script>