前端模块化详解(ESM & CommonJs)

首发地址:https://mp.weixin.qq.com/s/Be...

  “ 用最精简的方式,抓住最核心的知识点,帮助你快读ES6。

关键词:ESModule、CommonJs

系列文章:ES6精读【划重点系列】(二)

前文涉及:class实例化和继承

正文从此开始~

前言

开始正文前,先补充几个基本知识:

Q1:js是解释执行,每一个代码块的执行过程:语法分析,预编译阶段,解释执行,其中预编译阶段会对变量和函数做一些处理:

    - 函数及变量的声明都将被提升到函数的最顶部;

    - 对于变量赋值会先开辟一块内存空间并指向变量名,且赋值为undefined;

    - 对于函数声明,则会直接将函数体处理后放入开辟的内存空间,即用函数声明方式,在预编译阶段便已完成了函数的创建工作。

Q2:为什么export和import只能在模块最外层作用域,为什么不能在条件语句内?

    因为对import和export是在静态分析阶段做的分析处理,而条件语句要等到执行时才会解析到。

Q3:前端模块化的探索历程:

    - 函数:污染全局变量,命名冲突;

    - 命名空间(简单对象封装):命名空间过长;可以直接修改内部数据,不安全;

    - 立即执行函数(闭包):私有数据,提供暴露接口;支持传入依赖;(模块化雏形)

    - 需要解决的问题:文件依赖管理,脚本引入顺序,按需加载等问题;

    - 服务端:commonJs ;浏览器端: AMD(依赖前置) | CMD(依赖后置)

    - ESModule

ES Module

ES6设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

export 

模块的对外接口,在接口名与模块内部变量之间,建立了一一对应的动态绑定关系。

1. 命名导出

  - 导出接口数量不限,导出形式export const e1 = 18;使用命名导出的接口变量:import {e1, e2} from './my-module';

  - 支持导出的值类型:函数,对象或原始值

  - 允许导出一个列表的形式:export {e1, e2};其中{}中只能放已经定义好的接口,而不是一个对象字面量:export {name: 123}

2. 默认导出

  - 只能有一个default接口;

  - 导出形式:export default [default的值];注意default后不能是变量声明语句,因为相当于导出的接口名为default,其后的值是对其的赋值;

  - 使用命名导出的接口变量:import _ from './my-module';可以任意命名获取。好处是用户无需查API,可以自定义变量使用。

3. 混用

模块同样支持导出别的模块的接口:

//会忽略a模块的default,需要加上下面一行
export * from  './a';   
//注意不能写成 export default from './a',会出现解析错误,[from './a']理应是能赋值给default的值。
等价于
export { default } from './a'; 

//如果想要导出a的default,加上如下代码
import default from './a';
export { default };

import

编译阶段(静态分析),会被提升到模块首部,生成只读引用,链接模块中对应的export接口。

1. 由于是静态执行,所以导入的变量名不能使用表达式和变量;

2. 导入的模块是单例模式,因此同一模块多次导入,只执行一次;

3. 整体加载,用星号(*)指定一个对象,命名和默认导出的接口都会挂载在上面;

4. default必须首先声明

import _ , { fun } from './my-module.js';

5. 副作用方式导入

import './my-module.js'; 
//整个模块为副作用,仅运行模块中的全局代码,不导入模块内任何接口
//使用webpack做treeSharking时可以使用sideEffects声明是否有副作用

6. 导入的引用是只读的:

不可重新赋值,但如果导入的是对象,可以修改其属性,不建议;

import { var1, obj } from './module';
//情景1:
let var1 = 123; //Identifier 'var1' has already been declared
//情景2:
var1 = 123; //Uncaught ReferenceError: var1 is not defined
//情景3:
obj.a = 123; //正确;可以对导出的对象接口进行修改、添加、删除

需要注意如下写法时:

import _, * as M from 'moudle';
//情景1
不允许直接修改_,报错同上
//情景2
M.default = 123;//正确;同时_也变成123
M.var1 = 123;
//Uncaught TypeError: Cannot set property var1 of # which has only a getter

va1的属性描述器如下:

一般对导出模块中的变量使用constObject.freeze()或者immutable处理,避免修改。三者做比较:

const:
块级作用域;变量不可重新赋值,但是不能阻止对象属性的变更操作;
【即使用let声明,导出的变量同样不允许重新赋值,不过最好显示约束下】

Object.freeze:
冻结对象,不能被修改等;浅层限制,如果对象是多层,只能递归冻结;

Immutable:
每次变更都会返回一个新的对象,结构共享。

import()

动态加载模块,返回Promise对象。

总结

1. 为解决命名冲突,支持用as关键字重命名导出 | 导入

2. 模块中的代码是在模块作用域中执行,绑定其所在模块,因此无法使用外部环境中的变量;

3. 模块内默认使用严格模式;

4. 模块顶层,this关键字返回undefined。可用于判断当前是否在ES6模块中。

下面是大致的原理图,有助于理解(来源网络,侵删)

关键是在解析阶段,将导出变量和对应内存地址关联起来;赋值是在运行阶段(声明式函数特殊理解)。

循环依赖

//a.js
import { age } from './b.js'
console.log(age);  //18
export const name= 'M2';
export function getName() {
  // ...
}
//b.js
import { name, getName } from './a';
console.log(name);  //undefined
console.log(getName);  //f getName() {}
export const age = 18;

//main.js
import 'a.js'

