在浏览器中使用JS module
简介
现在,所有主流浏览器都已经支持JS module(Chrome、Edge、Safari、Firefox)~这篇文章将为你讲解如何在浏览器中使用JS module,如何负责的部署它们,以及chrome团队如何努力的使JS module变的更好。
什么是JS module
JS module(或者称作ES module,ECMAScript module)是一个主要的新特性,或者说是一系列新特性。你可能已经使用过第三方的模块加载系统。CommonJs 如 nodeJs 、 AMD 如 requireJs 等等。这些模块加载系统都有一个共同点:它们允许你执行导入导出操作。
javascript现在已经为模块化制定了标准语法。有了JS module,你可以用export
关键字去导出任何东西。你可以导出const
、function
,或者任何其他变量绑定或声明。你需要做的只是在变量声明前加个export
或者用export
去声明它。
// lib.mjs
export const repeat = (string) => `${string} ${string}`;
export function shout(string) {
return `${string.toUpperCase()}!`;
}
你可以使用import
从任何其他模块导入模块。这里,我们从lib
模块中引入repeat
和shout
方法,并在main模块(当前模块)中使用它们。
// 📁 main.mjs
import {repeat, shout} from './lib.mjs';
repeat('hello');
// → 'hello hello'
shout('Modules in action');
// → 'MODULES IN ACTION!'
你也可以使用export default
从模块中导出一个默认值。
// 📁 lib.mjs
export default function(string) {
return `${string.toUpperCase()}!`;
}
用export default
导出的模块可以在其它模块中使用任意名称去import
。
// 📁 main.mjs
import repeat from './lib.mjs';
// ^^^^^
// 📁 main2.mjs
import shout from './lib.mjs';
// ^^^^^
JS module
和普通的js脚本
有些不同:
-
module
默认使用的是严格模式(strick module
) - 不支持html风格的注释
// Error: 不要在javascript中使用html风格的注释 const x = 42; <!-- TODO: Rename x to y. // Error: 使用普通的单行注释 const x = 42; // TODO: Rename x to y.
- 模块具有自己的作用域。这意味着在模块中使用
var foo = 42
并不会创建一个全局变量foo
,不能通过window.foo
去访问。这点和普通的js脚本
不同。 -
export
和import
关键字仅可在模块系统中使用----所以不能在普通的js脚本中使用。
由于有这些不同点,当相同的js脚本分别被当做JS module
和普通js脚本
执行时,可能有不同的行为表现。所以,javascript运行环境必须知道引入的脚本是不是一个JS module
。
在浏览器中使用JS modules
在web应用中,你可以将<script>
标签的type
属性设置为module
,这样浏览器就会把引入的脚本识别为JS module
。
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>
浏览器会识别typ="module"
属性,并忽略设置有nomodule
属性的脚本。这意味着你可以向支持模块加载的浏览器提供基于JS module
的代码,同时让不支持模块加载的浏览器回退到普通js脚本
模式。
如果在表现上,这种区分脚本的能力惊人的有用。
想一想:只有现代浏览器支持modules,如果浏览器能够识别你的模块代码,那么它肯定也支持在模块化标准之前的新的js特性,比如:箭头函数,async-await
。
你不需要再去编译那些使用新特性写的代码!能为现代浏览器提供更小、很大程度上不需要再编译的代码。只有不支持模块加载的浏览器会请求设置nomodule
的脚本。
特定于浏览器环境下, JS module
和普通js脚本
的区别
前面已经说过,JS module
和普通js加载
是不同的。
在上边我们列举了一些平台无关的差异,在浏览器环境中它们还有一些不同点。
比如,JS module
只会被浏览器解析并执行一次,普通js脚本
每一次通过<script>
引入,浏览器都会去解析和执行它。
<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 只会被解析一次 -->
而且,JS module
脚本和依赖有了跨域限制,这意味着任何跨域的JS module
脚本都必须有正确的http头部信息。如:Access-Control-Allow-Origin: *
。而普通的js脚本
并没有这些限制。
另一个不同与async
属性有关,async
属性可以使js脚本的下载不会阻塞HTML解析(就像defer),但同时async
属性也会使得脚本在加载完成后立即执行(defer是等到html解析完成后执行),不能保证脚本的执行顺序,也不会等待html解析完成。
async
属性在内联普通js脚本
不生效,不过在内联JS module
模式下可以生效。
关于文件扩展名
你可能已经注意到我们在写模块代码时是用.mjs
作为文件扩展名的。当然,在web中,只要http的响应头中提供了JavaScript MIME typetext/javascript
字段,文件扩展名并不是特别重要。
而且,浏览器通过<script>
标签上的type
属性就可以知道这是不是一个JS module
模块。
但是,我们仍然推荐使用.mjs
作为文件扩展名,原因有以下两个方面:
- 在开发中,我们可以很容易的通过扩展名去辨识文件是一个模块而不是一个普通的脚本文件(仅仅靠查看代码去辨别不会总行得通)。正如前边提到的,浏览器对待模块代码和普通脚本文件是完全不同的。
- 符合node.js规范。nodeJs实验模块仅仅支持
.mjs
扩展名的文件。
模块说明符(Module specifiers)
当我们使用import
时,最后边用来说明模块位置的字符串叫做 module specifiers 或 import specifier,在我们前面的例子中, module specifier 是 './lib.mjs'
import {shout} from './lib.mjs';
// ^^^^^^^^^^^
在浏览器中,模块说明符还有一些限制。
目前是不支持 裸模块 (指像在node中只通过包名引入)的。这个规定让浏览器可以允许自定义模块加载器对像下面这样的空模块说明符赋予特殊的意义。
// 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。
模块默认是延迟执行的(defer)
普通js脚本默认会阻塞html解析。你可以加一个defer属性,让脚本的下载和html解析并发执行。
JS module
脚本默认就有defer属性,所以,没有必要再加一个defer
属性到<script type="module">
标签上!
不仅仅是主模块,所有模块的加载都和html解析是并行的。
其他模块特性
Dynamic 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()查看
import.meta
另一个模块相关的新特性是import.meta
,从这个属性我们可以获得当前模块的元数据。你所能得到的确切元数据并没有作为ECMAScript规范的一部分。它取决于你的宿主环境,在浏览器和NodeJs中你可能得到不同的元数据,比如:
下面是一个浏览器中使用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);
性能建议
保持使用打包工具
JS modules
使得不使用打包工具(webpack, Rollup, 或Parcel)开发网站成为可能。在下列场景中,你就可以直接使用JS module
去开发应用。
- 本地开发环境中
- 在总模块少于100和依赖树相对较浅(即,最大深度小于5)的小型Web App中
然而,我们在对chrome加载管道进行瓶颈分析时,加载了由大约300模块组成的模块化库,此时打包应用程序的加载性能优于未打包应用程序。
其中一个原因是因为静态的import / export
是可以静态分析的,因此,打包工具可以通过消除未使用的export
来优化代码。静态的import
和export
不仅仅是语法,更是一种新的工具特性!
同样的chrome开发者工具的代码覆盖率特性可以帮助你识别你是否将不必要的代码推送给了用户。我们还建议开发者使用代码分割工具分割代码,将首屏渲染不需要的脚本延迟加载。
打包和不打包的权衡
在web开发中,每一件事都需要去做权衡。未打包的代码可能会用户降低首次访问页面的性能(冷缓存),但与一个没有做代码分割的包相比,它可以提高后续访问的加载性能。
对一个200kb的代码库,将其中一个细粒度的模块分割出来,将其作为用户后续访问从服务器获取的唯一内容比每次都要下载整个代码库好得多。
使用更细粒度的模块
养成写更小、细粒度模块代码的习惯。在开发中, 每个(文件)模块只含有少数几个导出 要比 将多个导出写到一个文件 好的多。
考虑这样一种情况,util.mjs
文件导出三个方法分别是:drop
,pluck
和zip
:
export function drop() { /* … */ }
export function pluck() { /* … */ }
export function zip() { /* … */ }
如果你的代码只需要pluck
方法,你可能要这样写:
import { pluck } from './util.mjs';
在这种情况下(没有进行构建时打包),即使你只需要pluck
一个导出,浏览器仍然需要去下载、解析和编译整个utils.mjs
文件。真是糟糕呢~
如果pluck
没有和drop
、zip
有任何共享模块的话,最好的做法是将它拆分为更细粒度的模块文件./pluck.mjs
。
export function pluck() { /* … */ }
这样,我们可以只导入pluck
,而不用浏览器去处理drop
和zip
模块:
import { pluck } from './pluck.mjs';
提示:您可以在此处使用命名导出
export default
这样不仅能使你的代码保持良好和简单,还能减少打包工具处理无用代码的时间。如果其中一个模块没有被使用,那它永远不会被import,所以浏览器永远不会使用它。而那些被使用的模块会被浏览器每个单独缓存下来。
使用更细粒度的模块拆分可以让你的代码为将来或许可以直接使用原生打包解决方案做好准备。
预加载模块
你可以使用<link rel="modulepreload">
进一步优化你的模块的加载(delivery)。
这样,浏览器可以预加载和预编译模块以及它的依赖。
<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"
声明了整个应用的脚本列表,浏览器就不需要去逐步的去解析并请求这些依赖。
使用HTTP/2
尽可能的使用HHTP/2总是很好的性能建议,即使仅仅是去使用它的多路复用特性。
使用HTTP/2多路复用,可以同时处理多个请求和响应信息,这对模块依赖树的加载很有利。
Chrome团队研究过HTTP/2的另一个新特性,HTTP/2服务端推送,是否能成为部署高度模块化应用的解决方案。不幸的是,HTTP/2 服务端推送难做到,web服务器和浏览器的实现目前还没有针对高度模块化的web应用实例进行优化。
比如,我们很难去做到只推送用户没有缓存的数据,而且通过向服务端去发送整个客户端的缓存状态会造成一种隐私风险。
无论如何,继续使用HTTP/2!只需要记住HTTP/2服务端推送(很不幸)不是一个好办法。
JS modules的使用现状
JS modules正逐渐的被web开发者接受,我们的用户统计数据显示当前有0.08%的页面在使用<script type="module">
,这个数据排除了其他使用方式,如import()
或 worklets
对于JS modules,接下来我们会做什么
Chrome团队正在努力从多方面去提升JS modules的开发体验,下面我们来讲讲其中的一部分。
更快更确定的模块解析算法
我们对模块解析算法进行了修改,解决了速度和确定性方面的不足。新算法现在已经同时存在于HTML规范和ECMAScript规范中,而且已经在Chrome 63中实现。希望这个改进能在其他浏览器中尽快实现。
新算法更加的高效和快速。旧算法在依赖关系计算上的复杂度是二次方的(n²),旧的Chrome也是如此,新的算法是线性的(n)。
而且,新算法以一种确定的方式去报告解析错误。给定一个包含多个错误的依赖关系图,对造成解析失败的根本原因,旧算法多次运行可能报告不同的错误。这对调试造成了不必要的麻烦。而新算法保证每次都报告相同的错误信息。
Worklets and web workers
Chrome现在在实现worklets,这将允许web开发者去自定义浏览器底层的硬编码逻辑。使用worklets,web开发者可以将一个JS模块提供给渲染管道或者音频管道(未来可能会有更多的管道支持)。
Chrome 65 支持使用PaintWorklet
去控制DOM元素的绘制。
const result = await css.paintWorklet.addModule('paint-worklet.mjs');
Chrome 66 支持 AudioWorklet
,允许你控制用自己的代码控制音频进程。同时这个版本还开始了一个 实验性质的AnimationWorklet
,让开发者能够创建scroll-linked和其它高性能的程序动画(这块不太理解)。
最后, LayoutWorklet
(又叫做 the CSS Layout API)已经应用在Chrome 67上。
我们正在努力为Chrome添加支持,使得开发者可以在专用的web Workers中使用JS modules。你现在就可以尝试这个新特性:chrome://flags/#enable-experimental-web-platform-features enabled.
(在chrome输入栏输入开启)。
const worker = new Worker('worker.mjs', { type: 'module' });
JS module对shared wrokers和service workers的支持也会很快到来:
const worker = new SharedWorker('worker.mjs', { type: 'module' });
const registration = await navigator.serviceWorker.register('worker.mjs', { type: 'module' });
包名映射(Package name maps)
在Node.js/npm中,我们常用模块的包名去引入模块。如:
import moment from 'moment';
import { pluck } from 'lodash-es';
目前,按照HTML规范,这样的 裸模块 会抛出异常。我们的 包名映射建议允许这样的代码在浏览器中正常工作,包括生产环境中。包名映射是一种json数据,能帮助浏览器将 裸模块 形式的说明符转换成完整的url。
包名映射现在还在提案阶段。尽管我们已经思考了很多关于如何处理各种用例的问题,但我们仍然在与社区进行沟通,而且还没有写出一个完整的规范。欢迎大家提任何反馈(最下方我会给出原文链接)。
Web打包:原生捆绑包
Chrome开发团队正在探索一种原生的web打包格式作为一种新方式去发布web应用。原生web打包的核心特性是:
Signed HTTP Exchanges允许浏览器信任一个签名的HTTP请求/响应是被当前源要求生成的。
Bundled HTTP Exchanges,也就是,一个交换集,每个交换集可以是已签名的或者未签名的,其中有一些元数据描述如何将bundled解释为一个整体。
组合起来,这样的web打包格式将使多个同源资源安全地嵌入到单个HTTP GET响应中。
现在的打包工具,如webpack, Rollup, or Parcel 目前只会发出一个javascript包,丢失了原始独立模块和资源的语义。使用原生捆绑包,浏览器可以将资源解包到他们的原始形式。
简单来说,你可以将Bundled HTTP Exchange想象成一组资源,可以通过一个目录(manifest)以任何顺序访问它,而且,所包含的资源可以根据他们的相对重要性进行有效的存储和标记,同时保持了单个文件的概念。
由于这个特点,原生捆绑包可以提升调试体验。在从devtools中查看资源时,浏览器可以在不需要复杂的代码映射的情况下确定原始模块。
原生捆绑包的透明性提供了各种优化机会。比如,如果浏览器本地已经有了一部分捆绑包的缓存,那么它可以将该信息告诉web服务器,然后只需要下载缺失的部分。
Chrome已经支持了一部分提议(SignedExchanges)但是捆绑包以及对高度模块化应用的应用仍处于探索阶段。
Your feedback is highly welcome on the repository or via email to loading-dev@chromium.org!
分层API(Layered APIs)
发布新特性和web API需要持续的维护和运行时成本 ---- 每一个新特性都会污染浏览器命名空间,增加启动成本,在代码中引入新的bug。
Layered APIs是为实现通过浏览器以可扩展的方式实现和交付更高级别的api做的努力。JS modules 是实现分层api的关键技术:
- 由于模块是显式导入的,因此需要通过模块公开分层api,从而确保开发人员只为他们使用的分层api负责。
- 由于模块加载是可配置的,所以分层api可以有一个内置机制,用于在不支持分层api的浏览器中自动加载polyfill。
modules 和 layered APIs 如何一起工作的细节我们还在制定当中,不过当前的建议看起来像下边这样:
<script
type="module"
src="std:virtual-scroller|https://example.com/virtual-scroller.mjs"
></script>
这个<script>
标签会从浏览器内置的分层API集(std:virtual-scroller
)或者从指向回滚到polyfill的url中去加载virtual-scroller
API。这个API可以做任何JS模块在web浏览器中可以做的事情。其中一个例子就是定义一个自定义<virtual-scroller>
元素,因此下面的HTML将可以按照你想要的方式逐步加强。
<virtual-scroller>
<!-- Content goes here. -->
</virtual-scroller>
原文链接
如有错误,欢迎指正