对于async属性,浏览器对二者也会区别对待,async属性被用来告知浏览器下载脚本但不要阻塞 HTML 渲染,并且希望一旦下载完成,就立即执行,不用考虑顺序,不用考虑HTML渲染是否完成,async 属性在传统的行内<script>元素引入时是无效,但是在行内<script type="module">却是有效的。
关于扩展名的说明
上文中,我们一直在使用.mjs作为模块的拓展名,实际上,在web 上,拓展名本身并不重要,重要的是该文件的MIME type 需要设置为 text/javascript ,浏览器仅通过<script>元素上的type属性来识别其是否是一个模块。
![Uploading 1031000-c93620608470c6d6.png…]()
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
标签引入脚本有一些区别,如下: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> <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
拓展名 ,有如下两个原因:.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 渲染的影响
模块脚本默认为
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()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 是可行的:参考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 获取响应中.
现存的诸如
webpack
,Rollup
,Parcel
等打包工具目前都将文件打包为一个单一的 JS 文件,这会导致原始模块语义的丢失,而通过原生的打包,浏览器可以解压打包资源为原始的状态。这就保持了单个资源的独立性。原生打包由此可以改进调试的体验,当在devtools 中查看资源时,浏览器可以指明原始的模块,而不再需要使用复杂的 source-map 了。原生打包还提供了其它优化的可能,比如说,如果浏览器已经缓存了部分内容在本地,浏览器可以只在服务器下载缺失的部分。
Chrome 已经支持这个提议的一部分(SignedExchange),不过原生打包本身即其在高模块化app中的应用还处于探索阶段。
Layers APIs
每个新功能都可能会污染浏览器命名空间, 增加启动成本, 在整个代码库中引入 bug。Layers APIs 是在将更高层次的 api 与 web 浏览器结合在一起所做的努力。JS 模块是分层 api 的关键依赖技术:
模块与 Layers APIs 该如何协同使用目前还没有定论,目前的提议用法如下:
<script type="module" src="std:virtual-scroller|https://example.com/virtual-scroller.mjs" ></script>
浏览器按照上述方法在
<script>
标准中加载 Layers APIs。