老席老席杂货铺
Picture of github

ES Module 概述

  • ES module 为异步加载,与defer 行为一致,CommonJS 是同步加载

  • ES6 module 的引入和导出是静态的,import 会自动提升到代码的顶层 ,import , export 不能放在块级作用域或条件语句中。

  • ES6 module import 的导入名不能为字符串或在判断语句。

  • CommonJS 是运行时加载,ES Module 是编译时输出接口

  • CommonJS 模块输出是一个值的拷贝,ES6 模块输出是值的引用

    • 值的拷贝的意思是,一旦一个值被输出后,模块内部的变化就影响不到这个值了

      // 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,
      };
      
    • ES module 值的引用:JS 引擎在对脚本静态分析的时候,碰到模块加载命令 import ,会生成一个只读引用,等脚本真正执行的时候,再根据这个只读引用,到被加载的模块中去取值。

      // 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
      
  • Node.js 加载

    • Node.js 中最好不要混用 CommonJS 和 ES module

    • .mjs 后缀的文件在 Node 中指 ES module 文件,.cjs 后缀的文件指 CommonJS 模块文件

    • 可以通过 package.json 文件中的 type 字段指定 .js 文件默认用哪个模块规范处理

      type: 'module' // 那么就不用设置 ES module 文件的后缀为 .mjs 了,
                                   // 但是需要将 CommonJS 文件的后缀改为 .cjs
      // 总结一句话,.mjs 文件总是以ES module加载,.cjs 总是以 CommonJS 模块加载,
      // .js 的加载取决于 package.json 的 type 字段设置
      
  • package.json 中有两个字段可以指定模块的入口文件,main 和 exports。

    • main 字段

      // ./node_modules/es-module-package/package.json
      {
        "type": "module",
        "main": "./src/index.js"
      }
      
      // use.js
      import { something } from 'es-module-package';
      // 实际加载的是 ./node_modules/es-module-package/src/index.js
      // 如果通过 require 加载就会报错,因为这个模块是 ES module
      
    • exports 字段的优先级高于 main 字段,它有多种用法

      • 子目录别名

        // ./node_modules/es-module-package/package.json
        {
          "exports": {
            "./submodule": "./src/submodule.js"
          }
        }
        
        import submodule from 'es-module-package/submodule';
        // 加载 ./node_modules/es-module-package/src/submodule.js
        
        // 如果没有指定子目录别名,就不能用 “模块+脚本名”这种方式加载模块
        // 报错
        import submodule from 'es-module-package/private-module.js';
        // 不报错
        import submodule from './node_modules/es-module-package/private-module.js';
        
      • main的别名,由于 exports 字段只有支持 ES6 的 Node 才认识,所以别名可以兼容旧版本的 Node

        // 如果export 的目录是 .,就代表模块的入口,优先级高于 main 字段,可以简写成字符串
        {
          "exports": {
            ".": "./main.js"
          }
        }
        // 等同于
        {
          "exports": "./main.js"
        }
        
        // 兼容旧版本的 Node,注意后缀,CommonJS 是认识的
        {
          "main": "./main-legacy.cjs",
          "exports": {
            ".": "./main-modern.cjs"
          }
        }
        
      • 条件加载,这个功能需要在 Node.js 运行的时候,打开 --experimental-conditional-exports 标志,最新版本不知道有没有变化

        // 利用 . 这个别名,可以为 ES6 和 CommonJS 指定不同的入口文件
        {
          "type": "module",
          "exports": {
            ".": {
              "require": "./main.cjs", // CommonJS 入口文件
              "default": "./main.js"   // 其他情况的入口 (ES6 入口文件)
            }
          }
        }
        
        // 在没有其他目录别名的情况下,可以简写为下面,如果有其他目录别名,则不能简写
        {
          "exports": {
            "require": "./main.cjs",
            "default": "./main.js"
          }
        }
        
  • ES6 加载 CommomJS 模块

    // ./node_modules/pkg/package.json
    {
      "type": "module",
      "main": "./index.cjs",
      "exports": {
        "require": "./index.cjs",
        "default": "./wrapper.mjs"
      }
    }
    
    // ./node_modules/pkg/index.cjs
    exports.name = 'value';
    
    // ./node_modules/pkg/wrapper.mjs
    import cjsModule from './index.cjs';
    export const name = cjsModule.name;
    
    // use.js
    // import 命令加载 CommonJS 模块只能整体加载,不能加载单一的输出项
    // 正确
    import packageMain from 'commonjs-package';
    // 报错
    import { method } from 'commonjs-package';
    
    // 有一种变通的方法,就是使用 Node.js 内置的module.createRequire()方法。
    // 下面代码中,ES6 模块通过module.createRequire()方法可以加载 CommonJS 模块
    // cjs.cjs
    module.exports = 'cjs';
    // esm.mjs
    import { createRequire } from 'module';
    const require = createRequire(import.meta.url);
    const cjs = require('./cjs.cjs');
    cjs === 'cjs'; // true
    
  • CommonJS 加载 ES6 模块

    • CommonJS的require 命令不能直接加载ES6 的模块文件,只能通过import() 方法加载

      (async () => {
        await import('./my-app.mjs');
      })();
      
  • 加载路径。

    • ES6 模块的加载路径必须给出脚本的完整路径,不能省略脚本的后缀名。

    • 为了与浏览器的 import 加载规则相同,Node.js 的 .mjs 文件支持 URL 路径。

      import './foo.mjs?query=1'; // 加载 ./foo 传入参数 ?query=1
      
    • Node 会按 URL 规则解读。同一个脚本只要参数不同,就会被加载多次,并且保存成不同的缓存。

  • 内部变量

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

    • CommonJS 模块的重要特性是加载时执行,即脚本代码在 require 的时候,就会全部执行。一旦出现某个模块被”循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。

    • 由于 CommonJS 模块遇到循环加载时,返回的是当前已经执行的部分的值,而不是代码全部执行后的值,所以不同的代码阶段同一个属性的值可能会有差异。

      var a = require('a'); // 安全的写法
      var foo = require('a').foo; // 危险的写法
      exports.good = function (arg) {
        return a.foo('good', arg); // 使用的是 a.foo 的最新值
      };
      exports.bad = function (arg) {
        return foo('bad', arg); // 使用的是一个部分加载时的值
      };
      
    • ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用 import 从一个模块加载变量(即 import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

Commonjs 总结

  • CommonJS 模块由 JS 运行时实现。
  • CommonJs 是单个值导出,本质上导出的就是 exports 属性。
  • CommonJS 是可以动态加载的,对每一个加载都存在缓存,可以有效的解决循环引用问题。
  • CommonJS 模块同步加载并执行模块文件。

ES Module 总结

  • ES6 Module 是静态的,在编译时加载,所以import 命令导入导出操作不能放在块级作用域内。同时,静态分析也成为可能。
  • ES6 Module 导出的是引用,值是动态绑定的,可以通过导出后的修改,可以直接访问修改结果。
  • ES6 Module 导入是需要提供路径及后缀名,在开发时,目前的大包工具会帮我们补全。
  • ES6 Module 可以导出多个属性和方法,可以单个导入导出,混合导入导出。
  • ES6 模块提前加载并执行模块文件,
  • ES6 Module 导入模块在严格模式下。严格模式主要有以下限制。
    • 变量必须声明后再使用
    • 函数的参数不能有同名属性,否则报错
    • 不能使用 with 语句
    • 不能对只读属性赋值,否则报错
    • 不能使用前缀0表示八进制数,否则报错
    • 不能删除不可删除的属性,否则报错
    • 不能删除变量delete prop,会报错,只能删除属性delete global[prop]
    • eval不会在它的外层作用域引入变量
    • evalarguments不能被重新赋值
    • arguments不会自动反映函数参数的变化
    • 不能使用arguments.callee
    • 不能使用arguments.caller
    • 禁止this指向全局对象
    • 不能使用fn.callerfn.arguments获取函数调用的堆栈
    • 增加了保留字(比如protectedstaticinterface
  • ES Module 是通过cors 的方式请求外部js 模块的,需要外部资源服务器支持cors,否则会加载失败。
  • ES6 Module 的特性可以很容易实现 Tree Shaking 和 Code Splitting。
  • Polyfill (下面的方式只适合在开发时测试,生产环境下,需要提前将所有代码编译为浏览器可以支持的方式,因为polyfill 是在浏览器运行时动态转换代码,有性能损耗)
    • 在不支持ES module的浏览器中需要加入polyfill来保证ES module 代码可以正常执行
    • polyfill的原理就是将浏览器中不识别的ES module 文件交给babel转换后使用,import的文件通过ajax请求回来后,通过babel转换,然后再使用。
      • polyfills: browser-es-module-loader, babel-browser-build, promise-polyfill等
    • 对于本来就支持module的浏览器,添加polyfill 会导致代码执行两边,通过在script 标签上田间 nomodule 来实现,nomodule 下的文件只会在不支持 module 的浏览器中执行
  • ES module 在node 端执行时,需要

Babel

balel 是一个主流的javascript 编译器,它的作用是帮我门把使用了新特性的javascript 代码编译成当前环境支持的代码

  • babel 基于插件形式来编译代码,不同插件转换不同的特性

    yarn babel-node index.js --presets=@babel/preset-env
    
  • presets 代表babel 编译时使用的一组插件,在使用babel时可以直接使用插件,而不用presets,可以通过在 .babelrc 中配置presets 或者 plugins,这样在运行 babel-node 时,就可以省略 —presets 参数

    // .babelrc, 它是json 格式
    {
      "presets": [ '@babel/preset-env' ], // presets 是一组插件,如果使用单独插件,可以用plugins 替换
    
      "plugins": [
            "@babel/plugin-transform-modules-commonjs", // 转换es module 到 commonjs
        ],
    }