2017 年,主流浏览器陆续开始原生支持 ES2015 模块,这意味着——是时候重新学习 script 标签了。以及,我保证这绝不是又一篇只讲 ES module 语法不谈实践的“月经”文。
还记得当初入门前端开发的时候写过的 Hello World 么?一开始我们先创建了一个 HTML 文件,在 标签里写上网页内容;后来需要学习页面交互逻辑时,在 HTML markup 里增加一个
标签引入外部 script.js 代码,script.js 负责页面交互逻辑。
随着前端社区 JavaScript 模块化的发展,我们现在的习惯是拆分 JS 代码模块后使用 Webpack 打包为一个 bundle.js 文件,再在 HTML 中使用 标签引入打包后的 JS。这意味着我们的前端开发工作流从“石器时代”跨越到了“工业时代”,但是对浏览器来说并没有质的改变,它所加载的代码依然一个 bundle.js ,与我们在 Hello World 时加载脚本的方式没什么两样。
——直到浏览器对 ES Module 标准的原生支持,改变了这种情况。目前大多数浏览器已经支持通过 的方式加载标准的 ES 模块,正是时候让我们重新学习 script 相关的知识点了。
复习:defer 和 async 傻傻分不清楚?
请听题:
Q:有两个 script 元素,一个从 CDN 加载 lodash,另一个从本地加载 script.js,假设总是本地脚本下载更快,那么以下 plain.html、async.html 和 defer.html 分别输出什么?
// script.js
try {
console.log(_.VERSION);
} catch (error) {
console.log('Lodash Not Available');
}
console.log(document.body ? 'YES' : 'NO');
复制代码
// A. plain.html
<head>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js">script>
<script src="script.js">script>
head>
// B. async.html
<head>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js" async>script>
<script src="script.js" async>script>
head>
// C. defer.html
<head>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js" defer>script>
<script src="script.js" defer>script>
head>
复制代码
如果你知道答案,恭喜你可以跳过这一节了,否则就要复习一下了。
首先 A. plain.html 的输出是:
4.17.10
NO
复制代码
也就是说 script.js 在执行时,lodash 已下载并执行完毕,但 document.body 尚未加载。
在 defer 和 async 属性诞生之前,最初浏览器加载脚本是采用同步模型的。浏览器解析器在自上而下解析 HTML 标签,遇到 script 标签时会暂停对文档其它标签的解析而读取 script 标签。此时:
- 如果 script 标签无 src 属性,为内联脚本,解析器会直接读取标签的 textContent,由 JS 解释器执行 JS 代码
- 如果 script 有 src 属性,则从 src 指定的 URI 发起网络请求下载脚本,然后由 JS 解释器执行
无论哪种情况,都会阻塞浏览器的解析器,刚刚说到浏览器是自上而下解析 HTML Markup 的,所以这个阻塞的特性就决定了,script 标签中的脚本执行时,位于该 script 标签以上的 DOM 元素是可用的,位于其以下的 DOM 元素不可用。
如果我们的脚本的执行需要操作前面的 DOM 元素,并且后面的 DOM 元素的加载和渲染依赖该脚本的执行结果,这样的阻塞是有意义的。但如果情况相反,那么脚本的执行只会拖慢页面的渲染。
正因如此,2006 年的《Yahoo 网站优化建议》中有一个著名的规则:
把脚本放在 body 底部
但现代浏览器早已支持给 标签加上 defer 或 async 属性,二者的共同点是都不会阻塞 HTML 解析器。
当文档只有一个 script 标签时,defer 与 async 并没有显著差异。但当有多个 script 标签时,二者表现不同:
- async 脚本每个都会在下载完成后立即执行,无关 script 标签出现的顺序
- defer 脚本会根据 script 标签顺序先后执行
所以以上问题中,后两种情况分别输出:
// B. async.html
Lodash Not Available
YES
// C. defer.html
4.17.10
YES
复制代码
因为 async.html 中 script.js 体积更小下载更快,所以执行时间也比从 CDN 加载的 lodash 更早,所以 _.VERSION
上不可用,输出 Lodash Not Available
;而 defer.html 中的 script.js 下载完毕后并不立即执行,而是在 lodash 下载和执行之后才执行。
以下这张图片可以直观地看出 Default、defer、async 三种不同 script 脚本的加载方式的差异,浅蓝色为脚本下载阶段,黄色为脚本执行阶段。
One more thing...
上文只分析了包含 src 属性的 script 标签,也就是需要发起网络请求从外部加载脚本的情况,那么当内联 标签遇到 async 和 defer 属性时又如何呢?
答案就是简单的不支持,把 async 和 defer 属性用以下这种方式写到 script 标签中没有任何效果,意味着内联的 JS 脚本一定是同步阻塞执行的。
// defer attribute is useless
<script defer>
console.log(_.VERSION)
script>
// async attribute is useless
<script async>
console.log(_.VERSION)
script>
复制代码
这一点之所以值得单独拎出来讲,是因为稍后我们会发现浏览器处理 ES Module 时与常规 script 相反,默认情况下是异步不阻塞的。
改变游戏规则的
TLDR;
- 给 script 标签添加 type=module 属性,就可以让浏览器以 ES Module 的方式加载脚本
- type=module 标签既支持内联脚本,也支持加载脚本
- 默认情况下 ES 脚本是 defer 的,无论内联还是外联
- 给 script 标签显式指定
async
属性,可以覆盖默认的 defer 行为 - 同一模块仅执行一次
- 远程 script 根据 URL 作为判断唯一性的 Key
- 安全策略更严格,非同域脚本的加载受 CORS 策略限制
- 服务器端提供 ES Module 资源时,必须返回有效的属于 JavaScript 类型的 Content-Type 头
#1 ES Module 101
导入与导出
ES 标准的模块使用 import
、export
实现模块导入和导出。
export 可以导出任意可用的 JavaScript 标识符(idendifier),显式的导出方式包括声明(declaration)语句和 export { idendifier as name }
两种方式。
// lib/math.js
export function sum(x, y) {
return x + y;
}
export let pi = 3.141593;
export const epsilon = Number.EPSILON;
export { pi as PI };
复制代码
在另一个文件中,使用 import ... from ...
可以导入其他模块 export 的标识符,常用的使用方式包括:
import * as math from ...
导入整个模块,并通过 math 命名空间调用import { pi, epsilon } from ...
部分导入,可直接调用 pi, epsilon 等变量
// app.js
import * as math from './lib/math.js';
import { pi, PI, epsilon } from './lib/math.js';
console.log(`2π = ${math.sum(math.pi, math.pi)}`);
console.log(`epsilon = ${epsilon}`);
console.log(`PI = ${PI}`);
复制代码
default
ES 模块支持 default
关键词实现无命名的导入,神奇的点在于它可以与其他显式 export
的变量同时导入。
// lib/math.js
export function sum(x, y) {
return x + y;
}
export default 123;
复制代码
对于这种模块,导入该模块有两种方式,第一种为默认导入 default 值。
import oneTwoThree from './lib/math.js';
// 此时 oneTwoThree 为 123
复制代码
第二种为 import *
方式导入 default 与其他变量。
import * as allDeps from './lib/math.js'
// 此时 allDeps 是一个包含了 sum 和 default 的对象,allDeps.default 为 123
// { sum: ..., default: 123}
复制代码
语法限制
ES 模块规范要求 import 和 export 必须写在脚本文件的最顶层,这是因为它与 CommonJS 中的 module.exports 不同,export 和 import 并不是传统的 JavaScript 语句(statement)。
-
不能像 CommonJS 一样将导出代码写在条件代码块中
// ./lib/logger.js // 正确 const isError = true; let logFunc; if (isError) { logFunc = (message) => console.log(`%c${message}`, 'color: red'); } else { logFunc = (message) => console.log(`%c${message}`, 'color: green'); } export { logFunc as log }; const isError = true; const greenLog = (message) => console.log(`%c${message}`, 'color: green'); const redLog = (message) => console.log(`%c${message}`, 'color: red'); // 错误! if (isError) { export const log = redLog; } else { export const log = greenLog; } 复制代码
-
不能把 import 和 export 放在 try catch 语句中
// 错误! try { import * as logger from './lib/logger.js'; } catch (e) { console.log(e); } 复制代码
另外 ES 模块规范中 import 的路径必须是有效的相对路径、或绝对路径(URI),并且不支持使用表达式作为 URI 路径。
// 错误:不支持类 npm 的“模块名” 导入
import * from 'lodash'
// 错误:必须为纯字符串表示,不支持表达式形式的动态导入
import * from './lib/' + vendor + '.js'
复制代码
#2 来认识一下 type=module
吧
以上是 ES 标准模块的基础知识,这玩意属于标准先行,实现滞后,浏览器支持没有马上跟上。但正如本文一开始所说,好消息目前业界最新的几个主流浏览器 Chrome、Firefox、Safari、Microsoft Edge 都已经支持了,我们要学习的就是 标签的新属性:type=module。
只要在常规 标签里,加上
type=module
属性,浏览器就会将这个脚本视为 ES 标准模块,并以模块的方式去加载、执行。
一个简单的 Hello World 是这样子的:
<html>
<head>
<script type=module src="./app.js">script>
head>
<body>
body>
html>
复制代码
// ./lib/math.js
const PI = 3.14159;
export { PI as PI };
// app.js
function sum (a, b) {
return a + b;
}
import * as math from './lib/math.js';
document.body.innerHTML = `PI = ${math.PI}`;
复制代码
打开 index.html 会发现页面内容如下:
可以从 Network 面板中看到资源请求过程,浏览器从 script.src 加载 app.js,在 Initiator 可以看到,app.js:1 发起了 math.js 的请求,即执行到 app.js 第一行 import 语句时去加载依赖模块 math.js。
模块脚本中 JavaScript 语句的执行与常规 script 所加载的脚本一样,可以使用 DOM API,BOM API 等接口,但有一个值得注意的知识点是,作为模块加载的脚本不会像普通的 script 脚本一样污染全局作用域。
例如我们的代码中 app.js 定义了函数 sum
,math.js 定义了常量 PI
,如果打开 Console 输入 PI 或 sum 浏览器会产生 ReferenceError 报错。
(Finally...)
#3 type=module 模块支持内联
在我们以上的示例代码中,如果把 type-module.html 中引用的 app.js 代码改为内联 JavaScript,效果是一样的。
<html>
<head>
<script type=module>
import * as math from './lib/math.js';
document.body.innerHTML = `PI = ${math.PI}`;
script>
head>
<body>
body>
html>
复制代码
当然内联的模块脚本只在作为 “入口” 脚本加载时有意义,这样做可以免去一次下载 app.js 的 HTTP 请求,此时 import 语句所引用的 math.js 路径自然也需要修改为相对于 type-module.html 的路径。
#4 默认 defer,支持 async
细心的你可能注意到了,我们的 Hello World 示例中 script 标签写在 head 标签中,其中用到了 document.body.innerHTML
的 API 去操作 body,但无论是从外部加载脚本,还是内联在 script 标签中,浏览器都可以正常执行没有报错。
这是因为 默认拥有类似
defer
的行为,所以脚本的执行不会阻塞页面渲染,因此会等待 document.body 可用时执行。
之所以说 类似 defer 而非确定,是因为我在浏览器 Console 中尝试检查默认 script 元素的 defer 属性(执行
script.defer
),得到的结果是 false 而非 true。
这就意味着如果有多个 脚本,浏览器下载完成脚本之后不一定会立即执行,而是按照引入顺序先后执行。
另外,与传统 script 标签类似,我们可以在 标签上写入 async 属性,从而使浏览器按照 async 的方式加载模块——下载完成后立即执行。
#5 同一模块执行一次
ES 模块被多次引用时只会执行一次,我们执行多次 import 语句获取到的内容是一样的。对于 HTML 中的 标签来说也一样,两个 script 标签先后导入同一个模块,只会执行一次。
例如以下脚本读取 count 值并加一:
// app.js
const el = document.getElementById('count');
const count = parseInt(el.innerHTML.trim(), 10);
el.innerHTML = count + 1;
复制代码
如果重复引入 只会执行一次 app.js 脚本,页面显示
count: 1
:
<html>
<head>
<script type=module src="app.js">script>
<script type=module src="app.js">script>
head>
<body>
count: <span id="count">0span>
body>
html>
复制代码
问题来了?如何定义“同一个模块”呢,答案是相同的 URL,不仅包括 pathname 也包括 ?
开始的参数字符串,所以如果我们给同一个脚本加上不同的参数,浏览器会认为这是两个不同的模块,从而会执行两次。
如果将上面 HTML 代码中第二个 app.js 加上 url 参数:
<script type=module src="app.js">script>
<script type=module src="app.js?foo=bar">script>
复制代码
浏览器会执行两次 app.js 脚本,页面显示 count: 2
:
#6 CORS 跨域限制
我们知道常规的 script 标签有一个重要的特性是不受 CORS 限制,script.src 可以是任何非同域的脚本资源。正因此,我们早些年间利用这个特性“发明”了 JSONP 的方案来实现“跨域”。
但是 type=module 的 script 标签加强了这方面的安全策略,浏览器加载不同域的脚本资源时,如果服务器未返回有效的 Allow-Origin
相关 CORS 头,浏览器会禁止加载改脚本。
如下 HTML 通过 5501 端口 serve,而去加载 8082 端口的 app.js 脚本:
<html>
<head>
<script type=module src="http://localhost:8082/app.js">script>
head>
<body>
count: <span id="count">0span>
body>
html>
复制代码
浏览器会禁止加载这个 app.js 脚本。
#7 MIME 类型
浏览器请求远程资源时,可以根据 HTTP 返回头中的 Content-Type
确定所加载资源的 MIME 类型(脚本、HTML、图片格式等)。
因为浏览器一直以来的宽容特性,对于常规的 script 标签来说,即使服务器端未返回 Content-Type 头指定脚本类型为 JavaScript,浏览器默认也会将脚本作为 JavaScript 解析和执行。
但对于 type=module 类型的 script 标签,浏览器不再宽容。如果服务器端对远程脚本的 MIME 类型不属于有效的 JavaScript 类型,浏览器会禁止执行该脚本。
用事实说话:如果我们把 app.js 重命名为 app.xyz,会发现页面会禁止执行这个脚本。因为在 Network 面板中可以看到浏览器返回的 Content-Type 头为 chemical/x-xyz
,而非有效的 JavaScript 类型如:text/javascript
。
<html>
<head>
<script type="module" src="app.xyz">script>
head>
<body>
count: <span id="count">0span>
body>
html>
复制代码
页面内容依然是 count: 0
,数值未被修改,可以在控制台和 Network 看到相关信息:
真实世界里的 ES Module 实践
向后兼容方案
OK 现在来聊现实——旧版本浏览器的兼容性问题,浏览器在处理 ES 模块时有非常巧妙的兼容性方案。
首先在旧版浏览器中,在 HTML markup 的解析阶段遇到 标签,浏览器认为这是自己不能支持的脚本格式,会直接忽略掉该标签;出于浏览器的宽恕性特点,并不会报错,而是静默地继续向下解析 HTML 余下的部分。
所以针对旧版浏览器,我们还是需要新增一个传统的 标签加载 JS 脚本,以实现向后兼容。
其次,而这种用于向后兼容的第二个 标签需要被新浏览器忽略,避免新浏览器重复执行同样的业务逻辑。
为了解决这个问题,script 标签新增了一个 nomodule
属性。已支持 type=module 的浏览器版本,应当忽略带有 nomodule 属性的 script 标签,而旧版浏览器因为不认识该属性,所以它是无意义的,不会干扰浏览器以正常的逻辑去加载 script 标签中的脚本。
<script type="module" src="app.js">script>
<script nomodule src="fallback.js">script>
复制代码
如上代码所示,新版浏览器加载第一个 script 标签,忽略第二个;旧版不支持 type=module 的浏览器则忽略第一个,加载第二个。
相当优雅对不对?不需要自己手写特性检测的 JS 代码,直接使用 script 的属性即可。
正因如此,进一步思考,我们可以大胆地得出这样的结论:
不特性检验,我们可以立即在生产环境中使用
带来的益处
聊到这里,我们是时候来思考浏览器原生支持 ES 模块能给我们带来的实际好处了。
#1 简化开发工作流
在前端工程化大行其道的今天,前端模块化开发已经是标配工作流。但是浏览器不支持 type=module 加载 ES 模板时,我们还是离不开 webpack 为核心的打包工具将本地模块化代码打包成 bundle 再加载。
但由于最新浏览器对 的天然支持,理论上我们的本地开发流可以完全脱离 webpack 这类 JS 打包工具了,只需要这样做:
- 直接将 entry.js 文件使用
标签引用
- 从 entry.js 到所有依赖的模块代码,全部采用 ES Module 方案实现
当然,之所以说是理论上,是因为第 1 点很容易做到,第 2 点要求我们所有依赖代码都用 ES 模块化方案,在目前前端工程化生态圈中,我们的依赖管理是采用 npm 的,而 npm 包大部分是采用 CommonJS 标准而未兼容 ES 标准的。
但毋庸置疑,只要能满足以上 2 点,本地开发可以轻松实现真正的模块化,这对我们的调试体验是相当大的改善,webpack --watch
、source map 什么的见鬼去吧。
现在你打开 devtools 里的 Source 面板就可以直接打断点了朋友!Just debug it!
#2 作为检查新特性支持度的水位线
ES 模块可以作为一个天然的、非常靠谱的浏览器版本检验器,从而在检查其他很多新特性的支持度时,起到水位线 的作用。
这里的逻辑其实非常简单,我们可以使用 caniuse 查到浏览器对 的支持状况,很显然对浏览器版本要求很高。
~> caniuse typemodule
JavaScript modules via script tag ✔ 70.94% ◒ 0.99% [WHATWG Living Standard]
Loading JavaScript module scripts using `