ES Module

零.7种模块化方式

1.分节注释

<!--html-->
<script>
    // module1 code
    // module2 code
</script>

手动添加注释来标明模块范围,类似于CSS里的分节注释:

/* -----------------
 * TOOLTIPS
 * ----------------- */

惟一作用是让浏览代码变得容易一些,迅速找到指定模块,根本原因是单文件内容太长,已经遇到了维护的麻烦,所以手动插入一些锚点供快速跳转

非常原始的模块化方案,没有实质性的好处(比如模块作用域,依赖处理,模块间错误隔离等等)

2.多script标签

<!--html-->
<script type="application/javascript" src="PATH/polyfill-vendor.js" ></script>
<script type="application/javascript" src="PATH/module1.js" ></script>
<script type="application/javascript" src="PATH/module2.js" ></script>
<script type="application/javascript" src="PATH/app.js" ></script>

把各个模块拆分成独立文件,有3个好处:

  • 通过控制资源加载顺序来处理模块依赖

  • 有模块间错误隔离(module1.js初始化执行异常不会阻断module2.jsapp.js的执行)

  • 各模块位于单独文件,切实提高了维护体验

但还存在2个问题:

  • 没有模块作用域

  • 资源请求数量与模块化粒度相关,需要寻找性能与模块化收益的平衡

3.IIFE

const myModule = (function (...deps){
   // JavaScript chunk
   return {hello : () => console.log('hello from myModule')};
})(dependencies);

可以作为补丁,配合其他方式使用,提供模块作用域

4.Asynchronous module definition (AMD)

RequireJS示例:

// polyfill-vendor.js
define(function () {
    // polyfills-vendor code
});

// module1.js
define(function () {
    //...
    return module1;
});
// module2.js
define(function () {
    //...
    return module2;
});

// app.js
define(['PATH/polyfill-vendor'] , function () {
    define(['PATH/module1', 'PATH/module2'] , function (module1, module2) {
        var APP = {};

        if (isModule1Needed) {
            APP.module1 = module1({param: 1});
        }
        APP.module2 = new module2({a: 42});
    });
});

一套比较完善的模块定义方案,解决了模块依赖问题,提供了模块作用域,错误隔离/捕获等方案。但看起来稍微有些冗余

P.S.另外还有SeaJS(官网都没了,不做介绍)。社区实现的模块化补丁都只是过渡产物,目前看来,JS似乎终将迎来模块化特性

5.CommonJS

NodeJS示例:

// polyfill-vendor.js
    // polyfills-vendor code

// module1.js
    // module1 code
    module.exports= module1;
// module2.js
module.exports= module2;

// app.js
require('PATH/polyfill-vendor');

const module1 = require('PATH/module1');
const module2 = require('PATH/module2');

const APP = {};
if(isModule1Needed){
    APP.module1 = module1({param:1});
}
APP.module2 = new module2({a: 42});

NodeJS遵循CommonJS规范,文件即模块,同样是一套相对完善的方案,但不适用于浏览器环境

6.UMD (Universal Module Dependency)

UMD示例:

(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' ? factory() :
    typeof define === 'function' && define.amd ? define(factory) :
(factory());
}(this, function () {
    // JavaScript chunk
    return {
       hello : () => console.log(‘hello from myModule’)
    }
});

同样是一个补丁,兼容AMD和CommonJS模块定义,实现了模块跨环境通用。出现UMD的根本原因是社区模块定义方式太多了,开源模块维护变得很麻烦(出现各种MD issue,只好换上UMD),所以迫切需要标准化,ES6肩负着这个使命

P.S.当然,开源模块的维护问题还在(为了迎合ES Module,又添上专门的ES6构建版本),但不会加剧,毕竟已经在标准化的路上了

7.ES6 Module

基本用法示例:

// myModule.js
export {fn1, fn2};

function fn1() {
    console.log('fn1');
}
function fn2() {
    console.log('fn2');
}

// app.js
import {fn1, fn2} from './myModule.js';
fn1();
fn2();

// index.html
<script type="module" src="app.js"></script>

