开始在web中使用JS Modules

本文由云+社区发表javascript

做者:css

原文:《Using JavaScript modules on the web》 https://developers.google.com/web/fundamentals/primers/moduleshtml

译者序

JS modules,即ES6的模块化特性,经过 <scripttype="modules">能够实现不通过打包直接在浏览器中import/export,此玩法确实让人眼前一亮。java

先看看 <scripttype="modules">的兼容性。目前只有较新版本的chrome/firefox/safari/edge支持此特性,看来要普及使用还任重道远。下面跟着这篇文章深刻了解一下涨涨姿式。node

img

本文将介绍JS模块化;怎样在不通过打包的状况下直接在浏览器中使用模块化;以及Chrome团队在JS模块化的优化和普及上正在作的一些事情。jquery

JS模块化

你可能用过命名空间、CommonJS或者AMD规范进行JS模块化,但全部的这些模块解决方案万变不离其宗:引入(import)其余模块,做为一个模块输出(export)。若是说命名空间、CommonJS、AMD都是野路子,那ES6的JS modules则是正规军,将模块化语法统一块儿来(一统江湖,千秋万代)。webpack

在JS modules中,你可使用 export关键字输出任何东西: constfunction等。web

// lib.mjsexport const repeat = (string) => `${string} ${string}`;export function shout(string) {  return `${string.toUpperCase()}!`;}

而后你能够用 import关键字从另外一个模块中引进来。下面代码将lib模块中的 repeatshout函数引到了咱们的主模块main中。算法

// main.mjsimport {repeat, shout} from './lib.mjs';repeat('hello');// → 'hello hello'shout('Modules in action');// → 'MODULES IN ACTION!'

你也能够经过 default关键字,输出一个默认值。chrome

// lib.mjsexport default function(string) {  return `${string.toUpperCase()}!`;}

而经过上面的 default输出的模块,在引入时能够用其余任何变量名。

// main.mjsimport shout from './lib.mjs';//     ^^^^^

模块脚本与常规脚本有所区别:

  • 模块脚本默认开启了严格模式
  • 不支持HTML风格的注释 <!-- comment -->
  • 模块具备词法顶级做用域。也就是说在模块中 varfoo=42;并不会像传统脚本同样,建立一个全局变量 foo,能够经过 window.foo访问。
  • 新的 importexport语法仅限于在模块脚本中使用,不能用在常规脚本中。

正由于这些差别,模块脚本和传统脚本显然须要各自不一样的解析方式。所以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。

img

IE系列均会执行fallback.js

img

加载fallback的同时,也会把index.mjs一并加载

img

而支持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,缘由有两个:

  1. 在开发的时候,能够不须要看代码,经过后缀名很是直观地看出哪些是模块脚本。
  2. 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解析并行)

img

但这里想告诉你的是,模块脚本默认具有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等打包工具就能够享受原生的模块化福利,在如下场景建议能够直接使用原生的模块脚本:

  1. 开发环境下
  2. 不超过100个模块且相对较浅的依赖层级关系(小于5)的小型web应用

然而,咱们在性能瓶颈分析中发现,加载一个模块化库(大约300个模块),通过打包的性能数据要比未通过打包直接使用原生模块脚本的好。

img

其中一个缘由是 import/ export语法是能够静态分析的,所以打包工具在打包过程当中就能够进行静态分析并移除冗余未使用的模块。从这能够看出,静态的 import/ export不只仅只是语法特性,还具有关键的工具属性(可静态分析)!

咱们的整体建议是继续使用打包工具进行上线前的模块打包处理。毕竟从某种程度上,打包能够帮助你尽量减小代码体积,用户没必要要加载无用的脚本,更有利于页面性能。

开发者工具的代码覆盖率检查能帮助你检测源码中是否存在无用代码。咱们同时也建议经过代码分割对模块进行合理拆分,以及延迟加载非首屏关键路径的脚本。

打包与使用模块脚本的权衡取舍

一般在web开发领域,全部方案都有利弊,须要权衡取舍。与加载一个未通过代码拆分的打包脚本相比,使用模块脚本也许会下降首次加载性能(cold cache),可是能够提高用户再次加载(warm cache)的速度。好比对于总大小200KB的代码,在修改一个细颗粒化的模块以后,那么用户只须要更新有变动的代码,这总比从新加载全部代码(打包脚本)要强。

若是相对于首次访问体验来讲,你更关注用户再次访问体验,而且你的应用不超过数百个细颗粒化模块的话,你不妨尝试下使用模块脚本,经过性能数据对比以后再作出最后的选择。

浏览器工程师们正努力提高模块脚本的性能,咱们但愿模块脚本之后可以适用于更多的应用场景。

使用细颗粒化的模块

尽量让你的代码以细颗粒化的模块进行组织。当在开发时,每一个模块最好不要输出过多的内容。

下面的 ./util.mjs模块,输出了 drop pluckzip三个函数。

export function drop() { /* … */ }export function pluck() { /* … */ }export function zip() { /* … */ }

若是你的代码仅仅只须要 pluck,你也许会这样引入:

import { pluck } from './util.mjs';

在这种状况下,若是没有构建打包编译,浏览器会仍是会下载、解析和编译整个 ./util.js模块,即便只仅仅须要其中一个export。

若是 pluck不与 dropzip有引用或依赖关系的话,最好仍是将它独立成一个模块 ./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的主要特性以下:

  1. Signed HTTP Exchanges:可让浏览器信任某个HTTP请求对(request/response)确实是来自于所声明的源服务器。
  2. 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/37008246

此文已由腾讯云+社区在各渠道发布

获取更多新鲜技术干货,能够关注咱们腾讯云技术社区-云加社区官方号及知乎机构号

相关文章
相关标签/搜索