执行时,b.js中正常获取,而变量不行。原因:静态分析阶段对getName和name两个接口做了内存的链接,由于getName函数具有提升作用,在a.jsimport执行前,便已经定义了,因此b.js中使用getName接口时,就会链接到对应的内存,因此可以获取使用。

commonJs

1. 运行时加载,导出模块对象(module.exports);

2. 值拷贝;

附上node模块加载的简单实现:

let path = require('path');
let fs = require('fs');
let vm = require('vm');

function Module(filename){
    // ...
    this.filename = filename;
    this.exports = {};   //模块导出对象
    this.loaded = false;  //是否已加载完成
}

Module._cache = {};  //缓存
Module._extentions = ['.js', '.json', 'node'];  //不同的文件后缀

//找到模块的完整文件路径
Module.findAbsPath= function(filename) {
    const p = path.resolve(_dirname, filename);
    if(!path.extname(p)) {  //是否包含后缀
        const extentions = Module._extentions;
      /*
      * 无文件后缀时的处理(这里简单处理)
      * step1:
      * 将x当成文件,依次x、x.js、x.json、x.node顺序查找,找到返回
      * step2:
      * 将x当成目录,依次x/package.json、x/index.js、x/index.json、x/index.node顺序查找,找到返回
      */
     for(let i = 0; i < extentions.length; i++) {
            let newPath = p + extentions[i];
            try {
                fs.accessSync(newPath); //文件路径是否有效
                return newPath;
            }catch(e) {
             //异常捕获
            }
        }
    }
    return p;
}

Module.wrap = function(script) {
    //将模块包装成函数,exports等作为参数传入
    return `(function(exports,require,module,_dirname,_filename){
        ${script}
      }`;
}

// 不同类型的文件对应不同的处理逻辑
Module._extentions['js'] = function(module) {
    let scriptStr = fs.readFileSync(module.filename);
    ler fnStr = Module.wrap(scriptStr);
    // vm.runInThisContext(fnStr)类似于eval,不过是沙箱模式
    // 执行模块,传入已创建的module对象,执行过程中对exports添加值
    vm.runInThisContext(fnStr)
    .call(module.exports, req, module);
}

//加载模块
Module.prototype.load = function(filename) {
    let ext = path.extname(filename).slice(1);
    Module._extentions[ext](this);
}

function _require(filename) {
    //获取文件的绝对路径
    let absName = Module.findAbsPath(filename);
    //判断是否已加载 | 单例模式
    let cacheModule = Module._cache[absName];
    if(cacheModule) {
        return cacheModule.exports;
    }
    //实例化模块对象
    let module = new Module(absName);
    //先加入缓存
    // 核心模块存储在NativeModule.cache
    // 文件模块存储在Module.cache
    // 【循环依赖时,exports挂载了什么就有什么,否则为初始值{}】
    Module._cache[filename] = module;
    //模块同步加载
    module.load(filename);
    //模块标记已加载完成
    module.loaded = true;
    //返回模块
    return module.exports;
}

热更新 && 内存泄漏

//文件模块会被缓存在require这个函数的cache属性上:
require.cache = Module.cache
//删除缓存,每次读取时就会重新加载模块,实现热更新加载配置。
delete require.cache[require.resolve('./xx')]

但是由于parent.children.push(this);会保存module对象的引用,每次重新加载模块就会重新push一次新加载模块的引用,导致内存泄漏。

循环依赖

    加载时执行,一旦出现某个模块被"循环加载",就只输出已经执行的部分(当前module对象上exports被挂载的值),还未执行的部分不会输出。

node支持es6模块

[方案1]:node12.10支持ESModule,后面的版本支持有问题,不过最新版本将重新支持(https://github.com/CesiumGS/c...)。使用规则如下:

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

[方案2]:三方支持

1) 转码es6模块为commonjs模块:rollup、gulp + babel

2) hook node的require机制,内部改写Module._extentions[ext],核心是劫持获取的源文件后使用特定逻辑处理成CommonJs,最后返回改写后的文件:

const newCode = hook(code, filename);

1. pirates提供了添加hook的能力
  addHook(
    (code, filename) => 
        code.replace('@@foo', 'console.log(\'foo\');'), 
    { exts: ['.js'], matcher }
  );
2. babel-register:【由于它是实时转码,所以只适合在开发环境使用】
改写 require 命令,为它加上一个钩子。
每当使用 require 加载 .js、.jsx、.es 和 .es6 后缀名的文件,就会先用 babel 进行转码。
require('@babel/register')({
  plugins: ['@babel/plugin-transform-modules-commonjs']
});
require('./index');

3. babel-node:提供了在命令行直接运行 es6 模块文件的便捷方式
  babel-node = babel-polyfill + babel-register
  {
    "scripts": {
      "run": "babel-node src/index.js --plugins @babel/plugin-transform-modules-commonjs"
    }
  }

回顾

1. 介绍了ESModule的原理以及使用方法和注意事项;

2. 并对CommonJs的模块加载以及node支持ESModule的方案做了总结。

3. 另外,AMD是前端异步获取模块,执行回调函数的解决方案:通过动态创建script标签的方式获取模块,加载完成后立即执行(CMD则是先缓存下载的文件,等真正使用时才执行)缓存模块对象,只有当前模块的所有依赖执行完才执行当前模块。

近期会写一篇关于【webpack打包模块运行时链接加载(runtime)原理】的文章,敬请期待~

你可能感兴趣的:(javascript,前端,模块化,es6,commonjs)