ES6常用但被忽略的方法(第九弹Module)

2,654 阅读9分钟

写在开头

  • ES6常用但被忽略的方法 系列文章,整理作者认为一些日常开发可能会用到的一些方法、使用技巧和一些应用场景,细节深入请查看相关内容连接,欢迎补充交流。

相关文章

Module

  • ES6-Module
  • CommonJSAMD 模块,都只能在运行时确定这些东西。 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高,这种加载称为“编译时加载”或者静态加载。
  • 优势
    1. 能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。
    2. 不再需要UMD模块格式。
    3. 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性。
    4. 不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供。

严格模式

  • ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict"
  • 限制:
    1. 变量必须声明后再使用
    2. 函数的参数不能有同名属性,否则报错
    3. 不能使用with语句
    4. 不能对只读属性赋值,否则报错
    5. 不能使用前缀 0 表示八进制数,否则报错
    6. 不能删除不可删除的属性,否则报错
    7. 不能删除变量delete prop,会报错,只能删除属性delete global[prop]
    8. eval不会在它的外层作用域引入变量
    9. evalarguments不能被重新赋值
    10. arguments不会自动反映函数参数的变化
    11. 不能使用arguments.calleearguments.caller
    12. 禁止this指向全局对象
    13. 不能使用fn.callerfn.arguments获取函数调用的堆栈
    14. 增加了保留字(比如protectedstaticinterface
  • 尤其需要注意this的限制。ES6 模块之中,顶层的this指向undefined,即不应该在顶层代码使用this

export 命令

  • export命令用于规定模块的对外接口。
  • 一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。除了输出变量,还可以输出函数或类(class)。
// index.js
export const name = 'detanx';
export const year = 1995;
export function multiply(x, y) {
  return x * y;
};

// 写法二
const name = 'detanx';
const year = 1995;
function multiply(x, y) {
  return x * y;
};
export { name, year, multiply }
  • export输出的变量就是本来的名字,但是可以使用as关键字重命名。重命名后,可以用不同的名字输出多次。
function v1() { ... }
function v2() { ... }

export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};
  • export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
// 报错
export 1;

var m = 1;
export m;

// 正确
export var m = 1;

var m = 1;
export {m};

var n = 1;
export {n as m};
  • export命令可以出现在模块的任何位置,只要处于模块顶层就可以。
  • export *命令会忽略模块的default方法。
// 整体输出
export * from 'my_module';

import 命令

  • 使用export命令定义了模块的对外接口以后,其他 JS 文件就可以通过import命令加载这个模块。想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。
import { name, year } from './index.js';
import { name as username } from './profile.js';
  • import命令输入的变量都是只读的,因为它的本质是输入接口。 也就是说,不允许在加载模块的脚本里面,改写接口。如果a是一个对象,改写a的属性是允许的。
import {a} from './xxx.js'
a = {}; // Syntax Error : 'a' is read-only;
a.foo = 'hello'; // 合法操作
  • import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径,.js后缀可以省略。如果只是模块名,不带有路径,那么必须有配置文件(例如使用webpack配置路径),告诉 JavaScript 引擎该模块的位置。
import {myMethod} from 'util';
  • import命令具有提升效果,会提升到整个模块的头部,首先执行。
foo(); // 不会报错
import { foo } from 'my_module';
  • 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 'lodash';
import 'lodash'; // 只会执行一次

import { foo } from 'my_module';
import { bar } from 'my_module';

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

模块的整体加载

  • 除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。
import * as user from './index.js';
user.name; // 'detanx'
user.year; // 1995

export default 命令

  • export default命令,为模块指定默认输出。其他模块加载该模块时,import命令(import命令后面,不使用大括号)可以为该匿名函数指定任意名字。
// export-default.js
export default function () {
  console.log('detanx');
}

// import-default.js
import customName from './export-default';
customName(); // 'detanx'
  • 使用export default时,对应的import语句不需要使用大括号;使用export,对应的import语句需要使用大括号。 一个模块只能有一个默认输出,因此export default命令只能使用一次。
export default function crc32() {  ...}
import crc32 from 'crc32'; 

export function crc32() { ... };
import { crc32 } from 'crc32';

export 与 import 的复合写法

  • 如果在一个模块之中,先输入后输出同一个模块, import语句可以与export语句写在一起。写成一行以后,foobar实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用foobar
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 'foo';
  • 具名接口改为默认接口的写法如下。
export { es6 as default } from './someModule';

// 等同于
import { es6 } from './someModule';
export default es6;
  • 同样地,默认接口也可以改名为具名接口。
export { default as es6 } from './someModule';
ES2020 之前,有一种import语句,没有对应的复合写法。

import * as someIdentifier from "someModule";
  • ES2020补上了这个写法。
export * as ns from "mod";

// 等同于
import * as ns from "mod";
export {ns};