注意

  • script标签必须声明type="module"表明以ES Module方式解析内容,否则不会执行

  • import模块文件精确路径./)、文件后缀名.js)及对应的MIME类型必须要有,否则引入失败

目前各大主流浏览器都提供了ES Module实验性功能:

  • Safari 10.1.

  • Chrome Canary 60 – behind the Experimental Web Platform flag in chrome:flags.

  • Firefox 54 – behind the dom.moduleScripts.enabled setting in about:config.

  • Edge 15 – behind the Experimental JavaScript Features setting in about:flags.

等了2年的Demo终于能跑起来了:http://ayqy.net/temp/module/index.html

P.S.一般都叫ES Module,因为Module特性不存在多个版本,ES Module指的就是ES6引入的Module特性

一.语法

export

// 基本语法
export { name1, name2, …, nameN };
export { variable1 as name1, variable2 as name2, …, nameN };
export let name1, name2, …, nameN; // also var, function
export let name1 = …, name2 = …, …, nameN; // also var, const

// 默认导出
export default expression;
export default function (…) { … } // also class, function*
export default function name1(…) { … } // also class, function*
export { name1 as default, … };

// 聚合导出
export * from …;
export { name1, name2, …, nameN } from …;
export { import1 as name1, import2 as name2, …, nameN } from …;

注意exportexport default的区别:

  • 每个模块(/文件)只能有一个export default,可以有多个export

  • export default后面可以接任意表达式,而export语法只有3种

例如:

// 不合法,语法错误
export {
    a: 1
};
// 而应该用export { name1, name2, …, nameN };
let a = 1;
export {
    a
};
// 或者export let name1 = …, name2 = …, …, nameN; // also var, const
export let a = 1;

默认导出

默认导出是一种特殊的导出形式,例如:

// module.js
export {fn1, fn2};
function fn1() {
    console.log('fn1');
}
function fn2() {
    console.log('fn2');
}
export default {
    a: 1
};
let b = 2;
export {
    b
};
export let c = 3;

// app.js
import * as m from './module.js';
console.log(m);
// 输出结果
Module {
    b: 2,
    c: 3,
    default: {
        a: 1
    },
    fn1: ƒn1,
    fn2: ƒn2
}

默认导出被隔离在Module对象的default属性里,与其它export待遇不同

聚合导出

相当于import + export,但不会在当前模块作用域引入各个API变量(导入后直接导出,无法引用),仅起API聚合的中转作用,例如:

// lib.js
let util = {name: 'util'};
let dialog = {name: 'core'};
let modal = {name: 'modal'};

export {
    util,
    dialog,
    modal
}

// module.js
console.log(`before export from lib: ${typeof dialog}`);
export * from './lib.js';
console.log(`after export from lib: ${typeof dialog}`);

前后都是undefined,因为仅中转,不在当前模块作用域引入。而import + export会先引入,在当前模块可用

import

// 引入default export内容
import defaultMember from "module-name";
// 引入所有export内容,包括default,并打包到名为mame的对象
import * as name from "module-name";
// 按名引入指定export内容
import { member } from "module-name";
import { member as alias } from "module-name";
import { member1, member2 } from "module-name";
import { member1, member2 as alias2 , [...] } from "module-name";
// 引入default export内容,同时按名引入指定export内容
import defaultMember, { member [ , [...] ] } from "module-name";
import defaultMember, * as name from "module-name";
// 不引入模块里暴露的东西,仅执行该模块代码
import "module-name";

最后一种比较有意思,被称为Import a module for its side effects only,仅执行模块代码,不引入任何新东西(只有影响外部状态的部分会生效,即副作用)

P.S.关于ES Module语法的更多信息,请查看module_ES6笔记13,或者参考资料部分的ES Module Spec

P.S.NodeJS也在考虑支持ES Module,但遇到了怎么区分CommonJS模块和ES Module的问题,还在讨论中,更多信息请查看ES Module Detection in Node

二.加载机制

ES6模块加载机制示意图

也就是说:

  • type="module"的资源相当于自带defer效果(等到HTML文档解析完毕才执行)

  • async依然有效(资源加载完毕后立即执行,执行完继续解析HTML文档)

  • import资源加载是并行的

