本文由云 + 社区发表作者:
原文:《Using JavaScript modules on the web》https://developers.google.com…
译者序
JS modules,即 ES6 的模块化特性,通过 <scripttype=”modules”> 可以实现不经过打包直接在浏览器中 import/export,此玩法确实让人眼前一亮。
先看看 <scripttype=”modules”> 的兼容性。目前只有较新版本的 chrome/firefox/safari/edge 支持此特性,看来要普及使用还任重道远。下面跟着这篇文章深入了解一下涨涨姿势。
本文将介绍 JS 模块化;怎样在不经过打包的情况下直接在浏览器中使用模块化;以及 Chrome 团队在 JS 模块化的优化和普及上正在做的一些事情。
JS 模块化
你可能用过命名空间、CommonJS 或者 AMD 规范进行 JS 模块化,但所有的这些模块解决方案万变不离其宗:引入 (import) 其他模块,作为一个模块输出(export)。如果说命名空间、CommonJS、AMD 都是野路子,那 ES6 的 JS modules 则是正规军,将模块化语法统一起来(一统江湖,千秋万代)。
在 JS modules 中,你可以使用 export 关键字输出任何东西:const、function 等。
// lib.mjsexport const repeat = (string) => `${string} ${string}`;export function shout(string) {return `${string.toUpperCase()}!`;}
然后你可以用 import 关键字从另一个模块中引进来。下面代码将 lib 模块中的 repeat 和 shout 函数引到了我们的主模块 main 中。
// main.mjsimport {repeat, shout} from ‘./lib.mjs’;repeat(‘hello’);// → ‘hello hello’shout(‘Modules in action’);// → ‘MODULES IN ACTION!’
你也可以通过 default 关键字,输出一个默认值。
// lib.mjsexport default function(string) {return `${string.toUpperCase()}!`;}
而通过上面的 default 输出的模块,在引入时可以用其他任何变量名。
// main.mjsimport shout from ‘./lib.mjs’;// ^^^^^
模块脚本与常规脚本有所区别:
模块脚本默认开启了严格模式
不支持 HTML 风格的注释 <!– comment –>
模块具有词法顶级作用域。也就是说在模块中 varfoo=42; 并不会像传统脚本一样,创建一个全局变量 foo,可以通过 window.foo 访问。
新的 import 和 export 语法仅限于在模块脚本中使用,不能用在常规脚本中。
正因为这些差异,模块脚本和传统脚本显然需要各自不同的解析方式。因此 JS 解析器需要标识出哪些脚本属于是模块类型的。
浏览器如何识别模块脚本
你可以通过设置 <script> 元素的 type 属性为 module,以此告诉浏览器这段 script 需要以模块进行处理。
<script type=”module” src=”index.mjs”></script> <!– 下文称作模块脚本 –><script nomodule src=”fallback.js”></script> <!– 下文称作传统脚本 –>
那些支持 type=module 的浏览器会忽略掉 nomodule 的脚本,而不兼容也会优雅降级,执行 fallback.js。
译者注:亲测在 IE7+ 到 edge,oppo 手机自带的浏览器都能够降级而执行 fallback.js。不过加载 fallback 的同时,也会把 index.mjs 一并加载,而支持 module 的浏览器则不会加载 fallback。
IE 系列均会执行 fallback.js
加载 fallback 的同时,也会把 index.mjs 一并加载
而支持 module 的浏览器则只会加载模块
有没想过另外一个好处:既然浏览器能够识别 module,那它必然也能够支持 ES67 的其他特性,如箭头函数、async-await。你不需要为这些特性进行 babel 编译,现代浏览器跑着更小和最大部分未编译的模块化代码,而不兼容的则使用 nomodule 的降级代码。
浏览器加载方面的异同:模块脚本 vs 传统脚本
上面介绍了模块脚本和传统脚本在语言层面的异同,除此之外,在浏览器加载过程中也有所不同。
同样的模块脚本只会执行一次,而传统脚本会声明多次。
<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:*。而众所周知,传统脚本则不需要(译者注:还记得传说中的 JSONP 吗)。
async 属性对内联脚本有效
<script async>var test = 1;</script><!– async 无效 –><script async type=”module”>import {a} from ‘./a.mjs'</script><!– async 有效 –>
加了 async 属性会使得脚本在下载过程中不阻塞 DOM 渲染,而下载完成后立即执行,两个 async 脚本之间的执行时序不确定,执行时机也不确定,有可能在 domContentLoaded 之前或者之后。但这一属性对传统的内联脚本是无效的,而对模块的内联脚本却是有效的。
关于 .mjs 文件后缀
你可能会对前面的 .mjs 后缀感到好奇,但是在互联网的世界里,文件后缀并不重要,只要服务器下发的 MIME 类型 (Content-Type:text/javascript) 正确就可以。浏览器是通过 script 标签上的 type 属性来识别模块脚本的,而不是后缀名。
所以无论使用 .js 还是 .mjs 都是可以的。但是我们还是建议使用 .mjs,原因有两个:
在开发的时候,可以不需要看代码,通过后缀名非常直观地看出哪些是模块脚本。
nodejs 中,ES6 的模块化特性仍在实验性阶段,而该特性只支持 .mjs 后缀的脚本。
模块资源标识符 – module specifier
在 import 一个模块时,后面的相对或绝对路径字符串称为 module specifier 或 import specifier,也就是模块资源路径。
import {shout} from ‘./lib.mjs’;// ^^^^^^^^^^^
浏览器对于模块资源路径做了一些限制。不支持类似下面这种只有模块名或部分文件名的资源路径(称之为 bare module specifiers)。这样的限制是为了以后浏览器在支持自定义模块加载器之后,加载器能够自行决定 bare module specifiers 的解析方式。
// Not supported (yet):import {shout} from ‘jquery’;import {shout} from ‘lib.mjs’;import {shout} from ‘modules/lib.mjs’;
目前,模块资源路径必须是完整的 URL,或者以 /, ./, ../ 开头的相对 URL
// 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’;
模块 script 默认是 defer
传统脚本的加载和解析会阻塞 html 的解析,可以通过添加 defer 属性解决(让脚本加载和 html 解析并行)
但这里想告诉你的是,模块脚本默认具备 defer 的并行功能,因此无需画蛇添足加上 defer 属性。还有不仅仅只有主模块与 html 解析并行,其他子模块也一样。
JS 模块化的其他特性
动态引入:import()
我们之前仅仅用到了静态的 import,它需要在首屏就把全部模块资源都下载下来。但有时候按需加载或异步加载会更为合理,这有助于提高首次加载时间,而 import()可以用来解决这个问题。
<script type=”module”> (async () => {const moduleSpecifier = ‘./lib.mjs’; const {repeat, shout} = await import(moduleSpecifier); // lib 会在主模块及其依赖都加载并执行完毕之后才会 import repeat(‘hello’); // → ‘hello hello’ shout(‘Dynamic import in action’); // → ‘DYNAMIC IMPORT IN ACTION!’ })();</script>
不像静态 import 只能用在 <scripttype=”module>” 一样,动态 import()也可以用在普通的 script。具体可以看下我们关于动态 import 的文章。
NOTE: Webapck 自己实现了一套 import()方案,可以动态将 import()进去的模块抽离出来,生成单独的文件。
import.meta
另一个和 JS modules 相关的新特性是 import.meta,它能提供关于当前模块的 meta 信息。准确的 meta 信息并不是 ECMAScript 规范指定的部分,它取决于宿主环境。在浏览器拿到的 meta 信息和在 nodejs 里面拿到的是有区别的。
下面的例子中,图片的相对路径默认是基于 HTML 所在位置来解析的,但通过 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 等打包工具就可以享受原生的模块化福利,在以下场景建议可以直接使用原生的模块脚本:
开发环境下
不超过 100 个模块且相对较浅的依赖层级关系(小于 5)的小型 web 应用
然而,我们在性能瓶颈分析中发现,加载一个模块化库(大约 300 个模块),经过打包的性能数据要比未经过打包直接使用原生模块脚本的好。
其中一个原因是 import/ export 语法是可以静态分析的,因此打包工具在打包过程中就可以进行静态分析并移除冗余未使用的模块。从这可以看出,静态的 import/ export 不仅仅只是语法特性,还具备关键的工具属性(可静态分析)!
我们的总体建议是继续使用打包工具进行上线前的模块打包处理。毕竟从某种程度上,打包可以帮助你尽可能减少代码体积,用户不必要加载无用的脚本,更有利于页面性能。
开发者工具的代码覆盖率检查能帮助你检测源码中是否存在无用代码。我们同时也建议通过代码分割对模块进行合理拆分,以及延迟加载非首屏关键路径的脚本。
打包与使用模块脚本的权衡取舍
通常在 web 开发领域,所有方案都有利弊,需要权衡取舍。与加载一个未经过代码拆分的打包脚本相比,使用模块脚本也许会降低首次加载性能(cold cache),但是可以提升用户再次加载(warm cache)的速度。比如对于总大小 200KB 的代码,在修改一个细颗粒化的模块之后,那么用户只需要更新有变更的代码,这总比重新加载所有代码(打包脚本)要强。
如果相对于首次访问体验来说,你更关注用户再次访问体验,并且你的应用不超过数百个细颗粒化模块的话,你不妨尝试下使用模块脚本,通过性能数据对比之后再做出最后的选择。
浏览器工程师们正努力提升模块脚本的性能,我们希望模块脚本以后能够适用于更多的应用场景。
使用细颗粒化的模块
尽可能让你的代码以细颗粒化的模块进行组织。当在开发时,每个模块最好不要输出过多的内容。
下面的 ./util.mjs 模块,输出了 drop pluck 和 zip 三个函数。
export function drop() { /* … */}export function pluck() { /* … */}export function zip() { /* … */}
如果你的代码仅仅只需要 pluck,你也许会这样引入:
import {pluck} from ‘./util.mjs’;
在这种情况下,如果没有构建打包编译,浏览器会还是会下载、解析和编译整个 ./util.js 模块,即使只仅仅需要其中一个 export。
如果 pluck 不与 drop 和 zip 有引用或依赖关系的话,最好还是将它独立成一个模块 ./pluck.mjs。以达到无需加载其他无用函数的目的。
export function pluck() { /* … */}
这不仅能够让你的源码简洁,还能够减少对打包工具(移除冗余代码)的依赖。如果在你的应用中其中一个模块从未被 import 过,那么浏览器就不会去下载。而那些真正有用的模块则会被浏览器缓存起来。
此外,使用细颗粒化的模块也有助于对接未来的浏览器原生打包功能。
预加载模块
通过 <linkrel=”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>
这对于有复杂依赖关系模块的应用尤为重要。没有 rel=”modulepreload”,浏览器需要发出多个 HTTP 请求来计算出整个依赖关系。而如果你把所有依赖模块通过 rel=”modulepreload” 提前告诉浏览器,那么浏览器则无需再渐进式地去计算。
采用 HTTP/ 2 协议
HTTP/ 2 支持多路复用,多个请求及响应信息可以同时进行传输,这有助于提高模块树的加载效率。
Chrome 团队还预研了服务器推送——另一个 HTTP/ 2 特性,是否能够作为部署高度模块化应用的一个可行方案。但结局令人失望,HTTP/ 2 的服务器推送比想象中要难以应用,并且 web 服务器及浏览器的对其实现目前并没有针对高度模块化 web 应用进行优化。另一方面,服务器很难只推送未被缓存的资源。如果通过告知服务器完整的用户缓存状态来解决这个问题的话,又存在隐私泄露风险。
无论如何,采用 HTTP/ 2 协议吧!只要记住目前 HTTP/ 2 的服务器推送目前还不能作为一个好的解决方案。
目前的使用率
JS modules 正在缓慢地被接纳使用。我们的使用统计显示只有 0.08%(不包括动态 import()或者 worklets)的页面目前使用了 <scripttype=”module”>。
JS Modules 未来的发展
Chrome 团队正在通过不同的方式,致力于提高基于 JS modules 的开发体验。下面列举其中的几种。
更高效、确定性更高的模块解析算法
我们提交了一版对于目前模块解析算法的优化。新算法目前已经被同时列入了 HTML 规范和 ECMASciprt 规范,并且已在 Chrome 63 版本中实现。希望这项优化能够在更多的浏览器中落地。
新算法更快更高效,旧算法在计算依赖图谱(dependency graph)大小的时间复杂度为 O(n²),在 Chrome 中的实现也是一样。而新算法则提升至 O(n)。
此外,新算法在报解析错误时更加准确。如果一个依赖图谱中有多个错误,那么基于旧算法,每次执行都会报不同的解析错误。这给开发调试带来不必要的困难。新算法则保证每次执行都会报相同的解析错误。
Worklets 和 web workers
Chrome 实现了 worklets,允许 web 开发者自定义那些在浏览器底层的硬编码逻辑。目前开发者可以将一个 JS 模块引入到渲染管道(rendering pipeline)或者音频处理管道。
Chrome65 版本支持了 PaintWorklet,也称为 CSS 绘制 API(the CSS Paint API),用于控制如何绘制一个 DOM 元素。
const result = await css.paintWorklet.addModule(‘paint-worklet.mjs’);
Chrome66 版本支持了 AudioWorklet,允许开发者注入自定义的音频处理代码。同时这个版本开始了 AnimationWorklet 的公测,开发者可以创造视差滚动效果 (scroll-linked) 以及其他高性能程序动画(procedural animations)。
最后,LayoutWorklet,又称为 CSS 布局 API(the CSS Layout API)已在 Chrome67 版本中实现。
我们正在对 Chrome 中的 web workers 支持传入模块脚本。你可以通过输入 chrome://flags/#enable-experimental-web-platform-features 开启这个特性。
const worker = new Worker(‘worker.mjs’, { type: ‘module’});
在 shared workers 和 service workers 传入模块脚本也即将支持。
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 规范,类似上述的包名写法(bare import specifiers)会抛出异常。我们提交的“包名映射表”提案将会支持上述写法(包括在生产环境)。该映射表(JSON 格式)将帮助浏览器将包名转换为完整资源路径(full URLs)。
包名映射表目前仍处于提案阶段(proposal stage)。
Web packaging:浏览器原生打包
Chrome loading 团队正在探索一种原生的 web 打包格式(下称为 web packaging),作为一种新模式来分发 web 应用。web packaging 的主要特性如下:
Signed HTTP Exchanges:可以让浏览器信任某个 HTTP 请求对(request/response)确实是来自于所声明的源服务器。
Bundled HTTP Exchanges:是多个请求对的集合,不要求当中的每个请求都进行签名(signed),只要携带某些元数据(metadata)用于描述如何将请求束作为一个整体来解析。
两者结合起来,这种 web 打包格式就能够将多个同源资源安全地整合到一个 HTTP GET 相应中。
市面上的打包工具如 webpack、Rollup、Parcel,都会将多个模块最终打包成一个或少数几个 bundle,这会导致源码中进行的模块拆分在上线后就丧失了它的意义。那么通过原生打包,浏览器可以将 bundle 反解成原样。
简单来说,你可以把一个 HTTP 请求对包(Bundled HTTP Exchange)理解为一个资源文件包,它可以通过目录表(manifest)随意访问,并且里面的资源能够被高效地缓存以及根据相对优先级的高低来标记。有了这个机制,原生模块能够提升开发调试的体验。当你在 Chrome 开发者工具查看资源时,浏览器会精准定位到原生的模块代码中,而不需要复杂的 source-map。
Chrome 已经实现了一部分提案(SignedExchanges),但是打包格式(bundling format)以及在高度模块化 app 中的应用仍在探索阶段。
Layered APIs
移植新的功能和 API 到浏览器中无可避免会带来持续性的维护成本以及运行成本。每一个新特性都会污染浏览器的命名空间,增加启动开销,并且也增大引入 bug 的可能性。Layered APIs 的目的是以一种更具扩展性的方式通过浏览器来实现或移植一些高级 API。而模块脚本是实现 Layered APIs 的一项关键技术。
由于模块是显式引入的,所以通过模块来引入 layered APIs 可实现按需使用(不会默认内置)。
模块的加载源可自定义,因此 layered APIs 实现了一套自动加载 polyfill(当不支持时)的机制。
模块脚本和 layered APIs 如何协同运作,具体细节仍在制定中,但目前的协议如下:
<!– src 中竖杠后面是指定 polyfill 的路径,浏览器不支持时可自动加载,不错的降级方式 –><script type=”module” src=”std:virtual-scroller|https://example.com/virtual-scroller.mjs”></script><virtual-scroller> <!– Content goes here. –></virtual-scroller>
这个模块脚本引入了 virtual-scrollerAPI,如果浏览器支持则会直接读取内置 layered APIs 集合(std:virtual-scroller),反之则网络加载对应的 polyfill。
译者:对于 Layered APIs 更多的中文介绍 https://zhuanlan.zhihu.com/p/…
此文已由腾讯云 + 社区在各渠道发布
获取更多新鲜技术干货,可以关注我们腾讯云技术社区 - 云加社区官方号及知乎机构号