上周在四个不同的地方看到了推荐 Using JavaScript modules on the web 这篇文章,之前一直没有去了解过原生模块在web浏览器中该如何使用,周末把这篇文章大致翻译了一下。
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
标签引入脚本有一些区别,如下:
- JS模块默认使用严格模式
- 模块中不支持使用
格式的注释,即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
拓展名 ,有如下两个原因:
- 开发阶段,这个拓展名可以充分说明它是一个模块,毕竟模块和普通的脚本还是有区别的。
-
和node兼容;.mjs
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
deferred
传统的
<script>
的下载默认会阻塞 HTML 渲染。不过可以通过添加
defer
属性,使得其下载与 HTML 渲染同步进行。
下图说明了不同的属性,脚本下载与执行对 HTML 渲染的影响
模块脚本默认为
defer
, 其依赖的所有其它模块也会以 defer 模式加载。
其它的模块特性
动态 import()
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
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 是可行的:
- 在本地开发环境中
- 小型项目(所依赖模块不超过100个,依赖树浅,比如依赖层级不超过5层)
参考
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 的关键依赖技术:
- 由于模块是显式导入的, 因此需要通过模块公开分层 api, 以确保开发人员只用管他们使用的Layers APIs;
- 模块加载是可配置的, 因此Layers APIs 也可以有一个内置机制, 用于在不支持Layers APIs 的浏览器中自动加载 polyfills。
模块与 Layers APIs 该如何协同使用目前还没有定论,目前的提议用法如下:
<script
type="module"
src="std:virtual-scroller|https://example.com/virtual-scroller.mjs"
></script>
浏览器按照上述方法在
<script>
标准中加载 Layers APIs。
原文发布时间为:2018年06月27日
原文作者:掘金
本文来源:
掘金 https://juejin.im/entry/5b30f14df265da59645b109c如需转载请联系原作者