自带defer效果,与裸script默认行为(加载资源立即执行,并且阻塞HTML文档解析)不同。另外,虽然import加载同级资源是并行的,但寻找下一级依赖的过程不可避免是顺序串行的,这部分性能无法忽略,即便浏览器原生支持了ES Module,也不能肆无忌惮地import

类似于CSS中的@import规则,可能会发展出最佳实践,在模块化与加载性能之间寻求平衡

三.特点

1.静态机制

不能在iftry-catch语句,函数或者eval等地方使用import,只能出现在模块最外层

并且import提升(Hosting)特性,如同变量声明被提升到当前作用域顶部一样,模块里声明的import会被提升到模块顶部

P.S.静态模块机制有利于做解析/执行优化

2.新script类型

需要用新的script类型属性type="module"。因为解析器没有办法推测出内容是不是ES Module(比如没有import, export关键字,也遵循严格模式,那么算不算个模块?)

另外,根据内容猜测存在多次解析的性能损耗

3.模块作用域

每个模块有自己的作用域,模块下的变量声明不会暴露到全局

4.默认开启严格模式

this不指向global,而是undefined

5.支持Data URI和Blob URI

import grape from 'data:text/javascript,export default "grape"';

// create an empty ES module
const scriptAsBlob = new Blob([''], {
    type: 'application/javascript'
});
const srcObjectURL = URL.createObjectURL(scriptAsBlob);
 // insert the ES module and listen events on it
const script = document.createElement('script');
script.type = 'module';
document.head.appendChild(script);
// start loading the script
script.src = srcObjectURL;

6.受CORS限制

跨域的模块资源无法import引入,也无法通过script标签以模块方式加载

7.HTTPS资源无法importHTTP资源

类似于HTTPS页面加载HTTP资源,会被block掉

8.模块是单例

不同于普通script,引入的模块是单例(只执行一次),无论是import还是通过type="module"script标签引入

9.请求模块资源不带身份凭证(credentials)

Fetch API脾气一样,默认不带身份证,需要给script标签添上crossorigin属性

四.问题

1.import报错

必须要给出精确的模块文件路径,否则不会执行模块内容,并且Chrome 60连报错都没有

P.S.import报错目前各浏览器还存在差异

2.模块间错误隔离仍然是个问题

资源加载错误:动态插入script加载模块,onerror监听加载异常

模块初始化错误:window.onerror全局捕获,尝试通过错误信息找出模块名,记下模块初始化失败

3.请求数量爆炸

比如lodash demo,需要加载600多个文件

HTTP2能缓解碎文件的问题,但从根源看,需要一套适用于生产环境的最佳实践,规范模块化的粒度

4.动态import

目前还没有实现,import() API专门解决这个问题,规范还处于草案第3阶段,更多信息请查看Native ECMAScript modules: dynamic import()

5.模块环境检测

检查当前执行环境是不是模块:

const inModule = this === undefined;

看起来不很靠谱,但似乎只能这么干,因为document.currentScript在ES Module是null,没办法做type检查

五.降级方案

1.特性检测

过一遍特性检测,由环境检测util引入模块,比较费劲且亏性能,例如malyw/es-modules-utils

typeof行不通,因为import, export是关键字,可以插入type="module"script标签,加载空模块(可以用Blob URI或者Data URI),触发onload说明支持

另外还有一种取巧的方法

<script type="module">
    window.__browserHasModules = true;
</script>

引入这样的模块做特性检测,但因为ES Module自带defer效果,为了保证执行顺序,后续所有JS资源都要有defer属性(包括用于降级的正常版本)

2.nomodule

nomodule属性,作用类似于noscript标签,<script nomodule>console.log('仅在不支持ES Module的环境执行')</script>

但依赖浏览器支持,在不支持该属性但支持ES Module的环境就有问题了(两个都执行),已经添到了HTML规范,但目前兼容性还比较差

  • Firefox最新版支持

  • Edge不支持

  • Safari 10.1不支持,但有办法解决

  • Chrome 60支持

关于降级方案的更多信息,请查看Native ECMAScript modules: nomodule attribute for the migration

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code