应用

  1. 公共模块
    • 例如项目有很多的公共方法放到一个constant的文件,我们需要什么就加载什么。
    // constants.js 模块
    export const A = 1;
    export const B = 3;
    export const C = 4;
    
    // use.js
    import {A, B} from './constants';
    
  2. import()
    • import命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行(import命令叫做“连接” binding 其实更合适)。所以我们只能在最顶层去使用。ES2020引入import()函数,支持动态加载模块。
    • import()返回一个 Promise 对象。
    const main = document.querySelector('main');
    
    import(`./section-modules/${someVariable}.js`)
      .then(module => {
        module.loadPageInto(main);
      })
      .catch(err => {
        main.textContent = err.message;
      });
    
    • import()函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()函数与所加载的模块没有静态连接关系,这点也是与import语句不相同。import()类似于 Noderequire方法,区别主要是前者是异步加载,后者是同步加载。
    • 适用场景按需加载、条件加载、动态的模块路径。
  3. 注意点
    • import()加载模块成功以后,这个模块会作为一个对象,当作then方法的参数。因此,可以使用对象解构赋值的语法,获取输出接口。
    import('./myModule.js')
    .then(({export1, export2}) => {
      // ...·
    });
    
    • 上面代码中,export1export2都是myModule.js的输出接口,可以解构获得。
    • 如果模块有default输出接口,可以用参数直接获得。
    import('./myModule.js')
    .then(myModule => {
      console.log(myModule.default);
    });
    
    • 上面的代码也可以使用具名输入的形式。
    import('./myModule.js')
    .then(({default: theDefault}) => {
      console.log(theDefault);
    });
    
    • 如果想同时加载多个模块,可以采用下面的写法。
    Promise.all([
      import('./module1.js'),
      import('./module2.js'),
      import('./module3.js'),
    ])
    .then(([module1, module2, module3]) => {
       ···
    });
    
    • import()也可以用在 async 函数之中。
    async function main() {
      const myModule = await import('./myModule.js');
      const {export1, export2} = await import('./myModule.js');
      const [module1, module2, module3] =
        await Promise.all([
          import('./module1.js'),
          import('./module2.js'),
          import('./module3.js'),
        ]);
    }
    main();
    

Module 加载实现

简介

  1. 传统加载
    • 默认情况下,浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到<script>标签就会停下来,等到执行完脚本,再继续向下渲染。为了解决<script>标签打开deferasync属性,脚本就会异步加载。
    • deferasync的区别是:defer要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;async一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。一句话,defer是“渲染完再执行”,async是“下载完就执行”。另外,如果有多个defer脚本,会按照它们在页面出现的顺序加载,而多个async脚本是不能保证加载顺序的。
  2. 加载规则
    • 浏览器加载 ES6 模块,也使用<script>标签,但是要加入type="module"属性。等同于打开了<script>标签的defer属性。
    <script type="module" src="./foo.js"></script>
    
    <!-- 等同于 -->
    <script type="module" src="./foo.js" defer></script>
    
    • 对于外部的模块脚本,有几点需要注意。
      1. 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
      2. 模块脚本自动采用严格模式,不管有没有声明"use strict"
      3. 模块之中,可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口。
      4. 模块之中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的。
      5. 同一个模块如果加载多次,将只执行一次。
      import utils from 'https://example.com/js/utils.js';
      const x = 1;
      
      console.log(x === window.x); //false
      console.log(this === undefined); // true
      
    • 利用顶层的this等于undefined这个语法点,可以侦测当前代码是否在 ES6 模块之中。
    const isNotModuleScript = this !== undefined;
    

ES6 模块与 CommonJS 模块的差异

  • 讨论 Node.js 加载 ES6 模块之前,必须了解 ES6 模块与 CommonJS 模块完全不同。
  • 它们有两个重大差异。
    1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
    2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。(因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。)
  • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。除非写成一个函数,才能得到内部变动后的值。
// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

// main.js
var mod = require('./lib');

console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 3

// 写成函数
// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  get counter() {
    return counter
  },
  incCounter: incCounter,
};

$ node main.js
3
4
  • ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}

// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4
  • ES6 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错。
// lib.js
export let obj = {};

// main.js
import { obj } from './lib';

obj.prop = 123; // OK
obj = {}; // TypeError
  • export通过接口,输出的是同一个值。不同的脚本加载这个接口,得到的都是同样的实例。
// mod.js
function C() {
  this.sum = 0;
  this.add = function () {
    this.sum += 1;
  };
  this.show = function () {
    console.log(this.sum);
  };
}

export let c = new C();
  • 上面的脚本mod.js,输出的是一个C的实例。不同的脚本加载这个模块,得到的都是同一个实例。
// x.js
import {c} from './mod';
c.add();

// y.js
import {c} from './mod';
c.show();

// main.js
import './x';
import './y';
  • 现在执行main.js,输出的是 1
$ babel-node main.js
1
  • 证明了x.jsy.js加载的都是C的同一个实例。

Node.js 加载

  • Node.js 要求 ES6 模块采用.mjs后缀文件名。Node.js 遇到.mjs文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定"use strict"。 如果不希望将后缀名改成.mjs,可以在项目的package.json文件中,指定type字段为module
{
   "type": "module"
}
  • 这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成.cjs。如果没有type字段,或者type字段为commonjs,则.js脚本会被解释成 CommonJS 模块。

  • 总结:.mjs文件总是以 ES6 模块加载,.cjs文件总是以 CommonJS 模块加载,.js文件的加载取决于package.json里面type字段的设置。

  • 注意,ES6 模块与 CommonJS 模块尽量不要混用。require命令不能加载.mjs文件,会报错,只有import命令才可以加载.mjs文件。反过来,.mjs文件里面也不能使用require命令,必须使用import。

  • Node.js 加载 主要是介绍ES6 模块和 CommonJS 相互之间的支持,有兴趣的可以自己去看看。

循环加载

  • “循环加载”(circular dependency指的是,a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本。“循环加载”表示存在强耦合,如果处理不好,还可能导致递归加载,使得程序无法执行,因此应该避免出现,但很难避免尤其是特别复杂的项目。