上周在四个不一样的地方看到了推荐 Using JavaScript modules on the web 这篇文章,以前一直没有去了解过原生模块在web浏览器中该如何使用,周末把这篇文章大体翻译了一下。
JS 模块 目前已获得全部主流浏览器的支持,本文将讲述什么是 JS 模块,如何使用 JS 模块,以及 Chrome 团队将来计划如何优化 JS 模块。javascript
JS modules 其实是一系列功能的集合。以前你可能听过说 Common JS
,AMD
等模块标准,不一样标准的模块功能都是相似的,都容许你 import
或者 export
一些东西。css
JavaScript 模块目前有标准的语法,在模块中,你能够经过 export
关键字,导出一切东西(变量,函数,其它声明等等)html
// 📁 lib.mjs export const repeat = (string) => `${string} ${string}`; export function shout(string) { return `${string.toUpperCase()}!`; }
而想要导入该模块,只须要在其它文件中使用import
关键字引入便可java
// 📁 main.mjs import {repeat, shout} from './lib.mjs'; repeat('hello'); // → 'hello hello' shout('Modules in action'); // → 'MODULES IN ACTION!'
模块中还能够导出默认值node
// 📁 lib.mjs export default function(string) { return `${string.toUpperCase()}!`; }
具备默认值的模块能够以任意名字导入到其它模块中jquery
// 📁 main.mjs import shout from './lib.mjs'; // ^^^^^
模块和传统的script
标签引入脚本有一些区别,以下:webpack
html
格式的注释,即<!-- TODO: Rename x to y. -->
var foo = 42;
语句时,并不会建立一个全局变量foo
, 所以也不能经过window.foo
在浏览器中访问该变量。import
和 export
关键字只在模块中有效。因为存在上述不一样,经过传统方式引入的脚本 和 以模块方式引入的脚本,就会有相同的代码,也会产生不一样的行为,于是 JS 执行环节须要知道那些脚本是模块。git
在 浏览器中,经过设置 <script>
元素的type
属性为 module
能够声明其实一个模块。github
<script type="module" src="main.mjs"></script> <script nomodule src="fallback.js"></script>
支持type="module"
的浏览器会忽略带有nomudule
属性的的<script>
元素,这样就提供了降级处理的空间。其意义不只如此,支持type="module"
的环境意味着其也支持箭头函数,async-await
等新语法功能,这样引入的脚本无须再作转义处理了。web
若是模块引入了屡次,浏览器只会执行一次相同模块中的代码,而对传统的方式引入的脚本引入了多少次,浏览器就会执行多少次。
<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,也叫作 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()
注:这和 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 是可行的:
参考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 ,这对性能的提高也是显而易见的, 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 还不是一个有效的方案.
JS 模块在逐步被 web 采用,据 usage counters 统计,大概有0.08%
的网页目前在使用<script type="module">
, 不过须要注意,这类数据中包括动态import()
和 worklets
相关的数据。
Chrome 团队致力于改进开发阶段使用 JS modules 的体验,如下是一些方向:
谷歌提出了一种更快更准确的模块解析算法,目前这种算法已经存在于 HTML 规范 及 ECMA 规范中,该算法在Chrome63 中已经开始使用,能够预见在不久的未来将会应用于更多的浏览器中。
旧算法的时间复杂度为O(n²)
,而新算法则为O(n)
。
新算法还能够针对错误给出更有效的提示,相比较而言,旧算法对错误的处理就没那么有效。
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' });
在 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 团队已经提出了多种使用示例, 可是目前还处于和社区的沟通中, 目前也尚未成文的规范。
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中的应用还处于探索阶段。
每一个新功能均可能会污染浏览器命名空间, 增长启动成本, 在整个代码库中引入 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。