28- ES6 模块化

作者: 夏海峰 | 来源:发表于2018-08-22 16:21 被阅读92次

    1、模块 概述

    在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

    ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。

    CommonJS 模块就是对象,输入时必须查找对象属性。
    CommonJS 模块:

    let { stat, exists, readFile } = require('fs');
    // 等同于
    let _fs = require('fs');
    let stat = _fs.stat;
    let exists = _fs.exists;
    let readFile = _fs.readFile;
    

    上面代码的实质是整体加载fs模块(即加载fs的所有方法),生成一个对象(_fs),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。

    ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,再通过 import 命令输入。

    import { stat, exists, readFile } from 'fs';
    

    上面代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。

    2、ES6 模块的优势

    由于 ES6 模块是编译时加载,使得静态分析成为可能。
    不再需要UMD模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。目前,通过各种工具库,其实已经做到了这一点。
    将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性。
    不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供。

    3、严格模式

    ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict";。

    严格模式主要有以下限制:

    变量必须声明后再使用
    函数的参数不能有同名属性,否则报错
    不能使用with语句
    不能对只读属性赋值,否则报错
    不能使用前缀 0 表示八进制数,否则报错
    不能删除不可删除的属性,否则报错
    不能删除变量delete prop,会报错,只能删除属性delete global[prop]
    eval不会在它的外层作用域引入变量
    eval和arguments不能被重新赋值
    arguments不会自动反映函数参数的变化
    不能使用arguments.callee
    不能使用arguments.caller
    禁止this指向全局对象
    不能使用fn.caller和fn.arguments获取函数调用的堆栈
    增加了保留字(比如protected、static和interface)

    尤其需要注意this的限制。ES6 模块之中,顶层的this指向undefined,即不应该在顶层代码使用this。

    4、export 命令

    ES6 模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。

    一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。下面是一个 JS 文件,里面使用export命令输出变量。

    // 写法一
    export var year = 2018;
    export function add(x, y) { return x + y };
    
    // 写法二
    var year = 2018;
    var add = function(x, y) { return x + y };
    export {
        year,
        add
    }
    
    // 写法三
    // 通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。
    // 重命名后,变量可以用不同的名字输出多次。
    export {
        year as date,
        add as addFun1,
        add as addFun2,
        add as addFun3
    }
    

    export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。

    export var foo = 1;
    setTimeout(() => foo = 2, 1000);
    

    上面代码输出变量foo,值为 1 ,一秒之后变成 2。 这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新。

    最后,export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,下一节的import命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了 ES6 模块的设计初衷。

    5、import 命令

    import命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(export)对外接口的名称相同。

    // 写法一
    import { year } from './profile.js';
    
    // 写法二
    // 如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。
    import { year as date } from './profile.js';
    
    // 写法三
    // import语句会执行所加载的模块,比如执行一个js文件,或者加载一张图片等
    import './createTime.js';
    import './data.json';
    import './image/dog.png';
    import './style.css';
    
    // 写法四
    // 模块的整体加载,除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。
    import * as circle from './circle.js';
    

    import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面改写接口。 但是,如果导入的变量是对象类型,改写它的属性是允许的,一旦改写了,其他模块也可以读以它被改写后的值。因此,这种写法很难查错,建议凡是import导入的变量,都当作完全只读,不要轻易改变它的属性。

    注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。import命令是编译阶段执行的,在代码运行时之前。 由于import是静态执行,所以不能使用表达式和变量。

    // 报错
    import { 'f' + 'oo' } from 'my_module';
    // 报错
    let module = 'my_module';
    import { foo } from module;
    // 报错
    if (x === 1) {
      import { foo } from 'module1';
    } else {
      import { foo } from 'module2';
    }
    

    如果多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。

    import './do.js';
    import './do.js';
    import './do.js';   // 只会执行一次
    
    import { foo } from 'module';
    import { bar } from 'module';
    // 等同于
    import { foo, bar } from 'module';
    

    上面代码中,虽然foo和bar在两个语句中加载,但是它们对应的是同一个module实例。也就是说,import语句是 Singleton 模式。

    目前阶段,通过 Babel 转码,CommonJS 模块的require命令和 ES6 模块的import命令,可以写在同一个模块里面,但是最好不要这样做。因为import在静态解析阶段执行,所以它是一个模块之中最早执行的。
    ES6 模块是静态加载执行的,发生在编译阶段
    CommonJS 模块是动态加载的,发生在运行时阶段

    6、export default 命令

    本质上,export default就是输出一个叫做default的变量或方法,然后系统允许你为它取任意名字。
    一个模块中只能有一个默认输出,因此export default命令只能使用一次。

    export default function() {};
    export default function foo() {};
    export default a;
    export default 45;
    export default class MyClass {};
    // export default命令其实只是输出一个叫做default的变量,所以它后面不能跟变量声明语句。
    export default var a = 1;   // 报错
    // 导入 default 接口
    import xxx from './my_default.js';
    

    注意:一个模块中,使用 export default 导出的接口,import 时无须大括号。直接用 export 导出的接口,import 时,都需要用大括号包裹起来。

    export a;
    export b;
    export default c;
    
    import c, { a, b} from './my_default.js';  // 注意大括号的使用
    

    7、export 与 import 的复合写法

    如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。写成一行以后,被导入再导出的接口 API,实际上并没有被导入当前模块,只是相当于对外转发了这些接口,导致当前模块不能直接使用这些接口。

    export { foo, bar } from 'my_module';
    // 等同于
    import { foo, bar } from 'my_module';
    export { foo, bar };
    

    这种写法,可以用于对模块接口进行改名和整体输出:

    // 接口改名
    export { foo as myFoo } from 'my_module';
    // 整体导出
    export * from 'my_module';
    
    // 导出默认接口
    export { default } from 'my_module';
    export { es6 as default } from 'my_module';
    export { default as es6 } from 'my_module';
    

    8、模块的继承

    模块之间也可以继承。

    export * from 'circle';
    export var e = 2.718;
    export default function(x) {
        return Math.exp(x);
    }
    

    注意,export *命令会忽略circle模块的default方法。然后,上面代码又输出了自定义的e变量和默认方法。

    9、跨模块常量

    const声明的常量只在当前代码块有效。如果想设置跨模块的常量(即跨多个文件),或者说一个值要被多个模块共享,可以采用下面的写法。

    // constants.js模块
    export const A = 1;
    export const B = 2;
    export const C = 2;
    
    // 使用这些跨模块的常量
    import * as constants from './constants.js';
    console.log(constants.A);
    // 另一种使用方式
    import { A, B } from './constants.js';
    console.log(B);
    

    如果要使用的常量非常多,可以建立一个常量目录,把这些常量分模块进行定义,再统一导出。做法如下:

    // constants/db.js
    export const db = {
        url: 'http://my.local:8000',
        admin: 'root',
        pwd: '123456'
    };
    // constants/user.js
    export const users = ['root', 'admin', 'staff', 'ceo'];
    
    // 合并常量,统一导出
    // constants/index.js
    export { db } from './db.js';
    export { users } from './users.js';
    
    // 使用这些常量
    import { db, users } from './index.js';
    

    10、import ()

    import命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行。也就是说,import和export命令只能在模块的顶层,不能在代码块之中。

    这样的设计,固然有利于编译器提高效率,但也导致无法在运行时加载模块。在语法上,条件加载就不可能实现。如果import命令要取代 Node 的require方法,这就形成了一个障碍。因为require是运行时加载模块,import命令无法取代require的动态加载功能。

    提案:建议引入 import(),实现动态加载功能。像node.js的require()一样:

    const path = './' + fileName;
    let myModule = null;
    if (bol) {
        myModule = require(path);
    }
    

    上面介绍了模块的语法,下面介绍如何在浏览器和 Node 之中加载 ES6 模块,以及实际开发中经常遇到的一些问题(比如循环加载)。

    11、浏览器中的模块加载(传统做法)

    // 内嵌脚本
    <script type="application/javascript">
        // some js code
    </script>
    
    // 加载外部脚本
    <script type="application/javascript" src='./some.js'></script>
    

    由于浏览器脚本的默认语言是 JavaScript,因此type="application/javascript"可以省略。
    默认情况下,浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到<script>标签就会停下来,等到执行完脚本,再继续向下渲染。

    // 浏览器端也可以异步加载js脚本
    <script src="path/to/myModule.js" defer></script>
    <script src="path/to/myModule.js" async></script>
    

    defer与async的区别是:defer要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;async一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。一句话,defer是“渲染完再执行”,async是“下载完就执行”。另外,如果有多个defer脚本,会按照它们在页面出现的顺序加载,而多个async脚本是不能保证加载顺序的。

    12、在浏览器中加载 ES6 模块
    浏览器加载 ES6 模块,也使用<script>标签,但是要加入type="module"属性。

    浏览器对于带有type="module"的<script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>标签的defer属性。

    <script type="module" src="./foo.js"></script>
    <!-- 等同于 -->
    <script type="module" src="./foo.js" defer></script>
    
    <script type="module">
      import utils from "./utils.js";
      // other code
    </script>
    

    13、浏览器下加载 ES6模块,以下点必须注意:

    代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
    模块脚本自动采用严格模式,不管有没有声明use strict。
    模块之中,可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口。
    模块之中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的。
    同一个模块如果加载多次,将只执行一次。

    14、ES6 模块与 CommonJS 模块的差异

    差异一:CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

    差异二:CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

    15、在 Node.js 环境中加载 ES6 模块

    Node 对 ES6 模块的处理比较麻烦,因为它有自己的 CommonJS 模块格式,与 ES6 模块格式是不兼容的。目前的解决方案是,将两者分开,ES6 模块和 CommonJS 采用各自的加载方案。

    Node 要求 ES6 模块采用.mjs后缀文件名。也就是说,只要脚本文件里面使用import或者export命令,那么就必须采用.mjs后缀名。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import。
    Node 的import命令是异步加载,这一点与浏览器中的ES6模块加载处理方法相同。

    ES6 模块之中,顶层的this指向undefined;CommonJS 模块的顶层this指向当前模块,这是两者的一个重大差异。

    其次,以下这些顶层变量在 ES6 模块之中都是不存在的。

        arguments
        require
        module
        exports
        __filename
        __dirname
    

    16、ES6 模块中 加载 CommonJS 模块

    import * as express from 'express';
    // 或者
    import expres from 'express';
    

    17、CommonJS 模块中 加载 ES6 模块

    CommonJS 模块加载 ES6 模块,不能使用require命令,而要使用import()函数。

    const es = await import('./es.js');
    

    18、模块的循环加载

    “循环加载”(circular dependency)指的是,a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本。

    通常,“循环加载”表示存在强耦合,如果处理不好,还可能导致递归加载,使得程序无法执行,因此应该避免出现。

    19、ES6 模块的转码

    浏览器目前还不支持 ES6 模块,为了现在就能使用,可以将转为 ES5 的写法。除了 Babel 可以用来转码之外,还有以下两个方法,也可以用来转码。

    ES6 module transpiler是 square 公司开源的一个转码器,可以将 ES6 模块转为 CommonJS 模块或 AMD 模块的写法,从而在浏览器中使用。

    另一种解决方法是使用 SystemJS。它是一个垫片库(polyfill),可以在浏览器内加载 ES6 模块、AMD 模块和 CommonJS 模块,将其转为 ES5 格式。它在后台调用的是 Google 的 Traceur 转码器。


    完!!!

    相关文章

      网友评论

        本文标题:28- ES6 模块化

        本文链接:https://www.haomeiwen.com/subject/dhsyiftx.html